Compare commits
1
Commits
9cc9fbdcff
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92f216d520 |
+8
-11
@@ -160,10 +160,8 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing
|
|||||||
|
|
||||||
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
|
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
|
||||||
модулями providers, функции и типы. Внешний production import идёт через этот
|
модулями providers, функции и типы. Внешний production import идёт через этот
|
||||||
API или файл Nest module. Чистые `integrations/tbank/decimal.ts` и
|
API или файл Nest module. Чистый `integrations/tbank/decimal.ts` — отдельный
|
||||||
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
|
публичный вход для protobuf conversion: импорт арифметики не загружает DI/Prisma.
|
||||||
публичные входы для protobuf conversion и interval rules: импорт расчётов
|
|
||||||
не загружает DI/Prisma.
|
|
||||||
Внутри модуля используй прямые локальные импорты.
|
Внутри модуля используй прямые локальные импорты.
|
||||||
Tests могут импортировать внутренний owner для проверки поведения и mocks.
|
Tests могут импортировать внутренний owner для проверки поведения и mocks.
|
||||||
Новый export требует текущего потребителя; controller и persistence остаются
|
Новый export требует текущего потребителя; controller и persistence остаются
|
||||||
@@ -235,8 +233,8 @@ protobuf units/nano conversion — T-Bank integration. Domain используе
|
|||||||
Общий выбор symbolic values и числовых policies —
|
Общий выбор symbolic values и числовых policies —
|
||||||
[Domain values](conventions/engineering.md#domain-values).
|
[Domain values](conventions/engineering.md#domain-values).
|
||||||
Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай
|
Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай
|
||||||
`unknown` + runtime narrowing; ESLint запрещает explicit `any`
|
`unknown` + runtime narrowing; отключённый lint `no-explicit-any` не делает
|
||||||
в domain/application model, тестах и конфигурации TypeScript. `declare` допустим для заполняемых извне
|
`any` нормой domain/application model. `declare` допустим для заполняемых извне
|
||||||
DTO fields, не для сокрытия неинициализированного owned state.
|
DTO fields, не для сокрытия неинициализированного owned state.
|
||||||
|
|
||||||
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
|
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
|
||||||
@@ -246,11 +244,10 @@ OpenAPI enum metadata и runtime validation должны читать тот ж
|
|||||||
сохраняй; generated Prisma/protocol code вручную не меняй.
|
сохраняй; generated Prisma/protocol code вручную не меняй.
|
||||||
|
|
||||||
`@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule
|
`@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule
|
||||||
отключено в пользу TS-версии. Общего списка `ignore` для чисел и bigint нет:
|
отключено в пользу TS-версии. Исключения для очевидных чисел и их bigint форм,
|
||||||
значения, проверяемые правилом, получают предметные имена у владельца.
|
индексов, default values, enum/type declarations и readonly properties заданы
|
||||||
Исключения для индексов, default values, enum/type declarations и readonly
|
в ESLint config. Test fixtures/expected values остаются inline, остальные lint
|
||||||
properties заданы в ESLint config. Test fixtures/expected values остаются inline,
|
правила продолжают действовать. Numeric policy получает предметное имя и единицы;
|
||||||
остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы;
|
|
||||||
semantic magic strings проверяются conventions/review без нового инструмента.
|
semantic magic strings проверяются conventions/review без нового инструмента.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ application/domain outcome → application mapping → HttpExceptionFilter
|
|||||||
```
|
```
|
||||||
|
|
||||||
Сохраняй принятый профиль RFC 9457: `status`, стабильный публичный `code`,
|
Сохраняй принятый профиль RFC 9457: `status`, стабильный публичный `code`,
|
||||||
для validation — `errors: [{ pointer, detail }]`. Точный контракт validation
|
для validation — `errors: [{ pointer, detail }]`. Точный контракт и известное
|
||||||
описан в [HTTP API](http-api.md#источник-контракта).
|
расхождение validation описаны в [HTTP API](http-api.md#источник-контракта).
|
||||||
Не добавляй `type/title/instance` как обязательные поля без contract change.
|
Не добавляй `type/title/instance` как обязательные поля без contract change.
|
||||||
|
|
||||||
Чистый domain не импортирует Nest HTTP exceptions. Существующий application
|
Чистый domain не импортирует Nest HTTP exceptions. Существующий application
|
||||||
|
|||||||
@@ -83,6 +83,6 @@ artifact отдельно; backend не читает их репозитории
|
|||||||
HTTP prefix — `/api/v1`; Swagger доступен согласно `src/config/swagger.config.ts`.
|
HTTP prefix — `/api/v1`; Swagger доступен согласно `src/config/swagger.config.ts`.
|
||||||
Общий error response — `application/problem+json` со `status` и публичным `code`.
|
Общий error response — `application/problem+json` со `status` и публичным `code`.
|
||||||
Validation требует `400 / VALIDATION_ERROR` и `errors: [{ pointer, detail }]`.
|
Validation требует `400 / VALIDATION_ERROR` и `errors: [{ pointer, detail }]`.
|
||||||
ValidationPipe сохраняет структурированные field errors без `target/value`,
|
Известное ограничение: ValidationPipe сейчас формирует строковые сообщения,
|
||||||
а filter формирует JSON pointers, включая вложенные поля. Schema parity не
|
а filter ожидает структурированные field errors; schema parity не доказывает
|
||||||
доказывает правильность `errors`; изменения проверяются через реальный HTTP pipeline.
|
правильность `errors`. Изменение требует проверки реального HTTP pipeline.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -1,673 +0,0 @@
|
|||||||
# Аудит backend WealthLedger и план исправлений
|
|
||||||
|
|
||||||
Дата аудита: 2026-09-27. Репозиторий: `/Users/ksv741/Project/vibe/WealthLedger/backend`.
|
|
||||||
База: `516e43203eb3fee8e6a0c7018be2fee11f1b900c`, ветка `codex/investment-contracts`.
|
|
||||||
При аудите рабочее дерево было чистым; production-код не изменялся.
|
|
||||||
Этот документ сохраняет результаты аудита и задаёт проверяемый план исправлений.
|
|
||||||
Само сохранение документа не означает выполнения исправлений.
|
|
||||||
|
|
||||||
Номера строк относятся к указанному commit. Перед изменением повторно найти
|
|
||||||
symbol и проверить актуальный flow: после предыдущих исправлений строки сдвинутся.
|
|
||||||
Все относительные пути и ссылки относятся только к backend.
|
|
||||||
|
|
||||||
## Общая оценка
|
|
||||||
|
|
||||||
Backend имеет понятных владельцев ответственности: контроллеры тонкие,
|
|
||||||
денежные расчёты преимущественно точные, транзакции и очереди сосредоточены
|
|
||||||
у своих владельцев. Массовое дробление файлов не требуется.
|
|
||||||
Наиболее проблемные области — обработка ошибок auth, проверка ответов MOEX,
|
|
||||||
согласование состояния фоновой синхронизации и временных признаков кешированных данных.
|
|
||||||
|
|
||||||
Изучены локальные [AGENTS.md](AGENTS.md), [порядок работы](.ai/conventions/engineering.md),
|
|
||||||
[архитектура](.ai/architecture.md) и активированные темы HTTP, errors, data,
|
|
||||||
testing, security, integrations и observability. Соседние репозитории не использовались.
|
|
||||||
|
|
||||||
Инвентаризированы 135 production-файлов TypeScript, 16 004 строки, 17 Nest module-файлов.
|
|
||||||
Generated Prisma, vendor protocols, миграции и тесты отделены от production-рейтинга.
|
|
||||||
|
|
||||||
Приоритеты: **высокий** — существенный риск безопасности или искажения данных;
|
|
||||||
**средний** — ограниченный дефект поведения, контракта или доказанная стоимость связности;
|
|
||||||
**низкий** — локальное упрощение. Уверенность всех перечисленных находок высокая
|
|
||||||
для описанного механизма; ограничения конкретного воспроизведения указаны отдельно.
|
|
||||||
|
|
||||||
## План выполнения и границы
|
|
||||||
|
|
||||||
1. Прочитать действующий backend AGENTS и активированные conventions; зафиксировать
|
|
||||||
текущие HEAD/status. Сохранить существующие staged/unstaged/untracked изменения.
|
|
||||||
2. Работать по находкам A01–A10 и cleanup C01–C07 ниже, в порядке пакетов из таблицы.
|
|
||||||
Перед каждым исправлением перечитать владельца целиком, callers, связанные tests
|
|
||||||
и 1–3 релевантных примера. Если факт больше не воспроизводится, отметить это
|
|
||||||
с доказательством вместо механического применения рекомендации.
|
|
||||||
3. Для дефекта сначала получить падающую проверку наблюдаемого результата на самой
|
|
||||||
дешёвой надёжной boundary. Для concurrency/atomicity использовать реальную
|
|
||||||
изолированную SQLite; mock race не доказывает DB-инвариант.
|
|
||||||
4. Исправлять у существующего владельца минимальным связанным diff. Сохранять
|
|
||||||
precision, ownership, public error codes, transaction/fencing, cache isolation,
|
|
||||||
limiter и выполняющиеся promises. Предложения ниже задают результат, а не
|
|
||||||
обязательную форму реализации.
|
|
||||||
5. После каждого пакета выполнить targeted checks; в конце — актуальные обязательные
|
|
||||||
gates по backend conventions. Перед каждой Node-командой выполнить `nvm use --silent`.
|
|
||||||
6. Сохранить исходный снимок аудита; результаты реализации записывать в таблицу
|
|
||||||
«Статус исправлений». Для каждого ID указать diff/решение, команды и результат,
|
|
||||||
остаточный риск либо точную причину невыполнения.
|
|
||||||
|
|
||||||
| Пакет | Пункты | Условие завершения |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 1. Auth и безопасные ошибки | A01, A02 | Отказы хранилища не маскируются; секреты не попадают в completion log |
|
|
||||||
| 2. MOEX trust boundary | A03 | Malformed response не публикуется как полный результат; pagination завершается |
|
|
||||||
| 3. Статус синхронизации | A04 | Screen учитывает состояние successor через владельца operations |
|
|
||||||
| 4. Свечи | A05 | Force не теряется при coalescing; forced publication остаётся атомарной |
|
|
||||||
| 5. Свежесть и время | A06, A07 | Stale-флаг актуален; wire timestamp совпадает с используемым при фильтрации |
|
|
||||||
| 6. HTTP validation | A08 | Реальный pipeline возвращает структурированные field errors |
|
|
||||||
| 7. Связность и удаление legacy | A09, A10, C01–C07 | Живые boundaries сохранены; лишние реализации и exports удалены |
|
|
||||||
|
|
||||||
Пакеты независимы по цели, но могут пересекаться файлами и общими fixtures.
|
|
||||||
Параллелить только области с непересекающимся владением файлами, затем выполнить
|
|
||||||
общую проверку. A02 и A08 изменяют один HTTP filter и должны быть согласованы.
|
|
||||||
|
|
||||||
В scope входят исправления подтверждённых находок и их regression tests.
|
|
||||||
Гипотезы в конце документа не являются задачами на изменение кода.
|
|
||||||
Новые зависимости, generic repositories, scheduler framework, интерфейсы ради
|
|
||||||
паттерна, schema migrations и size-only splits для этих исправлений не требуются.
|
|
||||||
Отдельную необходимость такого расширения следует доказать до реализации.
|
|
||||||
Коммиты, push и PR выполняются только по отдельному запросу пользователя.
|
|
||||||
|
|
||||||
## Подтверждённые находки
|
|
||||||
|
|
||||||
### A01. Auth lifecycle смешивает invalid credentials и operational failure
|
|
||||||
|
|
||||||
**Приоритет:** высокий. **Уверенность:** высокая.
|
|
||||||
|
|
||||||
**Места и потребители:** [AuthService.logout, строки 104–126](src/modules/auth/application/auth.service.ts),
|
|
||||||
[AuthController.logout, 95–104; refresh, 85–91](src/modules/auth/presentation/auth.controller.ts).
|
|
||||||
|
|
||||||
Общий catch в logout подавляет JWT verification errors и отказ Prisma при записи
|
|
||||||
`revokedAt`. Controller затем очищает cookie и возвращает 204, хотя серверная
|
|
||||||
сессия осталась действительной. Сохранённая копия refresh token продолжает работать.
|
|
||||||
Смежное проявление: refresh controller удаляет cookie при любой ошибке, включая
|
|
||||||
временный отказ БД, без доказательства недействительности токена.
|
|
||||||
|
|
||||||
**Доказательство:** inline fault injection на настоящих AuthService, JwtService
|
|
||||||
и AuthController с синтетической сессией подтвердил:
|
|
||||||
|
|
||||||
```text
|
|
||||||
logoutResolved=true
|
|
||||||
cookieCleared=true
|
|
||||||
sessionNotRevoked=true
|
|
||||||
sameRefreshTokenStillUsable=true
|
|
||||||
unexpectedRefreshErrorPropagated=true
|
|
||||||
validCookieRemoved=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Это доказательство обработки injected boundary failure, а не реального отказа БД.
|
|
||||||
[Auth HTTP suite](test/app.e2e-spec.ts) проверяет обычный logout, но не этот отказ.
|
|
||||||
Нарушен [контракт отзыва сессии](.ai/conventions/security-auth.md): идемпотентность
|
|
||||||
не превращает несостоявшуюся запись в успешную.
|
|
||||||
|
|
||||||
**Минимальное действие:** ограничить подавление ошибок logout недействительным JWT;
|
|
||||||
ошибки хранилища пробрасывать. При refresh очищать cookie только при установленном
|
|
||||||
auth failure. Существующий AuthService.refresh уже отделяет verification от DB reads;
|
|
||||||
BankCardsRepository.write также пробрасывает неизвестные ошибки.
|
|
||||||
|
|
||||||
**Приёмка:**
|
|
||||||
|
|
||||||
- Отсутствующий/недействительный/уже отозванный refresh сохраняет идемпотентный logout.
|
|
||||||
- Ошибка записи revocation не возвращает фиктивный 204.
|
|
||||||
- Успешный logout делает последующий refresh невозможным.
|
|
||||||
- Временный отказ refresh не удаляет действующую cookie, повтор после восстановления возможен.
|
|
||||||
- Проверены status/code/cookie через configured HTTP; успешный отзыв — с SQLite.
|
|
||||||
|
|
||||||
**Риск:** сохранить существующую семантику недействительных токенов. Изменение
|
|
||||||
access-JWT lifetime или введение replay history не входят в этот пункт.
|
|
||||||
|
|
||||||
### A02. Многострочное сообщение исключения попадает в completion log
|
|
||||||
|
|
||||||
**Приоритет:** высокий. **Уверенность:** высокая.
|
|
||||||
|
|
||||||
**Места:** [HttpExceptionFilter, 30–37](src/common/filters/http-exception.filter.ts).
|
|
||||||
**Потребители:** [RequestLoggingMiddleware, 43–57](src/common/logging/request-logging.middleware.ts),
|
|
||||||
[StructuredLogger, 47–63](src/common/logging/structured-logger.service.ts).
|
|
||||||
|
|
||||||
Фильтр удаляет только первую строку `exception.stack`. Остальные строки
|
|
||||||
многострочного Error.message сохраняются. Поле stack разрешено logger allowlist;
|
|
||||||
redaction HTTP body к нему не применяется.
|
|
||||||
|
|
||||||
**Воспроизведение:** configured HTTP app; AuthService.login выбрасывает синтетический
|
|
||||||
`Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET')`. Ответ остаётся
|
|
||||||
`500 / INTERNAL_SERVER_ERROR`, но completion log содержит вторую строку сообщения.
|
|
||||||
Реальные секреты не использовались. Существующий filter spec проверяет однострочное сообщение.
|
|
||||||
Нарушены [observability](.ai/conventions/observability.md) и security rules.
|
|
||||||
|
|
||||||
**Минимальное действие:** убрать передачу сырого stack через общую HTTP boundary,
|
|
||||||
сохранив code/request context. Если stack необходим, нужна проверенная форма кадров
|
|
||||||
без произвольного текста исключения; удаление только первой строки недостаточно.
|
|
||||||
|
|
||||||
**Приёмка:** multiline synthetic secrets отсутствуют в response и в конечном
|
|
||||||
payload logger, а status/code/correlation сохраняются. Проверить настоящий
|
|
||||||
filter → middleware → logger, не только mock вызова filter.
|
|
||||||
|
|
||||||
**Риск:** диагностическая подробность может уменьшиться; raw message возвращать нельзя.
|
|
||||||
|
|
||||||
### A03. MOEX malformed response превращается в полный пустой прогноз
|
|
||||||
|
|
||||||
**Приоритет:** высокий. **Уверенность:** высокая.
|
|
||||||
|
|
||||||
**Место:** [MoexClient, 122–142 и 163–165](src/integrations/moex/moex.client.ts).
|
|
||||||
Отсутствующий запрошенный ISS block преобразуется в `[]`. Неполный cursor может
|
|
||||||
завершить чтение с частичными данными; cursor с нулевым pageSize может зациклить его.
|
|
||||||
|
|
||||||
**Реальная цепочка:** [CatalogEnrichmentService, 444 и 509–548](src/modules/investments/instruments/application/catalog-enrichment.service.ts)
|
|
||||||
публикует `bondEvents: []`; readMany считает существующий row complete;
|
|
||||||
[UpcomingEventsService, 137–197](src/modules/investments/upcoming-events/upcoming-events.service.ts)
|
|
||||||
строит пустые events/totals; [ForecastRepository, 124–150](src/modules/investments/portfolio/infrastructure/forecast.repository.ts)
|
|
||||||
может заменить прежний непустой forecast. Владелец protocol validation — integration client.
|
|
||||||
|
|
||||||
**Воспроизведение:** настоящий MoexClient с mock fetch HTTP 200 без coupons/offers
|
|
||||||
вернул `[]`. Cursor `INDEX=0,TOTAL=1,PAGESIZE=0` дал пять запросов `start=0`;
|
|
||||||
проба остановлена искусственной ошибкой. Timeout каждого fetch не ограничивает весь цикл.
|
|
||||||
|
|
||||||
**Расхождение тестов:** [moex.client.spec, 97–107](src/integrations/moex/moex.client.spec.ts)
|
|
||||||
считает отсутствующий block пустым результатом;
|
|
||||||
[upcoming-events.e2e, 439–451](test/upcoming-events.e2e-spec.ts) обещает обратное,
|
|
||||||
но подменяет весь клиент готовым rejection и не проверяет parser.
|
|
||||||
|
|
||||||
**Минимальное действие:** различать отсутствующий/повреждённый block и корректный
|
|
||||||
`data: []`; проверять структуру строк и cursor, положительный pageSize и строгое
|
|
||||||
продвижение start. Нарушение протокола → `MOEX_UNAVAILABLE`, без частичного success.
|
|
||||||
|
|
||||||
**Приёмка:**
|
|
||||||
|
|
||||||
- Malformed block/cursor не меняет сохранённый полный календарь/forecast.
|
|
||||||
- Корректный пустой block остаётся успешным пустым результатом.
|
|
||||||
- Непродвигающаяся pagination завершается ошибкой, без бесконечных fetch.
|
|
||||||
- Семантика HTTP 404 проверена отдельно и не изменена случайно.
|
|
||||||
- Downstream regression использует настоящий MoexClient с mock fetch, а не mock rejection клиента.
|
|
||||||
|
|
||||||
**Риск:** перепутать законное отсутствие данных с ошибкой protocol shape.
|
|
||||||
|
|
||||||
### A04. Screen sync сообщает succeeded до завершения successor
|
|
||||||
|
|
||||||
**Приоритет:** средний. **Уверенность:** высокая.
|
|
||||||
|
|
||||||
**Места:** [TbankSyncService.status, 330–345](src/modules/investments/tbank-sync/tbank-sync.service.ts),
|
|
||||||
[TbankSyncRepository.read, 70–80](src/modules/investments/tbank-sync/tbank-sync.repository.ts).
|
|
||||||
[Operations repository, 441–445](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts)
|
|
||||||
помечает объединяемую группу `superseded` и сохраняет successorId. Владелец операций
|
|
||||||
умеет разрешать цепочку в observe, строки 564–590, но screen читает прямого child.
|
|
||||||
|
|
||||||
**Сценарий:** две активные screen-группы на части января; третья объединяет интервалы.
|
|
||||||
Screen вытесненной группы сообщает succeeded, хотя successor ещё running; его
|
|
||||||
последующая ошибка также теряется.
|
|
||||||
|
|
||||||
**Воспроизведение настоящего status с mocked persistence:**
|
|
||||||
|
|
||||||
```text
|
|
||||||
screenState=succeeded
|
|
||||||
resource.availability=missing
|
|
||||||
resource.sync.state=running
|
|
||||||
actualOperationState=running
|
|
||||||
```
|
|
||||||
|
|
||||||
SQLite bridge покрыт [operations-sync e2e, 455–474](test/tbank-operations-sync.e2e-spec.ts),
|
|
||||||
но существующая проверка наблюдает operations.observe, а не screen status.
|
|
||||||
|
|
||||||
**Минимальное действие:** разрешать operations successors через существующего
|
|
||||||
владельца перед агрегацией screen state/progress. Не копировать второй алгоритм
|
|
||||||
разрешения цепочки в screen service.
|
|
||||||
|
|
||||||
**Приёмка:** configured HTTP + SQLite bridge; задержанный successor → running,
|
|
||||||
затем успех → succeeded или отказ → failed; progress согласован, чужая revision
|
|
||||||
не видна. **Риск:** fencing, цепочки successors и корректный подсчёт progress.
|
|
||||||
|
|
||||||
### A05. Force обновления свечей теряется при coalescing
|
|
||||||
|
|
||||||
**Приоритет:** средний. **Уверенность:** высокая.
|
|
||||||
|
|
||||||
**Места:** [CandleService.request/load, 326–365](src/modules/investments/instruments/application/candle.service.ts),
|
|
||||||
[CandleRepository.reserve, 76–82](src/modules/investments/instruments/infrastructure/candle.repository.ts).
|
|
||||||
Публичный caller: [TbankSyncService, 288–306](src/modules/investments/tbank-sync/tbank-sync.service.ts)
|
|
||||||
вызывает candles.request с force=true; candleRange необязателен, ключ может совпасть
|
|
||||||
с ordinary GET/prefetch.
|
|
||||||
|
|
||||||
При launch=false service возвращает существующий id без сохранения новых force/priority.
|
|
||||||
При присоединении cross-user observer требования также не повышаются.
|
|
||||||
|
|
||||||
**Воспроизведение настоящего класса с mocked repository/upstream:** январь покрыт,
|
|
||||||
ordinary period=all заблокирован перед загрузкой detail, затем приходит force all.
|
|
||||||
После release единственный RPC идёт от `2026-02-01T00:00:00.000Z` до
|
|
||||||
`2026-02-02T12:00:00.000Z`; январь не перечитан. Исправленные исторические значения
|
|
||||||
остаются прежними. Existing candle suite отдельно проверяет force и coalescing.
|
|
||||||
|
|
||||||
**Минимальное действие:** сохранить forced требование и выполнить полную reconciliation.
|
|
||||||
Если gap-план уже начат, безопасно перепланировать или выполнить следующую forced
|
|
||||||
попытку. Простого `force ||= true` после публикации первых окон недостаточно.
|
|
||||||
Аналог повышения требований есть в CatalogDiscoveryService, но его алгоритм нельзя
|
|
||||||
механически переносить на staged свечи.
|
|
||||||
|
|
||||||
**Приёмка:** ordinary load + force того же scope действительно обновляют уже
|
|
||||||
закрытую свечу. Возвращённый work ID завершается только после выполнения forced
|
|
||||||
требования с актуальным диапазоном запроса. Проверить join до запуска и после
|
|
||||||
публикации первого обычного окна, включая observer другого пользователя;
|
|
||||||
экранный запрос получает экранный приоритет. Ошибка следующего forced окна сохраняет
|
|
||||||
прежнюю историю и timestamps; user/revision fencing сохраняется.
|
|
||||||
Proof атомарности — существующая SQLite boundary.
|
|
||||||
|
|
||||||
**Риск:** высокий риск неверного локального исправления из-за staged/atomic publication.
|
|
||||||
|
|
||||||
### A06. Payout snapshot замораживает positionIsStale
|
|
||||||
|
|
||||||
**Приоритет:** средний. **Уверенность:** высокая.
|
|
||||||
|
|
||||||
**Место:** [ForecastRepository.resolve, 132–137](src/modules/investments/portfolio/infrastructure/forecast.repository.ts).
|
|
||||||
**Потребители:** [StockIncomeService, 67–87 и 161–163](src/modules/investments/instruments/stocks/application/stock-income.service.ts),
|
|
||||||
[BondIncomeService, 180–182](src/modules/investments/instruments/bonds/application/bond-income.service.ts).
|
|
||||||
|
|
||||||
Истечение 60-секундного portfolio TTL меняет isStale, но не source revision/timestamps.
|
|
||||||
Repository возвращает прежний payload вместо нового candidate. Возможны одновременно
|
|
||||||
`meta.freshness: stale` и `data.positionIsStale: false`.
|
|
||||||
|
|
||||||
**Воспроизведение:** настоящий repository с mocked transaction; candidate false,
|
|
||||||
затем true при тех же версиях → повторный результат false, всего одна запись.
|
|
||||||
Pure payout tests принимают stale input, но не проверяют aging persisted forecast.
|
|
||||||
|
|
||||||
**Минимальное действие:** вычислять возраст сохранённой позиции при выдаче по её
|
|
||||||
positionAt и действующей freshness policy. Учитывать именно позицию сохранённого
|
|
||||||
fallback, а не случайно подставлять свежесть более нового несовместимого портфеля.
|
|
||||||
Не сравнивать весь candidate без разбора: текущий calculatedAt у events вызовет
|
|
||||||
запись на каждом чтении. Не менять financial revision только из-за хода времени.
|
|
||||||
|
|
||||||
**Приёмка:** controllable clock, TTL истёк без изменения источников, sync=observe:
|
|
||||||
stale=true, суммы и source timestamps прежние, новых RPC нет; fallback остаётся
|
|
||||||
согласованным. **Риск:** смешение возраста сохранённой и текущей позиции.
|
|
||||||
|
|
||||||
### A07. Timestamp операций расходится между persistence и wire
|
|
||||||
|
|
||||||
**Приоритет:** средний. **Уверенность:** высокая.
|
|
||||||
|
|
||||||
**Место:** [brokerage-operation.mapper, 85–90](src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts)
|
|
||||||
игнорирует nanos; [repository, 653–657 и publish](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts)
|
|
||||||
учитывает миллисекунды. Consumers mapper: BrokerageOperationsService, обычный ledger
|
|
||||||
и income reads (строки 359 и 619 на базе аудита).
|
|
||||||
|
|
||||||
**Воспроизведение:** seconds=1768000000, nanos=600000000 → persistence
|
|
||||||
`2026-01-09T23:06:40.600Z`, wire `2026-01-09T23:06:40.000Z`.
|
|
||||||
Запрос from=`…40.500Z` возвращает строку с видимым временем раньше from.
|
|
||||||
Existing mapper specs проверяют money/quantity, но не это соответствие.
|
|
||||||
|
|
||||||
**Минимальное действие:** согласовать конвертацию protobuf timestamp; при возможности
|
|
||||||
использовать уже сохранённый occurredAt для wire. Предотвратить дальнейшее расхождение
|
|
||||||
реализаций без создания универсальной библиотеки дат.
|
|
||||||
|
|
||||||
**Приёмка:** subsecond mapper case и HTTP boundary внутри секунды; порядок/cursor,
|
|
||||||
обычный ledger и income используют согласованное время. **Риск:** исправляется
|
|
||||||
публичное значение timestamp; формат и точность Money сохраняются.
|
|
||||||
|
|
||||||
### A08. Configured validation response теряет ошибки полей
|
|
||||||
|
|
||||||
**Приоритет:** средний. **Уверенность:** высокая.
|
|
||||||
|
|
||||||
**Места:** [configureApp, 29](src/main.ts),
|
|
||||||
[HttpExceptionFilter, 44–48 и 70–78](src/common/filters/http-exception.filter.ts).
|
|
||||||
ValidationPipe выдаёт message:string[], filter приводит его к ValidationError[]
|
|
||||||
и ищет constraints. children вложенных ошибок также не обходятся.
|
|
||||||
|
|
||||||
**Configured HTTP reproduction:** invalid email и отсутствующий password:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"status":400,"code":"VALIDATION_ERROR","errors":[]}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ожидаемый [HTTP contract](.ai/conventions/http-api.md) требует pointer/detail.
|
|
||||||
Conventions уже фиксируют это известное расхождение. OpenAPI parity проверяет
|
|
||||||
schema, но не наблюдаемый response pipeline.
|
|
||||||
|
|
||||||
**Минимальное действие:** согласовать exceptionFactory и filter, сформировать
|
|
||||||
структурированные ошибки, включая вложенные поля, без target/value исходного DTO.
|
|
||||||
|
|
||||||
**Приёмка:** реальные requests credentials и nested Money дают непустые корректные
|
|
||||||
pointer/detail; status/code/content-type сохраняются; неизвестные исключения
|
|
||||||
по-прежнему безопасны. Проверить согласование с A02 и публичной metadata.
|
|
||||||
**Риск:** public error shape и корректность вложенных путей.
|
|
||||||
|
|
||||||
### A09. Planner зависит от service barrel и образует runtime import cycle
|
|
||||||
|
|
||||||
**Приоритет:** средний. **Уверенность:** высокая.
|
|
||||||
|
|
||||||
**Места:** [investment-sync-plan, 3–6](src/modules/investments/investment-sync-plan.ts)
|
|
||||||
→ [operations.api](src/modules/investments/operations/operations.api.ts)
|
|
||||||
→ operations services → investment-sync-plan.
|
|
||||||
|
|
||||||
Алгоритму нужны interval rules, но barrel загружает services, TbankClient и Prisma.
|
|
||||||
[Planner spec, строка 1](src/modules/investments/investment-sync-plan.spec.ts) уже
|
|
||||||
вынужден mock Prisma. Это доказанная стоимость связности; production crash из-за
|
|
||||||
цикла не обнаружен. Type-only цикл T-Bank records — отдельный безвредный случай.
|
|
||||||
|
|
||||||
**Минимальное действие:** предоставить interval rules чистый публичный вход,
|
|
||||||
аналогичный разрешённому integrations/tbank/decimal.ts; service API оставить отдельно.
|
|
||||||
Не заменять cycle на forwardRef и не вводить новый DI слой.
|
|
||||||
|
|
||||||
**Приёмка:** planner импортируется/тестируется без Prisma mock; cycle устранён,
|
|
||||||
interval semantics и DI initialization сохранены. **Риск:** низкий, import/export surface.
|
|
||||||
|
|
||||||
### A10. Неиспользуемый legacy T-Bank access flow
|
|
||||||
|
|
||||||
**Приоритет:** низкий. **Уверенность:** высокая.
|
|
||||||
|
|
||||||
**Место:** [TbankAccessService, 255–330](src/integrations/tbank/tbank-access.service.ts).
|
|
||||||
listSupportedAccounts/withSupportedAccounts/withConnection/withAccount/
|
|
||||||
withAccountForConnection/private connection вызываются только внутри собственной
|
|
||||||
цепочки и tests. Проверены production consumers, DI и decorators.
|
|
||||||
|
|
||||||
Legacy flow поддерживает generation=hash(ciphertext), тогда как живой persisted
|
|
||||||
flow использует DB revision, local и tokenForAttempt. Поддерживаются две модели
|
|
||||||
без production-потребности; около 76 строк, context types и TbankClient dependency.
|
|
||||||
|
|
||||||
**Минимальное действие:** повторно проверить callers на актуальном HEAD, удалить
|
|
||||||
legacy-цепочку, неиспользуемые context exports и зависимость; отдельно проверить
|
|
||||||
оставшихся consumers accessibleTbankAccount до его удаления.
|
|
||||||
|
|
||||||
**Приёмка:** нет мёртвой цепочки, живые access/invalid-token/revision/fencing cases
|
|
||||||
сохранены. Перенести полезные security assertions, если они были только в удаляемых
|
|
||||||
specs. **Риск:** удалить общее правило или полезную проверку вместе с legacy.
|
|
||||||
|
|
||||||
## Локальные упрощения
|
|
||||||
|
|
||||||
Все пункты: низкий приоритет, высокая уверенность. Перед удалением повторить поиск
|
|
||||||
callers; отсутствие production callers в базе аудита не гарантирует его на новом HEAD.
|
|
||||||
|
|
||||||
| ID | Место и доказательство | Минимальное действие и приёмка |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| C01 | [portfolio.service, 515–522](src/modules/investments/portfolio/application/portfolio.service.ts): локальный decimalFromString повторяет common parser без validation; callers position/value | Импортировать существующий common owner; удалить дубликат. Exact-money tests и typecheck. Текущая ошибка сумм не доказана; проверить допустимые входы. |
|
|
||||||
| C02 | [tbank.api](src/integrations/tbank/tbank.api.ts): TbankSyncAttempt импортируют напрямую семь внешних файлов sync/instruments; enrichment также обходит API для market types | Экспортировать используемые типы, перенаправить imports через API. TbankCoupon уже экспортируется. Typecheck и поиск обходов; runtime-риск минимален. |
|
|
||||||
| C03 | [auth.service, 190–192](src/modules/auth/application/auth.service.ts): normalizeEmail возвращает аргумент; register/login вызывают его, реальная нормализация в CredentialsDto | Удалить no-op, сохранить DTO transform. Existing HTTP case/whitespace tests; дополнительную нормализацию без необходимости не вводить. |
|
|
||||||
| C04 | [bank-card.calculations, 56–76](src/modules/banking/bank-cards/domain/bank-card.calculations.ts): string period используется только tests; production summary передаёт from/to | Сузить API до рабочего диапазона; перевести полезные unit cases. Проверить границы месяца/range. |
|
|
||||||
| C05 | [instrument-market, 39–51](src/modules/investments/instruments/application/instrument-market.ts): optional без consumers; [stock-position wrapper](src/modules/investments/instruments/stocks/application/stock-position.ts) нужен только своему spec | Удалить optional, перенаправить полезные wrapper tests на instrumentPosition. Сохранить fractional/mixed/offset holdings cases. |
|
|
||||||
| C06 | [portfolio.api, 2](src/modules/investments/portfolio/portfolio.api.ts): totalsByCurrency не имеет внешнего production consumer; внутренний использует mapper напрямую | Удалить только лишний export, mapper сохранить; typecheck. |
|
|
||||||
| C07 | [auth.controller.spec, 9–20](src/modules/auth/presentation/auth.controller.spec.ts): проверяет HTTP_CODE_METADATA через prototype; те же 200 проверяются configured HTTP suite | Удалить дублирующий wiring spec только при сохранении реальных HTTP status/cookie assertions. |
|
|
||||||
|
|
||||||
## Карта ответственности и проверенные сценарии
|
|
||||||
|
|
||||||
| Область / владелец | Интерфейс и consumers | Состояние, зависимости, инварианты и эффекты |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| App/config/common/Prisma | Composition root, configureApp, Money/decimal, logging | Env validation, HTTP pipeline, DB lifecycle, request context; общая арифметика без Nest/Prisma |
|
|
||||||
| Auth | Auth HTTP; guard/AccessPayload API для protected controllers | User/Session, bcrypt/JWT, refresh hash/CAS; cookies у controller |
|
|
||||||
| Bank cards/operations/categories | BankCardsController → профильные services | Ownership, archive/version/replay/tombstones; refund budget и перевод в общей transaction boundary |
|
|
||||||
| Bank summary | Controller → use case → repository → pure calculation | Согласованное чтение и BigInt-сводка по периоду |
|
|
||||||
| Brokerage accounts | HTTP/read API для screen → service → PortfolioService | Собственной persistence нет; missing membership отличается от confirmed empty |
|
|
||||||
| Portfolio/forecast | HTTP и API для instruments/events/accounts | Private user/revision/account snapshots; mapping/decimal; aggregate completeness; atomic dependency version checks |
|
|
||||||
| Operations | HTTP/API для income/screen | Local journal, signed cursor binding; durable work/staging, intervals, recovery, atomic reconciliation |
|
|
||||||
| Income analytics | HTTP/read API → Operations API | Period roles/completeness, grouping и точные signed totals по валютам; своей DB нет |
|
|
||||||
| Upcoming events | HTTP/read API → portfolio/calendars → forecast | Events по positions; missing calendar не равен пустому; redemption исключён из income totals |
|
|
||||||
| Catalog discovery/enrichment | Stock/bond services и public source APIs | Shared identity/aliases/source rows; private work, TTL, batching, coalescing и generation-fenced publication |
|
|
||||||
| Candles | Market services/screen → CandleService/Repository | Shared candles/coverage; mutable tail, gaps, windows, очередь и staged force replacement |
|
|
||||||
| Stock/bond position/income | Controllers → application services | UID/type matching, exact weighted cost, mixed currencies, payout estimates; Portfolio/Catalog/Forecast APIs |
|
|
||||||
| T-Bank connection | Guarded GET/PUT/DELETE | Проверка токена до replacement, AES-GCM, новая revision/cascade; failed validation сохраняет connection |
|
|
||||||
| T-Bank client/access/membership/metadata | Integration API для business modules | gRPC/TLS, единые limiter/retry/deadline; credentials, account access, membership и publication fencing |
|
|
||||||
| Screen sync | POST descriptor / GET status | Orchestration через APIs; durable children/attempts; passive observation |
|
|
||||||
| MOEX/NCR | Integration APIs → enrichment | ISS parser/pagination; NCR strict registry parser/exact ISIN/shared cache |
|
|
||||||
|
|
||||||
Прослежены auth/session flows; создание/изменение карт, операций, переводов,
|
|
||||||
возвратов и summary; ledger→sync→staging→publication; portfolio→mapping→snapshot;
|
|
||||||
payouts/events→forecast; catalog/candles и screen orchestration.
|
|
||||||
|
|
||||||
Повторной регистрации владельцев caches/queues и циклов Nest modules не обнаружено.
|
|
||||||
InstrumentSourcesModule позволяет portfolio пользоваться источниками без DI-цикла
|
|
||||||
с InstrumentsModule. Type-only цикл T-Bank records не является finding.
|
|
||||||
|
|
||||||
## Размеры production-файлов
|
|
||||||
|
|
||||||
Количество строк включает comments/blank lines. Размер — основание для чтения,
|
|
||||||
а не самостоятельный дефект. Обязательные splits не обоснованы.
|
|
||||||
|
|
||||||
| Файл | Строк | Решение и причина |
|
|
||||||
| --- | ---: | --- |
|
|
||||||
| [brokerage-operations.repository](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts) | 924 | Оставить: overlap groups, staging, recovery и publication держат общий transactional invariant |
|
|
||||||
| [catalog-enrichment.service](src/modules/investments/instruments/application/catalog-enrichment.service.ts) | 665 | Упростить локально: protocol boundary/imports; сохранить producers, batching, completions вместе |
|
|
||||||
| [brokerage-operations.service](src/modules/investments/operations/application/brokerage-operations.service.ts) | 636 | Оставить: ledger/income имеют различную completeness; generic builder добавит скрытые режимы |
|
|
||||||
| [tbank-sync.service](src/modules/investments/tbank-sync/tbank-sync.service.ts) | 595 | Упростить: разрешение successors вернуть operations owner, generic job framework не нужен |
|
|
||||||
| [tbank.client](src/integrations/tbank/tbank.client.ts) | 560 | Оставить: records/RPC methods и единый limiter/retry owner |
|
|
||||||
| [portfolio.service](src/modules/investments/portfolio/application/portfolio.service.ts) | 522 | Упростить: удалить duplicate parser, scheduling state не разрывать |
|
|
||||||
| [candle.service](src/modules/investments/instruments/application/candle.service.ts) | 481 | Оставить: исправить force join, сохранить queue/publication invariant |
|
|
||||||
| [catalog-discovery.repository](src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts) | 480 | Оставить: identity/aliases/source publication и CAS связны |
|
|
||||||
| [catalog-discovery.service](src/modules/investments/instruments/application/catalog-discovery.service.ts) | 390 | Оставить: search/detail и producer lifecycle имеют одного owner |
|
|
||||||
| [bank-operations.service](src/modules/banking/bank-cards/application/bank-operations.service.ts) | 343 | Оставить: разрыв validation/refund/transfer writes повышает риск |
|
|
||||||
| [tbank-access.service](src/integrations/tbank/tbank-access.service.ts) | 331 | Упростить удалением legacy flow |
|
|
||||||
| [investment-sync-plan](src/modules/investments/investment-sync-plan.ts) | 327 | Упростить зависимость: убрать service barrel из алгоритма |
|
|
||||||
| [instrument-metadata.repository](src/integrations/tbank/instrument-metadata.repository.ts) | 320 | Оставить: identity, batching и publication согласованы |
|
|
||||||
| [income-analytics.service](src/modules/investments/income/income-analytics.service.ts) | 318 | Оставить: связные projections, выгода дополнительных моделей не доказана |
|
|
||||||
| [candle.repository](src/modules/investments/instruments/infrastructure/candle.repository.ts) | 306 | Оставить: reconciliation, coverage, fencing и stage cleanup |
|
|
||||||
| [upcoming-events.service](src/modules/investments/upcoming-events/upcoming-events.service.ts) | 297 | Оставить: один сценарий, основной дефект у MOEX boundary |
|
|
||||||
| [brokerage-operations-sync.service](src/modules/investments/operations/application/brokerage-operations-sync.service.ts) | 294 | Оставить: связный владелец durable work execution |
|
|
||||||
|
|
||||||
Отдельные категории: 37 unit specs / 3319 строк; 21 HTTP/SQLite specs / 7878 строк;
|
|
||||||
10 test support/fixtures / 1993 строки; 6 vendor proto / 3588 строк;
|
|
||||||
5 SQL migrations / 345 строк. Generated Prisma и build artifacts не ранжировались.
|
|
||||||
|
|
||||||
## Удачные решения, которые требуется сохранить
|
|
||||||
|
|
||||||
- BigInt/decimal и string Money; разные валюты не складываются неявно.
|
|
||||||
- Refresh CAS, ownership queries, версии операций, replay fingerprints/tombstones.
|
|
||||||
- Network calls отделены от DB transactions.
|
|
||||||
- Revision/attempt fencing, late-result discard, staging перед force reconciliation.
|
|
||||||
- Missing/partial/confirmed empty/saved complete различаются явно.
|
|
||||||
- Concrete providers, компактные плоские модули и разрешённый прямой Prisma
|
|
||||||
в связных CRUD/transaction services не являются автоматическими дефектами.
|
|
||||||
- SQLite behavior tests проверяют реальные денежные границы, races и rollback.
|
|
||||||
|
|
||||||
## Выполненные проверки и пределы доказательства
|
|
||||||
|
|
||||||
Перед успешными Node-командами загружалась `~/.nvm/nvm.sh`, затем `nvm use --silent`.
|
|
||||||
Первый вызов nvm без загрузки shell function завершился command not found;
|
|
||||||
после загрузки проверок это ограничение не осталось.
|
|
||||||
|
|
||||||
| Команда / проверка | Результат аудита |
|
|
||||||
| --- | --- |
|
|
||||||
| git status, git rev-parse HEAD, git diff --check | Чистое дерево, неизменный commit, diff errors отсутствуют |
|
|
||||||
| npm run lint | Успешно |
|
|
||||||
| npm run typecheck:app; npm run typecheck:test | Успешно |
|
|
||||||
| npm test -- --runInBand | 37 suites / 200 tests passed |
|
|
||||||
| Build в npm run test:e2e | Prisma generation и Nest build успешны |
|
|
||||||
| Полный e2e в sandbox | listen EPERM на localhost; не засчитан |
|
|
||||||
| LOG_LEVEL=error node test/sqlite.cjs --all, с разрешением localhost | all-accounts: 18 passed; auth-suite затем завершилась native SIGABRT |
|
|
||||||
| node test/sqlite.cjs tbank-screen-sync.e2e-spec.ts tbank-operations-persistence.e2e-spec.ts tbank-candles-persistence.e2e-spec.ts tbank-metadata-persistence.e2e-spec.ts tbank-local-reads.e2e-spec.ts openapi-contract.e2e-spec.ts | Первые три: 27 + 5 + 9 passed; metadata native abort; последующие не достигнуты |
|
|
||||||
| node test/sqlite.cjs openapi-contract.e2e-spec.ts, отдельно | 3 tests passed |
|
|
||||||
| Inline reproductions | Подтверждены описанные случаи; validation/logging через configureApp, остальные с указанными mocked boundaries |
|
|
||||||
|
|
||||||
Native abort произошёл в `node::RemoveEnvironmentCleanupHook` / `better_sqlite3.node`
|
|
||||||
на Node 24.21.0. Причина не установлена; связывать его с конкретной source-находкой
|
|
||||||
нельзя. Полный e2e gate не подтверждён. При реализации сначала повторить в штатном
|
|
||||||
окружении; если проблема остаётся, диагностировать отдельно и явно сообщить,
|
|
||||||
не отключая падающие suites и не выдавая неполную проверку за зелёную.
|
|
||||||
|
|
||||||
Логи текущей сессии (временные файлы, могут исчезнуть):
|
|
||||||
`/tmp/wealthledger-audit-e2e.log`, `/tmp/wealthledger-audit-targeted-e2e.log`,
|
|
||||||
`/tmp/wealthledger-audit-openapi.log`. Документ не зависит от сохранности этих файлов.
|
|
||||||
|
|
||||||
**Проверено:** все production-модули, composition/API, основные consumers,
|
|
||||||
значимые flows, связанные tests кандидатов. Все production auth/banking,
|
|
||||||
integrations/sync/operations и остальные investment slices прочитаны.
|
|
||||||
|
|
||||||
**Частично проверено:** весь набор HTTP fixtures и integration suites не проходил
|
|
||||||
одинаково глубокий построчный аудит; реальное выполнение e2e ограничено указанным
|
|
||||||
native abort. Schema прочитана; история миграций не проходила полный compatibility audit.
|
|
||||||
|
|
||||||
**Не проверено:** clean npm ci, полный verify с coverage как единая команда,
|
|
||||||
durability/restart/backup, container/deployment, production performance,
|
|
||||||
реальные внешние API и полная сверка vendor proto с upstream.
|
|
||||||
|
|
||||||
### Финальная проверка реализации
|
|
||||||
|
|
||||||
Использовать актуальные scripts/conventions backend. На базе аудита для code changes
|
|
||||||
требуются npm ci, npm run verify, relevant SQLite/HTTP checks; для этих HTTP изменений
|
|
||||||
также OpenAPI generation/parity. Полный npm run test:e2e нужен для итоговой проверки
|
|
||||||
совокупных изменений. Перед Node — nvm use --silent.
|
|
||||||
|
|
||||||
Автоисправления lint/format не являются доказательством. Не запускать миграции,
|
|
||||||
reset/drop или tests на пользовательской БД. Штатный SQLite runner создаёт временные
|
|
||||||
базы и защищает от использования внешнего DATABASE_URL.
|
|
||||||
|
|
||||||
По завершении повторить routing по staged/unstaged/new files, выполнить Boy Scout
|
|
||||||
и semantic review: callers, owners, money/access/atomicity/error contracts,
|
|
||||||
generation/cache/async lifecycle. Указать точные команды, outcomes и ограничения.
|
|
||||||
|
|
||||||
## Неподтверждённые гипотезы — вне scope исправлений
|
|
||||||
|
|
||||||
- Logout/refresh interleaving и browser cookie overwrite требуют явных ordering
|
|
||||||
semantics и отдельного HTTP/browser proof.
|
|
||||||
- N+1 в карточных views виден, но измеренная latency/timeout проблема не установлена.
|
|
||||||
- JSON casts внутренних сохранённых payload сами по себе не доказывают небезопасную boundary.
|
|
||||||
- Cash/accrued-interest valuation: локального контракта недостаточно для обвинения в ошибке.
|
|
||||||
- Native SQLite abort требует отдельной диагностики runtime/dependency lifecycle.
|
|
||||||
|
|
||||||
## Статус исправлений
|
|
||||||
|
|
||||||
Начало исправлений: HEAD `516e43203eb3fee8e6a0c7018be2fee11f1b900c`,
|
|
||||||
ветка `codex/investment-contracts`; `git status --short`: только
|
|
||||||
`?? BACKEND_AUDIT.md`, staged/unstaged tracked changes отсутствовали.
|
|
||||||
Исходные разделы аудита сохранены. `npm ci` успешно: 1065 packages, 44s;
|
|
||||||
manifest/lockfile не менялись. Перед Node-командами загружается `~/.nvm/nvm.sh`
|
|
||||||
и выполняется `nvm use --silent`.
|
|
||||||
|
|
||||||
Все A01–A10 и C01–C07 повторно подтверждены и реализованы в порядке пакетов.
|
|
||||||
Статусы ниже разделяют готовность исправления и полноту проверки: native abort
|
|
||||||
не засчитывается как успешный behavioral test. Неподтвердившихся и уже исправленных
|
|
||||||
на исходном HEAD пунктов не обнаружено.
|
|
||||||
|
|
||||||
| ID | Статус | Изменение / доказательство | Проверки и ограничения |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| A01 | Исправлено и проверено | Logout подавляет только JWT verification failure; refresh удаляет cookie только при UnauthorizedException. Configured HTTP red: logout 204 вместо 500, refresh удалил cookie при отказе БД. | `node test/sqlite.cjs app.e2e-spec.ts`: regression воспроизведена на временной SQLite; macOS green повтор и первый полный e2e столкнулись с native SIGABRT. Итоговый Linux e2e: auth 12/12 passed, включая revocation failure и сохранение cookie для retry. |
|
|
||||||
| A02 | Исправлено и проверено | Удалены raw stack и его неиспользуемый transport/allowlist; regression проверяет multiline synthetic secret в конечном Pino payload и correlation. | Targeted filter/logging: 3 suites / 4 tests passed, unit red → green; macOS configured HTTP runner завершился native SIGABRT; итоговый Linux auth 12/12 passed, включая настоящий filter → middleware → Pino payload. |
|
|
||||||
| A03 | Исправлено и проверено | MoexClient отклоняет отсутствующий/повреждённый block, неверную ширину row, malformed/nonprogressing cursor. Пустые блоки и 404 сохранены. | Unit red 12 failures → 21 passed; `node test/sqlite.cjs upcoming-events.e2e-spec.ts`: downstream red 2 failures → 18 passed, настоящий client + mock fetch сохраняют calendar/forecast payload/revisions/timestamps. Typecheck и scoped lint passed. |
|
|
||||||
| A04 | Исправлено и проверено | Screen status разрешает superseded child через operations.observe перед агрегацией state/progress; attempt/revision fencing сохранён. | `node test/sqlite.cjs tbank-screen-sync.e2e-spec.ts`: HTTP/SQLite red succeeded вместо running → 29 passed; successor success/failure, progress, чужой user и удалённая revision. |
|
|
||||||
| A05 | Исправлено и проверено | Каждый join повышает force/priority при более сильном требовании; идентичный join не меняет updatedAt. Load фиксирует force/now на проход, при новой forced потребности выполняет полную reconciliation. Финальная публикация и observers сериализованы с reservations; screen priority=0. | Service unit red → green (полный исторический диапазон и более поздний now); `node test/sqlite.cjs tbank-candles-persistence.e2e-spec.ts`: 14 passed на финальном коде, same/cross-user join до загрузки и после обычного окна, rollback второго forced окна, timestamps и fencing. Native abort в первом финальном запуске; единственный повтор без изменений прошёл. |
|
|
||||||
| A06 | Исправлено и проверено | ForecastService вычисляет возвращаемый positionIsStale из сохранённого positionAt через существующую portfolio TTL policy; не меняет финансовый snapshot. | `node test/sqlite.cjs upcoming-events.e2e-spec.ts`: red stock/bond false на TTL → 20 passed; TTL−1/TTL, observe без RPC, прежние суммы/revisions/timestamps, fallback при несовместимом свежем candidate. Review дополнительно воспроизвёл meta.freshness=fresh для старого fallback (2 red); freshForMs теперь учитывает сохранённый stale flag, repeat 20 passed, stateVersion рассчитывается штатно. |
|
|
||||||
| A07 | Исправлено и проверено | Одна operationTimestamp conversion у mapper для stage/publish; ledger/income wire используют сохранённый occurredAt. Missing seconds по-прежнему отклоняется. | Unit red .600→.000 → 9 passed; `node test/sqlite.cjs tbank-ledger-local.e2e-spec.ts`: HTTP red → 11 passed (from=.500, cursor .800/.600, income timestamp/exact total). Оба typecheck/lint passed. На macOS operations-persistence завершилась native SIGABRT и не засчитана. Итоговый Linux полный e2e: ledger 11/11 и operations-persistence 5/5 passed. |
|
|
||||||
| A08 | Исправлено и проверено | Global/screen ValidationPipe сохраняют structured errors без target/value; filter обходит children и экранирует JSON pointers. Удалён descriptorErrors; OpenAPI errors.items согласован с object shape. | Nested Money HTTP red errors:[] → bank22 / screen29 passed; escaped pointer и отсутствие дублей/secret проверены. Unit7 и scoped lint passed. Metadata red items:string → финальный OpenAPI parity 3 passed. На macOS auth suite native SIGABRT; итоговый Linux auth 12/12 passed, включая credential field errors без input leak. |
|
|
||||||
| A09 | Исправлено и проверено | Planner импортирует существующий чистый operations/domain/operations-sync-plan как явно документированный публичный вход; service barrel больше не экспортирует runtime interval rules. | Planner и CandleService tests выполняются без Prisma mock; targeted пакет 7 suites / 49 tests passed. DI подтверждается configured HTTP и итоговым build. |
|
|
||||||
| A10 | Исправлено и проверено | Удалены шесть legacy methods, context types, TbankClient constructor dependency и accessibleTbankAccount без живых callers; legacy-only spec удалён. | Поиск src/test не находит legacy symbols; 5 unit suites / 11 tests, оба typecheck, scoped lint/build; connection HTTP/SQLite4 passed: missing/invalid/unsupported/cross-user, replacement/deletion, revision/fencing. Token error/crypto assertions сохранены. MacOS metadata-persistence15 passed, operations-sync/catalog-discovery native SIGABRT. Итоговый Linux e2e: metadata15, operations-sync17 и catalog-discovery10 passed. |
|
|
||||||
| C01 | Исправлено и проверено | PortfolioService использует common decimalFromString; локальный parser удалён. Входы из money/formatDecimal уже canonical; текущая ошибка сумм не заявляется. | Exact-money portfolio tests в targeted49 passed; итоговые types/verify ниже. |
|
|
||||||
| C02 | Исправлено и проверено | TbankSyncAttempt/TbankLastPrice экспортируются type-only через tbank.api; семь production consumers используют API, TbankCoupon переиспользован. | Поиск прямых imports tbank-access.service/tbank-market.types в src/modules пуст; оба typecheck и scoped lint passed. |
|
|
||||||
| C03 | Исправлено и проверено | Удалён no-op normalizeEmail; CredentialsDto transform trim/lowercase сохранён. | Auth unit passed; реальные case/whitespace HTTP assertions сохранены. После macOS native SIGABRT полный Linux auth gate прошёл: 12/12. |
|
|
||||||
| C04 | Исправлено и проверено | bankSummary принимает только рабочий диапазон from/to; строковый month overload удалён, полезные unit cases переведены на диапазоны. | Targeted49 passed: month start/end, outside range, signed64/BigInt суммы и refunds. |
|
|
||||||
| C05 | Исправлено и проверено | Удалены optional без callers и stockPosition wrapper; spec перенесён к instrumentPosition, проверяет напрямую владельца. | Targeted49 passed: fractional/large quantity, weighted costs, mixed/unknown currencies, no position и offset long/short. |
|
|
||||||
| C06 | Исправлено и проверено | Удалён неиспользуемый внешний totalsByCurrency export; живой mapper и внутренний caller сохранены. | Поиск imports подтверждает единственный внутренний caller; exact-money targeted tests passed, итоговый typecheck ниже. |
|
|
||||||
| C07 | Исправлено и проверено | Удалён HTTP_CODE_METADATA wiring spec; реальные register/login/refresh status200 и logout/cookie assertions сохранены в app.e2e. | Проверены callers/оставшиеся HTTP assertions; полный Linux auth gate прошёл: 12/12. Предыдущий macOS native SIGABRT остаётся отдельным ограничением среды. |
|
|
||||||
|
|
||||||
### Gates первого прогона (macOS)
|
|
||||||
|
|
||||||
Все команды выполнены из backend на Node `v24.21.0` после `nvm use --silent`.
|
|
||||||
Проверки данных использовали штатный runner, fresh temporary SQLite и штатные
|
|
||||||
migrations; рабочая БД не использовалась. Для localhost/Prisma cache запуски
|
|
||||||
получили разрешение вне sandbox; native abort повторялся и там.
|
|
||||||
|
|
||||||
| Команда | Итог |
|
|
||||||
| --- | --- |
|
|
||||||
| `npm ci` | PASS; manifest и lockfile без изменений. |
|
|
||||||
| `npm run verify` | PASS: lint, app/test typecheck, 36 suites / 209 unit tests с coverage, build. |
|
|
||||||
| `npm run openapi:generate` | PASS; актуальный игнорируемый `openapi.json` сформирован. |
|
|
||||||
| `npm run test:openapi-contract` | PASS: 1 suite / 3 tests, включая object shape validation errors. |
|
|
||||||
| `npx prisma validate` | PASS. Schema и migrations не менялись. |
|
|
||||||
| `npm run test:durability -- /tmp/wealthledger-fix-baseline-current-runtime-fjftdF` | PASS. Baseline `0862d56`, до последней T-Bank migration, отдельно установлен и собран на текущем Node; результат `/var/folders/j_/h2ft23gd3qq35ps3v5bcftfm0000gn/T/wealthledger-bank-durability-SIeCq0`. |
|
|
||||||
| `LOG_LEVEL=error npm run test:e2e` | INCOMPLETE, exit 1: all-accounts 18/18 passed, затем app.e2e native SIGABRT до Jest summary. Остальные suites общий gate не выполнил. |
|
|
||||||
| `node test/sqlite.cjs tbank-candles-persistence.e2e-spec.ts` | PASS на финальном коде: 14/14 после одного native abort и повтора без edits. |
|
|
||||||
| `node test/sqlite.cjs tbank-metadata-persistence.e2e-spec.ts` | PASS на финальном коде: 15/15. |
|
|
||||||
| `node test/sqlite.cjs tbank-operations-sync.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. |
|
|
||||||
| `node test/sqlite.cjs tbank-operations-persistence.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. |
|
|
||||||
| `node test/sqlite.cjs tbank-catalog-discovery.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. |
|
|
||||||
| `git diff --check` | PASS. |
|
|
||||||
|
|
||||||
Логи итоговых gates: `/tmp/wealthledger-fix-verify.log`,
|
|
||||||
`/tmp/wealthledger-fix-openapi-generate.log`, `/tmp/wealthledger-fix-openapi-contract.log`,
|
|
||||||
`/tmp/wealthledger-fix-prisma-validate.log`, `/tmp/wealthledger-fix-durability.log`,
|
|
||||||
`/tmp/wealthledger-fix-final-e2e.log`. Дополнительные SQLite логи:
|
|
||||||
`/tmp/wealthledger-fix-a05-latest-sqlite-retry.log`,
|
|
||||||
`/tmp/wealthledger-fix-final-metadata-persistence.log`,
|
|
||||||
`/tmp/wealthledger-final-operations-sync.log`,
|
|
||||||
`/tmp/wealthledger-final-operations-persistence.log`,
|
|
||||||
`/tmp/wealthledger-fix-final-catalog-discovery.log`.
|
|
||||||
|
|
||||||
Native failure: assertion `(env) != nullptr` в `node::RemoveEnvironmentCleanupHook`,
|
|
||||||
stack содержит destructor из `better_sqlite3.node`. Это конкретное ограничение
|
|
||||||
текущего native runtime, уже наблюдавшееся при исходном аудите; его первопричина
|
|
||||||
в рамках исправлений не установлена. Тесты не отключались, runner/dependencies
|
|
||||||
не изменялись. Эти macOS запуски остаются INCOMPLETE. Полный Linux прогон ниже
|
|
||||||
закрыл e2e gate и auth/operations/catalog acceptance; локальный native abort
|
|
||||||
на macOS при этом не исправлен.
|
|
||||||
|
|
||||||
### Повторная полная проверка (Linux, 2026-09-27)
|
|
||||||
|
|
||||||
**PASS: 21 e2e suites / 262 tests, exit 0**, ни одного skipped/disabled test.
|
|
||||||
Выполнена вся штатная `npm run test:e2e`: build и `node test/sqlite.cjs --all`.
|
|
||||||
Каждая suite получила отдельную temporary SQLite со всеми пятью migrations.
|
|
||||||
Закрыты прежде неполные auth12, operations-persistence5, operations-sync17,
|
|
||||||
catalog-discovery10; также прошли candles14, forecast/upcoming20, screen29,
|
|
||||||
OpenAPI3 и большой read-baseline с 300 000 операций / 1 000 000 свечей.
|
|
||||||
|
|
||||||
Перед повтором зафиксирован тот же HEAD и hashes всех tracked/untracked files;
|
|
||||||
до завершения тестов ни один исходник, тест, config или audit не менялся.
|
|
||||||
Поэтому предыдущие `verify` (209 unit tests), OpenAPI и durability сохраняют
|
|
||||||
актуальность. В этом повторе изменён только статус данного документа.
|
|
||||||
|
|
||||||
MacOS `LOG_LEVEL=error node test/sqlite.cjs app.e2e-spec.ts` снова завершился
|
|
||||||
native SIGABRT. Для Linux использован существующий Dockerfile target
|
|
||||||
`development`, Node `v24.21.0` (проверен против `.nvmrc`) и неизменённый lockfile:
|
|
||||||
SHA-256 `f986979b7922d684fead3a4d32532d11f1e469cec0e35669509343be41cbb2cd`.
|
|
||||||
Image `wealthledger-backend:audit-verification-20260927`,
|
|
||||||
ID `sha256:69264cf3dba23566d99fded450104bbe56f92755604e88232beb97825155ae73`.
|
|
||||||
Сборка выполнила `npm ci`; зависимости и runner не подменялись. Test/scripts
|
|
||||||
подключались read-only, внешняя сеть отключена, host ports не публиковались;
|
|
||||||
одноразовый контейнер удалён после завершения, рабочая БД не подключалась.
|
|
||||||
|
|
||||||
Команды воспроизведения из backend (на host предварительно `source ~/.nvm/nvm.sh`
|
|
||||||
и `nvm use --silent`; внутри image закреплён тот же Node):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build --target development --tag wealthledger-backend:audit-verification-20260927 .
|
|
||||||
docker run --rm --network none \
|
|
||||||
--mount type=bind,source=/Users/ksv741/Project/vibe/WealthLedger/backend/test,target=/app/test,readonly \
|
|
||||||
--mount type=bind,source=/Users/ksv741/Project/vibe/WealthLedger/backend/scripts,target=/app/scripts,readonly \
|
|
||||||
-e LOG_LEVEL=error wealthledger-backend:audit-verification-20260927 npm run test:e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
Логи: `/tmp/wealthledger-recheck-auth.log` (macOS abort),
|
|
||||||
`/tmp/wealthledger-recheck-linux-build.log` (Linux image build),
|
|
||||||
`/tmp/wealthledger-recheck-linux-e2e.log` (полный PASS).
|
|
||||||
|
|
||||||
Read-only диагностика локализовала macOS crash в GC finalizer Statement/Database
|
|
||||||
вложенного `better-sqlite3 12.11.1`, используемого Prisma adapter 7.10.0.
|
|
||||||
Node 24.21 ObjectWrap destructor вызывает RemoveEnvironmentCleanupHook, который
|
|
||||||
падает на `env != nullptr`. Связь с Jest VM context и происхождением native
|
|
||||||
binary остаётся гипотезой, а не доказанной первопричиной. Ошибка application
|
|
||||||
lifecycle не обнаружена: close/disconnect ожидаются. Linux PASS доказывает
|
|
||||||
поведение backend в этом окружении, но не устранение native crash на macOS.
|
|
||||||
|
|
||||||
### Semantic / Boy Scout review и состояние дерева
|
|
||||||
|
|
||||||
Повторный routing по staged/unstaged/new files активировал Architecture,
|
|
||||||
HTTP/errors, Data, Testing, Security, Integrations и Observability. Проверены
|
|
||||||
owners/callers и связанная документация; независимый review охватил auth/logging,
|
|
||||||
candle coalescing/publication, forecast fallback и cleanup. После устранения
|
|
||||||
найденной при review stale metadata у старого forecast fallback оставшихся
|
|
||||||
semantic замечаний нет. Проверены точная денежная арифметика, ownership,
|
|
||||||
transactions, generation fencing, сохранение cache/pending promises и отсутствие
|
|
||||||
новых дублирующих helpers.
|
|
||||||
|
|
||||||
На момент завершения проверки, до запроса публикации, HEAD оставался
|
|
||||||
`516e43203eb3fee8e6a0c7018be2fee11f1b900c`, ветка
|
|
||||||
`codex/investment-contracts`; staged files отсутствуют. Все изменения локальные,
|
|
||||||
без commit/push/PR. Новые untracked files: исходный `BACKEND_AUDIT.md`,
|
|
||||||
`src/modules/investments/instruments/application/candle.service.spec.ts` и
|
|
||||||
`src/modules/investments/instruments/application/instrument-position.spec.ts`.
|
|
||||||
Остальные изменения — tracked source/tests и три связанных convention-файла.
|
|
||||||
Новые зависимости, scripts, migrations и разделение production-файлов не добавлены.
|
|
||||||
Исходный текст до раздела «Статус исправлений» побайтово совпадает со снимком
|
|
||||||
`/tmp/wealthledger-backend-audit-before-fixes.md`.
|
|
||||||
|
|
||||||
### Подготовка публикации
|
|
||||||
|
|
||||||
После успешной полной проверки пользователь разрешил commit, PR и merge.
|
|
||||||
Ветка публикации — `codex/backend-audit-fixes` от актуального `origin/develop`
|
|
||||||
`5d3835e`; его tree совпадает с исходным HEAD проверки, поэтому исходники и
|
|
||||||
результаты gates не изменились. Pre-commit review не обнаружил посторонних
|
|
||||||
файлов, секретов или незакрытых semantic findings. PR направляется в `develop`;
|
|
||||||
merge выполняется после обязательных `verify`, `security`, `commits`.
|
|
||||||
@@ -1,5 +1,21 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.3...v1.0.0) (2026-09-26)
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* investment reads return data/meta resource envelopes and locally
|
||||||
|
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
|
||||||
|
synchronization endpoints.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* persist T-Bank data and synchronize reads ([d6e6c4a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d6e6c4ace553572ff9e9f635b7a4d21c8afbf97f))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* prove reads finish before source synchronization ([e423213](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e4232138d04ad6a2aa8100a7d36fdcb2d7663fa8))
|
||||||
|
|
||||||
## [0.2.3](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.2...v0.2.3) (2026-09-19)
|
## [0.2.3](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.2...v0.2.3) (2026-09-19)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -105,34 +105,6 @@ Required checks настраиваются отдельно в Gitea: налич
|
|||||||
runtime-валидацию. В обычном коде нужны инициализация или сужение типа
|
runtime-валидацию. В обычном коде нужны инициализация или сужение типа
|
||||||
после проверки значения.
|
после проверки значения.
|
||||||
|
|
||||||
## ESLint
|
|
||||||
|
|
||||||
`npm run lint` проверяет весь authored TypeScript и JavaScript, включая scripts
|
|
||||||
и configs. `npm run lint:fix` (также `npm run format`) применяет безопасные
|
|
||||||
автоисправления. Форматирование принадлежит ESLint Stylistic: два пробела,
|
|
||||||
одинарные кавычки и точки с запятой; Prettier не используется.
|
|
||||||
|
|
||||||
Профиль: ESLint recommended, typescript-eslint recommendedTypeChecked,
|
|
||||||
Node, NestJS typed, Jest, import-x с TypeScript resolver, SonarJS, RegExp,
|
|
||||||
Security, Perfectionist recommended-natural и Stylistic customize.
|
|
||||||
NestJS проверяется в production TS, Jest — в specs/e2e; generated Prisma,
|
|
||||||
dist и coverage исключены. DI imports сохраняют runtime metadata.
|
|
||||||
|
|
||||||
Исключения конфигурации: private backend не публикуется в npm;
|
|
||||||
TS imports разрешает import-x вместо Node resolver; IsIn проверяет domain
|
|
||||||
unions. Автосортировка объектов, классов, модулей и декораторов отключена,
|
|
||||||
поскольку порядок может влиять на runtime. Остальные точечные исключения
|
|
||||||
объясняют false positives непосредственно в коде.
|
|
||||||
|
|
||||||
`eslint-suppressions.json` — штатный ESLint baseline только для существующих
|
|
||||||
SonarJS cognitive-complexity, no-nested-conditional и no-nested-template-literals.
|
|
||||||
Это отложенный refactor, а не исправленные нарушения. Baseline ограничен
|
|
||||||
файлами и количеством сообщений; новые файлы и превышение количества блокируют
|
|
||||||
lint. Он не защищает от усложнения уже учтённой функции при прежнем количестве
|
|
||||||
сообщений — это остаётся предметом review. При сокращении долга обновляйте
|
|
||||||
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
|
|
||||||
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
|
|
||||||
|
|
||||||
## SonarQube
|
## SonarQube
|
||||||
|
|
||||||
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ['@commitlint/config-conventional'],
|
extends: ["@commitlint/config-conventional"],
|
||||||
rules: { 'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']] },
|
rules: { "type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]] },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
{
|
|
||||||
"src/common/decimal.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/common/logging/request-logging.middleware.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/integrations/tbank/instrument-metadata.repository.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/integrations/tbank/instrument-metadata.service.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/integrations/tbank/tbank.client.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/banking/bank-cards/application/bank-operations.service.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/income/income-analytics.service.ts": {
|
|
||||||
"sonarjs/no-nested-template-literals": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/income/income-period.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/instruments/application/candle.service.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 4
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 4
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-template-literals": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/instruments/bonds/application/bond-income.service.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/instruments/bonds/application/bond.spec.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/instruments/infrastructure/candle.repository.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/instruments/stocks/application/stock-income.service.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/investment-resource.mapper.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 3
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/investment-sync-plan.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 4
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/operations/application/brokerage-operations.service.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 9
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/operations/domain/operations-sync-plan.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 3
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/portfolio/application/forecast.service.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/portfolio/application/portfolio.service.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/tbank-sync/tbank-screen.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 3
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 7
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/upcoming-events/upcoming-events.service.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"test/bank-cards-durability.cjs": {
|
|
||||||
"sonarjs/no-nested-template-literals": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"test/sqlite-regression.cjs": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"test/sqlite.cjs": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"test/tbank-ledger-local.e2e-spec.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-template-literals": {
|
|
||||||
"count": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+56
-76
@@ -1,75 +1,59 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import nest from '@darraghor/eslint-plugin-nestjs-typed';
|
|
||||||
import eslint from '@eslint/js';
|
import eslint from '@eslint/js';
|
||||||
import stylistic from '@stylistic/eslint-plugin';
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript';
|
|
||||||
import { importX } from 'eslint-plugin-import-x';
|
|
||||||
import jest from 'eslint-plugin-jest';
|
|
||||||
import node from 'eslint-plugin-n';
|
|
||||||
import perfectionist from 'eslint-plugin-perfectionist';
|
|
||||||
import regexp from 'eslint-plugin-regexp';
|
|
||||||
import security from 'eslint-plugin-security';
|
|
||||||
import sonarjs from 'eslint-plugin-sonarjs';
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
export default tseslint.config(
|
||||||
|
|
||||||
export default defineConfig(
|
|
||||||
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
|
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,js,cjs,mjs}'],
|
ignores: ['eslint.config.mjs', 'dist/', 'generated/'],
|
||||||
extends: [
|
},
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
node.configs['flat/recommended'],
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
importX.flatConfigs.recommended,
|
eslintPluginPrettierRecommended,
|
||||||
sonarjs.configs.recommended,
|
|
||||||
regexp.configs['flat/recommended'],
|
|
||||||
security.configs.recommended,
|
|
||||||
perfectionist.configs['recommended-natural'],
|
|
||||||
stylistic.configs.customize({ indent: 2, quotes: 'single', semi: true, jsx: false }),
|
|
||||||
],
|
|
||||||
languageOptions: { globals: globals.node },
|
|
||||||
linterOptions: { reportUnusedDisableDirectives: 'error' },
|
|
||||||
settings: {
|
|
||||||
'import-x/resolver-next': [createTypeScriptImportResolver()],
|
|
||||||
'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.21.0' },
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'curly': ['error', 'multi-line'],
|
|
||||||
// This private application is deployed as a whole, never published to npm.
|
|
||||||
'n/no-unpublished-import': 'off',
|
|
||||||
'n/no-unpublished-require': 'off',
|
|
||||||
// Decorator execution and object insertion order can be observable at runtime.
|
|
||||||
'perfectionist/sort-decorators': 'off',
|
|
||||||
'perfectionist/sort-objects': 'off',
|
|
||||||
'perfectionist/sort-modules': 'off',
|
|
||||||
'perfectionist/sort-classes': 'off',
|
|
||||||
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }],
|
|
||||||
'@stylistic/arrow-parens': ['error', 'always'],
|
|
||||||
'@stylistic/operator-linebreak': ['error', 'after'],
|
|
||||||
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
files: ['**/*.mjs'],
|
|
||||||
extends: [node.configs['flat/recommended-module']],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['**/*.ts'],
|
|
||||||
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
sourceType: 'module',
|
globals: {
|
||||||
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
},
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
rules: {
|
rules: {
|
||||||
// TypeScript resolves source imports; import-x checks them with the TS resolver.
|
|
||||||
'n/no-missing-import': 'off',
|
|
||||||
'no-magic-numbers': 'off',
|
'no-magic-numbers': 'off',
|
||||||
'@typescript-eslint/no-magic-numbers': [
|
'@typescript-eslint/no-magic-numbers': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
|
ignore: [
|
||||||
|
-60,
|
||||||
|
-24,
|
||||||
|
-10,
|
||||||
|
-2,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
10,
|
||||||
|
24,
|
||||||
|
60,
|
||||||
|
'-60n',
|
||||||
|
'-24n',
|
||||||
|
'-10n',
|
||||||
|
'-2n',
|
||||||
|
'-1n',
|
||||||
|
'0n',
|
||||||
|
'1n',
|
||||||
|
'2n',
|
||||||
|
'10n',
|
||||||
|
'24n',
|
||||||
|
'60n',
|
||||||
|
],
|
||||||
ignoreArrayIndexes: true,
|
ignoreArrayIndexes: true,
|
||||||
ignoreDefaultValues: true,
|
ignoreDefaultValues: true,
|
||||||
ignoreEnums: true,
|
ignoreEnums: true,
|
||||||
@@ -79,28 +63,24 @@ export default defineConfig(
|
|||||||
enforceConst: true,
|
enforceConst: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||||
'no-restricted-syntax': ['error', {
|
'no-restricted-syntax': [
|
||||||
selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
|
'error',
|
||||||
message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
|
{
|
||||||
}],
|
selector:
|
||||||
|
'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
|
||||||
|
message:
|
||||||
|
'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
'prettier/prettier': ['error', { endOfLine: 'auto' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['src/**/*.ts'],
|
files: ['**/*.spec.ts', 'test/**/*.ts'],
|
||||||
ignores: tests,
|
rules: { '@typescript-eslint/no-magic-numbers': 'off' },
|
||||||
extends: [nest.configs.flatRecommended],
|
|
||||||
rules: {
|
|
||||||
// Domain string unions are validated with IsIn rather than runtime TS enums.
|
|
||||||
'@darraghor/nestjs-typed/validated-non-primitive-property-needs-type-decorator': ['error', { additionalTypeDecorators: ['IsIn'] }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: tests,
|
|
||||||
extends: [jest.configs['flat/recommended']],
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/no-magic-numbers': 'off',
|
|
||||||
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+209
-748
File diff suppressed because it is too large
Load Diff
+12
-20
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.2.3",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -11,14 +11,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "prisma generate && nest build",
|
"build": "prisma generate && nest build",
|
||||||
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
|
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
|
||||||
"format": "npm run lint:fix",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
||||||
"start:debug": "nest start --debug --watch --path tsconfig.dev.json",
|
"start:debug": "nest start --debug --watch --path tsconfig.dev.json",
|
||||||
"start:prod": "node dist/src/main.js",
|
"start:prod": "node dist/src/main.js",
|
||||||
"lint": "npm run lint:check",
|
"lint": "npm run lint:check",
|
||||||
"lint:check": "eslint . --max-warnings=0",
|
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --max-warnings=0",
|
||||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"typecheck:app": "tsc --project tsconfig.typecheck.app.json",
|
"typecheck:app": "tsc --project tsconfig.typecheck.app.json",
|
||||||
"typecheck:test": "tsc --project tsconfig.typecheck.test.json",
|
"typecheck:test": "tsc --project tsconfig.typecheck.test.json",
|
||||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
||||||
@@ -30,11 +30,10 @@
|
|||||||
"test:container": "node test/container-smoke.cjs",
|
"test:container": "node test/container-smoke.cjs",
|
||||||
"test:durability": "node test/bank-cards-durability.cjs",
|
"test:durability": "node test/bank-cards-durability.cjs",
|
||||||
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
|
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
|
||||||
"verify": "npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build",
|
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build",
|
||||||
"release:preview": "release-it --dry-run --ci",
|
"release:preview": "release-it --dry-run --ci",
|
||||||
"release:prepare": "release-it --ci",
|
"release:prepare": "release-it --ci",
|
||||||
"commitlint": "commitlint",
|
"commitlint": "commitlint"
|
||||||
"test:lint": "node --test test/eslint-config.cjs"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.4",
|
"@grpc/grpc-js": "1.14.4",
|
||||||
@@ -54,7 +53,6 @@
|
|||||||
"class-transformer": "0.5.1",
|
"class-transformer": "0.5.1",
|
||||||
"class-validator": "0.15.1",
|
"class-validator": "0.15.1",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"dotenv": "17.4.2",
|
|
||||||
"lru-cache": "11.5.2",
|
"lru-cache": "11.5.2",
|
||||||
"nestjs-pino": "5.1.0",
|
"nestjs-pino": "5.1.0",
|
||||||
"passport": "0.7.0",
|
"passport": "0.7.0",
|
||||||
@@ -67,13 +65,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "21.2.2",
|
"@commitlint/cli": "21.2.2",
|
||||||
"@commitlint/config-conventional": "21.2.2",
|
"@commitlint/config-conventional": "21.2.2",
|
||||||
"@darraghor/eslint-plugin-nestjs-typed": "7.5.6",
|
"@eslint/eslintrc": "3.3.7",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@nestjs/cli": "12.0.0",
|
"@nestjs/cli": "12.0.0",
|
||||||
"@nestjs/schematics": "12.0.1",
|
"@nestjs/schematics": "12.0.1",
|
||||||
"@nestjs/testing": "12.0.1",
|
"@nestjs/testing": "12.0.1",
|
||||||
"@release-it/conventional-changelog": "12.0.0",
|
"@release-it/conventional-changelog": "12.0.0",
|
||||||
"@stylistic/eslint-plugin": "5.10.0",
|
|
||||||
"@types/bcrypt": "6.0.0",
|
"@types/bcrypt": "6.0.0",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
@@ -82,17 +79,12 @@
|
|||||||
"@types/passport-jwt": "4.0.1",
|
"@types/passport-jwt": "4.0.1",
|
||||||
"@types/supertest": "7.2.1",
|
"@types/supertest": "7.2.1",
|
||||||
"conventional-changelog-conventionalcommits": "10.4.0",
|
"conventional-changelog-conventionalcommits": "10.4.0",
|
||||||
"eslint": "10.11.0",
|
"eslint": "10.10.0",
|
||||||
"eslint-import-resolver-typescript": "4.4.5",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-import-x": "4.17.1",
|
"eslint-plugin-prettier": "5.5.6",
|
||||||
"eslint-plugin-jest": "29.16.6",
|
|
||||||
"eslint-plugin-n": "18.4.0",
|
|
||||||
"eslint-plugin-perfectionist": "5.12.1",
|
|
||||||
"eslint-plugin-regexp": "3.3.1",
|
|
||||||
"eslint-plugin-security": "4.1.0",
|
|
||||||
"eslint-plugin-sonarjs": "4.2.1",
|
|
||||||
"globals": "17.12.0",
|
"globals": "17.12.0",
|
||||||
"jest": "30.5.1",
|
"jest": "30.5.1",
|
||||||
|
"prettier": "3.9.6",
|
||||||
"release-it": "21.0.2",
|
"release-it": "21.0.2",
|
||||||
"source-map-support": "0.5.21",
|
"source-map-support": "0.5.21",
|
||||||
"supertest": "7.2.2",
|
"supertest": "7.2.2",
|
||||||
@@ -101,7 +93,7 @@
|
|||||||
"ts-node": "10.9.2",
|
"ts-node": "10.9.2",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.70.1"
|
"typescript-eslint": "8.70.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@nestjs/platform-express": {
|
"@nestjs/platform-express": {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require("node:assert/strict");
|
||||||
const { execFileSync } = require('node:child_process');
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const { version } = require("../package.json");
|
||||||
|
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
|
||||||
|
|
||||||
const { version } = require('../package.json');
|
assert.equal(git("status", "--porcelain"), "", "Commit all changes before preparing a release");
|
||||||
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
|
const tags = git("tag", "--merged", "HEAD", "--list", "v[0-9]*", "--sort=-version:refname")
|
||||||
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
|
.split("\n").filter(tag => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||||
|
assert.equal(version, tags[0]?.slice(1) ?? "0.0.0",
|
||||||
assert.equal(git('status', '--porcelain'), '', 'Commit all changes before preparing a release');
|
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.");
|
||||||
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
|
|
||||||
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
|
||||||
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
|
|
||||||
'Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.');
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ Object.assign(process.env, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { NestFactory } = require('@nestjs/core');
|
const { NestFactory } = require('@nestjs/core');
|
||||||
|
|
||||||
const { AppModule } = require('../dist/src/app.module.js');
|
const { AppModule } = require('../dist/src/app.module.js');
|
||||||
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
|
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -1,8 +1,8 @@
|
|||||||
|
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { LoggingModule } from './common/logging/logging.module';
|
import { LoggingModule } from './common/logging/logging.module';
|
||||||
import { Environment, validateEnv } from './config/env.validation';
|
import { Environment, validateEnv } from './config/env.validation';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
@@ -13,7 +13,6 @@ import { InstrumentsModule } from './modules/investments/instruments/instruments
|
|||||||
import { OperationsModule } from './modules/investments/operations/operations.module';
|
import { OperationsModule } from './modules/investments/operations/operations.module';
|
||||||
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
|
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
|
||||||
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
|
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
|
||||||
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
|
|
||||||
import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module';
|
import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
|
||||||
|
|||||||
+23
-45
@@ -1,30 +1,12 @@
|
|||||||
export const DECIMAL_RADIX = 10n;
|
export type Decimal = { value: bigint; scale: number };
|
||||||
|
|
||||||
const SIGN_LENGTH = 1;
|
|
||||||
const NEGATIVE_SIGN = -1n;
|
|
||||||
const POSITIVE_SIGN = 1n;
|
|
||||||
const ZERO_UNITS = 0n;
|
|
||||||
const EQUAL = 0;
|
|
||||||
const GREATER_THAN = 1;
|
|
||||||
const LESS_THAN = -1;
|
|
||||||
const INTEGER_SCALE = 0;
|
|
||||||
const MIN_WHOLE_DIGITS = 1;
|
|
||||||
const DIGITS_START = 0;
|
|
||||||
const PREVIOUS_DIGIT_OFFSET = 1;
|
|
||||||
const DIGIT_STEP = 1;
|
|
||||||
const ROUNDING_DIVISOR = 2n;
|
|
||||||
|
|
||||||
export type Decimal = { scale: number; value: bigint };
|
|
||||||
|
|
||||||
export function decimalFromString(value: string): Decimal {
|
export function decimalFromString(value: string): Decimal {
|
||||||
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
|
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
|
||||||
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) {
|
|
||||||
throw new Error('Invalid decimal string');
|
throw new Error('Invalid decimal string');
|
||||||
}
|
|
||||||
const negative = value.startsWith('-');
|
const negative = value.startsWith('-');
|
||||||
const [whole, fraction = ''] = (negative ? value.slice(SIGN_LENGTH) : value).split('.');
|
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
|
||||||
return {
|
return {
|
||||||
value: (negative ? NEGATIVE_SIGN : POSITIVE_SIGN) * BigInt(`${whole}${fraction}`),
|
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
|
||||||
scale: fraction.length,
|
scale: fraction.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -33,8 +15,8 @@ export function add(left: Decimal, right: Decimal): Decimal {
|
|||||||
const scale = Math.max(left.scale, right.scale);
|
const scale = Math.max(left.scale, right.scale);
|
||||||
return {
|
return {
|
||||||
value:
|
value:
|
||||||
left.value * DECIMAL_RADIX ** BigInt(scale - left.scale) +
|
left.value * 10n ** BigInt(scale - left.scale) +
|
||||||
right.value * DECIMAL_RADIX ** BigInt(scale - right.scale),
|
right.value * 10n ** BigInt(scale - right.scale),
|
||||||
scale,
|
scale,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -48,37 +30,33 @@ export function multiply(left: Decimal, right: Decimal): Decimal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isZero(value: Decimal): boolean {
|
export function isZero(value: Decimal): boolean {
|
||||||
return value.value === ZERO_UNITS;
|
return value.value === 0n;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compare(left: Decimal, right: Decimal): number {
|
export function compare(left: Decimal, right: Decimal): number {
|
||||||
const difference = subtract(left, right).value;
|
const difference = subtract(left, right).value;
|
||||||
return difference === ZERO_UNITS ? EQUAL : difference > ZERO_UNITS ? GREATER_THAN : LESS_THAN;
|
return difference === 0n ? 0 : difference > 0n ? 1 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDecimal({ value, scale }: Decimal): string {
|
export function formatDecimal({ value, scale }: Decimal): string {
|
||||||
if (value === ZERO_UNITS) return '0';
|
if (value === 0n) return '0';
|
||||||
const sign = value < ZERO_UNITS ? '-' : '';
|
const sign = value < 0n ? '-' : '';
|
||||||
if (scale === INTEGER_SCALE) return `${sign}${value < ZERO_UNITS ? -value : value}`;
|
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
|
||||||
const digits = (value < ZERO_UNITS ? -value : value)
|
const digits = (value < 0n ? -value : value)
|
||||||
.toString()
|
.toString()
|
||||||
.padStart(scale + MIN_WHOLE_DIGITS, '0');
|
.padStart(scale + 1, '0');
|
||||||
const whole = digits.slice(DIGITS_START, -scale) || '0';
|
const whole = digits.slice(0, -scale) || '0';
|
||||||
const rawFraction = digits.slice(-scale);
|
const fraction = digits.slice(-scale).replace(/0+$/, '');
|
||||||
let fractionEnd = rawFraction.length;
|
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
|
||||||
while (rawFraction.charAt(fractionEnd - PREVIOUS_DIGIT_OFFSET) === '0') fractionEnd -= DIGIT_STEP;
|
|
||||||
const fraction = rawFraction.slice(DIGITS_START, fractionEnd);
|
|
||||||
const fractionalPart = fraction ? '.' + fraction : '';
|
|
||||||
return `${sign}${whole}${fractionalPart}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round half away from zero; callers choose presentation precision explicitly.
|
// Round half away from zero; callers choose presentation precision explicitly.
|
||||||
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
|
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
|
||||||
if (right.value === ZERO_UNITS) throw new Error('Division by zero');
|
if (right.value === 0n) throw new Error('Division by zero');
|
||||||
const numerator = left.value * DECIMAL_RADIX ** BigInt(right.scale + scale);
|
const numerator = left.value * 10n ** BigInt(right.scale + scale);
|
||||||
const denominator = right.value * DECIMAL_RADIX ** BigInt(left.scale);
|
const denominator = right.value * 10n ** BigInt(left.scale);
|
||||||
const sign = (numerator < ZERO_UNITS) !== (denominator < ZERO_UNITS) ? NEGATIVE_SIGN : POSITIVE_SIGN;
|
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n;
|
||||||
const n = numerator < ZERO_UNITS ? -numerator : numerator;
|
const n = numerator < 0n ? -numerator : numerator;
|
||||||
const d = denominator < ZERO_UNITS ? -denominator : denominator;
|
const d = denominator < 0n ? -denominator : denominator;
|
||||||
return { value: sign * ((n + d / ROUNDING_DIVISOR) / d), scale };
|
return { value: sign * ((n + d / 2n) / d), scale };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { applyDecorators, HttpStatus } from '@nestjs/common';
|
import { HttpStatus, applyDecorators } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiExtraModels,
|
ApiExtraModels,
|
||||||
ApiProperty,
|
ApiProperty,
|
||||||
ApiPropertyOptional,
|
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
getSchemaPath,
|
getSchemaPath,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
@@ -14,17 +13,11 @@ export class ProblemDetailsDto {
|
|||||||
@ApiProperty({ example: 'UNAUTHORIZED' })
|
@ApiProperty({ example: 'UNAUTHORIZED' })
|
||||||
declare code: string;
|
declare code: string;
|
||||||
|
|
||||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
|
@ApiProperty({
|
||||||
@ApiPropertyOptional({
|
required: false,
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['pointer', 'detail'],
|
|
||||||
properties: { pointer: { type: 'string' }, detail: { type: 'string' } },
|
|
||||||
},
|
|
||||||
example: [{ pointer: '#/email', detail: 'must be a valid email address' }],
|
example: [{ pointer: '#/email', detail: 'must be a valid email address' }],
|
||||||
})
|
})
|
||||||
errors?: { detail: string; pointer: string }[];
|
errors?: { pointer: string; detail: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function problemResponse(status: number, description: string) {
|
function problemResponse(status: number, description: string) {
|
||||||
@@ -39,7 +32,7 @@ function problemResponse(status: number, description: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ApiProblemResponses(): ClassDecorator & MethodDecorator {
|
export function ApiProblemResponses(): MethodDecorator & ClassDecorator {
|
||||||
return applyDecorators(
|
return applyDecorators(
|
||||||
ApiExtraModels(ProblemDetailsDto),
|
ApiExtraModels(ProblemDetailsDto),
|
||||||
problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
|
problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||||
|
|
||||||
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
|
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
|
||||||
import {
|
import {
|
||||||
CreateBankCardDto,
|
CreateBankCardDto,
|
||||||
@@ -7,8 +6,7 @@ import {
|
|||||||
} from '../modules/banking/bank-cards/presentation/bank-card.dto';
|
} from '../modules/banking/bank-cards/presentation/bank-card.dto';
|
||||||
|
|
||||||
describe('DTO validation', () => {
|
describe('DTO validation', () => {
|
||||||
const pipe = new ValidationPipe({
|
const pipe = new ValidationPipe({ transform: true, whitelist: true });
|
||||||
forbidUnknownValues: true, transform: true, whitelist: true });
|
|
||||||
|
|
||||||
it('requires credentials and normalizes a valid email', async () => {
|
it('requires credentials and normalizes a valid email', async () => {
|
||||||
const metadata = { type: 'body' as const, metatype: CredentialsDto };
|
const metadata = { type: 'body' as const, metatype: CredentialsDto };
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ArgumentsHost } from '@nestjs/common';
|
import { ArgumentsHost } from '@nestjs/common';
|
||||||
|
|
||||||
import { HttpExceptionFilter } from './http-exception.filter';
|
import { HttpExceptionFilter } from './http-exception.filter';
|
||||||
|
|
||||||
describe('HttpExceptionFilter', () => {
|
describe('HttpExceptionFilter', () => {
|
||||||
@@ -15,10 +14,7 @@ describe('HttpExceptionFilter', () => {
|
|||||||
}),
|
}),
|
||||||
} as unknown as ArgumentsHost;
|
} as unknown as ArgumentsHost;
|
||||||
|
|
||||||
filter.catch(
|
filter.catch(new Error('database password: secret'), host);
|
||||||
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
|
|
||||||
host,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(500);
|
expect(status).toHaveBeenCalledWith(500);
|
||||||
expect(type).toHaveBeenCalledWith('application/problem+json');
|
expect(type).toHaveBeenCalledWith('application/problem+json');
|
||||||
@@ -27,6 +23,7 @@ describe('HttpExceptionFilter', () => {
|
|||||||
code: 'INTERNAL_SERVER_ERROR',
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
});
|
});
|
||||||
expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR');
|
expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR');
|
||||||
expect(locals.errorStack).toBeUndefined();
|
expect(typeof locals.errorStack).toBe('string');
|
||||||
|
expect(locals.errorStack).not.toContain('database password: secret');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { Response } from 'express';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArgumentsHost,
|
ArgumentsHost,
|
||||||
Catch,
|
Catch,
|
||||||
@@ -8,6 +6,7 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
@@ -15,33 +14,40 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
const context = host.switchToHttp();
|
const context = host.switchToHttp();
|
||||||
const response = context.getResponse<Response>();
|
const response = context.getResponse<Response>();
|
||||||
const status =
|
const status =
|
||||||
exception instanceof HttpException ?
|
exception instanceof HttpException
|
||||||
exception.getStatus() :
|
? exception.getStatus()
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR;
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
const payload =
|
const payload =
|
||||||
exception instanceof HttpException ? exception.getResponse() : undefined;
|
exception instanceof HttpException ? exception.getResponse() : undefined;
|
||||||
const isValidation = Array.isArray(
|
const isValidation = Array.isArray(
|
||||||
(payload as { message?: unknown })?.message,
|
(payload as { message?: unknown })?.message,
|
||||||
);
|
);
|
||||||
const code = isValidation ?
|
const code = isValidation
|
||||||
'VALIDATION_ERROR' :
|
? 'VALIDATION_ERROR'
|
||||||
this.codeFor(status, payload);
|
: this.codeFor(status, payload);
|
||||||
response.locals ??= {};
|
(response.locals ??= {}).publicErrorCode = code;
|
||||||
response.locals.publicErrorCode = code;
|
if (
|
||||||
|
status >= Number(HttpStatus.INTERNAL_SERVER_ERROR) &&
|
||||||
|
exception instanceof Error
|
||||||
|
)
|
||||||
|
response.locals.errorStack = exception.stack
|
||||||
|
?.split('\n')
|
||||||
|
.slice(1)
|
||||||
|
.join('\n');
|
||||||
response
|
response
|
||||||
.status(status)
|
.status(status)
|
||||||
.type('application/problem+json')
|
.type('application/problem+json')
|
||||||
.json({
|
.json({
|
||||||
status,
|
status,
|
||||||
code,
|
code,
|
||||||
...(isValidation ?
|
...(isValidation
|
||||||
{
|
? {
|
||||||
errors: this.validationErrors(
|
errors: this.validationErrors(
|
||||||
(payload as { message: ValidationError[] }).message,
|
(payload as { message: ValidationError[] }).message,
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
{}),
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +57,6 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
if (typeof code === 'string') return code;
|
if (typeof code === 'string') return code;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request.
|
|
||||||
{
|
{
|
||||||
400: 'BAD_REQUEST',
|
400: 'BAD_REQUEST',
|
||||||
401: 'UNAUTHORIZED',
|
401: 'UNAUTHORIZED',
|
||||||
@@ -64,18 +69,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
|
|
||||||
private validationErrors(
|
private validationErrors(
|
||||||
errors: ValidationError[],
|
errors: ValidationError[],
|
||||||
prefix = '#',
|
): { pointer: string; detail: string }[] {
|
||||||
): { detail: string; pointer: string }[] {
|
return errors.flatMap((error) =>
|
||||||
return errors.flatMap((error) => {
|
Object.values(error.constraints ?? {}).map((detail) => ({
|
||||||
if (!error || typeof error.property !== 'string') return [];
|
pointer: `#/${error.property}`,
|
||||||
const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`;
|
|
||||||
return [
|
|
||||||
...Object.values(error.constraints ?? {}).map((detail) => ({
|
|
||||||
pointer,
|
|
||||||
detail,
|
detail,
|
||||||
})),
|
})),
|
||||||
...this.validationErrors(error.children ?? [], pointer),
|
);
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { RequestContextService } from './request-context.service';
|
import { RequestContextService } from './request-context.service';
|
||||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
||||||
import { StructuredLogger } from './structured-logger.service';
|
import { StructuredLogger } from './structured-logger.service';
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
|
|
||||||
export type RequestLogContext = {
|
export type RequestLogContext = {
|
||||||
brokerageAccountId?: string;
|
|
||||||
clientRequestId?: string;
|
|
||||||
requestId: string;
|
requestId: string;
|
||||||
|
clientRequestId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
brokerageAccountId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
||||||
|
|
||||||
describe('RequestLoggingMiddleware', () => {
|
describe('RequestLoggingMiddleware', () => {
|
||||||
@@ -20,11 +19,11 @@ describe('RequestLoggingMiddleware', () => {
|
|||||||
const next = jest.fn();
|
const next = jest.fn();
|
||||||
|
|
||||||
const request: {
|
const request: {
|
||||||
|
method: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
route: { path: string };
|
||||||
header: jest.Mock<string>;
|
header: jest.Mock<string>;
|
||||||
id?: string;
|
id?: string;
|
||||||
method: string;
|
|
||||||
route: { path: string };
|
|
||||||
} = {
|
} = {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
baseUrl: '/api/v1',
|
baseUrl: '/api/v1',
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { NextFunction, Request, Response } from 'express';
|
|
||||||
|
|
||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { constants as httpStatus } from 'node:http2';
|
import { constants as httpStatus } from 'node:http2';
|
||||||
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { RequestContextService } from './request-context.service';
|
import { RequestContextService } from './request-context.service';
|
||||||
import { StructuredLogger } from './structured-logger.service';
|
import { StructuredLogger } from './structured-logger.service';
|
||||||
|
|
||||||
@@ -33,26 +31,29 @@ export class RequestLoggingMiddleware implements NestMiddleware {
|
|||||||
const routePath = (routeRequest as { route?: { path?: unknown } }).route
|
const routePath = (routeRequest as { route?: { path?: unknown } }).route
|
||||||
?.path;
|
?.path;
|
||||||
const route =
|
const route =
|
||||||
typeof routePath === 'string' ?
|
typeof routePath === 'string'
|
||||||
`${request.baseUrl}${routePath}` :
|
? `${request.baseUrl}${routePath}`
|
||||||
'<unmatched>';
|
: '<unmatched>';
|
||||||
const context = {
|
const context = {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
route,
|
route,
|
||||||
status: response.statusCode,
|
status: response.statusCode,
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
...(response.locals.publicErrorCode ?
|
...(response.locals.publicErrorCode
|
||||||
{ code: response.locals.publicErrorCode as string } :
|
? { code: response.locals.publicErrorCode as string }
|
||||||
{}),
|
: {}),
|
||||||
|
...(response.locals.errorStack
|
||||||
|
? { stack: response.locals.errorStack as string }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
const level =
|
const level =
|
||||||
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR ?
|
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||||
'error' :
|
? 'error'
|
||||||
response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
|
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
|
||||||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
|
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
|
||||||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN ?
|
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
|
||||||
'warn' :
|
? 'warn'
|
||||||
'info';
|
: 'info';
|
||||||
this.logger.event(level, 'http.request.completed', context);
|
this.logger.event(level, 'http.request.completed', context);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { RequestContextService } from './request-context.service';
|
import { RequestContextService } from './request-context.service';
|
||||||
|
|
||||||
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
|
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||||
type LogContext = Record<string, boolean | number | string | undefined>;
|
type LogContext = Record<string, string | number | boolean | undefined>;
|
||||||
|
|
||||||
const allowedFields = new Set([
|
const allowedFields = new Set([
|
||||||
'attempts',
|
|
||||||
'brokerageAccountId',
|
|
||||||
'clientRequestId',
|
|
||||||
'code',
|
|
||||||
'count',
|
|
||||||
'durationMs',
|
|
||||||
'environment',
|
|
||||||
'found',
|
|
||||||
'grpcCode',
|
|
||||||
'method',
|
|
||||||
'operation',
|
|
||||||
'requestId',
|
|
||||||
'route',
|
|
||||||
'service',
|
'service',
|
||||||
'status',
|
'environment',
|
||||||
|
'requestId',
|
||||||
|
'clientRequestId',
|
||||||
'userId',
|
'userId',
|
||||||
|
'brokerageAccountId',
|
||||||
|
'method',
|
||||||
|
'route',
|
||||||
|
'status',
|
||||||
|
'durationMs',
|
||||||
|
'code',
|
||||||
|
'stack',
|
||||||
|
'operation',
|
||||||
|
'grpcCode',
|
||||||
|
'attempts',
|
||||||
|
'count',
|
||||||
|
'found',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -49,7 +49,6 @@ export class StructuredLogger implements OnApplicationShutdown {
|
|||||||
message: string,
|
message: string,
|
||||||
context: LogContext = {},
|
context: LogContext = {},
|
||||||
): void {
|
): void {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
|
|
||||||
this.pino[level](
|
this.pino[level](
|
||||||
this.safeContext({ ...this.requestContext.get(), ...context }),
|
this.safeContext({ ...this.requestContext.get(), ...context }),
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
import type { Money } from './money.types';
|
import type { Money } from './money.types';
|
||||||
|
|
||||||
export class MoneyDto implements Money {
|
export class MoneyDto implements Money {
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
export const ISO_DATE_LENGTH = 10;
|
|
||||||
export const ISO_DATE_START = 0;
|
|
||||||
export const MILLISECONDS_PER_SECOND = 1_000;
|
export const MILLISECONDS_PER_SECOND = 1_000;
|
||||||
export const MILLISECONDS_PER_DAY = 86_400_000;
|
export const MILLISECONDS_PER_DAY = 86_400_000;
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
const MIN_PORT = 1;
|
|
||||||
const MIN_REQUESTS_PER_SECOND = 1;
|
|
||||||
|
|
||||||
const DEFAULT_PORT = 3000;
|
const DEFAULT_PORT = 3000;
|
||||||
const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5;
|
const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5;
|
||||||
const MAX_PORT = 65_535;
|
const MAX_PORT = 65_535;
|
||||||
@@ -10,12 +7,12 @@ export interface Environment {
|
|||||||
DATABASE_URL: string;
|
DATABASE_URL: string;
|
||||||
FRONTEND_ORIGIN: string;
|
FRONTEND_ORIGIN: string;
|
||||||
JWT_SECRET: string;
|
JWT_SECRET: string;
|
||||||
LOG_LEVEL: 'debug' | 'error' | 'info' | 'warn';
|
|
||||||
PORT: number;
|
PORT: number;
|
||||||
|
TOKEN_ENCRYPTION_KEY: string;
|
||||||
TBANK_CA_CERT_PATH?: string;
|
TBANK_CA_CERT_PATH?: string;
|
||||||
TBANK_GRPC_ENDPOINT: string;
|
TBANK_GRPC_ENDPOINT: string;
|
||||||
TBANK_RATE_LIMIT_PER_SECOND: number;
|
TBANK_RATE_LIMIT_PER_SECOND: number;
|
||||||
TOKEN_ENCRYPTION_KEY: string;
|
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateEnv(config: Record<string, unknown>): Environment {
|
export function validateEnv(config: Record<string, unknown>): Environment {
|
||||||
@@ -59,7 +56,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
|||||||
const parsedPort = Number(port);
|
const parsedPort = Number(port);
|
||||||
if (
|
if (
|
||||||
!Number.isInteger(parsedPort) ||
|
!Number.isInteger(parsedPort) ||
|
||||||
parsedPort < MIN_PORT ||
|
parsedPort < 1 ||
|
||||||
parsedPort > MAX_PORT
|
parsedPort > MAX_PORT
|
||||||
) {
|
) {
|
||||||
throw new Error('PORT must be a valid port number');
|
throw new Error('PORT must be a valid port number');
|
||||||
@@ -74,7 +71,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
typeof tokenEncryptionKey !== 'string' ||
|
typeof tokenEncryptionKey !== 'string' ||
|
||||||
!/^[A-Z0-9+/]+={0,2}$/i.test(tokenEncryptionKey) ||
|
!/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) ||
|
||||||
Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES
|
Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -87,7 +84,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsedTbankRateLimit = Number(tbankRateLimit);
|
const parsedTbankRateLimit = Number(tbankRateLimit);
|
||||||
if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < MIN_REQUESTS_PER_SECOND) {
|
if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < 1) {
|
||||||
throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer');
|
throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +92,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
|||||||
throw new Error('TBANK_CA_CERT_PATH must be a file path');
|
throw new Error('TBANK_CA_CERT_PATH must be a file path');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['debug', 'error', 'info', 'warn'].includes(logLevel as string)) {
|
if (!['debug', 'info', 'warn', 'error'].includes(logLevel as string)) {
|
||||||
throw new Error('LOG_LEVEL must be debug, info, warn, or error');
|
throw new Error('LOG_LEVEL must be debug, info, warn, or error');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,9 +102,9 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
|||||||
JWT_SECRET: jwtSecret,
|
JWT_SECRET: jwtSecret,
|
||||||
PORT: parsedPort,
|
PORT: parsedPort,
|
||||||
TOKEN_ENCRYPTION_KEY: tokenEncryptionKey,
|
TOKEN_ENCRYPTION_KEY: tokenEncryptionKey,
|
||||||
...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ?
|
...(typeof tbankCaCertPath === 'string' && tbankCaCertPath
|
||||||
{ TBANK_CA_CERT_PATH: tbankCaCertPath } :
|
? { TBANK_CA_CERT_PATH: tbankCaCertPath }
|
||||||
{}),
|
: {}),
|
||||||
TBANK_GRPC_ENDPOINT: tbankEndpoint,
|
TBANK_GRPC_ENDPOINT: tbankEndpoint,
|
||||||
TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit,
|
TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit,
|
||||||
LOG_LEVEL: logLevel as Environment['LOG_LEVEL'],
|
LOG_LEVEL: logLevel as Environment['LOG_LEVEL'],
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { MoexClient } from './moex.client';
|
import { MoexClient } from './moex.client';
|
||||||
|
|
||||||
const emptyEvents = {
|
|
||||||
coupons: { columns: ['coupondate', 'value', 'faceunit'], data: [] },
|
|
||||||
offers: { columns: ['offerdate'], data: [] },
|
|
||||||
description: { columns: ['name', 'value'], data: [] },
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('MoexClient', () => {
|
describe('MoexClient', () => {
|
||||||
afterEach(() => jest.restoreAllMocks());
|
afterEach(() => jest.restoreAllMocks());
|
||||||
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
|
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
|
||||||
@@ -98,179 +92,17 @@ describe('MoexClient', () => {
|
|||||||
expect(result?.value ?? null).toBe(value === null ? null : '0');
|
expect(result?.value ?? null).toBe(value === null ? null : '0');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
it('accepts confirmed empty blocks', async () => {
|
it('treats a successful response without the requested block as no events', async () => {
|
||||||
jest
|
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||||
.spyOn(global, 'fetch')
|
ok: true,
|
||||||
.mockImplementation(() => Promise.resolve(Response.json(emptyEvents)));
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
description: { columns: ['name', 'value'], data: [] },
|
||||||
|
boards: { columns: ['secid'], data: [] },
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]);
|
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]);
|
||||||
|
fetchMock.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves not-found semantics independently of malformed HTTP 200', async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(global, 'fetch')
|
|
||||||
.mockImplementation(() =>
|
|
||||||
Promise.resolve(new Response(null, { status: 404 })),
|
|
||||||
);
|
|
||||||
const client = new MoexClient();
|
|
||||||
await expect(client.bondEvents('MISSING')).resolves.toEqual([]);
|
|
||||||
await expect(
|
|
||||||
client.bondYield('MISSING', 'ISIN', 'TQOB'),
|
|
||||||
).resolves.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['missing block', { coupons: undefined }],
|
|
||||||
['missing columns', { coupons: { data: [] } }],
|
|
||||||
['invalid column', { coupons: { columns: [null], data: [] } }],
|
|
||||||
['invalid row', { coupons: { columns: ['coupondate'], data: [null] } }],
|
|
||||||
[
|
|
||||||
'short row',
|
|
||||||
{ coupons: { columns: ['coupondate', 'value'], data: [['2099-02-01']] } },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'long row',
|
|
||||||
{ coupons: { columns: ['coupondate'], data: [['2099-02-01', 2]] } },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'empty cursor',
|
|
||||||
{
|
|
||||||
'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'missing cursor field',
|
|
||||||
{ 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'null cursor index',
|
|
||||||
{
|
|
||||||
'coupons.cursor': {
|
|
||||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
|
||||||
data: [[null, 0, 1]],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'boolean cursor total',
|
|
||||||
{
|
|
||||||
'coupons.cursor': {
|
|
||||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
|
||||||
data: [[0, false, 1]],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'empty cursor total',
|
|
||||||
{
|
|
||||||
'coupons.cursor': {
|
|
||||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
|
||||||
data: [[0, '', 1]],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'short page',
|
|
||||||
{
|
|
||||||
'coupons.cursor': {
|
|
||||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
|
||||||
data: [[0, 1, 1]],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
])(
|
|
||||||
'rejects %s without returning a partial calendar',
|
|
||||||
async (_name, invalid) => {
|
|
||||||
jest
|
|
||||||
.spyOn(global, 'fetch')
|
|
||||||
.mockImplementation(() =>
|
|
||||||
Promise.resolve(Response.json({ ...emptyEvents, ...invalid })),
|
|
||||||
);
|
|
||||||
await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({
|
|
||||||
response: { code: 'MOEX_UNAVAILABLE' },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each([0, -1])(
|
|
||||||
'rejects nonpositive page size %s before repeating a fetch',
|
|
||||||
async (pageSize) => {
|
|
||||||
const starts: string[] = [];
|
|
||||||
jest.spyOn(global, 'fetch').mockImplementation((input) => {
|
|
||||||
const url = new URL(input instanceof Request ? input.url : input);
|
|
||||||
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
|
|
||||||
starts.push(url.searchParams.get('start') ?? '');
|
|
||||||
if (starts.length > 1) {
|
|
||||||
return Promise.reject(new Error('Repeated page'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Promise.resolve(
|
|
||||||
Response.json({
|
|
||||||
...emptyEvents,
|
|
||||||
'coupons.cursor': {
|
|
||||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
|
||||||
data: [[0, 1, pageSize]],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({
|
|
||||||
response: { code: 'MOEX_UNAVAILABLE' },
|
|
||||||
});
|
|
||||||
expect(starts).toEqual(['0']);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each([false, true])(
|
|
||||||
'reads all cursor pages and rejects a repeated index: %s',
|
|
||||||
async (repeatIndex) => {
|
|
||||||
const starts: string[] = [];
|
|
||||||
jest.spyOn(global, 'fetch').mockImplementation((input) => {
|
|
||||||
const url = new URL(input instanceof Request ? input.url : input);
|
|
||||||
const start = Number(url.searchParams.get('start'));
|
|
||||||
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
|
|
||||||
starts.push(String(start));
|
|
||||||
if (starts.length > 2) {
|
|
||||||
return Promise.reject(new Error('Repeated page'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Promise.resolve(
|
|
||||||
Response.json({
|
|
||||||
...emptyEvents,
|
|
||||||
'coupons': {
|
|
||||||
columns: ['coupondate', 'value', 'faceunit'],
|
|
||||||
data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']],
|
|
||||||
},
|
|
||||||
'coupons.cursor': {
|
|
||||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
|
||||||
data: [[repeatIndex ? 0 : start, 2, 1]],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const result = new MoexClient().bondEvents('BOND');
|
|
||||||
if (repeatIndex) {
|
|
||||||
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
|
|
||||||
await expect(result).rejects.toMatchObject({
|
|
||||||
response: { code: 'MOEX_UNAVAILABLE' },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
|
|
||||||
await expect(result).resolves.toEqual([
|
|
||||||
{
|
|
||||||
type: 'coupon',
|
|
||||||
date: '2099-02-01',
|
|
||||||
value: '2.000000001',
|
|
||||||
currency: 'RUB',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'coupon',
|
|
||||||
date: '2099-02-02',
|
|
||||||
value: '2.000000001',
|
|
||||||
currency: 'RUB',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
expect(starts).toEqual(['0', '1']);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
|
||||||
import { constants as httpStatus } from 'node:http2';
|
import { constants as httpStatus } from 'node:http2';
|
||||||
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
|
||||||
const FIRST_PAGE_OFFSET = 0;
|
|
||||||
const EXPECTED_CURSOR_ROWS = 1;
|
|
||||||
const EMPTY_PAGE_SIZE = 0;
|
|
||||||
const MIN_CURSOR_VALUE = 0;
|
|
||||||
|
|
||||||
const FALLBACK_PAGE_SIZE = 100;
|
const FALLBACK_PAGE_SIZE = 100;
|
||||||
|
|
||||||
@@ -13,18 +7,18 @@ const baseUrl = 'https://iss.moex.com/iss';
|
|||||||
const timeoutMs = 10_000;
|
const timeoutMs = 10_000;
|
||||||
|
|
||||||
export type MoexBondEvent =
|
export type MoexBondEvent =
|
||||||
| { currency: string; date: string; type: 'coupon'; value: string } |
|
| { type: 'coupon'; date: string; value: string; currency: string }
|
||||||
{ currency: string; date: string; type: 'maturity'; value: string } |
|
| { type: 'maturity'; date: string; value: string; currency: string }
|
||||||
{ date: string; type: 'offer' };
|
| { type: 'offer'; date: string };
|
||||||
|
|
||||||
type IssBlock = { columns?: unknown; data?: unknown };
|
type IssBlock = { columns?: unknown; data?: unknown };
|
||||||
type IssResponse = Record<string, IssBlock | undefined>;
|
type IssResponse = Record<string, IssBlock | undefined>;
|
||||||
export type MoexBondYield = {
|
export type MoexBondYield = {
|
||||||
date: string;
|
|
||||||
dateType: null | string;
|
|
||||||
pricePercent: string;
|
|
||||||
tradeAt: string;
|
|
||||||
value: string;
|
value: string;
|
||||||
|
pricePercent: string;
|
||||||
|
date: string;
|
||||||
|
dateType: string | null;
|
||||||
|
tradeAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -58,15 +52,14 @@ export class MoexClient {
|
|||||||
if (
|
if (
|
||||||
value === null ||
|
value === null ||
|
||||||
pricePercent === null ||
|
pricePercent === null ||
|
||||||
Number(pricePercent) <= MIN_PRICE_PERCENT_EXCLUSIVE ||
|
Number(pricePercent) <= 0 ||
|
||||||
!date ||
|
!date ||
|
||||||
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
||||||
!Number.isFinite(Date.parse(date)) ||
|
!Number.isFinite(Date.parse(date)) ||
|
||||||
!tradeAt ||
|
!tradeAt ||
|
||||||
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt)
|
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt)
|
||||||
) {
|
)
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`;
|
const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`;
|
||||||
if (!Number.isFinite(Date.parse(tradeIso))) return null;
|
if (!Number.isFinite(Date.parse(tradeIso))) return null;
|
||||||
return {
|
return {
|
||||||
@@ -77,7 +70,6 @@ export class MoexClient {
|
|||||||
tradeAt: new Date(tradeIso).toISOString(),
|
tradeAt: new Date(tradeIso).toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async bondEvents(secid: string): Promise<MoexBondEvent[]> {
|
async bondEvents(secid: string): Promise<MoexBondEvent[]> {
|
||||||
const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
|
const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
|
||||||
const [coupons, offers, description] = await Promise.all([
|
const [coupons, offers, description] = await Promise.all([
|
||||||
@@ -94,10 +86,9 @@ export class MoexClient {
|
|||||||
const date = text(row.coupondate);
|
const date = text(row.coupondate);
|
||||||
const value = numberText(row.value);
|
const value = numberText(row.value);
|
||||||
const currency = currencyText(row.faceunit);
|
const currency = currencyText(row.faceunit);
|
||||||
if (date && value && currency) {
|
if (date && value && currency)
|
||||||
events.push({ type: 'coupon', date, value, currency });
|
events.push({ type: 'coupon', date, value, currency });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for (const row of offers) {
|
for (const row of offers) {
|
||||||
const date = text(row.offerdate);
|
const date = text(row.offerdate);
|
||||||
if (date) events.push({ type: 'offer', date });
|
if (date) events.push({ type: 'offer', date });
|
||||||
@@ -108,9 +99,8 @@ export class MoexClient {
|
|||||||
const date = values.get('MATDATE');
|
const date = values.get('MATDATE');
|
||||||
const value = numberText(values.get('FACEVALUE'));
|
const value = numberText(values.get('FACEVALUE'));
|
||||||
const currency = currencyText(values.get('FACEUNIT'));
|
const currency = currencyText(values.get('FACEUNIT'));
|
||||||
if (date && value && currency) {
|
if (date && value && currency)
|
||||||
events.push({ type: 'maturity', date, value, currency });
|
events.push({ type: 'maturity', date, value, currency });
|
||||||
}
|
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,41 +110,35 @@ export class MoexClient {
|
|||||||
paginated = true,
|
paginated = true,
|
||||||
): Promise<Record<string, unknown>[]> {
|
): Promise<Record<string, unknown>[]> {
|
||||||
const rows: Record<string, unknown>[] = [];
|
const rows: Record<string, unknown>[] = [];
|
||||||
let start = FIRST_PAGE_OFFSET;
|
let start = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
'iss.meta': 'off',
|
'iss.meta': 'off',
|
||||||
'iss.only': `${block},${block}.cursor`,
|
'iss.only': `${block},${block}.cursor`,
|
||||||
'start': String(start),
|
start: String(start),
|
||||||
});
|
});
|
||||||
const response = await this.request(`${baseUrl}/${path}?${query}`);
|
const response = await this.request(`${baseUrl}/${path}?${query}`);
|
||||||
if (!response) return rows;
|
if (!response) return rows;
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
|
|
||||||
const current = parseRows(response[block]);
|
const current = parseRows(response[block]);
|
||||||
rows.push(...current);
|
rows.push(...current);
|
||||||
if (!paginated) return rows;
|
if (!paginated) return rows;
|
||||||
const cursorBlock = response[`${block}.cursor`];
|
const cursorBlock = response[`${block}.cursor`];
|
||||||
if (cursorBlock === undefined) {
|
if (!cursorBlock) {
|
||||||
if (current.length < FALLBACK_PAGE_SIZE) return rows;
|
if (current.length < FALLBACK_PAGE_SIZE) return rows;
|
||||||
start += FALLBACK_PAGE_SIZE;
|
start += FALLBACK_PAGE_SIZE;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cursors = parseRows(cursorBlock);
|
const cursor = parseRows(cursorBlock)[0];
|
||||||
const cursor = cursors[0];
|
|
||||||
const index = integer(cursor?.index);
|
const index = integer(cursor?.index);
|
||||||
const total = integer(cursor?.total);
|
const total = integer(cursor?.total);
|
||||||
const pageSize = integer(cursor?.pagesize);
|
const pageSize = integer(cursor?.pagesize);
|
||||||
if (
|
if (
|
||||||
cursors.length !== EXPECTED_CURSOR_ROWS ||
|
index === null ||
|
||||||
index !== start ||
|
|
||||||
total === null ||
|
total === null ||
|
||||||
pageSize === null ||
|
pageSize === null ||
|
||||||
pageSize === EMPTY_PAGE_SIZE ||
|
index + pageSize >= total
|
||||||
current.length !== Math.min(pageSize, total - start)
|
)
|
||||||
) {
|
return rows;
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
|
||||||
}
|
|
||||||
if (index + pageSize >= total) return rows;
|
|
||||||
start = index + pageSize;
|
start = index + pageSize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,9 +151,8 @@ export class MoexClient {
|
|||||||
if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
|
if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
|
||||||
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
|
||||||
const body: unknown = await response.json();
|
const body: unknown = await response.json();
|
||||||
if (!body || typeof body !== 'object') {
|
if (!body || typeof body !== 'object')
|
||||||
throw new Error('MOEX malformed response');
|
throw new Error('MOEX malformed response');
|
||||||
}
|
|
||||||
return body as IssResponse;
|
return body as IssResponse;
|
||||||
} catch {
|
} catch {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||||
@@ -178,40 +161,36 @@ export class MoexClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
||||||
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
|
if (!block) return [];
|
||||||
|
if (!Array.isArray(block.columns) || !Array.isArray(block.data))
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||||
}
|
|
||||||
const columns = block.columns;
|
const columns = block.columns;
|
||||||
if (!columns.every((column) => typeof column === 'string')) {
|
if (!columns.every((column) => typeof column === 'string'))
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||||
}
|
|
||||||
return block.data.map((values) => {
|
return block.data.map((values) => {
|
||||||
if (!Array.isArray(values) || values.length !== columns.length) {
|
if (!Array.isArray(values))
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||||
}
|
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
|
|
||||||
columns.map((column, index) => [column.toLowerCase(), values[index]]),
|
columns.map((column, index) => [column.toLowerCase(), values[index]]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function text(value: unknown): null | string {
|
function text(value: unknown): string | null {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function numberText(value: unknown): null | string {
|
function numberText(value: unknown): string | null {
|
||||||
const result = typeof value === 'number' ? String(value) : text(value);
|
const result = typeof value === 'number' ? String(value) : text(value);
|
||||||
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
|
|
||||||
return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null;
|
return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function currencyText(value: unknown): null | string {
|
function currencyText(value: unknown): string | null {
|
||||||
const result = text(value)?.toUpperCase();
|
const result = text(value)?.toUpperCase();
|
||||||
return result === 'SUR' ? 'RUB' : (result ?? null);
|
return result === 'SUR' ? 'RUB' : (result ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function integer(value: unknown): null | number {
|
function integer(value: unknown): number | null {
|
||||||
const result = Number(numberText(value) ?? NaN);
|
const result = Number(value);
|
||||||
return Number.isSafeInteger(result) && result >= MIN_CURSOR_VALUE ? result : null;
|
return Number.isSafeInteger(result) && result >= 0 ? result : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { MoexClient } from './moex.client';
|
import { MoexClient } from './moex.client';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
|
||||||
import { NcrRatingsClient } from './ncr-ratings.client';
|
import { NcrRatingsClient } from './ncr-ratings.client';
|
||||||
const isin = 'RU000A10EW44';
|
const isin = 'RU000A10EW44';
|
||||||
const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') =>
|
const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') =>
|
||||||
@@ -66,18 +65,6 @@ describe('public NCR issue ratings', () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
it.each(['AA+.ru', 'B-.ru', 'BBB+.ru', 'CCC-.ru', 'CC.ru', 'D.ru'])(
|
|
||||||
'accepts the public rating grammar: %s', async (value) => {
|
|
||||||
jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value))));
|
|
||||||
expect(await new NcrRatingsClient().rating(isin)).toMatchObject({ value });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
it.each(['AAA+.ru', 'CC-.ru', 'BBBB.ru', 'BBB++.ru'])(
|
|
||||||
'rejects invalid rating grades: %s', async (value) => {
|
|
||||||
jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value))));
|
|
||||||
await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf(ServiceUnavailableException);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
it('bounds outage retries then recovers without retaining a false missing rating', async () => {
|
it('bounds outage retries then recovers without retaining a false missing rating', async () => {
|
||||||
const fetchMock = jest
|
const fetchMock = jest
|
||||||
.spyOn(global, 'fetch')
|
.spyOn(global, 'fetch')
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
|||||||
import { load } from 'cheerio';
|
import { load } from 'cheerio';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
|
||||||
|
|
||||||
const EXPECTED_REGISTRY_TABLES = 1;
|
|
||||||
const RATING_COLUMN = 2;
|
|
||||||
|
|
||||||
const REGISTRY_CACHE_TTL_MS = 21_600_000;
|
const REGISTRY_CACHE_TTL_MS = 21_600_000;
|
||||||
const REQUEST_TIMEOUT_MS = 10_000;
|
const REQUEST_TIMEOUT_MS = 10_000;
|
||||||
const COLUMN_COUNT = 6;
|
const COLUMN_COUNT = 6;
|
||||||
@@ -16,11 +11,11 @@ const DATE_COLUMN = 5;
|
|||||||
const registryUrl = 'https://ratings.ru/ratings/issues/';
|
const registryUrl = 'https://ratings.ru/ratings/issues/';
|
||||||
export type NcrRating = {
|
export type NcrRating = {
|
||||||
agency: 'NCR';
|
agency: 'NCR';
|
||||||
date: string;
|
value: string | null;
|
||||||
observedAt: string;
|
|
||||||
sourceUrl: string;
|
|
||||||
status: 'active' | 'withdrawn';
|
status: 'active' | 'withdrawn';
|
||||||
value: null | string;
|
date: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
observedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -38,9 +33,8 @@ export class NcrRatingsClient {
|
|||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = this.registry().catch(() => {
|
pending = this.registry().catch(() => {
|
||||||
// Bound outage traffic too; never present a failed download as no rating.
|
// Bound outage traffic too; never present a failed download as no rating.
|
||||||
if (this.cache.get('registry') === pending) {
|
if (this.cache.get('registry') === pending)
|
||||||
this.cache.set('registry', pending, { ttl: 60_000 });
|
this.cache.set('registry', pending, { ttl: 60_000 });
|
||||||
}
|
|
||||||
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' });
|
||||||
});
|
});
|
||||||
this.cache.set('registry', pending);
|
this.cache.set('registry', pending);
|
||||||
@@ -60,16 +54,13 @@ export class NcrRatingsClient {
|
|||||||
.map((_, cell) => $(cell).text().trim())
|
.map((_, cell) => $(cell).text().trim())
|
||||||
.get();
|
.get();
|
||||||
if (
|
if (
|
||||||
table.length !== EXPECTED_REGISTRY_TABLES ||
|
table.length !== 1 ||
|
||||||
headings.length !== COLUMN_COUNT ||
|
headings.length !== COLUMN_COUNT ||
|
||||||
headings[2] !== 'Рейтинг' ||
|
headings[2] !== 'Рейтинг' ||
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
|
|
||||||
headings[ISIN_COLUMN] !== 'ISIN' ||
|
headings[ISIN_COLUMN] !== 'ISIN' ||
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
|
|
||||||
headings[DATE_COLUMN] !== 'Дата'
|
headings[DATE_COLUMN] !== 'Дата'
|
||||||
) {
|
)
|
||||||
throw new Error('NCR registry changed');
|
throw new Error('NCR registry changed');
|
||||||
}
|
|
||||||
const observedAt = new Date().toISOString();
|
const observedAt = new Date().toISOString();
|
||||||
const result = new Map<string, NcrRating>();
|
const result = new Map<string, NcrRating>();
|
||||||
table.find('tbody tr').each((_, row) => {
|
table.find('tbody tr').each((_, row) => {
|
||||||
@@ -78,30 +69,27 @@ export class NcrRatingsClient {
|
|||||||
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
|
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
|
||||||
// Unassigned issues cannot safely be matched to a held instrument.
|
// Unassigned issues cannot safely be matched to a held instrument.
|
||||||
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
|
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
|
||||||
const raw = cells.eq(RATING_COLUMN).text().replace(/\s+/g, ' ').trim();
|
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim();
|
||||||
const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
|
const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
|
||||||
if (
|
if (
|
||||||
!withdrawn &&
|
!withdrawn &&
|
||||||
!/^(?:AAA|A{1,2}[+-]?|B{1,3}[+-]?|CCC[+-]?|C{1,2}|D)\.ru$/.test(
|
!/^(AAA|AA[+-]?|A[+-]?|BBB[+-]?|BB[+-]?|B[+-]?|CCC[+-]?|CC|C|D)\.ru$/.test(
|
||||||
raw,
|
raw,
|
||||||
)
|
)
|
||||||
) {
|
)
|
||||||
throw new Error('NCR rating changed');
|
throw new Error('NCR rating changed');
|
||||||
}
|
|
||||||
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
|
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
|
||||||
cells.eq(DATE_COLUMN).text().trim(),
|
cells.eq(DATE_COLUMN).text().trim(),
|
||||||
);
|
);
|
||||||
const href = cells.eq(ISIN_COLUMN).find('a').attr('href');
|
const href = cells.eq(ISIN_COLUMN).find('a').attr('href');
|
||||||
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) {
|
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href))
|
||||||
throw new Error('NCR identity missing');
|
throw new Error('NCR identity missing');
|
||||||
}
|
|
||||||
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
|
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(Date.parse(date)) ||
|
!Number.isFinite(Date.parse(date)) ||
|
||||||
new Date(date).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== date
|
new Date(date).toISOString().slice(0, 10) !== date
|
||||||
) {
|
)
|
||||||
throw new Error('NCR date invalid');
|
throw new Error('NCR date invalid');
|
||||||
}
|
|
||||||
const rating: NcrRating = {
|
const rating: NcrRating = {
|
||||||
agency: 'NCR',
|
agency: 'NCR',
|
||||||
value: withdrawn ? null : raw,
|
value: withdrawn ? null : raw,
|
||||||
@@ -115,9 +103,8 @@ export class NcrRatingsClient {
|
|||||||
previous &&
|
previous &&
|
||||||
previous.date === date &&
|
previous.date === date &&
|
||||||
(previous.value !== rating.value || previous.status !== rating.status)
|
(previous.value !== rating.value || previous.status !== rating.status)
|
||||||
) {
|
)
|
||||||
throw new Error('NCR rating ambiguous');
|
throw new Error('NCR rating ambiguous');
|
||||||
}
|
|
||||||
if (!previous || previous.date < date) result.set(isin, rating);
|
if (!previous || previous.date < date) result.set(isin, rating);
|
||||||
});
|
});
|
||||||
if (!result.size) throw new Error('NCR registry empty');
|
if (!result.size) throw new Error('NCR registry empty');
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { NcrRatingsClient } from './ncr-ratings.client';
|
import { NcrRatingsClient } from './ncr-ratings.client';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|||||||
@@ -21,18 +21,3 @@ describe('decimal', () => {
|
|||||||
).toBe('1');
|
).toBe('1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('decimal formatting', () => {
|
|
||||||
it.each([
|
|
||||||
[120000n, 4, '12'],
|
|
||||||
[-120030n, 4, '-12.003'],
|
|
||||||
[1n, 4, '0.0001'],
|
|
||||||
[0n, 4, '0'],
|
|
||||||
])('preserves value %s at scale %s while trimming only trailing zeros', (value, scale, expected) => {
|
|
||||||
expect(formatDecimal({ value, scale })).toBe(expected);
|
|
||||||
});
|
|
||||||
it('preserves a long fraction with a trailing zero run', () => {
|
|
||||||
const significant = '1'.repeat(10_000);
|
|
||||||
expect(formatDecimal({ value: BigInt(significant + '0'.repeat(10_000)), scale: 20_000 })).toBe('0.' + significant);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { Decimal } from '../../common/decimal';
|
import { Decimal } from '../../common/decimal';
|
||||||
|
|
||||||
const MISSING_PROTO_COMPONENT = 0;
|
|
||||||
|
|
||||||
const NANO = 1_000_000_000n;
|
const NANO = 1_000_000_000n;
|
||||||
|
|
||||||
export type ProtoDecimal = { nano?: number | string; units?: number | string };
|
export type ProtoDecimal = { units?: string | number; nano?: string | number };
|
||||||
|
|
||||||
export function decimal(value: ProtoDecimal): Decimal {
|
export function decimal(value: ProtoDecimal): Decimal {
|
||||||
return {
|
return {
|
||||||
value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
|
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0),
|
||||||
scale: 9,
|
scale: 9,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,24 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
Prisma,
|
||||||
TbankInstrumentLookup as MetadataLookupRow,
|
TbankInstrumentLookup as MetadataLookupRow,
|
||||||
TbankInstrument as MetadataRow,
|
TbankInstrument as MetadataRow,
|
||||||
Prisma,
|
|
||||||
} from '../../../generated/prisma/client';
|
} from '../../../generated/prisma/client';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import type { TbankSyncAttempt } from './tbank-access.service';
|
import type { TbankSyncAttempt } from './tbank-access.service';
|
||||||
import type { TbankInstrument } from './tbank.client';
|
import type { TbankInstrument } from './tbank.client';
|
||||||
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { tbankSourceTtlMs } from './tbank-source.policy';
|
import { tbankSourceTtlMs } from './tbank-source.policy';
|
||||||
|
|
||||||
const FIRST_BATCH_OFFSET = 0;
|
|
||||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
|
||||||
const REVISION_INCREMENT = 1;
|
|
||||||
const MISSING_DATA_REVISION = 0;
|
|
||||||
|
|
||||||
export type MetadataIdentity = {
|
export type MetadataIdentity = {
|
||||||
figi?: null | string;
|
instrumentUid?: string | null;
|
||||||
instrumentUid?: null | string;
|
figi?: string | null;
|
||||||
};
|
};
|
||||||
export type MetadataLookup = {
|
export type MetadataLookup = {
|
||||||
data: null | TbankInstrument;
|
type: 'uid' | 'figi';
|
||||||
id: string;
|
id: string;
|
||||||
type: 'figi' | 'uid';
|
data: TbankInstrument | null;
|
||||||
};
|
};
|
||||||
const provider = 'tbank';
|
const provider = 'tbank';
|
||||||
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates.
|
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates.
|
||||||
@@ -43,16 +36,15 @@ export class InstrumentMetadataRepository {
|
|||||||
|
|
||||||
async readMany(identities: MetadataIdentity[]) {
|
async readMany(identities: MetadataIdentity[]) {
|
||||||
const identifiers = identities.flatMap((identity) => [
|
const identifiers = identities.flatMap((identity) => [
|
||||||
...(identity.instrumentUid?.trim() ?
|
...(identity.instrumentUid?.trim()
|
||||||
[{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] :
|
? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }]
|
||||||
[]),
|
: []),
|
||||||
...(identity.figi?.trim() ?
|
...(identity.figi?.trim()
|
||||||
[{ identifierType: 'figi', identifier: identity.figi.trim() }] :
|
? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
|
||||||
[]),
|
: []),
|
||||||
]);
|
]);
|
||||||
if (!identifiers.length) {
|
if (!identifiers.length)
|
||||||
return new Map<string, ReturnType<typeof selectMetadata>>();
|
return new Map<string, ReturnType<typeof selectMetadata>>();
|
||||||
}
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const lookups: MetadataLookupRow[] = [];
|
const lookups: MetadataLookupRow[] = [];
|
||||||
for (const identifierType of ['uid', 'figi']) {
|
for (const identifierType of ['uid', 'figi']) {
|
||||||
@@ -64,10 +56,10 @@ export class InstrumentMetadataRepository {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
for (
|
for (
|
||||||
let offset = FIRST_BATCH_OFFSET;
|
let offset = 0;
|
||||||
offset < values.length;
|
offset < values.length;
|
||||||
offset += metadataReadBatchSize
|
offset += metadataReadBatchSize
|
||||||
) {
|
)
|
||||||
lookups.push(
|
lookups.push(
|
||||||
...(await tx.tbankInstrumentLookup.findMany({
|
...(await tx.tbankInstrumentLookup.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -80,16 +72,15 @@ export class InstrumentMetadataRepository {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const uids = [
|
const uids = [
|
||||||
...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
|
...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
|
||||||
];
|
];
|
||||||
const instruments: MetadataRow[] = [];
|
const instruments: MetadataRow[] = [];
|
||||||
for (
|
for (
|
||||||
let offset = FIRST_BATCH_OFFSET;
|
let offset = 0;
|
||||||
offset < uids.length;
|
offset < uids.length;
|
||||||
offset += metadataReadBatchSize
|
offset += metadataReadBatchSize
|
||||||
) {
|
)
|
||||||
instruments.push(
|
instruments.push(
|
||||||
...(await tx.tbankInstrument.findMany({
|
...(await tx.tbankInstrument.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -98,7 +89,6 @@ export class InstrumentMetadataRepository {
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
const byIdentifier = new Map(
|
const byIdentifier = new Map(
|
||||||
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
|
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
|
||||||
);
|
);
|
||||||
@@ -113,7 +103,7 @@ export class InstrumentMetadataRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async reserve(
|
async reserve(
|
||||||
attemptOwner: { revision: string; userId: string },
|
attemptOwner: { userId: string; revision: string },
|
||||||
identity: MetadataIdentity,
|
identity: MetadataIdentity,
|
||||||
runId: string,
|
runId: string,
|
||||||
active: ReadonlySet<string>,
|
active: ReadonlySet<string>,
|
||||||
@@ -135,9 +125,8 @@ export class InstrumentMetadataRepository {
|
|||||||
previous?.state === 'running' &&
|
previous?.state === 'running' &&
|
||||||
previous.processRunId === runId &&
|
previous.processRunId === runId &&
|
||||||
active.has(previous.attemptId)
|
active.has(previous.attemptId)
|
||||||
) {
|
)
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
|
||||||
const data = {
|
const data = {
|
||||||
attemptId: randomUUID(),
|
attemptId: randomUUID(),
|
||||||
processRunId: runId,
|
processRunId: runId,
|
||||||
@@ -194,7 +183,7 @@ export class InstrumentMetadataRepository {
|
|||||||
['uid', uid],
|
['uid', uid],
|
||||||
['figi', figi],
|
['figi', figi],
|
||||||
] as const) {
|
] as const) {
|
||||||
if (id && uid && !aliases.has(`${type}:${id}`)) {
|
if (id && uid && !aliases.has(`${type}:${id}`))
|
||||||
aliases.set(`${type}:${id}`, {
|
aliases.set(`${type}:${id}`, {
|
||||||
type,
|
type,
|
||||||
id,
|
id,
|
||||||
@@ -202,15 +191,13 @@ export class InstrumentMetadataRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for (const result of aliases.values()) {
|
for (const result of aliases.values()) {
|
||||||
const instrument = result.data;
|
const instrument = result.data;
|
||||||
const uid =
|
const uid =
|
||||||
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
|
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
|
||||||
// FIGI is an alias, never a fabricated provider UID.
|
// FIGI is an alias, never a fabricated provider UID.
|
||||||
if (instrument && !uid) {
|
if (instrument && !uid)
|
||||||
throw new Error('Metadata response has no instrument UID');
|
throw new Error('Metadata response has no instrument UID');
|
||||||
}
|
|
||||||
if (instrument && uid) {
|
if (instrument && uid) {
|
||||||
const key = { provider, uid };
|
const key = { provider, uid };
|
||||||
const previous = await tx.tbankInstrument.findUnique({
|
const previous = await tx.tbankInstrument.findUnique({
|
||||||
@@ -233,9 +220,9 @@ export class InstrumentMetadataRepository {
|
|||||||
update: {
|
update: {
|
||||||
...data,
|
...data,
|
||||||
dataRevision: {
|
dataRevision: {
|
||||||
increment: isDeepStrictEqual(previous?.metadata, payload) ?
|
increment: isDeepStrictEqual(previous?.metadata, payload)
|
||||||
UNCHANGED_REVISION_INCREMENT :
|
? 0
|
||||||
REVISION_INCREMENT,
|
: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -256,9 +243,9 @@ export class InstrumentMetadataRepository {
|
|||||||
matchedUids,
|
matchedUids,
|
||||||
fetchedAt: now,
|
fetchedAt: now,
|
||||||
dataRevision: {
|
dataRevision: {
|
||||||
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ?
|
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
|
||||||
UNCHANGED_REVISION_INCREMENT :
|
? 0
|
||||||
REVISION_INCREMENT,
|
: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -279,7 +266,7 @@ export class InstrumentMetadataRepository {
|
|||||||
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (success) {
|
if (success)
|
||||||
await this.prisma.tbankSyncWork.updateMany({
|
await this.prisma.tbankSyncWork.updateMany({
|
||||||
where: {
|
where: {
|
||||||
...attempt,
|
...attempt,
|
||||||
@@ -290,14 +277,13 @@ export class InstrumentMetadataRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function selectMetadata(
|
function selectMetadata(
|
||||||
identity: MetadataIdentity,
|
identity: MetadataIdentity,
|
||||||
lookups: ReadonlyMap<string, MetadataLookupRow>,
|
lookups: ReadonlyMap<string, MetadataLookupRow>,
|
||||||
instruments: ReadonlyMap<string, MetadataRow>,
|
instruments: ReadonlyMap<string, MetadataRow>,
|
||||||
) {
|
) {
|
||||||
const resolve = (type: string, id: null | string | undefined) => {
|
const resolve = (type: string, id: string | null | undefined) => {
|
||||||
const lookup = lookups.get(`${type}:${id?.trim()}`);
|
const lookup = lookups.get(`${type}:${id?.trim()}`);
|
||||||
const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
|
const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
|
||||||
const instrument = uid ? instruments.get(uid) : undefined;
|
const instrument = uid ? instruments.get(uid) : undefined;
|
||||||
@@ -321,14 +307,13 @@ function selectMetadata(
|
|||||||
complete,
|
complete,
|
||||||
fresh:
|
fresh:
|
||||||
complete &&
|
complete &&
|
||||||
// eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard.
|
|
||||||
fetchedAt !== null &&
|
fetchedAt !== null &&
|
||||||
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
|
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
|
||||||
fetchedAt,
|
fetchedAt,
|
||||||
dataRevision: used
|
dataRevision: used
|
||||||
.map(
|
.map(
|
||||||
(row) =>
|
(row) =>
|
||||||
`${row.lookup?.dataRevision ?? MISSING_DATA_REVISION}:${row.instrument?.dataRevision ?? MISSING_DATA_REVISION}`,
|
`${row.lookup?.dataRevision ?? 0}:${row.instrument?.dataRevision ?? 0}`,
|
||||||
)
|
)
|
||||||
.join('/'),
|
.join('/'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
import {
|
|
||||||
InstrumentMetadataRepository,
|
|
||||||
type MetadataIdentity,
|
|
||||||
metadataKey,
|
|
||||||
type MetadataLookup,
|
|
||||||
} from './instrument-metadata.repository';
|
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||||
|
import {
|
||||||
|
InstrumentMetadataRepository,
|
||||||
|
metadataKey,
|
||||||
|
type MetadataIdentity,
|
||||||
|
type MetadataLookup,
|
||||||
|
} from './instrument-metadata.repository';
|
||||||
export { metadataKey } from './instrument-metadata.repository';
|
export { metadataKey } from './instrument-metadata.repository';
|
||||||
export type { MetadataIdentity } from './instrument-metadata.repository';
|
export type { MetadataIdentity } from './instrument-metadata.repository';
|
||||||
|
|
||||||
type Producer = {
|
type Producer = {
|
||||||
attempt: TbankSyncAttempt;
|
|
||||||
identity: MetadataIdentity;
|
identity: MetadataIdentity;
|
||||||
|
attempt: TbankSyncAttempt;
|
||||||
observers: TbankSyncAttempt[];
|
observers: TbankSyncAttempt[];
|
||||||
};
|
};
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -50,9 +49,8 @@ export class InstrumentMetadataService {
|
|||||||
const connection = await this.access.local(userId);
|
const connection = await this.access.local(userId);
|
||||||
if (revision && connection.revision !== revision) return null;
|
if (revision && connection.revision !== revision) return null;
|
||||||
const data = (await this.savedMany([identity])).get(metadataKey(identity));
|
const data = (await this.savedMany([identity])).get(metadataKey(identity));
|
||||||
if (!observe && !data?.fresh) {
|
if (!observe && !data?.fresh)
|
||||||
await this.request(userId, connection.revision, identity);
|
await this.request(userId, connection.revision, identity);
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
work: await this.repository.work(userId, connection.revision, identity),
|
work: await this.repository.work(userId, connection.revision, identity),
|
||||||
@@ -121,7 +119,7 @@ export class InstrumentMetadataService {
|
|||||||
let useful = false;
|
let useful = false;
|
||||||
let uidFailed = false;
|
let uidFailed = false;
|
||||||
if (identity.instrumentUid) {
|
if (identity.instrumentUid) {
|
||||||
let data: null | TbankInstrument | undefined;
|
let data: TbankInstrument | null | undefined;
|
||||||
try {
|
try {
|
||||||
data = await this.tbank.getInstrumentByUid(
|
data = await this.tbank.getInstrumentByUid(
|
||||||
token,
|
token,
|
||||||
@@ -132,37 +130,29 @@ export class InstrumentMetadataService {
|
|||||||
!identity.figi ||
|
!identity.figi ||
|
||||||
(error instanceof Error &&
|
(error instanceof Error &&
|
||||||
error.message === 'INVALID_TBANK_TOKEN')
|
error.message === 'INVALID_TBANK_TOKEN')
|
||||||
) {
|
)
|
||||||
throw error;
|
throw error;
|
||||||
}
|
|
||||||
uidFailed = true;
|
uidFailed = true;
|
||||||
}
|
}
|
||||||
if (data?.uid && data.uid !== identity.instrumentUid) {
|
if (data?.uid && data.uid !== identity.instrumentUid)
|
||||||
throw new Error('Metadata UID mismatch');
|
throw new Error('Metadata UID mismatch');
|
||||||
}
|
if (data !== undefined)
|
||||||
if (data !== undefined) {
|
|
||||||
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||||
}
|
|
||||||
useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
|
useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
|
||||||
if (!useful && data && !identity.figi) {
|
if (!useful && data && !identity.figi)
|
||||||
throw new Error('Incomplete metadata');
|
throw new Error('Incomplete metadata');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!useful && identity.figi) {
|
if (!useful && identity.figi) {
|
||||||
const data = await this.tbank.getInstrumentByFigi(
|
const data = await this.tbank.getInstrumentByFigi(
|
||||||
token,
|
token,
|
||||||
identity.figi,
|
identity.figi,
|
||||||
);
|
);
|
||||||
if (data?.figi && data.figi !== identity.figi) {
|
if (data?.figi && data.figi !== identity.figi)
|
||||||
throw new Error('Metadata FIGI mismatch');
|
throw new Error('Metadata FIGI mismatch');
|
||||||
}
|
if (data && !data.name?.trim() && !data.ticker?.trim())
|
||||||
if (data && !data.name?.trim() && !data.ticker?.trim()) {
|
|
||||||
throw new Error('Incomplete metadata');
|
throw new Error('Incomplete metadata');
|
||||||
}
|
if (data === undefined || (uidFailed && !data))
|
||||||
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation at an untrusted upstream boundary includes missing payloads.
|
|
||||||
if (data === undefined || (uidFailed && !data)) {
|
|
||||||
throw new Error('Metadata source unavailable');
|
throw new Error('Metadata source unavailable');
|
||||||
}
|
|
||||||
results.push({ type: 'figi', id: identity.figi, data });
|
results.push({ type: 'figi', id: identity.figi, data });
|
||||||
// A successful FIGI response identifies its canonical UID too;
|
// A successful FIGI response identifies its canonical UID too;
|
||||||
// a failed UID RPC is never persisted as a negative lookup.
|
// a failed UID RPC is never persisted as a negative lookup.
|
||||||
@@ -170,10 +160,9 @@ export class InstrumentMetadataService {
|
|||||||
uidFailed &&
|
uidFailed &&
|
||||||
data?.uid === identity.instrumentUid &&
|
data?.uid === identity.instrumentUid &&
|
||||||
identity.instrumentUid
|
identity.instrumentUid
|
||||||
) {
|
)
|
||||||
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
!results.length ||
|
!results.length ||
|
||||||
(results.some((result) => result.data) &&
|
(results.some((result) => result.data) &&
|
||||||
@@ -181,9 +170,8 @@ export class InstrumentMetadataService {
|
|||||||
(result) =>
|
(result) =>
|
||||||
result.data?.name?.trim() || result.data?.ticker?.trim(),
|
result.data?.name?.trim() || result.data?.ticker?.trim(),
|
||||||
))
|
))
|
||||||
) {
|
)
|
||||||
throw new Error('Incomplete metadata');
|
throw new Error('Incomplete metadata');
|
||||||
}
|
|
||||||
success = await this.repository.publish(
|
success = await this.repository.publish(
|
||||||
attempt,
|
attempt,
|
||||||
results,
|
results,
|
||||||
@@ -191,14 +179,13 @@ export class InstrumentMetadataService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
await this.access.rejectMembershipToken(attempt);
|
await this.access.rejectMembershipToken(attempt);
|
||||||
} else {
|
else
|
||||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: 'TBANK_UNAVAILABLE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Remove the producer before awaiting finalization: a new request cannot
|
// Remove the producer before awaiting finalization: a new request cannot
|
||||||
// attach to a producer whose observer list has already been drained.
|
// attach to a producer whose observer list has already been drained.
|
||||||
this.producers.delete(metadataKey(identity));
|
this.producers.delete(metadataKey(identity));
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
jest.mock('../../prisma/prisma.service', () => ({
|
||||||
|
PrismaService: class PrismaService {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import type { TbankAccountContext } from './tbank-access.service';
|
||||||
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
|
|
||||||
|
describe('TbankAccessService', () => {
|
||||||
|
const prisma = { tbankConnection: { findUnique: jest.fn() } };
|
||||||
|
const token = {
|
||||||
|
decrypt: jest.fn(),
|
||||||
|
translateInvalidToken: jest.fn((operation: () => Promise<unknown>) =>
|
||||||
|
new TbankTokenService({
|
||||||
|
getOrThrow: () => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=',
|
||||||
|
} as never).translateInvalidToken(operation),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const tbank = { getOpenAccounts: jest.fn() };
|
||||||
|
const service = new TbankAccessService(
|
||||||
|
prisma as never,
|
||||||
|
token as never,
|
||||||
|
tbank as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||||
|
encryptedToken: 'encrypted',
|
||||||
|
});
|
||||||
|
token.decrypt.mockReturnValue('plain-token');
|
||||||
|
tbank.getOpenAccounts.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'account',
|
||||||
|
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||||
|
name: ' Основной ',
|
||||||
|
openedDate: { seconds: '1723507200' },
|
||||||
|
},
|
||||||
|
{ id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists supported accounts through the saved decrypted token', async () => {
|
||||||
|
await expect(service.listSupportedAccounts('user')).resolves.toEqual([
|
||||||
|
{
|
||||||
|
id: 'account',
|
||||||
|
type: 'BROKERAGE',
|
||||||
|
name: 'Основной',
|
||||||
|
openedOn: '2024-08-13',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(prisma.tbankConnection.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user' },
|
||||||
|
});
|
||||||
|
expect(token.decrypt).toHaveBeenCalledWith('encrypted');
|
||||||
|
expect(tbank.getOpenAccounts).toHaveBeenCalledWith('plain-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing connections before calling T-Bank', async () => {
|
||||||
|
prisma.tbankConnection.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
|
||||||
|
expect.any(ConflictException),
|
||||||
|
);
|
||||||
|
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the persisted connection generation without an upstream account call', async () => {
|
||||||
|
const context = await service.withConnection('user', (value) => value);
|
||||||
|
|
||||||
|
expect(context).toEqual({
|
||||||
|
token: 'plain-token',
|
||||||
|
generation: expect.any(String) as string,
|
||||||
|
});
|
||||||
|
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates invalid tokens and preserves unavailable upstream errors', async () => {
|
||||||
|
tbank.getOpenAccounts.mockRejectedValueOnce(
|
||||||
|
new Error('INVALID_TBANK_TOKEN'),
|
||||||
|
);
|
||||||
|
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
|
||||||
|
expect.any(BadRequestException),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unavailable = new ServiceUnavailableException({
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
tbank.getOpenAccounts.mockRejectedValueOnce(unavailable);
|
||||||
|
await expect(service.listSupportedAccounts('user')).rejects.toBe(
|
||||||
|
unavailable,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps account-scoped handlers with resolved account access', async () => {
|
||||||
|
const handler = jest.fn((context: TbankAccountContext) =>
|
||||||
|
Promise.resolve({
|
||||||
|
resolvedToken: context.token,
|
||||||
|
generation: context.generation,
|
||||||
|
accountId: context.account.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.withAccount('user', 'account', handler),
|
||||||
|
).resolves.toEqual({
|
||||||
|
resolvedToken: 'plain-token',
|
||||||
|
generation: expect.any(String) as string,
|
||||||
|
accountId: 'account',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives a stable opaque generation from each persisted encrypted payload', async () => {
|
||||||
|
const generation = () =>
|
||||||
|
service.withAccount('user', 'account', ({ generation: value }) =>
|
||||||
|
Promise.resolve(value),
|
||||||
|
);
|
||||||
|
prisma.tbankConnection.findUnique
|
||||||
|
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
|
||||||
|
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
|
||||||
|
.mockResolvedValueOnce({ encryptedToken: 'encrypted:second-nonce' });
|
||||||
|
|
||||||
|
const first = await generation();
|
||||||
|
const sameRecord = await generation();
|
||||||
|
const replaced = await generation();
|
||||||
|
|
||||||
|
expect(sameRecord).toBe(first);
|
||||||
|
expect(replaced).not.toBe(first);
|
||||||
|
expect(first).not.toContain('plain-token');
|
||||||
|
expect(first).not.toContain('encrypted:first-nonce');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects inaccessible accounts before running the handler', async () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.withAccount('user', 'missing', handler),
|
||||||
|
).rejects.toEqual(expect.any(NotFoundException));
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates invalid-token handler failures and preserves unrelated handler failures', async () => {
|
||||||
|
await expect(
|
||||||
|
service.withAccount('user', 'account', () =>
|
||||||
|
Promise.reject(new Error('INVALID_TBANK_TOKEN')),
|
||||||
|
),
|
||||||
|
).rejects.toEqual(expect.any(BadRequestException));
|
||||||
|
|
||||||
|
const failure = new Error('source failed');
|
||||||
|
await expect(
|
||||||
|
service.withAccount('user', 'account', () => Promise.reject(failure)),
|
||||||
|
).rejects.toBe(failure);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,27 +1,34 @@
|
|||||||
|
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import type { TbankAccount } from './tbank.client';
|
|
||||||
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
import {
|
||||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
SupportedTbankAccount,
|
||||||
|
accessibleTbankAccount,
|
||||||
|
supportedTbankAccounts,
|
||||||
|
} from './tbank-account.policy';
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
|
import { TbankAccount, TbankClient } from './tbank.client';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_CONNECTIONS = 1;
|
export type TbankConnectionContext = {
|
||||||
const MEMBERSHIP_REVISION_INCREMENT = 1;
|
token: string;
|
||||||
const UNCHANGED_MEMBERSHIP_REVISION_INCREMENT = 0;
|
generation: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TbankAccountContext = TbankConnectionContext & {
|
||||||
|
account: TbankAccount;
|
||||||
|
};
|
||||||
|
|
||||||
export type TbankSyncAttempt = {
|
export type TbankSyncAttempt = {
|
||||||
attemptId: string;
|
|
||||||
id: string;
|
|
||||||
revision: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
|
revision: string;
|
||||||
|
id: string;
|
||||||
|
attemptId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -29,6 +36,7 @@ export class TbankAccessService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly tokens: TbankTokenService,
|
private readonly tokens: TbankTokenService,
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async reserveMembership(
|
async reserveMembership(
|
||||||
@@ -54,9 +62,8 @@ export class TbankAccessService {
|
|||||||
connection.membershipAt,
|
connection.membershipAt,
|
||||||
new Date(),
|
new Date(),
|
||||||
)
|
)
|
||||||
) {
|
)
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
const scope = { userId, revision, scopeKey: 'membership' };
|
const scope = { userId, revision, scopeKey: 'membership' };
|
||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
@@ -65,9 +72,8 @@ export class TbankAccessService {
|
|||||||
previous?.state === 'running' &&
|
previous?.state === 'running' &&
|
||||||
previous.processRunId === processRunId &&
|
previous.processRunId === processRunId &&
|
||||||
active.has(previous.attemptId)
|
active.has(previous.attemptId)
|
||||||
) {
|
)
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
|
||||||
const data = {
|
const data = {
|
||||||
attemptId: randomUUID(),
|
attemptId: randomUUID(),
|
||||||
processRunId,
|
processRunId,
|
||||||
@@ -105,10 +111,10 @@ export class TbankAccessService {
|
|||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||||
});
|
});
|
||||||
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
return result.count === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
|
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<string | null> {
|
||||||
const work = await this.prisma.tbankSyncWork.findFirst({
|
const work = await this.prisma.tbankSyncWork.findFirst({
|
||||||
where: {
|
where: {
|
||||||
...attempt,
|
...attempt,
|
||||||
@@ -127,9 +133,8 @@ export class TbankAccessService {
|
|||||||
completeWork = false,
|
completeWork = false,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const supported = supportedTbankAccounts(accounts);
|
const supported = supportedTbankAccounts(accounts);
|
||||||
if (new Set(supported.map(({ id }) => id)).size !== supported.length) {
|
if (new Set(supported.map(({ id }) => id)).size !== supported.length)
|
||||||
throw new Error('Duplicate T-Bank account identity');
|
throw new Error('Duplicate T-Bank account identity');
|
||||||
}
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
// Acquire the write fence before reading membership. Replacement and
|
// Acquire the write fence before reading membership. Replacement and
|
||||||
// attempt supersession cannot interleave with the publication below.
|
// attempt supersession cannot interleave with the publication below.
|
||||||
@@ -155,7 +160,6 @@ export class TbankAccessService {
|
|||||||
const changed =
|
const changed =
|
||||||
previous.length !== next.length ||
|
previous.length !== next.length ||
|
||||||
previous.some((account, index) => {
|
previous.some((account, index) => {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The index comes from the paired account array iteration.
|
|
||||||
const incoming = next[index];
|
const incoming = next[index];
|
||||||
return (
|
return (
|
||||||
account.accountId !== incoming.accountId ||
|
account.accountId !== incoming.accountId ||
|
||||||
@@ -188,15 +192,14 @@ export class TbankAccessService {
|
|||||||
where: { userId_revision: { userId, revision } },
|
where: { userId_revision: { userId, revision } },
|
||||||
data: {
|
data: {
|
||||||
membershipAt: fetchedAt,
|
membershipAt: fetchedAt,
|
||||||
membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
|
membershipRevision: { increment: changed ? 1 : 0 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (completeWork) {
|
if (completeWork)
|
||||||
await tx.tbankSyncWork.update({
|
await tx.tbankSyncWork.update({
|
||||||
where: { id: attempt.id },
|
where: { id: attempt.id },
|
||||||
data: { state: 'complete', progress: { accounts: supported.length } },
|
data: { state: 'complete', progress: { accounts: supported.length } },
|
||||||
});
|
});
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -235,20 +238,94 @@ export class TbankAccessService {
|
|||||||
accounts: { orderBy: { upstreamOrder: 'asc' } },
|
accounts: { orderBy: { upstreamOrder: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!connection) {
|
if (!connection)
|
||||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||||
}
|
if (connection.invalidToken)
|
||||||
if (connection.invalidToken) {
|
|
||||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
connection.membershipAt &&
|
connection.membershipAt &&
|
||||||
accountId &&
|
accountId &&
|
||||||
accountId !== 'all' &&
|
accountId !== 'all' &&
|
||||||
!connection.accounts.some((account) => account.accountId === accountId)
|
!connection.accounts.some((account) => account.accountId === accountId)
|
||||||
) {
|
)
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
|
||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listSupportedAccounts(
|
||||||
|
userId: string,
|
||||||
|
): Promise<SupportedTbankAccount[]> {
|
||||||
|
return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async withSupportedAccounts<T>(
|
||||||
|
userId: string,
|
||||||
|
handler: (context: {
|
||||||
|
token: string;
|
||||||
|
generation: string;
|
||||||
|
accounts: SupportedTbankAccount[];
|
||||||
|
}) => Promise<T> | T,
|
||||||
|
): Promise<T> {
|
||||||
|
return this.withConnection(userId, async ({ token, generation }) =>
|
||||||
|
handler({
|
||||||
|
token,
|
||||||
|
generation,
|
||||||
|
accounts: supportedTbankAccounts(
|
||||||
|
await this.tbank.getOpenAccounts(token),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async withConnection<T>(
|
||||||
|
userId: string,
|
||||||
|
handler: (context: TbankConnectionContext) => Promise<T> | T,
|
||||||
|
): Promise<T> {
|
||||||
|
const connection = await this.connection(userId);
|
||||||
|
return this.tokens.translateInvalidToken(async () => handler(connection));
|
||||||
|
}
|
||||||
|
|
||||||
|
async withAccount<T>(
|
||||||
|
userId: string,
|
||||||
|
accountId: string,
|
||||||
|
handler: (context: TbankAccountContext) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return this.withConnection(userId, (connection) =>
|
||||||
|
this.withAccountForConnection(connection, accountId, handler),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async withAccountForConnection<T>(
|
||||||
|
connection: TbankConnectionContext,
|
||||||
|
accountId: string,
|
||||||
|
handler: (context: TbankAccountContext) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const { token, generation } = connection;
|
||||||
|
return this.tokens.translateInvalidToken(async () => {
|
||||||
|
const account = accessibleTbankAccount(
|
||||||
|
await this.tbank.getOpenAccounts(token),
|
||||||
|
accountId,
|
||||||
|
);
|
||||||
|
if (!account) throw new NotFoundException();
|
||||||
|
return handler({ token, generation, account });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connection(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ token: string; generation: string }> {
|
||||||
|
const connection = await this.prisma.tbankConnection.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
if (!connection)
|
||||||
|
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||||
|
if (connection.invalidToken)
|
||||||
|
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||||
|
return {
|
||||||
|
token: this.tokens.decrypt(connection.encryptedToken),
|
||||||
|
generation: createHash('sha256')
|
||||||
|
.update(connection.encryptedToken)
|
||||||
|
.digest('base64url'),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
import {
|
||||||
|
accessibleTbankAccount,
|
||||||
|
supportedTbankAccounts,
|
||||||
|
} 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', () => {
|
||||||
@@ -42,4 +45,26 @@ describe('tbank-account-policy', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('matches account-scoped access only for supported account types', () => {
|
||||||
|
expect(
|
||||||
|
accessibleTbankAccount(
|
||||||
|
[
|
||||||
|
{ id: 'account', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
|
||||||
|
{ id: 'other', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Other' },
|
||||||
|
],
|
||||||
|
'account',
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
accessibleTbankAccount(
|
||||||
|
[{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Brokerage' }],
|
||||||
|
'account',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
id: 'account',
|
||||||
|
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||||
|
name: 'Brokerage',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
|
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||||
import { TbankAccount } from './tbank.client';
|
import { TbankAccount } from './tbank.client';
|
||||||
|
|
||||||
const accountTypes = {
|
const accountTypes = {
|
||||||
@@ -18,8 +18,8 @@ type AccountType = keyof typeof accountTypes;
|
|||||||
export type SupportedTbankAccount = {
|
export type SupportedTbankAccount = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
openedOn: null | string;
|
|
||||||
type: (typeof accountTypes)[AccountType]['type'];
|
type: (typeof accountTypes)[AccountType]['type'];
|
||||||
|
openedOn: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function supportedTbankAccounts(
|
export function supportedTbankAccounts(
|
||||||
@@ -39,9 +39,20 @@ export function supportedTbankAccounts(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openedOn(seconds: number | string | undefined): null | string {
|
export function accessibleTbankAccount(
|
||||||
|
accounts: TbankAccount[],
|
||||||
|
accountId: string,
|
||||||
|
): TbankAccount | null {
|
||||||
|
return (
|
||||||
|
accounts.find(
|
||||||
|
(account) => account.id === accountId && account.type in accountTypes,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openedOn(seconds: string | number | undefined): string | null {
|
||||||
if (seconds === undefined) return null;
|
if (seconds === undefined) return null;
|
||||||
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
|
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
|
||||||
if (Number.isNaN(date.valueOf())) return null;
|
if (Number.isNaN(date.valueOf())) return null;
|
||||||
return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,23 @@ import type {
|
|||||||
} from './tbank.client';
|
} from './tbank.client';
|
||||||
|
|
||||||
export type TbankShare = TbankInstrument & {
|
export type TbankShare = TbankInstrument & {
|
||||||
apiTradeAvailableFlag?: boolean;
|
uid?: string;
|
||||||
|
figi?: string;
|
||||||
assetUid?: string;
|
assetUid?: string;
|
||||||
classCode?: string;
|
classCode?: string;
|
||||||
currency?: string;
|
|
||||||
figi?: string;
|
|
||||||
first_1dayCandleDate?: TbankTimestamp;
|
|
||||||
instrumentKind?: string;
|
|
||||||
lot?: number;
|
|
||||||
realExchange?: string;
|
realExchange?: string;
|
||||||
sector?: string;
|
apiTradeAvailableFlag?: boolean;
|
||||||
|
currency?: string;
|
||||||
shareType?: string;
|
shareType?: string;
|
||||||
uid?: string;
|
instrumentKind?: string;
|
||||||
|
sector?: string;
|
||||||
|
lot?: number;
|
||||||
|
first_1dayCandleDate?: TbankTimestamp;
|
||||||
};
|
};
|
||||||
export type TbankCandle = {
|
export type TbankCandle = {
|
||||||
|
time?: TbankTimestamp;
|
||||||
close?: TbankDecimal;
|
close?: TbankDecimal;
|
||||||
isComplete?: boolean;
|
isComplete?: boolean;
|
||||||
time?: TbankTimestamp;
|
|
||||||
};
|
};
|
||||||
export type TbankLastPrice = {
|
export type TbankLastPrice = {
|
||||||
instrumentUid?: string;
|
instrumentUid?: string;
|
||||||
@@ -32,38 +32,38 @@ export type TbankLastPrice = {
|
|||||||
export type TbankFundamentals = {
|
export type TbankFundamentals = {
|
||||||
assetUid?: string;
|
assetUid?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
ebitdaTtm?: number;
|
|
||||||
marketCapitalization?: number;
|
marketCapitalization?: number;
|
||||||
netDebtToEbitda?: number;
|
|
||||||
netIncomeTtm?: number;
|
|
||||||
peRatioTtm?: number;
|
peRatioTtm?: number;
|
||||||
priceToBookTtm?: number;
|
priceToBookTtm?: number;
|
||||||
revenueTtm?: number;
|
revenueTtm?: number;
|
||||||
|
netIncomeTtm?: number;
|
||||||
|
ebitdaTtm?: number;
|
||||||
roe?: number;
|
roe?: number;
|
||||||
totalDebtMrq?: number;
|
totalDebtMrq?: number;
|
||||||
|
netDebtToEbitda?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TbankBond = Omit<TbankShare, 'shareType'> & {
|
export type TbankBond = Omit<TbankShare, 'shareType'> & {
|
||||||
|
nominal?: TbankMoney;
|
||||||
|
initialNominal?: TbankMoney;
|
||||||
aciValue?: TbankMoney;
|
aciValue?: TbankMoney;
|
||||||
amortizationFlag?: boolean;
|
maturityDate?: TbankTimestamp;
|
||||||
bondType?: string;
|
|
||||||
callDate?: TbankTimestamp;
|
callDate?: TbankTimestamp;
|
||||||
couponQuantityPerYear?: number;
|
couponQuantityPerYear?: number;
|
||||||
floatingCouponFlag?: boolean;
|
floatingCouponFlag?: boolean;
|
||||||
initialNominal?: TbankMoney;
|
|
||||||
maturityDate?: TbankTimestamp;
|
|
||||||
nominal?: TbankMoney;
|
|
||||||
perpetualFlag?: boolean;
|
perpetualFlag?: boolean;
|
||||||
riskLevel?: string;
|
amortizationFlag?: boolean;
|
||||||
subordinatedFlag?: boolean;
|
subordinatedFlag?: boolean;
|
||||||
|
bondType?: string;
|
||||||
|
riskLevel?: string;
|
||||||
};
|
};
|
||||||
export type TbankCoupon = {
|
export type TbankCoupon = {
|
||||||
couponDate?: TbankTimestamp;
|
|
||||||
couponEndDate?: TbankTimestamp;
|
|
||||||
couponNumber?: string;
|
couponNumber?: string;
|
||||||
couponPeriod?: number;
|
couponDate?: TbankTimestamp;
|
||||||
couponStartDate?: TbankTimestamp;
|
|
||||||
couponType?: string;
|
|
||||||
fixDate?: TbankTimestamp;
|
fixDate?: TbankTimestamp;
|
||||||
payOneBond?: TbankMoney;
|
payOneBond?: TbankMoney;
|
||||||
|
couponType?: string;
|
||||||
|
couponStartDate?: TbankTimestamp;
|
||||||
|
couponEndDate?: TbankTimestamp;
|
||||||
|
couponPeriod?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ describe('request-driven source freshness', () => {
|
|||||||
'%s expires at its boundary without changing source time',
|
'%s expires at its boundary without changing source time',
|
||||||
(source) => {
|
(source) => {
|
||||||
const fetchedAt = new Date('2026-09-01Z');
|
const fetchedAt = new Date('2026-09-01Z');
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The key comes from the local source-policy test table.
|
|
||||||
const ttl = tbankSourceTtlMs[source];
|
const ttl = tbankSourceTtlMs[source];
|
||||||
expect(
|
expect(
|
||||||
tbankSourceNeedsRefresh(
|
tbankSourceNeedsRefresh(
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export function tbankSourceNeedsRefresh(
|
|||||||
return (
|
return (
|
||||||
force ||
|
force ||
|
||||||
!fetchedAt ||
|
!fetchedAt ||
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The key is a member of the closed TbankSource union.
|
|
||||||
now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
|
now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -26,9 +25,8 @@ export class TbankTokenService {
|
|||||||
try {
|
try {
|
||||||
return await operation();
|
return await operation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,18 @@
|
|||||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||||
export { InstrumentMetadataService } from './instrument-metadata.service';
|
export { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
export { metadataKey } from './instrument-metadata.service';
|
|
||||||
export type { MetadataIdentity } from './instrument-metadata.service';
|
|
||||||
export {
|
export {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankSyncAttempt,
|
type TbankConnectionContext,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
export { supportedTbankAccounts } from './tbank-account.policy';
|
|
||||||
export {
|
export {
|
||||||
type TbankBond,
|
type TbankBond,
|
||||||
type TbankCandle,
|
type TbankCandle,
|
||||||
type TbankCoupon,
|
type TbankCoupon,
|
||||||
type TbankFundamentals,
|
type TbankFundamentals,
|
||||||
type TbankLastPrice,
|
|
||||||
type TbankShare,
|
type TbankShare,
|
||||||
} from './tbank-market.types';
|
} from './tbank-market.types';
|
||||||
|
|
||||||
export { TbankMembershipService } from './tbank-membership.service';
|
|
||||||
|
|
||||||
export {
|
|
||||||
tbankSourceNeedsRefresh,
|
|
||||||
tbankSourceTtlMs,
|
|
||||||
} from './tbank-source.policy';
|
|
||||||
|
|
||||||
export { TbankTokenService } from './tbank-token.service';
|
export { TbankTokenService } from './tbank-token.service';
|
||||||
|
export { supportedTbankAccounts } from './tbank-account.policy';
|
||||||
export {
|
export {
|
||||||
TbankClient,
|
TbankClient,
|
||||||
type TbankDividend,
|
type TbankDividend,
|
||||||
@@ -35,3 +24,13 @@ export {
|
|||||||
type TbankPositions,
|
type TbankPositions,
|
||||||
type TbankTimestamp,
|
type TbankTimestamp,
|
||||||
} from './tbank.client';
|
} from './tbank.client';
|
||||||
|
|
||||||
|
export { TbankMembershipService } from './tbank-membership.service';
|
||||||
|
|
||||||
|
export {
|
||||||
|
tbankSourceTtlMs,
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
|
} from './tbank-source.policy';
|
||||||
|
|
||||||
|
export { metadataKey } from './instrument-metadata.service';
|
||||||
|
export type { MetadataIdentity } from './instrument-metadata.service';
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { status } from '@grpc/grpc-js';
|
import { status } from '@grpc/grpc-js';
|
||||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
describe('TbankClient operations', () => {
|
describe('TbankClient operations', () => {
|
||||||
@@ -47,27 +46,11 @@ describe('TbankClient operations', () => {
|
|||||||
seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)),
|
seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)),
|
||||||
},
|
},
|
||||||
to: {
|
to: {
|
||||||
seconds: String(Math.floor(Date.parse('2026-09-19T00:00:00Z') / 1000)),
|
seconds: String(Math.floor(Date.parse('2026-09-18T00:00:00Z') / 1000)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['getDividends', 'getBondCoupons'] as const)(
|
|
||||||
'%s converts a leap-day calendar window to a full half-open UTC day',
|
|
||||||
async (method) => {
|
|
||||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
|
||||||
const call = jest.fn().mockResolvedValue({});
|
|
||||||
(client as unknown as { call: jest.Mock }).call = call;
|
|
||||||
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
|
|
||||||
await client[method]('token', 'uid', '2024-02-29', '2024-02-29');
|
|
||||||
expect(call).toHaveBeenCalledWith('token', undefined, method, {
|
|
||||||
instrumentId: 'uid',
|
|
||||||
from: { seconds: String(Date.parse('2024-02-29T00:00:00Z') / 1000) },
|
|
||||||
to: { seconds: String(Date.parse('2024-03-01T00:00:00Z') / 1000) },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('propagates the normalized upstream error', async () => {
|
it('propagates the normalized upstream error', async () => {
|
||||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||||
const error = new ServiceUnavailableException({
|
const error = new ServiceUnavailableException({
|
||||||
@@ -113,10 +96,11 @@ describe('TbankClient operations', () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
});
|
});
|
||||||
const failed = invoke('token', {}, 'getPortfolio', {});
|
const failed = invoke('token', {}, 'getPortfolio', {});
|
||||||
await Promise.all([
|
const rejected = expect(failed).rejects.toEqual(
|
||||||
expect(failed).rejects.toEqual(expect.any(ServiceUnavailableException)),
|
expect.any(ServiceUnavailableException),
|
||||||
jest.advanceTimersByTimeAsync(1000),
|
);
|
||||||
]);
|
await jest.advanceTimersByTimeAsync(1000);
|
||||||
|
await rejected;
|
||||||
|
|
||||||
expect(logger.event).toHaveBeenCalledWith(
|
expect(logger.event).toHaveBeenCalledWith(
|
||||||
'debug',
|
'debug',
|
||||||
@@ -141,7 +125,7 @@ describe('TbankClient metadata retries', () => {
|
|||||||
const instrument = { name: 'Облигация', ticker: 'BOND' };
|
const instrument = { name: 'Облигация', ticker: 'BOND' };
|
||||||
let client: TbankClient;
|
let client: TbankClient;
|
||||||
let requestRpc: jest.Mock;
|
let requestRpc: jest.Mock;
|
||||||
let queue: { run: jest.Mock; size: number };
|
let queue: { size: number; run: jest.Mock };
|
||||||
let logger: {
|
let logger: {
|
||||||
event: jest.Mock<void, [string, string, Record<string, unknown>]>;
|
event: jest.Mock<void, [string, string, Record<string, unknown>]>;
|
||||||
};
|
};
|
||||||
@@ -235,10 +219,12 @@ describe('TbankClient metadata retries', () => {
|
|||||||
|
|
||||||
it('stops retrying a saturated queue after three admission attempts', async () => {
|
it('stops retrying a saturated queue after three admission attempts', async () => {
|
||||||
queue.size = 50;
|
queue.size = 50;
|
||||||
await Promise.all([
|
const result = expect(
|
||||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
client.getInstrumentByUid('token', 'uid'),
|
||||||
jest.advanceTimersByTimeAsync(6000),
|
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
]);
|
|
||||||
|
await jest.advanceTimersByTimeAsync(6000);
|
||||||
|
await result;
|
||||||
|
|
||||||
expect(queue.run).not.toHaveBeenCalled();
|
expect(queue.run).not.toHaveBeenCalled();
|
||||||
expect(logger.event).toHaveBeenCalledTimes(3);
|
expect(logger.event).toHaveBeenCalledTimes(3);
|
||||||
@@ -252,10 +238,12 @@ describe('TbankClient metadata retries', () => {
|
|||||||
|
|
||||||
it('stops retrying an unavailable upstream after three RPCs', async () => {
|
it('stops retrying an unavailable upstream after three RPCs', async () => {
|
||||||
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
||||||
await Promise.all([
|
const result = expect(
|
||||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
client.getInstrumentByUid('token', 'uid'),
|
||||||
jest.advanceTimersByTimeAsync(6000),
|
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
]);
|
|
||||||
|
await jest.advanceTimersByTimeAsync(6000);
|
||||||
|
await result;
|
||||||
|
|
||||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
expect(queue.run).toHaveBeenCalledTimes(3);
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
@@ -279,7 +267,6 @@ describe('TbankClient metadata retries', () => {
|
|||||||
async (method) => {
|
async (method) => {
|
||||||
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
|
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
|
||||||
|
|
||||||
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
|
|
||||||
await expect(client[method]('token', 'instrument')).resolves.toBeNull();
|
await expect(client[method]('token', 'instrument')).resolves.toBeNull();
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||||
import {
|
import {
|
||||||
ChannelCredentials,
|
ChannelCredentials,
|
||||||
Client,
|
Client,
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
|
import { Environment } from '../../config/env.validation';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
TbankBond,
|
TbankBond,
|
||||||
@@ -26,17 +29,6 @@ import type {
|
|||||||
TbankShare,
|
TbankShare,
|
||||||
} from './tbank-market.types';
|
} from './tbank-market.types';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
|
||||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
|
||||||
import { Environment } from '../../config/env.validation';
|
|
||||||
|
|
||||||
const INITIAL_REQUEST_COUNT = 0;
|
|
||||||
const MIN_RATE_DELAY_MS = 1;
|
|
||||||
const FIRST_ATTEMPT = 1;
|
|
||||||
const ATTEMPT_INCREMENT = 1;
|
|
||||||
const NO_RPC_ATTEMPTS = 0;
|
|
||||||
const NEXT_DAY_OFFSET = 1;
|
|
||||||
|
|
||||||
const RATE_WINDOW_MS = 1_000;
|
const RATE_WINDOW_MS = 1_000;
|
||||||
const METADATA_MAX_ATTEMPTS = 3;
|
const METADATA_MAX_ATTEMPTS = 3;
|
||||||
const RPC_MAX_ATTEMPTS = 2;
|
const RPC_MAX_ATTEMPTS = 2;
|
||||||
@@ -50,78 +42,78 @@ type Unary<Request, Response> = (
|
|||||||
request: Request,
|
request: Request,
|
||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
options: { deadline: Date },
|
options: { deadline: Date },
|
||||||
callback: (error: null | ServiceError, response: Response) => void,
|
callback: (error: ServiceError | null, response: Response) => void,
|
||||||
) => ClientUnaryCall;
|
) => ClientUnaryCall;
|
||||||
type TbankClientService = Client & Record<string, Unary<never, never>>;
|
type TbankClientService = Client & Record<string, Unary<never, never>>;
|
||||||
export type TbankDecimal = { nano?: number | string; units?: number | string };
|
export type TbankDecimal = { units?: string | number; nano?: string | number };
|
||||||
export type TbankMoney = TbankDecimal & { currency?: string };
|
export type TbankMoney = TbankDecimal & { currency?: string };
|
||||||
export type TbankAccount = {
|
export type TbankAccount = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
|
||||||
openedDate?: { seconds?: number | string };
|
|
||||||
type: string;
|
type: string;
|
||||||
|
name: string;
|
||||||
|
openedDate?: { seconds?: string | number };
|
||||||
};
|
};
|
||||||
export type TbankPortfolio = {
|
export type TbankPortfolio = {
|
||||||
positions?: {
|
positions?: {
|
||||||
averagePositionPrice?: TbankMoney;
|
|
||||||
currentPrice?: TbankMoney;
|
|
||||||
figi?: string;
|
figi?: string;
|
||||||
instrumentType?: string;
|
|
||||||
instrumentUid?: string;
|
instrumentUid?: string;
|
||||||
quantity?: TbankDecimal;
|
instrumentType?: string;
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
|
quantity?: TbankDecimal;
|
||||||
|
currentPrice?: TbankMoney;
|
||||||
|
averagePositionPrice?: TbankMoney;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
export type TbankPositions = { money?: TbankMoney[] };
|
export type TbankPositions = { money?: TbankMoney[] };
|
||||||
export type TbankInstrument = {
|
export type TbankInstrument = {
|
||||||
brand?: { logoName?: null | string };
|
uid?: string;
|
||||||
figi?: string;
|
figi?: string;
|
||||||
instrumentType?: string;
|
instrumentType?: string;
|
||||||
isin?: string;
|
|
||||||
name?: string;
|
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
uid?: string;
|
name?: string;
|
||||||
|
isin?: string;
|
||||||
|
brand?: { logoName?: string | null };
|
||||||
};
|
};
|
||||||
export type TbankDividend = {
|
export type TbankDividend = {
|
||||||
dividendNet?: TbankMoney;
|
dividendNet?: TbankMoney;
|
||||||
dividendType?: string;
|
|
||||||
lastBuyDate?: TbankTimestamp;
|
|
||||||
paymentDate?: TbankTimestamp;
|
|
||||||
recordDate?: TbankTimestamp;
|
recordDate?: TbankTimestamp;
|
||||||
|
paymentDate?: TbankTimestamp;
|
||||||
|
lastBuyDate?: TbankTimestamp;
|
||||||
|
dividendType?: string;
|
||||||
};
|
};
|
||||||
export type TbankTimestamp = { nanos?: number; seconds?: number | string };
|
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
|
||||||
export type TbankOperationItem = {
|
export type TbankOperationItem = {
|
||||||
accruedInt?: TbankMoney;
|
state?: string;
|
||||||
commission?: TbankMoney;
|
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
date?: TbankTimestamp;
|
date?: TbankTimestamp;
|
||||||
|
type?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
figi?: string;
|
figi?: string;
|
||||||
id?: string;
|
|
||||||
instrumentType?: string;
|
|
||||||
instrumentUid?: string;
|
instrumentUid?: string;
|
||||||
name?: string;
|
instrumentType?: string;
|
||||||
|
ticker?: string;
|
||||||
payment?: TbankMoney;
|
payment?: TbankMoney;
|
||||||
price?: TbankMoney;
|
price?: TbankMoney;
|
||||||
quantity?: number | string;
|
commission?: TbankMoney;
|
||||||
quantityDone?: number | string;
|
accruedInt?: TbankMoney;
|
||||||
state?: string;
|
quantity?: string | number;
|
||||||
ticker?: string;
|
quantityDone?: string | number;
|
||||||
type?: string;
|
|
||||||
};
|
};
|
||||||
export type TbankOperationsByCursorRequest = {
|
export type TbankOperationsByCursorRequest = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
cursor?: string;
|
|
||||||
from?: TbankTimestamp;
|
|
||||||
instrumentId?: string;
|
instrumentId?: string;
|
||||||
|
from?: TbankTimestamp;
|
||||||
|
to?: TbankTimestamp;
|
||||||
|
cursor?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
state: 'OPERATION_STATE_EXECUTED';
|
state: 'OPERATION_STATE_EXECUTED';
|
||||||
to?: TbankTimestamp;
|
|
||||||
};
|
};
|
||||||
export type TbankOperationsByCursorResponse = {
|
export type TbankOperationsByCursorResponse = {
|
||||||
hasNext?: boolean;
|
hasNext?: boolean;
|
||||||
items?: TbankOperationItem[];
|
|
||||||
nextCursor?: string;
|
nextCursor?: string;
|
||||||
|
items?: TbankOperationItem[];
|
||||||
};
|
};
|
||||||
type TbankNamespace = {
|
type TbankNamespace = {
|
||||||
tinkoff: {
|
tinkoff: {
|
||||||
@@ -131,7 +123,7 @@ type TbankNamespace = {
|
|||||||
|
|
||||||
class RateLimiter {
|
class RateLimiter {
|
||||||
private readonly waiting: (() => void)[] = [];
|
private readonly waiting: (() => void)[] = [];
|
||||||
private starts = INITIAL_REQUEST_COUNT;
|
private starts = 0;
|
||||||
private windowStartedAt = Date.now();
|
private windowStartedAt = Date.now();
|
||||||
|
|
||||||
constructor(private readonly perSecond: number) {}
|
constructor(private readonly perSecond: number) {}
|
||||||
@@ -156,16 +148,16 @@ class RateLimiter {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
|
if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
|
||||||
this.windowStartedAt = now;
|
this.windowStartedAt = now;
|
||||||
this.starts = INITIAL_REQUEST_COUNT;
|
this.starts = 0;
|
||||||
}
|
}
|
||||||
while (this.starts < this.perSecond && this.waiting.length) {
|
while (this.starts < this.perSecond && this.waiting.length) {
|
||||||
this.starts++;
|
this.starts += 1;
|
||||||
this.waiting.shift()?.();
|
this.waiting.shift()?.();
|
||||||
}
|
}
|
||||||
if (this.waiting.length && this.starts === this.perSecond) {
|
if (this.waiting.length && this.starts === this.perSecond) {
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => this.drain(),
|
() => this.drain(),
|
||||||
Math.max(MIN_RATE_DELAY_MS, RATE_WINDOW_MS - (now - this.windowStartedAt)),
|
Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,14 +197,12 @@ export class TbankClient {
|
|||||||
loadPackageDefinition(definition) as unknown as TbankNamespace
|
loadPackageDefinition(definition) as unknown as TbankNamespace
|
||||||
).tinkoff.public.invest.api.contract.v1;
|
).tinkoff.public.invest.api.contract.v1;
|
||||||
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
|
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
|
||||||
const credentials = caPath ?
|
const credentials = caPath
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request.
|
? ChannelCredentials.createSsl(readFileSync(caPath))
|
||||||
ChannelCredentials.createSsl(readFileSync(caPath)) :
|
: ChannelCredentials.createSsl();
|
||||||
ChannelCredentials.createSsl();
|
|
||||||
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
|
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
|
||||||
const create = (name: string) =>
|
const create = (name: string) =>
|
||||||
new (
|
new (
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
|
|
||||||
services[name] as new (
|
services[name] as new (
|
||||||
address: string,
|
address: string,
|
||||||
credentials: ChannelCredentials,
|
credentials: ChannelCredentials,
|
||||||
@@ -278,14 +268,14 @@ export class TbankClient {
|
|||||||
getInstrumentByFigi(
|
getInstrumentByFigi(
|
||||||
token: string,
|
token: string,
|
||||||
figi: string,
|
figi: string,
|
||||||
): Promise<null | TbankInstrument> {
|
): Promise<TbankInstrument | null> {
|
||||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
|
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
|
||||||
}
|
}
|
||||||
|
|
||||||
getInstrumentByUid(
|
getInstrumentByUid(
|
||||||
token: string,
|
token: string,
|
||||||
instrumentUid: string,
|
instrumentUid: string,
|
||||||
): Promise<null | TbankInstrument> {
|
): Promise<TbankInstrument | null> {
|
||||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
|
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +292,7 @@ export class TbankClient {
|
|||||||
{
|
{
|
||||||
instrumentId,
|
instrumentId,
|
||||||
from: { seconds: String(utcSeconds(from)) },
|
from: { seconds: String(utcSeconds(from)) },
|
||||||
to: { seconds: String(utcSeconds(to, true)) },
|
to: { seconds: String(utcSeconds(to)) },
|
||||||
},
|
},
|
||||||
).then((response) => response.dividends ?? []);
|
).then((response) => response.dividends ?? []);
|
||||||
}
|
}
|
||||||
@@ -330,8 +320,7 @@ export class TbankClient {
|
|||||||
},
|
},
|
||||||
).then((response) => response.instruments ?? []);
|
).then((response) => response.instruments ?? []);
|
||||||
}
|
}
|
||||||
|
getBond(token: string, uid: string): Promise<TbankBond | null> {
|
||||||
getBond(token: string, uid: string): Promise<null | TbankBond> {
|
|
||||||
return this.call<{ instrument?: TbankBond }>(
|
return this.call<{ instrument?: TbankBond }>(
|
||||||
token,
|
token,
|
||||||
this.sparseInstruments,
|
this.sparseInstruments,
|
||||||
@@ -342,7 +331,6 @@ export class TbankClient {
|
|||||||
},
|
},
|
||||||
).then((response) => response.instrument ?? null);
|
).then((response) => response.instrument ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBondCoupons(
|
getBondCoupons(
|
||||||
token: string,
|
token: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
@@ -356,12 +344,12 @@ export class TbankClient {
|
|||||||
{
|
{
|
||||||
instrumentId: uid,
|
instrumentId: uid,
|
||||||
from: { seconds: String(utcSeconds(from)) },
|
from: { seconds: String(utcSeconds(from)) },
|
||||||
to: { seconds: String(utcSeconds(to, true)) },
|
to: { seconds: String(utcSeconds(to)) },
|
||||||
},
|
},
|
||||||
).then((response) => response.events ?? []);
|
).then((response) => response.events ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
getShare(token: string, uid: string): Promise<null | TbankShare> {
|
getShare(token: string, uid: string): Promise<TbankShare | null> {
|
||||||
return this.call<{ instrument?: TbankShare }>(
|
return this.call<{ instrument?: TbankShare }>(
|
||||||
token,
|
token,
|
||||||
this.instruments,
|
this.instruments,
|
||||||
@@ -373,7 +361,7 @@ export class TbankClient {
|
|||||||
).then((response) => response.instrument ?? null);
|
).then((response) => response.instrument ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAssetDescription(token: string, assetUid: string): Promise<null | string> {
|
getAssetDescription(token: string, assetUid: string): Promise<string | null> {
|
||||||
return this.call<{ asset?: { description?: string } }>(
|
return this.call<{ asset?: { description?: string } }>(
|
||||||
token,
|
token,
|
||||||
this.instruments,
|
this.instruments,
|
||||||
@@ -387,7 +375,7 @@ export class TbankClient {
|
|||||||
getFundamentals(
|
getFundamentals(
|
||||||
token: string,
|
token: string,
|
||||||
assetUid: string,
|
assetUid: string,
|
||||||
): Promise<null | TbankFundamentals> {
|
): Promise<TbankFundamentals | null> {
|
||||||
return this.call<{ fundamentals?: TbankFundamentals[] }>(
|
return this.call<{ fundamentals?: TbankFundamentals[] }>(
|
||||||
token,
|
token,
|
||||||
this.sparseInstruments,
|
this.sparseInstruments,
|
||||||
@@ -411,13 +399,13 @@ export class TbankClient {
|
|||||||
).then((response) => response.lastPrices ?? []);
|
).then((response) => response.lastPrices ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
getLastPrice(token: string, uid: string): Promise<null | TbankLastPrice> {
|
getLastPrice(token: string, uid: string): Promise<TbankLastPrice | null> {
|
||||||
return this.getLastPrices(token, [uid]).then(
|
return this.getLastPrices(token, [uid]).then(
|
||||||
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
|
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getTradingStatus(token: string, uid: string): Promise<null | string> {
|
getTradingStatus(token: string, uid: string): Promise<string | null> {
|
||||||
return this.call<{ tradingStatus?: string }>(
|
return this.call<{ tradingStatus?: string }>(
|
||||||
token,
|
token,
|
||||||
this.marketData,
|
this.marketData,
|
||||||
@@ -454,9 +442,9 @@ export class TbankClient {
|
|||||||
|
|
||||||
private async getInstrument(
|
private async getInstrument(
|
||||||
token: string,
|
token: string,
|
||||||
idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID',
|
idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI',
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<null | TbankInstrument> {
|
): Promise<TbankInstrument | null> {
|
||||||
try {
|
try {
|
||||||
const response = await this.call<{ instrument?: TbankInstrument }>(
|
const response = await this.call<{ instrument?: TbankInstrument }>(
|
||||||
token,
|
token,
|
||||||
@@ -479,13 +467,12 @@ export class TbankClient {
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const metadataCall = method === 'getInstrumentBy';
|
const metadataCall = method === 'getInstrumentBy';
|
||||||
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
|
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
|
||||||
for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) {
|
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||||
try {
|
try {
|
||||||
if (queueFull) {
|
if (queueFull)
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||||
}
|
|
||||||
const response = await this.queue.run(() =>
|
const response = await this.queue.run(() =>
|
||||||
this.request<Response>(token, client, method, request),
|
this.request<Response>(token, client, method, request),
|
||||||
);
|
);
|
||||||
@@ -497,15 +484,15 @@ export class TbankClient {
|
|||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const grpcCode = (error as ServiceError).code;
|
const grpcCode = (error as ServiceError).code;
|
||||||
const failureContext = queueFull ?
|
const failureContext = queueFull
|
||||||
{ code: 'TBANK_UNAVAILABLE' } :
|
? { code: 'TBANK_UNAVAILABLE' }
|
||||||
{ grpcCode };
|
: { grpcCode };
|
||||||
const retryable = queueFull ?
|
const retryable = queueFull
|
||||||
metadataCall :
|
? metadataCall
|
||||||
[
|
: [
|
||||||
|
status.UNAVAILABLE,
|
||||||
status.DEADLINE_EXCEEDED,
|
status.DEADLINE_EXCEEDED,
|
||||||
status.RESOURCE_EXHAUSTED,
|
status.RESOURCE_EXHAUSTED,
|
||||||
status.UNAVAILABLE,
|
|
||||||
].includes(grpcCode);
|
].includes(grpcCode);
|
||||||
if (retryable && attempts < maxAttempts) {
|
if (retryable && attempts < maxAttempts) {
|
||||||
this.logger.event('warn', 'tbank.rpc.retry', {
|
this.logger.event('warn', 'tbank.rpc.retry', {
|
||||||
@@ -517,9 +504,9 @@ export class TbankClient {
|
|||||||
await new Promise((resolve) =>
|
await new Promise((resolve) =>
|
||||||
setTimeout(
|
setTimeout(
|
||||||
resolve,
|
resolve,
|
||||||
metadataCall ?
|
metadataCall
|
||||||
attempts * METADATA_RETRY_DELAY_MS :
|
? attempts * METADATA_RETRY_DELAY_MS
|
||||||
RPC_RETRY_DELAY_MS,
|
: RPC_RETRY_DELAY_MS,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -527,21 +514,19 @@ export class TbankClient {
|
|||||||
this.logger.event('error', 'tbank.rpc.failed', {
|
this.logger.event('error', 'tbank.rpc.failed', {
|
||||||
operation: method,
|
operation: method,
|
||||||
...failureContext,
|
...failureContext,
|
||||||
attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts,
|
attempts: queueFull && !metadataCall ? 0 : attempts,
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
[status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
|
[status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode)
|
||||||
) {
|
)
|
||||||
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
|
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
|
||||||
throw new Error('INVALID_TBANK_TOKEN');
|
throw new Error('INVALID_TBANK_TOKEN');
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
grpcCode === status.NOT_FOUND &&
|
grpcCode === status.NOT_FOUND &&
|
||||||
(method === 'shareBy' || metadataCall)
|
(method === 'shareBy' || metadataCall)
|
||||||
) {
|
)
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -557,7 +542,6 @@ export class TbankClient {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const metadata = new Metadata();
|
const metadata = new Metadata();
|
||||||
metadata.set('Authorization', `Bearer ${token}`);
|
metadata.set('Authorization', `Bearer ${token}`);
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
|
|
||||||
client[method](
|
client[method](
|
||||||
request as never,
|
request as never,
|
||||||
metadata,
|
metadata,
|
||||||
@@ -568,9 +552,6 @@ export class TbankClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)).
|
function utcSeconds(date: string): number {
|
||||||
function utcSeconds(date: string, nextDay = false): number {
|
return Math.floor(Date.parse(`${date}T00:00:00Z`) / MILLISECONDS_PER_SECOND);
|
||||||
const instant = new Date(`${date}T00:00:00Z`);
|
|
||||||
if (nextDay) instant.setUTCDate(instant.getUTCDate() + NEXT_DAY_OFFSET);
|
|
||||||
return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
const VERSION_SIZE_BYTES = 1;
|
|
||||||
|
|
||||||
const KEY_SIZE_BYTES = 32;
|
const KEY_SIZE_BYTES = 32;
|
||||||
|
|
||||||
const VERSION = 1;
|
const VERSION = 1;
|
||||||
@@ -13,9 +11,8 @@ export class TbankCrypto {
|
|||||||
|
|
||||||
static fromBase64(key: string): TbankCrypto {
|
static fromBase64(key: string): TbankCrypto {
|
||||||
const decoded = Buffer.from(key, 'base64');
|
const decoded = Buffer.from(key, 'base64');
|
||||||
if (decoded.length !== KEY_SIZE_BYTES) {
|
if (decoded.length !== KEY_SIZE_BYTES)
|
||||||
throw new Error('Invalid token encryption key');
|
throw new Error('Invalid token encryption key');
|
||||||
}
|
|
||||||
return new TbankCrypto(decoded);
|
return new TbankCrypto(decoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,15 +33,15 @@ export class TbankCrypto {
|
|||||||
|
|
||||||
decrypt(payload: string): string {
|
decrypt(payload: string): string {
|
||||||
const bytes = Buffer.from(payload, 'base64');
|
const bytes = Buffer.from(payload, 'base64');
|
||||||
if (bytes[0] !== VERSION || bytes.length < VERSION_SIZE_BYTES + NONCE_SIZE + TAG_SIZE) {
|
if (bytes[0] !== VERSION || bytes.length < 1 + NONCE_SIZE + TAG_SIZE) {
|
||||||
throw new Error('Invalid encrypted token');
|
throw new Error('Invalid encrypted token');
|
||||||
}
|
}
|
||||||
const nonce = bytes.subarray(VERSION_SIZE_BYTES, VERSION_SIZE_BYTES + NONCE_SIZE);
|
const nonce = bytes.subarray(1, 1 + NONCE_SIZE);
|
||||||
const tag = bytes.subarray(-TAG_SIZE);
|
const tag = bytes.subarray(-TAG_SIZE);
|
||||||
const decipher = createDecipheriv('aes-256-gcm', this.key, nonce);
|
const decipher = createDecipheriv('aes-256-gcm', this.key, nonce);
|
||||||
decipher.setAuthTag(tag);
|
decipher.setAuthTag(tag);
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
decipher.update(bytes.subarray(VERSION_SIZE_BYTES + NONCE_SIZE, -TAG_SIZE)),
|
decipher.update(bytes.subarray(1 + NONCE_SIZE, -TAG_SIZE)),
|
||||||
decipher.final(),
|
decipher.final(),
|
||||||
]).toString('utf8');
|
]).toString('utf8');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TbankMembershipService } from './tbank-membership.service';
|
||||||
import { InstrumentMetadataRepository } from './instrument-metadata.repository';
|
import { InstrumentMetadataRepository } from './instrument-metadata.repository';
|
||||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
import { TbankMembershipService } from './tbank-membership.service';
|
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
|
|||||||
+5
-18
@@ -1,21 +1,16 @@
|
|||||||
import {
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
INestApplication,
|
|
||||||
ValidationPipe,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { SwaggerModule } from '@nestjs/swagger';
|
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { API_PREFIX, createOpenApiDocument } from './openapi';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
|
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
|
||||||
import { StructuredLogger } from './common/logging/structured-logger.service';
|
import { StructuredLogger } from './common/logging/structured-logger.service';
|
||||||
import { Environment } from './config/env.validation';
|
import { Environment } from './config/env.validation';
|
||||||
import { shouldExposeSwagger } from './config/swagger.config';
|
import { shouldExposeSwagger } from './config/swagger.config';
|
||||||
import { API_PREFIX, createOpenApiDocument } from './openapi';
|
|
||||||
|
|
||||||
export function configureApp(app: INestApplication): void {
|
export function configureApp(app: INestApplication): void {
|
||||||
const config = app.get<ConfigService<Environment, true>>(ConfigService);
|
const config = app.get<ConfigService<Environment, true>>(ConfigService);
|
||||||
@@ -31,15 +26,7 @@ export function configureApp(app: INestApplication): void {
|
|||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
const requestLogging = app.get(RequestLoggingMiddleware);
|
const requestLogging = app.get(RequestLoggingMiddleware);
|
||||||
app.use(requestLogging.use.bind(requestLogging));
|
app.use(requestLogging.use.bind(requestLogging));
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||||
new ValidationPipe({
|
|
||||||
forbidUnknownValues: true,
|
|
||||||
transform: true,
|
|
||||||
whitelist: true,
|
|
||||||
validationError: { target: false, value: false },
|
|
||||||
exceptionFactory: (message) => new BadRequestException({ message }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ describe('AuthService', () => {
|
|||||||
where,
|
where,
|
||||||
data,
|
data,
|
||||||
}: {
|
}: {
|
||||||
data: { refreshTokenHash: string };
|
|
||||||
where: { id?: string; refreshTokenHash?: string };
|
where: { id?: string; refreshTokenHash?: string };
|
||||||
|
data: { refreshTokenHash: string };
|
||||||
}) => {
|
}) => {
|
||||||
if (where.id === session.id && where.refreshTokenHash === currentHash) {
|
if (where.id === session.id && where.refreshTokenHash === currentHash) {
|
||||||
currentHash = data.refreshTokenHash;
|
currentHash = data.refreshTokenHash;
|
||||||
|
|||||||
@@ -6,20 +6,16 @@ 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 EXPECTED_ROTATED_SESSIONS = 1;
|
|
||||||
|
|
||||||
const PASSWORD_HASH_ROUNDS = 12;
|
const PASSWORD_HASH_ROUNDS = 12;
|
||||||
const REFRESH_TTL_MS = 604_800_000;
|
const REFRESH_TTL_MS = 604_800_000;
|
||||||
|
|
||||||
const ACCESS_TTL = '15m';
|
const ACCESS_TTL = '15m';
|
||||||
const REFRESH_TTL = '7d';
|
const REFRESH_TTL = '7d';
|
||||||
const DUMMY_PASSWORD_HASH =
|
const DUMMY_PASSWORD_HASH =
|
||||||
// eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Public dummy bcrypt hash preserves login timing for nonexistent users; it is not a credential.
|
|
||||||
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
|
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -32,27 +28,26 @@ export class AuthService {
|
|||||||
async register(
|
async register(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||||
|
const normalizedEmail = this.normalizeEmail(email);
|
||||||
const existing = await this.prisma.user.findUnique({
|
const existing = await this.prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email: normalizedEmail },
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing)
|
||||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||||
}
|
|
||||||
|
|
||||||
let user: User;
|
let user: User;
|
||||||
try {
|
try {
|
||||||
user = await this.prisma.user.create({
|
user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
email,
|
email: normalizedEmail,
|
||||||
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUniqueConstraintError(error)) {
|
if (isUniqueConstraintError(error))
|
||||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return this.createSession(user);
|
return this.createSession(user);
|
||||||
@@ -61,23 +56,22 @@ export class AuthService {
|
|||||||
async login(
|
async login(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email: this.normalizeEmail(email) },
|
||||||
});
|
});
|
||||||
const valid = await bcrypt.compare(
|
const valid = await bcrypt.compare(
|
||||||
password,
|
password,
|
||||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||||
);
|
);
|
||||||
if (!user || !valid) {
|
if (!user || !valid)
|
||||||
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
|
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
|
||||||
}
|
|
||||||
return this.createSession(user);
|
return this.createSession(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh(
|
async refresh(
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||||
let payload: RefreshPayload;
|
let payload: RefreshPayload;
|
||||||
try {
|
try {
|
||||||
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||||
@@ -86,9 +80,8 @@ export class AuthService {
|
|||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
if (payload.type !== 'refresh') {
|
if (payload.type !== 'refresh')
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
}
|
|
||||||
const session = await this.prisma.session.findUnique({
|
const session = await this.prisma.session.findUnique({
|
||||||
where: { id: payload.sid },
|
where: { id: payload.sid },
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
@@ -99,27 +92,21 @@ export class AuthService {
|
|||||||
session.revokedAt ||
|
session.revokedAt ||
|
||||||
session.expiresAt <= new Date()
|
session.expiresAt <= new Date()
|
||||||
) {
|
) {
|
||||||
if (session && !session.revokedAt) {
|
if (session && !session.revokedAt)
|
||||||
await this.prisma.session.delete({ where: { id: session.id } });
|
await this.prisma.session.delete({ where: { id: session.id } });
|
||||||
}
|
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
if (session.refreshTokenHash !== this.hash(token)) {
|
if (session.refreshTokenHash !== this.hash(token))
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
}
|
|
||||||
return this.rotateSession(session, session.refreshTokenHash);
|
return this.rotateSession(session, session.refreshTokenHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(token?: string): Promise<void> {
|
async logout(token?: string): Promise<void> {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
let payload: RefreshPayload;
|
|
||||||
try {
|
try {
|
||||||
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
const payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||||
algorithms: ['HS256'],
|
algorithms: ['HS256'],
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (payload.type !== 'refresh') return;
|
if (payload.type !== 'refresh') return;
|
||||||
const session = await this.prisma.session.findUnique({
|
const session = await this.prisma.session.findUnique({
|
||||||
where: { id: payload.sid },
|
where: { id: payload.sid },
|
||||||
@@ -134,6 +121,9 @@ export class AuthService {
|
|||||||
data: { revokedAt: new Date() },
|
data: { revokedAt: new Date() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Logout is intentionally idempotent.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toUser(user: User): AuthUser {
|
toUser(user: User): AuthUser {
|
||||||
@@ -148,7 +138,7 @@ export class AuthService {
|
|||||||
|
|
||||||
private async createSession(
|
private async createSession(
|
||||||
user: User,
|
user: User,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||||
const session = await this.prisma.session.create({
|
const session = await this.prisma.session.create({
|
||||||
data: {
|
data: {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -163,7 +153,7 @@ export class AuthService {
|
|||||||
private async rotateSession(
|
private async rotateSession(
|
||||||
session: Session & { user: User },
|
session: Session & { user: User },
|
||||||
expectedRefreshTokenHash?: string,
|
expectedRefreshTokenHash?: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||||
const refreshToken = await this.jwt.signAsync(
|
const refreshToken = await this.jwt.signAsync(
|
||||||
{
|
{
|
||||||
sub: session.userId,
|
sub: session.userId,
|
||||||
@@ -182,9 +172,8 @@ export class AuthService {
|
|||||||
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
|
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
|
if (updated.count !== 1)
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
await this.prisma.session.update({ where: { id: session.id }, data });
|
await this.prisma.session.update({ where: { id: session.id }, data });
|
||||||
}
|
}
|
||||||
@@ -198,6 +187,10 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeEmail(email: string): string {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
private hash(token: string): string {
|
private hash(token: string): string {
|
||||||
return createHash('sha256').update(token).digest('hex');
|
return createHash('sha256').update(token).digest('hex');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
email: string;
|
|
||||||
id: string;
|
id: string;
|
||||||
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccessPayload {
|
export interface AccessPayload {
|
||||||
sid: string;
|
|
||||||
sub: string;
|
sub: string;
|
||||||
|
sid: string;
|
||||||
type: 'access';
|
type: 'access';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RefreshPayload {
|
export interface RefreshPayload {
|
||||||
jti: string;
|
|
||||||
sid: string;
|
|
||||||
sub: string;
|
sub: string;
|
||||||
|
sid: string;
|
||||||
type: 'refresh';
|
type: 'refresh';
|
||||||
|
jti: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +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 { AuthService } from './application/auth.service';
|
|
||||||
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
|
||||||
import { AuthController } from './presentation/auth.controller';
|
import { AuthController } from './presentation/auth.controller';
|
||||||
|
import { AuthService } from './application/auth.service';
|
||||||
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||||
|
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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 '../application/auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
|
|
||||||
@@ -18,9 +17,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validate(payload: AccessPayload): AccessPayload {
|
validate(payload: AccessPayload): AccessPayload {
|
||||||
if (payload.type !== 'access') {
|
if (payload.type !== 'access')
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
}
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
|
||||||
|
|
||||||
|
jest.mock('../application/auth.service', () => ({
|
||||||
|
AuthService: class AuthService {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
|
||||||
|
describe('AuthController response statuses', () => {
|
||||||
|
it.each(['register', 'login', 'refresh'] as const)(
|
||||||
|
'%s explicitly returns HTTP 200',
|
||||||
|
(handler) => {
|
||||||
|
// Nest stores @HttpCode metadata on the unbound handler function.
|
||||||
|
const handlerTarget = Object.getOwnPropertyDescriptor(
|
||||||
|
AuthController.prototype,
|
||||||
|
handler,
|
||||||
|
)?.value as object;
|
||||||
|
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handlerTarget)).toBe(200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { Request, Response } from 'express';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -19,11 +17,11 @@ import {
|
|||||||
ApiProperty,
|
ApiProperty,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
|
import { CredentialsDto } from './auth.dto';
|
||||||
import { AuthService } from '../application/auth.service';
|
import { AuthService } from '../application/auth.service';
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
import { CredentialsDto } from './auth.dto';
|
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
const REFRESH_COOKIE = 'refresh_token';
|
const REFRESH_COOKIE = 'refresh_token';
|
||||||
@@ -89,7 +87,7 @@ export class AuthController {
|
|||||||
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
return this.respond(response, await this.auth.refresh(token));
|
return this.respond(response, await this.auth.refresh(token));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedException) this.clearCookie(response);
|
this.clearCookie(response);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,9 +115,9 @@ export class AuthController {
|
|||||||
private respond(
|
private respond(
|
||||||
response: Response,
|
response: Response,
|
||||||
result: {
|
result: {
|
||||||
|
user: { id: string; email: string };
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
user: { email: string; id: string };
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
||||||
@@ -131,7 +129,6 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private refreshToken(request: Request): string | undefined {
|
private refreshToken(request: Request): string | undefined {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The cookie name is a fixed application constant; its value is narrowed below.
|
|
||||||
const token: unknown = request.cookies?.[REFRESH_COOKIE];
|
const token: unknown = request.cookies?.[REFRESH_COOKIE];
|
||||||
return typeof token === 'string' ? token : undefined;
|
return typeof token === 'string' ? token : undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import {
|
|||||||
ValidatorConstraint,
|
ValidatorConstraint,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
const EMAIL_PART_COUNT = 2;
|
|
||||||
const FIRST_CHARACTER_INDEX = 0;
|
|
||||||
|
|
||||||
const MAX_EMAIL_LENGTH = 254;
|
const MAX_EMAIL_LENGTH = 254;
|
||||||
const ASCII_SPACE = 32;
|
const ASCII_SPACE = 32;
|
||||||
const ASCII_DELETE = 127;
|
const ASCII_DELETE = 127;
|
||||||
@@ -22,19 +19,18 @@ class EmailConstraint {
|
|||||||
return (
|
return (
|
||||||
typeof value === 'string' &&
|
typeof value === 'string' &&
|
||||||
value.length <= MAX_EMAIL_LENGTH &&
|
value.length <= MAX_EMAIL_LENGTH &&
|
||||||
value.split('@').length === EMAIL_PART_COUNT &&
|
value.split('@').length === 2 &&
|
||||||
!value
|
!value
|
||||||
.split('')
|
.split('')
|
||||||
.some(
|
.some(
|
||||||
(character) =>
|
(character) =>
|
||||||
/\s/.test(character) ||
|
/\s/.test(character) ||
|
||||||
character.charCodeAt(FIRST_CHARACTER_INDEX) < ASCII_SPACE ||
|
character.charCodeAt(0) < ASCII_SPACE ||
|
||||||
character.charCodeAt(FIRST_CHARACTER_INDEX) === ASCII_DELETE,
|
character.charCodeAt(0) === ASCII_DELETE,
|
||||||
) &&
|
) &&
|
||||||
value.split('@').every(Boolean)
|
value.split('@').every(Boolean)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultMessage(): string {
|
defaultMessage(): string {
|
||||||
return 'must be a valid email address';
|
return 'must be a valid email address';
|
||||||
}
|
}
|
||||||
@@ -48,7 +44,6 @@ class PasswordBytesConstraint {
|
|||||||
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
|
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultMessage(): string {
|
defaultMessage(): string {
|
||||||
return 'must not exceed 72 UTF-8 bytes';
|
return 'must not exceed 72 UTF-8 bytes';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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 '../application/auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
|
|
||||||
@@ -15,9 +14,8 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
|||||||
const request = context
|
const request = context
|
||||||
.switchToHttp()
|
.switchToHttp()
|
||||||
.getRequest<{ user?: AccessPayload }>();
|
.getRequest<{ user?: AccessPayload }>();
|
||||||
if (request.user?.sub) {
|
if (request.user?.sub)
|
||||||
this.requestContext.set({ userId: request.user.sub });
|
this.requestContext.set({ userId: request.user.sub });
|
||||||
}
|
|
||||||
return Boolean(allowed);
|
return Boolean(allowed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
|
import {
|
||||||
|
MAX_CARD_NAME_LENGTH,
|
||||||
|
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||||
|
} from '../domain/bank-card-limits';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
|
||||||
import {
|
|
||||||
MAX_CARD_NAME_LENGTH,
|
|
||||||
MAX_OPERATION_DESCRIPTION_LENGTH,
|
|
||||||
} from '../domain/bank-card-limits';
|
|
||||||
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
|
|
||||||
import { parseRubMoney } from '../domain/rub-money';
|
|
||||||
import {
|
import {
|
||||||
bankPaymentSystems,
|
bankPaymentSystems,
|
||||||
BankPeriodQueryDto,
|
BankPeriodQueryDto,
|
||||||
CreateBankCardDto,
|
CreateBankCardDto,
|
||||||
CreateBankOperationDto,
|
CreateBankOperationDto,
|
||||||
} from '../presentation/bank-card.dto';
|
} from '../presentation/bank-card.dto';
|
||||||
|
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
|
||||||
const NEXT_MONTH_OFFSET = 1;
|
import { parseRubMoney } from '../domain/rub-money';
|
||||||
const PREVIOUS_MONTH_LAST_DAY = 0;
|
|
||||||
|
|
||||||
export const BankErrorCode = {
|
export const BankErrorCode = {
|
||||||
InvalidInput: 'BANK_INVALID_INPUT',
|
InvalidInput: 'BANK_INVALID_INPUT',
|
||||||
@@ -49,8 +44,8 @@ export function periodBounds(query: BankPeriodQueryDto) {
|
|||||||
const from = `${query.month}-01`;
|
const from = `${query.month}-01`;
|
||||||
if (!isCalendarDate(from)) return fail();
|
if (!isCalendarDate(from)) return fail();
|
||||||
const end = new Date(`${from}T00:00:00Z`);
|
const end = new Date(`${from}T00:00:00Z`);
|
||||||
end.setUTCMonth(end.getUTCMonth() + NEXT_MONTH_OFFSET, PREVIOUS_MONTH_LAST_DAY);
|
end.setUTCMonth(end.getUTCMonth() + 1, 0);
|
||||||
return { from, to: end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) };
|
return { from, to: end.toISOString().slice(0, 10) };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
query.month ||
|
query.month ||
|
||||||
@@ -59,9 +54,8 @@ export function periodBounds(query: BankPeriodQueryDto) {
|
|||||||
!isCalendarDate(query.from) ||
|
!isCalendarDate(query.from) ||
|
||||||
!isCalendarDate(query.to) ||
|
!isCalendarDate(query.to) ||
|
||||||
query.from > query.to
|
query.from > query.to
|
||||||
) {
|
)
|
||||||
return fail();
|
return fail();
|
||||||
}
|
|
||||||
return { from: query.from, to: query.to };
|
return { from: query.from, to: query.to };
|
||||||
}
|
}
|
||||||
export function conflict(
|
export function conflict(
|
||||||
@@ -99,9 +93,8 @@ export function currentDate(value: string) {
|
|||||||
if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
|
if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
|
||||||
}
|
}
|
||||||
export function name(value: string, max: number) {
|
export function name(value: string, max: number) {
|
||||||
if (typeof value !== 'string' || !value.trim() || value.trim().length > max) {
|
if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
|
||||||
fail();
|
fail();
|
||||||
}
|
|
||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
export function cardInput(dto: CreateBankCardDto) {
|
export function cardInput(dto: CreateBankCardDto) {
|
||||||
@@ -111,9 +104,8 @@ export function cardInput(dto: CreateBankCardDto) {
|
|||||||
if (
|
if (
|
||||||
dto.paymentSystem != null &&
|
dto.paymentSystem != null &&
|
||||||
!bankPaymentSystems.some((system) => system === dto.paymentSystem)
|
!bankPaymentSystems.some((system) => system === dto.paymentSystem)
|
||||||
) {
|
)
|
||||||
fail();
|
fail();
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
|
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
|
||||||
name: name(dto.name, MAX_CARD_NAME_LENGTH),
|
name: name(dto.name, MAX_CARD_NAME_LENGTH),
|
||||||
@@ -128,9 +120,8 @@ export function operationInput(dto: CreateBankOperationDto) {
|
|||||||
dto.description != null &&
|
dto.description != null &&
|
||||||
(typeof dto.description !== 'string' ||
|
(typeof dto.description !== 'string' ||
|
||||||
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
|
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
|
||||||
) {
|
)
|
||||||
fail();
|
fail();
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
kind: dto.kind,
|
kind: dto.kind,
|
||||||
cardId: dto.cardId,
|
cardId: dto.cardId,
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
|
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import { bankBalance } from '../domain/bank-card.calculations';
|
|
||||||
import { rubMoney } from '../domain/rub-money';
|
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
|
||||||
import {
|
import {
|
||||||
BankCardDto,
|
BankCardDto,
|
||||||
CreateBankCardDto,
|
CreateBankCardDto,
|
||||||
UpdateBankCardDto,
|
UpdateBankCardDto,
|
||||||
} from '../presentation/bank-card.dto';
|
} from '../presentation/bank-card.dto';
|
||||||
|
import { bankBalance } from '../domain/bank-card.calculations';
|
||||||
|
import { rubMoney } from '../domain/rub-money';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BankErrorCode,
|
|
||||||
cardInput,
|
cardInput,
|
||||||
|
BankErrorCode,
|
||||||
conflict,
|
conflict,
|
||||||
currentDate,
|
currentDate,
|
||||||
fail,
|
fail,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
missing,
|
missing,
|
||||||
} from './bank-card-input';
|
} from './bank-card-input';
|
||||||
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
const EXPECTED_UPDATED_CARDS = 1;
|
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BankCardsService {
|
export class BankCardsService {
|
||||||
@@ -29,7 +26,6 @@ export class BankCardsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly repository: BankCardsRepository,
|
private readonly repository: BankCardsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
|
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
|
||||||
const rows = await tx.bankCardOperation.findMany({
|
const rows = await tx.bankCardOperation.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -51,7 +47,6 @@ export class BankCardsService {
|
|||||||
archivedAt: card.archivedAt?.toISOString() ?? null,
|
archivedAt: card.archivedAt?.toISOString() ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
list(userId: string, includeArchived = false) {
|
list(userId: string, includeArchived = false) {
|
||||||
return this.prisma.$transaction(async (tx) =>
|
return this.prisma.$transaction(async (tx) =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
@@ -64,13 +59,11 @@ export class BankCardsService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(userId: string, id: string) {
|
get(userId: string, id: string) {
|
||||||
return this.prisma.$transaction(async (tx) =>
|
return this.prisma.$transaction(async (tx) =>
|
||||||
this.cardView(tx, await this.repository.card(tx, userId, id)),
|
this.cardView(tx, await this.repository.card(tx, userId, id)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
createCard(userId: string, dto: CreateBankCardDto) {
|
createCard(userId: string, dto: CreateBankCardDto) {
|
||||||
const input = cardInput(dto),
|
const input = cardInput(dto),
|
||||||
hash = fingerprint(input);
|
hash = fingerprint(input);
|
||||||
@@ -78,10 +71,8 @@ export class BankCardsService {
|
|||||||
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
|
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.userId !== userId) missing();
|
if (existing.userId !== userId) missing();
|
||||||
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
if (existing.createFingerprint !== hash)
|
||||||
if (existing.createFingerprint !== hash) {
|
|
||||||
conflict(BankErrorCode.IntentConflict);
|
conflict(BankErrorCode.IntentConflict);
|
||||||
}
|
|
||||||
return this.cardView(tx, existing);
|
return this.cardView(tx, existing);
|
||||||
}
|
}
|
||||||
currentDate(input.openingDate);
|
currentDate(input.openingDate);
|
||||||
@@ -94,7 +85,6 @@ export class BankCardsService {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
|
updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
const previous = await this.repository.card(tx, userId, id);
|
const previous = await this.repository.card(tx, userId, id);
|
||||||
@@ -103,17 +93,17 @@ export class BankCardsService {
|
|||||||
id,
|
id,
|
||||||
name: dto.name ?? previous.name,
|
name: dto.name ?? previous.name,
|
||||||
bankName:
|
bankName:
|
||||||
dto.bankName === undefined ?
|
dto.bankName === undefined
|
||||||
(previous.bankName ?? undefined) :
|
? (previous.bankName ?? undefined)
|
||||||
(dto.bankName ?? undefined),
|
: (dto.bankName ?? undefined),
|
||||||
lastFour:
|
lastFour:
|
||||||
dto.lastFour === undefined ?
|
dto.lastFour === undefined
|
||||||
(previous.lastFour ?? undefined) :
|
? (previous.lastFour ?? undefined)
|
||||||
(dto.lastFour ?? undefined),
|
: (dto.lastFour ?? undefined),
|
||||||
paymentSystem:
|
paymentSystem:
|
||||||
dto.paymentSystem === undefined ?
|
dto.paymentSystem === undefined
|
||||||
previous.paymentSystem :
|
? previous.paymentSystem
|
||||||
dto.paymentSystem,
|
: dto.paymentSystem,
|
||||||
openingBalance:
|
openingBalance:
|
||||||
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
|
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
|
||||||
openingDate: dto.openingDate ?? previous.openingDate,
|
openingDate: dto.openingDate ?? previous.openingDate,
|
||||||
@@ -121,9 +111,8 @@ export class BankCardsService {
|
|||||||
const financial =
|
const financial =
|
||||||
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
|
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
|
||||||
input.openingDate !== previous.openingDate;
|
input.openingDate !== previous.openingDate;
|
||||||
if (financial && previous.archivedAt) {
|
if (financial && previous.archivedAt)
|
||||||
conflict(BankErrorCode.CardArchived);
|
conflict(BankErrorCode.CardArchived);
|
||||||
}
|
|
||||||
currentDate(input.openingDate);
|
currentDate(input.openingDate);
|
||||||
if (
|
if (
|
||||||
await tx.bankCardOperation.findFirst({
|
await tx.bankCardOperation.findFirst({
|
||||||
@@ -134,25 +123,24 @@ export class BankCardsService {
|
|||||||
OR: [{ cardId: id }, { targetCardId: id }],
|
OR: [{ cardId: id }, { targetCardId: id }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
) {
|
)
|
||||||
fail(BankErrorCode.OpeningDateConflict);
|
fail(BankErrorCode.OpeningDateConflict);
|
||||||
}
|
|
||||||
const changed = await tx.bankCard.updateMany({
|
const changed = await tx.bankCard.updateMany({
|
||||||
where: { id, userId, version: dto.version },
|
where: { id, userId, version: dto.version },
|
||||||
data: {
|
data: {
|
||||||
...input,
|
...input,
|
||||||
paymentSystem: input.paymentSystem ?? null,
|
paymentSystem: input.paymentSystem ?? null,
|
||||||
version: { increment: 1 },
|
version: { increment: 1 },
|
||||||
...(dto.archived === undefined ?
|
...(dto.archived === undefined
|
||||||
{} :
|
? {}
|
||||||
{
|
: {
|
||||||
archivedAt: dto.archived ?
|
archivedAt: dto.archived
|
||||||
(previous.archivedAt ?? new Date()) :
|
? (previous.archivedAt ?? new Date())
|
||||||
null,
|
: null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
|
if (changed.count !== 1) conflict();
|
||||||
return this.cardView(tx, await this.repository.card(tx, userId, id));
|
return this.cardView(tx, await this.repository.card(tx, userId, id));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
|
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||||
import { GoneException, Injectable } from '@nestjs/common';
|
import { GoneException, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BankCardOperation,
|
BankCardOperation,
|
||||||
Prisma,
|
Prisma,
|
||||||
} from '../../../../../generated/prisma/client';
|
} from '../../../../../generated/prisma/client';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
|
||||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
|
||||||
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
|
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
|
||||||
import {
|
import {
|
||||||
BankOperationDto,
|
BankOperationDto,
|
||||||
|
DEFAULT_BANK_PAGE_SIZE,
|
||||||
BankOperationsQueryDto,
|
BankOperationsQueryDto,
|
||||||
BankPageQueryDto,
|
BankPageQueryDto,
|
||||||
CreateBankOperationDto,
|
CreateBankOperationDto,
|
||||||
DEFAULT_BANK_PAGE_SIZE,
|
|
||||||
UpdateBankOperationDto,
|
UpdateBankOperationDto,
|
||||||
} from '../presentation/bank-card.dto';
|
} from '../presentation/bank-card.dto';
|
||||||
|
import { rubMoney } from '../domain/rub-money';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BankErrorCode,
|
BankErrorCode,
|
||||||
conflict,
|
conflict,
|
||||||
@@ -27,9 +25,7 @@ import {
|
|||||||
operationInput,
|
operationInput,
|
||||||
periodBounds,
|
periodBounds,
|
||||||
} from './bank-card-input';
|
} from './bank-card-input';
|
||||||
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
const EXPECTED_UPDATED_ROWS = 1;
|
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BankOperationsService {
|
export class BankOperationsService {
|
||||||
@@ -37,35 +33,34 @@ export class BankOperationsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly repository: BankCardsRepository,
|
private readonly repository: BankCardsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async operationView(
|
private async operationView(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
row: BankCardOperation,
|
row: BankCardOperation,
|
||||||
): Promise<BankOperationDto> {
|
): Promise<BankOperationDto> {
|
||||||
const card = await this.repository.card(tx, row.userId, row.cardId);
|
const card = await this.repository.card(tx, row.userId, row.cardId);
|
||||||
const target = row.targetCardId ?
|
const target = row.targetCardId
|
||||||
await this.repository.card(tx, row.userId, row.targetCardId) :
|
? await this.repository.card(tx, row.userId, row.targetCardId)
|
||||||
null;
|
: null;
|
||||||
const source = row.originalExpenseId ?
|
const source = row.originalExpenseId
|
||||||
await this.repository.operation(tx, row.userId, row.originalExpenseId) :
|
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
|
||||||
null;
|
: null;
|
||||||
const categoryId = source?.categoryId ?? row.categoryId;
|
const categoryId = source?.categoryId ?? row.categoryId;
|
||||||
const category = categoryId ?
|
const category = categoryId
|
||||||
await tx.expenseCategory.findFirst({
|
? await tx.expenseCategory.findFirst({
|
||||||
where: { id: categoryId, userId: row.userId },
|
where: { id: categoryId, userId: row.userId },
|
||||||
}) :
|
})
|
||||||
null;
|
: null;
|
||||||
const refunds =
|
const refunds =
|
||||||
row.kind === BankOperationKind.Expense || source ?
|
row.kind === BankOperationKind.Expense || source
|
||||||
await tx.bankCardOperation.findMany({
|
? await tx.bankCardOperation.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
originalExpenseId: source?.id ?? row.id,
|
originalExpenseId: source?.id ?? row.id,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
...(source ? { id: { not: row.id } } : {}),
|
...(source ? { id: { not: row.id } } : {}),
|
||||||
},
|
},
|
||||||
}) :
|
})
|
||||||
[];
|
: [];
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
kind: row.kind as BankOperationDto['kind'],
|
kind: row.kind as BankOperationDto['kind'],
|
||||||
@@ -78,19 +73,18 @@ export class BankOperationsService {
|
|||||||
originalExpenseId: row.originalExpenseId,
|
originalExpenseId: row.originalExpenseId,
|
||||||
amount: rubMoney(row.amountKopecks),
|
amount: rubMoney(row.amountKopecks),
|
||||||
availableRefund:
|
availableRefund:
|
||||||
row.kind === BankOperationKind.Expense || source ?
|
row.kind === BankOperationKind.Expense || source
|
||||||
rubMoney(
|
? rubMoney(
|
||||||
(source?.amountKopecks ?? row.amountKopecks) -
|
(source?.amountKopecks ?? row.amountKopecks) -
|
||||||
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
|
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
|
||||||
) :
|
)
|
||||||
null,
|
: null,
|
||||||
date: row.date,
|
date: row.date,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
version: row.version,
|
version: row.version,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateOperation(
|
private async validateOperation(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -108,26 +102,23 @@ export class BankOperationsService {
|
|||||||
if (
|
if (
|
||||||
(id === input.cardId || id === input.targetCardId) &&
|
(id === input.cardId || id === input.targetCardId) &&
|
||||||
input.date < card.openingDate
|
input.date < card.openingDate
|
||||||
) {
|
)
|
||||||
fail(BankErrorCode.InvalidDate);
|
fail(BankErrorCode.InvalidDate);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
old &&
|
old &&
|
||||||
(old.kind !== input.kind ||
|
(old.kind !== input.kind ||
|
||||||
old.originalExpenseId !== input.originalExpenseId)
|
old.originalExpenseId !== input.originalExpenseId)
|
||||||
) {
|
)
|
||||||
fail(BankErrorCode.ImmutableOperation);
|
fail(BankErrorCode.ImmutableOperation);
|
||||||
}
|
|
||||||
if (input.kind === BankOperationKind.Transfer) {
|
if (input.kind === BankOperationKind.Transfer) {
|
||||||
if (
|
if (
|
||||||
!input.targetCardId ||
|
!input.targetCardId ||
|
||||||
input.targetCardId === input.cardId ||
|
input.targetCardId === input.cardId ||
|
||||||
input.categoryId ||
|
input.categoryId ||
|
||||||
input.originalExpenseId
|
input.originalExpenseId
|
||||||
) {
|
)
|
||||||
fail();
|
fail();
|
||||||
}
|
|
||||||
} else if (input.targetCardId) fail();
|
} else if (input.targetCardId) fail();
|
||||||
if (input.kind === BankOperationKind.Expense) {
|
if (input.kind === BankOperationKind.Expense) {
|
||||||
if (input.originalExpenseId) fail();
|
if (input.originalExpenseId) fail();
|
||||||
@@ -142,13 +133,11 @@ export class BankOperationsService {
|
|||||||
!(await tx.expenseCategory.findFirst({
|
!(await tx.expenseCategory.findFirst({
|
||||||
where: { userId, id: input.categoryId },
|
where: { userId, id: input.categoryId },
|
||||||
}))
|
}))
|
||||||
) {
|
)
|
||||||
missing();
|
missing();
|
||||||
}
|
|
||||||
} else if (input.categoryId) fail();
|
} else if (input.categoryId) fail();
|
||||||
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
|
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId)
|
||||||
fail();
|
fail();
|
||||||
}
|
|
||||||
if (input.kind === BankOperationKind.Refund) {
|
if (input.kind === BankOperationKind.Refund) {
|
||||||
if (!input.originalExpenseId) fail();
|
if (!input.originalExpenseId) fail();
|
||||||
const source = await this.repository.operation(
|
const source = await this.repository.operation(
|
||||||
@@ -160,9 +149,8 @@ export class BankOperationsService {
|
|||||||
source.deletedAt ||
|
source.deletedAt ||
|
||||||
source.kind !== BankOperationKind.Expense ||
|
source.kind !== BankOperationKind.Expense ||
|
||||||
source.cardId !== input.cardId
|
source.cardId !== input.cardId
|
||||||
) {
|
)
|
||||||
fail(BankErrorCode.InvalidRefund);
|
fail(BankErrorCode.InvalidRefund);
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
(
|
(
|
||||||
await tx.bankCardOperation.updateMany({
|
await tx.bankCardOperation.updateMany({
|
||||||
@@ -174,10 +162,9 @@ export class BankOperationsService {
|
|||||||
},
|
},
|
||||||
data: { version: { increment: 1 } },
|
data: { version: { increment: 1 } },
|
||||||
})
|
})
|
||||||
).count !== EXPECTED_UPDATED_ROWS
|
).count !== 1
|
||||||
) {
|
)
|
||||||
conflict();
|
conflict();
|
||||||
}
|
|
||||||
const refunds = await tx.bankCardOperation.findMany({
|
const refunds = await tx.bankCardOperation.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
@@ -188,13 +175,12 @@ export class BankOperationsService {
|
|||||||
});
|
});
|
||||||
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
||||||
if (
|
if (
|
||||||
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
|
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
|
||||||
input.amountKopecks >
|
input.amountKopecks >
|
||||||
source.amountKopecks
|
source.amountKopecks
|
||||||
) {
|
)
|
||||||
conflict(BankErrorCode.RefundLimit);
|
conflict(BankErrorCode.RefundLimit);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (old?.kind === BankOperationKind.Expense) {
|
if (old?.kind === BankOperationKind.Expense) {
|
||||||
const refunds = await tx.bankCardOperation.findMany({
|
const refunds = await tx.bankCardOperation.findMany({
|
||||||
where: { userId, originalExpenseId: old.id, deletedAt: null },
|
where: { userId, originalExpenseId: old.id, deletedAt: null },
|
||||||
@@ -203,14 +189,12 @@ export class BankOperationsService {
|
|||||||
refunds.length &&
|
refunds.length &&
|
||||||
(input.cardId !== old.cardId ||
|
(input.cardId !== old.cardId ||
|
||||||
refunds.some((row) => row.date < input.date) ||
|
refunds.some((row) => row.date < input.date) ||
|
||||||
refunds.reduce((sum, row) => sum + row.amountKopecks, ZERO_KOPECKS) >
|
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
|
||||||
input.amountKopecks)
|
input.amountKopecks)
|
||||||
) {
|
)
|
||||||
conflict(BankErrorCode.DependentRefunds);
|
conflict(BankErrorCode.DependentRefunds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
createOperation(userId: string, dto: CreateBankOperationDto) {
|
createOperation(userId: string, dto: CreateBankOperationDto) {
|
||||||
const normalized = operationInput(dto),
|
const normalized = operationInput(dto),
|
||||||
hash = fingerprint(normalized);
|
hash = fingerprint(normalized);
|
||||||
@@ -220,13 +204,10 @@ export class BankOperationsService {
|
|||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.userId !== userId) missing();
|
if (existing.userId !== userId) missing();
|
||||||
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
if (existing.createFingerprint !== hash)
|
||||||
if (existing.createFingerprint !== hash) {
|
|
||||||
conflict(BankErrorCode.IntentConflict);
|
conflict(BankErrorCode.IntentConflict);
|
||||||
}
|
if (existing.deletedAt)
|
||||||
if (existing.deletedAt) {
|
|
||||||
throw new GoneException({ code: BankErrorCode.OperationDeleted });
|
throw new GoneException({ code: BankErrorCode.OperationDeleted });
|
||||||
}
|
|
||||||
return this.operationView(tx, existing);
|
return this.operationView(tx, existing);
|
||||||
}
|
}
|
||||||
const input = { ...normalized };
|
const input = { ...normalized };
|
||||||
@@ -239,7 +220,6 @@ export class BankOperationsService {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getOperation(userId: string, id: string) {
|
getOperation(userId: string, id: string) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const row = await this.repository.operation(tx, userId, id);
|
const row = await this.repository.operation(tx, userId, id);
|
||||||
@@ -247,7 +227,6 @@ export class BankOperationsService {
|
|||||||
return this.operationView(tx, row);
|
return this.operationView(tx, row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
|
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
const old = await this.repository.operation(tx, userId, id);
|
const old = await this.repository.operation(tx, userId, id);
|
||||||
@@ -272,34 +251,30 @@ export class BankOperationsService {
|
|||||||
where: { id, userId, version: dto.version, deletedAt: null },
|
where: { id, userId, version: dto.version, deletedAt: null },
|
||||||
data: { ...input, version: { increment: 1 } },
|
data: { ...input, version: { increment: 1 } },
|
||||||
})
|
})
|
||||||
).count !== EXPECTED_UPDATED_ROWS
|
).count !== 1
|
||||||
) {
|
)
|
||||||
conflict();
|
conflict();
|
||||||
}
|
|
||||||
return this.operationView(
|
return this.operationView(
|
||||||
tx,
|
tx,
|
||||||
await this.repository.operation(tx, userId, id),
|
await this.repository.operation(tx, userId, id),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteOperation(userId: string, id: string, version: number) {
|
deleteOperation(userId: string, id: string, version: number) {
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
const row = await this.repository.operation(tx, userId, id);
|
const row = await this.repository.operation(tx, userId, id);
|
||||||
if (row.deletedAt) return;
|
if (row.deletedAt) return;
|
||||||
if (row.version !== version) conflict();
|
if (row.version !== version) conflict();
|
||||||
await this.repository.card(tx, userId, row.cardId, true);
|
await this.repository.card(tx, userId, row.cardId, true);
|
||||||
if (row.targetCardId) {
|
if (row.targetCardId)
|
||||||
await this.repository.card(tx, userId, row.targetCardId, true);
|
await this.repository.card(tx, userId, row.targetCardId, true);
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
row.kind === BankOperationKind.Expense &&
|
row.kind === BankOperationKind.Expense &&
|
||||||
(await tx.bankCardOperation.findFirst({
|
(await tx.bankCardOperation.findFirst({
|
||||||
where: { userId, originalExpenseId: id, deletedAt: null },
|
where: { userId, originalExpenseId: id, deletedAt: null },
|
||||||
}))
|
}))
|
||||||
) {
|
)
|
||||||
conflict(BankErrorCode.DependentRefunds);
|
conflict(BankErrorCode.DependentRefunds);
|
||||||
}
|
|
||||||
if (row.originalExpenseId) {
|
if (row.originalExpenseId) {
|
||||||
const source = await this.repository.operation(
|
const source = await this.repository.operation(
|
||||||
tx,
|
tx,
|
||||||
@@ -312,50 +287,47 @@ export class BankOperationsService {
|
|||||||
where: { id: source.id, userId, version: source.version },
|
where: { id: source.id, userId, version: source.version },
|
||||||
data: { version: { increment: 1 } },
|
data: { version: { increment: 1 } },
|
||||||
})
|
})
|
||||||
).count !== EXPECTED_UPDATED_ROWS
|
).count !== 1
|
||||||
) {
|
)
|
||||||
conflict();
|
conflict();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
(
|
(
|
||||||
await tx.bankCardOperation.updateMany({
|
await tx.bankCardOperation.updateMany({
|
||||||
where: { id, userId, version, deletedAt: null },
|
where: { id, userId, version, deletedAt: null },
|
||||||
data: { deletedAt: new Date(), version: { increment: 1 } },
|
data: { deletedAt: new Date(), version: { increment: 1 } },
|
||||||
})
|
})
|
||||||
).count !== EXPECTED_UPDATED_ROWS
|
).count !== 1
|
||||||
) {
|
)
|
||||||
conflict();
|
conflict();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
history(
|
history(
|
||||||
userId: string,
|
userId: string,
|
||||||
id: string,
|
id: string,
|
||||||
query: BankOperationsQueryDto | BankPageQueryDto,
|
query: BankOperationsQueryDto | BankPageQueryDto,
|
||||||
expenses = false,
|
expenses = false,
|
||||||
) {
|
) {
|
||||||
const period = expenses ?
|
const period = expenses
|
||||||
undefined :
|
? undefined
|
||||||
periodBounds(query as BankOperationsQueryDto);
|
: periodBounds(query as BankOperationsQueryDto);
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
await this.repository.card(tx, userId, id);
|
await this.repository.card(tx, userId, id);
|
||||||
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
|
const page = query.page ?? 1,
|
||||||
page = query.page ?? FIRST_PAGE;
|
limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE;
|
||||||
const where: Prisma.BankCardOperationWhereInput = {
|
const where: Prisma.BankCardOperationWhereInput = {
|
||||||
userId,
|
userId,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
OR: [{ cardId: id }, { targetCardId: id }],
|
OR: [{ cardId: id }, { targetCardId: id }],
|
||||||
...(period ?
|
...(period
|
||||||
{ date: { gte: period.from, lte: period.to } } :
|
? { date: { gte: period.from, lte: period.to } }
|
||||||
{ kind: BankOperationKind.Expense }),
|
: { kind: BankOperationKind.Expense }),
|
||||||
};
|
};
|
||||||
const total = await tx.bankCardOperation.count({ where });
|
const total = await tx.bankCardOperation.count({ where });
|
||||||
const rows = await tx.bankCardOperation.findMany({
|
const rows = await tx.bankCardOperation.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||||
skip: (page - FIRST_PAGE) * limit,
|
skip: (page - 1) * limit,
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
|
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
|
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
|
||||||
import { conflict, missing, name } from './bank-card-input';
|
import { conflict, missing, name } from './bank-card-input';
|
||||||
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
const EXPECTED_UPDATED_CATEGORIES = 1;
|
|
||||||
const VERSION_INCREMENT = 1;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpenseCategoriesService {
|
export class ExpenseCategoriesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly repository: BankCardsRepository,
|
private readonly repository: BankCardsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async categories(userId: string) {
|
async categories(userId: string) {
|
||||||
return (
|
return (
|
||||||
await this.prisma.expenseCategory.findMany({
|
await this.prisma.expenseCategory.findMany({
|
||||||
@@ -29,7 +24,6 @@ export class ExpenseCategoriesService {
|
|||||||
version,
|
version,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
createCategory(userId: string, value: string) {
|
createCategory(userId: string, value: string) {
|
||||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
@@ -50,7 +44,6 @@ export class ExpenseCategoriesService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCategory(userId: string, id: string, value: string, version: number) {
|
updateCategory(userId: string, id: string, value: string, version: number) {
|
||||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
@@ -66,15 +59,14 @@ export class ExpenseCategoriesService {
|
|||||||
version: { increment: 1 },
|
version: { increment: 1 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
).count !== EXPECTED_UPDATED_CATEGORIES
|
).count !== 1
|
||||||
) {
|
)
|
||||||
conflict();
|
conflict();
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: label,
|
name: label,
|
||||||
isDefault: row.seedKey === 'other',
|
isDefault: row.seedKey === 'other',
|
||||||
version: version + VERSION_INCREMENT,
|
version: version + 1,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
|
||||||
import { bankSummary } from '../domain/bank-card.calculations';
|
import { bankSummary } from '../domain/bank-card.calculations';
|
||||||
import { rubMoney } from '../domain/rub-money';
|
import { rubMoney } from '../domain/rub-money';
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
|
||||||
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
|
|
||||||
import { periodBounds } from './bank-card-input';
|
import { periodBounds } from './bank-card-input';
|
||||||
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GetBankSummaryUseCase {
|
export class GetBankSummaryUseCase {
|
||||||
constructor(private readonly repository: BankCardsRepository) {}
|
constructor(private readonly repository: BankCardsRepository) {}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
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 { BankCardsService } from './application/bank-cards.service';
|
||||||
import { BankOperationsService } from './application/bank-operations.service';
|
import { BankOperationsService } from './application/bank-operations.service';
|
||||||
import { ExpenseCategoriesService } from './application/expense-categories.service';
|
import { ExpenseCategoriesService } from './application/expense-categories.service';
|
||||||
import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case';
|
import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case';
|
||||||
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
|
|
||||||
import { BankCardsController } from './presentation/bank-cards.controller';
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule],
|
||||||
controllers: [BankCardsController],
|
controllers: [BankCardsController],
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
export const FIRST_PAGE = 1;
|
|
||||||
|
|
||||||
export const MIN_ENTITY_VERSION = 1;
|
|
||||||
|
|
||||||
export const MIN_CATEGORY_NAME_LENGTH = 1;
|
|
||||||
|
|
||||||
export const MIN_CARD_NAME_LENGTH = 1;
|
|
||||||
export const MAX_CARD_NAME_LENGTH = 100;
|
export const MAX_CARD_NAME_LENGTH = 100;
|
||||||
export const MAX_CATEGORY_NAME_LENGTH = 80;
|
export const MAX_CATEGORY_NAME_LENGTH = 80;
|
||||||
export const MAX_OPERATION_DESCRIPTION_LENGTH = 500;
|
export const MAX_OPERATION_DESCRIPTION_LENGTH = 500;
|
||||||
|
|||||||
@@ -116,9 +116,7 @@ describe('bank card calculations', () => {
|
|||||||
amountKopecks: 5000n,
|
amountKopecks: 5000n,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
expect(
|
expect(bankSummary('2026-09', operations)).toEqual({
|
||||||
bankSummary({ from: '2026-09-01', to: '2026-09-30' }, operations),
|
|
||||||
).toEqual({
|
|
||||||
expenses: 120n,
|
expenses: 120n,
|
||||||
refunds: 500n,
|
refunds: 500n,
|
||||||
net: -380n,
|
net: -380n,
|
||||||
@@ -127,9 +125,7 @@ describe('bank card calculations', () => {
|
|||||||
{ categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n },
|
{ categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(
|
expect(bankSummary('2026-07', operations)).toEqual({
|
||||||
bankSummary({ from: '2026-07-01', to: '2026-07-31' }, operations),
|
|
||||||
).toEqual({
|
|
||||||
expenses: 0n,
|
expenses: 0n,
|
||||||
refunds: 0n,
|
refunds: 0n,
|
||||||
net: 0n,
|
net: 0n,
|
||||||
@@ -145,12 +141,7 @@ describe('bank card calculations', () => {
|
|||||||
amountKopecks: 2n ** 63n - 1n,
|
amountKopecks: 2n ** 63n - 1n,
|
||||||
};
|
};
|
||||||
const total = 2n * (2n ** 63n - 1n);
|
const total = 2n * (2n ** 63n - 1n);
|
||||||
expect(
|
expect(bankSummary('2026-09', [operation, operation])).toEqual({
|
||||||
bankSummary({ from: '2026-09-01', to: '2026-09-30' }, [
|
|
||||||
operation,
|
|
||||||
operation,
|
|
||||||
]),
|
|
||||||
).toEqual({
|
|
||||||
expenses: total,
|
expenses: total,
|
||||||
refunds: 0n,
|
refunds: 0n,
|
||||||
net: total,
|
net: total,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
|
||||||
import { BankOperationKind } from './bank-operation-kind';
|
import { BankOperationKind } from './bank-operation-kind';
|
||||||
import { ZERO_KOPECKS } from './rub-money';
|
|
||||||
|
|
||||||
|
const ISO_MONTH_LENGTH = 7;
|
||||||
export function moscowToday(now = new Date()): string {
|
export function moscowToday(now = new Date()): string {
|
||||||
return new Intl.DateTimeFormat('en-CA', {
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
timeZone: 'Europe/Moscow',
|
timeZone: 'Europe/Moscow',
|
||||||
@@ -12,11 +11,11 @@ export function moscowToday(now = new Date()): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isCalendarDate(value: string): boolean {
|
export function isCalendarDate(value: string): boolean {
|
||||||
if (value.length !== ISO_DATE_LENGTH || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||||
if (value.startsWith('0000')) return false;
|
if (value.startsWith('0000')) return false;
|
||||||
const date = new Date(`${value}T00:00:00.000Z`);
|
const date = new Date(`${value}T00:00:00.000Z`);
|
||||||
return (
|
return (
|
||||||
!Number.isNaN(date.getTime()) && date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === value
|
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,10 +23,10 @@ export function bankBalance(
|
|||||||
cardId: string,
|
cardId: string,
|
||||||
opening: bigint,
|
opening: bigint,
|
||||||
operations: Array<{
|
operations: Array<{
|
||||||
amountKopecks: bigint;
|
|
||||||
cardId: string;
|
|
||||||
kind: string;
|
kind: string;
|
||||||
targetCardId: null | string;
|
cardId: string;
|
||||||
|
targetCardId: string | null;
|
||||||
|
amountKopecks: bigint;
|
||||||
}>,
|
}>,
|
||||||
): bigint {
|
): bigint {
|
||||||
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||||
@@ -37,65 +36,67 @@ export function bankBalance(
|
|||||||
if (
|
if (
|
||||||
operation.kind === BankOperationKind.Expense ||
|
operation.kind === BankOperationKind.Expense ||
|
||||||
operation.kind === BankOperationKind.Transfer
|
operation.kind === BankOperationKind.Transfer
|
||||||
) {
|
)
|
||||||
balance -= operation.amountKopecks;
|
balance -= operation.amountKopecks;
|
||||||
} else if (
|
else if (
|
||||||
operation.kind === BankOperationKind.Topup ||
|
operation.kind === BankOperationKind.Topup ||
|
||||||
operation.kind === BankOperationKind.Refund
|
operation.kind === BankOperationKind.Refund
|
||||||
) {
|
)
|
||||||
balance += operation.amountKopecks;
|
balance += operation.amountKopecks;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
operation.kind === BankOperationKind.Transfer &&
|
operation.kind === BankOperationKind.Transfer &&
|
||||||
operation.targetCardId === cardId
|
operation.targetCardId === cardId
|
||||||
) {
|
)
|
||||||
balance += operation.amountKopecks;
|
balance += operation.amountKopecks;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return balance;
|
return balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bankSummary(
|
export function bankSummary(
|
||||||
period: { from: string; to: string },
|
period: string | { from: string; to: string },
|
||||||
operations: Array<{
|
operations: Array<{
|
||||||
amountKopecks: bigint;
|
|
||||||
categoryId: null | string;
|
|
||||||
date: string;
|
|
||||||
kind: string;
|
kind: string;
|
||||||
|
date: string;
|
||||||
|
amountKopecks: bigint;
|
||||||
|
categoryId: string | null;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
let expenses = ZERO_KOPECKS;
|
let expenses = 0n;
|
||||||
let refunds = ZERO_KOPECKS;
|
let refunds = 0n;
|
||||||
const categories = new Map<
|
const categories = new Map<
|
||||||
string,
|
string,
|
||||||
{ categoryId: string; expenses: bigint; net: bigint; refunds: bigint }
|
{ categoryId: string; expenses: bigint; refunds: bigint; net: bigint }
|
||||||
>();
|
>();
|
||||||
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||||
for (const operation of operations) {
|
for (const operation of operations) {
|
||||||
if (operation.date < period.from || operation.date > period.to) continue;
|
if (
|
||||||
|
typeof period === 'string'
|
||||||
|
? operation.date.slice(0, ISO_MONTH_LENGTH) !== period
|
||||||
|
: operation.date < period.from || operation.date > period.to
|
||||||
|
)
|
||||||
|
continue;
|
||||||
if (
|
if (
|
||||||
operation.kind !== BankOperationKind.Expense &&
|
operation.kind !== BankOperationKind.Expense &&
|
||||||
operation.kind !== BankOperationKind.Refund
|
operation.kind !== BankOperationKind.Refund
|
||||||
) {
|
)
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
const expense =
|
const expense =
|
||||||
operation.kind === BankOperationKind.Expense ?
|
operation.kind === BankOperationKind.Expense
|
||||||
operation.amountKopecks :
|
? operation.amountKopecks
|
||||||
ZERO_KOPECKS;
|
: 0n;
|
||||||
const refund =
|
const refund =
|
||||||
operation.kind === BankOperationKind.Refund ?
|
operation.kind === BankOperationKind.Refund
|
||||||
operation.amountKopecks :
|
? operation.amountKopecks
|
||||||
ZERO_KOPECKS;
|
: 0n;
|
||||||
expenses += expense;
|
expenses += expense;
|
||||||
refunds += refund;
|
refunds += refund;
|
||||||
if (operation.categoryId !== null) {
|
if (operation.categoryId !== null) {
|
||||||
const category = categories.get(operation.categoryId) ?? {
|
const category = categories.get(operation.categoryId) ?? {
|
||||||
categoryId: operation.categoryId,
|
categoryId: operation.categoryId,
|
||||||
expenses: ZERO_KOPECKS,
|
expenses: 0n,
|
||||||
refunds: ZERO_KOPECKS,
|
refunds: 0n,
|
||||||
net: ZERO_KOPECKS,
|
net: 0n,
|
||||||
};
|
};
|
||||||
category.expenses += expense;
|
category.expenses += expense;
|
||||||
category.refunds += refund;
|
category.refunds += refund;
|
||||||
|
|||||||
@@ -39,9 +39,8 @@ describe('RUB money', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('requires positive operation amounts, including at the maximum', () => {
|
it('requires positive operation amounts, including at the maximum', () => {
|
||||||
for (const value of ['0', '-0.00', '-0.01']) {
|
for (const value of ['0', '-0.00', '-0.01'])
|
||||||
expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow();
|
expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow();
|
||||||
}
|
|
||||||
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
|
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
|
||||||
expect(
|
expect(
|
||||||
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
|
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
|
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||||
import type { Money } from '../../../../common/money.types';
|
import type { Money } from '../../../../common/money.types';
|
||||||
|
|
||||||
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal';
|
|
||||||
|
|
||||||
export const ZERO_KOPECKS = 0n;
|
|
||||||
|
|
||||||
const RUB_DECIMAL_PLACES = 2;
|
|
||||||
|
|
||||||
const MIN_KOPECKS = -9_223_372_036_854_775_808n;
|
const MIN_KOPECKS = -9_223_372_036_854_775_808n;
|
||||||
const MAX_KOPECKS = 9_223_372_036_854_775_807n;
|
const MAX_KOPECKS = 9_223_372_036_854_775_807n;
|
||||||
|
|
||||||
@@ -14,26 +9,22 @@ export function parseRubMoney(money: Money, positive = false): bigint {
|
|||||||
money?.currency !== 'RUB' ||
|
money?.currency !== 'RUB' ||
|
||||||
typeof money.value !== 'string' ||
|
typeof money.value !== 'string' ||
|
||||||
money.value !== money.value.trim()
|
money.value !== money.value.trim()
|
||||||
) {
|
)
|
||||||
throw new TypeError('Expected RUB money with a decimal string');
|
throw new TypeError('Expected RUB money with a decimal string');
|
||||||
}
|
|
||||||
const amount = decimalFromString(money.value);
|
const amount = decimalFromString(money.value);
|
||||||
if (amount.scale > RUB_DECIMAL_PLACES) {
|
if (amount.scale > 2)
|
||||||
throw new RangeError('RUB money supports at most two decimal places');
|
throw new RangeError('RUB money supports at most two decimal places');
|
||||||
}
|
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
|
||||||
const kopecks = amount.value * DECIMAL_RADIX ** BigInt(RUB_DECIMAL_PLACES - amount.scale);
|
if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS)
|
||||||
if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) {
|
|
||||||
throw new RangeError('RUB money is outside the signed 64-bit range');
|
throw new RangeError('RUB money is outside the signed 64-bit range');
|
||||||
}
|
if (positive && kopecks <= 0n)
|
||||||
if (positive && kopecks <= ZERO_KOPECKS) {
|
|
||||||
throw new RangeError('Operation amount must be positive');
|
throw new RangeError('Operation amount must be positive');
|
||||||
}
|
|
||||||
return kopecks;
|
return kopecks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rubMoney(kopecks: bigint): Money {
|
export function rubMoney(kopecks: bigint): Money {
|
||||||
return {
|
return {
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
value: formatDecimal({ value: kopecks, scale: RUB_DECIMAL_PLACES }),
|
value: formatDecimal({ value: kopecks, scale: 2 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
|
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { Prisma } from '../../../../../generated/prisma/client';
|
import { Prisma } from '../../../../../generated/prisma/client';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BankErrorCode,
|
BankErrorCode,
|
||||||
conflict,
|
conflict,
|
||||||
missing,
|
missing,
|
||||||
} from '../application/bank-card-input';
|
} from '../application/bank-card-input';
|
||||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
|
||||||
|
|
||||||
const INITIAL_RETRY_COUNT = 0;
|
|
||||||
const LAST_SEED_OFFSET = 1;
|
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
const TRANSACTION_MAX_WAIT_MS = 10_000;
|
const TRANSACTION_MAX_WAIT_MS = 10_000;
|
||||||
const TRANSACTION_TIMEOUT_MS = 15_000;
|
const TRANSACTION_TIMEOUT_MS = 15_000;
|
||||||
@@ -32,7 +28,7 @@ const seeds = [
|
|||||||
export class BankCardsRepository {
|
export class BankCardsRepository {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
|
for (let attempt = 0; ; attempt++) {
|
||||||
try {
|
try {
|
||||||
return await this.prisma.$transaction(run, {
|
return await this.prisma.$transaction(run, {
|
||||||
maxWait: TRANSACTION_MAX_WAIT_MS,
|
maxWait: TRANSACTION_MAX_WAIT_MS,
|
||||||
@@ -40,45 +36,41 @@ export class BankCardsRepository {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code =
|
const code =
|
||||||
error instanceof Prisma.PrismaClientKnownRequestError ?
|
error instanceof Prisma.PrismaClientKnownRequestError
|
||||||
error.code :
|
? error.code
|
||||||
'';
|
: '';
|
||||||
const busy =
|
const busy =
|
||||||
code === 'P2034' ||
|
code === 'P2034' ||
|
||||||
code === 'P2028' ||
|
code === 'P2028' ||
|
||||||
(error instanceof Error &&
|
(error instanceof Error &&
|
||||||
/SQLITE_BUSY|database is locked/.test(error.message));
|
/SQLITE_BUSY|database is locked/.test(error.message));
|
||||||
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) {
|
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT)
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
if (busy) conflict(BankErrorCode.WriteConflict);
|
if (busy) conflict(BankErrorCode.WriteConflict);
|
||||||
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
|
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async card(tx: Tx, userId: string, id: string, active = false) {
|
async card(tx: Tx, userId: string, id: string, active = false) {
|
||||||
const card = await tx.bankCard.findFirst({ where: { id, userId } });
|
const card = await tx.bankCard.findFirst({ where: { id, userId } });
|
||||||
if (!card) return missing();
|
if (!card) return missing();
|
||||||
if (active && card.archivedAt) conflict(BankErrorCode.CardArchived);
|
if (active && card.archivedAt) conflict(BankErrorCode.CardArchived);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
async operation(tx: Tx, userId: string, id: string) {
|
async operation(tx: Tx, userId: string, id: string) {
|
||||||
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
|
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
|
||||||
if (!row) return missing();
|
if (!row) return missing();
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
async seed(tx: Tx, userId: string) {
|
async seed(tx: Tx, userId: string) {
|
||||||
for (const [index, label] of seeds.entries()) {
|
for (const [index, label] of seeds.entries()) {
|
||||||
const seedKey = index === seeds.length - LAST_SEED_OFFSET ? 'other' : String(index);
|
const seedKey = index === seeds.length - 1 ? 'other' : String(index);
|
||||||
if (
|
if (
|
||||||
!(await tx.expenseCategory.findUnique({
|
!(await tx.expenseCategory.findUnique({
|
||||||
where: { userId_seedKey: { userId, seedKey } },
|
where: { userId_seedKey: { userId, seedKey } },
|
||||||
}))
|
}))
|
||||||
) {
|
)
|
||||||
await tx.expenseCategory.create({
|
await tx.expenseCategory.create({
|
||||||
data: {
|
data: {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -90,8 +82,6 @@ export class BankCardsRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
summaryData(
|
summaryData(
|
||||||
userId: string,
|
userId: string,
|
||||||
period: { from: string; to: string },
|
period: { from: string; to: string },
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
MAX_CARD_NAME_LENGTH,
|
||||||
|
MAX_CATEGORY_NAME_LENGTH,
|
||||||
|
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||||
|
} from '../domain/bank-card-limits';
|
||||||
|
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||||
import {
|
import {
|
||||||
ApiProperty,
|
ApiProperty,
|
||||||
ApiPropertyOptional,
|
ApiPropertyOptional,
|
||||||
@@ -22,20 +28,7 @@ import {
|
|||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
import { MoneyDto } from '../../../../common/money.dto';
|
import { MoneyDto } from '../../../../common/money.dto';
|
||||||
import {
|
|
||||||
FIRST_PAGE,
|
|
||||||
MAX_CARD_NAME_LENGTH,
|
|
||||||
MAX_CATEGORY_NAME_LENGTH,
|
|
||||||
MAX_OPERATION_DESCRIPTION_LENGTH,
|
|
||||||
MIN_CARD_NAME_LENGTH,
|
|
||||||
MIN_CATEGORY_NAME_LENGTH,
|
|
||||||
MIN_ENTITY_VERSION,
|
|
||||||
} from '../domain/bank-card-limits';
|
|
||||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
|
||||||
|
|
||||||
const MIN_PAGE_SIZE = 1;
|
|
||||||
|
|
||||||
export const DEFAULT_BANK_PAGE_SIZE = 50;
|
export const DEFAULT_BANK_PAGE_SIZE = 50;
|
||||||
const MAX_BANK_PAGE_SIZE = 100;
|
const MAX_BANK_PAGE_SIZE = 100;
|
||||||
@@ -48,19 +41,17 @@ export class RubMoneyInputDto {
|
|||||||
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
|
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
|
||||||
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
|
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
|
||||||
@IsString()
|
@IsString()
|
||||||
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
|
|
||||||
@Matches(/^-?\d+(\.\d{1,2})?$/)
|
@Matches(/^-?\d+(\.\d{1,2})?$/)
|
||||||
declare value: string;
|
declare value: string;
|
||||||
}
|
}
|
||||||
export class CreateBankCardDto {
|
export class CreateBankCardDto {
|
||||||
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
|
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
|
||||||
@ApiProperty({ minLength: MIN_CARD_NAME_LENGTH, maxLength: MAX_CARD_NAME_LENGTH })
|
@ApiProperty({ minLength: 1, maxLength: MAX_CARD_NAME_LENGTH })
|
||||||
@Transform(trim)
|
@Transform(trim)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(MIN_CARD_NAME_LENGTH)
|
@MinLength(1)
|
||||||
@MaxLength(MAX_CARD_NAME_LENGTH)
|
@MaxLength(MAX_CARD_NAME_LENGTH)
|
||||||
declare name: string;
|
declare name: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: String,
|
type: String,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
@@ -70,24 +61,20 @@ export class CreateBankCardDto {
|
|||||||
@Transform(trim)
|
@Transform(trim)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(MAX_CARD_NAME_LENGTH)
|
@MaxLength(MAX_CARD_NAME_LENGTH)
|
||||||
bankName?: null | string;
|
bankName?: string | null;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
|
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(/^\d{4}$/)
|
@Matches(/^[0-9]{4}$/)
|
||||||
lastFour?: null | string;
|
lastFour?: string | null;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true })
|
@ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(bankPaymentSystems)
|
@IsIn(bankPaymentSystems)
|
||||||
paymentSystem?: null | string;
|
paymentSystem?: string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: RubMoneyInputDto })
|
@ApiProperty({ type: RubMoneyInputDto })
|
||||||
@IsDefined()
|
@IsDefined()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => RubMoneyInputDto)
|
@Type(() => RubMoneyInputDto)
|
||||||
declare openingBalance: MoneyDto;
|
declare openingBalance: MoneyDto;
|
||||||
|
|
||||||
@ApiProperty({ format: 'date' })
|
@ApiProperty({ format: 'date' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
@@ -97,56 +84,50 @@ export class UpdateBankCardDto extends PartialType(
|
|||||||
OmitType(CreateBankCardDto, ['id'] as const),
|
OmitType(CreateBankCardDto, ['id'] as const),
|
||||||
{ skipNullProperties: false },
|
{ skipNullProperties: false },
|
||||||
) {
|
) {
|
||||||
@ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
|
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
}
|
}
|
||||||
export class CategoryNameDto {
|
export class CategoryNameDto {
|
||||||
@ApiProperty({ minLength: MIN_CATEGORY_NAME_LENGTH, maxLength: MAX_CATEGORY_NAME_LENGTH })
|
@ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH })
|
||||||
@Transform(trim)
|
@Transform(trim)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(MIN_CATEGORY_NAME_LENGTH)
|
@MinLength(1)
|
||||||
@MaxLength(MAX_CATEGORY_NAME_LENGTH)
|
@MaxLength(MAX_CATEGORY_NAME_LENGTH)
|
||||||
declare name: string;
|
declare name: string;
|
||||||
}
|
}
|
||||||
export class UpdateExpenseCategoryDto extends CategoryNameDto {
|
export class UpdateExpenseCategoryDto extends CategoryNameDto {
|
||||||
@ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
|
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
|
||||||
}
|
}
|
||||||
export class CreateBankOperationDto {
|
export class CreateBankOperationDto {
|
||||||
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
|
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
|
||||||
@ApiProperty({ enum: bankOperationKinds })
|
@ApiProperty({ enum: bankOperationKinds })
|
||||||
@IsIn(bankOperationKinds)
|
@IsIn(bankOperationKinds)
|
||||||
declare kind: BankOperationKind;
|
declare kind: BankOperationKind;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string;
|
@ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string;
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
targetCardId?: string;
|
targetCardId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
originalExpenseId?: string;
|
originalExpenseId?: string;
|
||||||
|
|
||||||
@ApiProperty({ type: RubMoneyInputDto })
|
@ApiProperty({ type: RubMoneyInputDto })
|
||||||
@IsDefined()
|
@IsDefined()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => RubMoneyInputDto)
|
@Type(() => RubMoneyInputDto)
|
||||||
declare amount: MoneyDto;
|
declare amount: MoneyDto;
|
||||||
|
|
||||||
@ApiProperty({ format: 'date' })
|
@ApiProperty({ format: 'date' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
declare date: string;
|
declare date: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH })
|
@ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH })
|
||||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -157,7 +138,7 @@ export class UpdateBankOperationDto extends PartialType(
|
|||||||
OmitType(CreateBankOperationDto, ['id'] as const),
|
OmitType(CreateBankOperationDto, ['id'] as const),
|
||||||
{ skipNullProperties: false },
|
{ skipNullProperties: false },
|
||||||
) {
|
) {
|
||||||
@ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
|
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
|
||||||
}
|
}
|
||||||
export class BankCardsQueryDto {
|
export class BankCardsQueryDto {
|
||||||
@ApiPropertyOptional({ enum: ['true', 'false'] })
|
@ApiPropertyOptional({ enum: ['true', 'false'] })
|
||||||
@@ -166,22 +147,21 @@ export class BankCardsQueryDto {
|
|||||||
includeArchived?: string;
|
includeArchived?: string;
|
||||||
}
|
}
|
||||||
export class BankPageQueryDto {
|
export class BankPageQueryDto {
|
||||||
@ApiPropertyOptional({ minimum: FIRST_PAGE, default: FIRST_PAGE })
|
@ApiPropertyOptional({ minimum: 1, default: 1 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(FIRST_PAGE)
|
@Min(1)
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
minimum: MIN_PAGE_SIZE,
|
minimum: 1,
|
||||||
maximum: MAX_BANK_PAGE_SIZE,
|
maximum: MAX_BANK_PAGE_SIZE,
|
||||||
default: DEFAULT_BANK_PAGE_SIZE,
|
default: DEFAULT_BANK_PAGE_SIZE,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(MIN_PAGE_SIZE)
|
@Min(1)
|
||||||
@Max(MAX_BANK_PAGE_SIZE)
|
@Max(MAX_BANK_PAGE_SIZE)
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
@@ -193,7 +173,6 @@ export class BankPeriodQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
|
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
|
||||||
month?: string;
|
month?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'date',
|
format: 'date',
|
||||||
description: 'Inclusive start date; requires to.',
|
description: 'Inclusive start date; requires to.',
|
||||||
@@ -201,7 +180,6 @@ export class BankPeriodQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
from?: string;
|
from?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'date',
|
format: 'date',
|
||||||
description: 'Inclusive end date; requires from.',
|
description: 'Inclusive end date; requires from.',
|
||||||
@@ -221,10 +199,10 @@ export class BankSummaryQueryDto extends BankPeriodQueryDto {
|
|||||||
cardId?: string;
|
cardId?: string;
|
||||||
}
|
}
|
||||||
export class BankVersionQueryDto {
|
export class BankVersionQueryDto {
|
||||||
@ApiProperty({ minimum: MIN_ENTITY_VERSION })
|
@ApiProperty({ minimum: 1 })
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(MIN_ENTITY_VERSION)
|
@Min(1)
|
||||||
declare version: number;
|
declare version: number;
|
||||||
}
|
}
|
||||||
export class ExpenseCategoryDto {
|
export class ExpenseCategoryDto {
|
||||||
@@ -237,20 +215,16 @@ export class BankCardDto {
|
|||||||
@ApiProperty({ type: String }) declare id: string;
|
@ApiProperty({ type: String }) declare id: string;
|
||||||
@ApiProperty({ type: String }) declare name: string;
|
@ApiProperty({ type: String }) declare name: string;
|
||||||
@ApiProperty({ type: String, nullable: true }) declare bankName:
|
@ApiProperty({ type: String, nullable: true }) declare bankName:
|
||||||
null | string;
|
string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true }) declare lastFour:
|
@ApiProperty({ type: String, nullable: true }) declare lastFour:
|
||||||
null | string;
|
string | null;
|
||||||
|
|
||||||
@ApiProperty({ enum: bankPaymentSystems, nullable: true })
|
@ApiProperty({ enum: bankPaymentSystems, nullable: true })
|
||||||
declare paymentSystem: null | string;
|
declare paymentSystem: string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto;
|
||||||
@ApiProperty({ type: String }) declare openingDate: string;
|
@ApiProperty({ type: String }) declare openingDate: string;
|
||||||
@ApiProperty({ type: MoneyDto }) declare balance: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare balance: MoneyDto;
|
||||||
@ApiProperty({ type: String, nullable: true }) declare archivedAt:
|
@ApiProperty({ type: String, nullable: true }) declare archivedAt:
|
||||||
null | string;
|
string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: Number }) declare version: number;
|
@ApiProperty({ type: Number }) declare version: number;
|
||||||
}
|
}
|
||||||
export class BankOperationDto {
|
export class BankOperationDto {
|
||||||
@@ -259,35 +233,27 @@ export class BankOperationDto {
|
|||||||
@ApiProperty({ type: String }) declare cardId: string;
|
@ApiProperty({ type: String }) declare cardId: string;
|
||||||
@ApiProperty({ type: String }) declare cardName: string;
|
@ApiProperty({ type: String }) declare cardName: string;
|
||||||
@ApiProperty({ type: String, nullable: true }) declare targetCardId:
|
@ApiProperty({ type: String, nullable: true }) declare targetCardId:
|
||||||
null | string;
|
string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true }) declare targetCardName:
|
@ApiProperty({ type: String, nullable: true }) declare targetCardName:
|
||||||
null | string;
|
string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true }) declare categoryId:
|
@ApiProperty({ type: String, nullable: true }) declare categoryId:
|
||||||
null | string;
|
string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true }) declare categoryName:
|
@ApiProperty({ type: String, nullable: true }) declare categoryName:
|
||||||
null | string;
|
string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true }) declare originalExpenseId:
|
@ApiProperty({ type: String, nullable: true }) declare originalExpenseId:
|
||||||
null | string;
|
string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: MoneyDto }) declare amount: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare amount: MoneyDto;
|
||||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||||
declare availableRefund: MoneyDto | null;
|
declare availableRefund: MoneyDto | null;
|
||||||
|
|
||||||
@ApiProperty({ type: String }) declare date: string;
|
@ApiProperty({ type: String }) declare date: string;
|
||||||
@ApiProperty({ type: String, nullable: true }) declare description:
|
@ApiProperty({ type: String, nullable: true }) declare description:
|
||||||
null | string;
|
string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: Number }) declare version: number;
|
@ApiProperty({ type: Number }) declare version: number;
|
||||||
@ApiProperty({ type: String }) declare createdAt: string;
|
@ApiProperty({ type: String }) declare createdAt: string;
|
||||||
}
|
}
|
||||||
export class BankOperationsPageDto {
|
export class BankOperationsPageDto {
|
||||||
@ApiProperty({ type: BankOperationDto, isArray: true })
|
@ApiProperty({ type: BankOperationDto, isArray: true })
|
||||||
declare items: BankOperationDto[];
|
declare items: BankOperationDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: Number }) declare page: number;
|
@ApiProperty({ type: Number }) declare page: number;
|
||||||
@ApiProperty({ type: Number }) declare total: number;
|
@ApiProperty({ type: Number }) declare total: number;
|
||||||
@ApiProperty({ type: Boolean }) declare hasNext: boolean;
|
@ApiProperty({ type: Boolean }) declare hasNext: boolean;
|
||||||
@@ -303,7 +269,7 @@ export class BankSummaryDto {
|
|||||||
@ApiPropertyOptional({ type: String }) month?: string;
|
@ApiPropertyOptional({ type: String }) month?: string;
|
||||||
@ApiPropertyOptional({ format: 'date' }) from?: string;
|
@ApiPropertyOptional({ format: 'date' }) from?: string;
|
||||||
@ApiPropertyOptional({ format: 'date' }) to?: string;
|
@ApiPropertyOptional({ format: 'date' }) to?: string;
|
||||||
@ApiProperty({ type: String, nullable: true }) declare cardId: null | string;
|
@ApiProperty({ type: String, nullable: true }) declare cardId: string | null;
|
||||||
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
|
||||||
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
|
||||||
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { Request } from 'express';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -23,16 +21,12 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
getSchemaPath,
|
getSchemaPath,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
import type { Request } from 'express';
|
||||||
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 { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||||
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';
|
|
||||||
import {
|
import {
|
||||||
BankCardDto,
|
BankCardDto,
|
||||||
BankCardsQueryDto,
|
BankCardsQueryDto,
|
||||||
@@ -51,6 +45,10 @@ import {
|
|||||||
UpdateBankOperationDto,
|
UpdateBankOperationDto,
|
||||||
UpdateExpenseCategoryDto,
|
UpdateExpenseCategoryDto,
|
||||||
} from './bank-card.dto';
|
} from './bank-card.dto';
|
||||||
|
import { BankCardsService } from '../application/bank-cards.service';
|
||||||
|
import { BankOperationsService } from '../application/bank-operations.service';
|
||||||
|
import { ExpenseCategoriesService } from '../application/expense-categories.service';
|
||||||
|
import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case';
|
||||||
type AuthRequest = Request & { user: AccessPayload };
|
type AuthRequest = Request & { user: AccessPayload };
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 409,
|
status: 409,
|
||||||
@@ -84,31 +82,26 @@ export class BankCardsController {
|
|||||||
private readonly expenseCategories: ExpenseCategoriesService,
|
private readonly expenseCategories: ExpenseCategoriesService,
|
||||||
private readonly bankSummary: GetBankSummaryUseCase,
|
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) {
|
||||||
return this.cards.list(req.user.sub, query.includeArchived === 'true');
|
return this.cards.list(req.user.sub, query.includeArchived === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiCreatedResponse({ type: BankCardDto })
|
@ApiCreatedResponse({ type: BankCardDto })
|
||||||
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
|
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
|
||||||
return this.cards.createCard(req.user.sub, body);
|
return this.cards.createCard(req.user.sub, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('categories')
|
@Get('categories')
|
||||||
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
|
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
|
||||||
categories(@Req() req: AuthRequest) {
|
categories(@Req() req: AuthRequest) {
|
||||||
return this.expenseCategories.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.expenseCategories.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 })
|
||||||
updateCategory(
|
updateCategory(
|
||||||
@@ -123,13 +116,11 @@ export class BankCardsController {
|
|||||||
body.version,
|
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.bankSummary.execute(req.user.sub, query);
|
return this.bankSummary.execute(req.user.sub, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('operations')
|
@Post('operations')
|
||||||
@ApiCreatedResponse({ type: BankOperationDto })
|
@ApiCreatedResponse({ type: BankOperationDto })
|
||||||
createOperation(
|
createOperation(
|
||||||
@@ -138,13 +129,11 @@ export class BankCardsController {
|
|||||||
) {
|
) {
|
||||||
return this.operations.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.operations.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 })
|
||||||
updateOperation(
|
updateOperation(
|
||||||
@@ -154,7 +143,6 @@ export class BankCardsController {
|
|||||||
) {
|
) {
|
||||||
return this.operations.updateOperation(req.user.sub, id, body);
|
return this.operations.updateOperation(req.user.sub, id, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('operations/:id')
|
@Delete('operations/:id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiNoContentResponse()
|
@ApiNoContentResponse()
|
||||||
@@ -165,7 +153,6 @@ export class BankCardsController {
|
|||||||
) {
|
) {
|
||||||
return this.operations.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 })
|
||||||
history(
|
history(
|
||||||
@@ -175,7 +162,6 @@ export class BankCardsController {
|
|||||||
) {
|
) {
|
||||||
return this.operations.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 })
|
||||||
expenses(
|
expenses(
|
||||||
@@ -185,13 +171,11 @@ export class BankCardsController {
|
|||||||
) {
|
) {
|
||||||
return this.operations.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 })
|
||||||
get(@Req() req: AuthRequest, @Param('id') id: string) {
|
get(@Req() req: AuthRequest, @Param('id') id: string) {
|
||||||
return this.cards.get(req.user.sub, id);
|
return this.cards.get(req.user.sub, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ApiOkResponse({ type: BankCardDto })
|
@ApiOkResponse({ type: BankCardDto })
|
||||||
update(
|
update(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
import { MoneyDto } from '../../../common/money.dto';
|
|
||||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { MoneyDto } from '../../../common/money.dto';
|
||||||
|
|
||||||
export class PortfolioSummaryDto {
|
export class PortfolioSummaryDto {
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||||
@@ -41,18 +40,15 @@ export class BrokerageAccountDto {
|
|||||||
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
|
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||||
declare openedOn: null | string;
|
declare openedOn: string | null;
|
||||||
|
|
||||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Preserve the published required nullable schema; partial internal models may omit the field.
|
|
||||||
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
|
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
|
||||||
portfolioSummary?: null | PortfolioSummaryDto;
|
portfolioSummary?: PortfolioSummaryDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerageAccountsResourceDto {
|
export class BrokerageAccountsResourceDto {
|
||||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema.
|
|
||||||
@ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true })
|
@ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true })
|
||||||
declare data: BrokerageAccountDto[] | null;
|
declare data: BrokerageAccountDto[] | null;
|
||||||
|
|
||||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
declare meta: InvestmentResourceMetaDto;
|
declare meta: InvestmentResourceMetaDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { Request } from 'express';
|
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||||
|
import { Controller, Get, Req, Query, UseGuards } from '@nestjs/common';
|
||||||
import { Controller, Get, 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 { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
|
||||||
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
|
||||||
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
jest.mock('../../../prisma/prisma.service', () => ({
|
jest.mock('../../../prisma/prisma.service', () => ({
|
||||||
PrismaService: class {},
|
PrismaService: class {},
|
||||||
}));
|
}));
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
|
|
||||||
jest.mock('../portfolio/application/portfolio.service', () => ({
|
jest.mock('../portfolio/application/portfolio.service', () => ({
|
||||||
PortfolioService: class PortfolioService {},
|
PortfolioService: class PortfolioService {},
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
|
||||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||||
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -12,15 +11,15 @@ export class BrokerageAccountsService {
|
|||||||
async list(userId: string, observe = false) {
|
async list(userId: string, observe = false) {
|
||||||
const saved = await this.portfolio.saved(userId, 'all', observe);
|
const saved = await this.portfolio.saved(userId, 'all', observe);
|
||||||
const data: BrokerageAccountDto[] | null =
|
const data: BrokerageAccountDto[] | null =
|
||||||
saved.membership.availability === 'missing' ?
|
saved.membership.availability === 'missing'
|
||||||
null :
|
? null
|
||||||
saved.accounts.map((account) => ({
|
: saved.accounts.map((account) => ({
|
||||||
id: account.id,
|
id: account.id,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
type: account.type as BrokerageAccountDto['type'],
|
type: account.type as BrokerageAccountDto['type'],
|
||||||
openedOn: account.openedOn,
|
openedOn: account.openedOn,
|
||||||
portfolioSummary: account.snapshot ?
|
portfolioSummary: account.snapshot
|
||||||
{
|
? {
|
||||||
totalsByCurrency: account.snapshot.totalsByCurrency,
|
totalsByCurrency: account.snapshot.totalsByCurrency,
|
||||||
stockPositionCount: account.snapshot.positions.filter(
|
stockPositionCount: account.snapshot.positions.filter(
|
||||||
(position) => position.type === 'STOCK',
|
(position) => position.type === 'STOCK',
|
||||||
@@ -28,8 +27,8 @@ export class BrokerageAccountsService {
|
|||||||
bondPositionCount: account.snapshot.positions.filter(
|
bondPositionCount: account.snapshot.positions.filter(
|
||||||
(position) => position.type === 'BOND',
|
(position) => position.type === 'BOND',
|
||||||
).length,
|
).length,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
@@ -47,18 +46,18 @@ export class BrokerageAccountsService {
|
|||||||
)
|
)
|
||||||
.digest('base64url'),
|
.digest('base64url'),
|
||||||
availability:
|
availability:
|
||||||
data === null ?
|
data === null
|
||||||
'missing' :
|
? 'missing'
|
||||||
saved.accounts.some((account) => !account.snapshot) ?
|
: saved.accounts.some((account) => !account.snapshot)
|
||||||
'partial' :
|
? 'partial'
|
||||||
'complete',
|
: 'complete',
|
||||||
fetchedAt: saved.membership.lastSuccessfulAt ?
|
fetchedAt: saved.membership.lastSuccessfulAt
|
||||||
new Date(saved.membership.lastSuccessfulAt) :
|
? new Date(saved.membership.lastSuccessfulAt)
|
||||||
null,
|
: null,
|
||||||
freshForMs: 300_000,
|
freshForMs: 300_000,
|
||||||
work: saved.membership.sync.id ?
|
work: saved.membership.sync.id
|
||||||
{ ...saved.membership.sync, id: saved.membership.sync.id } :
|
? { ...saved.membership.sync, id: saved.membership.sync.id }
|
||||||
null,
|
: null,
|
||||||
dependencies: saved.accounts.map((account) => account.meta),
|
dependencies: saved.accounts.map((account) => account.meta),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import type { Request } from 'express';
|
|
||||||
|
|
||||||
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 { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
|
||||||
import {
|
import {
|
||||||
IncomeAnalyticsQueryDto,
|
|
||||||
IncomeAnalyticsResourceDto,
|
IncomeAnalyticsResourceDto,
|
||||||
|
IncomeAnalyticsQueryDto,
|
||||||
IncomeSummaryResourceDto,
|
IncomeSummaryResourceDto,
|
||||||
} from './income-analytics.dto';
|
} from './income-analytics.dto';
|
||||||
|
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||||
import { IncomeAnalyticsService } from './income-analytics.service';
|
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||||
|
|
||||||
@ApiTags('brokerage-accounts')
|
@ApiTags('brokerage-accounts')
|
||||||
|
|||||||
@@ -1,31 +1,22 @@
|
|||||||
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 { MoneyDto } from '../../../common/money.dto';
|
import { MoneyDto } from '../../../common/money.dto';
|
||||||
|
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InvestmentDependencyDto,
|
|
||||||
InvestmentReadQueryDto,
|
InvestmentReadQueryDto,
|
||||||
InvestmentResourceMetaDto,
|
InvestmentResourceMetaDto,
|
||||||
} from '../investment-resource.dto';
|
} from '../investment-resource.dto';
|
||||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
|
||||||
|
|
||||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
|
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({ format: 'date' })
|
||||||
format: 'date',
|
|
||||||
description:
|
|
||||||
'Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(dateOnly)
|
@Matches(dateOnly)
|
||||||
from?: string;
|
from?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({ format: 'date' })
|
||||||
format: 'date',
|
|
||||||
description:
|
|
||||||
'Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(dateOnly)
|
@Matches(dateOnly)
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -102,16 +93,16 @@ export class IncomeInstrumentDto {
|
|||||||
declare key: string;
|
declare key: string;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true })
|
@ApiProperty({ type: String, nullable: true })
|
||||||
declare figi: null | string;
|
declare figi: string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true })
|
@ApiProperty({ type: String, nullable: true })
|
||||||
declare instrumentUid: null | string;
|
declare instrumentUid: string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true })
|
@ApiProperty({ type: String, nullable: true })
|
||||||
declare ticker: null | string;
|
declare ticker: string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true })
|
@ApiProperty({ type: String, nullable: true })
|
||||||
declare instrumentType: null | string;
|
declare instrumentType: string | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
declare displayName: string;
|
declare displayName: string;
|
||||||
@@ -142,7 +133,7 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
|||||||
declare yearComparison: IncomeComparisonDto | null;
|
declare yearComparison: IncomeComparisonDto | null;
|
||||||
|
|
||||||
@ApiProperty({ enum: ['day', 'week', 'month'] })
|
@ApiProperty({ enum: ['day', 'week', 'month'] })
|
||||||
declare bucketGranularity: 'day' | 'month' | 'week';
|
declare bucketGranularity: 'day' | 'week' | 'month';
|
||||||
|
|
||||||
@ApiProperty({ type: IncomeBucketDto, isArray: true })
|
@ApiProperty({ type: IncomeBucketDto, isArray: true })
|
||||||
declare buckets: IncomeBucketDto[];
|
declare buckets: IncomeBucketDto[];
|
||||||
@@ -157,34 +148,13 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
|||||||
export class IncomeSummaryResourceDto {
|
export class IncomeSummaryResourceDto {
|
||||||
@ApiProperty({ type: IncomeSummaryDto, nullable: true })
|
@ApiProperty({ type: IncomeSummaryDto, nullable: true })
|
||||||
declare data: IncomeSummaryDto | null;
|
declare data: IncomeSummaryDto | null;
|
||||||
|
|
||||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
declare meta: InvestmentResourceMetaDto;
|
declare meta: InvestmentResourceMetaDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomeDependencyRoles = ['selected', 'previous', 'year'] as const;
|
|
||||||
|
|
||||||
export class IncomeDependencyDto extends InvestmentDependencyDto {
|
|
||||||
@ApiProperty({ enum: incomeDependencyRoles })
|
|
||||||
declare role: (typeof incomeDependencyRoles)[number];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto {
|
|
||||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional dependencies inherited from InvestmentResourceMetaDto.
|
|
||||||
@ApiProperty({
|
|
||||||
type: IncomeDependencyDto,
|
|
||||||
isArray: true,
|
|
||||||
required: true,
|
|
||||||
description:
|
|
||||||
'Dependencies identified by role, not array order or resourceKey. A missing role is unavailable.',
|
|
||||||
})
|
|
||||||
declare dependencies: IncomeDependencyDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class IncomeAnalyticsResourceDto {
|
export class IncomeAnalyticsResourceDto {
|
||||||
@ApiProperty({ type: IncomeAnalyticsDto, nullable: true })
|
@ApiProperty({ type: IncomeAnalyticsDto, nullable: true })
|
||||||
declare data: IncomeAnalyticsDto | null;
|
declare data: IncomeAnalyticsDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
@ApiProperty({ type: IncomeAnalyticsMetaDto })
|
declare meta: InvestmentResourceMetaDto;
|
||||||
declare meta: IncomeAnalyticsMetaDto;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ jest.mock('../operations/application/brokerage-operations.service', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
|
||||||
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
|
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
|
||||||
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
import { IncomeAnalyticsService } from './income-analytics.service';
|
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||||
|
|
||||||
describe('IncomeAnalyticsService', () => {
|
describe('IncomeAnalyticsService', () => {
|
||||||
@@ -17,44 +16,6 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
|
|
||||||
beforeEach(() => jest.clearAllMocks());
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
it('labels each comparison dependency independently of its resource key', async () => {
|
|
||||||
seed([]);
|
|
||||||
const response = await service.analytics(
|
|
||||||
'user',
|
|
||||||
'account',
|
|
||||||
'2026-01-01',
|
|
||||||
'2026-01-31',
|
|
||||||
);
|
|
||||||
expect(response.meta.dependencies).toEqual([
|
|
||||||
expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }),
|
|
||||||
expect.objectContaining({ role: 'previous', resourceKey: 'period/1' }),
|
|
||||||
expect.objectContaining({ role: 'year', resourceKey: 'period/2' }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('labels an unavailable leap-day comparison as year', async () => {
|
|
||||||
operations.collectRanges.mockImplementation(
|
|
||||||
(_user: string, _account: string, build: () => unknown[]) =>
|
|
||||||
build().map((_, index) => ({ items: [], meta: completeMeta(index) })),
|
|
||||||
);
|
|
||||||
const response = await service.analytics(
|
|
||||||
'user',
|
|
||||||
'account',
|
|
||||||
'2024-02-29',
|
|
||||||
'2024-02-29',
|
|
||||||
);
|
|
||||||
expect(response.meta.dependencies).toEqual([
|
|
||||||
expect.objectContaining({ role: 'selected' }),
|
|
||||||
expect.objectContaining({ role: 'previous' }),
|
|
||||||
expect.objectContaining({
|
|
||||||
role: 'year',
|
|
||||||
availability: 'missing',
|
|
||||||
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(response.data?.yearComparison).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
function seed(items: BrokerageOperationDto[]) {
|
function seed(items: BrokerageOperationDto[]) {
|
||||||
operations.collectRanges.mockImplementation(
|
operations.collectRanges.mockImplementation(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
compare,
|
compare,
|
||||||
@@ -8,21 +7,23 @@ import {
|
|||||||
formatDecimal,
|
formatDecimal,
|
||||||
subtract,
|
subtract,
|
||||||
} from '../../../common/decimal';
|
} from '../../../common/decimal';
|
||||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
|
import {
|
||||||
|
IncomeAnalyticsResourceDto,
|
||||||
|
IncomeSummaryResourceDto,
|
||||||
|
} from './income-analytics.dto';
|
||||||
|
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||||
import {
|
import {
|
||||||
BrokerageOperationDto,
|
BrokerageOperationDto,
|
||||||
BrokerageOperationsService,
|
BrokerageOperationsService,
|
||||||
} from '../operations/operations.api';
|
} from '../operations/operations.api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IncomeAnalyticsEntryDto,
|
IncomeAnalyticsEntryDto,
|
||||||
IncomeAnalyticsResourceDto,
|
|
||||||
IncomeComparisonDto,
|
IncomeComparisonDto,
|
||||||
IncomeCurrencySummaryDto,
|
IncomeCurrencySummaryDto,
|
||||||
IncomeDependencyDto,
|
|
||||||
IncomeInstrumentDto,
|
IncomeInstrumentDto,
|
||||||
IncomeSummaryDto,
|
IncomeSummaryDto,
|
||||||
IncomeSummaryResourceDto,
|
|
||||||
} from './income-analytics.dto';
|
} from './income-analytics.dto';
|
||||||
import {
|
import {
|
||||||
IncomeBucketGranularity,
|
IncomeBucketGranularity,
|
||||||
@@ -30,8 +31,6 @@ import {
|
|||||||
rangeBuckets,
|
rangeBuckets,
|
||||||
} from './income-period';
|
} from './income-period';
|
||||||
|
|
||||||
const STALE_FRESHNESS_MS = 0;
|
|
||||||
|
|
||||||
type Total = {
|
type Total = {
|
||||||
coupon: ReturnType<typeof decimalFromString>;
|
coupon: ReturnType<typeof decimalFromString>;
|
||||||
dividend: ReturnType<typeof decimalFromString>;
|
dividend: ReturnType<typeof decimalFromString>;
|
||||||
@@ -91,14 +90,10 @@ export class IncomeAnalyticsService {
|
|||||||
observe,
|
observe,
|
||||||
);
|
);
|
||||||
const [current, prior, year] = periods;
|
const [current, prior, year] = periods;
|
||||||
// Preserve the legacy order while consumers migrate to explicit roles.
|
const dependencies = periods.map((period) => period.meta);
|
||||||
const dependencies: IncomeDependencyDto[] = [
|
if (!plan.yearAgo)
|
||||||
{ ...current.meta, role: 'selected' },
|
dependencies.push({
|
||||||
{ ...prior.meta, role: 'previous' },
|
...investmentResourceMeta({
|
||||||
{
|
|
||||||
...(plan.yearAgo && year ?
|
|
||||||
year.meta :
|
|
||||||
investmentResourceMeta({
|
|
||||||
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||||
dataRevision: 'unavailable',
|
dataRevision: 'unavailable',
|
||||||
availability: 'missing',
|
availability: 'missing',
|
||||||
@@ -106,29 +101,24 @@ export class IncomeAnalyticsService {
|
|||||||
freshForMs: 0,
|
freshForMs: 0,
|
||||||
work: null,
|
work: null,
|
||||||
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||||
})),
|
}),
|
||||||
role: 'year',
|
});
|
||||||
},
|
|
||||||
];
|
|
||||||
const states = periods.map((period) => period.meta.sync);
|
const states = periods.map((period) => period.meta.sync);
|
||||||
const work =
|
const work =
|
||||||
states.find((state) => state.state === 'running') ??
|
states.find((state) => state.state === 'running') ??
|
||||||
states.find((state) => state.state === 'failed');
|
states.find((state) => state.state === 'failed');
|
||||||
const meta = {
|
const meta = investmentResourceMeta({
|
||||||
...investmentResourceMeta({
|
|
||||||
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
||||||
dataRevision: current.meta.dataRevision,
|
dataRevision: current.meta.dataRevision,
|
||||||
availability: current.meta.availability,
|
availability: current.meta.availability,
|
||||||
fetchedAt: current.meta.lastSuccessfulAt ?
|
fetchedAt: current.meta.lastSuccessfulAt
|
||||||
new Date(current.meta.lastSuccessfulAt) :
|
? new Date(current.meta.lastSuccessfulAt)
|
||||||
null,
|
: null,
|
||||||
freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
|
freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0,
|
||||||
work: work?.id ? { ...work, id: work.id } : null,
|
work: work?.id ? { ...work, id: work.id } : null,
|
||||||
coverage: current.meta.coverage,
|
coverage: current.meta.coverage,
|
||||||
dependencies,
|
dependencies,
|
||||||
}),
|
});
|
||||||
dependencies,
|
|
||||||
};
|
|
||||||
if (current.items === null) return { data: null, meta };
|
if (current.items === null) return { data: null, meta };
|
||||||
assert(current.meta.lastSuccessfulAt);
|
assert(current.meta.lastSuccessfulAt);
|
||||||
const selected = qualifying(current.items).map((entry) => ({
|
const selected = qualifying(current.items).map((entry) => ({
|
||||||
@@ -144,13 +134,13 @@ export class IncomeAnalyticsService {
|
|||||||
current.meta.lastSuccessfulAt,
|
current.meta.lastSuccessfulAt,
|
||||||
),
|
),
|
||||||
previous:
|
previous:
|
||||||
prior.items === null ?
|
prior.items === null
|
||||||
null :
|
? null
|
||||||
comparison(selected, plan.previous, qualifying(prior.items)),
|
: comparison(selected, plan.previous, qualifying(prior.items)),
|
||||||
yearComparison:
|
yearComparison:
|
||||||
plan.yearAgo && year?.items !== null && year ?
|
plan.yearAgo && year?.items !== null && year
|
||||||
comparison(selected, plan.yearAgo, qualifying(year.items)) :
|
? comparison(selected, plan.yearAgo, qualifying(year.items))
|
||||||
null,
|
: null,
|
||||||
bucketGranularity: plan.granularity,
|
bucketGranularity: plan.granularity,
|
||||||
buckets: buckets(plan.selected, plan.granularity, selected),
|
buckets: buckets(plan.selected, plan.granularity, selected),
|
||||||
instruments: instruments(selected),
|
instruments: instruments(selected),
|
||||||
@@ -184,9 +174,9 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
|||||||
if (!entry.payment) continue;
|
if (!entry.payment) continue;
|
||||||
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
||||||
const value = decimalFromString(entry.payment.value);
|
const value = decimalFromString(entry.payment.value);
|
||||||
if (entry.type === 'OPERATION_TYPE_COUPON') {
|
if (entry.type === 'OPERATION_TYPE_COUPON')
|
||||||
total.coupon = add(total.coupon, value);
|
total.coupon = add(total.coupon, value);
|
||||||
} else total.dividend = add(total.dividend, value);
|
else total.dividend = add(total.dividend, value);
|
||||||
values.set(entry.payment.currency, total);
|
values.set(entry.payment.currency, total);
|
||||||
}
|
}
|
||||||
return [...values]
|
return [...values]
|
||||||
@@ -207,7 +197,6 @@ function comparison(
|
|||||||
);
|
);
|
||||||
const currencies = [
|
const currencies = [
|
||||||
...new Set([...selected.keys(), ...compared.keys()]),
|
...new Set([...selected.keys(), ...compared.keys()]),
|
||||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
|
||||||
].sort();
|
].sort();
|
||||||
return {
|
return {
|
||||||
period: comparedPeriod,
|
period: comparedPeriod,
|
||||||
@@ -280,9 +269,8 @@ function instruments(
|
|||||||
function instrumentKey(entry: BrokerageOperationDto): string {
|
function instrumentKey(entry: BrokerageOperationDto): string {
|
||||||
if (entry.instrumentUid) return `uid:${entry.instrumentUid}`;
|
if (entry.instrumentUid) return `uid:${entry.instrumentUid}`;
|
||||||
if (entry.figi) return `figi:${entry.figi}`;
|
if (entry.figi) return `figi:${entry.figi}`;
|
||||||
if (entry.ticker || entry.instrumentType) {
|
if (entry.ticker || entry.instrumentType)
|
||||||
return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`;
|
return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`;
|
||||||
}
|
|
||||||
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
|
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
import { dateOnlyBounds } from '../investment-calendar';
|
import { dateOnlyBounds } from '../investment-calendar';
|
||||||
import { planIncomePeriod, rangeBuckets } from './income-period';
|
import { planIncomePeriod, rangeBuckets } from './income-period';
|
||||||
|
|
||||||
@@ -18,9 +17,8 @@ describe('income period planning', () => {
|
|||||||
['2026-03-02', '2026-03-01'],
|
['2026-03-02', '2026-03-01'],
|
||||||
['2026-01-01', '2027-01-02'],
|
['2026-01-01', '2027-01-02'],
|
||||||
['2026-01-01', '2026-04-01'],
|
['2026-01-01', '2026-04-01'],
|
||||||
]) {
|
])
|
||||||
expect(() => planIncomePeriod(from, to)).toThrow(BadRequestException);
|
expect(() => planIncomePeriod(from, to)).toThrow(BadRequestException);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => {
|
it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => {
|
||||||
@@ -55,18 +53,4 @@ describe('income period planning', () => {
|
|||||||
to: new Date('2026-08-27T21:44:18.510Z'),
|
to: new Date('2026-08-27T21:44:18.510Z'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it.each([
|
|
||||||
['2024-02-29', '2024-02-28T21:00:00.000Z', '2024-02-29T21:00:00.000Z'],
|
|
||||||
['2025-12-31', '2025-12-30T21:00:00.000Z', '2025-12-31T21:00:00.000Z'],
|
|
||||||
])('maps inclusive %s to one full half-open Moscow day', (date, from, to) => {
|
|
||||||
expect(
|
|
||||||
dateOnlyBounds(
|
|
||||||
{ from: date, to: date },
|
|
||||||
new Date('2026-01-01T12:00:00Z'),
|
|
||||||
),
|
|
||||||
).toEqual({
|
|
||||||
from: new Date(from),
|
|
||||||
to: new Date(to),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
|
import { MILLISECONDS_PER_DAY } from '../../../common/time';
|
||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time';
|
|
||||||
import {
|
import {
|
||||||
DateOnlyRange,
|
DateOnlyRange,
|
||||||
dateParts,
|
dateParts,
|
||||||
@@ -8,55 +7,47 @@ import {
|
|||||||
utcDate,
|
utcDate,
|
||||||
} from '../investment-calendar';
|
} from '../investment-calendar';
|
||||||
|
|
||||||
const MONDAY_WEEKDAY_INDEX = 1;
|
|
||||||
const INCLUSIVE_DAY_OFFSET = 1;
|
|
||||||
const NEXT_DAY_OFFSET = 1;
|
|
||||||
const PREVIOUS_MONTH_LAST_DAY = 0;
|
|
||||||
const PREVIOUS_DAY_OFFSET = -1;
|
|
||||||
const PREVIOUS_YEAR_OFFSET = 1;
|
|
||||||
const DATE_COMPONENT_DIGITS = 2;
|
|
||||||
|
|
||||||
const DAILY_BUCKET_MAX_DAYS = 45;
|
const DAILY_BUCKET_MAX_DAYS = 45;
|
||||||
const WEEKLY_BUCKET_MAX_DAYS = 180;
|
const WEEKLY_BUCKET_MAX_DAYS = 180;
|
||||||
const DAYS_PER_WEEK = 7;
|
const DAYS_PER_WEEK = 7;
|
||||||
const ISO_MONTH_LENGTH = 7;
|
const ISO_MONTH_LENGTH = 7;
|
||||||
const ISO_YEAR_LENGTH = 4;
|
const ISO_YEAR_LENGTH = 4;
|
||||||
|
|
||||||
export type IncomeBucketGranularity = 'day' | 'month' | 'week';
|
export type IncomeBucketGranularity = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
export function planIncomePeriod(
|
export function planIncomePeriod(
|
||||||
from: string | undefined,
|
from: string | undefined,
|
||||||
to: string | undefined,
|
to: string | undefined,
|
||||||
openedDate?: Date,
|
openedDate?: Date,
|
||||||
): {
|
): {
|
||||||
granularity: IncomeBucketGranularity;
|
|
||||||
previous: DateOnlyRange;
|
|
||||||
selected: DateOnlyRange;
|
selected: DateOnlyRange;
|
||||||
|
previous: DateOnlyRange;
|
||||||
yearAgo: DateOnlyRange | null;
|
yearAgo: DateOnlyRange | null;
|
||||||
|
granularity: IncomeBucketGranularity;
|
||||||
} {
|
} {
|
||||||
if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
|
if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
|
||||||
const today = moscowDate(new Date());
|
const today = moscowDate(new Date());
|
||||||
const selected =
|
const selected =
|
||||||
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
|
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
|
||||||
assertRange(selected, today);
|
assertRange(selected, today);
|
||||||
const previous = isFullMonth(selected) ?
|
const previous = isFullMonth(selected)
|
||||||
previousMonth(selected.from) :
|
? previousMonth(selected.from)
|
||||||
shiftDays(selected, -rangeDays(selected));
|
: shiftDays(selected, -rangeDays(selected));
|
||||||
const candidate = yearAgo(selected);
|
const candidate = yearAgo(selected);
|
||||||
const yearAgoRange =
|
const yearAgoRange =
|
||||||
candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) ?
|
candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
|
||||||
candidate :
|
? candidate
|
||||||
null;
|
: null;
|
||||||
return {
|
return {
|
||||||
selected,
|
selected,
|
||||||
previous,
|
previous,
|
||||||
yearAgo: yearAgoRange,
|
yearAgo: yearAgoRange,
|
||||||
granularity:
|
granularity:
|
||||||
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS ?
|
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
|
||||||
'day' :
|
? 'day'
|
||||||
rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS ?
|
: rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS
|
||||||
'week' :
|
? 'week'
|
||||||
'month',
|
: 'month',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,30 +55,29 @@ export function rangeBuckets(
|
|||||||
range: DateOnlyRange,
|
range: DateOnlyRange,
|
||||||
granularity: IncomeBucketGranularity,
|
granularity: IncomeBucketGranularity,
|
||||||
): DateOnlyRange[] {
|
): DateOnlyRange[] {
|
||||||
if (granularity === 'day') {
|
if (granularity === 'day')
|
||||||
return dates(range).map((date) => ({ from: date, to: date }));
|
return dates(range).map((date) => ({ from: date, to: date }));
|
||||||
}
|
|
||||||
const buckets: DateOnlyRange[] = [];
|
const buckets: DateOnlyRange[] = [];
|
||||||
let cursor =
|
let cursor =
|
||||||
granularity === 'week' ?
|
granularity === 'week'
|
||||||
addDays(
|
? addDays(
|
||||||
range.from,
|
range.from,
|
||||||
-(
|
-(
|
||||||
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) %
|
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - 1) %
|
||||||
DAYS_PER_WEEK
|
DAYS_PER_WEEK
|
||||||
),
|
),
|
||||||
) :
|
)
|
||||||
`${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
|
: `${range.from.slice(0, ISO_MONTH_LENGTH)}-01`;
|
||||||
while (cursor <= range.to) {
|
while (cursor <= range.to) {
|
||||||
const end =
|
const end =
|
||||||
granularity === 'week' ?
|
granularity === 'week'
|
||||||
addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) :
|
? addDays(cursor, DAYS_PER_WEEK - 1)
|
||||||
endOfMonth(cursor);
|
: endOfMonth(cursor);
|
||||||
buckets.push({
|
buckets.push({
|
||||||
from: cursor < range.from ? range.from : cursor,
|
from: cursor < range.from ? range.from : cursor,
|
||||||
to: end > range.to ? range.to : end,
|
to: end > range.to ? range.to : end,
|
||||||
});
|
});
|
||||||
cursor = addDays(end, NEXT_DAY_OFFSET);
|
cursor = addDays(end, 1);
|
||||||
}
|
}
|
||||||
return buckets;
|
return buckets;
|
||||||
}
|
}
|
||||||
@@ -97,7 +87,7 @@ export function rangeDays(range: DateOnlyRange): number {
|
|||||||
Math.round(
|
Math.round(
|
||||||
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
|
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
|
||||||
MILLISECONDS_PER_DAY,
|
MILLISECONDS_PER_DAY,
|
||||||
) + INCLUSIVE_DAY_OFFSET
|
) + 1
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,24 +104,23 @@ function assertRange(range: DateOnlyRange, today: string): void {
|
|||||||
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);
|
||||||
return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dates(range: DateOnlyRange): string[] {
|
function dates(range: DateOnlyRange): string[] {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
for (let date = range.from; date <= range.to; date = addDays(date, NEXT_DAY_OFFSET)) {
|
for (let date = range.from; date <= range.to; date = addDays(date, 1))
|
||||||
result.push(date);
|
result.push(date);
|
||||||
}
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstOfMonth(value: string): string {
|
function firstOfMonth(value: string): string {
|
||||||
return `${value.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
|
return `${value.slice(0, ISO_MONTH_LENGTH)}-01`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function endOfMonth(value: string): string {
|
function endOfMonth(value: string): string {
|
||||||
const [year, month] = dateParts(firstOfMonth(value));
|
const [year, month] = dateParts(firstOfMonth(value));
|
||||||
return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
return new Date(Date.UTC(year, month, 0)).toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFullMonth(range: DateOnlyRange): boolean {
|
function isFullMonth(range: DateOnlyRange): boolean {
|
||||||
@@ -141,7 +130,7 @@ function isFullMonth(range: DateOnlyRange): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function previousMonth(from: string): DateOnlyRange {
|
function previousMonth(from: string): DateOnlyRange {
|
||||||
const last = addDays(firstOfMonth(from), PREVIOUS_DAY_OFFSET);
|
const last = addDays(firstOfMonth(from), -1);
|
||||||
return { from: firstOfMonth(last), to: last };
|
return { from: firstOfMonth(last), to: last };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +141,10 @@ function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange {
|
|||||||
function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
|
function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
|
||||||
const shift = (value: string) => {
|
const shift = (value: string) => {
|
||||||
const [year, month, day] = dateParts(value);
|
const [year, month, day] = dateParts(value);
|
||||||
const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
|
const result = `${String(year - 1).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result ?
|
return utcDate(result).toISOString().slice(0, 10) === result
|
||||||
result :
|
? result
|
||||||
null;
|
: null;
|
||||||
};
|
};
|
||||||
const from = shift(range.from);
|
const from = shift(range.from);
|
||||||
const to = shift(range.to);
|
const to = shift(range.to);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { OperationsModule } from '../operations/operations.module';
|
import { OperationsModule } from '../operations/operations.module';
|
||||||
import { IncomeAnalyticsController } from './income-analytics.controller';
|
import { IncomeAnalyticsController } from './income-analytics.controller';
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export const MIN_INSTRUMENT_QUERY_LENGTH = 2;
|
|
||||||
export const MAX_INSTRUMENT_QUERY_LENGTH = 80;
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
jest.mock('../infrastructure/candle.repository', () => ({
|
|
||||||
CandleRepository: class {},
|
|
||||||
}));
|
|
||||||
jest.mock('./catalog-enrichment.service', () => ({
|
|
||||||
CatalogEnrichmentService: class {},
|
|
||||||
}));
|
|
||||||
jest.mock('../../../../integrations/tbank/tbank.api', () => ({
|
|
||||||
TbankAccessService: class {},
|
|
||||||
TbankClient: class {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { CandleService } from './candle.service';
|
|
||||||
|
|
||||||
describe('CandleService forced demand', () => {
|
|
||||||
it.each([false, true])(
|
|
||||||
'reconciles the full range through the latest forced timestamp while force=%s is loading',
|
|
||||||
async (initialForce) => {
|
|
||||||
let entered = () => {};
|
|
||||||
let release = () => {};
|
|
||||||
let complete = () => {};
|
|
||||||
const started = new Promise<void>((resolve) => (entered = resolve));
|
|
||||||
const blocked = new Promise<void>((resolve) => (release = resolve));
|
|
||||||
const finished = new Promise<void>((resolve) => (complete = resolve));
|
|
||||||
let completed = 0;
|
|
||||||
const work = { id: 'work', attemptId: 'attempt' };
|
|
||||||
const repository = {
|
|
||||||
reserve: jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce({ work, launch: true })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
work: { id: 'other', attemptId: 'other-attempt' },
|
|
||||||
launch: true,
|
|
||||||
})
|
|
||||||
.mockResolvedValue({ work, launch: false }),
|
|
||||||
read: jest.fn().mockResolvedValue({
|
|
||||||
coverage: [
|
|
||||||
{
|
|
||||||
from: new Date('2026-01-01Z'),
|
|
||||||
to: new Date('2026-02-01Z'),
|
|
||||||
isComplete: true,
|
|
||||||
fetchedAt: new Date('2026-02-01Z'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
stage: jest.fn().mockResolvedValue(true),
|
|
||||||
publish: jest.fn((_attempt, _intent, done = true) => {
|
|
||||||
if (done && ++completed === 2) complete();
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}),
|
|
||||||
fail: jest.fn(),
|
|
||||||
};
|
|
||||||
const sources = {
|
|
||||||
ensureDetail: jest.fn().mockResolvedValue({
|
|
||||||
first_1dayCandleDate: {
|
|
||||||
seconds: String(Date.parse('2026-01-01Z') / 1000),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const upstream = { getCandles: jest.fn().mockResolvedValue([]) };
|
|
||||||
upstream.getCandles.mockImplementationOnce(async () => {
|
|
||||||
entered();
|
|
||||||
await blocked;
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
const service = new CandleService(
|
|
||||||
repository as never,
|
|
||||||
sources as never,
|
|
||||||
{ tokenForAttempt: jest.fn().mockResolvedValue('token') } as never,
|
|
||||||
upstream as never,
|
|
||||||
{ event: jest.fn() } as never,
|
|
||||||
);
|
|
||||||
const input = {
|
|
||||||
uid: 'uid',
|
|
||||||
instrumentType: 'share' as const,
|
|
||||||
period: 'all' as const,
|
|
||||||
};
|
|
||||||
await service.request(
|
|
||||||
'owner',
|
|
||||||
'revision',
|
|
||||||
input,
|
|
||||||
new Date('2026-02-02T12:00:00Z'),
|
|
||||||
initialForce,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
await started;
|
|
||||||
const latest = new Date('2026-02-02T13:00:00Z');
|
|
||||||
await service.request(
|
|
||||||
'owner',
|
|
||||||
'revision',
|
|
||||||
{ ...input, uid: 'other' },
|
|
||||||
latest,
|
|
||||||
false,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
await service.request('owner', 'revision', input, latest, true, 0),
|
|
||||||
).toBe('work');
|
|
||||||
release();
|
|
||||||
await finished;
|
|
||||||
|
|
||||||
expect(upstream.getCandles).toHaveBeenCalledWith(
|
|
||||||
'token',
|
|
||||||
'uid',
|
|
||||||
new Date('2026-01-01Z'),
|
|
||||||
latest,
|
|
||||||
);
|
|
||||||
expect(repository.fail).not.toHaveBeenCalled();
|
|
||||||
expect(
|
|
||||||
upstream.getCandles.mock.calls.map((call: unknown[]) => call[1]),
|
|
||||||
).toEqual(['uid', 'uid', 'other']);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,56 +1,46 @@
|
|||||||
|
import { planInvestmentScreen } from '../../investment-sync-plan';
|
||||||
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { formatDecimal } from '../../../../common/decimal';
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
|
||||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||||
|
import { formatDecimal } from '../../../../common/decimal';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
type TbankShare,
|
type TbankShare,
|
||||||
type TbankSyncAttempt,
|
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import { planInvestmentScreen } from '../../investment-sync-plan';
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||||
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
import {
|
import {
|
||||||
candleGaps,
|
candleGaps,
|
||||||
type CandleRange,
|
|
||||||
candleWindows,
|
candleWindows,
|
||||||
utcDay,
|
utcDay,
|
||||||
|
type CandleRange,
|
||||||
} from '../domain/candle-coverage';
|
} from '../domain/candle-coverage';
|
||||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
|
||||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
|
||||||
import { timestamp } from './instrument-market';
|
import { timestamp } from './instrument-market';
|
||||||
|
|
||||||
const FIRST_BATCH_OFFSET = 0;
|
|
||||||
const BACKGROUND_SYNC_PRIORITY = 2;
|
|
||||||
const REPLACED_WINDOW_COUNT = 1;
|
|
||||||
const NEXT_WINDOW_OFFSET = 1;
|
|
||||||
|
|
||||||
type CandleInput = {
|
type CandleInput = {
|
||||||
instrumentType: 'bond' | 'share';
|
|
||||||
period: 'all' | 'halfYear' | 'month' | 'year';
|
|
||||||
range?: CandleRange;
|
|
||||||
uid: string;
|
uid: string;
|
||||||
|
instrumentType: 'share' | 'bond';
|
||||||
|
period: 'month' | 'halfYear' | 'year' | 'all';
|
||||||
|
range?: CandleRange;
|
||||||
};
|
};
|
||||||
type Producer = {
|
type Producer = {
|
||||||
attempt: TbankSyncAttempt;
|
|
||||||
force: boolean;
|
|
||||||
input: CandleInput;
|
input: CandleInput;
|
||||||
|
attempt: TbankSyncAttempt;
|
||||||
now: Date;
|
now: Date;
|
||||||
observers: TbankSyncAttempt[];
|
|
||||||
pass?: {
|
|
||||||
force: boolean;
|
force: boolean;
|
||||||
nextWindow: number;
|
|
||||||
now: Date;
|
|
||||||
ranges: CandleRange[];
|
|
||||||
};
|
|
||||||
priority: number;
|
priority: number;
|
||||||
|
observers: TbankSyncAttempt[];
|
||||||
|
ranges?: CandleRange[];
|
||||||
|
nextWindow?: number;
|
||||||
};
|
};
|
||||||
const months = { month: 1, halfYear: 6, year: 12 };
|
const months = { month: 1, halfYear: 6, year: 12 };
|
||||||
const tailFreshForMs = 60_000;
|
const tailFreshForMs = 60_000;
|
||||||
@@ -67,21 +57,19 @@ function rangeFor(
|
|||||||
const from = utcDay(now);
|
const from = utcDay(now);
|
||||||
const history = timestamp(instrument.first_1dayCandleDate);
|
const history = timestamp(instrument.first_1dayCandleDate);
|
||||||
if (input.period === 'all') {
|
if (input.period === 'all') {
|
||||||
if (!history) {
|
if (!history)
|
||||||
throw new ServiceUnavailableException({
|
throw new ServiceUnavailableException({
|
||||||
code:
|
code:
|
||||||
input.instrumentType === 'share' ?
|
input.instrumentType === 'share'
|
||||||
'STOCK_HISTORY_START_UNAVAILABLE' :
|
? 'STOCK_HISTORY_START_UNAVAILABLE'
|
||||||
'BOND_HISTORY_START_UNAVAILABLE',
|
: 'BOND_HISTORY_START_UNAVAILABLE',
|
||||||
});
|
});
|
||||||
}
|
|
||||||
from.setTime(utcDay(new Date(history)).valueOf());
|
from.setTime(utcDay(new Date(history)).valueOf());
|
||||||
} else {
|
} else {
|
||||||
from.setUTCMonth(from.getUTCMonth() - months[input.period]);
|
from.setUTCMonth(from.getUTCMonth() - months[input.period]);
|
||||||
if (history && Date.parse(history) > from.valueOf()) {
|
if (history && Date.parse(history) > from.valueOf())
|
||||||
from.setTime(utcDay(new Date(history)).valueOf());
|
from.setTime(utcDay(new Date(history)).valueOf());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) };
|
return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) };
|
||||||
}
|
}
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -110,18 +98,16 @@ export class CandleService {
|
|||||||
true,
|
true,
|
||||||
['detail'],
|
['detail'],
|
||||||
);
|
);
|
||||||
if (detail.meta.availability === 'complete' && !detail.instrument) {
|
if (detail.meta.availability === 'complete' && !detail.instrument)
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const range = detail.instrument ?
|
const range = detail.instrument
|
||||||
rangeFor(input, detail.instrument, now) :
|
? rangeFor(input, detail.instrument, now)
|
||||||
null;
|
: null;
|
||||||
const saved = range ? await this.repository.read(input.uid, range) : null;
|
const saved = range ? await this.repository.read(input.uid, range) : null;
|
||||||
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
|
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
|
||||||
if (!observe && (!saved || gaps.length)) {
|
if (!observe && (!saved || gaps.length))
|
||||||
await this.request(userId, revision, input, now);
|
await this.request(userId, revision, input, now);
|
||||||
}
|
|
||||||
const work = await this.repository.work(userId, revision, scope(input));
|
const work = await this.repository.work(userId, revision, scope(input));
|
||||||
const fetchedAt =
|
const fetchedAt =
|
||||||
saved?.coverage
|
saved?.coverage
|
||||||
@@ -139,14 +125,14 @@ export class CandleService {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
data:
|
data:
|
||||||
!saved || !covered ?
|
!saved || !covered
|
||||||
null :
|
? null
|
||||||
{
|
: {
|
||||||
candles: saved.data,
|
candles: saved.data,
|
||||||
currency:
|
currency:
|
||||||
input.instrumentType === 'share' ?
|
input.instrumentType === 'share'
|
||||||
detail.instrument?.currency?.toUpperCase() || null :
|
? detail.instrument?.currency?.toUpperCase() || null
|
||||||
null,
|
: null,
|
||||||
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
|
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
|
||||||
observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
|
observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
|
||||||
},
|
},
|
||||||
@@ -159,8 +145,8 @@ export class CandleService {
|
|||||||
work,
|
work,
|
||||||
coverage: {
|
coverage: {
|
||||||
requested: range ? [publicRange(range)] : [],
|
requested: range ? [publicRange(range)] : [],
|
||||||
completeRanges: range ?
|
completeRanges: range
|
||||||
completeCoverage.flatMap((row) => {
|
? completeCoverage.flatMap((row) => {
|
||||||
const from = new Date(
|
const from = new Date(
|
||||||
Math.max(row.from.valueOf(), range.from.valueOf()),
|
Math.max(row.from.valueOf(), range.from.valueOf()),
|
||||||
);
|
);
|
||||||
@@ -168,22 +154,22 @@ export class CandleService {
|
|||||||
Math.min(row.to.valueOf(), range.to.valueOf()),
|
Math.min(row.to.valueOf(), range.to.valueOf()),
|
||||||
);
|
);
|
||||||
return from < to ? [publicRange({ from, to })] : [];
|
return from < to ? [publicRange({ from, to })] : [];
|
||||||
}) :
|
})
|
||||||
[],
|
: [],
|
||||||
missingRanges: missing.map(publicRange),
|
missingRanges: missing.map(publicRange),
|
||||||
asOf: fetchedAt?.toISOString() ?? null,
|
asOf: fetchedAt?.toISOString() ?? null,
|
||||||
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ?
|
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate)
|
||||||
'known' :
|
? 'known'
|
||||||
'unknown',
|
: 'unknown',
|
||||||
historyState: complete ?
|
historyState: complete
|
||||||
'complete' :
|
? 'complete'
|
||||||
work?.state === 'failed' ?
|
: work?.state === 'failed'
|
||||||
'failed' :
|
? 'failed'
|
||||||
work?.state === 'running' ?
|
: work?.state === 'running'
|
||||||
'loading' :
|
? 'loading'
|
||||||
covered ?
|
: covered
|
||||||
'partial' :
|
? 'partial'
|
||||||
'missing',
|
: 'missing',
|
||||||
},
|
},
|
||||||
dependencies: [
|
dependencies: [
|
||||||
detail.meta,
|
detail.meta,
|
||||||
@@ -193,9 +179,9 @@ export class CandleService {
|
|||||||
dataRevision: String(row.dataRevision),
|
dataRevision: String(row.dataRevision),
|
||||||
availability: 'complete',
|
availability: 'complete',
|
||||||
fetchedAt: row.fetchedAt,
|
fetchedAt: row.fetchedAt,
|
||||||
freshForMs: row.isComplete ?
|
freshForMs: row.isComplete
|
||||||
Number.POSITIVE_INFINITY :
|
? Number.POSITIVE_INFINITY
|
||||||
tailFreshForMs,
|
: tailFreshForMs,
|
||||||
work: null,
|
work: null,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -207,14 +193,13 @@ export class CandleService {
|
|||||||
prefetch(
|
prefetch(
|
||||||
userId: string,
|
userId: string,
|
||||||
revision: string,
|
revision: string,
|
||||||
positions: { instrumentUid?: null | string; type: string }[],
|
positions: { instrumentUid?: string | null; type: string }[],
|
||||||
) {
|
) {
|
||||||
const key = JSON.stringify([
|
const key = JSON.stringify([
|
||||||
userId,
|
userId,
|
||||||
revision,
|
revision,
|
||||||
positions
|
positions
|
||||||
.map((position) => [position.instrumentUid, position.type])
|
.map((position) => [position.instrumentUid, position.type])
|
||||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
|
||||||
.sort(),
|
.sort(),
|
||||||
]);
|
]);
|
||||||
const existing = this.prefetches.get(key);
|
const existing = this.prefetches.get(key);
|
||||||
@@ -229,14 +214,14 @@ export class CandleService {
|
|||||||
private async prefetchPositions(
|
private async prefetchPositions(
|
||||||
userId: string,
|
userId: string,
|
||||||
revision: string,
|
revision: string,
|
||||||
positions: { instrumentUid?: null | string; type: string }[],
|
positions: { instrumentUid?: string | null; type: string }[],
|
||||||
) {
|
) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const unique = new Map(
|
const unique = new Map(
|
||||||
positions
|
positions
|
||||||
.filter(
|
.filter(
|
||||||
(position) =>
|
(position) =>
|
||||||
position.instrumentUid && ['BOND', 'STOCK'].includes(position.type),
|
position.instrumentUid && ['STOCK', 'BOND'].includes(position.type),
|
||||||
)
|
)
|
||||||
.map((position) => [position.instrumentUid ?? '', position]),
|
.map((position) => [position.instrumentUid ?? '', position]),
|
||||||
);
|
);
|
||||||
@@ -251,7 +236,7 @@ export class CandleService {
|
|||||||
)
|
)
|
||||||
.map(([uid]) => uid);
|
.map(([uid]) => uid);
|
||||||
for (
|
for (
|
||||||
let offset = FIRST_BATCH_OFFSET;
|
let offset = 0;
|
||||||
offset < uids.length;
|
offset < uids.length;
|
||||||
offset += prefetchReadBatchSize
|
offset += prefetchReadBatchSize
|
||||||
) {
|
) {
|
||||||
@@ -278,7 +263,7 @@ export class CandleService {
|
|||||||
instruments: [
|
instruments: [
|
||||||
{
|
{
|
||||||
uid,
|
uid,
|
||||||
type: position.type as 'BOND' | 'STOCK',
|
type: position.type as 'STOCK' | 'BOND',
|
||||||
historyFrom: history ? new Date(history) : null,
|
historyFrom: history ? new Date(history) : null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -291,9 +276,9 @@ export class CandleService {
|
|||||||
) {
|
) {
|
||||||
const input = { uid, instrumentType, period: 'all' as const };
|
const input = { uid, instrumentType, period: 'all' as const };
|
||||||
const range =
|
const range =
|
||||||
detail?.instrument && history ?
|
detail?.instrument && history
|
||||||
rangeFor(input, detail.instrument, now) :
|
? rangeFor(input, detail.instrument, now)
|
||||||
null;
|
: null;
|
||||||
if (
|
if (
|
||||||
!range ||
|
!range ||
|
||||||
candleGaps(
|
candleGaps(
|
||||||
@@ -301,9 +286,8 @@ export class CandleService {
|
|||||||
coverage.filter((row) => row.instrumentUid === uid),
|
coverage.filter((row) => row.instrumentUid === uid),
|
||||||
now,
|
now,
|
||||||
).length
|
).length
|
||||||
) {
|
)
|
||||||
await this.request(userId, revision, input, now, false, BACKGROUND_SYNC_PRIORITY);
|
await this.request(userId, revision, input, now, false, 2);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,14 +323,9 @@ export class CandleService {
|
|||||||
id: reserved.work.id,
|
id: reserved.work.id,
|
||||||
attemptId: reserved.work.attemptId,
|
attemptId: reserved.work.attemptId,
|
||||||
};
|
};
|
||||||
const producer = this.producers.get(scope(input));
|
|
||||||
if (producer) {
|
|
||||||
producer.force ||= force;
|
|
||||||
producer.priority = Math.min(producer.priority, priority);
|
|
||||||
if (force && now > producer.now) producer.now = now;
|
|
||||||
}
|
|
||||||
if (reserved.launch) {
|
if (reserved.launch) {
|
||||||
this.active.add(attempt.attemptId);
|
this.active.add(attempt.attemptId);
|
||||||
|
const producer = this.producers.get(scope(input));
|
||||||
if (producer) producer.observers.push(attempt);
|
if (producer) producer.observers.push(attempt);
|
||||||
else {
|
else {
|
||||||
const next = { input, attempt, now, force, priority, observers: [] };
|
const next = { input, attempt, now, force, priority, observers: [] };
|
||||||
@@ -367,7 +346,7 @@ export class CandleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async load(producer: Producer) {
|
private async load(producer: Producer) {
|
||||||
const { input, attempt } = producer;
|
const { input, attempt, now } = producer;
|
||||||
const instrument = await this.sources.ensureDetail(
|
const instrument = await this.sources.ensureDetail(
|
||||||
attempt.userId,
|
attempt.userId,
|
||||||
attempt.revision,
|
attempt.revision,
|
||||||
@@ -375,31 +354,25 @@ export class CandleService {
|
|||||||
input.uid,
|
input.uid,
|
||||||
);
|
);
|
||||||
if (!instrument) throw new NotFoundException();
|
if (!instrument) throw new NotFoundException();
|
||||||
let pass = producer.pass;
|
|
||||||
if (!pass || (producer.force && (!pass.force || producer.now > pass.now))) {
|
|
||||||
const { now, force } = producer;
|
|
||||||
const range = rangeFor(input, instrument, now);
|
const range = rangeFor(input, instrument, now);
|
||||||
|
if (!producer.ranges) {
|
||||||
const saved = await this.repository.read(input.uid, range);
|
const saved = await this.repository.read(input.uid, range);
|
||||||
pass = producer.pass = {
|
producer.ranges = (
|
||||||
now,
|
producer.force ? [range] : candleGaps(range, saved.coverage, now)
|
||||||
force,
|
)
|
||||||
ranges: (force ? [range] : candleGaps(range, saved.coverage, now))
|
|
||||||
.flatMap(candleWindows)
|
.flatMap(candleWindows)
|
||||||
.reverse(),
|
.reverse();
|
||||||
nextWindow: 0,
|
producer.nextWindow = 0;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const { ranges, now, force } = pass;
|
const ranges = producer.ranges;
|
||||||
const index = pass.nextWindow;
|
const index = producer.nextWindow ?? 0;
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
|
|
||||||
let window = ranges[index];
|
let window = ranges[index];
|
||||||
if (window && !force) {
|
if (window && !producer.force) {
|
||||||
const latest = await this.repository.read(input.uid, window);
|
const latest = await this.repository.read(input.uid, window);
|
||||||
const gaps = candleGaps(window, latest.coverage, now)
|
const gaps = candleGaps(window, latest.coverage, now)
|
||||||
.flatMap(candleWindows)
|
.flatMap(candleWindows)
|
||||||
.reverse();
|
.reverse();
|
||||||
ranges.splice(index, REPLACED_WINDOW_COUNT, ...gaps);
|
ranges.splice(index, 1, ...gaps);
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
|
|
||||||
window = ranges[index];
|
window = ranges[index];
|
||||||
}
|
}
|
||||||
if (window) {
|
if (window) {
|
||||||
@@ -422,23 +395,24 @@ export class CandleService {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
if (!(await this.repository.stage(attempt, window, rows, now))) {
|
if (!(await this.repository.stage(attempt, window, rows, now)))
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
!force &&
|
!producer.force &&
|
||||||
!(await this.repository.publish(
|
!(await this.repository.publish(
|
||||||
attempt,
|
attempt,
|
||||||
{ uid: input.uid, ranges: [window] },
|
{ uid: input.uid, ranges: [window] },
|
||||||
false,
|
false,
|
||||||
))
|
))
|
||||||
) {
|
)
|
||||||
return false;
|
return false;
|
||||||
|
producer.nextWindow = index + 1;
|
||||||
|
if (producer.nextWindow < ranges.length) return null;
|
||||||
}
|
}
|
||||||
pass.nextWindow = index + NEXT_WINDOW_OFFSET;
|
return this.repository.publish(attempt, {
|
||||||
if (pass.nextWindow < ranges.length) return null;
|
uid: input.uid,
|
||||||
}
|
ranges: producer.force ? ranges : [],
|
||||||
return true;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async drain() {
|
private async drain() {
|
||||||
@@ -457,37 +431,18 @@ export class CandleService {
|
|||||||
}
|
}
|
||||||
success = result;
|
success = result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
await this.access.rejectMembershipToken(producer.attempt);
|
await this.access.rejectMembershipToken(producer.attempt);
|
||||||
}
|
|
||||||
if (error instanceof ServiceUnavailableException) {
|
if (error instanceof ServiceUnavailableException) {
|
||||||
const response = error.getResponse();
|
const response = error.getResponse();
|
||||||
if (
|
if (
|
||||||
typeof response === 'object' &&
|
typeof response === 'object' &&
|
||||||
'code' in response &&
|
'code' in response &&
|
||||||
typeof response.code === 'string'
|
typeof response.code === 'string'
|
||||||
) {
|
)
|
||||||
code = response.code;
|
code = response.code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Seal publication with reservations so a late forced join cannot be
|
|
||||||
// acknowledged after the final demand check but before work completion.
|
|
||||||
const finished = this.reservations.then(async () => {
|
|
||||||
const pass = producer.pass;
|
|
||||||
if (success && pass) {
|
|
||||||
if (producer.force && (!pass.force || producer.now > pass.now)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
success = await this.repository.publish(producer.attempt, {
|
|
||||||
uid: producer.input.uid,
|
|
||||||
ranges: pass.force ? pass.ranges : [],
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
success = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!success) await this.repository.fail(producer.attempt, code);
|
if (!success) await this.repository.fail(producer.attempt, code);
|
||||||
this.active.delete(producer.attempt.attemptId);
|
this.active.delete(producer.attempt.attemptId);
|
||||||
for (const observer of producer.observers) {
|
for (const observer of producer.observers) {
|
||||||
@@ -497,30 +452,21 @@ export class CandleService {
|
|||||||
observer,
|
observer,
|
||||||
producer.attempt,
|
producer.attempt,
|
||||||
))
|
))
|
||||||
) {
|
)
|
||||||
await this.repository.fail(observer, code);
|
await this.repository.fail(observer, code);
|
||||||
}
|
|
||||||
this.active.delete(observer.attemptId);
|
this.active.delete(observer.attemptId);
|
||||||
}
|
}
|
||||||
this.producers.delete(scope(producer.input));
|
this.producers.delete(scope(producer.input));
|
||||||
return true;
|
if (success && !producer.force && producer.input.period !== 'all')
|
||||||
});
|
|
||||||
this.reservations = finished.catch(() => undefined);
|
|
||||||
if (!(await finished)) {
|
|
||||||
this.queue.push(producer);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (success && !producer.force && producer.input.period !== 'all') {
|
|
||||||
await this.request(
|
await this.request(
|
||||||
producer.attempt.userId,
|
producer.attempt.userId,
|
||||||
producer.attempt.revision,
|
producer.attempt.revision,
|
||||||
{ ...producer.input, period: 'all' },
|
{ ...producer.input, period: 'all' },
|
||||||
producer.now,
|
producer.now,
|
||||||
false,
|
false,
|
||||||
BACKGROUND_SYNC_PRIORITY,
|
2,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.event('error', 'candles.persistence-failed', {
|
this.logger.event('error', 'candles.persistence-failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
|||||||
@@ -1,42 +1,37 @@
|
|||||||
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||||
import {
|
import {
|
||||||
ConflictException,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
tbankSourceNeedsRefresh,
|
tbankSourceNeedsRefresh,
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
type TbankSyncAttempt,
|
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import {
|
import {
|
||||||
CatalogDiscoveryRepository,
|
CatalogDiscoveryRepository,
|
||||||
catalogQuery,
|
catalogQuery,
|
||||||
type CatalogQuery,
|
|
||||||
catalogScope,
|
catalogScope,
|
||||||
|
type CatalogQuery,
|
||||||
} from '../infrastructure/catalog-discovery.repository';
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
|
||||||
import {
|
import {
|
||||||
catalogSearchLimit,
|
catalogSearchLimit,
|
||||||
selectCatalogInstruments,
|
selectCatalogInstruments,
|
||||||
} from './catalog-selection';
|
} from './catalog-selection';
|
||||||
|
|
||||||
const UNAMBIGUOUS_MATCH_COUNT = 1;
|
|
||||||
const NO_MATCHES = 0;
|
|
||||||
const FIRST_RESULT_OFFSET = 0;
|
|
||||||
|
|
||||||
const tickerAmbiguityLimit = 2;
|
const tickerAmbiguityLimit = 2;
|
||||||
|
|
||||||
type Producer = {
|
type Producer = {
|
||||||
attempt: TbankSyncAttempt;
|
|
||||||
force: boolean;
|
|
||||||
input: CatalogQuery;
|
input: CatalogQuery;
|
||||||
|
attempt: TbankSyncAttempt;
|
||||||
observers: TbankSyncAttempt[];
|
observers: TbankSyncAttempt[];
|
||||||
|
force: boolean;
|
||||||
};
|
};
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CatalogDiscoveryService {
|
export class CatalogDiscoveryService {
|
||||||
@@ -48,9 +43,8 @@ export class CatalogDiscoveryService {
|
|||||||
private running = false;
|
private running = false;
|
||||||
private readonly completions = new Map<
|
private readonly completions = new Map<
|
||||||
string,
|
string,
|
||||||
{ done: Promise<void>; resolve: () => void; workId: string }
|
{ workId: string; done: Promise<void>; resolve: () => void }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly repository: CatalogDiscoveryRepository,
|
private readonly repository: CatalogDiscoveryRepository,
|
||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
@@ -79,14 +73,14 @@ export class CatalogDiscoveryService {
|
|||||||
|
|
||||||
async search(userId: string, input: CatalogQuery, observe = false) {
|
async search(userId: string, input: CatalogQuery, observe = false) {
|
||||||
const discovery = await this.read(userId, input, observe);
|
const discovery = await this.read(userId, input, observe);
|
||||||
const sources = discovery.data ?
|
const sources = discovery.data
|
||||||
await this.enrichment.readMany(
|
? await this.enrichment.readMany(
|
||||||
userId,
|
userId,
|
||||||
input.instrumentType,
|
input.instrumentType,
|
||||||
discovery.data.map((item) => item.uid),
|
discovery.data.map((item) => item.uid),
|
||||||
observe,
|
observe,
|
||||||
) :
|
)
|
||||||
[];
|
: [];
|
||||||
const dependencies = [
|
const dependencies = [
|
||||||
discovery.meta,
|
discovery.meta,
|
||||||
...sources.map((source) => source.meta),
|
...sources.map((source) => source.meta),
|
||||||
@@ -114,22 +108,22 @@ export class CatalogDiscoveryService {
|
|||||||
.map((dependency) => dependency.dataRevision)
|
.map((dependency) => dependency.dataRevision)
|
||||||
.join('/'),
|
.join('/'),
|
||||||
availability:
|
availability:
|
||||||
discovery.data === null ?
|
discovery.data === null
|
||||||
'missing' :
|
? 'missing'
|
||||||
sources.every((source) => source.meta.availability === 'complete') ?
|
: sources.every((source) => source.meta.availability === 'complete')
|
||||||
'complete' :
|
? 'complete'
|
||||||
'partial',
|
: 'partial',
|
||||||
fetchedAt: discovery.meta.lastSuccessfulAt ?
|
fetchedAt: discovery.meta.lastSuccessfulAt
|
||||||
new Date(discovery.meta.lastSuccessfulAt) :
|
? new Date(discovery.meta.lastSuccessfulAt)
|
||||||
null,
|
: null,
|
||||||
freshForMs: tbankSourceTtlMs.catalog,
|
freshForMs: tbankSourceTtlMs.catalog,
|
||||||
work: discovery.meta.sync.id ?
|
work: discovery.meta.sync.id
|
||||||
{
|
? {
|
||||||
id: discovery.meta.sync.id,
|
id: discovery.meta.sync.id,
|
||||||
state: discovery.meta.sync.state,
|
state: discovery.meta.sync.state,
|
||||||
errorCode: discovery.meta.sync.errorCode,
|
errorCode: discovery.meta.sync.errorCode,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
dependencies,
|
dependencies,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -137,7 +131,7 @@ export class CatalogDiscoveryService {
|
|||||||
|
|
||||||
async detail(
|
async detail(
|
||||||
userId: string,
|
userId: string,
|
||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'share' | 'bond',
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
observe = false,
|
||||||
) {
|
) {
|
||||||
@@ -149,24 +143,22 @@ export class CatalogDiscoveryService {
|
|||||||
['detail'],
|
['detail'],
|
||||||
);
|
);
|
||||||
const source = sources[0];
|
const source = sources[0];
|
||||||
if (source.meta.availability === 'complete' && !source.instrument) {
|
if (source.meta.availability === 'complete' && !source.instrument)
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
|
||||||
const instrument = source.instrument;
|
const instrument = source.instrument;
|
||||||
let slug = uid;
|
let slug = uid;
|
||||||
const dependencies = [source.meta];
|
const dependencies = [source.meta];
|
||||||
if (
|
if (
|
||||||
instrument?.ticker &&
|
instrument?.ticker &&
|
||||||
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
|
/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(instrument.ticker)
|
||||||
) {
|
) {
|
||||||
const alias = await this.read(
|
const alias = await this.read(
|
||||||
userId,
|
userId,
|
||||||
{ instrumentType, query: instrument.ticker, exactTicker: true },
|
{ instrumentType, query: instrument.ticker, exactTicker: true },
|
||||||
observe,
|
observe,
|
||||||
);
|
);
|
||||||
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
|
if (alias.data?.length === 1 && alias.data[0].uid === uid)
|
||||||
slug = instrument.ticker.toUpperCase();
|
slug = instrument.ticker.toUpperCase();
|
||||||
}
|
|
||||||
dependencies.push(alias.meta);
|
dependencies.push(alias.meta);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -177,17 +169,17 @@ export class CatalogDiscoveryService {
|
|||||||
.map((dependency) => dependency.dataRevision)
|
.map((dependency) => dependency.dataRevision)
|
||||||
.join('/'),
|
.join('/'),
|
||||||
availability: source.meta.availability,
|
availability: source.meta.availability,
|
||||||
fetchedAt: source.meta.lastSuccessfulAt ?
|
fetchedAt: source.meta.lastSuccessfulAt
|
||||||
new Date(source.meta.lastSuccessfulAt) :
|
? new Date(source.meta.lastSuccessfulAt)
|
||||||
null,
|
: null,
|
||||||
freshForMs: tbankSourceTtlMs.metadata,
|
freshForMs: tbankSourceTtlMs.metadata,
|
||||||
work: source.meta.sync.id ?
|
work: source.meta.sync.id
|
||||||
{
|
? {
|
||||||
id: source.meta.sync.id,
|
id: source.meta.sync.id,
|
||||||
state: source.meta.sync.state,
|
state: source.meta.sync.state,
|
||||||
errorCode: source.meta.sync.errorCode,
|
errorCode: source.meta.sync.errorCode,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
dependencies,
|
dependencies,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -195,7 +187,7 @@ export class CatalogDiscoveryService {
|
|||||||
|
|
||||||
async byTicker(
|
async byTicker(
|
||||||
userId: string,
|
userId: string,
|
||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'share' | 'bond',
|
||||||
ticker: string,
|
ticker: string,
|
||||||
observe = false,
|
observe = false,
|
||||||
) {
|
) {
|
||||||
@@ -204,40 +196,39 @@ export class CatalogDiscoveryService {
|
|||||||
{ instrumentType, query: ticker, exactTicker: true },
|
{ instrumentType, query: ticker, exactTicker: true },
|
||||||
observe,
|
observe,
|
||||||
);
|
);
|
||||||
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
|
if (lookup.data?.length === 0) throw new NotFoundException();
|
||||||
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
|
if (lookup.data && lookup.data.length > 1)
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code:
|
code:
|
||||||
instrumentType === 'share' ?
|
instrumentType === 'share'
|
||||||
'STOCK_TICKER_AMBIGUOUS' :
|
? 'STOCK_TICKER_AMBIGUOUS'
|
||||||
'BOND_TICKER_AMBIGUOUS',
|
: 'BOND_TICKER_AMBIGUOUS',
|
||||||
});
|
});
|
||||||
}
|
const result = lookup.data
|
||||||
const result = lookup.data ?
|
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
|
||||||
await this.detail(userId, instrumentType, lookup.data[0].uid, observe) :
|
: null;
|
||||||
null;
|
|
||||||
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
|
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
|
||||||
return {
|
return {
|
||||||
data: result?.data ?
|
data: result?.data
|
||||||
{ ...result.data, slug: ticker.trim().toUpperCase() } :
|
? { ...result.data, slug: ticker.trim().toUpperCase() }
|
||||||
null,
|
: null,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
|
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
|
||||||
dataRevision: dependencies
|
dataRevision: dependencies
|
||||||
.map((dependency) => dependency.dataRevision)
|
.map((dependency) => dependency.dataRevision)
|
||||||
.join('/'),
|
.join('/'),
|
||||||
availability: result?.meta.availability ?? 'missing',
|
availability: result?.meta.availability ?? 'missing',
|
||||||
fetchedAt: lookup.meta.lastSuccessfulAt ?
|
fetchedAt: lookup.meta.lastSuccessfulAt
|
||||||
new Date(lookup.meta.lastSuccessfulAt) :
|
? new Date(lookup.meta.lastSuccessfulAt)
|
||||||
null,
|
: null,
|
||||||
freshForMs: tbankSourceTtlMs.metadata,
|
freshForMs: tbankSourceTtlMs.metadata,
|
||||||
work: lookup.meta.sync.id ?
|
work: lookup.meta.sync.id
|
||||||
{
|
? {
|
||||||
id: lookup.meta.sync.id,
|
id: lookup.meta.sync.id,
|
||||||
state: lookup.meta.sync.state,
|
state: lookup.meta.sync.state,
|
||||||
errorCode: lookup.meta.sync.errorCode,
|
errorCode: lookup.meta.sync.errorCode,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
dependencies,
|
dependencies,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -254,9 +245,8 @@ export class CatalogDiscoveryService {
|
|||||||
const saved = await this.repository.read(input);
|
const saved = await this.repository.read(input);
|
||||||
if (
|
if (
|
||||||
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
|
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
|
||||||
) {
|
)
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
const reservation = await this.repository.reserve(
|
const reservation = await this.repository.reserve(
|
||||||
{ userId, revision },
|
{ userId, revision },
|
||||||
input,
|
input,
|
||||||
@@ -301,13 +291,12 @@ export class CatalogDiscoveryService {
|
|||||||
|
|
||||||
async refresh(userId: string, revision: string, query: CatalogQuery) {
|
async refresh(userId: string, revision: string, query: CatalogQuery) {
|
||||||
const id = await this.request(userId, revision, query, true);
|
const id = await this.request(userId, revision, query, true);
|
||||||
if (id) {
|
if (id)
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
[...this.completions.values()]
|
[...this.completions.values()]
|
||||||
.filter((entry) => entry.workId === id)
|
.filter((entry) => entry.workId === id)
|
||||||
.map((entry) => entry.done),
|
.map((entry) => entry.done),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
const saved = await this.repository.read(catalogQuery(query));
|
const saved = await this.repository.read(catalogQuery(query));
|
||||||
const sources = await this.enrichment.readMany(
|
const sources = await this.enrichment.readMany(
|
||||||
userId,
|
userId,
|
||||||
@@ -334,41 +323,40 @@ export class CatalogDiscoveryService {
|
|||||||
const token = await this.access.tokenForAttempt(attempt);
|
const token = await this.access.tokenForAttempt(attempt);
|
||||||
if (token) {
|
if (token) {
|
||||||
const found =
|
const found =
|
||||||
input.instrumentType === 'share' ?
|
input.instrumentType === 'share'
|
||||||
await this.tbank.findShares(token, input.query) :
|
? await this.tbank.findShares(token, input.query)
|
||||||
await this.tbank.findBonds(token, input.query);
|
: await this.tbank.findBonds(token, input.query);
|
||||||
success = await this.repository.publish(
|
success = await this.repository.publish(
|
||||||
attempt,
|
attempt,
|
||||||
input,
|
input,
|
||||||
selectCatalogInstruments(
|
selectCatalogInstruments(
|
||||||
input.exactTicker ?
|
input.exactTicker
|
||||||
found.filter(
|
? found.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.ticker?.trim().toUpperCase() === input.query,
|
item.ticker?.trim().toUpperCase() === input.query,
|
||||||
) :
|
)
|
||||||
found,
|
: found,
|
||||||
input.query,
|
input.query,
|
||||||
input.instrumentType,
|
input.instrumentType,
|
||||||
).slice(
|
).slice(
|
||||||
FIRST_RESULT_OFFSET,
|
0,
|
||||||
input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit,
|
input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit,
|
||||||
),
|
),
|
||||||
new Date(),
|
new Date(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
await this.access.rejectMembershipToken(attempt);
|
await this.access.rejectMembershipToken(attempt);
|
||||||
} else {
|
else
|
||||||
this.logger.event('warn', 'catalog-discovery.failed', {
|
this.logger.event('warn', 'catalog-discovery.failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: 'TBANK_UNAVAILABLE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (success) {
|
if (success) {
|
||||||
const saved = await this.repository.read(input);
|
const saved = await this.repository.read(input);
|
||||||
for (const observer of [attempt, ...producer.observers]) {
|
for (const observer of [attempt, ...producer.observers])
|
||||||
if (!input.exactTicker || saved.data?.length === UNAMBIGUOUS_MATCH_COUNT) {
|
if (!input.exactTicker || saved.data?.length === 1)
|
||||||
await this.enrichment.requestMany(
|
await this.enrichment.requestMany(
|
||||||
observer.userId,
|
observer.userId,
|
||||||
observer.revision,
|
observer.revision,
|
||||||
@@ -378,8 +366,6 @@ export class CatalogDiscoveryService {
|
|||||||
input.exactTicker ? ['detail'] : ['last-price', 'detail'],
|
input.exactTicker ? ['detail'] : ['last-price', 'detail'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
this.producers.delete(catalogScope(input));
|
this.producers.delete(catalogScope(input));
|
||||||
for (const observer of [attempt, ...producer.observers]) {
|
for (const observer of [attempt, ...producer.observers]) {
|
||||||
await this.repository.finish(observer, success);
|
await this.repository.finish(observer, success);
|
||||||
|
|||||||
@@ -1,51 +1,49 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import type { TbankDividend } from '../../../../integrations/tbank/tbank.api';
|
||||||
import { randomUUID } from 'node:crypto';
|
import type { MoexBondEvent } from '../../../../integrations/moex/moex.api';
|
||||||
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
|
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||||
import {
|
import {
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
divide,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
} from '../../../../common/decimal';
|
} from '../../../../common/decimal';
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
import {
|
import {
|
||||||
MILLISECONDS_PER_DAY,
|
MILLISECONDS_PER_DAY,
|
||||||
MILLISECONDS_PER_SECOND,
|
MILLISECONDS_PER_SECOND,
|
||||||
} from '../../../../common/time';
|
} from '../../../../common/time';
|
||||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
import { MoexClient } from '../../../../integrations/moex/moex.api';
|
||||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import type {
|
||||||
|
TbankCandle,
|
||||||
|
TbankFundamentals,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { TbankCoupon } from '../../../../integrations/tbank/tbank-market.types';
|
||||||
|
import { previousClose, timestamp } from './instrument-market';
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankBond,
|
|
||||||
type TbankCandle,
|
|
||||||
TbankClient,
|
TbankClient,
|
||||||
type TbankCoupon,
|
|
||||||
type TbankDividend,
|
|
||||||
type TbankFundamentals,
|
|
||||||
type TbankLastPrice,
|
|
||||||
type TbankShare,
|
|
||||||
tbankSourceNeedsRefresh,
|
tbankSourceNeedsRefresh,
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
type TbankSyncAttempt,
|
type TbankBond,
|
||||||
|
type TbankShare,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { TbankLastPrice } from '../../../../integrations/tbank/tbank-market.types';
|
||||||
|
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
|
||||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
|
||||||
import {
|
import {
|
||||||
CatalogDiscoveryRepository,
|
CatalogDiscoveryRepository,
|
||||||
catalogScope,
|
catalogScope,
|
||||||
type CatalogSource,
|
type CatalogSource,
|
||||||
} from '../infrastructure/catalog-discovery.repository';
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
import { catalogSearchLimit } from './catalog-selection';
|
import { catalogSearchLimit } from './catalog-selection';
|
||||||
import { previousClose, timestamp } from './instrument-market';
|
|
||||||
|
|
||||||
const MISSING_DATA_REVISION = 0;
|
|
||||||
const FIRST_QUEUE_INDEX = 0;
|
|
||||||
const DEQUEUED_ITEM_COUNT = 1;
|
|
||||||
|
|
||||||
type Producer = {
|
type Producer = {
|
||||||
attempt: TbankSyncAttempt;
|
|
||||||
input: CatalogSource;
|
input: CatalogSource;
|
||||||
|
attempt: TbankSyncAttempt;
|
||||||
observers: { attempt: TbankSyncAttempt; input: CatalogSource }[];
|
observers: { attempt: TbankSyncAttempt; input: CatalogSource }[];
|
||||||
};
|
};
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -58,13 +56,13 @@ export class CatalogEnrichmentService {
|
|||||||
private running = false;
|
private running = false;
|
||||||
private readonly completions = new Map<
|
private readonly completions = new Map<
|
||||||
string,
|
string,
|
||||||
{ done: Promise<void>; resolve: () => void; workId: string }
|
{ workId: string; done: Promise<void>; resolve: () => void }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
async ensureDetail(
|
async ensureDetail(
|
||||||
userId: string,
|
userId: string,
|
||||||
revision: string,
|
revision: string,
|
||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'share' | 'bond',
|
||||||
uid: string,
|
uid: string,
|
||||||
) {
|
) {
|
||||||
const ids = await this.requestMany(
|
const ids = await this.requestMany(
|
||||||
@@ -86,7 +84,6 @@ export class CatalogEnrichmentService {
|
|||||||
if (!rows.length) throw new Error('Instrument dependency missing');
|
if (!rows.length) throw new Error('Instrument dependency missing');
|
||||||
return (rows[0].payload as CatalogPayload).instrument ?? null;
|
return (rows[0].payload as CatalogPayload).instrument ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly repository: CatalogDiscoveryRepository,
|
private readonly repository: CatalogDiscoveryRepository,
|
||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
@@ -99,21 +96,21 @@ export class CatalogEnrichmentService {
|
|||||||
|
|
||||||
async readMany(
|
async readMany(
|
||||||
userId: string,
|
userId: string,
|
||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'share' | 'bond',
|
||||||
uids: string[],
|
uids: string[],
|
||||||
observe: boolean,
|
observe: boolean,
|
||||||
kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
|
kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
|
||||||
range?: {
|
range?: {
|
||||||
from: string;
|
from: string;
|
||||||
|
to: string;
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
tickers?: Record<string, string>;
|
tickers?: Record<string, string>;
|
||||||
to: string;
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { revision } = await this.access.local(userId);
|
const { revision } = await this.access.local(userId);
|
||||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
const rows = inputs.length ? await this.repository.readSources(inputs) : [];
|
const rows = inputs.length ? await this.repository.readSources(inputs) : [];
|
||||||
if (!observe) {
|
if (!observe)
|
||||||
await this.requestMany(
|
await this.requestMany(
|
||||||
userId,
|
userId,
|
||||||
revision,
|
revision,
|
||||||
@@ -123,10 +120,9 @@ export class CatalogEnrichmentService {
|
|||||||
kinds,
|
kinds,
|
||||||
range,
|
range,
|
||||||
);
|
);
|
||||||
}
|
const work = inputs.length
|
||||||
const work = inputs.length ?
|
? await this.repository.workMany(userId, revision, inputs)
|
||||||
await this.repository.workMany(userId, revision, inputs) :
|
: [];
|
||||||
[];
|
|
||||||
const byScope = new Map(work.map((row) => [row.scopeKey, row]));
|
const byScope = new Map(work.map((row) => [row.scopeKey, row]));
|
||||||
return inputs.map((input) => {
|
return inputs.map((input) => {
|
||||||
const row = rows.find(
|
const row = rows.find(
|
||||||
@@ -136,22 +132,22 @@ export class CatalogEnrichmentService {
|
|||||||
const payload = row?.payload as CatalogPayload | undefined;
|
const payload = row?.payload as CatalogPayload | undefined;
|
||||||
return {
|
return {
|
||||||
input,
|
input,
|
||||||
version: row ?
|
version: row
|
||||||
{
|
? {
|
||||||
instrumentUid: row.instrumentUid,
|
instrumentUid: row.instrumentUid,
|
||||||
source: row.source,
|
source: row.source,
|
||||||
kind: row.kind,
|
kind: row.kind,
|
||||||
rangeKey: row.rangeKey,
|
rangeKey: row.rangeKey,
|
||||||
dataRevision: row.dataRevision,
|
dataRevision: row.dataRevision,
|
||||||
fetchedAt: row.fetchedAt,
|
fetchedAt: row.fetchedAt,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
payload,
|
payload,
|
||||||
instrument: payload?.instrument ?? null,
|
instrument: payload?.instrument ?? null,
|
||||||
price: payload?.price ?? null,
|
price: payload?.price ?? null,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
resourceKey: catalogScope(input),
|
resourceKey: catalogScope(input),
|
||||||
dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
|
dataRevision: String(row?.dataRevision ?? 0),
|
||||||
availability: row ? 'complete' : 'missing',
|
availability: row ? 'complete' : 'missing',
|
||||||
fetchedAt: row?.fetchedAt ?? null,
|
fetchedAt: row?.fetchedAt ?? null,
|
||||||
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)],
|
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)],
|
||||||
@@ -164,22 +160,22 @@ export class CatalogEnrichmentService {
|
|||||||
requestMany(
|
requestMany(
|
||||||
userId: string,
|
userId: string,
|
||||||
revision: string,
|
revision: string,
|
||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'share' | 'bond',
|
||||||
uids: string[],
|
uids: string[],
|
||||||
force = false,
|
force = false,
|
||||||
kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
|
kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
|
||||||
range?: {
|
range?: {
|
||||||
from: string;
|
from: string;
|
||||||
|
to: string;
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
tickers?: Record<string, string>;
|
tickers?: Record<string, string>;
|
||||||
to: string;
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
const reservation = this.reservations.then(async () => {
|
const reservation = this.reservations.then(async () => {
|
||||||
const rows = inputs.length ?
|
const rows = inputs.length
|
||||||
await this.repository.readSources(inputs) :
|
? await this.repository.readSources(inputs)
|
||||||
[];
|
: [];
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
const row = rows.find(
|
const row = rows.find(
|
||||||
@@ -193,9 +189,8 @@ export class CatalogEnrichmentService {
|
|||||||
new Date(),
|
new Date(),
|
||||||
force,
|
force,
|
||||||
)
|
)
|
||||||
) {
|
)
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
const reserved = await this.repository.reserve(
|
const reserved = await this.repository.reserve(
|
||||||
{ userId, revision },
|
{ userId, revision },
|
||||||
input,
|
input,
|
||||||
@@ -236,24 +231,24 @@ export class CatalogEnrichmentService {
|
|||||||
|
|
||||||
async section(
|
async section(
|
||||||
userId: string,
|
userId: string,
|
||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'share' | 'bond',
|
||||||
uid: string,
|
uid: string,
|
||||||
section: 'company' | 'metrics' | 'quote',
|
section: 'quote' | 'company' | 'metrics',
|
||||||
observe = false,
|
observe = false,
|
||||||
) {
|
) {
|
||||||
const kinds: CatalogSource['kind'][] =
|
const kinds: CatalogSource['kind'][] =
|
||||||
section === 'company' ?
|
section === 'company'
|
||||||
['detail', 'description', 'fundamentals'] :
|
? ['detail', 'description', 'fundamentals']
|
||||||
[
|
: [
|
||||||
...(instrumentType === 'bond' ?
|
...(instrumentType === 'bond'
|
||||||
['bond-current' as const] :
|
? ['bond-current' as const]
|
||||||
['detail' as const]),
|
: ['detail' as const]),
|
||||||
'last-price',
|
'last-price',
|
||||||
'trading-status',
|
'trading-status',
|
||||||
'comparison',
|
'comparison',
|
||||||
...(section === 'metrics' ?
|
...(section === 'metrics'
|
||||||
['coupons' as const, 'yield' as const, 'rating' as const] :
|
? ['coupons' as const, 'yield' as const, 'rating' as const]
|
||||||
[]),
|
: []),
|
||||||
];
|
];
|
||||||
const sources = await this.readMany(
|
const sources = await this.readMany(
|
||||||
userId,
|
userId,
|
||||||
@@ -263,9 +258,8 @@ export class CatalogEnrichmentService {
|
|||||||
kinds,
|
kinds,
|
||||||
);
|
);
|
||||||
const first = sources[0];
|
const first = sources[0];
|
||||||
if (first.meta.availability === 'complete' && !first.instrument) {
|
if (first.meta.availability === 'complete' && !first.instrument)
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
|
||||||
const values = sources.reduce<CatalogPayload>(
|
const values = sources.reduce<CatalogPayload>(
|
||||||
(result, source) => ({ ...result, ...source.payload }),
|
(result, source) => ({ ...result, ...source.payload }),
|
||||||
{},
|
{},
|
||||||
@@ -279,18 +273,18 @@ export class CatalogEnrichmentService {
|
|||||||
dataRevision: dependencies
|
dataRevision: dependencies
|
||||||
.map((dependency) => dependency.dataRevision)
|
.map((dependency) => dependency.dataRevision)
|
||||||
.join('/'),
|
.join('/'),
|
||||||
availability: !first.instrument ?
|
availability: !first.instrument
|
||||||
'missing' :
|
? 'missing'
|
||||||
complete ?
|
: complete
|
||||||
'complete' :
|
? 'complete'
|
||||||
'partial',
|
: 'partial',
|
||||||
fetchedAt: first.meta.lastSuccessfulAt ?
|
fetchedAt: first.meta.lastSuccessfulAt
|
||||||
new Date(first.meta.lastSuccessfulAt) :
|
? new Date(first.meta.lastSuccessfulAt)
|
||||||
null,
|
: null,
|
||||||
freshForMs:
|
freshForMs:
|
||||||
section === 'company' ?
|
section === 'company'
|
||||||
tbankSourceTtlMs.company :
|
? tbankSourceTtlMs.company
|
||||||
tbankSourceTtlMs.quote,
|
: tbankSourceTtlMs.quote,
|
||||||
work: null,
|
work: null,
|
||||||
dependencies,
|
dependencies,
|
||||||
});
|
});
|
||||||
@@ -315,21 +309,18 @@ export class CatalogEnrichmentService {
|
|||||||
): Promise<CatalogPayload> {
|
): Promise<CatalogPayload> {
|
||||||
if (input.kind === 'detail' || input.kind === 'bond-current') {
|
if (input.kind === 'detail' || input.kind === 'bond-current') {
|
||||||
const instrument =
|
const instrument =
|
||||||
input.instrumentType === 'share' ?
|
input.instrumentType === 'share'
|
||||||
await this.tbank.getShare(token, input.uid) :
|
? await this.tbank.getShare(token, input.uid)
|
||||||
await this.tbank.getBond(token, input.uid);
|
: await this.tbank.getBond(token, input.uid);
|
||||||
if (instrument && instrument.uid !== input.uid) {
|
if (instrument && instrument.uid !== input.uid)
|
||||||
throw new Error('Catalog UID mismatch');
|
throw new Error('Catalog UID mismatch');
|
||||||
}
|
|
||||||
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
|
|
||||||
if (instrument === undefined) throw new Error('Missing catalog response');
|
if (instrument === undefined) throw new Error('Missing catalog response');
|
||||||
return { instrument: instrument ? publicInstrument(instrument) : null };
|
return { instrument: instrument ? publicInstrument(instrument) : null };
|
||||||
}
|
}
|
||||||
if (input.kind === 'trading-status') {
|
if (input.kind === 'trading-status')
|
||||||
return {
|
return {
|
||||||
tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
|
tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
|
||||||
};
|
};
|
||||||
}
|
|
||||||
if (input.kind === 'comparison') {
|
if (input.kind === 'comparison') {
|
||||||
const prices = await this.repository.readSources([
|
const prices = await this.repository.readSources([
|
||||||
{ ...input, kind: 'last-price', rangeKey: undefined },
|
{ ...input, kind: 'last-price', rangeKey: undefined },
|
||||||
@@ -354,19 +345,17 @@ export class CatalogEnrichmentService {
|
|||||||
);
|
);
|
||||||
const rows = response.map((candle) => {
|
const rows = response.map((candle) => {
|
||||||
const time = timestamp(candle.time);
|
const time = timestamp(candle.time);
|
||||||
if (!time || !candle.close) {
|
if (!time || !candle.close)
|
||||||
throw new Error('Invalid candle response');
|
throw new Error('Invalid candle response');
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
time,
|
time,
|
||||||
close: formatDecimal(decimal(candle.close)),
|
close: formatDecimal(decimal(candle.close)),
|
||||||
isComplete: candle.isComplete === true,
|
isComplete: candle.isComplete === true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (!(await this.candles.stage(attempt, gap, rows, now))) {
|
if (!(await this.candles.stage(attempt, gap, rows, now)))
|
||||||
throw new Error('Cancelled candle comparison');
|
throw new Error('Cancelled candle comparison');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
gaps.length &&
|
gaps.length &&
|
||||||
!(await this.candles.publish(
|
!(await this.candles.publish(
|
||||||
@@ -374,9 +363,8 @@ export class CatalogEnrichmentService {
|
|||||||
{ uid: input.uid, ranges: gaps },
|
{ uid: input.uid, ranges: gaps },
|
||||||
false,
|
false,
|
||||||
))
|
))
|
||||||
) {
|
)
|
||||||
throw new Error('Cancelled candle comparison');
|
throw new Error('Cancelled candle comparison');
|
||||||
}
|
|
||||||
const values = (await this.candles.read(input.uid, range)).data.map(
|
const values = (await this.candles.read(input.uid, range)).data.map(
|
||||||
(row) => {
|
(row) => {
|
||||||
const value = divide(
|
const value = divide(
|
||||||
@@ -400,51 +388,48 @@ export class CatalogEnrichmentService {
|
|||||||
}
|
}
|
||||||
const details = await this.repository.readSources([
|
const details = await this.repository.readSources([
|
||||||
{ ...input, kind: 'detail', rangeKey: undefined },
|
{ ...input, kind: 'detail', rangeKey: undefined },
|
||||||
...(input.instrumentType === 'bond' ?
|
...(input.instrumentType === 'bond'
|
||||||
[{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] :
|
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
|
||||||
[]),
|
: []),
|
||||||
]);
|
]);
|
||||||
const instrument = details
|
const instrument = details
|
||||||
.map((row) => (row.payload as CatalogPayload).instrument)
|
.map((row) => (row.payload as CatalogPayload).instrument)
|
||||||
.find(Boolean);
|
.find(Boolean);
|
||||||
if (
|
if (
|
||||||
!instrument &&
|
!instrument &&
|
||||||
!['bond-events', 'coupons', 'dividends'].includes(input.kind)
|
!['dividends', 'coupons', 'bond-events'].includes(input.kind)
|
||||||
) {
|
)
|
||||||
throw new Error('Instrument dependency missing');
|
throw new Error('Instrument dependency missing');
|
||||||
}
|
if (input.kind === 'description')
|
||||||
if (input.kind === 'description') {
|
|
||||||
return {
|
return {
|
||||||
description: instrument?.assetUid ?
|
description: instrument?.assetUid
|
||||||
await this.tbank.getAssetDescription(token, instrument?.assetUid) :
|
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
if (input.kind === 'fundamentals')
|
||||||
if (input.kind === 'fundamentals') {
|
|
||||||
return {
|
return {
|
||||||
fundamentals: instrument?.assetUid ?
|
fundamentals: instrument?.assetUid
|
||||||
await this.tbank.getFundamentals(token, instrument?.assetUid) :
|
? await this.tbank.getFundamentals(token, instrument?.assetUid)
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
if (input.kind === 'coupons' || input.kind === 'dividends') {
|
if (input.kind === 'coupons' || input.kind === 'dividends') {
|
||||||
const year = Number(input.rangeKey);
|
const year = Number(input.rangeKey);
|
||||||
const [from, to] = input.rangeKey?.startsWith('[') ?
|
const [from, to] = input.rangeKey?.startsWith('[')
|
||||||
(JSON.parse(input.rangeKey) as [string, string]) :
|
? (JSON.parse(input.rangeKey) as [string, string])
|
||||||
[
|
: [
|
||||||
`${year - payoutHistoryYears}-01-01`,
|
`${year - payoutHistoryYears}-01-01`,
|
||||||
`${year + payoutFutureYears}-01-01`,
|
`${year + payoutFutureYears}-01-01`,
|
||||||
];
|
];
|
||||||
return input.kind === 'coupons' ?
|
return input.kind === 'coupons'
|
||||||
{
|
? {
|
||||||
coupons: await this.tbank.getBondCoupons(
|
coupons: await this.tbank.getBondCoupons(
|
||||||
token,
|
token,
|
||||||
input.uid,
|
input.uid,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
{
|
: {
|
||||||
dividends: await this.tbank.getDividends(
|
dividends: await this.tbank.getDividends(
|
||||||
token,
|
token,
|
||||||
input.uid,
|
input.uid,
|
||||||
@@ -458,25 +443,23 @@ export class CatalogEnrichmentService {
|
|||||||
if (!ticker) throw new Error('Bond ticker missing');
|
if (!ticker) throw new Error('Bond ticker missing');
|
||||||
return { bondEvents: await this.moex.bondEvents(ticker) };
|
return { bondEvents: await this.moex.bondEvents(ticker) };
|
||||||
}
|
}
|
||||||
if (input.kind === 'yield') {
|
if (input.kind === 'yield')
|
||||||
return {
|
return {
|
||||||
effectiveYield:
|
effectiveYield:
|
||||||
instrument?.ticker && instrument.isin && instrument.classCode ?
|
instrument?.ticker && instrument.isin && instrument.classCode
|
||||||
await this.moex.bondYield(
|
? await this.moex.bondYield(
|
||||||
instrument.ticker,
|
instrument.ticker,
|
||||||
instrument.isin,
|
instrument.isin,
|
||||||
instrument.classCode,
|
instrument.classCode,
|
||||||
) :
|
)
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
if (input.kind === 'rating')
|
||||||
if (input.kind === 'rating') {
|
|
||||||
return {
|
return {
|
||||||
creditRating: instrument?.isin ?
|
creditRating: instrument?.isin
|
||||||
await this.ratings.rating(instrument.isin) :
|
? await this.ratings.rating(instrument.isin)
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
throw new Error('Unsupported catalog source');
|
throw new Error('Unsupported catalog source');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,10 +472,9 @@ export class CatalogEnrichmentService {
|
|||||||
// One batch RPC for queued prices sharing the same authorized producer.
|
// One batch RPC for queued prices sharing the same authorized producer.
|
||||||
if (producer.input.kind === 'last-price') {
|
if (producer.input.kind === 'last-price') {
|
||||||
for (
|
for (
|
||||||
let i = FIRST_QUEUE_INDEX;
|
let i = 0;
|
||||||
i < this.queue.length && batch.length < catalogSearchLimit;
|
i < this.queue.length && batch.length < catalogSearchLimit;
|
||||||
) {
|
) {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
|
|
||||||
const next = this.queue[i];
|
const next = this.queue[i];
|
||||||
if (
|
if (
|
||||||
next.input.kind === 'last-price' &&
|
next.input.kind === 'last-price' &&
|
||||||
@@ -500,7 +482,7 @@ export class CatalogEnrichmentService {
|
|||||||
next.attempt.revision === producer.attempt.revision
|
next.attempt.revision === producer.attempt.revision
|
||||||
) {
|
) {
|
||||||
batch.push(next);
|
batch.push(next);
|
||||||
this.queue.splice(i, DEQUEUED_ITEM_COUNT);
|
this.queue.splice(i, 1);
|
||||||
} else i++;
|
} else i++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,40 +492,38 @@ export class CatalogEnrichmentService {
|
|||||||
try {
|
try {
|
||||||
const token = await this.access.tokenForAttempt(producer.attempt);
|
const token = await this.access.tokenForAttempt(producer.attempt);
|
||||||
if (!token) failed = true;
|
if (!token) failed = true;
|
||||||
else if (producer.input.kind === 'last-price') {
|
else if (producer.input.kind === 'last-price')
|
||||||
prices = await this.tbank.getLastPrices(
|
prices = await this.tbank.getLastPrices(
|
||||||
token,
|
token,
|
||||||
batch.map((item) => item.input.uid),
|
batch.map((item) => item.input.uid),
|
||||||
);
|
);
|
||||||
} else {
|
else
|
||||||
payload = await this.load(producer.input, token, producer.attempt);
|
payload = await this.load(producer.input, token, producer.attempt);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed = true;
|
failed = true;
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
await this.access.rejectMembershipToken(producer.attempt);
|
await this.access.rejectMembershipToken(producer.attempt);
|
||||||
} else {
|
else
|
||||||
this.logger.event('warn', 'catalog-enrichment.failed', {
|
this.logger.event('warn', 'catalog-enrichment.failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: 'TBANK_UNAVAILABLE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for (const item of batch) {
|
for (const item of batch) {
|
||||||
const price =
|
const price =
|
||||||
prices?.find((entry) => entry.instrumentUid === item.input.uid) ??
|
prices?.find((entry) => entry.instrumentUid === item.input.uid) ??
|
||||||
null;
|
null;
|
||||||
const value =
|
const value =
|
||||||
item.input.kind === 'last-price' ?
|
item.input.kind === 'last-price'
|
||||||
{
|
? {
|
||||||
price: price ?
|
price: price
|
||||||
{
|
? {
|
||||||
instrumentUid: item.input.uid,
|
instrumentUid: item.input.uid,
|
||||||
price: price.price,
|
price: price.price,
|
||||||
time: price.time,
|
time: price.time,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
} :
|
}
|
||||||
payload;
|
: payload;
|
||||||
// Keep accepting observers while publication awaits SQLite.
|
// Keep accepting observers while publication awaits SQLite.
|
||||||
item.observers.unshift({ attempt: item.attempt, input: item.input });
|
item.observers.unshift({ attempt: item.attempt, input: item.input });
|
||||||
for (const observer of item.observers) {
|
for (const observer of item.observers) {
|
||||||
@@ -580,14 +560,14 @@ export class CatalogEnrichmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function sourceInputs(
|
function sourceInputs(
|
||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'share' | 'bond',
|
||||||
uids: string[],
|
uids: string[],
|
||||||
kinds: CatalogSource['kind'][],
|
kinds: CatalogSource['kind'][],
|
||||||
range?: {
|
range?: {
|
||||||
from: string;
|
from: string;
|
||||||
|
to: string;
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
tickers?: Record<string, string>;
|
tickers?: Record<string, string>;
|
||||||
to: string;
|
|
||||||
},
|
},
|
||||||
): CatalogSource[] {
|
): CatalogSource[] {
|
||||||
return kinds.flatMap((kind) =>
|
return kinds.flatMap((kind) =>
|
||||||
@@ -595,22 +575,20 @@ function sourceInputs(
|
|||||||
instrumentType,
|
instrumentType,
|
||||||
uid,
|
uid,
|
||||||
kind,
|
kind,
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
|
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker)
|
||||||
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) ?
|
? { ticker: range?.tickers?.[uid] || range?.ticker }
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
|
: {}),
|
||||||
{ ticker: range?.tickers?.[uid] || range?.ticker } :
|
...(['coupons', 'dividends'].includes(kind)
|
||||||
{}),
|
? {
|
||||||
...(['coupons', 'dividends'].includes(kind) ?
|
rangeKey: range
|
||||||
{
|
? JSON.stringify([range.from, range.to])
|
||||||
rangeKey: range ?
|
: String(new Date().getUTCFullYear()),
|
||||||
JSON.stringify([range.from, range.to]) :
|
}
|
||||||
String(new Date().getUTCFullYear()),
|
: {}),
|
||||||
} :
|
|
||||||
{}),
|
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function publicInstrument(value: TbankBond | TbankShare) {
|
function publicInstrument(value: TbankShare | TbankBond) {
|
||||||
const bond = value as TbankBond;
|
const bond = value as TbankBond;
|
||||||
return {
|
return {
|
||||||
uid: value.uid,
|
uid: value.uid,
|
||||||
@@ -649,39 +627,38 @@ const previousCloseLookbackMs =
|
|||||||
const payoutHistoryYears = 5;
|
const payoutHistoryYears = 5;
|
||||||
const payoutFutureYears = 2;
|
const payoutFutureYears = 2;
|
||||||
export type CatalogPayload = {
|
export type CatalogPayload = {
|
||||||
bondEvents?: MoexBondEvent[];
|
instrument?: TbankShare | TbankBond | null;
|
||||||
candle?: null | TbankCandle;
|
price?: TbankLastPrice | null;
|
||||||
|
candle?: TbankCandle | null;
|
||||||
|
tradingStatus?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
fundamentals?: TbankFundamentals | null;
|
||||||
coupons?: TbankCoupon[];
|
coupons?: TbankCoupon[];
|
||||||
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
|
|
||||||
description?: null | string;
|
|
||||||
dividends?: TbankDividend[];
|
dividends?: TbankDividend[];
|
||||||
|
bondEvents?: MoexBondEvent[];
|
||||||
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
|
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
|
||||||
fundamentals?: null | TbankFundamentals;
|
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
|
||||||
instrument?: null | TbankBond | TbankShare;
|
|
||||||
price?: null | TbankLastPrice;
|
|
||||||
tradingStatus?: null | string;
|
|
||||||
};
|
};
|
||||||
function sourcePolicy(kind: CatalogSource['kind']) {
|
function sourcePolicy(kind: CatalogSource['kind']) {
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
'bond-current',
|
|
||||||
'comparison',
|
|
||||||
'last-price',
|
'last-price',
|
||||||
|
'bond-current',
|
||||||
'trading-status',
|
'trading-status',
|
||||||
|
'comparison',
|
||||||
'yield',
|
'yield',
|
||||||
].includes(kind)
|
].includes(kind)
|
||||||
) {
|
)
|
||||||
return 'quote';
|
return 'quote';
|
||||||
}
|
|
||||||
if (kind === 'description' || kind === 'fundamentals') return 'company';
|
if (kind === 'description' || kind === 'fundamentals') return 'company';
|
||||||
if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar';
|
if (['coupons', 'dividends', 'bond-events'].includes(kind)) return 'calendar';
|
||||||
return 'metadata';
|
return 'metadata';
|
||||||
}
|
}
|
||||||
function producerKey(input: CatalogSource) {
|
function producerKey(input: CatalogSource) {
|
||||||
return input.instrumentType === 'bond' &&
|
return input.instrumentType === 'bond' &&
|
||||||
(input.kind === 'detail' || input.kind === 'bond-current') ?
|
(input.kind === 'detail' || input.kind === 'bond-current')
|
||||||
`bond-metadata:${input.uid}` :
|
? `bond-metadata:${input.uid}`
|
||||||
catalogScope(input);
|
: catalogScope(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotationScale = 9;
|
const quotationScale = 9;
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ export const catalogSearchLimit = 30;
|
|||||||
export function selectCatalogInstruments<T extends TbankShare>(
|
export function selectCatalogInstruments<T extends TbankShare>(
|
||||||
found: T[],
|
found: T[],
|
||||||
query: string,
|
query: string,
|
||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'share' | 'bond',
|
||||||
): (T & { uid: string })[] {
|
): (T & { uid: string })[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const kind =
|
const kind =
|
||||||
instrumentType === 'share' ?
|
instrumentType === 'share'
|
||||||
'INSTRUMENT_TYPE_SHARE' :
|
? 'INSTRUMENT_TYPE_SHARE'
|
||||||
'INSTRUMENT_TYPE_BOND';
|
: 'INSTRUMENT_TYPE_BOND';
|
||||||
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
|
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
|
||||||
return found
|
return found
|
||||||
.filter(
|
.filter(
|
||||||
@@ -29,9 +29,9 @@ export function selectCatalogInstruments<T extends TbankShare>(
|
|||||||
a.uid.localeCompare(b.uid),
|
a.uid.localeCompare(b.uid),
|
||||||
)
|
)
|
||||||
.filter((instrument) => {
|
.filter((instrument) => {
|
||||||
const key = instrument.isin?.trim() ?
|
const key = instrument.isin?.trim()
|
||||||
`isin:${instrument.isin.trim().toUpperCase()}` :
|
? `isin:${instrument.isin.trim().toUpperCase()}`
|
||||||
`uid:${instrument.uid}`;
|
: `uid:${instrument.uid}`;
|
||||||
if (seen.has(key)) return false;
|
if (seen.has(key)) return false;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
|
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||||
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
import type {
|
import type {
|
||||||
TbankCandle,
|
TbankCandle,
|
||||||
TbankTimestamp,
|
TbankTimestamp,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
|
||||||
|
|
||||||
const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
|
|
||||||
|
|
||||||
const MOSCOW_OFFSET_MS = 10_800_000;
|
const MOSCOW_OFFSET_MS = 10_800_000;
|
||||||
export function timestamp(value?: TbankTimestamp): null | string {
|
export function timestamp(value?: TbankTimestamp): string | null {
|
||||||
const seconds = Number(value?.seconds);
|
const seconds = Number(value?.seconds);
|
||||||
return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE ?
|
return Number.isFinite(seconds) && seconds > 0
|
||||||
new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() :
|
? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString()
|
||||||
null;
|
: null;
|
||||||
}
|
}
|
||||||
export function moscowDay(iso: string): string {
|
export function moscowDay(iso: string): string {
|
||||||
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
|
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
.slice(0, 10);
|
||||||
}
|
}
|
||||||
export function previousClose(
|
export function previousClose(
|
||||||
candles: TbankCandle[],
|
candles: TbankCandle[],
|
||||||
priceAt: string,
|
priceAt: string,
|
||||||
): null | TbankCandle {
|
): TbankCandle | null {
|
||||||
return (
|
return (
|
||||||
candles
|
candles
|
||||||
.filter((candle) => {
|
.filter((candle) => {
|
||||||
@@ -31,10 +29,23 @@ export function previousClose(
|
|||||||
candle.isComplete &&
|
candle.isComplete &&
|
||||||
candle.close &&
|
candle.close &&
|
||||||
time &&
|
time &&
|
||||||
time.slice(ISO_DATE_START, ISO_DATE_LENGTH) < moscowDay(priceAt)
|
time.slice(0, 10) < moscowDay(priceAt)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ??
|
.sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ??
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export async function optional<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
name: string,
|
||||||
|
unavailable: string[],
|
||||||
|
): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ServiceUnavailableException)) throw error;
|
||||||
|
unavailable.push(name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
} from '../../../../common/decimal';
|
} from '../../../../common/decimal';
|
||||||
|
|
||||||
import { MoneyDto } from '../../../../common/money.dto';
|
import { MoneyDto } from '../../../../common/money.dto';
|
||||||
import {
|
import {
|
||||||
PortfolioDto,
|
PortfolioDto,
|
||||||
@@ -12,17 +13,13 @@ import {
|
|||||||
} from '../../portfolio/portfolio.api';
|
} from '../../portfolio/portfolio.api';
|
||||||
import { StockPositionDto } from '../stocks/presentation/stock.dto';
|
import { StockPositionDto } from '../stocks/presentation/stock.dto';
|
||||||
|
|
||||||
const EMPTY_POSITIONS_COUNT = 0;
|
|
||||||
const ZERO_QUANTITY = 0n;
|
|
||||||
const ZERO_AMOUNT = 0n;
|
|
||||||
|
|
||||||
const PRICE_DECIMAL_PLACES = 9;
|
const PRICE_DECIMAL_PLACES = 9;
|
||||||
const RETURN_PERCENT_DECIMAL_PLACES = 6;
|
const RETURN_PERCENT_DECIMAL_PLACES = 6;
|
||||||
|
|
||||||
export function instrumentPosition(
|
export function instrumentPosition(
|
||||||
portfolio: PortfolioDto,
|
portfolio: PortfolioDto,
|
||||||
uid: string,
|
uid: string,
|
||||||
type: 'BOND' | 'STOCK',
|
type: 'STOCK' | 'BOND',
|
||||||
): StockPositionDto {
|
): StockPositionDto {
|
||||||
const positions = portfolio.positions.filter(
|
const positions = portfolio.positions.filter(
|
||||||
(p) => p.type === type && p.instrumentUid === uid,
|
(p) => p.type === type && p.instrumentUid === uid,
|
||||||
@@ -36,15 +33,15 @@ export function instrumentPosition(
|
|||||||
const currentValue = sumComplete(positions.map((p) => p.currentValue));
|
const currentValue = sumComplete(positions.map((p) => p.currentValue));
|
||||||
const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn));
|
const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn));
|
||||||
const positive =
|
const positive =
|
||||||
positions.length > EMPTY_POSITIONS_COUNT &&
|
positions.length > 0 &&
|
||||||
positions.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY);
|
positions.every((p) => decimalFromString(p.quantity).value > 0n);
|
||||||
return {
|
return {
|
||||||
quantity: formatDecimal(quantity),
|
quantity: formatDecimal(quantity),
|
||||||
currentValue,
|
currentValue,
|
||||||
absoluteReturn,
|
absoluteReturn,
|
||||||
averagePurchasePrice:
|
averagePurchasePrice:
|
||||||
positive && totalCost ?
|
positive && totalCost
|
||||||
{
|
? {
|
||||||
currency: totalCost.currency,
|
currency: totalCost.currency,
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
divide(
|
divide(
|
||||||
@@ -53,15 +50,15 @@ export function instrumentPosition(
|
|||||||
PRICE_DECIMAL_PLACES,
|
PRICE_DECIMAL_PLACES,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
|
returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
|
||||||
accounts: positions.map((p) => ({
|
accounts: positions.map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
returnPercent:
|
returnPercent:
|
||||||
decimalFromString(p.quantity).value > ZERO_QUANTITY ?
|
decimalFromString(p.quantity).value > 0n
|
||||||
percent(p.absoluteReturn, cost(p)) :
|
? percent(p.absoluteReturn, cost(p))
|
||||||
null,
|
: null,
|
||||||
})),
|
})),
|
||||||
isStale: portfolio.isStale,
|
isStale: portfolio.isStale,
|
||||||
observedAt: portfolio.lastSuccessfulAt,
|
observedAt: portfolio.lastSuccessfulAt,
|
||||||
@@ -69,8 +66,8 @@ export function instrumentPosition(
|
|||||||
}
|
}
|
||||||
function cost(position: PortfolioPositionDto): MoneyDto | null {
|
function cost(position: PortfolioPositionDto): MoneyDto | null {
|
||||||
const price = position.averagePurchasePrice;
|
const price = position.averagePurchasePrice;
|
||||||
return price ?
|
return price
|
||||||
{
|
? {
|
||||||
currency: price.currency,
|
currency: price.currency,
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
multiply(
|
multiply(
|
||||||
@@ -78,17 +75,16 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
|
|||||||
decimalFromString(position.quantity),
|
decimalFromString(position.quantity),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
null;
|
: null;
|
||||||
}
|
}
|
||||||
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
|
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
|
||||||
const currency = values[0]?.currency;
|
const currency = values[0]?.currency;
|
||||||
if (
|
if (
|
||||||
!currency ||
|
!currency ||
|
||||||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency)
|
!values.every((v): v is MoneyDto => v !== null && v.currency === currency)
|
||||||
) {
|
)
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
currency,
|
currency,
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
@@ -102,15 +98,14 @@ function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
|
|||||||
function percent(
|
function percent(
|
||||||
result: MoneyDto | null,
|
result: MoneyDto | null,
|
||||||
base: MoneyDto | null,
|
base: MoneyDto | null,
|
||||||
): null | string {
|
): string | null {
|
||||||
if (
|
if (
|
||||||
!base ||
|
!base ||
|
||||||
!result ||
|
!result ||
|
||||||
base.currency !== result.currency ||
|
base.currency !== result.currency ||
|
||||||
decimalFromString(base.value).value <= ZERO_AMOUNT
|
decimalFromString(base.value).value <= 0n
|
||||||
) {
|
)
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
return formatDecimal(
|
return formatDecimal(
|
||||||
divide(
|
divide(
|
||||||
multiply(decimalFromString(result.value), decimalFromString('100')),
|
multiply(decimalFromString(result.value), decimalFromString('100')),
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
|
||||||
|
|
||||||
import { formatDecimal } from '../../../../../common/decimal';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
|
||||||
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { formatDecimal } from '../../../../../common/decimal';
|
||||||
|
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||||
|
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||||
import { timestamp } from '../../application/instrument-market';
|
import { timestamp } from '../../application/instrument-market';
|
||||||
import {
|
import {
|
||||||
BondDto,
|
BondDto,
|
||||||
@@ -14,11 +12,8 @@ import {
|
|||||||
|
|
||||||
type IdentifiedBond = TbankBond & { uid: string };
|
type IdentifiedBond = TbankBond & { uid: string };
|
||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
|
||||||
import { money } from '../infrastructure/bond-money.mapper';
|
import { money } from '../infrastructure/bond-money.mapper';
|
||||||
|
|
||||||
const MIN_LOT_EXCLUSIVE = 0;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BondCatalogService {
|
export class BondCatalogService {
|
||||||
constructor(private readonly discovery: CatalogDiscoveryService) {}
|
constructor(private readonly discovery: CatalogDiscoveryService) {}
|
||||||
@@ -37,24 +32,23 @@ export class BondCatalogService {
|
|||||||
data:
|
data:
|
||||||
result.data?.map((item) => {
|
result.data?.map((item) => {
|
||||||
const short = toBond(item);
|
const short = toBond(item);
|
||||||
const detail = item.detail ?
|
const detail = item.detail
|
||||||
toBond({ ...item.detail, uid: item.uid }) :
|
? toBond({ ...item.detail, uid: item.uid })
|
||||||
short;
|
: short;
|
||||||
const last = item.price;
|
const last = item.price;
|
||||||
return {
|
return {
|
||||||
...detail,
|
...detail,
|
||||||
classCode: detail.classCode ?? short.classCode,
|
classCode: detail.classCode ?? short.classCode,
|
||||||
isin: detail.isin ?? short.isin,
|
isin: detail.isin ?? short.isin,
|
||||||
apiTradeAvailable: short.apiTradeAvailable,
|
apiTradeAvailable: short.apiTradeAvailable,
|
||||||
pricePercent: last?.price ?
|
pricePercent: last?.price
|
||||||
formatDecimal(decimal(last.price)) :
|
? formatDecimal(decimal(last.price))
|
||||||
null,
|
: null,
|
||||||
priceAt: timestamp(last?.time),
|
priceAt: timestamp(last?.time),
|
||||||
};
|
};
|
||||||
}) ?? null,
|
}) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(
|
async get(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
@@ -63,12 +57,11 @@ export class BondCatalogService {
|
|||||||
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
data: result.data ?
|
data: result.data
|
||||||
{ ...toBond(result.data), slug: result.data.slug } :
|
? { ...toBond(result.data), slug: result.data.slug }
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByTicker(
|
async getByTicker(
|
||||||
userId: string,
|
userId: string,
|
||||||
ticker: string,
|
ticker: string,
|
||||||
@@ -82,9 +75,9 @@ export class BondCatalogService {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
data: result.data ?
|
data: result.data
|
||||||
{ ...toBond(result.data), slug: result.data.slug } :
|
? { ...toBond(result.data), slug: result.data.slug }
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,13 +96,13 @@ function toBond(bond: IdentifiedBond): BondDto {
|
|||||||
currency: bond.currency?.toUpperCase() || null,
|
currency: bond.currency?.toUpperCase() || null,
|
||||||
bondType: bond.bondType || null,
|
bondType: bond.bondType || null,
|
||||||
sector: bond.sector || null,
|
sector: bond.sector || null,
|
||||||
lot: bond.lot && bond.lot > MIN_LOT_EXCLUSIVE ? bond.lot : null,
|
lot: bond.lot && bond.lot > 0 ? bond.lot : null,
|
||||||
historyFrom: timestamp(bond.first_1dayCandleDate),
|
historyFrom: timestamp(bond.first_1dayCandleDate),
|
||||||
nominal: money(bond.nominal),
|
nominal: money(bond.nominal),
|
||||||
initialNominal: money(bond.initialNominal),
|
initialNominal: money(bond.initialNominal),
|
||||||
aci: money(bond.aciValue),
|
aci: money(bond.aciValue),
|
||||||
maturityDate: timestamp(bond.maturityDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
maturityDate: timestamp(bond.maturityDate)?.slice(0, 10) ?? null,
|
||||||
callDate: timestamp(bond.callDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
callDate: timestamp(bond.callDate)?.slice(0, 10) ?? null,
|
||||||
couponQuantityPerYear: bond.couponQuantityPerYear ?? null,
|
couponQuantityPerYear: bond.couponQuantityPerYear ?? null,
|
||||||
floatingCoupon: bond.floatingCouponFlag ?? null,
|
floatingCoupon: bond.floatingCouponFlag ?? null,
|
||||||
perpetual: bond.perpetualFlag ?? null,
|
perpetual: bond.perpetualFlag ?? null,
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
|
divide,
|
||||||
} from '../../../../../common/decimal';
|
} from '../../../../../common/decimal';
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankBond,
|
|
||||||
type TbankCoupon,
|
type TbankCoupon,
|
||||||
|
type TbankBond,
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
} from '../../../../../integrations/tbank/tbank.api';
|
||||||
import {
|
import {
|
||||||
ForecastService,
|
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
|
ForecastService,
|
||||||
} from '../../../portfolio/portfolio.api';
|
} from '../../../portfolio/portfolio.api';
|
||||||
import {
|
import {
|
||||||
CatalogEnrichmentService,
|
CatalogEnrichmentService,
|
||||||
@@ -22,21 +20,15 @@ import {
|
|||||||
} from '../../application/catalog-enrichment.service';
|
} from '../../application/catalog-enrichment.service';
|
||||||
import { moscowDay, timestamp } from '../../application/instrument-market';
|
import { moscowDay, timestamp } from '../../application/instrument-market';
|
||||||
import { instrumentPosition } from '../../application/instrument-position';
|
import { instrumentPosition } from '../../application/instrument-position';
|
||||||
import { money } from '../infrastructure/bond-money.mapper';
|
|
||||||
import {
|
import {
|
||||||
BondCouponsDto,
|
BondCouponsDto,
|
||||||
BondCouponsResourceDto,
|
BondCouponsResourceDto,
|
||||||
|
BondPositionDto,
|
||||||
BondMetricsDto,
|
BondMetricsDto,
|
||||||
BondMetricsResourceDto,
|
BondMetricsResourceDto,
|
||||||
BondPositionDto,
|
|
||||||
} from '../presentation/bond.dto';
|
} from '../presentation/bond.dto';
|
||||||
|
import { money } from '../infrastructure/bond-money.mapper';
|
||||||
import { bondQuote } from './bond-market.service';
|
import { bondQuote } from './bond-market.service';
|
||||||
|
|
||||||
const INCOME_FORECAST_YEARS = 1;
|
|
||||||
const ZERO_QUANTITY = 0n;
|
|
||||||
const MIN_COUPON_PERIOD_EXCLUSIVE = 0;
|
|
||||||
const ZERO_AMOUNT = 0n;
|
|
||||||
|
|
||||||
const ISO_YEAR_LENGTH = 4;
|
const ISO_YEAR_LENGTH = 4;
|
||||||
const INCOME_HISTORY_YEARS = 5;
|
const INCOME_HISTORY_YEARS = 5;
|
||||||
const COUPON_YIELD_DECIMAL_PLACES = 4;
|
const COUPON_YIELD_DECIMAL_PLACES = 4;
|
||||||
@@ -48,7 +40,6 @@ export class BondIncomeService {
|
|||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly forecasts: ForecastService,
|
private readonly forecasts: ForecastService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async metrics(
|
async metrics(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
@@ -62,9 +53,9 @@ export class BondIncomeService {
|
|||||||
observe,
|
observe,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument ?
|
data: result.values.instrument
|
||||||
bondMetrics(result.values, result.unavailable, result.observedAt) :
|
? bondMetrics(result.values, result.unavailable, result.observedAt)
|
||||||
null,
|
: null,
|
||||||
meta: result.meta,
|
meta: result.meta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -76,7 +67,6 @@ export class BondIncomeService {
|
|||||||
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
|
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async coupons(
|
async coupons(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
@@ -84,8 +74,8 @@ export class BondIncomeService {
|
|||||||
): Promise<BondCouponsResourceDto> {
|
): Promise<BondCouponsResourceDto> {
|
||||||
const connection = await this.access.local(userId);
|
const connection = await this.access.local(userId);
|
||||||
const today = moscowDay(new Date().toISOString());
|
const today = moscowDay(new Date().toISOString());
|
||||||
const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||||
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||||
const portfolio = await this.portfolios.read(userId, 'all', observe);
|
const portfolio = await this.portfolios.read(userId, 'all', observe);
|
||||||
const sources = await this.sources.readMany(
|
const sources = await this.sources.readMany(
|
||||||
userId,
|
userId,
|
||||||
@@ -95,22 +85,21 @@ export class BondIncomeService {
|
|||||||
['detail', 'coupons'],
|
['detail', 'coupons'],
|
||||||
{ from, to },
|
{ from, to },
|
||||||
);
|
);
|
||||||
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
|
if (sources[0].meta.availability === 'complete' && !sources[0].instrument)
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
|
||||||
const events = sources[1].payload?.coupons;
|
const events = sources[1].payload?.coupons;
|
||||||
const position = portfolio.data ?
|
const position = portfolio.data
|
||||||
instrumentPosition(portfolio.data, uid, 'BOND') :
|
? instrumentPosition(portfolio.data, uid, 'BOND')
|
||||||
null;
|
: null;
|
||||||
const projected = events ?
|
const projected = events
|
||||||
bondCoupons(
|
? bondCoupons(
|
||||||
events,
|
events,
|
||||||
position,
|
position,
|
||||||
{ from, to, today },
|
{ from, to, today },
|
||||||
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||||
!portfolio.data,
|
!portfolio.data,
|
||||||
) :
|
)
|
||||||
undefined;
|
: undefined;
|
||||||
const result = await this.forecasts.result({
|
const result = await this.forecasts.result({
|
||||||
userId,
|
userId,
|
||||||
revision: connection.revision,
|
revision: connection.revision,
|
||||||
@@ -121,9 +110,9 @@ export class BondIncomeService {
|
|||||||
candidate:
|
candidate:
|
||||||
portfolio.data && sources[0].instrument ? projected : undefined,
|
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||||
});
|
});
|
||||||
return result.data || !projected ?
|
return result.data || !projected
|
||||||
result :
|
? result
|
||||||
{
|
: {
|
||||||
data: bondCoupons(
|
data: bondCoupons(
|
||||||
events ?? [],
|
events ?? [],
|
||||||
null,
|
null,
|
||||||
@@ -146,19 +135,19 @@ export function bondCoupons(
|
|||||||
const { from, to, today } = window;
|
const { from, to, today } = window;
|
||||||
const quantity =
|
const quantity =
|
||||||
position?.accounts.length &&
|
position?.accounts.length &&
|
||||||
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ?
|
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
|
||||||
decimalFromString(position.quantity) :
|
? decimalFromString(position.quantity)
|
||||||
null;
|
: null;
|
||||||
const coupons = events
|
const coupons = events
|
||||||
.map((event) => {
|
.map((event) => {
|
||||||
const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null;
|
||||||
const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null;
|
||||||
const perBond = money(event.payOneBond);
|
const perBond = money(event.payOneBond);
|
||||||
const timing = !paymentDate ?
|
const timing = !paymentDate
|
||||||
('undated' as const) :
|
? ('undated' as const)
|
||||||
paymentDate >= today ?
|
: paymentDate >= today
|
||||||
('upcoming' as const) :
|
? ('upcoming' as const)
|
||||||
('past' as const);
|
: ('past' as const);
|
||||||
return {
|
return {
|
||||||
number: event.couponNumber ?? null,
|
number: event.couponNumber ?? null,
|
||||||
paymentDate,
|
paymentDate,
|
||||||
@@ -172,14 +161,14 @@ export function bondCoupons(
|
|||||||
perBond &&
|
perBond &&
|
||||||
timing === 'upcoming' &&
|
timing === 'upcoming' &&
|
||||||
recordDate &&
|
recordDate &&
|
||||||
recordDate >= today ?
|
recordDate >= today
|
||||||
{
|
? {
|
||||||
currency: perBond.currency,
|
currency: perBond.currency,
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
multiply(quantity, decimalFromString(perBond.value)),
|
multiply(quantity, decimalFromString(perBond.value)),
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''));
|
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''));
|
||||||
@@ -205,10 +194,10 @@ export function bondMetrics(
|
|||||||
const effectiveYield = values.effectiveYield ?? null;
|
const effectiveYield = values.effectiveYield ?? null;
|
||||||
const creditRating = values.creditRating ?? null;
|
const creditRating = values.creditRating ?? null;
|
||||||
const today = moscowDay(new Date().toISOString());
|
const today = moscowDay(new Date().toISOString());
|
||||||
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||||
const next = events
|
const next = events
|
||||||
?.filter((event) => {
|
?.filter((event) => {
|
||||||
const date = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
const date = timestamp(event.couponDate)?.slice(0, 10);
|
||||||
return date && date >= today && date <= to;
|
return date && date >= today && date <= to;
|
||||||
})
|
})
|
||||||
.sort(
|
.sort(
|
||||||
@@ -217,16 +206,16 @@ export function bondMetrics(
|
|||||||
const coupon = money(next?.payOneBond);
|
const coupon = money(next?.payOneBond);
|
||||||
const period = next?.couponPeriod;
|
const period = next?.couponPeriod;
|
||||||
const couponPeriodDays =
|
const couponPeriodDays =
|
||||||
period && Number.isInteger(period) && period > MIN_COUPON_PERIOD_EXCLUSIVE ? period : null;
|
period && Number.isInteger(period) && period > 0 ? period : null;
|
||||||
const clean = quote?.cleanPrice;
|
const clean = quote?.cleanPrice;
|
||||||
const couponYieldPercent =
|
const couponYieldPercent =
|
||||||
coupon &&
|
coupon &&
|
||||||
couponPeriodDays &&
|
couponPeriodDays &&
|
||||||
clean &&
|
clean &&
|
||||||
coupon.currency === clean.currency &&
|
coupon.currency === clean.currency &&
|
||||||
decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
|
decimalFromString(coupon.value).value >= 0n &&
|
||||||
decimalFromString(clean.value).value > ZERO_AMOUNT ?
|
decimalFromString(clean.value).value > 0n
|
||||||
formatDecimal(
|
? formatDecimal(
|
||||||
divide(
|
divide(
|
||||||
multiply(
|
multiply(
|
||||||
decimalFromString(coupon.value),
|
decimalFromString(coupon.value),
|
||||||
@@ -238,18 +227,18 @@ export function bondMetrics(
|
|||||||
),
|
),
|
||||||
COUPON_YIELD_DECIMAL_PLACES,
|
COUPON_YIELD_DECIMAL_PLACES,
|
||||||
),
|
),
|
||||||
) :
|
)
|
||||||
null;
|
: null;
|
||||||
const couponType =
|
const couponType =
|
||||||
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ?
|
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED'
|
||||||
next.couponType :
|
? next.couponType
|
||||||
instrument.floatingCouponFlag === true ?
|
: instrument.floatingCouponFlag === true
|
||||||
'COUPON_TYPE_FLOATING' :
|
? 'COUPON_TYPE_FLOATING'
|
||||||
null;
|
: null;
|
||||||
return {
|
return {
|
||||||
creditRating,
|
creditRating,
|
||||||
coupon,
|
coupon,
|
||||||
couponDate: timestamp(next?.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null,
|
||||||
couponType,
|
couponType,
|
||||||
couponPeriodDays,
|
couponPeriodDays,
|
||||||
couponYieldPercent,
|
couponYieldPercent,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { CandleService } from '../../application/candle.service';
|
||||||
|
|
||||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||||
|
import {
|
||||||
|
CatalogEnrichmentService,
|
||||||
|
type CatalogPayload,
|
||||||
|
} from '../../application/catalog-enrichment.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
@@ -10,22 +13,15 @@ import {
|
|||||||
multiply,
|
multiply,
|
||||||
subtract,
|
subtract,
|
||||||
} from '../../../../../common/decimal';
|
} from '../../../../../common/decimal';
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||||
import { CandleService } from '../../application/candle.service';
|
|
||||||
import {
|
|
||||||
CatalogEnrichmentService,
|
|
||||||
type CatalogPayload,
|
|
||||||
} from '../../application/catalog-enrichment.service';
|
|
||||||
import { timestamp } from '../../application/instrument-market';
|
import { timestamp } from '../../application/instrument-market';
|
||||||
import { money } from '../infrastructure/bond-money.mapper';
|
|
||||||
import {
|
import {
|
||||||
BondCandlesQueryDto,
|
BondCandlesQueryDto,
|
||||||
BondQuoteDto,
|
BondQuoteDto,
|
||||||
BondQuoteResourceDto,
|
BondQuoteResourceDto,
|
||||||
} from '../presentation/bond.dto';
|
} from '../presentation/bond.dto';
|
||||||
|
|
||||||
const ZERO_AMOUNT = 0n;
|
import { money } from '../infrastructure/bond-money.mapper';
|
||||||
|
|
||||||
const PRICE_DECIMAL_PLACES = 9;
|
const PRICE_DECIMAL_PLACES = 9;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -34,7 +30,6 @@ export class BondMarketService {
|
|||||||
private readonly history: CandleService,
|
private readonly history: CandleService,
|
||||||
private readonly sources: CatalogEnrichmentService,
|
private readonly sources: CatalogEnrichmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async quote(
|
async quote(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
@@ -48,13 +43,12 @@ export class BondMarketService {
|
|||||||
observe,
|
observe,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument ?
|
data: result.values.instrument
|
||||||
bondQuote(result.values, result.unavailable, result.observedAt) :
|
? bondQuote(result.values, result.unavailable, result.observedAt)
|
||||||
null,
|
: null,
|
||||||
meta: result.meta,
|
meta: result.meta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
candles(
|
candles(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
@@ -83,8 +77,8 @@ export function bondQuote(
|
|||||||
const cleanPrice =
|
const cleanPrice =
|
||||||
pricePercent !== null &&
|
pricePercent !== null &&
|
||||||
nominal &&
|
nominal &&
|
||||||
decimalFromString(nominal.value).value > ZERO_AMOUNT ?
|
decimalFromString(nominal.value).value > 0n
|
||||||
{
|
? {
|
||||||
currency: nominal.currency,
|
currency: nominal.currency,
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
divide(
|
divide(
|
||||||
@@ -96,11 +90,11 @@ export function bondQuote(
|
|||||||
PRICE_DECIMAL_PLACES,
|
PRICE_DECIMAL_PLACES,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
null;
|
: null;
|
||||||
const dirtyPrice =
|
const dirtyPrice =
|
||||||
cleanPrice && aci && aci.currency === cleanPrice.currency ?
|
cleanPrice && aci && aci.currency === cleanPrice.currency
|
||||||
{
|
? {
|
||||||
currency: cleanPrice.currency,
|
currency: cleanPrice.currency,
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
add(
|
add(
|
||||||
@@ -108,8 +102,8 @@ export function bondQuote(
|
|||||||
decimalFromString(aci.value),
|
decimalFromString(aci.value),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
null;
|
: null;
|
||||||
return {
|
return {
|
||||||
pricePercent,
|
pricePercent,
|
||||||
cleanPrice,
|
cleanPrice,
|
||||||
@@ -118,10 +112,10 @@ export function bondQuote(
|
|||||||
priceAt,
|
priceAt,
|
||||||
tradingStatus,
|
tradingStatus,
|
||||||
changePercentagePoints:
|
changePercentagePoints:
|
||||||
last?.price && previous?.close ?
|
last?.price && previous?.close
|
||||||
formatDecimal(subtract(decimal(last.price), decimal(previous.close))) :
|
? formatDecimal(subtract(decimal(last.price), decimal(previous.close)))
|
||||||
null,
|
: null,
|
||||||
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
|
||||||
unavailable,
|
unavailable,
|
||||||
observedAt,
|
observedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
|
||||||
|
|
||||||
import { instrumentPosition } from '../../application/instrument-position';
|
|
||||||
import { bondCoupons, bondMetrics } from './bond-income.service';
|
import { bondCoupons, bondMetrics } from './bond-income.service';
|
||||||
import { bondQuote } from './bond-market.service';
|
import { bondQuote } from './bond-market.service';
|
||||||
|
import { instrumentPosition } from '../../application/instrument-position';
|
||||||
|
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
||||||
jest.mock('../../../../../prisma/prisma.service', () => ({
|
jest.mock('../../../../../prisma/prisma.service', () => ({
|
||||||
PrismaService: class {},
|
PrismaService: class {},
|
||||||
}));
|
}));
|
||||||
@@ -123,9 +122,9 @@ describe('bond financial projections', () => {
|
|||||||
{
|
{
|
||||||
couponDate: time('2026-10-10'),
|
couponDate: time('2026-10-10'),
|
||||||
payOneBond:
|
payOneBond:
|
||||||
scenario === 'unknown' ?
|
scenario === 'unknown'
|
||||||
undefined :
|
? undefined
|
||||||
money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
||||||
couponPeriod: scenario === 'period' ? 0 : 90,
|
couponPeriod: scenario === 'period' ? 0 : 90,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user