Compare commits
1
Commits
ca5dbcb382
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92f216d520 |
+2
-4
@@ -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 остаются
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.2.3",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.2.3",
|
"version": "1.0.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.4",
|
"@grpc/grpc-js": "1.14.4",
|
||||||
|
|||||||
+1
-1
@@ -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,
|
||||||
|
|||||||
@@ -15,12 +15,6 @@ export class ProblemDetailsDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
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?: { pointer: string; detail: string }[];
|
errors?: { pointer: string; detail: string }[];
|
||||||
|
|||||||
@@ -14,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');
|
||||||
@@ -26,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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
? 'VALIDATION_ERROR'
|
? 'VALIDATION_ERROR'
|
||||||
: this.codeFor(status, payload);
|
: this.codeFor(status, payload);
|
||||||
(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')
|
||||||
@@ -61,18 +69,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
|
|
||||||
private validationErrors(
|
private validationErrors(
|
||||||
errors: ValidationError[],
|
errors: ValidationError[],
|
||||||
prefix = '#',
|
|
||||||
): { pointer: string; detail: string }[] {
|
): { pointer: string; detail: string }[] {
|
||||||
return errors.flatMap((error) => {
|
return errors.flatMap((error) =>
|
||||||
if (!error || typeof error.property !== 'string') return [];
|
Object.values(error.constraints ?? {}).map((detail) => ({
|
||||||
const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`;
|
pointer: `#/${error.property}`,
|
||||||
return [
|
detail,
|
||||||
...Object.values(error.constraints ?? {}).map((detail) => ({
|
})),
|
||||||
pointer,
|
);
|
||||||
detail,
|
|
||||||
})),
|
|
||||||
...this.validationErrors(error.children ?? [], pointer),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export class RequestLoggingMiddleware implements NestMiddleware {
|
|||||||
...(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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const allowedFields = new Set([
|
|||||||
'status',
|
'status',
|
||||||
'durationMs',
|
'durationMs',
|
||||||
'code',
|
'code',
|
||||||
|
'stack',
|
||||||
'operation',
|
'operation',
|
||||||
'grpcCode',
|
'grpcCode',
|
||||||
'attempts',
|
'attempts',
|
||||||
|
|||||||
@@ -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,174 +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)
|
|
||||||
await expect(result).rejects.toMatchObject({
|
|
||||||
response: { code: 'MOEX_UNAVAILABLE' },
|
|
||||||
});
|
|
||||||
else
|
|
||||||
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']);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,26 +123,22 @@ export class MoexClient {
|
|||||||
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 !== 1 ||
|
index === null ||
|
||||||
index !== start ||
|
|
||||||
total === null ||
|
total === null ||
|
||||||
pageSize === null ||
|
pageSize === null ||
|
||||||
pageSize === 0 ||
|
index + pageSize >= total
|
||||||
current.length !== Math.min(pageSize, total - start)
|
|
||||||
)
|
)
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
return rows;
|
||||||
if (index + pageSize >= total) return rows;
|
|
||||||
start = index + pageSize;
|
start = index + pageSize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,13 +161,14 @@ 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(
|
||||||
columns.map((column, index) => [column.toLowerCase(), values[index]]),
|
columns.map((column, index) => [column.toLowerCase(), values[index]]),
|
||||||
@@ -194,6 +191,6 @@ function currencyText(value: unknown): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function integer(value: unknown): number | null {
|
function integer(value: unknown): number | null {
|
||||||
const result = Number(numberText(value) ?? NaN);
|
const result = Number(value);
|
||||||
return Number.isSafeInteger(result) && result >= 0 ? result : null;
|
return Number.isSafeInteger(result) && result >= 0 ? result : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,11 +5,24 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
import {
|
||||||
|
SupportedTbankAccount,
|
||||||
|
accessibleTbankAccount,
|
||||||
|
supportedTbankAccounts,
|
||||||
|
} from './tbank-account.policy';
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
import type { TbankAccount } from './tbank.client';
|
import { TbankAccount, TbankClient } from './tbank.client';
|
||||||
|
|
||||||
|
export type TbankConnectionContext = {
|
||||||
|
token: string;
|
||||||
|
generation: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TbankAccountContext = TbankConnectionContext & {
|
||||||
|
account: TbankAccount;
|
||||||
|
};
|
||||||
|
|
||||||
export type TbankSyncAttempt = {
|
export type TbankSyncAttempt = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -23,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(
|
||||||
@@ -237,4 +251,81 @@ export class TbankAccessService {
|
|||||||
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ export function supportedTbankAccounts(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
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);
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
export { InstrumentMetadataService } from './instrument-metadata.service';
|
export { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
export {
|
export {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankSyncAttempt,
|
type TbankConnectionContext,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
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 { TbankTokenService } from './tbank-token.service';
|
export { TbankTokenService } from './tbank-token.service';
|
||||||
|
|||||||
@@ -46,26 +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;
|
|
||||||
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({
|
||||||
|
|||||||
@@ -292,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 ?? []);
|
||||||
}
|
}
|
||||||
@@ -344,7 +344,7 @@ 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 ?? []);
|
||||||
}
|
}
|
||||||
@@ -552,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() + 1);
|
|
||||||
return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-13
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
INestApplication,
|
|
||||||
ValidationPipe,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
@@ -30,14 +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({
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ export class AuthService {
|
|||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
): 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' });
|
||||||
@@ -40,7 +41,7 @@ export class AuthService {
|
|||||||
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),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -57,7 +58,7 @@ export class AuthService {
|
|||||||
password: string,
|
password: string,
|
||||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
): 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,
|
||||||
@@ -102,27 +103,26 @@ export class AuthService {
|
|||||||
|
|
||||||
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 {
|
if (payload.type !== 'refresh') return;
|
||||||
return;
|
const session = await this.prisma.session.findUnique({
|
||||||
}
|
where: { id: payload.sid },
|
||||||
if (payload.type !== 'refresh') return;
|
|
||||||
const session = await this.prisma.session.findUnique({
|
|
||||||
where: { id: payload.sid },
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
session &&
|
|
||||||
!session.revokedAt &&
|
|
||||||
session.refreshTokenHash === this.hash(token)
|
|
||||||
) {
|
|
||||||
await this.prisma.session.update({
|
|
||||||
where: { id: session.id },
|
|
||||||
data: { revokedAt: new Date() },
|
|
||||||
});
|
});
|
||||||
|
if (
|
||||||
|
session &&
|
||||||
|
!session.revokedAt &&
|
||||||
|
session.refreshTokenHash === this.hash(token)
|
||||||
|
) {
|
||||||
|
await this.prisma.session.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Logout is intentionally idempotent.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -87,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
import { BankOperationKind } from './bank-operation-kind';
|
import { BankOperationKind } from './bank-operation-kind';
|
||||||
|
|
||||||
|
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',
|
||||||
@@ -53,7 +54,7 @@ export function bankBalance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bankSummary(
|
export function bankSummary(
|
||||||
period: { from: string; to: string },
|
period: string | { from: string; to: string },
|
||||||
operations: Array<{
|
operations: Array<{
|
||||||
kind: string;
|
kind: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -69,7 +70,12 @@ export function bankSummary(
|
|||||||
>();
|
>();
|
||||||
// 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
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { MoneyDto } from '../../../common/money.dto';
|
|||||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InvestmentDependencyDto,
|
|
||||||
InvestmentReadQueryDto,
|
InvestmentReadQueryDto,
|
||||||
InvestmentResourceMetaDto,
|
InvestmentResourceMetaDto,
|
||||||
} from '../investment-resource.dto';
|
} from '../investment-resource.dto';
|
||||||
@@ -12,20 +11,12 @@ import {
|
|||||||
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;
|
||||||
@@ -161,27 +152,9 @@ export class IncomeSummaryResourceDto {
|
|||||||
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 {
|
|
||||||
@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: IncomeAnalyticsMetaDto })
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
declare meta: IncomeAnalyticsMetaDto;
|
declare meta: InvestmentResourceMetaDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,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(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
import {
|
import {
|
||||||
IncomeAnalyticsResourceDto,
|
IncomeAnalyticsResourceDto,
|
||||||
IncomeDependencyDto,
|
|
||||||
IncomeSummaryResourceDto,
|
IncomeSummaryResourceDto,
|
||||||
} from './income-analytics.dto';
|
} from './income-analytics.dto';
|
||||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||||
@@ -91,44 +90,35 @@ 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({
|
||||||
{
|
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||||
...(plan.yearAgo && year
|
dataRevision: 'unavailable',
|
||||||
? year.meta
|
availability: 'missing',
|
||||||
: investmentResourceMeta({
|
fetchedAt: null,
|
||||||
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
freshForMs: 0,
|
||||||
dataRevision: 'unavailable',
|
work: null,
|
||||||
availability: 'missing',
|
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||||
fetchedAt: null,
|
}),
|
||||||
freshForMs: 0,
|
});
|
||||||
work: null,
|
|
||||||
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 : 0,
|
||||||
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) => ({
|
||||||
|
|||||||
@@ -53,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,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']);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -13,8 +13,8 @@ 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 { CatalogEnrichmentService } from './catalog-enrichment.service';
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
@@ -39,12 +39,8 @@ type Producer = {
|
|||||||
force: boolean;
|
force: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
observers: TbankSyncAttempt[];
|
observers: TbankSyncAttempt[];
|
||||||
pass?: {
|
ranges?: CandleRange[];
|
||||||
now: Date;
|
nextWindow?: number;
|
||||||
force: boolean;
|
|
||||||
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;
|
||||||
@@ -327,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: [] };
|
||||||
@@ -355,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,
|
||||||
@@ -363,24 +354,20 @@ export class CandleService {
|
|||||||
input.uid,
|
input.uid,
|
||||||
);
|
);
|
||||||
if (!instrument) throw new NotFoundException();
|
if (!instrument) throw new NotFoundException();
|
||||||
let pass = producer.pass;
|
const range = rangeFor(input, instrument, now);
|
||||||
if (!pass || (producer.force && (!pass.force || producer.now > pass.now))) {
|
if (!producer.ranges) {
|
||||||
const { now, force } = producer;
|
|
||||||
const range = rangeFor(input, instrument, now);
|
|
||||||
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(),
|
producer.nextWindow = 0;
|
||||||
nextWindow: 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const { ranges, now, force } = pass;
|
const ranges = producer.ranges;
|
||||||
const index = pass.nextWindow;
|
const index = producer.nextWindow ?? 0;
|
||||||
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)
|
||||||
@@ -411,7 +398,7 @@ 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] },
|
||||||
@@ -419,10 +406,13 @@ export class CandleService {
|
|||||||
))
|
))
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
pass.nextWindow = index + 1;
|
producer.nextWindow = index + 1;
|
||||||
if (pass.nextWindow < ranges.length) return null;
|
if (producer.nextWindow < ranges.length) return null;
|
||||||
}
|
}
|
||||||
return true;
|
return this.repository.publish(attempt, {
|
||||||
|
uid: input.uid,
|
||||||
|
ranges: producer.force ? ranges : [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async drain() {
|
private async drain() {
|
||||||
@@ -453,43 +443,20 @@ export class CandleService {
|
|||||||
code = response.code;
|
code = response.code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Seal publication with reservations so a late forced join cannot be
|
if (!success) await this.repository.fail(producer.attempt, code);
|
||||||
// acknowledged after the final demand check but before work completion.
|
this.active.delete(producer.attempt.attemptId);
|
||||||
const finished = this.reservations.then(async () => {
|
for (const observer of producer.observers) {
|
||||||
const pass = producer.pass;
|
if (
|
||||||
if (success && pass) {
|
!success ||
|
||||||
if (producer.force && (!pass.force || producer.now > pass.now))
|
!(await this.repository.completeObserver(
|
||||||
return false;
|
observer,
|
||||||
try {
|
producer.attempt,
|
||||||
success = await this.repository.publish(producer.attempt, {
|
))
|
||||||
uid: producer.input.uid,
|
)
|
||||||
ranges: pass.force ? pass.ranges : [],
|
await this.repository.fail(observer, code);
|
||||||
});
|
this.active.delete(observer.attemptId);
|
||||||
} catch {
|
|
||||||
success = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!success) await this.repository.fail(producer.attempt, code);
|
|
||||||
this.active.delete(producer.attempt.attemptId);
|
|
||||||
for (const observer of producer.observers) {
|
|
||||||
if (
|
|
||||||
!success ||
|
|
||||||
!(await this.repository.completeObserver(
|
|
||||||
observer,
|
|
||||||
producer.attempt,
|
|
||||||
))
|
|
||||||
)
|
|
||||||
await this.repository.fail(observer, code);
|
|
||||||
this.active.delete(observer.attemptId);
|
|
||||||
}
|
|
||||||
this.producers.delete(scope(producer.input));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
this.reservations = finished.catch(() => undefined);
|
|
||||||
if (!(await finished)) {
|
|
||||||
this.queue.push(producer);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
this.producers.delete(scope(producer.input));
|
||||||
if (success && !producer.force && producer.input.period !== 'all')
|
if (success && !producer.force && producer.input.period !== 'all')
|
||||||
await this.request(
|
await this.request(
|
||||||
producer.attempt.userId,
|
producer.attempt.userId,
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
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,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { TbankDividend } from '../../../../integrations/tbank/tbank.api';
|
||||||
import type { MoexBondEvent } from '../../../../integrations/moex/moex.api';
|
import type { MoexBondEvent } from '../../../../integrations/moex/moex.api';
|
||||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||||
@@ -13,6 +14,11 @@ import {
|
|||||||
} from '../../../../common/time';
|
} from '../../../../common/time';
|
||||||
import { 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 type {
|
||||||
|
TbankCandle,
|
||||||
|
TbankFundamentals,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { TbankCoupon } from '../../../../integrations/tbank/tbank-market.types';
|
||||||
import { previousClose, timestamp } from './instrument-market';
|
import { previousClose, timestamp } from './instrument-market';
|
||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
@@ -23,14 +29,10 @@ import {
|
|||||||
tbankSourceNeedsRefresh,
|
tbankSourceNeedsRefresh,
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
type TbankBond,
|
type TbankBond,
|
||||||
type TbankCandle,
|
|
||||||
type TbankCoupon,
|
|
||||||
type TbankDividend,
|
|
||||||
type TbankFundamentals,
|
|
||||||
type TbankLastPrice,
|
|
||||||
type TbankShare,
|
type TbankShare,
|
||||||
type TbankSyncAttempt,
|
|
||||||
} 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 {
|
import {
|
||||||
CatalogDiscoveryRepository,
|
CatalogDiscoveryRepository,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||||
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
import type {
|
import type {
|
||||||
TbankCandle,
|
TbankCandle,
|
||||||
TbankTimestamp,
|
TbankTimestamp,
|
||||||
@@ -35,3 +36,16 @@ export function previousClose(
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||||
import {
|
import {
|
||||||
confirmedCandleCoverage,
|
confirmedCandleCoverage,
|
||||||
type CandleRange,
|
type CandleRange,
|
||||||
@@ -78,37 +78,8 @@ export class CandleRepository {
|
|||||||
previous?.state === 'running' &&
|
previous?.state === 'running' &&
|
||||||
previous.processRunId === runId &&
|
previous.processRunId === runId &&
|
||||||
active.has(previous.attemptId)
|
active.has(previous.attemptId)
|
||||||
) {
|
)
|
||||||
const previousIntent = previous.intent as Prisma.JsonObject;
|
return { work: previous, launch: false };
|
||||||
const previousNow =
|
|
||||||
typeof previousIntent.now === 'string' ? previousIntent.now : '';
|
|
||||||
const nextPriority = Math.min(previous.priority, priority);
|
|
||||||
if (
|
|
||||||
nextPriority === previous.priority &&
|
|
||||||
(!intent.force ||
|
|
||||||
(previousIntent.force === true &&
|
|
||||||
(!intent.now || intent.now <= previousNow)))
|
|
||||||
)
|
|
||||||
return { work: previous, launch: false };
|
|
||||||
const work = await tx.tbankSyncWork.update({
|
|
||||||
where: { id: previous.id },
|
|
||||||
data: {
|
|
||||||
priority: nextPriority,
|
|
||||||
intent: {
|
|
||||||
...previousIntent,
|
|
||||||
...(intent.force
|
|
||||||
? {
|
|
||||||
force: true,
|
|
||||||
...(intent.now && intent.now > previousNow
|
|
||||||
? { now: intent.now }
|
|
||||||
: {}),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { work, launch: false };
|
|
||||||
}
|
|
||||||
if (previous)
|
if (previous)
|
||||||
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
|
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
|
||||||
const data = {
|
const data = {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||||
import type {
|
import type {
|
||||||
TbankBond,
|
TbankBond,
|
||||||
TbankShare,
|
TbankShare,
|
||||||
TbankSyncAttempt,
|
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
export type CatalogQuery = {
|
export type CatalogQuery = {
|
||||||
|
|||||||
+14
-23
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
PortfolioDto,
|
PortfolioDto,
|
||||||
PortfolioPositionDto,
|
PortfolioPositionDto,
|
||||||
} from '../../portfolio/presentation/portfolio.dto';
|
} from '../../../portfolio/presentation/portfolio.dto';
|
||||||
import { instrumentPosition } from './instrument-position';
|
import { stockPosition } from './stock-position';
|
||||||
|
|
||||||
const money = (value: string, currency = 'RUB') => ({ value, currency });
|
const money = (value: string, currency = 'RUB') => ({ value, currency });
|
||||||
const row = (
|
const row = (
|
||||||
@@ -34,14 +34,13 @@ const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
|
|||||||
|
|
||||||
describe('stock position', () => {
|
describe('stock position', () => {
|
||||||
it('weights costs exactly and keeps preferred shares separate', () => {
|
it('weights costs exactly and keeps preferred shares separate', () => {
|
||||||
const result = instrumentPosition(
|
const result = stockPosition(
|
||||||
portfolio([
|
portfolio([
|
||||||
row(),
|
row(),
|
||||||
row('3', '20', '70'),
|
row('3', '20', '70'),
|
||||||
row('100', '1', '100', 'preferred'),
|
row('100', '1', '100', 'preferred'),
|
||||||
]),
|
]),
|
||||||
'common',
|
'common',
|
||||||
'STOCK',
|
|
||||||
);
|
);
|
||||||
expect(result.quantity).toBe('5');
|
expect(result.quantity).toBe('5');
|
||||||
expect(result.averagePurchasePrice).toEqual(money('16'));
|
expect(result.averagePurchasePrice).toEqual(money('16'));
|
||||||
@@ -50,18 +49,14 @@ describe('stock position', () => {
|
|||||||
expect(result.returnPercent).toBe('25');
|
expect(result.returnPercent).toBe('25');
|
||||||
expect(result.accounts[0].returnPercent).toBe('50');
|
expect(result.accounts[0].returnPercent).toBe('50');
|
||||||
});
|
});
|
||||||
it.each(['9007199254740993', '0.25'])(
|
it('does not round large amounts through floating point', () => {
|
||||||
'preserves exact quantity %s',
|
const result = stockPosition(
|
||||||
(quantity) => {
|
portfolio([row('9007199254740993', '0.1', '900719925474099.3')]),
|
||||||
const result = instrumentPosition(
|
'common',
|
||||||
portfolio([row(quantity, '0.1', '900719925474099.3')]),
|
);
|
||||||
'common',
|
expect(result.quantity).toBe('9007199254740993');
|
||||||
'STOCK',
|
expect(result.averagePurchasePrice?.value).toBe('0.1');
|
||||||
);
|
});
|
||||||
expect(result.quantity).toBe(quantity);
|
|
||||||
expect(result.averagePurchasePrice?.value).toBe('0.1');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
it('does not sum currencies or treat unknown values as zero', () => {
|
it('does not sum currencies or treat unknown values as zero', () => {
|
||||||
const second = {
|
const second = {
|
||||||
...row(),
|
...row(),
|
||||||
@@ -69,11 +64,7 @@ describe('stock position', () => {
|
|||||||
averagePurchasePrice: null,
|
averagePurchasePrice: null,
|
||||||
absoluteReturn: null,
|
absoluteReturn: null,
|
||||||
};
|
};
|
||||||
const result = instrumentPosition(
|
const result = stockPosition(portfolio([row(), second]), 'common');
|
||||||
portfolio([row(), second]),
|
|
||||||
'common',
|
|
||||||
'STOCK',
|
|
||||||
);
|
|
||||||
expect(result.quantity).toBe('4');
|
expect(result.quantity).toBe('4');
|
||||||
expect(result.currentValue).toBeNull();
|
expect(result.currentValue).toBeNull();
|
||||||
expect(result.averagePurchasePrice).toBeNull();
|
expect(result.averagePurchasePrice).toBeNull();
|
||||||
@@ -81,14 +72,14 @@ describe('stock position', () => {
|
|||||||
expect(result.returnPercent).toBeNull();
|
expect(result.returnPercent).toBeNull();
|
||||||
});
|
});
|
||||||
it('keeps no-position and offsetting long/short holdings honest', () => {
|
it('keeps no-position and offsetting long/short holdings honest', () => {
|
||||||
expect(instrumentPosition(portfolio([]), 'common', 'STOCK')).toMatchObject({
|
expect(stockPosition(portfolio([]), 'common')).toMatchObject({
|
||||||
quantity: '0',
|
quantity: '0',
|
||||||
accounts: [],
|
accounts: [],
|
||||||
currentValue: null,
|
currentValue: null,
|
||||||
returnPercent: null,
|
returnPercent: null,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
instrumentPosition(portfolio([row(), row('-2')]), 'common', 'STOCK')
|
stockPosition(portfolio([row(), row('-2')]), 'common')
|
||||||
.averagePurchasePrice,
|
.averagePurchasePrice,
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
||||||
|
import { instrumentPosition } from '../../application/instrument-position';
|
||||||
|
export function stockPosition(portfolio: PortfolioDto, uid: string) {
|
||||||
|
return instrumentPosition(portfolio, uid, 'STOCK');
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
const MOSCOW_UTC_OFFSET_HOURS = 3;
|
const MOSCOW_UTC_OFFSET_HOURS = 3;
|
||||||
|
const MOSCOW_DAY_END_UTC = {
|
||||||
|
hours: 20,
|
||||||
|
minutes: 59,
|
||||||
|
seconds: 59,
|
||||||
|
milliseconds: 999,
|
||||||
|
};
|
||||||
export type DateOnlyRange = { from: string; to: string };
|
export type DateOnlyRange = { from: string; to: string };
|
||||||
const moscow = 'Europe/Moscow';
|
const moscow = 'Europe/Moscow';
|
||||||
const moscowFormatter = new Intl.DateTimeFormat('en-CA', {
|
const moscowFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
@@ -13,21 +19,25 @@ export function moscowDate(value: Date): string {
|
|||||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inclusive Moscow calendar dates become a half-open instant range, capped at now.
|
export function dateOnlyBounds(range: DateOnlyRange): { from: Date; to: Date } {
|
||||||
export function dateOnlyBounds(
|
|
||||||
range: DateOnlyRange,
|
|
||||||
now = new Date(),
|
|
||||||
): { from: Date; to: Date } {
|
|
||||||
const from = dateParts(range.from);
|
const from = dateParts(range.from);
|
||||||
const end = dateParts(range.to);
|
const end = dateParts(range.to);
|
||||||
const to = new Date(
|
const to = new Date(
|
||||||
Date.UTC(end[0], end[1] - 1, end[2] + 1, -MOSCOW_UTC_OFFSET_HOURS),
|
Date.UTC(
|
||||||
|
end[0],
|
||||||
|
end[1] - 1,
|
||||||
|
end[2],
|
||||||
|
MOSCOW_DAY_END_UTC.hours,
|
||||||
|
MOSCOW_DAY_END_UTC.minutes,
|
||||||
|
MOSCOW_DAY_END_UTC.seconds,
|
||||||
|
MOSCOW_DAY_END_UTC.milliseconds,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
from: new Date(
|
from: new Date(
|
||||||
Date.UTC(from[0], from[1] - 1, from[2], -MOSCOW_UTC_OFFSET_HOURS),
|
Date.UTC(from[0], from[1] - 1, from[2], -MOSCOW_UTC_OFFSET_HOURS),
|
||||||
),
|
),
|
||||||
to: to > now ? now : to,
|
to: to > new Date() ? new Date() : to,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
jest.mock('../../prisma/prisma.service', () => ({ PrismaService: class {} }));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
planInvestmentScreen,
|
planInvestmentScreen,
|
||||||
ledgerRange,
|
ledgerRange,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { dateOnlyBounds, moscowDate } from './investment-calendar';
|
|||||||
import {
|
import {
|
||||||
operationIntervals,
|
operationIntervals,
|
||||||
type OperationInterval,
|
type OperationInterval,
|
||||||
} from './operations/domain/operations-sync-plan';
|
} from './operations/operations.api';
|
||||||
|
|
||||||
export type InvestmentScreen = {
|
export type InvestmentScreen = {
|
||||||
screen:
|
screen:
|
||||||
@@ -141,8 +141,11 @@ export function planInvestmentScreen(
|
|||||||
? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
|
? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
|
||||||
: []),
|
: []),
|
||||||
]) {
|
]) {
|
||||||
const bounds = dateOnlyBounds(range, now);
|
const bounds = dateOnlyBounds(range);
|
||||||
operations(bounds.from, bounds.to);
|
operations(
|
||||||
|
bounds.from,
|
||||||
|
new Date(Math.min(bounds.to.valueOf() + 1, now.valueOf())),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (screen.screen === 'income' && !period.yearAgo)
|
if (screen.screen === 'income' && !period.yearAgo)
|
||||||
unavailable.push({
|
unavailable.push({
|
||||||
|
|||||||
@@ -356,10 +356,7 @@ export class BrokerageOperationsService {
|
|||||||
: {
|
: {
|
||||||
accountId,
|
accountId,
|
||||||
items: rows.map((row) => ({
|
items: rows.map((row) => ({
|
||||||
...mapBrokerageOperation(
|
...mapBrokerageOperation(row.payload as TbankOperationItem),
|
||||||
row.payload as TbankOperationItem,
|
|
||||||
row.occurredAt,
|
|
||||||
),
|
|
||||||
...(accountId === 'all'
|
...(accountId === 'all'
|
||||||
? {
|
? {
|
||||||
account: {
|
account: {
|
||||||
@@ -619,10 +616,7 @@ export class BrokerageOperationsService {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
...mapBrokerageOperation(
|
...mapBrokerageOperation(row.payload as TbankOperationItem),
|
||||||
row.payload as TbankOperationItem,
|
|
||||||
row.occurredAt,
|
|
||||||
),
|
|
||||||
...(accountId === 'all'
|
...(accountId === 'all'
|
||||||
? {
|
? {
|
||||||
account: {
|
account: {
|
||||||
|
|||||||
@@ -4,17 +4,6 @@ import {
|
|||||||
} from './brokerage-operation.mapper';
|
} from './brokerage-operation.mapper';
|
||||||
|
|
||||||
describe('mapBrokerageOperation', () => {
|
describe('mapBrokerageOperation', () => {
|
||||||
it('keeps subsecond timestamps at persisted millisecond precision and the standalone epoch default', () => {
|
|
||||||
expect(
|
|
||||||
mapBrokerageOperation({
|
|
||||||
date: { seconds: '1768000000', nanos: 600999999 },
|
|
||||||
}).occurredAt,
|
|
||||||
).toBe('2026-01-09T23:06:40.600Z');
|
|
||||||
expect(mapBrokerageOperation({}).occurredAt).toBe(
|
|
||||||
'1970-01-01T00:00:00.000Z',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['OPERATION_TYPE_COUPON', 'income'],
|
['OPERATION_TYPE_COUPON', 'income'],
|
||||||
['OPERATION_TYPE_DIVIDEND', 'income'],
|
['OPERATION_TYPE_DIVIDEND', 'income'],
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
OperationCategory,
|
OperationCategory,
|
||||||
} from '../presentation/brokerage-operation.dto';
|
} from '../presentation/brokerage-operation.dto';
|
||||||
|
|
||||||
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
|
|
||||||
|
|
||||||
const types: Record<string, OperationCategory> = {
|
const types: Record<string, OperationCategory> = {
|
||||||
OPERATION_TYPE_BUY: 'trade',
|
OPERATION_TYPE_BUY: 'trade',
|
||||||
OPERATION_TYPE_BUY_CARD: 'trade',
|
OPERATION_TYPE_BUY_CARD: 'trade',
|
||||||
@@ -44,11 +42,10 @@ export function operationCategory(type: string | undefined): OperationCategory {
|
|||||||
|
|
||||||
export function mapBrokerageOperation(
|
export function mapBrokerageOperation(
|
||||||
operation: TbankOperationItem,
|
operation: TbankOperationItem,
|
||||||
occurredAt = operationTimestamp(operation.date),
|
|
||||||
): BrokerageOperationDto {
|
): BrokerageOperationDto {
|
||||||
return {
|
return {
|
||||||
id: operation.id ?? operation.cursor ?? '',
|
id: operation.id ?? operation.cursor ?? '',
|
||||||
occurredAt: occurredAt.toISOString(),
|
occurredAt: timestamp(operation.date),
|
||||||
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
|
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
|
||||||
category: operationCategory(operation.type),
|
category: operationCategory(operation.type),
|
||||||
description: operation.description?.trim() || operation.name?.trim() || '',
|
description: operation.description?.trim() || operation.name?.trim() || '',
|
||||||
@@ -85,11 +82,10 @@ function money(value: TbankMoney | undefined) {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function operationTimestamp(
|
function timestamp(
|
||||||
value: { seconds?: string | number; nanos?: number } | undefined,
|
value: { seconds?: string | number; nanos?: number } | undefined,
|
||||||
) {
|
) {
|
||||||
return new Date(
|
return new Date(
|
||||||
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND +
|
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND,
|
||||||
Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND),
|
).toISOString();
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-18
@@ -5,21 +5,15 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
import type {
|
import type {
|
||||||
TbankOperationItem,
|
TbankOperationItem,
|
||||||
TbankOperationsByCursorResponse,
|
TbankOperationsByCursorResponse,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import {
|
import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar';
|
||||||
DateOnlyRange,
|
import { operationCategory } from './brokerage-operation.mapper';
|
||||||
dateOnlyBounds,
|
|
||||||
moscowDate,
|
|
||||||
} from '../../investment-calendar';
|
|
||||||
import {
|
|
||||||
operationCategory,
|
|
||||||
operationTimestamp,
|
|
||||||
} from './brokerage-operation.mapper';
|
|
||||||
import {
|
import {
|
||||||
type OperationInterval,
|
type OperationInterval,
|
||||||
operationIntervals,
|
operationIntervals,
|
||||||
@@ -47,6 +41,7 @@ type PageProgress = {
|
|||||||
pages: number;
|
pages: number;
|
||||||
};
|
};
|
||||||
const progressItem = '';
|
const progressItem = '';
|
||||||
|
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerageOperationsRepository {
|
export class BrokerageOperationsRepository {
|
||||||
@@ -101,12 +96,14 @@ export class BrokerageOperationsRepository {
|
|||||||
? new Date(`${oldest}T00:00:00+03:00`)
|
? new Date(`${oldest}T00:00:00+03:00`)
|
||||||
: undefined;
|
: undefined;
|
||||||
const periods = input.periods?.(openedAt).map((period) => {
|
const periods = input.periods?.(openedAt).map((period) => {
|
||||||
const now = new Date();
|
const bounds = dateOnlyBounds(period);
|
||||||
const bounds = dateOnlyBounds(period, now);
|
// Date-only end is inclusive; persisted coverage is half-open.
|
||||||
|
const end = new Date(`${period.to}T21:00:00Z`);
|
||||||
return {
|
return {
|
||||||
...bounds,
|
...bounds,
|
||||||
key: `${period.from}/${period.to}`,
|
key: `${period.from}/${period.to}`,
|
||||||
movingEnd: period.to >= moscowDate(now),
|
movingEnd: end.valueOf() > Date.now(),
|
||||||
|
to: new Date(Math.min(end.valueOf(), Date.now())),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const binding = createHash('sha256')
|
const binding = createHash('sha256')
|
||||||
@@ -651,11 +648,13 @@ export class BrokerageOperationsRepository {
|
|||||||
for (const operation of response.items ?? []) {
|
for (const operation of response.items ?? []) {
|
||||||
if (!operation.id?.trim())
|
if (!operation.id?.trim())
|
||||||
throw new Error('Missing broker operation identity');
|
throw new Error('Missing broker operation identity');
|
||||||
const occurredAt = operationTimestamp(operation.date);
|
const occurredAt = new Date(
|
||||||
if (
|
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
|
||||||
operation.date?.seconds === undefined ||
|
Math.floor(
|
||||||
!Number.isFinite(occurredAt.valueOf())
|
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
|
||||||
)
|
),
|
||||||
|
);
|
||||||
|
if (!Number.isFinite(occurredAt.valueOf()))
|
||||||
throw new Error('Invalid operation timestamp');
|
throw new Error('Invalid operation timestamp');
|
||||||
if (
|
if (
|
||||||
occurredAt < interval.from ||
|
occurredAt < interval.from ||
|
||||||
@@ -792,7 +791,12 @@ export class BrokerageOperationsRepository {
|
|||||||
if (previous && isDeepStrictEqual(previous.payload, operation))
|
if (previous && isDeepStrictEqual(previous.payload, operation))
|
||||||
continue;
|
continue;
|
||||||
const data = {
|
const data = {
|
||||||
occurredAt: operationTimestamp(operation.date),
|
occurredAt: new Date(
|
||||||
|
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
|
||||||
|
Math.floor(
|
||||||
|
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
|
||||||
|
),
|
||||||
|
),
|
||||||
instrumentUid: operation.instrumentUid ?? null,
|
instrumentUid: operation.instrumentUid ?? null,
|
||||||
category: operationCategory(operation.type),
|
category: operationCategory(operation.type),
|
||||||
payload: row.payload as Prisma.InputJsonObject,
|
payload: row.payload as Prisma.InputJsonObject,
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
|
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
|
||||||
export { BrokerageOperationsService } from './application/brokerage-operations.service';
|
export { BrokerageOperationsService } from './application/brokerage-operations.service';
|
||||||
|
|
||||||
export type { OperationInterval } from './domain/operations-sync-plan';
|
export {
|
||||||
|
operationIntervals,
|
||||||
|
type OperationInterval,
|
||||||
|
} from './domain/operations-sync-plan';
|
||||||
|
|
||||||
export { operationCategories } from './presentation/brokerage-operation.dto';
|
export { operationCategories } from './presentation/brokerage-operation.dto';
|
||||||
export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api';
|
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||||
import {
|
import {
|
||||||
@@ -68,23 +67,8 @@ export class ForecastService {
|
|||||||
dependencies
|
dependencies
|
||||||
.map((dependency) => dependency.sync)
|
.map((dependency) => dependency.sync)
|
||||||
.find((sync) => sync.state === 'failed');
|
.find((sync) => sync.state === 'failed');
|
||||||
const payload = stored ? (stored.payload as T) : null;
|
|
||||||
const data =
|
|
||||||
payload &&
|
|
||||||
'positionIsStale' in payload &&
|
|
||||||
'positionAt' in payload &&
|
|
||||||
typeof payload.positionAt === 'string'
|
|
||||||
? {
|
|
||||||
...payload,
|
|
||||||
positionIsStale: tbankSourceNeedsRefresh(
|
|
||||||
'portfolio',
|
|
||||||
new Date(payload.positionAt),
|
|
||||||
new Date(Date.now()),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: payload;
|
|
||||||
return {
|
return {
|
||||||
data,
|
data: stored ? (stored.payload as T) : null,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
resourceKey: input.scopeKey,
|
resourceKey: input.scopeKey,
|
||||||
dataRevision: String(stored?.dataRevision ?? 0),
|
dataRevision: String(stored?.dataRevision ?? 0),
|
||||||
@@ -96,10 +80,7 @@ export class ForecastService {
|
|||||||
? 'partial'
|
? 'partial'
|
||||||
: 'missing',
|
: 'missing',
|
||||||
fetchedAt: first ? new Date(first) : null,
|
fetchedAt: first ? new Date(first) : null,
|
||||||
freshForMs:
|
freshForMs: Number.POSITIVE_INFINITY,
|
||||||
data && 'positionIsStale' in data && data.positionIsStale === true
|
|
||||||
? 0
|
|
||||||
: Number.POSITIVE_INFINITY,
|
|
||||||
work: work?.id ? { ...work, id: work.id } : null,
|
work: work?.id ? { ...work, id: work.id } : null,
|
||||||
dependencies,
|
dependencies,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
decimalFromString,
|
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
isZero,
|
isZero,
|
||||||
multiply,
|
multiply,
|
||||||
@@ -512,3 +511,12 @@ export class PortfolioService {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decimalFromString(value: string) {
|
||||||
|
const negative = value.startsWith('-');
|
||||||
|
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
|
||||||
|
return {
|
||||||
|
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
|
||||||
|
scale: fraction.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// 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 { totalsByCurrency } from './infrastructure/tbank-portfolio.mapper';
|
||||||
export {
|
export {
|
||||||
PortfolioDto,
|
PortfolioDto,
|
||||||
PortfolioPositionDto,
|
PortfolioPositionDto,
|
||||||
|
|||||||
@@ -62,13 +62,4 @@ describe('manual sync screen boundary', () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
it('keeps independently supplied event dates and rejects an end before the default UTC start', () => {
|
|
||||||
for (const range of [{ from: '2096-02-29' }, { to: '2096-02-29' }]) {
|
|
||||||
const scope = { screen: 'events' as const, accountId: 'all', ...range };
|
|
||||||
expect(normalizeScreen(scope)).toEqual(scope);
|
|
||||||
}
|
|
||||||
expect(() =>
|
|
||||||
normalizeScreen({ screen: 'events', accountId: 'all', to: '2000-01-01' }),
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,19 +63,12 @@ export function normalizeScreen(
|
|||||||
if (['stock', 'bond'].includes(input.screen) && !input.instrumentUid) fail();
|
if (['stock', 'bond'].includes(input.screen) && !input.instrumentUid) fail();
|
||||||
if (input.accountId !== undefined && !accountId) fail();
|
if (input.accountId !== undefined && !accountId) fail();
|
||||||
if (['income', 'events'].includes(input.screen)) {
|
if (['income', 'events'].includes(input.screen)) {
|
||||||
if (input.screen === 'income' && Boolean(input.from) !== Boolean(input.to))
|
if (Boolean(input.from) !== Boolean(input.to)) fail();
|
||||||
fail();
|
|
||||||
for (const value of [input.from, input.to])
|
for (const value of [input.from, input.to])
|
||||||
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) fail();
|
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) fail();
|
||||||
}
|
}
|
||||||
if (input.from && input.to && new Date(input.from) > new Date(input.to))
|
if (input.from && input.to && new Date(input.from) > new Date(input.to))
|
||||||
fail();
|
fail();
|
||||||
if (
|
|
||||||
input.screen === 'events' &&
|
|
||||||
input.to &&
|
|
||||||
(input.from ?? new Date().toISOString().slice(0, 10)) > input.to
|
|
||||||
)
|
|
||||||
fail();
|
|
||||||
if (
|
if (
|
||||||
input.candleRange &&
|
input.candleRange &&
|
||||||
new Date(input.candleRange.from) >= new Date(input.candleRange.to)
|
new Date(input.candleRange.from) >= new Date(input.candleRange.to)
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import {
|
|||||||
Req,
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
|
type ValidationError,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiAcceptedResponse,
|
ApiAcceptedResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
ApiExtraModels,
|
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
@@ -24,21 +24,12 @@ import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-r
|
|||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import {
|
import {
|
||||||
InvestmentScreenDto,
|
InvestmentScreenDto,
|
||||||
IncomeInvestmentScreenDto,
|
|
||||||
EventsInvestmentScreenDto,
|
|
||||||
TimestampInvestmentScreenDto,
|
|
||||||
investmentScreenSchema,
|
|
||||||
TbankSyncAcceptedDto,
|
TbankSyncAcceptedDto,
|
||||||
TbankSyncStatusDto,
|
TbankSyncStatusDto,
|
||||||
} from './tbank-sync.dto';
|
} from './tbank-sync.dto';
|
||||||
import { TbankSyncService } from './tbank-sync.service';
|
import { TbankSyncService } from './tbank-sync.service';
|
||||||
|
|
||||||
@ApiTags('tbank-sync')
|
@ApiTags('tbank-sync')
|
||||||
@ApiExtraModels(
|
|
||||||
IncomeInvestmentScreenDto,
|
|
||||||
EventsInvestmentScreenDto,
|
|
||||||
TimestampInvestmentScreenDto,
|
|
||||||
)
|
|
||||||
@ApiProblemResponses()
|
@ApiProblemResponses()
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -47,7 +38,7 @@ export class TbankSyncController {
|
|||||||
constructor(private readonly sync: TbankSyncService) {}
|
constructor(private readonly sync: TbankSyncService) {}
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.ACCEPTED)
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
@ApiBody({ schema: investmentScreenSchema })
|
@ApiBody({ type: InvestmentScreenDto })
|
||||||
@ApiAcceptedResponse({ type: TbankSyncAcceptedDto })
|
@ApiAcceptedResponse({ type: TbankSyncAcceptedDto })
|
||||||
request(
|
request(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@@ -57,8 +48,8 @@ export class TbankSyncController {
|
|||||||
transform: true,
|
transform: true,
|
||||||
whitelist: true,
|
whitelist: true,
|
||||||
forbidNonWhitelisted: true,
|
forbidNonWhitelisted: true,
|
||||||
validationError: { target: false, value: false },
|
exceptionFactory: (message) =>
|
||||||
exceptionFactory: (message) => new BadRequestException({ message }),
|
new BadRequestException({ message: descriptorErrors(message) }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
input: unknown,
|
input: unknown,
|
||||||
@@ -74,3 +65,13 @@ export class TbankSyncController {
|
|||||||
return this.sync.status(request.user.sub, id);
|
return this.sync.status(request.user.sub, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function descriptorErrors(
|
||||||
|
errors: ValidationError[],
|
||||||
|
prefix = '',
|
||||||
|
): ValidationError[] {
|
||||||
|
return errors.flatMap((error) => [
|
||||||
|
{ ...error, property: `${prefix}${error.property}` },
|
||||||
|
...descriptorErrors(error.children ?? [], `${prefix}${error.property}/`),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
ApiProperty,
|
|
||||||
ApiPropertyOptional,
|
|
||||||
getSchemaPath,
|
|
||||||
OmitType,
|
|
||||||
PickType,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
@@ -52,21 +46,11 @@ export class InvestmentScreenDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@MaxLength(maxAccountIdLength)
|
@MaxLength(maxAccountIdLength)
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({ format: 'date-time' })
|
||||||
oneOf: [
|
|
||||||
{ type: 'string', format: 'date' },
|
|
||||||
{ type: 'string', format: 'date-time' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString({ strict: true })
|
@IsDateString({ strict: true })
|
||||||
from?: string;
|
from?: string;
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({ format: 'date-time' })
|
||||||
oneOf: [
|
|
||||||
{ type: 'string', format: 'date' },
|
|
||||||
{ type: 'string', format: 'date-time' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString({ strict: true })
|
@IsDateString({ strict: true })
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -101,98 +85,9 @@ export class InvestmentScreenDto {
|
|||||||
@Max(maxPageSize)
|
@Max(maxPageSize)
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IncomeInvestmentScreenDto extends PickType(InvestmentScreenDto, [
|
|
||||||
'accountId',
|
|
||||||
]) {
|
|
||||||
@ApiProperty({ enum: ['income'] }) declare screen: 'income';
|
|
||||||
@ApiProperty({ required: true }) declare accountId: string;
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
format: 'date',
|
|
||||||
description:
|
|
||||||
'Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.',
|
|
||||||
})
|
|
||||||
from?: string;
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
format: 'date',
|
|
||||||
description:
|
|
||||||
'Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.',
|
|
||||||
})
|
|
||||||
to?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EventsInvestmentScreenDto extends PickType(InvestmentScreenDto, [
|
|
||||||
'accountId',
|
|
||||||
]) {
|
|
||||||
@ApiProperty({ enum: ['events'] }) declare screen: 'events';
|
|
||||||
@ApiProperty({ required: true }) declare accountId: string;
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
format: 'date',
|
|
||||||
description:
|
|
||||||
'Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.',
|
|
||||||
})
|
|
||||||
from?: string;
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
format: 'date',
|
|
||||||
description:
|
|
||||||
'Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.',
|
|
||||||
})
|
|
||||||
to?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestampScreens = [
|
|
||||||
'accounts',
|
|
||||||
'account',
|
|
||||||
'operations',
|
|
||||||
'stocks',
|
|
||||||
'bonds',
|
|
||||||
'stock',
|
|
||||||
'bond',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export class TimestampInvestmentScreenDto extends OmitType(
|
|
||||||
InvestmentScreenDto,
|
|
||||||
['screen', 'from', 'to'],
|
|
||||||
) {
|
|
||||||
// The runtime validator retains legacy date-only inputs; the public contract uses instants.
|
|
||||||
@ApiProperty({ enum: timestampScreens })
|
|
||||||
declare screen: (typeof timestampScreens)[number];
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
format: 'date-time',
|
|
||||||
description: 'Start instant for operations.',
|
|
||||||
})
|
|
||||||
from?: string;
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
format: 'date-time',
|
|
||||||
description: 'End instant for operations.',
|
|
||||||
})
|
|
||||||
to?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const investmentScreenSchema = {
|
|
||||||
oneOf: [
|
|
||||||
IncomeInvestmentScreenDto,
|
|
||||||
EventsInvestmentScreenDto,
|
|
||||||
TimestampInvestmentScreenDto,
|
|
||||||
].map((model) => ({ $ref: getSchemaPath(model) })),
|
|
||||||
discriminator: {
|
|
||||||
propertyName: 'screen',
|
|
||||||
mapping: {
|
|
||||||
income: getSchemaPath(IncomeInvestmentScreenDto),
|
|
||||||
events: getSchemaPath(EventsInvestmentScreenDto),
|
|
||||||
...Object.fromEntries(
|
|
||||||
timestampScreens.map((screen) => [
|
|
||||||
screen,
|
|
||||||
getSchemaPath(TimestampInvestmentScreenDto),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export class TbankSyncAcceptedDto {
|
export class TbankSyncAcceptedDto {
|
||||||
@ApiProperty({ format: 'uuid' }) declare id: string;
|
@ApiProperty({ format: 'uuid' }) declare id: string;
|
||||||
@ApiProperty({ type: Object, ...investmentScreenSchema })
|
@ApiProperty({ type: InvestmentScreenDto })
|
||||||
declare scope: InvestmentScreenDto;
|
declare scope: InvestmentScreenDto;
|
||||||
@ApiProperty({ format: 'date-time' }) declare now: string;
|
@ApiProperty({ format: 'date-time' }) declare now: string;
|
||||||
}
|
}
|
||||||
@@ -203,7 +98,7 @@ export class TbankSyncProgressDto {
|
|||||||
}
|
}
|
||||||
export class TbankSyncStatusDto {
|
export class TbankSyncStatusDto {
|
||||||
@ApiProperty({ format: 'uuid' }) declare id: string;
|
@ApiProperty({ format: 'uuid' }) declare id: string;
|
||||||
@ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true })
|
@ApiProperty({ type: InvestmentScreenDto, nullable: true })
|
||||||
declare scope: InvestmentScreenDto | null;
|
declare scope: InvestmentScreenDto | null;
|
||||||
@ApiProperty({ format: 'date-time' }) declare now: string;
|
@ApiProperty({ format: 'date-time' }) declare now: string;
|
||||||
@ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] })
|
@ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] })
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import type { Prisma } from '../../../../generated/prisma/client';
|
import type { Prisma } from '../../../../generated/prisma/client';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api';
|
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service';
|
||||||
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
||||||
export type ScreenIntent = { scope: InvestmentScreenDto; now: string };
|
export type ScreenIntent = { scope: InvestmentScreenDto; now: string };
|
||||||
export type ScreenProgress = {
|
export type ScreenProgress = {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankMembershipService,
|
TbankMembershipService,
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
type TbankSyncAttempt,
|
|
||||||
} from '../../../integrations/tbank/tbank.api';
|
} from '../../../integrations/tbank/tbank.api';
|
||||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +30,7 @@ import { IncomeAnalyticsService } from '../income/income-read.api';
|
|||||||
import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api';
|
import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api';
|
||||||
import { planInvestmentScreen } from '../investment-sync-plan';
|
import { planInvestmentScreen } from '../investment-sync-plan';
|
||||||
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||||
|
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service';
|
||||||
import {
|
import {
|
||||||
TbankSyncRepository,
|
TbankSyncRepository,
|
||||||
type ScreenIntent,
|
type ScreenIntent,
|
||||||
@@ -303,7 +303,6 @@ export class TbankSyncService {
|
|||||||
},
|
},
|
||||||
now,
|
now,
|
||||||
true,
|
true,
|
||||||
0,
|
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -328,17 +327,10 @@ export class TbankSyncService {
|
|||||||
return this.sourceStatus(userId, saved.work);
|
return this.sourceStatus(userId, saved.work);
|
||||||
const intent = saved.work.intent as unknown as ScreenIntent;
|
const intent = saved.work.intent as unknown as ScreenIntent;
|
||||||
await this.access.local(userId, intent.scope.accountId);
|
await this.access.local(userId, intent.scope.accountId);
|
||||||
const children = await Promise.all(
|
const running = saved.children.some((child) => child.state === 'running');
|
||||||
saved.children.map((child) =>
|
|
||||||
child.state === 'superseded'
|
|
||||||
? this.operations.observe(userId, child.id)
|
|
||||||
: Promise.resolve(child),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const running = children.some((child) => child.state === 'running');
|
|
||||||
const failed =
|
const failed =
|
||||||
saved.work.errorCode ??
|
saved.work.errorCode ??
|
||||||
children.find((child) => child.errorCode)?.errorCode ??
|
saved.children.find((child) => child.errorCode)?.errorCode ??
|
||||||
null;
|
null;
|
||||||
const state =
|
const state =
|
||||||
saved.work.processRunId !== this.runId &&
|
saved.work.processRunId !== this.runId &&
|
||||||
@@ -348,7 +340,7 @@ export class TbankSyncService {
|
|||||||
? 'running'
|
? 'running'
|
||||||
: failed
|
: failed
|
||||||
? 'failed'
|
? 'failed'
|
||||||
: children.some((child) => child.state === 'cancelled')
|
: saved.children.some((child) => child.state === 'cancelled')
|
||||||
? 'cancelled'
|
? 'cancelled'
|
||||||
: 'succeeded';
|
: 'succeeded';
|
||||||
const resources = await this.observe(userId, intent.scope);
|
const resources = await this.observe(userId, intent.scope);
|
||||||
@@ -363,8 +355,9 @@ export class TbankSyncService {
|
|||||||
state,
|
state,
|
||||||
errorCode: failed,
|
errorCode: failed,
|
||||||
progress: {
|
progress: {
|
||||||
completed: children.filter((child) => child.state !== 'running').length,
|
completed: saved.children.filter((child) => child.state !== 'running')
|
||||||
total: children.length,
|
.length,
|
||||||
|
total: saved.children.length,
|
||||||
discovering: !saved.progress.dispatched,
|
discovering: !saved.progress.dispatched,
|
||||||
},
|
},
|
||||||
resources,
|
resources,
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import { AccountReferenceDto } from '../account-reference.dto';
|
|||||||
export class UpcomingEventsQueryDto extends InvestmentReadQueryDto {
|
export class UpcomingEventsQueryDto extends InvestmentReadQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'date',
|
format: 'date',
|
||||||
description:
|
description: 'Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.',
|
||||||
'Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.',
|
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
@@ -21,7 +20,7 @@ export class UpcomingEventsQueryDto extends InvestmentReadQueryDto {
|
|||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'date',
|
format: 'date',
|
||||||
description:
|
description:
|
||||||
'Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.',
|
'Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
|||||||
+11
-125
@@ -1,7 +1,6 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
import { AppModule } from '../src/app.module';
|
import { AppModule } from '../src/app.module';
|
||||||
@@ -29,10 +28,7 @@ describe('Authentication (e2e)', () => {
|
|||||||
await app.get(PrismaService).user.deleteMany();
|
await app.get(PrismaService).user.deleteMany();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(() => app.close());
|
||||||
await app.close();
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers, guards profile, rotates refresh and logs out', async () => {
|
it('registers, guards profile, rotates refresh and logs out', async () => {
|
||||||
const agent = request.agent(app.getHttpServer());
|
const agent = request.agent(app.getHttpServer());
|
||||||
@@ -54,94 +50,14 @@ describe('Authentication (e2e)', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const firstCookie = registration.headers['set-cookie'][0];
|
const firstCookie = registration.headers['set-cookie'][0];
|
||||||
const refreshed = await agent.post('/api/v1/auth/refresh').expect(200);
|
await agent.post('/api/v1/auth/refresh').expect(200);
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/refresh')
|
.post('/api/v1/auth/refresh')
|
||||||
.set('Cookie', firstCookie)
|
.set('Cookie', firstCookie)
|
||||||
.expect(401)
|
.expect(401)
|
||||||
.expect('Content-Type', /application\/problem\+json/);
|
.expect('Content-Type', /application\/problem\+json/);
|
||||||
const logout = await agent.post('/api/v1/auth/logout').expect(204);
|
|
||||||
expect(logout.headers['set-cookie'][0]).toContain('refresh_token=;');
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post('/api/v1/auth/refresh')
|
|
||||||
.set('Cookie', refreshed.headers['set-cookie'][0])
|
|
||||||
.expect(401)
|
|
||||||
.expect(({ body }: { body: unknown }) =>
|
|
||||||
expect(body).toEqual({ status: 401, code: 'UNAUTHORIZED' }),
|
|
||||||
);
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post('/api/v1/auth/logout')
|
|
||||||
.set('Cookie', refreshed.headers['set-cookie'][0])
|
|
||||||
.expect(204);
|
|
||||||
await agent.post('/api/v1/auth/logout').expect(204);
|
await agent.post('/api/v1/auth/logout').expect(204);
|
||||||
});
|
await agent.post('/api/v1/auth/logout').expect(204);
|
||||||
|
|
||||||
it('keeps logout idempotent for an invalid refresh token', async () => {
|
|
||||||
const logout = await request(app.getHttpServer())
|
|
||||||
.post('/api/v1/auth/logout')
|
|
||||||
.set('Cookie', 'refresh_token=invalid')
|
|
||||||
.expect(204);
|
|
||||||
|
|
||||||
expect(logout.headers['set-cookie'][0]).toContain('refresh_token=;');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports a failed revocation and preserves the refresh cookie for retry', async () => {
|
|
||||||
const registration = await request(app.getHttpServer())
|
|
||||||
.post('/api/v1/auth/register')
|
|
||||||
.send({ email: 'logout@example.test', password: 'a secure password' })
|
|
||||||
.expect(200);
|
|
||||||
const cookie = registration.headers['set-cookie'][0];
|
|
||||||
const revoke = jest
|
|
||||||
.spyOn(app.get(PrismaService).session, 'update')
|
|
||||||
.mockRejectedValueOnce(new Error('Storage unavailable'));
|
|
||||||
|
|
||||||
const failed = await request(app.getHttpServer())
|
|
||||||
.post('/api/v1/auth/logout')
|
|
||||||
.set('Cookie', cookie)
|
|
||||||
.expect(500)
|
|
||||||
.expect('Content-Type', /application\/problem\+json/);
|
|
||||||
revoke.mockRestore();
|
|
||||||
|
|
||||||
expect(failed.body).toEqual({
|
|
||||||
status: 500,
|
|
||||||
code: 'INTERNAL_SERVER_ERROR',
|
|
||||||
});
|
|
||||||
expect(failed.headers['set-cookie']).toBeUndefined();
|
|
||||||
expect(
|
|
||||||
await app.get(PrismaService).session.findFirstOrThrow(),
|
|
||||||
).toMatchObject({ revokedAt: null });
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post('/api/v1/auth/refresh')
|
|
||||||
.set('Cookie', cookie)
|
|
||||||
.expect(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves a valid refresh cookie during a storage failure', async () => {
|
|
||||||
const registration = await request(app.getHttpServer())
|
|
||||||
.post('/api/v1/auth/register')
|
|
||||||
.send({ email: 'refresh@example.test', password: 'a secure password' })
|
|
||||||
.expect(200);
|
|
||||||
const cookie = registration.headers['set-cookie'][0];
|
|
||||||
const read = jest
|
|
||||||
.spyOn(app.get(PrismaService).session, 'findUnique')
|
|
||||||
.mockRejectedValueOnce(new Error('Storage unavailable'));
|
|
||||||
|
|
||||||
const failed = await request(app.getHttpServer())
|
|
||||||
.post('/api/v1/auth/refresh')
|
|
||||||
.set('Cookie', cookie)
|
|
||||||
.expect(500)
|
|
||||||
.expect('Content-Type', /application\/problem\+json/);
|
|
||||||
read.mockRestore();
|
|
||||||
|
|
||||||
expect(failed.body).toEqual({
|
|
||||||
status: 500,
|
|
||||||
code: 'INTERNAL_SERVER_ERROR',
|
|
||||||
});
|
|
||||||
expect(failed.headers['set-cookie']).toBeUndefined();
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post('/api/v1/auth/refresh')
|
|
||||||
.set('Cookie', cookie)
|
|
||||||
.expect(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rotates one concurrent refresh on SQLite and keeps the winner usable', async () => {
|
it('rotates one concurrent refresh on SQLite and keeps the winner usable', async () => {
|
||||||
@@ -225,26 +141,6 @@ describe('Authentication (e2e)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns field errors for invalid credentials without exposing input', async () => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.post('/api/v1/auth/register')
|
|
||||||
.send({ email: 'SYNTHETIC_VALIDATION_SECRET' })
|
|
||||||
.expect(400)
|
|
||||||
.expect('Content-Type', /application\/problem\+json/);
|
|
||||||
expect(response.body).toEqual({
|
|
||||||
status: 400,
|
|
||||||
code: 'VALIDATION_ERROR',
|
|
||||||
errors: expect.arrayContaining([
|
|
||||||
{ pointer: '#/email', detail: 'must be a valid email address' },
|
|
||||||
{ pointer: '#/password', detail: 'password must be a string' },
|
|
||||||
]) as unknown,
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(response.body).toLowerCase()).not.toContain(
|
|
||||||
'synthetic_validation_secret',
|
|
||||||
);
|
|
||||||
expect(await app.get(PrismaService).user.count()).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('guards T-Bank connection and never returns a token', async () => {
|
it('guards T-Bank connection and never returns a token', async () => {
|
||||||
const registration = await request(app.getHttpServer())
|
const registration = await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/register')
|
.post('/api/v1/auth/register')
|
||||||
@@ -322,37 +218,27 @@ describe('Authentication (e2e)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs an unhandled error with safe public context and correlation', async () => {
|
it('records the public code and stack for an unhandled error', async () => {
|
||||||
const error = jest.spyOn(PinoLogger.prototype, 'error');
|
const logger = app.get(StructuredLogger);
|
||||||
const clientRequestId = 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43';
|
const event = jest.spyOn(logger, 'event');
|
||||||
jest
|
jest
|
||||||
.spyOn(app.get(AuthService), 'login')
|
.spyOn(app.get(AuthService), 'login')
|
||||||
.mockRejectedValue(
|
.mockRejectedValue(new Error('password: secret'));
|
||||||
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/login')
|
.post('/api/v1/auth/login')
|
||||||
.set('X-Request-ID', clientRequestId)
|
|
||||||
.send({ email: 'user@example.com', password: 'a secure password' })
|
.send({ email: 'user@example.com', password: 'a secure password' })
|
||||||
.expect(500)
|
.expect(500)
|
||||||
.expect('Content-Type', /application\/problem\+json/)
|
|
||||||
.expect(({ body }: { body: unknown }) =>
|
.expect(({ body }: { body: unknown }) =>
|
||||||
expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }),
|
expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(error).toHaveBeenCalledTimes(1);
|
expect(event).toHaveBeenCalledWith(
|
||||||
expect(error).toHaveBeenCalledWith(
|
'error',
|
||||||
|
'http.request.completed',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
requestId: response.headers['x-request-id'],
|
|
||||||
clientRequestId,
|
|
||||||
status: 500,
|
|
||||||
code: 'INTERNAL_SERVER_ERROR',
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
}),
|
}),
|
||||||
'http.request.completed',
|
|
||||||
);
|
|
||||||
expect(JSON.stringify(error.mock.calls)).not.toContain(
|
|
||||||
'SYNTHETIC_AUDIT_SECRET',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -275,40 +275,6 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
|
|||||||
})
|
})
|
||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
it('returns nested money field errors without exposing input or writing a card', async () => {
|
|
||||||
const before = await db.bankCard.count();
|
|
||||||
const response = await auth('post', '')
|
|
||||||
.send({
|
|
||||||
id: randomUUID(),
|
|
||||||
name: 'Card',
|
|
||||||
openingDate: '2026-01-01',
|
|
||||||
openingBalance: {
|
|
||||||
currency: 'USD',
|
|
||||||
value: 'SYNTHETIC_VALIDATION_SECRET',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.expect(400)
|
|
||||||
.expect('Content-Type', /application\/problem\+json/);
|
|
||||||
expect(response.body).toEqual({
|
|
||||||
status: 400,
|
|
||||||
code: 'VALIDATION_ERROR',
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
pointer: '#/openingBalance/currency',
|
|
||||||
detail: expect.stringContaining('RUB') as unknown,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pointer: '#/openingBalance/value',
|
|
||||||
detail: expect.any(String) as unknown,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(response.body)).not.toContain(
|
|
||||||
'SYNTHETIC_VALIDATION_SECRET',
|
|
||||||
);
|
|
||||||
expect(await db.bankCard.count()).toBe(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('scopes every card/category/expense link and edit to owner', async () => {
|
it('scopes every card/category/expense link and edit to owner', async () => {
|
||||||
const own = await createCard(),
|
const own = await createCard(),
|
||||||
foreign = await createCard('0', stranger);
|
foreign = await createCard('0', stranger);
|
||||||
|
|||||||
Vendored
+1
-1
@@ -57,7 +57,7 @@ repository.publish = async (...args) => {
|
|||||||
};
|
};
|
||||||
const service = new BrokerageOperationsSyncService(
|
const service = new BrokerageOperationsSyncService(
|
||||||
repository,
|
repository,
|
||||||
new TbankAccessService(prisma, tokens),
|
new TbankAccessService(prisma, tokens, upstream),
|
||||||
upstream,
|
upstream,
|
||||||
{
|
{
|
||||||
event() {
|
event() {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
@@ -23,127 +22,7 @@ describe('OpenAPI contract (integration)', () => {
|
|||||||
|
|
||||||
afterAll(() => app.close());
|
afterAll(() => app.close());
|
||||||
|
|
||||||
it('distinguishes calendar screen dates from operation instants in requests and responses', () => {
|
|
||||||
const document = createOpenApiDocument(app);
|
|
||||||
const schemas = document.components?.schemas;
|
|
||||||
const screenSchema = {
|
|
||||||
oneOf: [
|
|
||||||
{ $ref: '#/components/schemas/IncomeInvestmentScreenDto' },
|
|
||||||
{ $ref: '#/components/schemas/EventsInvestmentScreenDto' },
|
|
||||||
{ $ref: '#/components/schemas/TimestampInvestmentScreenDto' },
|
|
||||||
],
|
|
||||||
discriminator: {
|
|
||||||
propertyName: 'screen',
|
|
||||||
mapping: {
|
|
||||||
income: '#/components/schemas/IncomeInvestmentScreenDto',
|
|
||||||
events: '#/components/schemas/EventsInvestmentScreenDto',
|
|
||||||
operations: '#/components/schemas/TimestampInvestmentScreenDto',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
expect(
|
|
||||||
document.paths['/api/v1/tbank-sync'].post?.requestBody,
|
|
||||||
).toMatchObject({
|
|
||||||
content: { 'application/json': { schema: screenSchema } },
|
|
||||||
});
|
|
||||||
expect(schemas?.TbankSyncAcceptedDto).toMatchObject({
|
|
||||||
properties: { scope: screenSchema },
|
|
||||||
});
|
|
||||||
expect(schemas?.TbankSyncStatusDto).toMatchObject({
|
|
||||||
properties: { scope: { ...screenSchema, nullable: true } },
|
|
||||||
});
|
|
||||||
for (const [model, screen] of [
|
|
||||||
['IncomeInvestmentScreenDto', 'income'],
|
|
||||||
['EventsInvestmentScreenDto', 'events'],
|
|
||||||
]) {
|
|
||||||
const schema = schemas?.[model];
|
|
||||||
assert(schema && !('$ref' in schema));
|
|
||||||
expect(schema.required).toContain('screen');
|
|
||||||
expect(schema.required).toContain('accountId');
|
|
||||||
expect(schema).toMatchObject({
|
|
||||||
properties: {
|
|
||||||
screen: { enum: [screen] },
|
|
||||||
from: { type: 'string', format: 'date' },
|
|
||||||
to: { type: 'string', format: 'date' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
expect(schemas?.TimestampInvestmentScreenDto).toMatchObject({
|
|
||||||
properties: {
|
|
||||||
from: { type: 'string', format: 'date-time' },
|
|
||||||
to: { type: 'string', format: 'date-time' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(schemas).not.toHaveProperty('InvestmentScreenDto');
|
|
||||||
for (const scope of ['all', '{accountId}']) {
|
|
||||||
for (const [resource, format] of [
|
|
||||||
['income-analytics', 'date'],
|
|
||||||
['events', 'date'],
|
|
||||||
['operations', 'date-time'],
|
|
||||||
]) {
|
|
||||||
const parameters =
|
|
||||||
document.paths[`/api/v1/brokerage-accounts/${scope}/${resource}`].get
|
|
||||||
?.parameters;
|
|
||||||
for (const name of ['from', 'to'])
|
|
||||||
expect(
|
|
||||||
parameters?.find(
|
|
||||||
(parameter) => 'name' in parameter && parameter.name === name,
|
|
||||||
),
|
|
||||||
).toMatchObject({
|
|
||||||
name,
|
|
||||||
in: 'query',
|
|
||||||
schema: { format },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes required semantic income dependency roles', () => {
|
|
||||||
const schemas = createOpenApiDocument(app).components?.schemas;
|
|
||||||
expect(schemas?.IncomeAnalyticsResourceDto).toMatchObject({
|
|
||||||
properties: {
|
|
||||||
meta: { $ref: '#/components/schemas/IncomeAnalyticsMetaDto' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const meta = schemas?.IncomeAnalyticsMetaDto;
|
|
||||||
assert(meta && !('$ref' in meta));
|
|
||||||
expect(meta.required).toContain('dependencies');
|
|
||||||
expect(meta).toMatchObject({
|
|
||||||
properties: {
|
|
||||||
dependencies: {
|
|
||||||
type: 'array',
|
|
||||||
items: { $ref: '#/components/schemas/IncomeDependencyDto' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const dependency = schemas?.IncomeDependencyDto;
|
|
||||||
assert(dependency && !('$ref' in dependency));
|
|
||||||
expect(dependency.required).toContain('role');
|
|
||||||
expect(dependency).toMatchObject({
|
|
||||||
properties: {
|
|
||||||
role: { type: 'string', enum: ['selected', 'previous', 'year'] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates the runtime contract without server startup or deployment configuration', async () => {
|
it('generates the runtime contract without server startup or deployment configuration', async () => {
|
||||||
expect(
|
|
||||||
createOpenApiDocument(app).components?.schemas?.ProblemDetailsDto,
|
|
||||||
).toMatchObject({
|
|
||||||
properties: {
|
|
||||||
errors: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['pointer', 'detail'],
|
|
||||||
properties: {
|
|
||||||
pointer: { type: 'string' },
|
|
||||||
detail: { type: 'string' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-'));
|
const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-'));
|
||||||
try {
|
try {
|
||||||
const networkGuard = resolve(directory, 'no-network.cjs');
|
const networkGuard = resolve(directory, 'no-network.cjs');
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
}) as never,
|
}) as never,
|
||||||
);
|
);
|
||||||
const upstream = { getCandles: jest.fn() };
|
const upstream = { getCandles: jest.fn() };
|
||||||
const access = new TbankAccessService(prisma, tokens);
|
const access = new TbankAccessService(prisma, tokens, upstream as never);
|
||||||
const instrument = {
|
const instrument = {
|
||||||
uid: 'uid',
|
uid: 'uid',
|
||||||
currency: 'rub',
|
currency: 'rub',
|
||||||
@@ -46,10 +46,10 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
const createService = (selectedSources = sources) =>
|
const createService = () =>
|
||||||
new CandleService(
|
new CandleService(
|
||||||
repository,
|
repository,
|
||||||
selectedSources as never,
|
sources as never,
|
||||||
access,
|
access,
|
||||||
upstream as never,
|
upstream as never,
|
||||||
{ event: jest.fn() } as never,
|
{ event: jest.fn() } as never,
|
||||||
@@ -112,7 +112,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
afterAll(async () => prisma.$disconnect());
|
afterAll(async () => prisma.$disconnect());
|
||||||
afterEach(() => jest.restoreAllMocks());
|
|
||||||
it('deduplicates seams, excludes outside rows, persists exact decimals and closed empty coverage after reopen', async () => {
|
it('deduplicates seams, excludes outside rows, persists exact decimals and closed empty coverage after reopen', async () => {
|
||||||
const attempt = await reserve();
|
const attempt = await reserve();
|
||||||
await repository.stage(
|
await repository.stage(
|
||||||
@@ -303,149 +302,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
|||||||
expect(read.meta.sync.state).toBe('failed');
|
expect(read.meta.sync.state).toBe('failed');
|
||||||
expect(read.data?.candles).toEqual([]);
|
expect(read.data?.candles).toEqual([]);
|
||||||
});
|
});
|
||||||
it.each([
|
|
||||||
['owner', 'before loading', false],
|
|
||||||
['observer', 'before loading', false],
|
|
||||||
['owner', 'after ordinary publication', false],
|
|
||||||
['observer', 'after ordinary publication', false],
|
|
||||||
['observer', 'after ordinary publication', true],
|
|
||||||
] as const)(
|
|
||||||
'honors force from %s joining %s (later failure=%s)',
|
|
||||||
async (userId, phase, laterFailure) => {
|
|
||||||
if (userId !== owner.userId) {
|
|
||||||
await prisma.user.upsert({
|
|
||||||
where: { id: userId },
|
|
||||||
create: {
|
|
||||||
id: userId,
|
|
||||||
email: `${userId}@example.invalid`,
|
|
||||||
passwordHash: 'synthetic',
|
|
||||||
},
|
|
||||||
update: {},
|
|
||||||
});
|
|
||||||
await prisma.tbankConnection.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
revision: 'revision',
|
|
||||||
encryptedToken: tokens.encrypt('synthetic'),
|
|
||||||
connectedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const history = {
|
|
||||||
...instrument,
|
|
||||||
first_1dayCandleDate: {
|
|
||||||
seconds: String(Date.parse('2024-01-01Z') / 1000),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const covered = {
|
|
||||||
from: new Date('2024-01-01Z'),
|
|
||||||
to: new Date('2025-01-01Z'),
|
|
||||||
};
|
|
||||||
const old = { ...candle, time: '2024-01-02T00:00:00.000Z' };
|
|
||||||
const initial = await reserve([covered]);
|
|
||||||
await repository.stage(initial, covered, [old], new Date('2025-01-02Z'));
|
|
||||||
await repository.publish(initial, { uid: 'uid', ranges: [covered] });
|
|
||||||
let entered = () => {};
|
|
||||||
let release = () => {};
|
|
||||||
const started = new Promise<void>((resolve) => (entered = resolve));
|
|
||||||
const blocked = new Promise<void>((resolve) => (release = resolve));
|
|
||||||
const selectedSources = {
|
|
||||||
...sources,
|
|
||||||
ensureDetail: jest.fn().mockResolvedValue(history),
|
|
||||||
};
|
|
||||||
if (phase === 'before loading')
|
|
||||||
selectedSources.ensureDetail.mockImplementationOnce(async () => {
|
|
||||||
entered();
|
|
||||||
await blocked;
|
|
||||||
return history;
|
|
||||||
});
|
|
||||||
else {
|
|
||||||
const publish = repository.publish.bind(repository);
|
|
||||||
jest
|
|
||||||
.spyOn(repository, 'publish')
|
|
||||||
.mockImplementationOnce(async (...args) => {
|
|
||||||
const result = await publish(...args);
|
|
||||||
entered();
|
|
||||||
await blocked;
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const forcedAt = new Date('2026-02-03T13:00:00Z');
|
|
||||||
const forcedStates: string[] = [];
|
|
||||||
const fullRange = { from: covered.from, to: new Date('2026-02-04Z') };
|
|
||||||
let beforeForced:
|
|
||||||
Awaited<ReturnType<CandleRepository['read']>> | undefined;
|
|
||||||
upstream.getCandles.mockImplementation(
|
|
||||||
async (_token, _uid, _from, to: Date) => {
|
|
||||||
if (laterFailure && forcedStates.length)
|
|
||||||
throw new Error('Forced reconciliation second window unavailable');
|
|
||||||
if (to.valueOf() === forcedAt.valueOf()) {
|
|
||||||
beforeForced = await repository.read('uid', fullRange);
|
|
||||||
const work = await repository.work(
|
|
||||||
userId,
|
|
||||||
'revision',
|
|
||||||
'candles:uid:all',
|
|
||||||
);
|
|
||||||
forcedStates.push(work?.state ?? 'missing');
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
time: { seconds: String(Date.parse(old.time) / 1000) },
|
|
||||||
close: { units: '200', nano: 1 },
|
|
||||||
isComplete: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const service = createService(selectedSources);
|
|
||||||
const input = {
|
|
||||||
uid: 'uid',
|
|
||||||
instrumentType: 'share' as const,
|
|
||||||
period: 'all' as const,
|
|
||||||
};
|
|
||||||
const ordinaryId = await service.request(
|
|
||||||
'owner',
|
|
||||||
'revision',
|
|
||||||
input,
|
|
||||||
new Date('2026-02-02T12:00:00Z'),
|
|
||||||
false,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
await started;
|
|
||||||
const forcedId = await service.request(
|
|
||||||
userId,
|
|
||||||
'revision',
|
|
||||||
input,
|
|
||||||
forcedAt,
|
|
||||||
true,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
release();
|
|
||||||
await settled();
|
|
||||||
|
|
||||||
if (laterFailure) {
|
|
||||||
expect(beforeForced).toBeDefined();
|
|
||||||
expect(await repository.read('uid', fullRange)).toEqual(beforeForced);
|
|
||||||
} else
|
|
||||||
expect((await repository.read('uid', covered)).data).toEqual([
|
|
||||||
{ ...old, close: '200.000000001' },
|
|
||||||
]);
|
|
||||||
expect(forcedStates).toEqual(['running']);
|
|
||||||
expect(
|
|
||||||
await repository.work(userId, 'revision', 'candles:uid:all'),
|
|
||||||
).toMatchObject({
|
|
||||||
id: forcedId,
|
|
||||||
state: laterFailure ? 'failed' : 'complete',
|
|
||||||
priority: 0,
|
|
||||||
intent: {
|
|
||||||
force: true,
|
|
||||||
now: forcedAt.toISOString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (userId === owner.userId) expect(forcedId).toBe(ordinaryId);
|
|
||||||
else expect(forcedId).not.toBe(ordinaryId);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
it('preserves source incompleteness and bond percentage precision without nominal conversion', async () => {
|
it('preserves source incompleteness and bond percentage precision without nominal conversion', async () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setUTCHours(0, 0, 0, 0);
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe('shared catalog discovery (SQLite)', () => {
|
|||||||
}) as never,
|
}) as never,
|
||||||
);
|
);
|
||||||
const upstream = { findShares: jest.fn(), findBonds: jest.fn() };
|
const upstream = { findShares: jest.fn(), findBonds: jest.fn() };
|
||||||
const access = new TbankAccessService(prisma, tokens);
|
const access = new TbankAccessService(prisma, tokens, upstream as never);
|
||||||
const repository = new CatalogDiscoveryRepository(prisma);
|
const repository = new CatalogDiscoveryRepository(prisma);
|
||||||
const createService = () =>
|
const createService = () =>
|
||||||
new CatalogDiscoveryService(
|
new CatalogDiscoveryService(
|
||||||
|
|||||||
@@ -138,15 +138,7 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
).toEqual(['a', 'b']);
|
).toEqual(['a', 'b']);
|
||||||
expect(result.data.previous).toBeNull();
|
expect(result.data.previous).toBeNull();
|
||||||
expect(result.data.yearComparison).toBeNull();
|
expect(result.data.yearComparison).toBeNull();
|
||||||
expect(result.meta.dependencies.map(({ role }) => role)).toEqual([
|
expect(result.meta.dependencies?.[1].availability).toBe('missing');
|
||||||
'selected',
|
|
||||||
'previous',
|
|
||||||
'year',
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
result.meta.dependencies.find(({ role }) => role === 'previous')
|
|
||||||
?.availability,
|
|
||||||
).toBe('missing');
|
|
||||||
expect(result.data.calculatedAt).toBe('2025-02-01T00:00:00.000Z');
|
expect(result.data.calculatedAt).toBe('2025-02-01T00:00:00.000Z');
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
@@ -272,10 +264,9 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
});
|
});
|
||||||
it('distinguishes unavailable calendar comparison without scheduling it', async () => {
|
it('distinguishes unavailable calendar comparison without scheduling it', async () => {
|
||||||
const result = await read('a', 'from=2024-02-29&to=2024-02-29');
|
const result = await read('a', 'from=2024-02-29&to=2024-02-29');
|
||||||
expect(
|
expect(result.meta.dependencies?.at(-1)?.unavailableReason).toBe(
|
||||||
result.meta.dependencies.find(({ role }) => role === 'year')
|
'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||||
?.unavailableReason,
|
);
|
||||||
).toBe('CALENDAR_OR_ACCOUNT_HISTORY');
|
|
||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import { PrismaService } from '../src/prisma/prisma.service';
|
|||||||
import { InstrumentMetadataRepository } from '../src/integrations/tbank/instrument-metadata.repository';
|
import { InstrumentMetadataRepository } from '../src/integrations/tbank/instrument-metadata.repository';
|
||||||
import { TbankClient } from '../src/integrations/tbank/tbank.client';
|
import { TbankClient } from '../src/integrations/tbank/tbank.client';
|
||||||
import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto';
|
import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto';
|
||||||
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
|
|
||||||
import type { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto';
|
|
||||||
|
|
||||||
const uid = '11111111-1111-4111-8111-111111111111';
|
const uid = '11111111-1111-4111-8111-111111111111';
|
||||||
const period = 'from=2025-01-01&to=2025-01-31';
|
const period = 'from=2025-01-01&to=2025-01-31';
|
||||||
@@ -129,97 +127,6 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it('uses the persisted subsecond timestamp for filtering, cursor order and income entries', async () => {
|
|
||||||
await prisma.tbankOperation.deleteMany();
|
|
||||||
const { revision } = await prisma.tbankConnection.findUniqueOrThrow({
|
|
||||||
where: { userId: 'owner' },
|
|
||||||
});
|
|
||||||
const repository = app.get(BrokerageOperationsRepository);
|
|
||||||
const interval = {
|
|
||||||
accountId: 'a',
|
|
||||||
from: new Date('2025-01-02T00:00:00.500Z'),
|
|
||||||
to: new Date('2025-01-02T00:00:01.000Z'),
|
|
||||||
};
|
|
||||||
const reserved = await repository.request(
|
|
||||||
'owner',
|
|
||||||
revision,
|
|
||||||
[interval],
|
|
||||||
'subsecond-test',
|
|
||||||
true,
|
|
||||||
new Set(),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
assert(reserved);
|
|
||||||
const attempt = {
|
|
||||||
userId: 'owner',
|
|
||||||
revision,
|
|
||||||
id: reserved.work.id,
|
|
||||||
attemptId: reserved.work.attemptId,
|
|
||||||
};
|
|
||||||
await expect(
|
|
||||||
repository.stagePage(attempt, interval, undefined, {
|
|
||||||
hasNext: false,
|
|
||||||
items: [{ id: 'invalid', date: {} }],
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('Invalid operation timestamp');
|
|
||||||
await repository.stagePage(attempt, interval, undefined, {
|
|
||||||
hasNext: false,
|
|
||||||
items: [400, 600, 800].map((milliseconds) => ({
|
|
||||||
id: String(milliseconds),
|
|
||||||
date: { seconds: '1735776000', nanos: milliseconds * 1_000_000 },
|
|
||||||
type: 'OPERATION_TYPE_COUPON',
|
|
||||||
payment: { currency: 'rub', units: '1', nano: 1 },
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
await repository.publish(attempt, [interval], new Date(), true);
|
|
||||||
const saved = await prisma.tbankOperation.findMany({
|
|
||||||
orderBy: { occurredAt: 'desc' },
|
|
||||||
});
|
|
||||||
const expected = [
|
|
||||||
{ id: '800', occurredAt: '2025-01-02T00:00:00.800Z' },
|
|
||||||
{ id: '600', occurredAt: '2025-01-02T00:00:00.600Z' },
|
|
||||||
];
|
|
||||||
expect(
|
|
||||||
saved.map((row) => ({
|
|
||||||
id: row.brokerOperationId,
|
|
||||||
occurredAt: row.occurredAt.toISOString(),
|
|
||||||
})),
|
|
||||||
).toEqual(expected);
|
|
||||||
const items: { id: string; occurredAt: string }[] = [];
|
|
||||||
let cursor: string | null = null;
|
|
||||||
do {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.get(
|
|
||||||
`/api/v1/brokerage-accounts/a/operations?from=${interval.from.toISOString()}&to=2025-01-02T00:00:00.900Z&limit=1&sync=observe${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
|
|
||||||
)
|
|
||||||
.set('Authorization', auth())
|
|
||||||
.expect(200);
|
|
||||||
const result = response.body as BrokerageOperationsResourceDto;
|
|
||||||
assert(result.data);
|
|
||||||
expect(result.data.items).toHaveLength(1);
|
|
||||||
items.push(
|
|
||||||
...result.data.items.map(({ id, occurredAt }) => ({ id, occurredAt })),
|
|
||||||
);
|
|
||||||
cursor = result.data.nextCursor;
|
|
||||||
} while (cursor);
|
|
||||||
const income = (
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.get(
|
|
||||||
`/api/v1/brokerage-accounts/a/income-analytics?${period}&sync=observe`,
|
|
||||||
)
|
|
||||||
.set('Authorization', auth())
|
|
||||||
.expect(200)
|
|
||||||
).body as IncomeAnalyticsResourceDto;
|
|
||||||
expect(items).toEqual(expected);
|
|
||||||
expect(
|
|
||||||
income.data?.entries.map(({ id, occurredAt }) => ({ id, occurredAt })),
|
|
||||||
).toEqual(expected);
|
|
||||||
expect(income.data?.totalsByCurrency[0].total).toEqual({
|
|
||||||
currency: 'RUB',
|
|
||||||
value: '2.000000002',
|
|
||||||
});
|
|
||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
it.each(['default', 'today', 'today timestamp'])(
|
it.each(['default', 'today', 'today timestamp'])(
|
||||||
'keeps observed current coverage complete through %s period pagination without RPC',
|
'keeps observed current coverage complete through %s period pagination without RPC',
|
||||||
async (periodKind) => {
|
async (periodKind) => {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('durable shared instrument metadata (SQLite)', () => {
|
|||||||
getInstrumentByUid: jest.fn(),
|
getInstrumentByUid: jest.fn(),
|
||||||
getInstrumentByFigi: jest.fn(),
|
getInstrumentByFigi: jest.fn(),
|
||||||
};
|
};
|
||||||
const access = new TbankAccessService(prisma, tokens);
|
const access = new TbankAccessService(prisma, tokens, upstream as never);
|
||||||
const repository = new InstrumentMetadataRepository(prisma);
|
const repository = new InstrumentMetadataRepository(prisma);
|
||||||
const logger = { event: jest.fn() };
|
const logger = { event: jest.fn() };
|
||||||
const createService = () =>
|
const createService = () =>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe('Request-driven operations synchronization (SQLite)', () => {
|
|||||||
TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY,
|
TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const access = new TbankAccessService(prisma, tokens);
|
const access = new TbankAccessService(prisma, tokens, upstream as never);
|
||||||
const logger = { event: jest.fn() };
|
const logger = { event: jest.fn() };
|
||||||
const createService = () =>
|
const createService = () =>
|
||||||
new BrokerageOperationsSyncService(
|
new BrokerageOperationsSyncService(
|
||||||
|
|||||||
@@ -122,34 +122,10 @@ describe('screen sync HTTP and SQLite', () => {
|
|||||||
from: '2026-02-31',
|
from: '2026-02-31',
|
||||||
to: '2026-03-01',
|
to: '2026-03-01',
|
||||||
},
|
},
|
||||||
...(['income', 'events'] as const).flatMap((screen) => [
|
|
||||||
{ screen, accountId: 'all', from: '2025-02-29', to: '2025-03-01' },
|
|
||||||
{ screen, accountId: 'all', from: '2024-03-01', to: '2024-02-29' },
|
|
||||||
{
|
|
||||||
screen,
|
|
||||||
accountId: 'all',
|
|
||||||
from: '2024-02-29T00:00:00Z',
|
|
||||||
to: '2024-02-29',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
screen,
|
|
||||||
accountId: 'all',
|
|
||||||
from: '2024-02-29',
|
|
||||||
to: '2024-02-29T23:59:59Z',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
{ screen: 'income', accountId: 'all', from: '2024-02-29' },
|
|
||||||
{ screen: 'income', accountId: 'all', to: '2024-02-29' },
|
|
||||||
{ screen: 'events', accountId: 'all', to: '2000-01-01' },
|
|
||||||
{
|
{
|
||||||
screen: 'stock',
|
screen: 'stock',
|
||||||
instrumentUid: '00000000-0000-4000-8000-000000000001',
|
instrumentUid: '00000000-0000-4000-8000-000000000001',
|
||||||
candleRange: {
|
candleRange: { from: '2026-02-01', to: '2026-01-01', jobs: [] },
|
||||||
from: '2026-02-01',
|
|
||||||
to: '2026-01-01',
|
|
||||||
jobs: [],
|
|
||||||
'~bad/path': 'SYNTHETIC_VALIDATION_SECRET',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
])('rejects malformed scope without reserving work: %j', async (scope) => {
|
])('rejects malformed scope without reserving work: %j', async (scope) => {
|
||||||
const response = await post(scope).expect(400);
|
const response = await post(scope).expect(400);
|
||||||
@@ -166,55 +142,9 @@ describe('screen sync HTTP and SQLite', () => {
|
|||||||
expect(typeof error.pointer).toBe('string');
|
expect(typeof error.pointer).toBe('string');
|
||||||
expect(typeof error.detail).toBe('string');
|
expect(typeof error.detail).toBe('string');
|
||||||
}
|
}
|
||||||
if ('candleRange' in scope) {
|
|
||||||
expect(
|
|
||||||
problem.errors.filter(
|
|
||||||
(error) => error.pointer === '#/candleRange/jobs',
|
|
||||||
),
|
|
||||||
).toHaveLength(1);
|
|
||||||
expect(problem.errors).toContainEqual({
|
|
||||||
pointer: '#/candleRange/~0bad~1path',
|
|
||||||
detail: expect.any(String) as unknown,
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(response.body)).not.toContain(
|
|
||||||
'SYNTHETIC_VALIDATION_SECRET',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
expect(await prisma.tbankSyncWork.count()).toBe(0);
|
expect(await prisma.tbankSyncWork.count()).toBe(0);
|
||||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it.each([
|
|
||||||
{
|
|
||||||
screen: 'income',
|
|
||||||
accountId: 'all',
|
|
||||||
from: '2024-02-29',
|
|
||||||
to: '2024-02-29',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
screen: 'events',
|
|
||||||
accountId: 'all',
|
|
||||||
from: '2096-02-29',
|
|
||||||
to: '2096-02-29',
|
|
||||||
},
|
|
||||||
{ screen: 'events', accountId: 'all', from: '2096-02-29' },
|
|
||||||
{ screen: 'events', accountId: 'all', to: '2096-02-29' },
|
|
||||||
{
|
|
||||||
screen: 'operations',
|
|
||||||
accountId: 'all',
|
|
||||||
from: '2024-02-29T00:00:00Z',
|
|
||||||
to: '2024-03-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
'preserves valid calendar/instant scope in accepted and status responses: %j',
|
|
||||||
async (scope) => {
|
|
||||||
const response = await post(scope).expect(202);
|
|
||||||
const accepted = response.body as { id: string; scope: object };
|
|
||||||
expect(accepted.scope).toEqual(scope);
|
|
||||||
const result = await settled(accepted.id);
|
|
||||||
expect(result.scope).toEqual(scope);
|
|
||||||
expect(result.state).toBe('succeeded');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
it('keeps failed work terminal, cancels abandoned work without restart, and hides old connection handles', async () => {
|
it('keeps failed work terminal, cancels abandoned work without restart, and hides old connection handles', async () => {
|
||||||
upstream.getOpenAccounts.mockRejectedValue(
|
upstream.getOpenAccounts.mockRejectedValue(
|
||||||
new Error('upstream details never exposed'),
|
new Error('upstream details never exposed'),
|
||||||
@@ -306,99 +236,6 @@ describe('screen sync HTTP and SQLite', () => {
|
|||||||
await status(last.meta.sync.id).expect(200);
|
await status(last.meta.sync.id).expect(200);
|
||||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
it.each(['succeeded', 'failed'] as const)(
|
|
||||||
'observes the operations successor until it is %s',
|
|
||||||
async (outcome) => {
|
|
||||||
const accounts = [
|
|
||||||
{
|
|
||||||
id: 'a',
|
|
||||||
name: 'Synthetic',
|
|
||||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
|
||||||
openedDate: { seconds: '1577836800' },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let releaseMembership: (value: typeof accounts) => void = () => {};
|
|
||||||
upstream.getOpenAccounts.mockReturnValue(
|
|
||||||
new Promise((resolve) => {
|
|
||||||
releaseMembership = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
|
||||||
where: { userId: 'owner' },
|
|
||||||
});
|
|
||||||
await prisma.tbankAccount.create({
|
|
||||||
data: {
|
|
||||||
userId: 'owner',
|
|
||||||
revision: connection.revision,
|
|
||||||
accountId: 'a',
|
|
||||||
name: 'Synthetic',
|
|
||||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
|
||||||
openedOn: '2020-01-01',
|
|
||||||
upstreamOrder: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let release: (value: {
|
|
||||||
hasNext: boolean;
|
|
||||||
items: never[];
|
|
||||||
}) => void = () => {};
|
|
||||||
let reject: (error: Error) => void = () => {};
|
|
||||||
upstream.getOperationsByCursor
|
|
||||||
.mockReturnValueOnce(
|
|
||||||
new Promise((resolve, rejectResult) => {
|
|
||||||
release = resolve;
|
|
||||||
reject = rejectResult;
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mockImplementation(() =>
|
|
||||||
outcome === 'failed'
|
|
||||||
? Promise.reject(new Error('Synthetic outage'))
|
|
||||||
: Promise.resolve({ hasNext: false, items: [] }),
|
|
||||||
);
|
|
||||||
const accept = async (from: string, to: string) => {
|
|
||||||
const response = await post({
|
|
||||||
screen: 'operations',
|
|
||||||
accountId: 'a',
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
}).expect(202);
|
|
||||||
return (response.body as { id: string }).id;
|
|
||||||
};
|
|
||||||
const dispatched = async (id: string) => {
|
|
||||||
for (let i = 0; i < 300; i++) {
|
|
||||||
const work = await prisma.tbankSyncWork.findUniqueOrThrow({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
if ((work.progress as { dispatched: boolean }).dispatched) return id;
|
|
||||||
await delay(5);
|
|
||||||
}
|
|
||||||
throw new Error('Screen work did not dispatch');
|
|
||||||
};
|
|
||||||
const first = await accept('2026-01-01', '2026-01-10');
|
|
||||||
const second = await accept('2026-01-20', '2026-01-31');
|
|
||||||
const merged = await accept('2026-01-01', '2026-01-31');
|
|
||||||
releaseMembership(accounts);
|
|
||||||
await Promise.all([first, second, merged].map(dispatched));
|
|
||||||
try {
|
|
||||||
expect((await status(second).expect(200)).body).toMatchObject({
|
|
||||||
state: 'running',
|
|
||||||
progress: { completed: 1, total: 2, discovering: false },
|
|
||||||
});
|
|
||||||
await status(second, 'other').expect(404);
|
|
||||||
} finally {
|
|
||||||
if (outcome === 'failed') reject(new Error('Synthetic outage'));
|
|
||||||
else release({ hasNext: false, items: [] });
|
|
||||||
await settled(merged);
|
|
||||||
await settled(first);
|
|
||||||
}
|
|
||||||
expect(await settled(second)).toMatchObject({
|
|
||||||
state: outcome,
|
|
||||||
errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null,
|
|
||||||
progress: { completed: 2, total: 2, discovering: false },
|
|
||||||
});
|
|
||||||
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
|
|
||||||
await status(second).expect(404);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
it('rejects known foreign accounts locally and requires authentication', async () => {
|
it('rejects known foreign accounts locally and requires authentication', async () => {
|
||||||
await post({ screen: 'operations', accountId: 'foreign' }).expect(404);
|
await post({ screen: 'operations', accountId: 'foreign' }).expect(404);
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { PrismaService } from '../src/prisma/prisma.service';
|
|||||||
import {
|
import {
|
||||||
TbankClient,
|
TbankClient,
|
||||||
TbankTokenService,
|
TbankTokenService,
|
||||||
tbankSourceTtlMs,
|
|
||||||
} from '../src/integrations/tbank/tbank.api';
|
} from '../src/integrations/tbank/tbank.api';
|
||||||
import { MoexClient } from '../src/integrations/moex/moex.api';
|
import { MoexClient } from '../src/integrations/moex/moex.api';
|
||||||
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
|
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
|
||||||
@@ -424,95 +423,6 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
expect(moex.bondEvents).not.toHaveBeenCalled();
|
expect(moex.bondEvents).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
|
||||||
['stocks', shareUid, 'dividends', 'STOCK'],
|
|
||||||
['bonds', bondUid, 'coupons', 'BOND'],
|
|
||||||
])(
|
|
||||||
'ages the saved %s payout position without RPC or a financial revision, including incompatible fresh candidates',
|
|
||||||
async (kind, uid, payout, type) => {
|
|
||||||
const date = new Date(Date.now() + 7 * 86400000)
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 10);
|
|
||||||
upstream.getDividends.mockResolvedValue([
|
|
||||||
{
|
|
||||||
recordDate: time(date),
|
|
||||||
dividendNet: { units: '10', nano: 1, currency: 'rub' },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
upstream.getBondCoupons.mockResolvedValue([
|
|
||||||
{
|
|
||||||
couponDate: time(date),
|
|
||||||
fixDate: time(date),
|
|
||||||
payOneBond: { units: '2', currency: 'rub' },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await portfolio([position(uid, '3', type)]);
|
|
||||||
const path = `${kind}/${uid}/${payout}`;
|
|
||||||
const read = async () =>
|
|
||||||
(await get(`${path}?sync=observe`).expect(200)).body as
|
|
||||||
StockDividendsResourceDto | BondCouponsResourceDto;
|
|
||||||
await get(path).expect(200);
|
|
||||||
await settled();
|
|
||||||
const before = await read();
|
|
||||||
if (!before.data?.positionAt)
|
|
||||||
throw new Error('Expected a saved position');
|
|
||||||
expect(before.data.positionIsStale).toBe(false);
|
|
||||||
const saved = await prisma.tbankForecast.findFirstOrThrow();
|
|
||||||
const expiresAt =
|
|
||||||
Date.parse(before.data.positionAt) + tbankSourceTtlMs.portfolio;
|
|
||||||
const clock = jest.spyOn(Date, 'now').mockReturnValue(expiresAt - 1);
|
|
||||||
jest.clearAllMocks();
|
|
||||||
try {
|
|
||||||
expect((await read()).data).toEqual(before.data);
|
|
||||||
clock.mockReturnValue(expiresAt);
|
|
||||||
const aged = await read();
|
|
||||||
expect(aged.data).toEqual({ ...before.data, positionIsStale: true });
|
|
||||||
expect(aged.meta.freshness).toBe('stale');
|
|
||||||
expect(aged.meta.dataRevision).toBe(before.meta.dataRevision);
|
|
||||||
expect(aged.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
|
|
||||||
expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(saved);
|
|
||||||
|
|
||||||
await portfolio([position(uid, '9', type)]);
|
|
||||||
await prisma.tbankPortfolio.updateMany({
|
|
||||||
where: { userId: 'owner' },
|
|
||||||
data: { fetchedAt: new Date(expiresAt) },
|
|
||||||
});
|
|
||||||
const repository = app.get(ForecastRepository);
|
|
||||||
const resolve = repository.resolve.bind(repository);
|
|
||||||
const publish = jest
|
|
||||||
.spyOn(repository, 'resolve')
|
|
||||||
.mockImplementationOnce(async (input) => {
|
|
||||||
expect(input.candidate).toMatchObject({
|
|
||||||
positionAt: new Date(expiresAt).toISOString(),
|
|
||||||
positionIsStale: false,
|
|
||||||
});
|
|
||||||
await prisma.tbankPortfolio.updateMany({
|
|
||||||
where: { userId: 'owner' },
|
|
||||||
data: { dataRevision: { increment: 1 } },
|
|
||||||
});
|
|
||||||
return resolve(input);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const fallback = await read();
|
|
||||||
expect(fallback.data).toEqual(aged.data);
|
|
||||||
expect(fallback.meta.freshness).toBe('stale');
|
|
||||||
expect(fallback.meta.dataRevision).toBe(before.meta.dataRevision);
|
|
||||||
expect(fallback.meta.lastSuccessfulAt).toBe(
|
|
||||||
before.meta.lastSuccessfulAt,
|
|
||||||
);
|
|
||||||
expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(saved);
|
|
||||||
} finally {
|
|
||||||
publish.mockRestore();
|
|
||||||
}
|
|
||||||
for (const rpc of Object.values(upstream))
|
|
||||||
expect(rpc).not.toHaveBeenCalled();
|
|
||||||
expect(moex.bondEvents).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
clock.mockRestore();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('keeps optional metadata failure separate from calendar completeness and uses portfolio ticker', async () => {
|
it('keeps optional metadata failure separate from calendar completeness and uses portfolio ticker', async () => {
|
||||||
upstream.getBond.mockRejectedValue(new Error('metadata unavailable'));
|
upstream.getBond.mockRejectedValue(new Error('metadata unavailable'));
|
||||||
await portfolio([position(bondUid, '3', 'BOND')]);
|
await portfolio([position(bondUid, '3', 'BOND')]);
|
||||||
@@ -526,72 +436,14 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
|
expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it('does not turn a missing MOEX block into a complete empty forecast and defaults to a 30-day window', async () => {
|
||||||
['missing block', { coupons: undefined }],
|
|
||||||
[
|
|
||||||
'invalid cursor',
|
|
||||||
{ 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } },
|
|
||||||
],
|
|
||||||
])(
|
|
||||||
'preserves the saved calendar and forecast when real MOEX parsing rejects %s',
|
|
||||||
async (_name, invalid) => {
|
|
||||||
await portfolio([position(bondUid, '3', 'BOND')]);
|
|
||||||
const response = {
|
|
||||||
coupons: {
|
|
||||||
columns: ['coupondate', 'value', 'faceunit'],
|
|
||||||
data: [['2099-02-01', '2', 'RUB']],
|
|
||||||
},
|
|
||||||
offers: { columns: ['offerdate'], data: [] },
|
|
||||||
description: { columns: ['name', 'value'], data: [] },
|
|
||||||
};
|
|
||||||
const fetchMock = jest
|
|
||||||
.spyOn(global, 'fetch')
|
|
||||||
.mockImplementation(() => Promise.resolve(Response.json(response)));
|
|
||||||
moex.bondEvents.mockImplementation((ticker: string) =>
|
|
||||||
new MoexClient().bondEvents(ticker),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await events(false).expect(200);
|
|
||||||
await settled();
|
|
||||||
const before = (await events().expect(200))
|
|
||||||
.body as UpcomingEventsResourceDto;
|
|
||||||
expect(before.data?.incomeTotalsByCurrency).toEqual([
|
|
||||||
{ currency: 'RUB', value: '6' },
|
|
||||||
]);
|
|
||||||
const calendar = await prisma.tbankInstrumentSource.findFirstOrThrow({
|
|
||||||
where: { kind: 'bond-events' },
|
|
||||||
});
|
|
||||||
const forecast = await prisma.tbankForecast.findFirstOrThrow();
|
|
||||||
fetchMock.mockImplementation(() =>
|
|
||||||
Promise.resolve(Response.json({ ...response, ...invalid })),
|
|
||||||
);
|
|
||||||
await app
|
|
||||||
.get(CatalogEnrichmentService)
|
|
||||||
.requestMany('owner', 'owner-revision', 'bond', [bondUid], true, [
|
|
||||||
'bond-events',
|
|
||||||
]);
|
|
||||||
await settled();
|
|
||||||
const after = (await events().expect(200))
|
|
||||||
.body as UpcomingEventsResourceDto;
|
|
||||||
expect(after.data).toEqual(before.data);
|
|
||||||
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
|
|
||||||
expect(after.meta.sync.state).toBe('failed');
|
|
||||||
expect(
|
|
||||||
await prisma.tbankInstrumentSource.findFirstOrThrow({
|
|
||||||
where: { kind: 'bond-events' },
|
|
||||||
}),
|
|
||||||
).toEqual(calendar);
|
|
||||||
expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(forecast);
|
|
||||||
} finally {
|
|
||||||
fetchMock.mockRestore();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('defaults to a 30-day window', async () => {
|
|
||||||
await portfolio([position(bondUid, '3', 'BOND')]);
|
await portfolio([position(bondUid, '3', 'BOND')]);
|
||||||
|
moex.bondEvents.mockRejectedValueOnce(new Error('missing MOEX block'));
|
||||||
await events(false).expect(200);
|
await events(false).expect(200);
|
||||||
await settled();
|
await settled();
|
||||||
|
expect(
|
||||||
|
((await events().expect(200)).body as UpcomingEventsResourceDto).data,
|
||||||
|
).toBeNull();
|
||||||
await get('brokerage-accounts/account/events?from=2099-02-01').expect(200);
|
await get('brokerage-accounts/account/events?from=2099-02-01').expect(200);
|
||||||
await settled();
|
await settled();
|
||||||
const result = (
|
const result = (
|
||||||
@@ -645,38 +497,4 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
await get(`brokerage-accounts/account/events?${query}`).expect(400);
|
await get(`brokerage-accounts/account/events?${query}`).expect(400);
|
||||||
expect(upstream.getDividends).not.toHaveBeenCalled();
|
expect(upstream.getDividends).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses UTC today for an end-only range and includes the entire leap day', async () => {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
await get('brokerage-accounts/account/events?to=2096-02-29').expect(200);
|
|
||||||
await settled();
|
|
||||||
expect(upstream.getDividends).toHaveBeenCalledWith(
|
|
||||||
'synthetic-owner',
|
|
||||||
shareUid,
|
|
||||||
today,
|
|
||||||
'2096-02-29',
|
|
||||||
);
|
|
||||||
upstream.getDividends.mockResolvedValue([
|
|
||||||
{
|
|
||||||
recordDate: {
|
|
||||||
seconds: String(Date.parse('2096-02-29T23:59:59Z') / 1000),
|
|
||||||
},
|
|
||||||
dividendNet: { units: '7', currency: 'rub' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
recordDate: time('2096-03-01'),
|
|
||||||
dividendNet: { units: '99', currency: 'rub' },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const range = { from: '2096-02-29', to: '2096-02-29' };
|
|
||||||
await events(false, 'account', 'owner', range).expect(200);
|
|
||||||
await settled();
|
|
||||||
const result = (await events(true, 'account', 'owner', range).expect(200))
|
|
||||||
.body as UpcomingEventsResourceDto;
|
|
||||||
expect(result.data?.events).toHaveLength(1);
|
|
||||||
expect(result.data?.events[0]).toMatchObject({
|
|
||||||
date: '2096-02-29',
|
|
||||||
projectedAmount: { currency: 'RUB', value: '14' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user