diff --git a/.ai/CONVENTIONS.md b/.ai/CONVENTIONS.md index 72e31c6..9a3f4dd 100644 --- a/.ai/CONVENTIONS.md +++ b/.ai/CONVENTIONS.md @@ -6,12 +6,12 @@ | Решение / сигнал | Читать | | --- | --- | -| Новый module/provider/service; бизнес-логика; перенос responsibility | [Architecture](conventions/backend-architecture.md) | +| Новый файл/suffix/layer; поиск feature owner; progressive structure; перенос responsibility | [Architecture](conventions/backend-architecture.md) | | Controller, DTO, route/status, validation/exception, публичная HTTP-семантика | [HTTP API](conventions/backend-http-api.md) | -| Prisma/schema/query/persistence, transaction, migration, atomic write | [Data](conventions/backend-data.md) | +| Repository/data access; Prisma/schema/query, transaction, migration, atomic write | [Data](conventions/backend-data.md) | | Тесты или behavior с testing risk | [Testing](conventions/backend-testing.md) | | JWT, cookie, login/refresh/logout, guard/access, token encryption, sensitive logs | [Security](conventions/security-auth.md) | -| T-Bank/gRPC/proto, upstream retry/rate limit/cache, MoexClient | [Integrations](conventions/backend-integrations.md) | +| External client/integration boundary; T-Bank/gRPC/proto, retry/rate limit/cache | [Integrations](conventions/backend-integrations.md) | | Logging/request context без изменения secrets/auth | [Logging](conventions/backend-architecture.md#logging) | | Новый abstraction/dependency, refactor, неясный scope | [Общий выбор реализации](../../docs/development/engineering-governance.md#выбор-реализации) | | Изменение request/response/route/status/error shape или Swagger metadata | HTTP API + [общий contract workflow](../../docs/development/runtime-openapi-verification.md) | diff --git a/.ai/conventions/backend-architecture.md b/.ai/conventions/backend-architecture.md index 131a27d..9614aea 100644 --- a/.ai/conventions/backend-architecture.md +++ b/.ai/conventions/backend-architecture.md @@ -1,39 +1,207 @@ # Backend architecture -## Выбор владельца +## Module-first organization -Найди существующий use case, его collaborators и tests до создания module/service. -Сохрани ответственность в текущем module, если новая возможность относится к нему. -Module — граница связного набора providers, а не папка для каждого endpoint. +Backend — domain-oriented modular monolith с лёгким разделением +по ответственности. `src/modules` группирует код по бизнес-возможности: `auth`, `banking/bank-cards`, области `investments`. +В инвестициях отдельные Nest modules владеют `brokerage-accounts`, `portfolio`, +`operations`, `income`, `upcoming-events`, `instruments`, `tbank-connection`. +`instruments/stocks` и `instruments/bonds` — slices внутри одного модуля. -| Решение | Owner | +### Progressive structure + +Три уровня организации: business module → responsibility layer → technical grouping +при реальной необходимости. Компактный module с понятными controller/DTO/service +может оставаться плоским. Ориентир 5–7 файлов не является порогом: учитывай число +ответственностей, наличие domain/repository/use cases и удобство просмотра каталога. + +Когда в module смешаны несколько services, transport, расчёты и persistence, +группируй файлы по ответственности: + +```text +feature/ +├── feature.module.ts +├── feature.api.ts # только при существующих внешних потребителях +├── presentation/ # HTTP, DTO, guards +├── application/ # сценарии, orchestration, application input +├── domain/ # чистые бизнес-правила и расчёты +└── infrastructure/ # persistence, framework и boundary mapping +``` + +Создавай только нужные слои. В корне сложного module оставляй composition и +публичную boundary; production code и colocated tests размещай в слое владельца. +Внутри business slice действует то же правило, без отдельного Nest module ради папок. +`controllers/`, `dto/`, `services/`, `use-cases/`, `persistence/`, `mappers/` +допустимы только внутри слоя, когда множество однотипных файлов мешает навигации. +Для одного controller или пары DTO дополнительная поддиректория не нужна. +Пустые папки, новые barrels, CQRS и interfaces не требуются для этой организации. + +В текущем коде слои используют `auth`, `bank-cards`, `portfolio`, `operations` +и slices `instruments/stocks`, `instruments/bonds`. Общие application helpers +инструментов находятся в `instruments/application`; `instruments.module.ts` +компонует оба slice. `stock-position.ts` и `instrument-position.ts` формируют +результат из portfolio DTO; `instrument-market.ts` обрабатывает внешние данные +и ошибки Nest. Это application code, а не независимая domain model. +`brokerage-accounts`, `income`, `upcoming-events`, `tbank-connection` остаются +компактными плоскими modules. Наличие tests само по себе не требует слоёв. + +## Поиск и создание файла + +При работе над feature проходи владельцев в порядке: + +1. Найди business module. +2. Прочитай его `*.module.ts` (для slice — composition родительского module). +3. Проверь `presentation/`: controller и transport contract. +4. Проследи вызовы в `application/`: сценарии и services. +5. Проверь используемые правила в `domain/`. +6. Проверь `infrastructure/`: repository и технические реализации. +7. По обнаруженным зависимостям переходи в `integrations/` и `common/`. + +В плоском module классифицируй файлы по suffix и следуй их ответственности. +Отсутствующий слой пропускай. Если business owner известен, начинай локально, +а не с repository-wide search. + +**Rule of locality:** code that belongs to one business capability stays inside +that capability unless it is cross-cutting. Например, расчёты карт принадлежат +bank-cards/domain, даже если реализованы обычными функциями. + +Перед созданием файла определи: + +1. Какой business module владеет изменением? Сначала owner, затем common при + подтверждённой общей потребности. +2. Какова ответственность: HTTP/DTO/guard → presentation; сценарий/orchestration → + application; чистое business rule/calculation → domain; persistence или + module-specific technical implementation → infrastructure; внешний протокол → integrations. +3. Какой approved suffix из таблицы соответствует этой ответственности? +4. Какой слой владеет файлом? В компактном плоском module сохрани размещение рядом + с owner; в слоистом используй соответствующий слой. +5. Достаточно ли colocate файл рядом с существующими? Level 3 grouping создавай + только при заметном улучшении навигации по множеству однотипных файлов. + +Если owner или роль не определены, сначала установи ответственность. Generic +`utils.ts`, `helpers.ts`, `manager.ts`, `handler.ts` и arbitrary directory её не заменяют. + +## Naming & File Suffix Convention + +Имена файлов — `kebab-case`. File suffix определяет responsibility, directory +определяет architectural layer. Размещение в таблице предпочтительно для +слоистого module; компактный плоский module сохраняет файлы в корне. +Cross-module consumers во всех строках ограничены публичной границей ниже. + +| Suffix | Когда использовать | Где | Кто зависит | Когда не использовать | +| --- | --- | --- | --- | --- | +| `*.controller.ts` | HTTP endpoint / transport | presentation | Nest wiring | DB, расчёты, SDK, сложная оркестрация | +| `*.dto.ts` | Request/response, validation, OpenAPI | presentation | Controller; application boundary при совпадении shape | Универсальная domain model | +| `*.use-case.ts` | Один самостоятельный application scenario | application | Controller, другой orchestrator | Prisma queries, Request/Response, SDK implementation | +| `*.service.ts` | Конкретная cohesive reusable capability | рядом с owner; application/domain/integration по роли | Сценарии и services; domain — только от чистого domain service | Свалка несвязанных сценариев | +| `*.repository.ts` | Persistence queries и согласованные DB reads/writes | infrastructure | Application service/use case | Domain calculations, HTTP request, external transport | +| `*.client.ts` | Низкоуровневый внешний HTTP/gRPC/SDK | integrations или infrastructure | Application orchestration, boundary mapper | Пользовательский lifecycle и domain rules | +| `*.mapper.ts` | Meaningful conversion между моделями | обычно infrastructure у преобразуемой boundary | Код соответствующей boundary | Формальное копирование нескольких одинаковых полей | +| `*.policy.ts` | Cohesive decision rule | domain/application/integration по входным данным | Владелец правила; domain — от чистой policy | Persistence, transport, generic strategy | +| `*.guard.ts` | Nest guard | presentation | Nest/controller | Основная account authorization и финансовые правила | +| `*.strategy.ts` | Framework strategy, например JWT | infrastructure | Framework wiring | Обычный service без strategy contract | +| `*.module.ts` | Nest imports/providers/controllers/exports | module root | Composition root, importing module | Бизнес-логика | +| `*.types.ts` | Малая группа связанных TS types | рядом с owner | Потребители модели на разрешённых слоях | Глобальные types всего module, runtime validation | +| `*.api.ts` | Явный список публичных exports | root конкретного module/integration | Другие modules | DTO/client/service implementation или generic API logic | +| `*-input.ts` | Application input: нормализация и проверка формы, отличающейся от wire DTO | application | Application code | Копия DTO под новым именем; вариант `*.input.ts` | +| `*.calculations.ts`, `*.rules.ts` или предметное имя | Чистые бизнес-расчёты, value semantics | domain | Application и domain | Nest/Prisma/HTTP/SDK зависимости | +| `*.spec.ts` | Существующая unit/component проверка поведения | рядом с owner/slice | Test runner | Архитектурные проверки папок и trivial wiring | + +`bank-card-input.ts` использует выбранный `*-input.ts`: переводит Money в +копейки, нормализует значения и проверяет application input. DTO не дублируется +отдельным Input type. `rub-money.ts` — domain-правила RUB; `bond-money.mapper.ts` +— преобразование ответа клиента. Эти роли не определяются словом money в имени. + +Технические framework suffixes (`*.filter.ts`, `*.middleware.ts`, +`*.decorator.ts`, `*.config.ts`, `*.validation.ts`) сохраняют соответствующие +существующие роли в common/config. `tbank.crypto.ts` — локальная реализация +шифрования токена; имя не является разрешением создавать generic crypto layer. +HTTP/e2e naming и расположение — в [Testing](backend-testing.md). + +Без зафиксированной необходимости не вводи `*.helper.ts`, `*.utils.ts`, +`*.manager.ts`, `*.processor.ts`, `*.handler.ts`, `*.provider.ts`, `*.factory.ts`. +Новая naming convention сначала получает конкретную роль и обоснование здесь. +Для data access выбран только `*.repository.ts`, не persistence/store/dao. + +## Публичные границы + +`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими +модулями providers, функции и типы. Внешний production import идёт через этот +API или файл Nest module. Чистый `integrations/tbank/decimal.ts` — отдельный +публичный вход для protobuf conversion: импорт арифметики не загружает DI/Prisma. +Внутри модуля используй прямые локальные импорты. +Tests могут импортировать внутренний owner для проверки поведения и mocks. +Новый export требует текущего потребителя; controller и persistence остаются +приватными. Не добавляй `index.ts` на каждом уровне. + +Каждый provider регистрируется у одного владельца. Потребители импортируют +его module, а не повторяют provider: это сохраняет единственный кэш и лимитер. +`AuthModule` экспортирует guard; наличие JWT не заменяет account access check. + +- `income` читает операции через `operations.api.ts`. +- `instruments` получает личные позиции через `portfolio.api.ts`. +- `tbank-connection` использует T-Bank integration и публичный сброс портфеля. +- `portfolio` и другие инвестиционные модули используют `TbankModule`, который + не зависит от business modules. Так управление подключением не образует цикл. +- `investment-calendar.ts` и `account-reference.dto.ts` разделяются только + инвестиционными областями; это не глобальная библиотека бизнес-правил. + +## Ответственность и направление зависимостей + +| Владелец | Ответственность | | --- | --- | -| Route, request/response mapping, status/cookie | Controller, [HTTP rules](backend-http-api.md) | -| Business use case, orchestration, domain validation | Feature service | -| Input shape/format | DTO + configured ValidationPipe | -| Атомарность/DB query | Сервис use case через PrismaService, [Data](backend-data.md) | -| Внешний protocol, deadline/retry/limiting | Existing client, [Integrations](backend-integrations.md) | -| Чистое вычисление/mapper без lifecycle и DI | Обычная функция рядом с domain owner | -| Bootstrap/config/global cross-cutting concern | AppModule, config, существующий common owner | +| Controller / presentation | Route, verified identity, transport DTO, validation, status/cookie; вызов сценария | +| Use case / application service | Оркестрация, business validation, доступ и согласованность результата | +| Domain | Чистые правила и расчёты; без NestJS, Prisma, HTTP/Swagger и внешних SDK | +| Persistence / infrastructure | Запросы, транзакции, технические реализации границ | +| `src/integrations/{tbank,moex,ncr}` | Внешние протоколы, декодирование, retry/deadline/cache; без зависимостей от HTTP DTO business modules | +| `src/prisma` | Общий DB provider и lifecycle; не generic repository | -**Безопасный путь:** расширь текущий owner; выделяй collaborator, когда он скрывает -самостоятельный инвариант или координацию. Не вводи CQRS, generic repository, -interface с одной реализацией или base controller только ради шаблона Nest. -Не превращай common в хранилище бизнес-логики и не переноси frontend модели сюда. +Основной вызов: presentation → application → domain; application использует +конкретные infrastructure providers, зарегистрированные module. Infrastructure +может использовать application types/input и domain rules; новые interfaces +ради инверсии зависимостей не обязательны. Domain не импортирует presentation или +infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries +по [Data](backend-data.md); use case получает данные через repository и сам +Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется. +DTO остаются транспортными runtime classes; существующее использование DTO +на application boundary допустимо по [HTTP API](backend-http-api.md). -## Существующие seams +Сохраняй существующий error contract: HTTP exceptions остаются на application/ +transport boundary. Чистому расчёту не нужен новый framework ошибок. +Точные ограничения — [HTTP](backend-http-api.md), [Data](backend-data.md), +[Security](security-auth.md), [Integrations](backend-integrations.md). -`TbankConnectionModule` уже объединяет счета, портфель, операции, доходы и события. -Новый связанный use case обычно добавляется в него; создание отдельного module -требует собственной ответственности, не только нового файла. +## Сценарии и размер сервисов -- [BrokerageOperationsController](../../src/tbank/brokerage-operations.controller.ts) передаёт user/account/query в service, без финансовых вычислений в controller. -- [TbankAccessService](../../src/tbank/tbank-access.service.ts) владеет connection/account access и generation; новые account use cases используют его вместо копирования guard/decrypt logic. -- [decimal.ts](../../src/tbank/decimal.ts) — пример pure domain helper; Injectable и adapter для арифметики не нужны. +Разделяй независимые операции, а не строки. `BankCardsService` отвечает за карты, +`BankOperationsService` — журнал/переводы/возвраты, `ExpenseCategoriesService` — +категории, `GetBankSummaryUseCase` — сводку. `BankCardsRepository` сохраняет +общий transaction retry и owner lookup; денежные транзакции не разрываются. +Чистые расчёты банковских карт находятся в `domain` этого модуля. -Наличие guard подтверждает session, но не владение account. При пересечении -access boundary добавь [Security](security-auth.md). Ошибка upstream не должна -обходить общий HTTP filter или превращать чужой account в доступный. +У инструментов `StockCatalogService` / `BondCatalogService` хранят идентичность +листинга и общий кэш slice; market service — котировки/свечи, income service — выплаты/личные позиции. +`GetStockCompanyUseCase` владеет сведениями об эмитенте. Ключи, TTL и pending +promises разделяют прежний экземпляр кэша; extract не должен менять generation. +Portfolio и operations сохраняют связную координацию снимков/страниц и кэшей. +Расчёты портфеля находятся в `portfolio/domain`; преобразование брокерских +позиций — в `tbank-portfolio.mapper.ts`. `bond-money.mapper.ts` преобразует +ответ клиента и не является value object. + +Выбирай обычную функцию для чистого вычисления; repository contract — только +для реальной границы. CQRS/buses/events, BaseRepository/BaseService, DI token +для каждого класса, mapper/presenter для каждого DTO и новые dependencies +не нужны для структурного переноса. + +## Common + +`common` содержит общий error filter, logging, API decorators, тип `Money`, +его HTTP `MoneyDto` и точную decimal-арифметику, используемые несколькими областями. Код одного +модуля принадлежит этому модулю: например, RUB/kopecks rules — банковским картам, +protobuf units/nano conversion — T-Bank integration. Domain использует +`money.types.ts`, а не Swagger DTO; это общая форма денег для карт и портфеля. +Давай функциям предметные имена; не создавай глобальные `utils.ts`/`helpers.ts`. ## Logging diff --git a/.ai/conventions/backend-data.md b/.ai/conventions/backend-data.md index 4ad2b53..04954b5 100644 --- a/.ai/conventions/backend-data.md +++ b/.ai/conventions/backend-data.md @@ -9,12 +9,33 @@ PrismaService — существующий shared DB provider с lifecycle пр | Ситуация | Решение | | --- | --- | -| Один query/write конкретного use case | Existing service + PrismaService | +| Данные для use case | Concrete repository; use case не импортирует PrismaService/Prisma и не строит queries | | Несколько записей обязаны выполниться вместе | Database transaction вокруг инварианта | | Конкурентная смена одного значения | Atomic conditional update/constraint с проверкой результата | | Независимые reads | Не добавляй transaction ради внешнего вида | | Общая persistence abstraction | Только при реальной общей ответственности; generic repository для одной Prisma реализации не нужен | +Размещение repository определяется [Architecture](backend-architecture.md): +в слоистом module это `infrastructure/`. +Repository — конкретный класс `*.repository.ts` у business owner, без обязательных +interface/port/adapter. Он владеет queries, includes/filtering, DB mapping и +согласованным чтением. Суффиксы `*.persistence.ts`, `*.store.ts`, `*.dao.ts` +для той же роли не используем. Nest module регистрирует repository приватно. + +`BankCardsRepository.summaryData` проверяет владельца карты и получает операции +с категориями в одной read transaction. `GetBankSummaryUseCase` выбирает период, +вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records +не становятся domain entities: расчёт получает только нужные ему значения. + +Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction +services (auth, lifecycle подключения, изменения карт/операций/категорий) +сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует +лишь набора pass-through методов. Новая самостоятельная read/write capability +получает repository method; самостоятельный use case не выполняет Prisma queries. +`BankCardsRepository.write` сохраняет общую retry/transaction boundary для +существующих banking services. Такой callback с Prisma transaction client — +локальная техническая граница этих services, не API для domain или use cases. + Выбирай только нужные данные, сохраняй user/account ownership в запросах и application checks. Предварительный lookup не заменяет unique constraint при race. Пример: AuthService обрабатывает P2002 и использует conditional `updateMany` diff --git a/.ai/conventions/backend-http-api.md b/.ai/conventions/backend-http-api.md index 8c177ad..52e6f48 100644 --- a/.ai/conventions/backend-http-api.md +++ b/.ai/conventions/backend-http-api.md @@ -16,16 +16,23 @@ use case. Для route/request/response/status/error shape читай | HTTP envelope/безопасная ошибка | Общий HttpExceptionFilter | Controller объявляет contract и делегирует; не обращается напрямую к Prisma/gRPC -ради обхода service. Request identity берётся из verified `request.user`, не из -body/userId, предоставленного клиентом. Access rules активируют Security. +и не выполняет финансовые расчёты или сложную оркестрацию. +Controller вызывает application use case/service. Identity берётся из verified +`request.user`, не из body/userId, предоставленного клиентом. Access rules активируют Security. ## DTO и errors DTO — runtime class: TS interface не предоставляет validation metadata. +`*.controller.ts` и `*.dto.ts` остаются около HTTP boundary, в root компактного +module или presentation. DTO можно передать application boundary при совпадении +формы; отдельный Input с теми же полями не нужен. Domain не импортирует DTO: +например, чистые денежные расчёты используют `Money`, а Swagger — `MoneyDto`. +Нормализованная форма input нужна только при смысловом отличии от wire contract; +выбор suffix принадлежит [Architecture](backend-architecture.md). ValidationPipe уже настроен в `configureApp` (`transform`, `whitelist`). Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён. Для query numbers/arrays выбирай явное преобразование и валидаторы, как в -[operations DTO](../../src/tbank/brokerage-operation.dto.ts). Swagger metadata +[operations DTO](../../src/modules/investments/operations/presentation/brokerage-operation.dto.ts). Swagger metadata описывает те же required/optional/nullable/enum/range значения. Используй типизированные response DTO, `ApiOkResponse`/другой фактический status @@ -40,7 +47,7 @@ raw upstream error, Prisma record с secrets или stack. Текущая фор ## Пример и проверка -[Operations controller](../../src/tbank/brokerage-operations.controller.ts) +[Operations controller](../../src/modules/investments/operations/presentation/brokerage-operations.controller.ts) показывает guard, user.sub, DTO query и service delegation. Для нового field обновляй canonical source и generated artifacts в определённом направлении; body type frontend не исправляется вручную. diff --git a/.ai/conventions/backend-integrations.md b/.ai/conventions/backend-integrations.md index f41eac9..1c5d3fb 100644 --- a/.ai/conventions/backend-integrations.md +++ b/.ai/conventions/backend-integrations.md @@ -3,14 +3,34 @@ ## Выбор boundary T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient. Сервисы use case -получают нормализованный результат через эти clients. Frontend не вызывает +используют объявленные клиентом records; SDK clients, metadata и transport errors +остаются внутри integration. Frontend не вызывает T-Bank и не получает credentials/proto client. Reference source — [ADR 003](../../../docs/adr/003-tbank-server-side-grpc-boundary.md) и [connection behavior](../../../docs/integrations/tbank.md). +`modules/investments/tbank-connection` — пользовательская capability чтения, +создания/замены и удаления подключения. `integrations/tbank` — технические +RPC, credentials/access и resilience. Эти владельцы не объединяются из-за имени +поставщика; низкоуровневый client не зависит от пользовательского module. + +`*.client.ts` содержит transport и его retries; `*.api.ts` только перечисляет +публичные exports. Все шесть `auth/operations/portfolio/tbank/moex/ncr.api.ts` +имеют одинаковую роль программной границы, определённую в +[Architecture](backend-architecture.md). Логика, DTO definitions и HTTP routes +в эти файлы не помещаются. Type exports обозначаются `export type`. + +Преобразование provider records происходит на boundary: например, +`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper +остаётся отдельным чистым входом integration. Domain получает внутренние +значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый +client или mapper для простого копирования полей. Даты/units/nano разбираются +на границе клиента/его mapping, а не становятся моделью чистых расчётов. + Для нового account use case сначала используй TbankAccessService: access, connection generation и invalid-token translation не дублируются в callers. -Чистый mapper финансового значения остаётся рядом с domain owner. +Mapper внешних финансовых значений остаётся внутри business capability, +обычно в infrastructure по [Architecture](backend-architecture.md). ## Retry, cache и protocol @@ -30,7 +50,7 @@ cache/stale policy; semantics конкретного use case читай чер raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService и общем HTTP boundary. Для HTTP error change читай HTTP API/Security по trigger. -Vendor proto хранится в `src/tbank/proto/contracts`; для обновления сверяй +Vendor proto хранится в `src/integrations/tbank/proto/contracts`; для обновления сверяй официальный source и влияние на loader/DTO, не редактируй read-only references. Generated artifacts, если есть, создавай из source, без ручной подгонки вывода. @@ -40,7 +60,7 @@ Generated artifacts, если есть, создавай из source, без р ответственного service. Отдельный abstraction/adapter оправдан новой ответственностью или несовместимым protocol, не только вторым методом. -[TbankClient tests](../../src/tbank/tbank.client.spec.ts) — пример controlled +[TbankClient tests](../../src/integrations/tbank/tbank.client.spec.ts) — пример controlled upstream responses. Проверь queue/deadline/retry limit, token translation, cache isolation и late response для затронутых ветвей. Fixtures синтетические; реальная сеть, секреты и пользовательские accounts не нужны. diff --git a/.ai/conventions/backend-testing.md b/.ai/conventions/backend-testing.md index 5346d06..4e538ab 100644 --- a/.ai/conventions/backend-testing.md +++ b/.ai/conventions/backend-testing.md @@ -21,8 +21,8 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`. ## Случаи и примеры -- [BrokerageAccountsService tests](../../src/tbank/brokerage-accounts.service.spec.ts): отсутствие connection не вызывает upstream, нормализация и точные суммы. -- [AuthService tests](../../src/auth/auth.service.spec.ts): invalid/expired/superseded token и concurrent rotation; mock race test не является доказательством DB transaction. +- [BrokerageAccountsService tests](../../src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts): отсутствие connection не вызывает upstream, нормализация и точные суммы. +- [AuthService tests](../../src/modules/auth/application/auth.service.spec.ts): invalid/expired/superseded token и concurrent rotation; mock race test не является доказательством DB transaction. - [HTTP filter tests](../../src/common/filters/http-exception.filter.spec.ts): безопасный error envelope. - [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): configured app + frontend snapshot, без записи snapshot и без T-Bank. diff --git a/.ai/conventions/security-auth.md b/.ai/conventions/security-auth.md index 9bb0798..7534d4b 100644 --- a/.ai/conventions/security-auth.md +++ b/.ai/conventions/security-auth.md @@ -14,7 +14,7 @@ TbankTokenService/TbankCrypto. Расширяй существующую boundar [ADR 006](../../../docs/adr/006-auth-session-policy.md)/[009](../../../docs/adr/009-concurrent-refresh-rotation-policy.md) задают session semantics. Access хранится только в памяти frontend; refresh передаётся httpOnly cookie. Source code конфигурации cookie — -[AuthController](../../src/auth/auth.controller.ts); изменение flags/path требует +[AuthController](../../src/modules/auth/presentation/auth.controller.ts); изменение flags/path требует проверки browser/HTTP поведения, не только TypeScript. Refresh rotation атомарна: один победитель, проигравший/superseded token получает diff --git a/nest-cli.json b/nest-cli.json index b69f757..cd0881e 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -5,7 +5,7 @@ "compilerOptions": { "deleteOutDir": true, "assets": [ - { "include": "tbank/proto/contracts/**/*.proto", "watchAssets": true } + { "include": "integrations/tbank/proto/contracts/**/*.proto", "watchAssets": true } ] } } diff --git a/src/app.module.ts b/src/app.module.ts index 08db9cf..cbb0f78 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,13 +1,19 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { randomUUID } from 'node:crypto'; import { LoggerModule } from 'nestjs-pino'; -import { BankCardsModule } from './bank-cards/bank-cards.module'; -import { AuthModule } from './auth/auth.module'; +import { randomUUID } from 'node:crypto'; import { LoggingModule } from './common/logging/logging.module'; import { Environment, validateEnv } from './config/env.validation'; +import { AuthModule } from './modules/auth/auth.module'; +import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module'; +import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module'; +import { IncomeModule } from './modules/investments/income/income.module'; +import { InstrumentsModule } from './modules/investments/instruments/instruments.module'; +import { OperationsModule } from './modules/investments/operations/operations.module'; +import { PortfolioModule } from './modules/investments/portfolio/portfolio.module'; +import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module'; +import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module'; import { PrismaModule } from './prisma/prisma.module'; -import { TbankConnectionModule } from './tbank/tbank-connection.module'; @Module({ imports: [ @@ -51,6 +57,12 @@ import { TbankConnectionModule } from './tbank/tbank-connection.module'; PrismaModule, AuthModule, BankCardsModule, + BrokerageAccountsModule, + PortfolioModule, + OperationsModule, + IncomeModule, + UpcomingEventsModule, + InstrumentsModule, TbankConnectionModule, ], }) diff --git a/src/bank-cards/bank-cards.module.ts b/src/bank-cards/bank-cards.module.ts deleted file mode 100644 index 5f0e8d3..0000000 --- a/src/bank-cards/bank-cards.module.ts +++ /dev/null @@ -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 {} diff --git a/src/bank-cards/bank-cards.service.ts b/src/bank-cards/bank-cards.service.ts deleted file mode 100644 index cd60a10..0000000 --- a/src/bank-cards/bank-cards.service.ts +++ /dev/null @@ -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[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(run: (tx: Tx) => Promise): Promise { - 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 { - 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 { - 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, - 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), - }; - }), - }; - }); - } -} diff --git a/src/common/dto-validation.spec.ts b/src/common/dto-validation.spec.ts index 160ddf4..93016de 100644 --- a/src/common/dto-validation.spec.ts +++ b/src/common/dto-validation.spec.ts @@ -1,9 +1,9 @@ import { BadRequestException, ValidationPipe } from '@nestjs/common'; -import { CredentialsDto } from '../auth/auth.dto'; +import { CredentialsDto } from '../modules/auth/presentation/auth.dto'; import { CreateBankCardDto, UpdateBankCardDto, -} from '../bank-cards/bank-card.dto'; +} from '../modules/banking/bank-cards/presentation/bank-card.dto'; describe('DTO validation', () => { const pipe = new ValidationPipe({ transform: true, whitelist: true }); diff --git a/src/common/money.dto.ts b/src/common/money.dto.ts index b35b82b..87feaad 100644 --- a/src/common/money.dto.ts +++ b/src/common/money.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; +import type { Money } from './money.types'; -export class MoneyDto { +export class MoneyDto implements Money { @ApiProperty() declare currency: string; diff --git a/src/common/money.types.ts b/src/common/money.types.ts new file mode 100644 index 0000000..fa5bc6f --- /dev/null +++ b/src/common/money.types.ts @@ -0,0 +1 @@ +export type Money = { currency: string; value: string }; diff --git a/src/integrations/moex/moex.api.ts b/src/integrations/moex/moex.api.ts new file mode 100644 index 0000000..8e90436 --- /dev/null +++ b/src/integrations/moex/moex.api.ts @@ -0,0 +1,2 @@ +// Public cross-module surface. Nest providers are exported by the matching module. +export { type MoexBondEvent, MoexClient } from './moex.client'; diff --git a/src/tbank/moex.client.spec.ts b/src/integrations/moex/moex.client.spec.ts similarity index 100% rename from src/tbank/moex.client.spec.ts rename to src/integrations/moex/moex.client.spec.ts diff --git a/src/tbank/moex.client.ts b/src/integrations/moex/moex.client.ts similarity index 100% rename from src/tbank/moex.client.ts rename to src/integrations/moex/moex.client.ts diff --git a/src/integrations/moex/moex.module.ts b/src/integrations/moex/moex.module.ts new file mode 100644 index 0000000..9b20ab1 --- /dev/null +++ b/src/integrations/moex/moex.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { MoexClient } from './moex.client'; + +@Module({ + providers: [MoexClient], + exports: [MoexClient], +}) +export class MoexModule {} diff --git a/src/tbank/ncr-ratings.client.spec.ts b/src/integrations/ncr/ncr-ratings.client.spec.ts similarity index 100% rename from src/tbank/ncr-ratings.client.spec.ts rename to src/integrations/ncr/ncr-ratings.client.spec.ts diff --git a/src/tbank/ncr-ratings.client.ts b/src/integrations/ncr/ncr-ratings.client.ts similarity index 100% rename from src/tbank/ncr-ratings.client.ts rename to src/integrations/ncr/ncr-ratings.client.ts diff --git a/src/integrations/ncr/ncr.api.ts b/src/integrations/ncr/ncr.api.ts new file mode 100644 index 0000000..cc7c189 --- /dev/null +++ b/src/integrations/ncr/ncr.api.ts @@ -0,0 +1,2 @@ +// Public cross-module surface. Nest providers are exported by the matching module. +export { NcrRatingsClient } from './ncr-ratings.client'; diff --git a/src/integrations/ncr/ncr.module.ts b/src/integrations/ncr/ncr.module.ts new file mode 100644 index 0000000..ae77a97 --- /dev/null +++ b/src/integrations/ncr/ncr.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { NcrRatingsClient } from './ncr-ratings.client'; + +@Module({ + providers: [NcrRatingsClient], + exports: [NcrRatingsClient], +}) +export class NcrModule {} diff --git a/src/tbank/decimal.spec.ts b/src/integrations/tbank/decimal.spec.ts similarity index 88% rename from src/tbank/decimal.spec.ts rename to src/integrations/tbank/decimal.spec.ts index 8028e85..3cb5ea6 100644 --- a/src/tbank/decimal.spec.ts +++ b/src/integrations/tbank/decimal.spec.ts @@ -1,4 +1,4 @@ -import { add, formatDecimal, multiply, subtract } from '../common/decimal'; +import { add, formatDecimal, multiply, subtract } from '../../common/decimal'; import { decimal } from './decimal'; describe('decimal', () => { diff --git a/src/tbank/decimal.ts b/src/integrations/tbank/decimal.ts similarity index 84% rename from src/tbank/decimal.ts rename to src/integrations/tbank/decimal.ts index 423c599..06ec46e 100644 --- a/src/tbank/decimal.ts +++ b/src/integrations/tbank/decimal.ts @@ -1,4 +1,4 @@ -import { Decimal } from '../common/decimal'; +import { Decimal } from '../../common/decimal'; const NANO = 1_000_000_000n; diff --git a/src/tbank/instrument-metadata.service.spec.ts b/src/integrations/tbank/instrument-metadata.service.spec.ts similarity index 100% rename from src/tbank/instrument-metadata.service.spec.ts rename to src/integrations/tbank/instrument-metadata.service.spec.ts index f1f03ec..2c7e027 100644 --- a/src/tbank/instrument-metadata.service.spec.ts +++ b/src/integrations/tbank/instrument-metadata.service.spec.ts @@ -1,7 +1,7 @@ -import assert from 'node:assert/strict'; -import { InstrumentMetadataService } from './instrument-metadata.service'; import { ServiceUnavailableException } from '@nestjs/common'; import type { LRUCache } from 'lru-cache'; +import assert from 'node:assert/strict'; +import { InstrumentMetadataService } from './instrument-metadata.service'; import type { TbankInstrument } from './tbank.client'; describe('InstrumentMetadataService', () => { diff --git a/src/tbank/instrument-metadata.service.ts b/src/integrations/tbank/instrument-metadata.service.ts similarity index 97% rename from src/tbank/instrument-metadata.service.ts rename to src/integrations/tbank/instrument-metadata.service.ts index 53a8db8..f78c86d 100644 --- a/src/tbank/instrument-metadata.service.ts +++ b/src/integrations/tbank/instrument-metadata.service.ts @@ -1,7 +1,7 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { LRUCache } from 'lru-cache'; import { createHash } from 'node:crypto'; -import { StructuredLogger } from '../common/logging/structured-logger.service'; +import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { TbankClient, type TbankInstrument } from './tbank.client'; const metadataForMs = 86_400_000; diff --git a/src/tbank/proto/contracts/common.proto b/src/integrations/tbank/proto/contracts/common.proto similarity index 100% rename from src/tbank/proto/contracts/common.proto rename to src/integrations/tbank/proto/contracts/common.proto diff --git a/src/tbank/proto/contracts/google/api/field_behavior.proto b/src/integrations/tbank/proto/contracts/google/api/field_behavior.proto similarity index 100% rename from src/tbank/proto/contracts/google/api/field_behavior.proto rename to src/integrations/tbank/proto/contracts/google/api/field_behavior.proto diff --git a/src/tbank/proto/contracts/instruments.proto b/src/integrations/tbank/proto/contracts/instruments.proto similarity index 100% rename from src/tbank/proto/contracts/instruments.proto rename to src/integrations/tbank/proto/contracts/instruments.proto diff --git a/src/tbank/proto/contracts/marketdata.proto b/src/integrations/tbank/proto/contracts/marketdata.proto similarity index 100% rename from src/tbank/proto/contracts/marketdata.proto rename to src/integrations/tbank/proto/contracts/marketdata.proto diff --git a/src/tbank/proto/contracts/operations.proto b/src/integrations/tbank/proto/contracts/operations.proto similarity index 100% rename from src/tbank/proto/contracts/operations.proto rename to src/integrations/tbank/proto/contracts/operations.proto diff --git a/src/tbank/proto/contracts/users.proto b/src/integrations/tbank/proto/contracts/users.proto similarity index 100% rename from src/tbank/proto/contracts/users.proto rename to src/integrations/tbank/proto/contracts/users.proto diff --git a/src/tbank/tbank-access.service.spec.ts b/src/integrations/tbank/tbank-access.service.spec.ts similarity index 99% rename from src/tbank/tbank-access.service.spec.ts rename to src/integrations/tbank/tbank-access.service.spec.ts index b8e7a15..f0b94e0 100644 --- a/src/tbank/tbank-access.service.spec.ts +++ b/src/integrations/tbank/tbank-access.service.spec.ts @@ -5,12 +5,12 @@ import { ServiceUnavailableException, } from '@nestjs/common'; -jest.mock('../prisma/prisma.service', () => ({ +jest.mock('../../prisma/prisma.service', () => ({ PrismaService: class PrismaService {}, })); -import { TbankAccessService } from './tbank-access.service'; import type { TbankAccountContext } from './tbank-access.service'; +import { TbankAccessService } from './tbank-access.service'; import { TbankTokenService } from './tbank-token.service'; describe('TbankAccessService', () => { diff --git a/src/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts similarity index 88% rename from src/tbank/tbank-access.service.ts rename to src/integrations/tbank/tbank-access.service.ts index 4fa72a3..f7ac2f9 100644 --- a/src/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -4,14 +4,14 @@ import { NotFoundException, } from '@nestjs/common'; import { createHash } from 'node:crypto'; -import { PrismaService } from '../prisma/prisma.service'; -import { BrokerageAccountDto } from './brokerage-account.dto'; +import { PrismaService } from '../../prisma/prisma.service'; import { + SupportedTbankAccount, accessibleTbankAccount, supportedTbankAccounts, -} from './tbank-account-policy'; -import { TbankClient, TbankAccount } from './tbank.client'; +} from './tbank-account.policy'; import { TbankTokenService } from './tbank-token.service'; +import { TbankAccount, TbankClient } from './tbank.client'; export type TbankConnectionContext = { token: string; @@ -30,7 +30,9 @@ export class TbankAccessService { private readonly tbank: TbankClient, ) {} - async listSupportedAccounts(userId: string): Promise { + async listSupportedAccounts( + userId: string, + ): Promise { return this.withSupportedAccounts(userId, ({ accounts }) => accounts); } @@ -39,7 +41,7 @@ export class TbankAccessService { handler: (context: { token: string; generation: string; - accounts: BrokerageAccountDto[]; + accounts: SupportedTbankAccount[]; }) => Promise | T, ): Promise { return this.withConnection(userId, async ({ token, generation }) => diff --git a/src/tbank/tbank-account-policy.spec.ts b/src/integrations/tbank/tbank-account.policy.spec.ts similarity index 98% rename from src/tbank/tbank-account-policy.spec.ts rename to src/integrations/tbank/tbank-account.policy.spec.ts index 607faa6..7b582e8 100644 --- a/src/tbank/tbank-account-policy.spec.ts +++ b/src/integrations/tbank/tbank-account.policy.spec.ts @@ -1,7 +1,7 @@ import { accessibleTbankAccount, supportedTbankAccounts, -} from './tbank-account-policy'; +} from './tbank-account.policy'; describe('tbank-account-policy', () => { it('normalizes supported account types and fallback names', () => { diff --git a/src/tbank/tbank-account-policy.ts b/src/integrations/tbank/tbank-account.policy.ts similarity index 88% rename from src/tbank/tbank-account-policy.ts rename to src/integrations/tbank/tbank-account.policy.ts index 2e6351f..c84ccf9 100644 --- a/src/tbank/tbank-account-policy.ts +++ b/src/integrations/tbank/tbank-account.policy.ts @@ -1,4 +1,3 @@ -import { BrokerageAccountDto } from './brokerage-account.dto'; import { TbankAccount } from './tbank.client'; const accountTypes = { @@ -15,10 +14,16 @@ const accountTypes = { } as const; type AccountType = keyof typeof accountTypes; +export type SupportedTbankAccount = { + id: string; + name: string; + type: (typeof accountTypes)[AccountType]['type']; + openedOn: string | null; +}; export function supportedTbankAccounts( accounts: TbankAccount[], -): BrokerageAccountDto[] { +): SupportedTbankAccount[] { return accounts.flatMap((account) => { const details = accountTypes[account.type as AccountType]; if (!details) return []; diff --git a/src/tbank/tbank-market.types.ts b/src/integrations/tbank/tbank-market.types.ts similarity index 100% rename from src/tbank/tbank-market.types.ts rename to src/integrations/tbank/tbank-market.types.ts index f577f49..825953a 100644 --- a/src/tbank/tbank-market.types.ts +++ b/src/integrations/tbank/tbank-market.types.ts @@ -1,7 +1,7 @@ import type { TbankDecimal, - TbankMoney, TbankInstrument, + TbankMoney, TbankTimestamp, } from './tbank.client'; diff --git a/src/tbank/tbank-token.service.spec.ts b/src/integrations/tbank/tbank-token.service.spec.ts similarity index 100% rename from src/tbank/tbank-token.service.spec.ts rename to src/integrations/tbank/tbank-token.service.spec.ts diff --git a/src/tbank/tbank-token.service.ts b/src/integrations/tbank/tbank-token.service.ts similarity index 93% rename from src/tbank/tbank-token.service.ts rename to src/integrations/tbank/tbank-token.service.ts index eace0b9..2e1a9ed 100644 --- a/src/tbank/tbank-token.service.ts +++ b/src/integrations/tbank/tbank-token.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Environment } from '../config/env.validation'; +import { Environment } from '../../config/env.validation'; import { TbankCrypto } from './tbank.crypto'; @Injectable() diff --git a/src/integrations/tbank/tbank.api.ts b/src/integrations/tbank/tbank.api.ts new file mode 100644 index 0000000..4bedbe7 --- /dev/null +++ b/src/integrations/tbank/tbank.api.ts @@ -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'; diff --git a/src/tbank/tbank.client.spec.ts b/src/integrations/tbank/tbank.client.spec.ts similarity index 100% rename from src/tbank/tbank.client.spec.ts rename to src/integrations/tbank/tbank.client.spec.ts index 5e74bb9..7ba571f 100644 --- a/src/tbank/tbank.client.spec.ts +++ b/src/integrations/tbank/tbank.client.spec.ts @@ -1,5 +1,5 @@ -import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { status } from '@grpc/grpc-js'; +import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { TbankClient } from './tbank.client'; describe('TbankClient operations', () => { diff --git a/src/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts similarity index 97% rename from src/tbank/tbank.client.ts rename to src/integrations/tbank/tbank.client.ts index 573bde8..a995d17 100644 --- a/src/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -1,9 +1,3 @@ -import { - Injectable, - NotFoundException, - ServiceUnavailableException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { ChannelCredentials, Client, @@ -14,18 +8,24 @@ import { status, } from '@grpc/grpc-js'; import { loadSync } from '@grpc/proto-loader'; +import { + Injectable, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { Environment } from '../config/env.validation'; -import { StructuredLogger } from '../common/logging/structured-logger.service'; +import { StructuredLogger } from '../../common/logging/structured-logger.service'; +import { Environment } from '../../config/env.validation'; import type { - TbankShare, TbankBond, - TbankCoupon, TbankCandle, - TbankLastPrice, + TbankCoupon, TbankFundamentals, + TbankLastPrice, + TbankShare, } from './tbank-market.types'; const requestDeadlineMs = 15_000; @@ -164,10 +164,13 @@ export class TbankClient { config: ConfigService, private readonly logger: StructuredLogger, ) { - const sourceProtoRoot = join(process.cwd(), 'src/tbank/proto/contracts'); + const sourceProtoRoot = join( + process.cwd(), + 'src/integrations/tbank/proto/contracts', + ); const protoRoot = existsSync(sourceProtoRoot) ? sourceProtoRoot - : join(process.cwd(), 'dist/tbank/proto/contracts'); + : join(process.cwd(), 'dist/integrations/tbank/proto/contracts'); const definition = loadSync( [ 'users.proto', diff --git a/src/tbank/tbank.crypto.spec.ts b/src/integrations/tbank/tbank.crypto.spec.ts similarity index 100% rename from src/tbank/tbank.crypto.spec.ts rename to src/integrations/tbank/tbank.crypto.spec.ts diff --git a/src/tbank/tbank.crypto.ts b/src/integrations/tbank/tbank.crypto.ts similarity index 100% rename from src/tbank/tbank.crypto.ts rename to src/integrations/tbank/tbank.crypto.ts diff --git a/src/integrations/tbank/tbank.module.ts b/src/integrations/tbank/tbank.module.ts new file mode 100644 index 0000000..9be7b7f --- /dev/null +++ b/src/integrations/tbank/tbank.module.ts @@ -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 {} diff --git a/src/auth/auth.service.spec.ts b/src/modules/auth/application/auth.service.spec.ts similarity index 98% rename from src/auth/auth.service.spec.ts rename to src/modules/auth/application/auth.service.spec.ts index 78400ba..df2fe1f 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/modules/auth/application/auth.service.spec.ts @@ -7,7 +7,7 @@ jest.mock('bcrypt', () => ({ compare: jest.fn(() => Promise.resolve(true)), })); -jest.mock('../prisma/prisma.service', () => ({ +jest.mock('../../../prisma/prisma.service', () => ({ PrismaService: class PrismaService {}, })); diff --git a/src/auth/auth.service.ts b/src/modules/auth/application/auth.service.ts similarity index 97% rename from src/auth/auth.service.ts rename to src/modules/auth/application/auth.service.ts index 9c66a5c..ecbdf5d 100644 --- a/src/auth/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -6,8 +6,8 @@ import { import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { createHash, randomUUID } from 'crypto'; -import { Session, User } from '../../generated/prisma/client'; -import { PrismaService } from '../prisma/prisma.service'; +import { Session, User } from '../../../../generated/prisma/client'; +import { PrismaService } from '../../../prisma/prisma.service'; import { AuthUser, RefreshPayload } from './auth.types'; const ACCESS_TTL = '15m'; diff --git a/src/auth/auth.types.ts b/src/modules/auth/application/auth.types.ts similarity index 100% rename from src/auth/auth.types.ts rename to src/modules/auth/application/auth.types.ts diff --git a/src/modules/auth/auth.api.ts b/src/modules/auth/auth.api.ts new file mode 100644 index 0000000..ac5ed80 --- /dev/null +++ b/src/modules/auth/auth.api.ts @@ -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'; diff --git a/src/auth/auth.module.ts b/src/modules/auth/auth.module.ts similarity index 63% rename from src/auth/auth.module.ts rename to src/modules/auth/auth.module.ts index d1b0ee0..9fa5687 100644 --- a/src/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -2,11 +2,11 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; -import { Environment } from '../config/env.validation'; -import { AuthController } from './auth.controller'; -import { AuthService } from './auth.service'; -import { JwtAuthGuard } from './jwt-auth.guard'; -import { JwtStrategy } from './jwt.strategy'; +import { Environment } from '../../config/env.validation'; +import { AuthController } from './presentation/auth.controller'; +import { AuthService } from './application/auth.service'; +import { JwtAuthGuard } from './presentation/jwt-auth.guard'; +import { JwtStrategy } from './infrastructure/jwt.strategy'; @Module({ imports: [ @@ -19,6 +19,7 @@ import { JwtStrategy } from './jwt.strategy'; }), }), ], + exports: [JwtAuthGuard], controllers: [AuthController], providers: [AuthService, JwtStrategy, JwtAuthGuard], }) diff --git a/src/auth/jwt.strategy.ts b/src/modules/auth/infrastructure/jwt.strategy.ts similarity index 85% rename from src/auth/jwt.strategy.ts rename to src/modules/auth/infrastructure/jwt.strategy.ts index 0bcbb41..1f97b0e 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/modules/auth/infrastructure/jwt.strategy.ts @@ -2,8 +2,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; -import { Environment } from '../config/env.validation'; -import { AccessPayload } from './auth.types'; +import { Environment } from '../../../config/env.validation'; +import { AccessPayload } from '../application/auth.types'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { diff --git a/src/auth/auth.controller.spec.ts b/src/modules/auth/presentation/auth.controller.spec.ts similarity index 92% rename from src/auth/auth.controller.spec.ts rename to src/modules/auth/presentation/auth.controller.spec.ts index cad6202..5c8dc1e 100644 --- a/src/auth/auth.controller.spec.ts +++ b/src/modules/auth/presentation/auth.controller.spec.ts @@ -1,6 +1,6 @@ import { HTTP_CODE_METADATA } from '@nestjs/common/constants'; -jest.mock('./auth.service', () => ({ +jest.mock('../application/auth.service', () => ({ AuthService: class AuthService {}, })); diff --git a/src/auth/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts similarity index 93% rename from src/auth/auth.controller.ts rename to src/modules/auth/presentation/auth.controller.ts index 80079e3..4d35801 100644 --- a/src/auth/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -6,8 +6,8 @@ import { Post, Req, Res, - UseGuards, UnauthorizedException, + UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, @@ -17,11 +17,11 @@ import { ApiTags, } from '@nestjs/swagger'; import type { Request, Response } from 'express'; +import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { CredentialsDto } from './auth.dto'; -import { AuthService } from './auth.service'; -import { AccessPayload } from './auth.types'; +import { AuthService } from '../application/auth.service'; +import { AccessPayload } from '../application/auth.types'; import { JwtAuthGuard } from './jwt-auth.guard'; -import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; const REFRESH_COOKIE = 'refresh_token'; const COOKIE_PATH = '/api/v1/auth'; diff --git a/src/auth/auth.dto.ts b/src/modules/auth/presentation/auth.dto.ts similarity index 100% rename from src/auth/auth.dto.ts rename to src/modules/auth/presentation/auth.dto.ts index 7597a05..fa30110 100644 --- a/src/auth/auth.dto.ts +++ b/src/modules/auth/presentation/auth.dto.ts @@ -1,11 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsString, MinLength, Validate, ValidatorConstraint, } from 'class-validator'; -import { Transform } from 'class-transformer'; -import { ApiProperty } from '@nestjs/swagger'; @ValidatorConstraint({ name: 'email' }) class EmailConstraint { diff --git a/src/auth/jwt-auth.guard.ts b/src/modules/auth/presentation/jwt-auth.guard.ts similarity index 80% rename from src/auth/jwt-auth.guard.ts rename to src/modules/auth/presentation/jwt-auth.guard.ts index f183897..8f0f625 100644 --- a/src/auth/jwt-auth.guard.ts +++ b/src/modules/auth/presentation/jwt-auth.guard.ts @@ -1,7 +1,7 @@ import { ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; -import { RequestContextService } from '../common/logging/request-context.service'; -import { AccessPayload } from './auth.types'; +import { RequestContextService } from '../../../common/logging/request-context.service'; +import { AccessPayload } from '../application/auth.types'; @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') { diff --git a/src/modules/banking/bank-cards/application/bank-card-input.ts b/src/modules/banking/bank-cards/application/bank-card-input.ts new file mode 100644 index 0000000..2fdf64d --- /dev/null +++ b/src/modules/banking/bank-cards/application/bank-card-input.ts @@ -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[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, + }; +} diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts new file mode 100644 index 0000000..c83f8be --- /dev/null +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -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 { + 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)); + }); + } +} diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts new file mode 100644 index 0000000..3296be7 --- /dev/null +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -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 { + 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, + 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, + }; + }); + } +} diff --git a/src/modules/banking/bank-cards/application/expense-categories.service.ts b/src/modules/banking/bank-cards/application/expense-categories.service.ts new file mode 100644 index 0000000..8c44a29 --- /dev/null +++ b/src/modules/banking/bank-cards/application/expense-categories.service.ts @@ -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, + }; + }); + } +} diff --git a/src/bank-cards/bank-cards.service.spec.ts b/src/modules/banking/bank-cards/application/get-bank-summary.use-case.spec.ts similarity index 57% rename from src/bank-cards/bank-cards.service.spec.ts rename to src/modules/banking/bank-cards/application/get-bank-summary.use-case.spec.ts index 2932736..104b311 100644 --- a/src/bank-cards/bank-cards.service.spec.ts +++ b/src/modules/banking/bank-cards/application/get-bank-summary.use-case.spec.ts @@ -1,7 +1,10 @@ -jest.mock('../prisma/prisma.service', () => ({ PrismaService: class {} })); -jest.mock('../../generated/prisma/client', () => ({ Prisma: {} })); -import { PrismaService } from '../prisma/prisma.service'; -import { BankCardsService } from './bank-cards.service'; +jest.mock('../../../../prisma/prisma.service', () => ({ + PrismaService: class {}, +})); +jest.mock('../../../../../generated/prisma/client', () => ({ Prisma: {} })); +import { PrismaService } from '../../../../prisma/prisma.service'; +import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; +import { GetBankSummaryUseCase } from './get-bank-summary.use-case'; describe('bank card summary categories', () => { it('resolves category names and rejects an inconsistent category reference', async () => { @@ -22,12 +25,14 @@ describe('bank card summary categories', () => { }, expenseCategory: { findMany: categories }, }; - const service = new BankCardsService({ - $transaction: (run: (client: typeof tx) => unknown) => run(tx), - } as unknown as PrismaService); + const service = new GetBankSummaryUseCase( + new BankCardsRepository({ + $transaction: (run: (client: typeof tx) => unknown) => run(tx), + } as unknown as PrismaService), + ); await expect( - service.summary('user', { month: '2026-09' }), + service.execute('user', { month: '2026-09' }), ).resolves.toMatchObject({ categories: [ { @@ -38,7 +43,7 @@ describe('bank card summary categories', () => { ], }); categories.mockResolvedValue([]); - await expect(service.summary('user', { month: '2026-09' })).rejects.toThrow( + await expect(service.execute('user', { month: '2026-09' })).rejects.toThrow( 'Bank operation category is missing', ); }); diff --git a/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts b/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts new file mode 100644 index 0000000..5b224fb --- /dev/null +++ b/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts @@ -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), + }; + }), + }; + } +} diff --git a/src/modules/banking/bank-cards/bank-cards.module.ts b/src/modules/banking/bank-cards/bank-cards.module.ts new file mode 100644 index 0000000..e34a089 --- /dev/null +++ b/src/modules/banking/bank-cards/bank-cards.module.ts @@ -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 {} diff --git a/src/bank-cards/bank-card.calculations.spec.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.spec.ts similarity index 100% rename from src/bank-cards/bank-card.calculations.spec.ts rename to src/modules/banking/bank-cards/domain/bank-card.calculations.spec.ts diff --git a/src/bank-cards/bank-card.calculations.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts similarity index 100% rename from src/bank-cards/bank-card.calculations.ts rename to src/modules/banking/bank-cards/domain/bank-card.calculations.ts diff --git a/src/common/rub-money.spec.ts b/src/modules/banking/bank-cards/domain/rub-money.spec.ts similarity index 100% rename from src/common/rub-money.spec.ts rename to src/modules/banking/bank-cards/domain/rub-money.spec.ts diff --git a/src/common/rub-money.ts b/src/modules/banking/bank-cards/domain/rub-money.ts similarity index 74% rename from src/common/rub-money.ts rename to src/modules/banking/bank-cards/domain/rub-money.ts index 3d0d74d..5113565 100644 --- a/src/common/rub-money.ts +++ b/src/modules/banking/bank-cards/domain/rub-money.ts @@ -1,7 +1,7 @@ -import { decimalFromString, formatDecimal } from './decimal'; -import { MoneyDto } from './money.dto'; +import { decimalFromString, formatDecimal } from '../../../../common/decimal'; +import type { Money } from '../../../../common/money.types'; -export function parseRubMoney(money: MoneyDto, positive = false): bigint { +export function parseRubMoney(money: Money, positive = false): bigint { if ( money?.currency !== 'RUB' || typeof money.value !== 'string' || @@ -19,7 +19,7 @@ export function parseRubMoney(money: MoneyDto, positive = false): bigint { return kopecks; } -export function rubMoney(kopecks: bigint): MoneyDto { +export function rubMoney(kopecks: bigint): Money { return { currency: 'RUB', value: formatDecimal({ value: kopecks, scale: 2 }), diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts new file mode 100644 index 0000000..98ef256 --- /dev/null +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -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(run: (tx: Tx) => Promise): Promise { + 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, + }; + }); + } +} diff --git a/src/bank-cards/bank-card.dto.ts b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts similarity index 99% rename from src/bank-cards/bank-card.dto.ts rename to src/modules/banking/bank-cards/presentation/bank-card.dto.ts index 7aa77ad..5374707 100644 --- a/src/bank-cards/bank-card.dto.ts +++ b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts @@ -1,8 +1,8 @@ import { ApiProperty, ApiPropertyOptional, - OmitType, IntersectionType, + OmitType, PartialType, } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; @@ -22,7 +22,7 @@ import { ValidateIf, ValidateNested, } from 'class-validator'; -import { MoneyDto } from '../common/money.dto'; +import { MoneyDto } from '../../../../common/money.dto'; const trim = ({ value }: { value: unknown }) => typeof value === 'string' ? value.trim() : value; diff --git a/src/bank-cards/bank-cards.controller.ts b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts similarity index 74% rename from src/bank-cards/bank-cards.controller.ts rename to src/modules/banking/bank-cards/presentation/bank-cards.controller.ts index e601d27..91d6353 100644 --- a/src/bank-cards/bank-cards.controller.ts +++ b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts @@ -16,17 +16,16 @@ import { ApiCreatedResponse, ApiNoContentResponse, ApiOkResponse, - ApiTags, ApiResponse, + ApiTags, getSchemaPath, } from '@nestjs/swagger'; import type { Request } from 'express'; -import { AccessPayload } from '../auth/auth.types'; -import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { ApiProblemResponses, ProblemDetailsDto, -} from '../common/decorators/api-safe-error-response.decorator'; +} from '../../../../common/decorators/api-safe-error-response.decorator'; +import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; import { BankCardDto, BankCardsQueryDto, @@ -45,7 +44,10 @@ import { UpdateBankOperationDto, UpdateExpenseCategoryDto, } from './bank-card.dto'; -import { BankCardsService } from './bank-cards.service'; +import { BankCardsService } from '../application/bank-cards.service'; +import { BankOperationsService } from '../application/bank-operations.service'; +import { ExpenseCategoriesService } from '../application/expense-categories.service'; +import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case'; type AuthRequest = Request & { user: AccessPayload }; @ApiResponse({ status: 409, @@ -73,7 +75,12 @@ type AuthRequest = Request & { user: AccessPayload }; @UseGuards(JwtAuthGuard) @Controller('bank-cards') export class BankCardsController { - constructor(private readonly cards: BankCardsService) {} + constructor( + private readonly cards: BankCardsService, + private readonly operations: BankOperationsService, + private readonly expenseCategories: ExpenseCategoriesService, + private readonly bankSummary: GetBankSummaryUseCase, + ) {} @Get() @ApiOkResponse({ type: BankCardDto, isArray: true }) list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) { @@ -87,12 +94,12 @@ export class BankCardsController { @Get('categories') @ApiOkResponse({ type: ExpenseCategoryDto, isArray: true }) categories(@Req() req: AuthRequest) { - return this.cards.categories(req.user.sub); + return this.expenseCategories.categories(req.user.sub); } @Post('categories') @ApiCreatedResponse({ type: ExpenseCategoryDto }) createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) { - return this.cards.createCategory(req.user.sub, body.name); + return this.expenseCategories.createCategory(req.user.sub, body.name); } @Patch('categories/:id') @ApiOkResponse({ type: ExpenseCategoryDto }) @@ -101,12 +108,17 @@ export class BankCardsController { @Param('id') id: string, @Body() body: UpdateExpenseCategoryDto, ) { - return this.cards.updateCategory(req.user.sub, id, body.name, body.version); + return this.expenseCategories.updateCategory( + req.user.sub, + id, + body.name, + body.version, + ); } @Get('summary') @ApiOkResponse({ type: BankSummaryDto }) summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) { - return this.cards.summary(req.user.sub, query); + return this.bankSummary.execute(req.user.sub, query); } @Post('operations') @ApiCreatedResponse({ type: BankOperationDto }) @@ -114,12 +126,12 @@ export class BankCardsController { @Req() req: AuthRequest, @Body() body: CreateBankOperationDto, ) { - return this.cards.createOperation(req.user.sub, body); + return this.operations.createOperation(req.user.sub, body); } @Get('operations/:id') @ApiOkResponse({ type: BankOperationDto }) operation(@Req() req: AuthRequest, @Param('id') id: string) { - return this.cards.getOperation(req.user.sub, id); + return this.operations.getOperation(req.user.sub, id); } @Patch('operations/:id') @ApiOkResponse({ type: BankOperationDto }) @@ -128,7 +140,7 @@ export class BankCardsController { @Param('id') id: string, @Body() body: UpdateBankOperationDto, ) { - return this.cards.updateOperation(req.user.sub, id, body); + return this.operations.updateOperation(req.user.sub, id, body); } @Delete('operations/:id') @HttpCode(204) @@ -138,7 +150,7 @@ export class BankCardsController { @Param('id') id: string, @Query() query: BankVersionQueryDto, ) { - return this.cards.deleteOperation(req.user.sub, id, query.version); + return this.operations.deleteOperation(req.user.sub, id, query.version); } @Get(':id/operations') @ApiOkResponse({ type: BankOperationsPageDto }) @@ -147,7 +159,7 @@ export class BankCardsController { @Param('id') id: string, @Query() query: BankOperationsQueryDto, ) { - return this.cards.history(req.user.sub, id, query); + return this.operations.history(req.user.sub, id, query); } @Get(':id/expenses') @ApiOkResponse({ type: BankOperationsPageDto }) @@ -156,7 +168,7 @@ export class BankCardsController { @Param('id') id: string, @Query() query: BankPageQueryDto, ) { - return this.cards.history(req.user.sub, id, query, true); + return this.operations.history(req.user.sub, id, query, true); } @Get(':id') @ApiOkResponse({ type: BankCardDto }) diff --git a/src/tbank/account-reference.dto.ts b/src/modules/investments/account-reference.dto.ts similarity index 100% rename from src/tbank/account-reference.dto.ts rename to src/modules/investments/account-reference.dto.ts diff --git a/src/tbank/all-accounts.spec.ts b/src/modules/investments/all-accounts.spec.ts similarity index 96% rename from src/tbank/all-accounts.spec.ts rename to src/modules/investments/all-accounts.spec.ts index 10f554c..5a98e92 100644 --- a/src/tbank/all-accounts.spec.ts +++ b/src/modules/investments/all-accounts.spec.ts @@ -1,17 +1,19 @@ -import assert from 'node:assert/strict'; import { BadRequestException, ConflictException, ServiceUnavailableException, } from '@nestjs/common'; +import assert from 'node:assert/strict'; -jest.mock('./tbank-access.service', () => ({ TbankAccessService: class {} })); +jest.mock('../../integrations/tbank/tbank-access.service', () => ({ + TbankAccessService: class {}, +})); -import { BrokerageOperationsService } from './brokerage-operations.service'; -import { IncomeAnalyticsService } from './income-analytics.service'; -import { PortfolioService } from './portfolio.service'; -import { UpcomingEventsService } from './upcoming-events.service'; -import type { TbankConnectionContext } from './tbank-access.service'; +import type { TbankConnectionContext } from '../../integrations/tbank/tbank-access.service'; +import { IncomeAnalyticsService } from './income/income-analytics.service'; +import { BrokerageOperationsService } from './operations/application/brokerage-operations.service'; +import { PortfolioService } from './portfolio/application/portfolio.service'; +import { UpcomingEventsService } from './upcoming-events/upcoming-events.service'; function setup() { const context = { diff --git a/src/tbank/brokerage-account.dto.ts b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts similarity index 95% rename from src/tbank/brokerage-account.dto.ts rename to src/modules/investments/brokerage-accounts/brokerage-account.dto.ts index dfa4812..f7e1252 100644 --- a/src/tbank/brokerage-account.dto.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { MoneyDto } from '../common/money.dto'; +import { MoneyDto } from '../../../common/money.dto'; export class PortfolioSummaryDto { @ApiProperty({ type: MoneyDto, isArray: true }) diff --git a/src/tbank/brokerage-accounts.controller.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts similarity index 78% rename from src/tbank/brokerage-accounts.controller.ts rename to src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts index 60786bd..0207f51 100644 --- a/src/tbank/brokerage-accounts.controller.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts @@ -1,9 +1,8 @@ import { Controller, Get, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; -import { AccessPayload } from '../auth/auth.types'; -import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; +import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; +import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { BrokerageAccountDto } from './brokerage-account.dto'; import { BrokerageAccountsService } from './brokerage-accounts.service'; diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts new file mode 100644 index 0000000..e4016c0 --- /dev/null +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts @@ -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 {} diff --git a/src/tbank/brokerage-accounts.service.spec.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts similarity index 97% rename from src/tbank/brokerage-accounts.service.spec.ts rename to src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts index fae80d0..b754d65 100644 --- a/src/tbank/brokerage-accounts.service.spec.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts @@ -4,13 +4,13 @@ import { ServiceUnavailableException, } from '@nestjs/common'; -jest.mock('../prisma/prisma.service', () => ({ +jest.mock('../../../prisma/prisma.service', () => ({ PrismaService: class PrismaService {}, })); +import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service'; +import { TbankTokenService } from '../../../integrations/tbank/tbank-token.service'; import { BrokerageAccountsService } from './brokerage-accounts.service'; -import { TbankAccessService } from './tbank-access.service'; -import { TbankTokenService } from './tbank-token.service'; describe('BrokerageAccountsService', () => { const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; diff --git a/src/tbank/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts similarity index 87% rename from src/tbank/brokerage-accounts.service.ts rename to src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index 649c2e4..d76424f 100644 --- a/src/tbank/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -1,11 +1,12 @@ -import { Injectable } from '@nestjs/common'; -import { ServiceUnavailableException } from '@nestjs/common'; +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { isZero } from '../../../common/decimal'; +import { + TbankAccessService, + TbankClient, +} from '../../../integrations/tbank/tbank.api'; +import { decimal } from '../../../integrations/tbank/decimal'; +import { totalsByCurrency } from '../portfolio/portfolio.api'; import { BrokerageAccountDto } from './brokerage-account.dto'; -import { isZero } from '../common/decimal'; -import { decimal } from './decimal'; -import { totalsByCurrency } from './portfolio-totals'; -import { TbankAccessService } from './tbank-access.service'; -import { TbankClient } from './tbank.client'; @Injectable() export class BrokerageAccountsService { diff --git a/src/tbank/income-analytics.controller.ts b/src/modules/investments/income/income-analytics.controller.ts similarity index 89% rename from src/tbank/income-analytics.controller.ts rename to src/modules/investments/income/income-analytics.controller.ts index c67d1f5..41c7af2 100644 --- a/src/tbank/income-analytics.controller.ts +++ b/src/modules/investments/income/income-analytics.controller.ts @@ -1,9 +1,8 @@ import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; -import { AccessPayload } from '../auth/auth.types'; -import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; +import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; +import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { IncomeAnalyticsDto, IncomeAnalyticsQueryDto, diff --git a/src/tbank/income-analytics.dto.ts b/src/modules/investments/income/income-analytics.dto.ts similarity index 96% rename from src/tbank/income-analytics.dto.ts rename to src/modules/investments/income/income-analytics.dto.ts index 91057ae..23b95c1 100644 --- a/src/tbank/income-analytics.dto.ts +++ b/src/modules/investments/income/income-analytics.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsOptional, Matches } from 'class-validator'; -import { BrokerageOperationDto } from './brokerage-operation.dto'; -import { MoneyDto } from '../common/money.dto'; +import { MoneyDto } from '../../../common/money.dto'; +import { BrokerageOperationDto } from '../operations/operations.api'; const dateOnly = /^\d{4}-\d{2}-\d{2}$/; diff --git a/src/tbank/income-analytics.service.spec.ts b/src/modules/investments/income/income-analytics.service.spec.ts similarity index 97% rename from src/tbank/income-analytics.service.spec.ts rename to src/modules/investments/income/income-analytics.service.spec.ts index d5c4f4b..a0af087 100644 --- a/src/tbank/income-analytics.service.spec.ts +++ b/src/modules/investments/income/income-analytics.service.spec.ts @@ -1,8 +1,8 @@ -jest.mock('./brokerage-operations.service', () => ({ +jest.mock('../operations/application/brokerage-operations.service', () => ({ BrokerageOperationsService: class BrokerageOperationsService {}, })); -import { BrokerageOperationDto } from './brokerage-operation.dto'; +import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto'; import { IncomeAnalyticsService } from './income-analytics.service'; describe('IncomeAnalyticsService', () => { diff --git a/src/tbank/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts similarity index 97% rename from src/tbank/income-analytics.service.ts rename to src/modules/investments/income/income-analytics.service.ts index 4bd5c5b..c59f4d9 100644 --- a/src/tbank/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -1,21 +1,17 @@ import { Injectable } from '@nestjs/common'; -import { BrokerageOperationDto } from './brokerage-operation.dto'; -import { BrokerageOperationsService } from './brokerage-operations.service'; import { add, compare, decimalFromString, formatDecimal, subtract, -} from '../common/decimal'; - +} from '../../../common/decimal'; +import { DateOnlyRange, moscowDate } from '../investment-calendar'; import { - DateOnlyRange, - IncomeBucketGranularity, - moscowDate, - planIncomePeriod, - rangeBuckets, -} from './income-period'; + BrokerageOperationDto, + BrokerageOperationsService, +} from '../operations/operations.api'; + import { IncomeAnalyticsDto, IncomeAnalyticsEntryDto, @@ -24,6 +20,11 @@ import { IncomeInstrumentDto, IncomeSummaryDto, } from './income-analytics.dto'; +import { + IncomeBucketGranularity, + planIncomePeriod, + rangeBuckets, +} from './income-period'; type Total = { coupon: ReturnType; diff --git a/src/tbank/income-period.spec.ts b/src/modules/investments/income/income-period.spec.ts similarity index 94% rename from src/tbank/income-period.spec.ts rename to src/modules/investments/income/income-period.spec.ts index 9c995c2..61fd090 100644 --- a/src/tbank/income-period.spec.ts +++ b/src/modules/investments/income/income-period.spec.ts @@ -1,9 +1,6 @@ import { BadRequestException } from '@nestjs/common'; -import { - dateOnlyBounds, - planIncomePeriod, - rangeBuckets, -} from './income-period'; +import { dateOnlyBounds } from '../investment-calendar'; +import { planIncomePeriod, rangeBuckets } from './income-period'; describe('income period planning', () => { afterEach(() => jest.useRealTimers()); diff --git a/src/tbank/income-period.ts b/src/modules/investments/income/income-period.ts similarity index 73% rename from src/tbank/income-period.ts rename to src/modules/investments/income/income-period.ts index 96bce67..72b35bd 100644 --- a/src/tbank/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -1,10 +1,13 @@ import { BadRequestException } from '@nestjs/common'; +import { + DateOnlyRange, + dateParts, + moscowDate, + utcDate, +} from '../investment-calendar'; -export type DateOnlyRange = { from: string; to: string }; export type IncomeBucketGranularity = 'day' | 'week' | 'month'; -const moscow = 'Europe/Moscow'; - export function planIncomePeriod( from: string | undefined, to: string | undefined, @@ -41,27 +44,6 @@ export function planIncomePeriod( }; } -export function moscowDate(value: Date): string { - const parts = new Intl.DateTimeFormat('en-CA', { - timeZone: moscow, - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(value); - const get = (type: string) => parts.find((part) => part.type === type)?.value; - return `${get('year')}-${get('month')}-${get('day')}`; -} - -export function dateOnlyBounds(range: DateOnlyRange): { from: Date; to: Date } { - const from = dateParts(range.from); - const end = dateParts(range.to); - const to = new Date(Date.UTC(end[0], end[1] - 1, end[2], 20, 59, 59, 999)); - return { - from: new Date(Date.UTC(from[0], from[1] - 1, from[2], -3)), - to: to > new Date() ? new Date() : to, - }; -} - export function rangeBuckets( range: DateOnlyRange, granularity: IncomeBucketGranularity, @@ -104,27 +86,6 @@ function assertRange(range: DateOnlyRange, today: string): void { if (range.from > range.to || range.to > today) throw invalidPeriod(); } -function dateParts(value: string): [number, number, number] { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); - if (!match) throw new Error('Invalid date'); - const parts = [Number(match[1]), Number(match[2]), Number(match[3])] as [ - number, - number, - number, - ]; - if (utcDate(value).toISOString().slice(0, 10) !== value) - throw new Error('Invalid date'); - return parts; -} - -function utcDate(value: string): Date { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); - if (!match) return new Date(NaN); - return new Date( - Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])), - ); -} - function addDays(value: string, days: number): string { const date = utcDate(value); date.setUTCDate(date.getUTCDate() + days); diff --git a/src/modules/investments/income/income.module.ts b/src/modules/investments/income/income.module.ts new file mode 100644 index 0000000..fbe6d07 --- /dev/null +++ b/src/modules/investments/income/income.module.ts @@ -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 {} diff --git a/src/tbank/instrument-market.ts b/src/modules/investments/instruments/application/instrument-market.ts similarity index 91% rename from src/tbank/instrument-market.ts rename to src/modules/investments/instruments/application/instrument-market.ts index 0c11302..8bdf05b 100644 --- a/src/tbank/instrument-market.ts +++ b/src/modules/investments/instruments/application/instrument-market.ts @@ -1,6 +1,8 @@ import { ServiceUnavailableException } from '@nestjs/common'; -import type { TbankTimestamp } from './tbank.client'; -import type { TbankCandle } from './tbank-market.types'; +import type { + TbankCandle, + TbankTimestamp, +} from '../../../../integrations/tbank/tbank.api'; export function timestamp(value?: TbankTimestamp): string | null { const seconds = Number(value?.seconds); return Number.isFinite(seconds) && seconds > 0 diff --git a/src/tbank/instrument-position.ts b/src/modules/investments/instruments/application/instrument-position.ts similarity index 91% rename from src/tbank/instrument-position.ts rename to src/modules/investments/instruments/application/instrument-position.ts index 99735d1..c6479dd 100644 --- a/src/tbank/instrument-position.ts +++ b/src/modules/investments/instruments/application/instrument-position.ts @@ -4,11 +4,14 @@ import { divide, formatDecimal, multiply, -} from '../common/decimal'; +} from '../../../../common/decimal'; -import { MoneyDto } from '../common/money.dto'; -import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; -import { StockPositionDto } from './stock.dto'; +import { MoneyDto } from '../../../../common/money.dto'; +import { + PortfolioDto, + PortfolioPositionDto, +} from '../../portfolio/portfolio.api'; +import { StockPositionDto } from '../stocks/presentation/stock.dto'; export function instrumentPosition( portfolio: PortfolioDto, diff --git a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts new file mode 100644 index 0000000..a69d169 --- /dev/null +++ b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts @@ -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>({ + max: 300, + ttl: 30_000, + }); + constructor( + private readonly access: TbankAccessService, + private readonly tbank: TbankClient, + ) {} + search(userId: string, query: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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( + userId: string, + key: string, + operation: (connection: TbankConnectionContext) => Promise, + ): Promise { + return this.access.withConnection(userId, (connection) => + this.cachedForConnection(userId, connection, key, () => + operation(connection), + ), + ); + } + cachedForConnection( + userId: string, + { generation }: TbankConnectionContext, + key: string, + operation: () => Promise, + ): Promise { + const cacheKey = JSON.stringify([userId, generation, key]); + const cached = this.cache.get(cacheKey); + if (cached) return cached as Promise; + 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(); + 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, + }; +} diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts new file mode 100644 index 0000000..5033a14 --- /dev/null +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -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 { + 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 { + 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 { + return this.catalog.cached(userId, `position:${uid}`, async () => + instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'), + ); + } +} diff --git a/src/modules/investments/instruments/bonds/application/bond-market.service.ts b/src/modules/investments/instruments/bonds/application/bond-market.service.ts new file mode 100644 index 0000000..30f8abd --- /dev/null +++ b/src/modules/investments/instruments/bonds/application/bond-market.service.ts @@ -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 { + 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 { + 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(); + // 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(), + }; + }, + ); + } +} diff --git a/src/tbank/bond.service.spec.ts b/src/modules/investments/instruments/bonds/application/bond.spec.ts similarity index 83% rename from src/tbank/bond.service.spec.ts rename to src/modules/investments/instruments/bonds/application/bond.spec.ts index 68d4b26..5b6522a 100644 --- a/src/tbank/bond.service.spec.ts +++ b/src/modules/investments/instruments/bonds/application/bond.spec.ts @@ -1,19 +1,21 @@ -import assert from 'node:assert/strict'; -jest.mock('./tbank-access.service', () => ({ - TbankAccessService: class TbankAccessService {}, -})); import { ConflictException, NotFoundException, ServiceUnavailableException, UnauthorizedException, } from '@nestjs/common'; -import { BondService } from './bond.service'; -import { TbankAccessService } from './tbank-access.service'; -import { TbankClient } from './tbank.client'; -import { NcrRatingsClient } from './ncr-ratings.client'; -import { MoexClient } from './moex.client'; -import { PortfolioService } from './portfolio.service'; +import assert from 'node:assert/strict'; +import { MoexClient } from '../../../../../integrations/moex/moex.client'; +import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr-ratings.client'; +import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service'; +import { TbankClient } from '../../../../../integrations/tbank/tbank.client'; +import { PortfolioService } from '../../../portfolio/application/portfolio.service'; +import { BondIncomeService } from './bond-income.service'; +import { BondMarketService } from './bond-market.service'; +import { BondCatalogService } from './bond-catalog.service'; +jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({ + TbankAccessService: class TbankAccessService {}, +})); const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) }); const money = (units: string, currency = 'rub') => ({ units, currency }); const cacheClock = performance; @@ -63,14 +65,25 @@ function setup() { }; const ratings = { rating: jest.fn().mockResolvedValue(null) }; const moex = { bondYield: jest.fn().mockResolvedValue(null) }; - const service = new BondService( + const service = new BondCatalogService( access as unknown as TbankAccessService, tbank as unknown as TbankClient, + ); + const market = new BondMarketService( + service, + tbank as unknown as TbankClient, + ); + const income = new BondIncomeService( + service, + tbank as unknown as TbankClient, portfolios as unknown as PortfolioService, + market, moex as unknown as MoexClient, ratings as unknown as NcrRatingsClient, ); return { + market, + income, service, ratings, moex, @@ -92,7 +105,7 @@ describe('bond market use case', () => { jest.restoreAllMocks(); }); it('shows the next coupon and its annualized current yield without extra T-Bank calls', async () => { - const { service, tbank, moex } = setup(); + const { tbank, moex, market, income } = setup(); tbank.getBond.mockResolvedValue({ ...bond, classCode: 'TQOB' }); tbank.getBondCoupons.mockResolvedValue([ { @@ -122,9 +135,9 @@ describe('bond market use case', () => { }; moex.bondYield.mockResolvedValue(effectiveYield); const [metrics] = await Promise.all([ - service.metrics('user', 'uid'), - service.coupons('user', 'uid'), - service.quote('user', 'uid'), + income.metrics('user', 'uid'), + income.coupons('user', 'uid'), + market.quote('user', 'uid'), ]); expect(metrics).toMatchObject({ coupon: { value: '30', currency: 'RUB' }, @@ -142,7 +155,7 @@ describe('bond market use case', () => { it.each(['unknown', 'currency', 'period', 'zero-price'])( 'does not invent coupon yield with %s inputs', async (scenario) => { - const { service, tbank } = setup(); + const { tbank, income } = setup(); tbank.getBondCoupons.mockResolvedValue([ { couponDate: time('2026-10-10'), @@ -160,14 +173,14 @@ describe('bond market use case', () => { ]); if (scenario === 'zero-price') tbank.getLastPrice.mockResolvedValue({ price: { units: '0' } }); - expect(await service.metrics('user', 'uid')).toMatchObject({ + expect(await income.metrics('user', 'uid')).toMatchObject({ couponDate: '2026-10-10', couponYieldPercent: null, }); }, ); it('keeps coupons when MOEX fails and never substitutes a risk level for a credit rating', async () => { - const { service, tbank, moex } = setup(); + const { tbank, moex, income } = setup(); tbank.getBond.mockResolvedValue({ ...bond, classCode: 'TQOB', @@ -175,7 +188,7 @@ describe('bond market use case', () => { riskLevel: 'RISK_LEVEL_LOW', }); moex.bondYield.mockRejectedValue(new ServiceUnavailableException()); - expect(await service.metrics('user', 'uid')).toMatchObject({ + expect(await income.metrics('user', 'uid')).toMatchObject({ effectiveYield: null, coupon: null, couponType: 'COUPON_TYPE_FLOATING', @@ -183,12 +196,12 @@ describe('bond market use case', () => { unavailable: ['yield'], }); tbank.getBondCoupons.mockRejectedValue(new UnauthorizedException()); - await expect(service.metrics('other', 'uid')).rejects.toBeInstanceOf( + await expect(income.metrics('other', 'uid')).rejects.toBeInstanceOf( UnauthorizedException, ); }); it('returns only the rating of the exact issue and isolates a registry outage', async () => { - const { service, ratings } = setup(); + const { ratings, income } = setup(); const creditRating = { agency: 'NCR', value: 'AAA.ru', @@ -198,18 +211,18 @@ describe('bond market use case', () => { observedAt: '2026-09-10T10:00:00Z', }; ratings.rating.mockResolvedValue(creditRating); - expect(await service.metrics('user', 'uid')).toMatchObject({ + expect(await income.metrics('user', 'uid')).toMatchObject({ creditRating, }); expect(ratings.rating).toHaveBeenCalledWith('ISIN'); ratings.rating.mockRejectedValue(new ServiceUnavailableException()); - expect(await service.metrics('other', 'uid')).toMatchObject({ + expect(await income.metrics('other', 'uid')).toMatchObject({ creditRating: null, unavailable: ['rating'], }); }); it('opens concurrent sections within one upstream metadata request budget', async () => { - const { service, tbank } = setup(); + const { service, tbank, market, income } = setup(); tbank.getBond .mockReset() .mockResolvedValueOnce(bond) @@ -219,17 +232,17 @@ describe('bond market use case', () => { await expect( Promise.all([ service.getByTicker('user', 'BOND'), - service.quote('user', 'uid'), - service.candles('user', 'uid'), - service.coupons('user', 'uid'), - service.metrics('user', 'uid'), + market.quote('user', 'uid'), + market.candles('user', 'uid'), + income.coupons('user', 'uid'), + income.metrics('user', 'uid'), ]), ).resolves.toHaveLength(5); expect(tbank.getBond).toHaveBeenCalledTimes(1); expect(tbank.findBonds).toHaveBeenCalledTimes(1); }); it('searches thirty issues without a separate ticker lookup for each result', async () => { - const { service, tbank } = setup(); + const { service, tbank, market, income } = setup(); const issues = Array.from({ length: 30 }, (_, index) => ({ ...bond, uid: `uid-${index}`, @@ -246,15 +259,15 @@ describe('bond market use case', () => { expect(tbank.getLastPrices).toHaveBeenCalledTimes(1); await service.getByTicker('user', 'BOND-0'); await Promise.all([ - service.quote('user', 'uid-0'), - service.candles('user', 'uid-0'), - service.coupons('user', 'uid-0'), + market.quote('user', 'uid-0'), + market.candles('user', 'uid-0'), + income.coupons('user', 'uid-0'), ]); expect(tbank.getBond).toHaveBeenCalledTimes(30); expect(tbank.findBonds).toHaveBeenCalledTimes(2); }); it('refreshes current nominal and accrued interest after the metadata TTL', async () => { - const { service, tbank } = setup(); + const { service, tbank, market } = setup(); await service.get('user', 'uid'); await jest.advanceTimersByTimeAsync(30_001); tbank.getBond.mockResolvedValue({ @@ -262,37 +275,37 @@ describe('bond market use case', () => { nominal: money('400'), aciValue: money('3'), }); - expect(await service.quote('user', 'uid')).toMatchObject({ + expect(await market.quote('user', 'uid')).toMatchObject({ cleanPrice: { value: '392.5' }, dirtyPrice: { value: '395.5' }, }); expect(tbank.getBond).toHaveBeenCalledTimes(2); }); it('keeps late metadata responses within their original connection generation', async () => { - const { service, tbank, changeGeneration } = setup(); + const { tbank, changeGeneration, market } = setup(); let resolveOld: ((value: typeof bond) => void) | undefined; tbank.getBond.mockReturnValueOnce( new Promise((resolve) => { resolveOld = resolve; }), ); - const oldQuote = service.quote('user', 'uid'); + const oldQuote = market.quote('user', 'uid'); changeGeneration(); tbank.getBond.mockResolvedValue({ ...bond, nominal: money('400') }); - expect(await service.quote('user', 'uid')).toMatchObject({ + expect(await market.quote('user', 'uid')).toMatchObject({ cleanPrice: { value: '392.5' }, }); assert.ok(resolveOld); resolveOld(bond); await oldQuote; - expect(await service.quote('user', 'uid')).toMatchObject({ + expect(await market.quote('user', 'uid')).toMatchObject({ cleanPrice: { value: '392.5' }, }); expect(tbank.getBond).toHaveBeenCalledTimes(2); }); it('keeps percent quotes separate from money and uses current amortized nominal', async () => { - const { service } = setup(); - expect(await service.quote('user', 'uid')).toMatchObject({ + const { market } = setup(); + expect(await market.quote('user', 'uid')).toMatchObject({ pricePercent: '98.125', cleanPrice: { value: '490.625', currency: 'RUB' }, aci: { value: '12', currency: 'RUB' }, @@ -302,9 +315,9 @@ describe('bond market use case', () => { }); }); it('does not invent dirty prices or mix currencies when accrued interest is absent or mismatched', async () => { - const { service, tbank } = setup(); + const { tbank, market } = setup(); tbank.getBond.mockResolvedValueOnce({ ...bond, aciValue: undefined }); - expect(await service.quote('user', 'uid')).toMatchObject({ + expect(await market.quote('user', 'uid')).toMatchObject({ aci: null, dirtyPrice: null, }); @@ -312,11 +325,11 @@ describe('bond market use case', () => { ...bond, aciValue: money('12', 'usd'), }); - expect(await service.quote('other', 'uid')).toMatchObject({ + expect(await market.quote('other', 'uid')).toMatchObject({ dirtyPrice: null, }); tbank.getBond.mockResolvedValueOnce({ ...bond, nominal: undefined }); - expect(await service.quote('third', 'uid')).toMatchObject({ + expect(await market.quote('third', 'uid')).toMatchObject({ pricePercent: '98.125', cleanPrice: null, dirtyPrice: null, @@ -383,8 +396,8 @@ describe('bond market use case', () => { ); }); it('keeps daily candles in percent, deduplicates boundaries and requires an all-history start', async () => { - const { service, tbank } = setup(); - const history = await service.candles('user', 'uid', 'all'); + const { tbank, market } = setup(); + const history = await market.candles('user', 'uid', 'all'); expect(history.currency).toBeNull(); expect(history.candles.map((c) => c.close)).toEqual(['99', '100']); for (const [, , from, to] of tbank.getCandles.mock.calls as [ @@ -398,7 +411,7 @@ describe('bond market use case', () => { ...bond, first_1dayCandleDate: undefined, }); - await expect(service.candles('other', 'uid', 'all')).rejects.toBeInstanceOf( + await expect(market.candles('other', 'uid', 'all')).rejects.toBeInstanceOf( ServiceUnavailableException, ); }); @@ -419,7 +432,7 @@ describe('bond market use case', () => { }); }); it('estimates only future unfixed coupons from positive exact-UID bond holdings', async () => { - const { service, tbank, portfolios } = setup(); + const { tbank, portfolios, income } = setup(); const holding = { instrumentUid: 'uid', type: 'BOND', @@ -466,7 +479,7 @@ describe('bond market use case', () => { payOneBond: money('10'), }, ]); - const result = await service.coupons('user', 'uid'); + const result = await income.coupons('user', 'uid'); expect( result.coupons.find((c) => c.number === '1')?.estimatedAmount, ).toEqual({ value: '20', currency: 'RUB' }); @@ -477,7 +490,7 @@ describe('bond market use case', () => { ).toBe(true); expect(result.coupons.find((c) => c.number === '4')?.perBond).toBeNull(); expect(result.positionIsStale).toBe(true); - expect(await service.position('user', 'uid')).toMatchObject({ + expect(await income.position('user', 'uid')).toMatchObject({ quantity: '2', currentValue: { value: '981.25' }, }); diff --git a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts new file mode 100644 index 0000000..a57dead --- /dev/null +++ b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts @@ -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; +} diff --git a/src/tbank/bond.controller.ts b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts similarity index 73% rename from src/tbank/bond.controller.ts rename to src/modules/investments/instruments/bonds/presentation/bond.controller.ts index 57c0363..cb6f3bd 100644 --- a/src/tbank/bond.controller.ts +++ b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts @@ -9,22 +9,23 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; -import { AccessPayload } from '../auth/auth.types'; -import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; -import { BondService } from './bond.service'; +import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator'; +import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api'; +import { BondIncomeService } from '../application/bond-income.service'; +import { BondMarketService } from '../application/bond-market.service'; import { BondCandlesDto, - BondMetricsDto, BondCandlesQueryDto, BondCouponsDto, BondDto, - BondTickerParamsDto, + BondMetricsDto, BondPositionDto, BondQuoteDto, BondSearchQueryDto, BondSearchResultDto, + BondTickerParamsDto, } from './bond.dto'; +import { BondCatalogService } from '../application/bond-catalog.service'; @ApiTags('bonds') @ApiProblemResponses() @@ -32,14 +33,18 @@ import { @UseGuards(JwtAuthGuard) @Controller('bonds') export class BondController { - constructor(private readonly bonds: BondService) {} + constructor( + private readonly bonds: BondCatalogService, + private readonly market: BondMarketService, + private readonly income: BondIncomeService, + ) {} @Get(':uid/metrics') @ApiOkResponse({ type: BondMetricsDto }) metrics( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, ) { - return this.bonds.metrics(request.user.sub, uid); + return this.income.metrics(request.user.sub, uid); } @Get() @ApiOkResponse({ type: [BondSearchResultDto] }) @@ -71,7 +76,7 @@ export class BondController { @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, ) { - return this.bonds.quote(request.user.sub, uid); + return this.market.quote(request.user.sub, uid); } @Get(':uid/candles') @ApiOkResponse({ type: BondCandlesDto }) @@ -80,7 +85,7 @@ export class BondController { @Param('uid', new ParseUUIDPipe()) uid: string, @Query() query: BondCandlesQueryDto, ) { - return this.bonds.candles(request.user.sub, uid, query.period); + return this.market.candles(request.user.sub, uid, query.period); } @Get(':uid/position') @ApiOkResponse({ type: BondPositionDto }) @@ -88,7 +93,7 @@ export class BondController { @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, ) { - return this.bonds.position(request.user.sub, uid); + return this.income.position(request.user.sub, uid); } @Get(':uid/coupons') @ApiOkResponse({ type: BondCouponsDto }) @@ -96,6 +101,6 @@ export class BondController { @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, ) { - return this.bonds.coupons(request.user.sub, uid); + return this.income.coupons(request.user.sub, uid); } } diff --git a/src/tbank/bond.dto.ts b/src/modules/investments/instruments/bonds/presentation/bond.dto.ts similarity index 98% rename from src/tbank/bond.dto.ts rename to src/modules/investments/instruments/bonds/presentation/bond.dto.ts index 4f8c4b6..3372402 100644 --- a/src/tbank/bond.dto.ts +++ b/src/modules/investments/instruments/bonds/presentation/bond.dto.ts @@ -1,13 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; -import { MoneyDto } from '../common/money.dto'; +import { MoneyDto } from '../../../../../common/money.dto'; import { + StockCandlesDto, + StockCandlesQueryDto, + StockPositionDto, StockSearchQueryDto, StockTickerParamsDto, - StockCandlesQueryDto, - StockCandlesDto, - StockPositionDto, -} from './stock.dto'; +} from '../../stocks/presentation/stock.dto'; export class BondSearchQueryDto extends StockSearchQueryDto {} export class BondTickerParamsDto extends StockTickerParamsDto {} export class BondCandlesQueryDto extends StockCandlesQueryDto {} diff --git a/src/modules/investments/instruments/instruments.module.ts b/src/modules/investments/instruments/instruments.module.ts new file mode 100644 index 0000000..e930cae --- /dev/null +++ b/src/modules/investments/instruments/instruments.module.ts @@ -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 {} diff --git a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts new file mode 100644 index 0000000..5e2ff33 --- /dev/null +++ b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts @@ -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 { + 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, + }, + ] + : []; + }); +} diff --git a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts new file mode 100644 index 0000000..0455a76 --- /dev/null +++ b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts @@ -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>({ + max: 300, + ttl: 30_000, + }); + constructor( + private readonly access: TbankAccessService, + private readonly tbank: TbankClient, + ) {} + search(userId: string, query: string): Promise { + 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 { + 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 { + 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 { + 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 { + const share = await this.tbank.getShare(token, uid); + if (!share || share.uid !== uid) throw new NotFoundException(); + return { ...share, uid }; + } + cached( + userId: string, + key: string, + operation: (token: string) => Promise, + ): Promise { + 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; + 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(); + 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), + }; +} diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts new file mode 100644 index 0000000..8e65d93 --- /dev/null +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -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 { + return this.catalog.cached(userId, `position:${uid}`, async () => + stockPosition(await this.portfolios.getAll(userId), uid), + ); + } + dividends(userId: string, uid: string): Promise { + 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, + }; + }); + } +} diff --git a/src/modules/investments/instruments/stocks/application/stock-market.service.ts b/src/modules/investments/instruments/stocks/application/stock-market.service.ts new file mode 100644 index 0000000..4dc80c9 --- /dev/null +++ b/src/modules/investments/instruments/stocks/application/stock-market.service.ts @@ -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 { + 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 { + 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(); + // 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(), + }; + }, + ); + } +} diff --git a/src/tbank/stock-position.spec.ts b/src/modules/investments/instruments/stocks/application/stock-position.spec.ts similarity index 96% rename from src/tbank/stock-position.spec.ts rename to src/modules/investments/instruments/stocks/application/stock-position.spec.ts index 7097d20..4ecb8a5 100644 --- a/src/tbank/stock-position.spec.ts +++ b/src/modules/investments/instruments/stocks/application/stock-position.spec.ts @@ -1,5 +1,8 @@ +import { + PortfolioDto, + PortfolioPositionDto, +} from '../../../portfolio/presentation/portfolio.dto'; import { stockPosition } from './stock-position'; -import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; const money = (value: string, currency = 'RUB') => ({ value, currency }); const row = ( diff --git a/src/modules/investments/instruments/stocks/application/stock-position.ts b/src/modules/investments/instruments/stocks/application/stock-position.ts new file mode 100644 index 0000000..37402d4 --- /dev/null +++ b/src/modules/investments/instruments/stocks/application/stock-position.ts @@ -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'); +} diff --git a/src/tbank/stock.service.spec.ts b/src/modules/investments/instruments/stocks/application/stock.spec.ts similarity index 89% rename from src/tbank/stock.service.spec.ts rename to src/modules/investments/instruments/stocks/application/stock.spec.ts index cb5a685..9b3035a 100644 --- a/src/tbank/stock.service.spec.ts +++ b/src/modules/investments/instruments/stocks/application/stock.spec.ts @@ -1,16 +1,20 @@ -jest.mock('./tbank-access.service', () => ({ - TbankAccessService: class TbankAccessService {}, -})); import { - ServiceUnavailableException, ConflictException, NotFoundException, + ServiceUnavailableException, UnauthorizedException, } from '@nestjs/common'; -import { StockService, previousClose } from './stock.service'; -import { TbankAccessService } from './tbank-access.service'; -import { TbankClient } from './tbank.client'; -import { PortfolioService } from './portfolio.service'; +import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service'; +import { TbankClient } from '../../../../../integrations/tbank/tbank.client'; +import { PortfolioService } from '../../../portfolio/application/portfolio.service'; +import { GetStockCompanyUseCase } from './get-stock-company.use-case'; +import { StockIncomeService } from './stock-income.service'; +import { StockMarketService } from './stock-market.service'; +import { StockCatalogService } from './stock-catalog.service'; +import { previousClose } from '../../application/instrument-market'; +jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({ + TbankAccessService: class TbankAccessService {}, +})); const time = (date: string) => ({ seconds: String(Date.parse(date) / 1000) }); const share = { uid: 'uid', @@ -48,12 +52,27 @@ function setup() { isStale: false, }), }; - const service = new StockService( + const service = new StockCatalogService( access as unknown as TbankAccessService, tbank as unknown as TbankClient, + ); + const market = new StockMarketService( + service, + tbank as unknown as TbankClient, + ); + const income = new StockIncomeService( + service, + tbank as unknown as TbankClient, portfolios as unknown as PortfolioService, ); + const companyInfo = new GetStockCompanyUseCase( + service, + tbank as unknown as TbankClient, + ); return { + market, + income, + companyInfo, service, tbank, portfolios, @@ -298,11 +317,11 @@ describe('stock market use case', () => { expect(previousClose(candles, '2026-09-09T22:00:00Z')).toEqual(candles[0]); }); it('starts all-history at listing, keeps incomplete candles and deduplicates boundaries', async () => { - const { service, tbank } = setup(); + const { tbank, market } = setup(); tbank.getCandles.mockResolvedValue([ { time: time('2026-09-09'), close: { units: '10' }, isComplete: false }, ]); - const result = await service.candles('user', 'uid', 'all'); + const result = await market.candles('user', 'uid', 'all'); expect(tbank.getCandles).toHaveBeenCalledWith( 'user', 'uid', @@ -313,11 +332,11 @@ describe('stock market use case', () => { expect(result.candles[0].isComplete).toBe(false); }); it('does not manufacture zero fundamentals and isolates optional failures', async () => { - const { service, tbank } = setup(); + const { tbank, companyInfo } = setup(); tbank.getAssetDescription.mockRejectedValue( new ServiceUnavailableException(), ); - const result = await service.company('user', 'uid'); + const result = await companyInfo.execute('user', 'uid'); expect(result.description).toBeNull(); expect(result.unavailable).toEqual(['description']); expect(result.metrics.map((item) => item.key)).toEqual([ @@ -336,7 +355,7 @@ describe('stock market use case', () => { expect(tbank.getShare).toHaveBeenCalledTimes(3); }); it('keeps public dividends when there is no holding or position retrieval fails', async () => { - const { service, tbank, portfolios } = setup(); + const { tbank, portfolios, income } = setup(); portfolios.getAll.mockRejectedValue(new ServiceUnavailableException()); tbank.getDividends.mockResolvedValue([ { @@ -345,7 +364,7 @@ describe('stock market use case', () => { paymentDate: time('2026-10-27'), }, ]); - const result = await service.dividends('user', 'uid'); + const result = await income.dividends('user', 'uid'); expect(result.estimateUnavailable).toBe(true); expect(result.dividends[0]).toMatchObject({ perShare: { currency: 'RUB', value: '32.88' }, diff --git a/src/tbank/stock.controller.ts b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts similarity index 69% rename from src/tbank/stock.controller.ts rename to src/modules/investments/instruments/stocks/presentation/stock.controller.ts index e71af2b..766249d 100644 --- a/src/tbank/stock.controller.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts @@ -9,22 +9,24 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; -import { AccessPayload } from '../auth/auth.types'; -import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; -import { StockService } from './stock.service'; +import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator'; +import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api'; +import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case'; +import { StockIncomeService } from '../application/stock-income.service'; +import { StockMarketService } from '../application/stock-market.service'; import { StockCandlesDto, StockCandlesQueryDto, StockCompanyDto, StockDividendsDto, StockDto, - StockTickerParamsDto, StockPositionDto, StockQuoteDto, StockSearchQueryDto, StockSearchResultDto, + StockTickerParamsDto, } from './stock.dto'; +import { StockCatalogService } from '../application/stock-catalog.service'; @ApiTags('stocks') @ApiProblemResponses() @@ -32,7 +34,12 @@ import { @UseGuards(JwtAuthGuard) @Controller('stocks') export class StockController { - constructor(private readonly stocks: StockService) {} + constructor( + private readonly stocks: StockCatalogService, + private readonly market: StockMarketService, + private readonly income: StockIncomeService, + private readonly companyInfo: GetStockCompanyUseCase, + ) {} @Get() @ApiOkResponse({ type: [StockSearchResultDto] }) search( @@ -63,7 +70,7 @@ export class StockController { @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, ) { - return this.stocks.quote(request.user.sub, uid); + return this.market.quote(request.user.sub, uid); } @Get(':uid/candles') @ApiOkResponse({ type: StockCandlesDto }) @@ -72,7 +79,7 @@ export class StockController { @Param('uid', new ParseUUIDPipe()) uid: string, @Query() query: StockCandlesQueryDto, ) { - return this.stocks.candles(request.user.sub, uid, query.period); + return this.market.candles(request.user.sub, uid, query.period); } @Get(':uid/position') @ApiOkResponse({ type: StockPositionDto }) @@ -80,7 +87,7 @@ export class StockController { @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, ) { - return this.stocks.position(request.user.sub, uid); + return this.income.position(request.user.sub, uid); } @Get(':uid/company') @ApiOkResponse({ type: StockCompanyDto }) @@ -88,7 +95,7 @@ export class StockController { @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, ) { - return this.stocks.company(request.user.sub, uid); + return this.companyInfo.execute(request.user.sub, uid); } @Get(':uid/dividends') @ApiOkResponse({ type: StockDividendsDto }) @@ -96,6 +103,6 @@ export class StockController { @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, ) { - return this.stocks.dividends(request.user.sub, uid); + return this.income.dividends(request.user.sub, uid); } } diff --git a/src/tbank/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts similarity index 98% rename from src/tbank/stock.dto.ts rename to src/modules/investments/instruments/stocks/presentation/stock.dto.ts index 8c17353..4683041 100644 --- a/src/tbank/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -4,12 +4,12 @@ import { IsIn, IsOptional, IsString, - MaxLength, Matches, + MaxLength, MinLength, } from 'class-validator'; -import { MoneyDto } from '../common/money.dto'; -import { PortfolioPositionDto } from './portfolio.dto'; +import { MoneyDto } from '../../../../../common/money.dto'; +import { PortfolioPositionDto } from '../../../portfolio/portfolio.api'; export class StockSearchQueryDto { @ApiProperty({ minLength: 2, maxLength: 80 }) diff --git a/src/modules/investments/investment-calendar.ts b/src/modules/investments/investment-calendar.ts new file mode 100644 index 0000000..e7ff410 --- /dev/null +++ b/src/modules/investments/investment-calendar.ts @@ -0,0 +1,43 @@ +export type DateOnlyRange = { from: string; to: string }; +const moscow = 'Europe/Moscow'; +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 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; +} + +export 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])), + ); +} diff --git a/src/tbank/brokerage-operations.service.spec.ts b/src/modules/investments/operations/application/brokerage-operations.service.spec.ts similarity index 98% rename from src/tbank/brokerage-operations.service.spec.ts rename to src/modules/investments/operations/application/brokerage-operations.service.spec.ts index 6ef7415..9328fc3 100644 --- a/src/tbank/brokerage-operations.service.spec.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.spec.ts @@ -5,12 +5,12 @@ import { ServiceUnavailableException, } from '@nestjs/common'; -jest.mock('./tbank-access.service', () => ({ +jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({ TbankAccessService: class TbankAccessService {}, })); +import type { TbankAccountContext } from '../../../../integrations/tbank/tbank-access.service'; import { BrokerageOperationsService } from './brokerage-operations.service'; -import type { TbankAccountContext } from './tbank-access.service'; describe('BrokerageOperationsService', () => { const access = { withAccount: jest.fn() }; diff --git a/src/tbank/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts similarity index 96% rename from src/tbank/brokerage-operations.service.ts rename to src/modules/investments/operations/application/brokerage-operations.service.ts index 9157b70..73f0ce7 100644 --- a/src/tbank/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -3,23 +3,25 @@ import { Injectable, ServiceUnavailableException, } from '@nestjs/common'; -import { randomUUID } from 'node:crypto'; -import { AccountReferenceDto } from './account-reference.dto'; -import { BrokerageOperationDto } from './brokerage-operation.dto'; import { LRUCache } from 'lru-cache'; -import { RequestContextService } from '../common/logging/request-context.service'; +import { randomUUID } from 'node:crypto'; +import { RequestContextService } from '../../../../common/logging/request-context.service'; import { + InstrumentMetadataService, + TbankAccessService, + TbankClient, +} from '../../../../integrations/tbank/tbank.api'; +import { AccountReferenceDto } from '../../account-reference.dto'; +import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar'; +import { + BrokerageOperationDto, BrokerageOperationsPageDto, BrokerageOperationsQueryDto, -} from './brokerage-operation.dto'; +} from '../presentation/brokerage-operation.dto'; import { mapBrokerageOperation, operationCategory, -} from './brokerage-operation.mapper'; -import { InstrumentMetadataService } from './instrument-metadata.service'; -import { TbankAccessService } from './tbank-access.service'; -import { TbankClient } from './tbank.client'; -import { DateOnlyRange, dateOnlyBounds } from './income-period'; +} from '../infrastructure/brokerage-operation.mapper'; type OperationStream = { account: AccountReferenceDto; diff --git a/src/tbank/brokerage-operation.mapper.spec.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts similarity index 100% rename from src/tbank/brokerage-operation.mapper.spec.ts rename to src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts diff --git a/src/tbank/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts similarity index 90% rename from src/tbank/brokerage-operation.mapper.ts rename to src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 1fc1ae9..d2f9911 100644 --- a/src/tbank/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -1,10 +1,13 @@ -import { formatDecimal } from '../common/decimal'; -import { decimal } from './decimal'; +import { formatDecimal } from '../../../../common/decimal'; +import { + TbankMoney, + TbankOperationItem, +} from '../../../../integrations/tbank/tbank.api'; +import { decimal } from '../../../../integrations/tbank/decimal'; import { BrokerageOperationDto, OperationCategory, -} from './brokerage-operation.dto'; -import { TbankMoney, TbankOperationItem } from './tbank.client'; +} from '../presentation/brokerage-operation.dto'; const types: Record = { OPERATION_TYPE_BUY: 'trade', diff --git a/src/modules/investments/operations/operations.api.ts b/src/modules/investments/operations/operations.api.ts new file mode 100644 index 0000000..884739e --- /dev/null +++ b/src/modules/investments/operations/operations.api.ts @@ -0,0 +1,3 @@ +// Public cross-module surface. Nest providers are exported by the matching module. +export { BrokerageOperationDto } from './presentation/brokerage-operation.dto'; +export { BrokerageOperationsService } from './application/brokerage-operations.service'; diff --git a/src/modules/investments/operations/operations.module.ts b/src/modules/investments/operations/operations.module.ts new file mode 100644 index 0000000..4704655 --- /dev/null +++ b/src/modules/investments/operations/operations.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TbankModule } from '../../../integrations/tbank/tbank.module'; +import { AuthModule } from '../../auth/auth.module'; +import { BrokerageOperationsController } from './presentation/brokerage-operations.controller'; +import { BrokerageOperationsService } from './application/brokerage-operations.service'; + +@Module({ + imports: [AuthModule, TbankModule], + controllers: [BrokerageOperationsController], + providers: [BrokerageOperationsService], + exports: [BrokerageOperationsService], +}) +export class OperationsModule {} diff --git a/src/tbank/brokerage-operation.dto.ts b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts similarity index 96% rename from src/tbank/brokerage-operation.dto.ts rename to src/modules/investments/operations/presentation/brokerage-operation.dto.ts index 883a409..927f68e 100644 --- a/src/tbank/brokerage-operation.dto.ts +++ b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts @@ -1,5 +1,5 @@ -import { Transform, Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; import { IsArray, IsDateString, @@ -12,9 +12,9 @@ import { MaxLength, Min, } from 'class-validator'; -import { MoneyDto } from '../common/money.dto'; +import { MoneyDto } from '../../../../common/money.dto'; -import { AccountReferenceDto } from './account-reference.dto'; +import { AccountReferenceDto } from '../../account-reference.dto'; export const operationCategories = [ 'trade', diff --git a/src/tbank/brokerage-operations.controller.spec.ts b/src/modules/investments/operations/presentation/brokerage-operations.controller.spec.ts similarity index 91% rename from src/tbank/brokerage-operations.controller.spec.ts rename to src/modules/investments/operations/presentation/brokerage-operations.controller.spec.ts index 813153d..84c7984 100644 --- a/src/tbank/brokerage-operations.controller.spec.ts +++ b/src/modules/investments/operations/presentation/brokerage-operations.controller.spec.ts @@ -1,4 +1,4 @@ -jest.mock('../prisma/prisma.service', () => ({ +jest.mock('../../../../prisma/prisma.service', () => ({ PrismaService: class PrismaService {}, })); diff --git a/src/tbank/brokerage-operations.controller.ts b/src/modules/investments/operations/presentation/brokerage-operations.controller.ts similarity index 80% rename from src/tbank/brokerage-operations.controller.ts rename to src/modules/investments/operations/presentation/brokerage-operations.controller.ts index 09b9ac3..3f81ceb 100644 --- a/src/tbank/brokerage-operations.controller.ts +++ b/src/modules/investments/operations/presentation/brokerage-operations.controller.ts @@ -1,14 +1,13 @@ import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; -import { AccessPayload } from '../auth/auth.types'; -import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; +import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator'; +import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; import { BrokerageOperationsPageDto, BrokerageOperationsQueryDto, } from './brokerage-operation.dto'; -import { BrokerageOperationsService } from './brokerage-operations.service'; +import { BrokerageOperationsService } from '../application/brokerage-operations.service'; @ApiTags('brokerage-accounts') @ApiProblemResponses() diff --git a/src/tbank/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts similarity index 98% rename from src/tbank/portfolio.service.spec.ts rename to src/modules/investments/portfolio/application/portfolio.service.spec.ts index 6c9ddad..bbd5d7e 100644 --- a/src/tbank/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -1,20 +1,20 @@ -import assert from 'node:assert/strict'; import { BadRequestException, ConflictException, ServiceUnavailableException, } from '@nestjs/common'; +import assert from 'node:assert/strict'; -jest.mock('./tbank-access.service', () => ({ +jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({ TbankAccessService: class TbankAccessService {}, })); -import { PortfolioService } from './portfolio.service'; -import { InstrumentMetadataService } from './instrument-metadata.service'; +import { InstrumentMetadataService } from '../../../../integrations/tbank/instrument-metadata.service'; import type { TbankAccountContext, TbankConnectionContext, -} from './tbank-access.service'; +} from '../../../../integrations/tbank/tbank-access.service'; +import { PortfolioService } from './portfolio.service'; describe('PortfolioService', () => { const access = { diff --git a/src/tbank/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts similarity index 92% rename from src/tbank/portfolio.service.ts rename to src/modules/investments/portfolio/application/portfolio.service.ts index 2013505..b95acb5 100644 --- a/src/tbank/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -1,23 +1,28 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; -import { RequestContextService } from '../common/logging/request-context.service'; import { add, formatDecimal, isZero, multiply, subtract, -} from '../common/decimal'; -import { decimal } from './decimal'; -import { totalsByCurrency, sumMoneyByCurrency } from './portfolio-totals'; -import { portfolioStructure } from './portfolio-structure'; -import { MoneyDto } from '../common/money.dto'; -import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; +} from '../../../../common/decimal'; +import { RequestContextService } from '../../../../common/logging/request-context.service'; +import { MoneyDto } from '../../../../common/money.dto'; import { + InstrumentMetadataService, TbankAccessService, + TbankClient, TbankConnectionContext, -} from './tbank-access.service'; -import { InstrumentMetadataService } from './instrument-metadata.service'; -import { TbankClient, TbankMoney } from './tbank.client'; + TbankMoney, +} from '../../../../integrations/tbank/tbank.api'; +import { decimal } from '../../../../integrations/tbank/decimal'; +import { portfolioStructure } from '../domain/portfolio-structure'; +import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper'; +import { sumMoneyByCurrency } from '../domain/portfolio-totals'; +import { + PortfolioDto, + PortfolioPositionDto, +} from '../presentation/portfolio.dto'; const freshForMs = 30_000; const fallbackForMs = 300_000; diff --git a/src/tbank/portfolio-structure.spec.ts b/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts similarity index 98% rename from src/tbank/portfolio-structure.spec.ts rename to src/modules/investments/portfolio/domain/portfolio-structure.spec.ts index 0c19bc2..83cbe50 100644 --- a/src/tbank/portfolio-structure.spec.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts @@ -1,5 +1,5 @@ import { portfolioStructure } from './portfolio-structure'; -import { PortfolioPositionDto } from './portfolio.dto'; +import { PortfolioPositionDto } from '../presentation/portfolio.dto'; const position = ( type: string, diff --git a/src/tbank/portfolio-structure.ts b/src/modules/investments/portfolio/domain/portfolio-structure.ts similarity index 60% rename from src/tbank/portfolio-structure.ts rename to src/modules/investments/portfolio/domain/portfolio-structure.ts index ba5ead5..d60f518 100644 --- a/src/tbank/portfolio-structure.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.ts @@ -1,19 +1,18 @@ -import { add, decimalFromString, formatDecimal } from '../common/decimal'; - -import { MoneyDto } from '../common/money.dto'; import { - PortfolioPositionDto, - PortfolioStructureDto, - PortfolioStructureCategoryDto, -} from './portfolio.dto'; + add, + decimalFromString, + formatDecimal, +} from '../../../../common/decimal'; + +import type { Money } from '../../../../common/money.types'; const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const; // Build from positions and cash, never from totals (which already include cash). export function portfolioStructure( - positions: PortfolioPositionDto[], - cash: MoneyDto[], -): PortfolioStructureDto { + positions: { type: string; currentValue: Money | null }[], + cash: Money[], +) { const currencies = new Map< string, { @@ -22,7 +21,7 @@ export function portfolioStructure( } >(); const unvalued = new Map(); - const include = (type: string, money: MoneyDto) => { + const include = (type: string, money: Money) => { const group = currencies.get(money.currency) ?? { values: categoryTypes.map(() => decimalFromString('0')), hasNegativeValues: false, @@ -50,25 +49,23 @@ export function portfolioStructure( const total = group.values.reduce(add, decimalFromString('0')); const canCalculateShares = !unvalued.size && !group.hasNegativeValues && total.value > 0n; - const categories: PortfolioStructureCategoryDto[] = group.values.map( - (value, index) => ({ - type: categoryTypes[index], - value: formatDecimal(value), - // Rounded to six decimal places using integer arithmetic. Only the - // resulting dimensionless percentage is passed to chart geometry. - sharePercent: canCalculateShares - ? formatDecimal({ - value: - (value.value * - 10n ** BigInt(total.scale - value.scale) * - 100_000_000n + - total.value / 2n) / - total.value, - scale: 6, - }) - : null, - }), - ); + const categories = group.values.map((value, index) => ({ + type: categoryTypes[index], + value: formatDecimal(value), + // Rounded to six decimal places using integer arithmetic. Only the + // resulting dimensionless percentage is passed to chart geometry. + sharePercent: canCalculateShares + ? formatDecimal({ + value: + (value.value * + 10n ** BigInt(total.scale - value.scale) * + 100_000_000n + + total.value / 2n) / + total.value, + scale: 6, + }) + : null, + })); return { currency, totalValue: formatDecimal(total), diff --git a/src/modules/investments/portfolio/domain/portfolio-totals.ts b/src/modules/investments/portfolio/domain/portfolio-totals.ts new file mode 100644 index 0000000..e38548c --- /dev/null +++ b/src/modules/investments/portfolio/domain/portfolio-totals.ts @@ -0,0 +1,22 @@ +import { + add, + decimalFromString, + formatDecimal, +} from '../../../../common/decimal'; +import type { Money } from '../../../../common/money.types'; + +export function sumMoneyByCurrency(amounts: Money[]): Money[] { + const totals = new Map>(); + for (const amount of amounts) { + totals.set( + amount.currency, + add( + totals.get(amount.currency) ?? decimalFromString('0'), + decimalFromString(amount.value), + ), + ); + } + return [...totals] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([currency, value]) => ({ currency, value: formatDecimal(value) })); +} diff --git a/src/tbank/portfolio-totals.ts b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts similarity index 57% rename from src/tbank/portfolio-totals.ts rename to src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts index b61335a..fd01dc4 100644 --- a/src/tbank/portfolio-totals.ts +++ b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts @@ -1,19 +1,21 @@ import { add, - decimalFromString, formatDecimal, isZero, multiply, -} from '../common/decimal'; -import { decimal } from './decimal'; -import { MoneyDto } from '../common/money.dto'; +} from '../../../../common/decimal'; +import type { Money } from '../../../../common/money.types'; +import { decimal } from '../../../../integrations/tbank/decimal'; -import { TbankPortfolio, TbankPositions } from './tbank.client'; +import { + TbankPortfolio, + TbankPositions, +} from '../../../../integrations/tbank/tbank.api'; function totalsByCurrency( positions: NonNullable, money: NonNullable, -): MoneyDto[] { +): Money[] { const totals = new Map>(); const addTotal = ( currency: string | undefined, @@ -42,20 +44,4 @@ function totalsByCurrency( .map(([currency, value]) => ({ currency, value: formatDecimal(value) })); } -function sumMoneyByCurrency(amounts: MoneyDto[]): MoneyDto[] { - const totals = new Map>(); - for (const amount of amounts) { - totals.set( - amount.currency, - add( - totals.get(amount.currency) ?? decimal({}), - decimalFromString(amount.value), - ), - ); - } - return [...totals] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([currency, value]) => ({ currency, value: formatDecimal(value) })); -} - -export { totalsByCurrency, sumMoneyByCurrency }; +export { totalsByCurrency }; diff --git a/src/modules/investments/portfolio/portfolio.api.ts b/src/modules/investments/portfolio/portfolio.api.ts new file mode 100644 index 0000000..e7fbf20 --- /dev/null +++ b/src/modules/investments/portfolio/portfolio.api.ts @@ -0,0 +1,7 @@ +// Public cross-module surface. Nest providers are exported by the matching module. +export { totalsByCurrency } from './infrastructure/tbank-portfolio.mapper'; +export { + PortfolioDto, + PortfolioPositionDto, +} from './presentation/portfolio.dto'; +export { PortfolioService } from './application/portfolio.service'; diff --git a/src/modules/investments/portfolio/portfolio.module.ts b/src/modules/investments/portfolio/portfolio.module.ts new file mode 100644 index 0000000..d053151 --- /dev/null +++ b/src/modules/investments/portfolio/portfolio.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TbankModule } from '../../../integrations/tbank/tbank.module'; +import { AuthModule } from '../../auth/auth.module'; +import { PortfolioController } from './presentation/portfolio.controller'; +import { PortfolioService } from './application/portfolio.service'; + +@Module({ + imports: [AuthModule, TbankModule], + controllers: [PortfolioController], + providers: [PortfolioService], + exports: [PortfolioService], +}) +export class PortfolioModule {} diff --git a/src/tbank/portfolio.controller.ts b/src/modules/investments/portfolio/presentation/portfolio.controller.ts similarity index 77% rename from src/tbank/portfolio.controller.ts rename to src/modules/investments/portfolio/presentation/portfolio.controller.ts index c1a9be9..a136036 100644 --- a/src/tbank/portfolio.controller.ts +++ b/src/modules/investments/portfolio/presentation/portfolio.controller.ts @@ -1,11 +1,10 @@ import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; -import { AccessPayload } from '../auth/auth.types'; -import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; +import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator'; +import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; import { PortfolioDto } from './portfolio.dto'; -import { PortfolioService } from './portfolio.service'; +import { PortfolioService } from '../application/portfolio.service'; @ApiTags('brokerage-accounts') @ApiProblemResponses() diff --git a/src/tbank/portfolio.dto.ts b/src/modules/investments/portfolio/presentation/portfolio.dto.ts similarity index 96% rename from src/tbank/portfolio.dto.ts rename to src/modules/investments/portfolio/presentation/portfolio.dto.ts index 24dd2fa..5683490 100644 --- a/src/tbank/portfolio.dto.ts +++ b/src/modules/investments/portfolio/presentation/portfolio.dto.ts @@ -1,6 +1,6 @@ -import { MoneyDto } from '../common/money.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { AccountReferenceDto } from './account-reference.dto'; +import { MoneyDto } from '../../../../common/money.dto'; +import { AccountReferenceDto } from '../../account-reference.dto'; export class PortfolioPositionDto { @ApiPropertyOptional({ type: String, nullable: true }) diff --git a/src/tbank/tbank-connection.controller.ts b/src/modules/investments/tbank-connection/tbank-connection.controller.ts similarity index 86% rename from src/tbank/tbank-connection.controller.ts rename to src/modules/investments/tbank-connection/tbank-connection.controller.ts index b780b37..9247d1a 100644 --- a/src/tbank/tbank-connection.controller.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.controller.ts @@ -15,9 +15,8 @@ import { ApiTags, } from '@nestjs/swagger'; import type { Request } from 'express'; -import { AccessPayload } from '../auth/auth.types'; -import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; +import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; +import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { PutTbankConnectionDto, TbankConnectionStatusDto, diff --git a/src/tbank/tbank-connection.dto.ts b/src/modules/investments/tbank-connection/tbank-connection.dto.ts similarity index 100% rename from src/tbank/tbank-connection.dto.ts rename to src/modules/investments/tbank-connection/tbank-connection.dto.ts diff --git a/src/modules/investments/tbank-connection/tbank-connection.module.ts b/src/modules/investments/tbank-connection/tbank-connection.module.ts new file mode 100644 index 0000000..b58c750 --- /dev/null +++ b/src/modules/investments/tbank-connection/tbank-connection.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TbankModule } from '../../../integrations/tbank/tbank.module'; +import { AuthModule } from '../../auth/auth.module'; +import { PortfolioModule } from '../portfolio/portfolio.module'; +import { TbankConnectionController } from './tbank-connection.controller'; +import { TbankConnectionService } from './tbank-connection.service'; + +@Module({ + imports: [AuthModule, TbankModule, PortfolioModule], + controllers: [TbankConnectionController], + providers: [TbankConnectionService], +}) +export class TbankConnectionModule {} diff --git a/src/tbank/tbank-connection.service.spec.ts b/src/modules/investments/tbank-connection/tbank-connection.service.spec.ts similarity index 96% rename from src/tbank/tbank-connection.service.spec.ts rename to src/modules/investments/tbank-connection/tbank-connection.service.spec.ts index 5f0a0c9..8056d6d 100644 --- a/src/tbank/tbank-connection.service.spec.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.service.spec.ts @@ -1,11 +1,11 @@ import { BadRequestException } from '@nestjs/common'; -jest.mock('../prisma/prisma.service', () => ({ +jest.mock('../../../prisma/prisma.service', () => ({ PrismaService: class PrismaService {}, })); +import type { TbankClient } from '../../../integrations/tbank/tbank.client'; import { TbankConnectionService } from './tbank-connection.service'; -import type { TbankClient } from './tbank.client'; describe('TbankConnectionService', () => { const prisma = { diff --git a/src/tbank/tbank-connection.service.ts b/src/modules/investments/tbank-connection/tbank-connection.service.ts similarity index 87% rename from src/tbank/tbank-connection.service.ts rename to src/modules/investments/tbank-connection/tbank-connection.service.ts index 1210383..024cced 100644 --- a/src/tbank/tbank-connection.service.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.service.ts @@ -1,8 +1,10 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { PrismaService } from '../prisma/prisma.service'; -import { TbankClient } from './tbank.client'; -import { PortfolioService } from './portfolio.service'; -import { TbankTokenService } from './tbank-token.service'; +import { + TbankClient, + TbankTokenService, +} from '../../../integrations/tbank/tbank.api'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { PortfolioService } from '../portfolio/portfolio.api'; type TbankTokenValidator = Pick; diff --git a/src/tbank/upcoming-events.controller.spec.ts b/src/modules/investments/upcoming-events/upcoming-events.controller.spec.ts similarity index 92% rename from src/tbank/upcoming-events.controller.spec.ts rename to src/modules/investments/upcoming-events/upcoming-events.controller.spec.ts index 572368c..d7eb524 100644 --- a/src/tbank/upcoming-events.controller.spec.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.controller.spec.ts @@ -1,4 +1,4 @@ -jest.mock('../prisma/prisma.service', () => ({ +jest.mock('../../../prisma/prisma.service', () => ({ PrismaService: class PrismaService {}, })); diff --git a/src/tbank/upcoming-events.controller.ts b/src/modules/investments/upcoming-events/upcoming-events.controller.ts similarity index 84% rename from src/tbank/upcoming-events.controller.ts rename to src/modules/investments/upcoming-events/upcoming-events.controller.ts index d0b8210..dfc1f04 100644 --- a/src/tbank/upcoming-events.controller.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.controller.ts @@ -1,9 +1,8 @@ import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; -import { AccessPayload } from '../auth/auth.types'; -import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; +import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; +import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { UpcomingEventsDto, UpcomingEventsQueryDto, diff --git a/src/tbank/upcoming-events.dto.ts b/src/modules/investments/upcoming-events/upcoming-events.dto.ts similarity index 94% rename from src/tbank/upcoming-events.dto.ts rename to src/modules/investments/upcoming-events/upcoming-events.dto.ts index 67875c4..be23797 100644 --- a/src/tbank/upcoming-events.dto.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsDateString, IsOptional, Matches } from 'class-validator'; -import { AccountReferenceDto } from './account-reference.dto'; -import { MoneyDto } from '../common/money.dto'; +import { MoneyDto } from '../../../common/money.dto'; +import { AccountReferenceDto } from '../account-reference.dto'; export class UpcomingEventsQueryDto { @ApiPropertyOptional({ diff --git a/src/modules/investments/upcoming-events/upcoming-events.module.ts b/src/modules/investments/upcoming-events/upcoming-events.module.ts new file mode 100644 index 0000000..b2cbdd4 --- /dev/null +++ b/src/modules/investments/upcoming-events/upcoming-events.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { MoexModule } from '../../../integrations/moex/moex.module'; +import { TbankModule } from '../../../integrations/tbank/tbank.module'; +import { AuthModule } from '../../auth/auth.module'; +import { UpcomingEventsController } from './upcoming-events.controller'; +import { UpcomingEventsService } from './upcoming-events.service'; + +@Module({ + imports: [AuthModule, TbankModule, MoexModule], + controllers: [UpcomingEventsController], + providers: [UpcomingEventsService], +}) +export class UpcomingEventsModule {} diff --git a/src/tbank/upcoming-events.service.spec.ts b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts similarity index 98% rename from src/tbank/upcoming-events.service.spec.ts rename to src/modules/investments/upcoming-events/upcoming-events.service.spec.ts index bcc9fa9..6f8f9a7 100644 --- a/src/tbank/upcoming-events.service.spec.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts @@ -4,12 +4,12 @@ import { ServiceUnavailableException, } from '@nestjs/common'; -jest.mock('./tbank-access.service', () => ({ +jest.mock('../../../integrations/tbank/tbank-access.service', () => ({ TbankAccessService: class TbankAccessService {}, })); +import type { TbankAccountContext } from '../../../integrations/tbank/tbank-access.service'; import { UpcomingEventsService } from './upcoming-events.service'; -import type { TbankAccountContext } from './tbank-access.service'; describe('UpcomingEventsService', () => { const access = { withAccount: jest.fn() }; diff --git a/src/tbank/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts similarity index 95% rename from src/tbank/upcoming-events.service.ts rename to src/modules/investments/upcoming-events/upcoming-events.service.ts index 9fa24a8..530d3e8 100644 --- a/src/tbank/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -1,24 +1,24 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { RequestContextService } from '../common/logging/request-context.service'; -import { StructuredLogger } from '../common/logging/structured-logger.service'; import { add, decimalFromString, formatDecimal, isZero, multiply, -} from '../common/decimal'; -import { decimal } from './decimal'; -import { MoexBondEvent, MoexClient } from './moex.client'; -import { MoneyDto } from '../common/money.dto'; +} from '../../../common/decimal'; +import { RequestContextService } from '../../../common/logging/request-context.service'; +import { StructuredLogger } from '../../../common/logging/structured-logger.service'; +import { MoneyDto } from '../../../common/money.dto'; +import { MoexBondEvent, MoexClient } from '../../../integrations/moex/moex.api'; +import { decimal } from '../../../integrations/tbank/decimal'; -import { TbankAccessService } from './tbank-access.service'; import { + TbankAccessService, TbankClient, TbankDividend, TbankInstrument, TbankPortfolio, -} from './tbank.client'; +} from '../../../integrations/tbank/tbank.api'; import { UpcomingEventDto, UpcomingEventsDto, diff --git a/src/tbank/bond.service.ts b/src/tbank/bond.service.ts deleted file mode 100644 index 5be0936..0000000 --- a/src/tbank/bond.service.ts +++ /dev/null @@ -1,581 +0,0 @@ -import { - timestamp, - moscowDay, - optional, - previousClose, -} from './instrument-market'; -import { - Injectable, - ConflictException, - NotFoundException, - ServiceUnavailableException, -} from '@nestjs/common'; -import { LRUCache } from 'lru-cache'; -import { - decimalFromString, - divide, - formatDecimal, - multiply, - subtract, - add, -} from '../common/decimal'; -import { decimal } from './decimal'; -import { NcrRatingsClient } from './ncr-ratings.client'; -import { MoexClient } from './moex.client'; -import { PortfolioService } from './portfolio.service'; -import { instrumentPosition } from './instrument-position'; -import { - TbankAccessService, - type TbankConnectionContext, -} from './tbank-access.service'; -import { TbankClient, type TbankMoney } from './tbank.client'; -import type { TbankBond } from './tbank-market.types'; -import { - BondMetricsDto, - BondCandlesDto, - BondCandlesQueryDto, - BondDto, - BondPositionDto, - BondQuoteDto, - BondCouponsDto, - BondSearchResultDto, -} from './bond.dto'; - -const dayMs = 86_400_000; -type IdentifiedBond = TbankBond & { uid: string }; - -@Injectable() -export class BondService { - private readonly cache = new LRUCache>({ - max: 300, - ttl: 30_000, - }); - constructor( - private readonly access: TbankAccessService, - private readonly tbank: TbankClient, - private readonly portfolios: PortfolioService, - private readonly moex: MoexClient, - private readonly ratings: NcrRatingsClient, - ) {} - - search(userId: string, query: string): Promise { - 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 { - 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 { - 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 { - const normalized = ticker.trim().toUpperCase(); - return this.cached(userId, `ticker:${normalized}`, async (connection) => { - const { token } = connection; - const found = await this.tbank.findBonds(token, normalized); - const matches = selectBonds( - found.filter( - (item) => item.ticker?.trim().toUpperCase() === normalized, - ), - normalized, - ); - if (!matches.length) throw new NotFoundException(); - if (matches.length > 1) - throw new ConflictException({ code: 'BOND_TICKER_AMBIGUOUS' }); - return matches[0]; - }); - } - quote(userId: string, uid: string): Promise { - return this.cached(userId, `quote:${uid}`, async (connection) => { - const { token } = connection; - const instrument = await this.bond(userId, connection, uid); - const unavailable: string[] = []; - const [last, tradingStatus] = await Promise.all([ - optional( - () => this.tbank.getLastPrice(token, uid), - 'price', - unavailable, - ), - optional( - () => this.tbank.getTradingStatus(token, uid), - 'status', - unavailable, - ), - ]); - const priceAt = timestamp(last?.time); - const pricePercent = last?.price - ? formatDecimal(decimal(last.price)) - : null; - const nominal = money(instrument.nominal); - const aci = money(instrument.aciValue); - const cleanPrice = - pricePercent !== null && - nominal && - decimalFromString(nominal.value).value > 0n - ? { - currency: nominal.currency, - value: formatDecimal( - divide( - multiply( - decimalFromString(pricePercent), - decimalFromString(nominal.value), - ), - decimalFromString('100'), - 9, - ), - ), - } - : null; - const dirtyPrice = - cleanPrice && aci && aci.currency === cleanPrice.currency - ? { - currency: cleanPrice.currency, - value: formatDecimal( - add( - decimalFromString(cleanPrice.value), - decimalFromString(aci.value), - ), - ), - } - : null; - const previous = priceAt - ? await optional( - async () => - previousClose( - await this.tbank.getCandles( - token, - uid, - new Date(Date.parse(priceAt) - 20 * dayMs), - new Date(priceAt), - ), - priceAt, - ), - 'comparison', - unavailable, - ) - : null; - return { - pricePercent, - cleanPrice, - aci, - dirtyPrice, - priceAt, - tradingStatus, - changePercentagePoints: - last?.price && previous?.close - ? formatDecimal( - subtract(decimal(last.price), decimal(previous.close)), - ) - : null, - previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, - unavailable, - observedAt: new Date().toISOString(), - }; - }); - } - metrics(userId: string, uid: string): Promise { - return this.cached(userId, `metrics:${uid}`, async (connection) => { - const instrument = await this.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.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.cachedForConnection( - userId, - connection, - `coupon-events:${uid}:${from}:${to}`, - () => this.tbank.getBondCoupons(connection.token, uid, from, to), - ); - } - coupons(userId: string, uid: string): Promise { - return this.cached(userId, `coupons:${uid}`, async (connection) => { - await this.bond(userId, connection, uid); - const today = moscowDay(new Date().toISOString()); - const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; - const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; - const unavailable: string[] = []; - const [events, position] = await Promise.all([ - this.couponEvents(userId, connection, uid, from, to), - optional(() => this.position(userId, uid), 'position', unavailable), - ]); - const quantity = - position?.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) - ? decimalFromString(position.quantity) - : null; - const coupons = events - .map((event) => { - const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null; - const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null; - const perBond = money(event.payOneBond); - const timing = !paymentDate - ? ('undated' as const) - : paymentDate >= today - ? ('upcoming' as const) - : ('past' as const); - return { - number: event.couponNumber ?? null, - paymentDate, - recordDate, - perBond, - couponType: event.couponType ?? null, - periodDays: event.couponPeriod ?? null, - timing, - estimatedAmount: - quantity && - perBond && - timing === 'upcoming' && - recordDate && - recordDate >= today - ? { - currency: perBond.currency, - value: formatDecimal( - multiply(quantity, decimalFromString(perBond.value)), - ), - } - : null, - }; - }) - .sort((a, b) => - (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''), - ); - return { - coupons, - from, - to, - observedAt: new Date().toISOString(), - positionAt: quantity ? (position?.observedAt ?? null) : null, - estimateUnavailable: unavailable.length > 0, - positionIsStale: position?.isStale ?? false, - }; - }); - } - candles( - userId: string, - uid: string, - period: BondCandlesQueryDto['period'] = 'month', - ): Promise { - return this.cached( - userId, - `candles:${uid}:${period}`, - async (connection) => { - const { token } = connection; - const bond = await this.bond(userId, connection, uid); - const historyFrom = timestamp(bond.first_1dayCandleDate); - const to = new Date(); - const from = new Date(to); - from.setUTCMonth( - from.getUTCMonth() - - (period === 'month' ? 1 : period === 'halfYear' ? 6 : 12), - ); - if (period === 'all') { - if (!historyFrom) - throw new ServiceUnavailableException({ - code: 'BOND_HISTORY_START_UNAVAILABLE', - }); - from.setTime(Date.parse(historyFrom)); - } else if (historyFrom && Date.parse(historyFrom) > from.valueOf()) - from.setTime(Date.parse(historyFrom)); - const collected = new Map(); - // Conservative one-year windows also work with older deployed RPC limits. - for (let start = from.valueOf(); start < to.valueOf();) { - const end = Math.min(start + 365 * dayMs, to.valueOf()); - for (const candle of await this.tbank.getCandles( - token, - uid, - new Date(start), - new Date(end), - )) { - const time = timestamp(candle.time); - if ( - time && - candle.close && - Date.parse(time) >= from.valueOf() - dayMs && - Date.parse(time) <= to.valueOf() - ) - collected.set(time, { - time, - close: formatDecimal(decimal(candle.close)), - isComplete: candle.isComplete === true, - }); - } - start = end; - } - return { - candles: [...collected.values()].sort((a, b) => - a.time.localeCompare(b.time), - ), - currency: null, - historyFrom, - observedAt: new Date().toISOString(), - }; - }, - ); - } - async position(userId: string, uid: string): Promise { - return this.cached(userId, `position:${uid}`, async () => - instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'), - ); - } - private bond( - userId: string, - connection: TbankConnectionContext, - uid: string, - ): Promise { - 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 }; - }, - ); - } - private cached( - userId: string, - key: string, - operation: (connection: TbankConnectionContext) => Promise, - ): Promise { - return this.access.withConnection(userId, (connection) => - this.cachedForConnection(userId, connection, key, () => - operation(connection), - ), - ); - } - private cachedForConnection( - userId: string, - { generation }: TbankConnectionContext, - key: string, - operation: () => Promise, - ): Promise { - const cacheKey = JSON.stringify([userId, generation, key]); - const cached = this.cache.get(cacheKey); - if (cached) return cached as Promise; - 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(); - 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, - }; -} - -function money(value?: TbankMoney) { - const currency = value?.currency?.toUpperCase(); - return value && currency - ? { currency, value: formatDecimal(decimal(value)) } - : null; -} diff --git a/src/tbank/stock-position.ts b/src/tbank/stock-position.ts deleted file mode 100644 index aa422cb..0000000 --- a/src/tbank/stock-position.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { instrumentPosition } from './instrument-position'; -import type { PortfolioDto } from './portfolio.dto'; -export function stockPosition(portfolio: PortfolioDto, uid: string) { - return instrumentPosition(portfolio, uid, 'STOCK'); -} diff --git a/src/tbank/stock.service.ts b/src/tbank/stock.service.ts deleted file mode 100644 index 135b1b2..0000000 --- a/src/tbank/stock.service.ts +++ /dev/null @@ -1,517 +0,0 @@ -import { - timestamp, - moscowDay, - optional, - previousClose, -} from './instrument-market'; -export { previousClose } from './instrument-market'; -import { - Injectable, - ConflictException, - NotFoundException, - ServiceUnavailableException, -} from '@nestjs/common'; -import { LRUCache } from 'lru-cache'; -import { - decimalFromString, - divide, - formatDecimal, - multiply, - subtract, -} from '../common/decimal'; -import { decimal } from './decimal'; -import { PortfolioService } from './portfolio.service'; -import { stockPosition } from './stock-position'; -import { TbankAccessService } from './tbank-access.service'; -import { TbankClient } from './tbank.client'; -import type { TbankFundamentals, TbankShare } from './tbank-market.types'; -import { - StockCandlesDto, - StockCandlesQueryDto, - StockCompanyDto, - StockDividendDto, - StockDividendsDto, - StockDto, - StockMetricDto, - StockPositionDto, - StockQuoteDto, - StockSearchResultDto, -} from './stock.dto'; - -const dayMs = 86_400_000; -type IdentifiedShare = TbankShare & { uid: string }; - -@Injectable() -export class StockService { - private readonly cache = new LRUCache>({ - max: 300, - ttl: 30_000, - }); - constructor( - private readonly access: TbankAccessService, - private readonly tbank: TbankClient, - private readonly portfolios: PortfolioService, - ) {} - - search(userId: string, query: string): Promise { - 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 { - 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 { - 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 { - const normalized = ticker.trim().toUpperCase(); - return this.cached(userId, `ticker:${normalized}`, async (token) => { - const found = await this.tbank.findShares(token, normalized); - const matches = selectShares( - found.filter( - (item) => item.ticker?.trim().toUpperCase() === normalized, - ), - normalized, - ); - if (!matches.length) throw new NotFoundException(); - if (matches.length > 1) - throw new ConflictException({ code: 'STOCK_TICKER_AMBIGUOUS' }); - return matches[0]; - }); - } - quote(userId: string, uid: string): Promise { - return this.cached(userId, `quote:${uid}`, async (token) => { - const share = await this.share(token, uid); - const unavailable: string[] = []; - const [last, tradingStatus] = await Promise.all([ - optional( - () => this.tbank.getLastPrice(token, uid), - 'price', - unavailable, - ), - optional( - () => this.tbank.getTradingStatus(token, uid), - 'status', - unavailable, - ), - ]); - const priceAt = timestamp(last?.time); - const currency = share.currency?.toUpperCase(); - const price = - last?.price && currency - ? { currency, value: formatDecimal(decimal(last.price)) } - : null; - const previous = priceAt - ? await optional( - async () => { - const end = new Date(priceAt); - return previousClose( - await this.tbank.getCandles( - token, - uid, - new Date(end.valueOf() - 20 * dayMs), - end, - ), - priceAt, - ); - }, - 'comparison', - unavailable, - ) - : null; - const previousPrice = - previous?.close && currency - ? { currency, value: formatDecimal(decimal(previous.close)) } - : null; - const difference = - price && previousPrice - ? subtract( - decimalFromString(price.value), - decimalFromString(previousPrice.value), - ) - : null; - return { - price, - priceAt, - tradingStatus, - previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, - previousClose: previousPrice, - change: - difference && currency - ? { currency, value: formatDecimal(difference) } - : null, - changePercent: - difference && - previousPrice && - decimalFromString(previousPrice.value).value > 0n - ? formatDecimal( - divide( - multiply(difference, decimalFromString('100')), - decimalFromString(previousPrice.value), - 6, - ), - ) - : null, - unavailable, - observedAt: new Date().toISOString(), - }; - }); - } - candles( - userId: string, - uid: string, - period: StockCandlesQueryDto['period'] = 'month', - ): Promise { - return this.cached(userId, `candles:${uid}:${period}`, async (token) => { - const share = await this.share(token, uid); - const historyFrom = timestamp(share.first_1dayCandleDate); - const to = new Date(); - const from = new Date(to); - from.setUTCMonth( - from.getUTCMonth() - - (period === 'month' ? 1 : period === 'halfYear' ? 6 : 12), - ); - if (period === 'all') { - if (!historyFrom) - throw new ServiceUnavailableException({ - code: 'STOCK_HISTORY_START_UNAVAILABLE', - }); - from.setTime(Date.parse(historyFrom)); - } else if (historyFrom && Date.parse(historyFrom) > from.valueOf()) - from.setTime(Date.parse(historyFrom)); - const collected = new Map(); - // Conservative one-year windows also work with older deployed RPC limits. - for (let start = from.valueOf(); start < to.valueOf();) { - const end = Math.min(start + 365 * dayMs, to.valueOf()); - for (const candle of await this.tbank.getCandles( - token, - uid, - new Date(start), - new Date(end), - )) { - const time = timestamp(candle.time); - if ( - time && - candle.close && - Date.parse(time) >= from.valueOf() - dayMs && - Date.parse(time) <= to.valueOf() - ) - collected.set(time, { - time, - close: formatDecimal(decimal(candle.close)), - isComplete: candle.isComplete === true, - }); - } - start = end; - } - return { - candles: [...collected.values()].sort((a, b) => - a.time.localeCompare(b.time), - ), - currency: share.currency?.toUpperCase() || null, - historyFrom, - observedAt: new Date().toISOString(), - }; - }); - } - async position(userId: string, uid: string): Promise { - return this.cached(userId, `position:${uid}`, async () => - stockPosition(await this.portfolios.getAll(userId), uid), - ); - } - company(userId: string, uid: string): Promise { - return this.cached(userId, `company:${uid}`, async (token) => { - const share = await this.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(), - }; - }); - } - dividends(userId: string, uid: string): Promise { - return this.cached(userId, `dividends:${uid}`, async (token) => { - await this.share(token, uid); - const today = moscowDay(new Date().toISOString()); - const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; - const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; - const unavailable: string[] = []; - const [events, position] = await Promise.all([ - this.tbank.getDividends(token, uid, from, to), - optional(() => this.position(userId, uid), 'position', unavailable), - ]); - const quantity = - position && - position.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) - ? decimalFromString(position.quantity) - : null; - const dividends: StockDividendDto[] = events - .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') - .map((event) => { - const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null; - const paymentDate = - timestamp(event.paymentDate)?.slice(0, 10) ?? null; - const lastBuyDate = - timestamp(event.lastBuyDate)?.slice(0, 10) ?? null; - const currency = event.dividendNet?.currency?.toUpperCase(); - const perShare = - currency && event.dividendNet - ? { currency, value: formatDecimal(decimal(event.dividendNet)) } - : null; - // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. - const 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, - }; - }); - } - private async share(token: string, uid: string): Promise { - const share = await this.tbank.getShare(token, uid); - if (!share || share.uid !== uid) throw new NotFoundException(); - return { ...share, uid }; - } - private cached( - userId: string, - key: string, - operation: (token: string) => Promise, - ): Promise { - 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; - 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(); - 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), - }; -} -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, - }, - ] - : []; - }); -} diff --git a/src/tbank/tbank-connection.module.ts b/src/tbank/tbank-connection.module.ts deleted file mode 100644 index 689903c..0000000 --- a/src/tbank/tbank-connection.module.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NcrRatingsClient } from './ncr-ratings.client'; -import { BondController } from './bond.controller'; -import { BondService } from './bond.service'; -import { StockController } from './stock.controller'; -import { StockService } from './stock.service'; -import { Module } from '@nestjs/common'; -import { BrokerageAccountsController } from './brokerage-accounts.controller'; -import { BrokerageAccountsService } from './brokerage-accounts.service'; -import { BrokerageOperationsController } from './brokerage-operations.controller'; -import { BrokerageOperationsService } from './brokerage-operations.service'; -import { InstrumentMetadataService } from './instrument-metadata.service'; -import { MoexClient } from './moex.client'; -import { PortfolioController } from './portfolio.controller'; -import { PortfolioService } from './portfolio.service'; -import { TbankClient } from './tbank.client'; -import { TbankAccessService } from './tbank-access.service'; -import { TbankConnectionController } from './tbank-connection.controller'; -import { TbankConnectionService } from './tbank-connection.service'; -import { TbankTokenService } from './tbank-token.service'; -import { UpcomingEventsController } from './upcoming-events.controller'; -import { UpcomingEventsService } from './upcoming-events.service'; -import { IncomeAnalyticsController } from './income-analytics.controller'; -import { IncomeAnalyticsService } from './income-analytics.service'; - -@Module({ - controllers: [ - StockController, - BondController, - TbankConnectionController, - BrokerageAccountsController, - BrokerageOperationsController, - PortfolioController, - UpcomingEventsController, - IncomeAnalyticsController, - ], - providers: [ - StockService, - BondService, - TbankClient, - TbankConnectionService, - BrokerageAccountsService, - BrokerageOperationsService, - InstrumentMetadataService, - PortfolioService, - MoexClient, - NcrRatingsClient, - UpcomingEventsService, - TbankTokenService, - TbankAccessService, - IncomeAnalyticsService, - ], -}) -export class TbankConnectionModule {} diff --git a/test/all-accounts.e2e-spec.ts b/test/all-accounts.e2e-spec.ts index a87ff05..55a2040 100644 --- a/test/all-accounts.e2e-spec.ts +++ b/test/all-accounts.e2e-spec.ts @@ -1,11 +1,11 @@ import { ConflictException, INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import request from 'supertest'; import type { Server } from 'node:http'; +import request from 'supertest'; import { AppModule } from '../src/app.module'; +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; import { configureApp } from '../src/main'; -import { TbankAccessService } from '../src/tbank/tbank-access.service'; describe('All accounts HTTP boundary', () => { let app: INestApplication; diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 85c6139..cb756c2 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,13 +1,13 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; import { App } from 'supertest/types'; -import { AppModule } from './../src/app.module'; -import { StructuredLogger } from './../src/common/logging/structured-logger.service'; -import { configureApp } from './../src/main'; -import { PrismaService } from './../src/prisma/prisma.service'; -import { AuthService } from './../src/auth/auth.service'; +import { AppModule } from '../src/app.module'; +import { StructuredLogger } from '../src/common/logging/structured-logger.service'; +import { configureApp } from '../src/main'; +import { AuthService } from '../src/modules/auth/application/auth.service'; +import { PrismaService } from '../src/prisma/prisma.service'; type Authentication = { user: { email: string }; accessToken: string }; type Problem = { code: string }; diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts index 48899c8..13eb900 100644 --- a/test/bank-cards.e2e-spec.ts +++ b/test/bank-cards.e2e-spec.ts @@ -1,21 +1,21 @@ -import assert from 'node:assert/strict'; import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; +import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import type { Server } from 'node:http'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; import { BankCardDto, BankOperationDto, BankOperationsPageDto, BankSummaryDto, ExpenseCategoryDto, -} from '../src/bank-cards/bank-card.dto'; +} from '../src/modules/banking/bank-cards/presentation/bank-card.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; jest.setTimeout(60000); describe('Manual bank cards configured HTTP and real SQLite', () => { diff --git a/test/bonds.e2e-spec.ts b/test/bonds.e2e-spec.ts index 8afb98e..a7d3adc 100644 --- a/test/bonds.e2e-spec.ts +++ b/test/bonds.e2e-spec.ts @@ -1,16 +1,18 @@ import { - INestApplication, ConflictException, + INestApplication, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import request from 'supertest'; import type { Server } from 'node:http'; +import request from 'supertest'; import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; -import { BondService } from '../src/tbank/bond.service'; +import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service'; +import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.service'; +import { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Bond HTTP contract', () => { @@ -28,7 +30,11 @@ describe('Bond HTTP contract', () => { ); beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) - .overrideProvider(BondService) + .overrideProvider(BondCatalogService) + .useValue(bonds) + .overrideProvider(BondMarketService) + .useValue(bonds) + .overrideProvider(BondIncomeService) .useValue(bonds) .compile(); app = module.createNestApplication(); diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index e09eb34..c225252 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -3,8 +3,8 @@ import { Test } from '@nestjs/testing'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { AppModule } from './../src/app.module'; -import { configureApp } from './../src/main'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/main'; function canonicalize(value: unknown): unknown { if (Array.isArray(value)) return value.map(canonicalize); diff --git a/test/portfolio-structure.e2e-spec.ts b/test/portfolio-structure.e2e-spec.ts index dce742d..49b22fb 100644 --- a/test/portfolio-structure.e2e-spec.ts +++ b/test/portfolio-structure.e2e-spec.ts @@ -4,10 +4,10 @@ import { Test } from '@nestjs/testing'; import type { Server } from 'node:http'; import request from 'supertest'; import { AppModule } from '../src/app.module'; +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; -import { TbankAccessService } from '../src/tbank/tbank-access.service'; -import { TbankClient } from '../src/tbank/tbank.client'; -import { PortfolioService } from '../src/tbank/portfolio.service'; +import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service'; describe('Portfolio structure HTTP contract', () => { let app: INestApplication; diff --git a/test/stocks.e2e-spec.ts b/test/stocks.e2e-spec.ts index 5eb77d6..5b270b8 100644 --- a/test/stocks.e2e-spec.ts +++ b/test/stocks.e2e-spec.ts @@ -1,16 +1,18 @@ import { - INestApplication, ConflictException, + INestApplication, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import request from 'supertest'; import type { Server } from 'node:http'; +import request from 'supertest'; import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; -import { StockService } from '../src/tbank/stock.service'; +import { StockIncomeService } from '../src/modules/investments/instruments/stocks/application/stock-income.service'; +import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service'; +import { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Stock HTTP contract', () => { @@ -27,7 +29,11 @@ describe('Stock HTTP contract', () => { ); beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) - .overrideProvider(StockService) + .overrideProvider(StockCatalogService) + .useValue(stocks) + .overrideProvider(StockMarketService) + .useValue(stocks) + .overrideProvider(StockIncomeService) .useValue(stocks) .compile(); app = module.createNestApplication(); diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index 9c745dd..26ceabb 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -4,10 +4,10 @@ import { Test } from '@nestjs/testing'; import type { Server } from 'node:http'; import request from 'supertest'; import { AppModule } from '../src/app.module'; +import { MoexClient } from '../src/integrations/moex/moex.client'; +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; -import { TbankAccessService } from '../src/tbank/tbank-access.service'; -import { TbankClient } from '../src/tbank/tbank.client'; -import { MoexClient } from '../src/tbank/moex.client'; describe('Upcoming events date filter HTTP boundary', () => { let app: INestApplication;