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