Compare commits
87
Commits
ca5dbcb382
..
v2.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a0e70b273 | ||
|
|
449cfcd6c5 | ||
|
|
966a1f793a | ||
|
|
d1f6969dc7 | ||
|
|
b20080ac85 | ||
|
|
32622c1dad | ||
|
|
4f485ab0e9 | ||
|
|
8a6f60d965 | ||
|
|
e03fad622b | ||
|
|
774e58ef80 | ||
|
|
702302126b | ||
|
|
ec6d9a2639 | ||
|
|
7bc7d8ddec | ||
|
|
9e4f9d9cab | ||
|
|
a93f2d55e7 | ||
|
|
96d4de4195 | ||
|
|
c789e33c41 | ||
|
|
2ad3517d87 | ||
|
|
0d7839cf79 | ||
|
|
6975853656 | ||
|
|
14bcb9da5f | ||
|
|
b90cedd580 | ||
|
|
da2e1d0781 | ||
|
|
bbb5afff84 | ||
|
|
285dd08472 | ||
|
|
1033d2114e | ||
|
|
203cc72cb2 | ||
|
|
9b7e5e1215 | ||
|
|
31b0e15579 | ||
|
|
3d50db7903 | ||
|
|
8d5569a3c7 | ||
|
|
90486d4237 | ||
|
|
52fa3a36ba | ||
|
|
b9b76bab91 | ||
|
|
fa57a201fd | ||
|
|
96a442253e | ||
|
|
c5248dbefc | ||
|
|
c7d060023b | ||
|
|
a31e8d2c4d | ||
|
|
2f7c15205f | ||
|
|
d7895f287e | ||
|
|
bcd5cb3dd0 | ||
|
|
0ad84ff4f0 | ||
|
|
7d10ef8adf | ||
|
|
bc631e72de | ||
|
|
947ffefa9b | ||
|
|
db375cbee7 | ||
|
|
a46a4112cb | ||
|
|
966b2a5836 | ||
|
|
323b83b582 | ||
|
|
5d857de7a5 | ||
|
|
2facd8675c | ||
|
|
4870d49503 | ||
|
|
85858a160f | ||
|
|
0057925888 | ||
|
|
03e75f8d2b | ||
|
|
dbb1748a5f | ||
|
|
01e2de6a04 | ||
|
|
30e96e51e2 | ||
|
|
a7619d5c8f | ||
|
|
d698ed9425 | ||
|
|
51f345bdc5 | ||
|
|
0f27da9fd1 | ||
|
|
1be6db65d7 | ||
|
|
99de4af678 | ||
|
|
14a204de0d | ||
|
|
a13cf84ead | ||
|
|
7fe88a12c7 | ||
|
|
190eb90ffe | ||
|
|
fb2e851e7e | ||
|
|
15a3d5f331 | ||
|
|
bee34290fb | ||
|
|
6cd9c2dd28 | ||
|
|
b24433092b | ||
|
|
33592c1d5f | ||
|
|
183b7f1efd | ||
|
|
b78c215ff1 | ||
|
|
a970eaaf7a | ||
|
|
e76eb545d7 | ||
|
|
7d2262fb43 | ||
|
|
33a5135741 | ||
|
|
53b2794963 | ||
|
|
2d14081d27 | ||
|
|
0303e60b64 | ||
|
|
9cc9fbdcff | ||
|
|
9b3558f96c | ||
|
|
92f216d520 |
+24
-14
@@ -41,8 +41,8 @@ provider records преобразуются перед чистыми domain cal
|
|||||||
|
|
||||||
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
|
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
|
||||||
для публичных consumers.
|
для публичных consumers.
|
||||||
Problem Details фильтруются общей HTTP boundary; StructuredLogger добавляет
|
Problem Details фильтруются общей HTTP boundary; native Pino добавляет request
|
||||||
request context и ограничивает поля событий. Jest unit tests, изолированные
|
context, StructuredLogger ограничивает поля событий. Jest unit tests, изолированные
|
||||||
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
|
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
|
||||||
Локальные источники выбираются через [Источники](../AGENTS.md#источники).
|
Локальные источники выбираются через [Источники](../AGENTS.md#источники).
|
||||||
|
|
||||||
@@ -160,10 +160,11 @@ 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`,
|
||||||
|
`integrations/tbank/tbank-protocol.ts`, `integrations/tbank/tbank-error-code.ts` и
|
||||||
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
|
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
|
||||||
публичные входы для protobuf conversion и interval rules: импорт расчётов
|
публичные входы для protobuf conversion, protocol/error vocabulary и interval
|
||||||
не загружает DI/Prisma.
|
rules: импорт этих значений и расчётов не загружает DI/Prisma.
|
||||||
Внутри модуля используй прямые локальные импорты.
|
Внутри модуля используй прямые локальные импорты.
|
||||||
Tests могут импортировать внутренний owner для проверки поведения и mocks.
|
Tests могут импортировать внутренний owner для проверки поведения и mocks.
|
||||||
Новый export требует текущего потребителя; controller и persistence остаются
|
Новый export требует текущего потребителя; controller и persistence остаются
|
||||||
@@ -235,8 +236,8 @@ protobuf units/nano conversion — T-Bank integration. Domain используе
|
|||||||
Общий выбор symbolic values и числовых policies —
|
Общий выбор symbolic values и числовых policies —
|
||||||
[Domain values](conventions/engineering.md#domain-values).
|
[Domain values](conventions/engineering.md#domain-values).
|
||||||
Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай
|
Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай
|
||||||
`unknown` + runtime narrowing; отключённый lint `no-explicit-any` не делает
|
`unknown` + runtime narrowing; ESLint запрещает explicit `any`
|
||||||
`any` нормой domain/application model. `declare` допустим для заполняемых извне
|
в domain/application model, тестах и конфигурации TypeScript. `declare` допустим для заполняемых извне
|
||||||
DTO fields, не для сокрытия неинициализированного owned state.
|
DTO fields, не для сокрытия неинициализированного owned state.
|
||||||
|
|
||||||
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
|
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
|
||||||
@@ -246,11 +247,17 @@ OpenAPI enum metadata и runtime validation должны читать тот ж
|
|||||||
сохраняй; generated Prisma/protocol code вручную не меняй.
|
сохраняй; generated Prisma/protocol code вручную не меняй.
|
||||||
|
|
||||||
`@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule
|
`@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule
|
||||||
отключено в пользу TS-версии. Исключения для очевидных чисел и их bigint форм,
|
отключено в пользу TS-версии. Общего списка `ignore` для чисел и bigint нет:
|
||||||
индексов, default values, enum/type declarations и readonly properties заданы
|
значения, проверяемые правилом, получают предметные имена у владельца.
|
||||||
в ESLint config. Test fixtures/expected values остаются inline, остальные lint
|
Исключения для индексов, default values, enum/type declarations и readonly
|
||||||
правила продолжают действовать. Numeric policy получает предметное имя и единицы;
|
properties заданы в ESLint config. Test fixtures/expected values остаются inline,
|
||||||
semantic magic strings проверяются conventions/review без нового инструмента.
|
остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы;
|
||||||
|
Machine codes и protocol values задаются строковыми enum у владельца.
|
||||||
|
`no-restricted-syntax` запрещает inline-коды и значения известных protocol families
|
||||||
|
в production TS; `no-unsafe-enum-comparison` проверяет сравнения enum.
|
||||||
|
Внешние открытые строки проверяются на boundary без cast и без потери unknown fallback.
|
||||||
|
Tests сохраняют независимые literal wire assertions; обычный текст и env keys
|
||||||
|
не считаются кодами. Новые словари требуют проверки владельца и расширения selectors.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
@@ -260,5 +267,8 @@ semantic magic strings проверяются conventions/review без ново
|
|||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
|
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
|
||||||
Смысл boundaries проверяется review: backend не имеет отдельного автоматического
|
ESLint проверяет прямые imports: domain не зависит от transport/persistence,
|
||||||
FSD/DI architecture checker. Existing tests и build не доказывают правильный owner.
|
controllers делегируют application, use cases не импортируют Prisma. Чистые
|
||||||
|
protocol inputs разрешены; existing CRUD/transaction services сохраняют DB owner.
|
||||||
|
Транзитивные зависимости, DI composition и смысл ownership проверяются review:
|
||||||
|
backend не имеет полного FSD/DI graph checker. Tests/build не доказывают owner.
|
||||||
|
|||||||
@@ -7,11 +7,19 @@
|
|||||||
checkout. Внешний API задаётся предоставленным публичным контрактом;
|
checkout. Внешний API задаётся предоставленным публичным контрактом;
|
||||||
исходники или инструкции его реализации не являются зависимостью разработки.
|
исходники или инструкции его реализации не являются зависимостью разработки.
|
||||||
При нехватке требования сообщи, какое решение не определено, до его реализации.
|
При нехватке требования сообщи, какое решение не определено, до его реализации.
|
||||||
|
При конфликте с общим шаблоном следуй местным правилам и явному запросу
|
||||||
|
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
|
||||||
|
логики, публичного контракта или утверждённой семантики возвращай владельцу
|
||||||
|
на согласование до зависимой работы.
|
||||||
|
|
||||||
Начни с результата и affected areas. До edits проследи flow/invariants, найди
|
Начни с результата и affected areas. До edits проследи flow/invariants, найди
|
||||||
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
|
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
|
||||||
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
|
Выбери lightweight или SDD по Engineering Workflow из AGENTS; OpenSpec lifecycle
|
||||||
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
|
начинай после явного решения Change Owner.
|
||||||
|
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
|
||||||
|
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
|
||||||
|
выполни `nvm use --silent` из backend. Если `nvm.sh` недоступен, остановись,
|
||||||
|
не используй системный Node.
|
||||||
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
|
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
|
||||||
|
|
||||||
## Change scope
|
## Change scope
|
||||||
@@ -29,7 +37,8 @@ owner и 1–3 примера. Код показывает механизм; с
|
|||||||
## Domain values
|
## Domain values
|
||||||
|
|
||||||
Finite business vocabulary принадлежит одному domain/module/entity owner.
|
Finite business vocabulary принадлежит одному domain/module/entity owner.
|
||||||
Переиспользуй его symbols; для нового словаря без аналога — `as const` + union.
|
Переиспользуй его symbols; для machine codes и protocol values используй
|
||||||
|
строковые `enum`, для структуры с разными payload — discriminated union.
|
||||||
Сохраняй wire values; generated/external enums не переписывай вручную.
|
Сохраняй wire values; generated/external enums не переписывай вручную.
|
||||||
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
|
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
|
||||||
обычные labels не выноси в constants без semantic benefit.
|
обычные labels не выноси в constants без semantic benefit.
|
||||||
@@ -56,19 +65,33 @@ Peer-конфликт решай совместимыми версиями бе
|
|||||||
|
|
||||||
## Проверки и завершение
|
## Проверки и завершение
|
||||||
|
|
||||||
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
|
| Когда | Что проверить |
|
||||||
risk checks из местной testing convention. Targeted tests ускоряют итерацию,
|
| --- | --- |
|
||||||
но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
|
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
|
||||||
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
|
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
|
||||||
чтения и `git diff --check`; application build ради текста не нужен.
|
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
|
||||||
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
|
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
|
||||||
и ограничения. Зелёный lint не заменяет semantic/security review.
|
|
||||||
|
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
|
||||||
|
проверенные входы, команду, exit code, результат и ограничения. Повторяй
|
||||||
|
проверку при изменении значимых входов (включая config, dependencies и
|
||||||
|
generated artifacts), сомнении в evidence или явном запросе пользователя.
|
||||||
|
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
|
||||||
|
|
||||||
## Git и релизы
|
## Git и релизы
|
||||||
|
|
||||||
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
|
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
|
||||||
|
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
|
||||||
|
Имя ветки и связь PR с реальной Issue проверяются вручную: commitlint
|
||||||
|
проверяет синтаксис commit/PR title, но не существование и соответствие Issue.
|
||||||
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
|
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
|
||||||
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
|
Commits и PR titles — Conventional Commits с parser-recognized Issue reference; допустим
|
||||||
содержит одну связанную задачу. Root другого checkout не управляет этим repo.
|
`spec:`. Каждый commit содержит одну задачу. Исключения release и правила
|
||||||
|
ссылки — в Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
|
||||||
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
|
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
|
||||||
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
|
обычную работу. PR сливается squash или rebase-merge; merge-коммит не
|
||||||
|
используется. `main` обновляется только fast-forward из `develop`, поэтому
|
||||||
|
`origin/main` всегда предок `origin/develop`:
|
||||||
|
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
|
||||||
|
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
|
||||||
|
Перед релизом прочитай местный README, раздел «Релизы».
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ RPC, credentials/access и resilience. Эти владельцы не объед
|
|||||||
|
|
||||||
Преобразование provider records происходит на boundary: например,
|
Преобразование provider records происходит на boundary: например,
|
||||||
`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper
|
`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper
|
||||||
остаётся отдельным чистым входом integration. Domain получает внутренние
|
остаётся отдельным чистым входом integration. `tbank-protocol.ts` и
|
||||||
|
`tbank-error-code.ts` также импортируются напрямую: их enums и guards
|
||||||
|
не должны загружать DI/Prisma через service exports. Domain получает внутренние
|
||||||
значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый
|
значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый
|
||||||
client или mapper для простого копирования полей. Даты/units/nano разбираются
|
client или mapper для простого копирования полей. Даты/units/nano разбираются
|
||||||
на границе клиента/его mapping, а не становятся моделью чистых расчётов.
|
на границе клиента/его mapping, а не становятся моделью чистых расчётов.
|
||||||
@@ -41,6 +43,10 @@ CA mechanism. Точные параметры и ограничения прин
|
|||||||
[validated environment](../../src/config/env.validation.ts) и
|
[validated environment](../../src/config/env.validation.ts) и
|
||||||
[TbankClient](../../src/integrations/tbank/tbank.client.ts).
|
[TbankClient](../../src/integrations/tbank/tbank.client.ts).
|
||||||
|
|
||||||
|
Все RPC, включая metadata, допускают один повтор временной upstream-ошибки.
|
||||||
|
Переполнение локальной очереди не повторяется. Свечи загружаются по требуемому
|
||||||
|
диапазону; prefetch портфеля и автоматический переход к `all` не поддерживаются.
|
||||||
|
|
||||||
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
|
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
|
||||||
собственный bounded timeout и безопасный unavailable outcome; не приписывай им
|
собственный bounded timeout и безопасный unavailable outcome; не приписывай им
|
||||||
gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
|
gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
|
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
|
||||||
`warn` — восстановимая проблема/retry; `error` — окончательный сбой.
|
`warn` — восстановимая проблема/retry; `error` — окончательный сбой.
|
||||||
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
|
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
|
||||||
- RequestContextService и request middleware владеют correlation: серверный
|
- Native `nestjs-pino` context и `pino-http` completion владеют correlation:
|
||||||
request ID возвращается как `X-Request-ID`, входящий client ID хранится отдельно.
|
server request ID возвращается как `X-Request-ID`, client ID хранится отдельно.
|
||||||
|
Настройки принадлежат `common/logging/logging.module.ts`; второй AsyncLocalStorage
|
||||||
|
или HTTP completion middleware не нужен.
|
||||||
Передавай существующий context, не создавай собственный UUID в каждом service.
|
Передавай существующий context, не создавай собственный UUID в каждом service.
|
||||||
- Wrapper ограничивает поля, Pino redacts request secrets. Не логируй passwords,
|
- StructuredLogger ограничивает поля, Pino redacts request secrets. Не логируй passwords,
|
||||||
tokens, cookie, Authorization, credentials, connection generation, raw body
|
tokens, cookie, Authorization, credentials, connection generation, raw body
|
||||||
или upstream error. Secret в `message/stack` не становится безопасным из-за
|
или upstream error. Secret в `message/stack` не становится безопасным из-за
|
||||||
allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
|
allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ Source code конфигурации cookie —
|
|||||||
проверки browser/HTTP поведения, не только TypeScript.
|
проверки browser/HTTP поведения, не только TypeScript.
|
||||||
|
|
||||||
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
|
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
|
||||||
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace
|
401, но не меняет cookie и сам по себе не отзывает session победителя.
|
||||||
|
Cookie очищает logout; межвкладочная синхронизация не требуется, допустим re-login.
|
||||||
|
Не вводи Web Locks, replay history/grace
|
||||||
или иной security contract без текущей задачи. Logout идемпотентен, отзывает
|
или иной security contract без текущей задачи. Logout идемпотентен, отзывает
|
||||||
сессию и очищает refresh cookie на HTTP-границе.
|
сессию и очищает refresh cookie на HTTP-границе.
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
|
|||||||
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
|
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
|
||||||
`npm run test:openapi-contract` — отдельный required check при wire contract change.
|
`npm run test:openapi-contract` — отдельный required check при wire contract change.
|
||||||
|
|
||||||
`npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner;
|
`npm run test:e2e` запускает функциональные HTTP suites через изолированный SQLite runner;
|
||||||
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
|
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
|
||||||
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
|
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
|
||||||
|
Большой latency/load benchmark запускается отдельно через `npm run test:performance`:
|
||||||
|
его абсолютный wall-clock budget не является merge gate на shared runner.
|
||||||
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
|
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
|
||||||
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
|
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
|
||||||
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
|
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Неверное поведение
|
||||||
|
---
|
||||||
|
## Проблема и шаги воспроизведения
|
||||||
|
|
||||||
|
## Факт / ожидание
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Окружение / ссылки (если применимо)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
name: Engineering Task
|
||||||
|
about: CI, тесты, документация, процесс и прочее
|
||||||
|
---
|
||||||
|
## Контекст и результат
|
||||||
|
|
||||||
|
## Scope / связанные Issues (если есть)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Новое наблюдаемое поведение
|
||||||
|
---
|
||||||
|
## Зачем и какой результат
|
||||||
|
|
||||||
|
## Ожидаемое поведение
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Зависимости / связанные Issues (если есть)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: Refactoring / Technical Debt
|
||||||
|
about: Улучшение без нового поведения
|
||||||
|
---
|
||||||
|
## Проблема и результат
|
||||||
|
|
||||||
|
## Сохраняемые инварианты / границы
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Зависимости / ссылки (если есть)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
## Linked Issue
|
||||||
|
Refs
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Команда / результат / ограничения:
|
||||||
|
|
||||||
|
## Breaking Changes (если есть)
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
name: Conventional commits
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened, edited]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
commits:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: .nvmrc
|
|
||||||
- run: npm ci --ignore-scripts
|
|
||||||
- name: Check PR title and commits
|
|
||||||
env:
|
|
||||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
||||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
run: |
|
|
||||||
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
|
||||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
|
||||||
@@ -28,9 +28,8 @@ jobs:
|
|||||||
docker version
|
docker version
|
||||||
docker buildx version
|
docker buildx version
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx --no-install prisma generate
|
|
||||||
- run: npm run verify
|
- run: npm run verify
|
||||||
- run: npm run test:e2e
|
- run: node test/sqlite.cjs --all
|
||||||
- name: Login with dedicated Nexus writer
|
- name: Login with dedicated Nexus writer
|
||||||
env:
|
env:
|
||||||
REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }}
|
REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
name: Backend security
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
pull_request:
|
|
||||||
schedule:
|
|
||||||
- cron: '17 5 * * *'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
security:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Record checked revision
|
|
||||||
run: git rev-parse HEAD
|
|
||||||
- name: Scan history with Gitleaks 8.30.1
|
|
||||||
run: |
|
|
||||||
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
|
|
||||||
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
|
|
||||||
mkdir -p /tmp/gitleaks-bin
|
|
||||||
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
|
|
||||||
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: .nvmrc
|
|
||||||
- name: Audit runtime and development dependencies
|
|
||||||
run: npm audit --include=dev --audit-level=low
|
|
||||||
@@ -2,11 +2,80 @@ name: Backend verify
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['**']
|
branches: [develop, main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, edited]
|
||||||
|
schedule:
|
||||||
|
- cron: '47 5 * * *'
|
||||||
|
|
||||||
|
# Gitea >= 1.26. A new revision supersedes only the same PR/branch/event.
|
||||||
|
concurrency:
|
||||||
|
group: backend-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
commits:
|
||||||
|
if: gitea.event_name == 'pull_request'
|
||||||
|
timeout-minutes: 15
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
- run: npm ci --ignore-scripts
|
||||||
|
- name: Check PR title and commits
|
||||||
|
env:
|
||||||
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: |
|
||||||
|
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
||||||
|
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
|
||||||
|
else
|
||||||
|
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||||
|
fi
|
||||||
|
|
||||||
|
security:
|
||||||
|
needs: commits
|
||||||
|
if: >-
|
||||||
|
!cancelled() &&
|
||||||
|
gitea.event.action != 'edited' &&
|
||||||
|
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
|
||||||
|
timeout-minutes: 15
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Record checked revision
|
||||||
|
run: git rev-parse HEAD
|
||||||
|
- name: Scan history with Gitleaks 8.30.1
|
||||||
|
run: |
|
||||||
|
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
|
||||||
|
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
|
||||||
|
mkdir -p /tmp/gitleaks-bin
|
||||||
|
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
|
||||||
|
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
|
||||||
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
- name: Audit runtime and development dependencies
|
||||||
|
run: |
|
||||||
|
npm audit --omit=dev --audit-level=low
|
||||||
|
npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c
|
||||||
|
|
||||||
verify:
|
verify:
|
||||||
|
needs: [commits, security]
|
||||||
|
if: >-
|
||||||
|
!cancelled() && gitea.event.action != 'edited' &&
|
||||||
|
needs.security.result == 'success' &&
|
||||||
|
gitea.event_name != 'schedule'
|
||||||
|
timeout-minutes: 45
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
@@ -18,12 +87,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- name: Generate Prisma client before typechecks
|
|
||||||
run: npx --no-install prisma generate
|
|
||||||
- name: Run all backend gates
|
- name: Run all backend gates
|
||||||
run: npm run verify
|
run: npm run verify
|
||||||
- name: Run isolated SQLite and runtime OpenAPI suites
|
- name: Run isolated SQLite and runtime OpenAPI suites
|
||||||
run: npm run test:e2e
|
run: node test/sqlite.cjs --all
|
||||||
- name: Analyze develop with SonarQube
|
- name: Analyze develop with SonarQube
|
||||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop'
|
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop'
|
||||||
# v2.3.0: same Docker-based scanner used for SonarQube 9.9.
|
# v2.3.0: same Docker-based scanner used for SonarQube 9.9.
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all"
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
только его файлы и предоставленные публичные контракты. Перед работой прочитай
|
только его файлы и предоставленные публичные контракты. Перед работой прочитай
|
||||||
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
|
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
|
||||||
объединяй triggers задачи и путей; читай активированные темы до решения.
|
объединяй triggers задачи и путей; читай активированные темы до решения.
|
||||||
|
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
|
||||||
|
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
|
||||||
|
(main#28; после merge — актуальная версия `develop`).
|
||||||
|
|
||||||
| Решение / сигнал | Читать |
|
| Решение / сигнал | Читать |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
+169
@@ -1,5 +1,174 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.3.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.2.0...v2.3.0) (2026-10-24)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* log unhandled errors and pretty dev output (WealthLedger/backend[#120](https://git.personal.ksv741.ru/WealthLedger/backend/issues/120)) ([#126](https://git.personal.ksv741.ru/WealthLedger/backend/issues/126)) ([966a1f7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966a1f793aa2087cce3e47750e0017f78315bd64))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* persist refresh cookie across browser restarts (WealthLedger/backend[#123](https://git.personal.ksv741.ru/WealthLedger/backend/issues/123)) ([d1f6969](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d1f6969dc7c22ae3cf7396bccdbf0cea2f61db2e))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* **deps:** update non-major dependencies [#11](https://git.personal.ksv741.ru/WealthLedger/backend/issues/11) ([449cfcd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/449cfcd6c5a91307012bb3a640c8255ba243cbf5))
|
||||||
|
|
||||||
|
## [2.2.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.1.0...v2.2.0) (2026-10-18)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* unify income payment cards and drop period comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/backend/issues/33)) ([#83](https://git.personal.ksv741.ru/WealthLedger/backend/issues/83)) ([31b0e15](https://git.personal.ksv741.ru/WealthLedger/backend/commit/31b0e1557978019a9293e5fd3efeee319194cddd))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* align password policy to 8-72 printable ASCII on register (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/backend/issues/60)) ([#109](https://git.personal.ksv741.ru/WealthLedger/backend/issues/109)) ([e03fad6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e03fad622bfec0fa7e42d536f65e361d319a1ac9))
|
||||||
|
* restore screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/backend/issues/70)) ([#95](https://git.personal.ksv741.ru/WealthLedger/backend/issues/95)) ([2ad3517](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2ad3517d87bffa8af9285d72fd6f78b96b3eed9f))
|
||||||
|
* separate T-Bank RPC network time from queue wait (WealthLedger/backend[#81](https://git.personal.ksv741.ru/WealthLedger/backend/issues/81)) ([#94](https://git.personal.ksv741.ru/WealthLedger/backend/issues/94)) ([0d7839c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0d7839cf79669768bba55f420ed43304545ac8d9))
|
||||||
|
* skip hourly terms overlapped by a long walk (WealthLedger/backend[#54](https://git.personal.ksv741.ru/WealthLedger/backend/issues/54)) ([6975853](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6975853656698b61781ca056f35c0d68e47ea32a))
|
||||||
|
* surface durable hourly attempt in accounts and income meta (WealthLedger/backend[#58](https://git.personal.ksv741.ru/WealthLedger/backend/issues/58)) ([14bcb9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14bcb9da5ff081710d672d140175d348a03a2fe5))
|
||||||
|
* translate permanent invalid-token errors on online entrypoints (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/backend/issues/59)) ([#110](https://git.personal.ksv741.ru/WealthLedger/backend/issues/110)) ([8a6f60d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8a6f60d9651fc7c474dd9b3690314505e18c4fa0))
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
* batch bank operation page relations (WealthLedger/backend[#68](https://git.personal.ksv741.ru/WealthLedger/backend/issues/68)) ([285dd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/285dd08472d9acb66fab8d46d511d9ce607b110f))
|
||||||
|
* start bond search prices with details (WealthLedger/backend[#71](https://git.personal.ksv741.ru/WealthLedger/backend/issues/71)) ([#89](https://git.personal.ksv741.ru/WealthLedger/backend/issues/89)) ([da2e1d0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/da2e1d0781d23f4ab1c38e7bf3cad2e254d026d5))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* extract shared public instrument projection ([#69](https://git.personal.ksv741.ru/WealthLedger/backend/issues/69)) ([#84](https://git.personal.ksv741.ru/WealthLedger/backend/issues/84)) ([9b7e5e1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b7e5e1215b7c1c5320effb6ba5b2c2a8b0cd604))
|
||||||
|
* remove unreachable catalog producer and sync orchestration (WealthLedger/backend[#62](https://git.personal.ksv741.ru/WealthLedger/backend/issues/62)) ([#98](https://git.personal.ksv741.ru/WealthLedger/backend/issues/98)) ([96d4de4](https://git.personal.ksv741.ru/WealthLedger/backend/commit/96d4de4195b105d64ac6ffaf8d3d8aab4142ece4))
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
* validate edited PR titles (WealthLedger/backend[#74](https://git.personal.ksv741.ru/WealthLedger/backend/issues/74)) ([bbb5aff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bbb5afff842720374feecfcd74eea83283e0b2ae))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* await portfolio publication done instead of polling ticks (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#103](https://git.personal.ksv741.ru/WealthLedger/backend/issues/103)) ([7023021](https://git.personal.ksv741.ru/WealthLedger/backend/commit/702302126b81335959be2e66b4b5ad38e5806b70))
|
||||||
|
* cover auth token, cookie and expiry boundaries (WealthLedger/backend[#61](https://git.personal.ksv741.ru/WealthLedger/backend/issues/61)) ([#113](https://git.personal.ksv741.ru/WealthLedger/backend/issues/113)) ([32622c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/32622c1dad9649842d8cb78d8e964da33e5df459))
|
||||||
|
* isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend[#65](https://git.personal.ksv741.ru/WealthLedger/backend/issues/65)) ([#100](https://git.personal.ksv741.ru/WealthLedger/backend/issues/100)) ([9e4f9d9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9e4f9d9cab12aa714449b99280894a521c3514b6))
|
||||||
|
* replace late portfolio sleep with started and done barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#101](https://git.personal.ksv741.ru/WealthLedger/backend/issues/101)) ([7bc7d8d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7bc7d8ddecc6cb9f387d00be68f61eb99d57d6fc))
|
||||||
|
* replace metadata sleeps with upstream start barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#102](https://git.personal.ksv741.ru/WealthLedger/backend/issues/102)) ([ec6d9a2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/ec6d9a2639f1845674bf7ce5fc357f2f7b7f5dfb))
|
||||||
|
* slim sqlite runner regression workload (WealthLedger/backend[#72](https://git.personal.ksv741.ru/WealthLedger/backend/issues/72)) ([1033d21](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1033d2114e99232a90c5d5c321749a9c6e3fbff0))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* adopt linear git model in backend docs ([#111](https://git.personal.ksv741.ru/WealthLedger/backend/issues/111)) ([4f485ab](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4f485ab0e9f0f9d52ae446e51966525473948b0e))
|
||||||
|
* align SQLite driver version (WealthLedger/backend[#67](https://git.personal.ksv741.ru/WealthLedger/backend/issues/67)) ([203cc72](https://git.personal.ksv741.ru/WealthLedger/backend/commit/203cc72cb2d1aac34f39c5ba528820b511666f17))
|
||||||
|
* drop unused direct source-map-support dependency (WealthLedger/backend[#4](https://git.personal.ksv741.ru/WealthLedger/backend/issues/4)) ([#96](https://git.personal.ksv741.ru/WealthLedger/backend/issues/96)) ([c789e33](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c789e33c41281f2fc3de8851f21ae897a81e2b5d))
|
||||||
|
* make OpenAPI export atomic and add read-only check (WealthLedger/backend[#3](https://git.personal.ksv741.ru/WealthLedger/backend/issues/3)) ([#97](https://git.personal.ksv741.ru/WealthLedger/backend/issues/97)) ([a93f2d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a93f2d55e7a31515ed1cc056cd57101f2b315405))
|
||||||
|
* unify verification preparation (WealthLedger/backend[#66](https://git.personal.ksv741.ru/WealthLedger/backend/issues/66)) ([b90cedd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b90cedd580321fb560a4ad0d9f9f5ba03303248e))
|
||||||
|
|
||||||
|
## [2.1.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.0.0...v2.1.0) (2026-10-12)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add hourly portfolio sync history ([0057925](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0057925888dde874830149865fc5e13920ac5783))
|
||||||
|
* add stock screener ([7d10ef8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d10ef8adfce76ad2ed6368e1022fbd3f6847a23))
|
||||||
|
* reconcile hourly investment resource states ([323b83b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/323b83b5827d1191fbc14cfdf1d0e62f249fc78d))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* align backend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c7d0600](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c7d060023b612af9e1dd4c2a2fb2d5d1bf173784))
|
||||||
|
* bound hourly step awaits by the per-step wait limit ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([52fa3a3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/52fa3a36baa5ab236a7ebd64ef1b4f447c91e129))
|
||||||
|
* keep candle retries read-only ([947ffef](https://git.personal.ksv741.ru/WealthLedger/backend/commit/947ffefa9b8cad7f19355c187f2bd50c9739a822))
|
||||||
|
* restore terminal hourly lifecycle after persistence failure ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([fa57a20](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa57a201fdd8127774aee9af553da75a75add07c))
|
||||||
|
* unblock backend dependency audit ([85858a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85858a160f00dd428c80b8fa1824c1779245fdfe))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* clarify backend verification policy ([d7895f2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7895f287ee802e7b13df7a2a4c6dba0b711e5a9))
|
||||||
|
* pin backend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c5248db](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5248dbefc4b11a9f417bc835041a4294bd61047))
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
* skip checks on PR metadata edits ([0ad84ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ad84ff4f0b4712abc7e69dc299856f14af6eadd))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* align all accounts metadata ([db375cb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/db375cbee7d8f1eff48822b8e3c74898f40e8886))
|
||||||
|
* align resource availability metadata ([bc631e7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bc631e72de0d9b0ff7b261b1ea644a73c2943d2a))
|
||||||
|
* align upcoming events with local reads ([966b2a5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966b2a5836cdaf5b761e3c76568ff30a90ba1f4c))
|
||||||
|
* extend ledger sqlite timeout ([4870d49](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4870d4950303ad1de743e19b665b2fbdc05b781a))
|
||||||
|
* increase ledger request timeout ([2facd86](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2facd8675c74a97d92e0e8f44ed4a93aabb47674))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* align backend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([a31e8d2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a31e8d2c4d85a21cdd5efdcc9f5fe503f210804b))
|
||||||
|
* prune obsolete eslint suppressions ([a46a411](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a46a4112cbcc72b1c3ff17cac4ac8bebcf579bca))
|
||||||
|
|
||||||
|
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* remove investment stateVersion, queued sync state,
|
||||||
|
and tbank-sync status progress/resources. Consumers must observe
|
||||||
|
resources through sync=observe with the updated frontend contract.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **deps:** resolve npm audit advisories ([15a3d5f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/15a3d5f331a0368032a747b4acd60db824127d99))
|
||||||
|
* **deps:** restore backend security gate ([30e96e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/30e96e51e2622e3168f9137769d0343777243dcf))
|
||||||
|
* harden auth and investment synchronization ([4796d9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4796d9d3f0160d96004a982f6c2526f199de4b9f))
|
||||||
|
* **investments:** align roles and date contracts ([516e432](https://git.personal.ksv741.ru/WealthLedger/backend/commit/516e43203eb3fee8e6a0c7018be2fee11f1b900c))
|
||||||
|
* resolve build imports before linting ([b244330](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b24433092bd7deeb12589ee4c1fbc7f9e6b2a8b0))
|
||||||
|
* resolve build imports before linting ([7d2262f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d2262fb439298e73d2651aabae7c6dc6e2107d5))
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
* group catalog source reads by identity ([6cd9c2d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6cd9c2dd28f7f65eebb4dbfc0b459c52b0e8fe35))
|
||||||
|
* group catalog source reads by identity ([e76eb54](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e76eb545d7d2bbd763e696c947da9e0cd34e1ecd))
|
||||||
|
* **income:** compute Moscow dates once ([a7619d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a7619d5c8fbb1a741727a0864dbb627ce2a190be))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* centralize provider projections ([7fe88a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7fe88a12c7cd3c5eb16a1912c5bd55d171a64c95))
|
||||||
|
* clarify nullable card metadata ([190eb90](https://git.personal.ksv741.ru/WealthLedger/backend/commit/190eb90ffe0b55a0e71047e9778aa77e167841a1))
|
||||||
|
* enforce enum machine codes ([33592c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/33592c1d5ff582ce57b596ae4e2324b1cea2b0f4))
|
||||||
|
* enforce enum machine codes ([2d14081](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2d14081d27935dc85da466e5d9713d2f703ca673))
|
||||||
|
* isolate operation ranges and intents ([a13cf84](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a13cf84eadb0b36bd0c364a1d6f10831a8fdc44c))
|
||||||
|
* replace ignored numeric literals with constants ([b78c215](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b78c215ff13e908621e6970cfdcdd4f483e45d2a))
|
||||||
|
* replace ignored numeric literals with constants ([9cc9fbd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9cc9fbdcff91f1a58c78ab83eea5fc0bc80106c7))
|
||||||
|
* share authentication result type ([fb2e851](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fb2e851e7e3f58c915ba1086212aa7957c3f9ed3))
|
||||||
|
* simplify investment data flow ([1be6db6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1be6db65d70f6a96629988289500098b2728b2eb))
|
||||||
|
* simplify runtime coordination [#7](https://git.personal.ksv741.ru/WealthLedger/backend/issues/7) ([d698ed9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d698ed9425a5da49846ffe4e91a396e3f2d91f72))
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
* reduce duplicate actions and serialize checks ([53b2794](https://git.personal.ksv741.ru/WealthLedger/backend/commit/53b27949631cadda10f988ff1920ac813d175ccc))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* separate performance budget from e2e gate ([01e2de6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/01e2de6a041e75da739b8288d055b1dce82d38a2))
|
||||||
|
|
||||||
|
### Styles
|
||||||
|
|
||||||
|
* align multiline ternary operators ([183b7f1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/183b7f1efdafb77f2eb5afb82160f79feb642887))
|
||||||
|
* align multiline ternary operators ([0303e60](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0303e60b64c507b03fba91f9987f024cf28c95cc))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* guard backend responsibility boundaries ([14a204d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14a204de0d32ad78193c614bef28513d1f97c593))
|
||||||
|
* strengthen backend eslint profile ([a970eaa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a970eaaf7a5549d1c51844ee6fc06c1aaf5afa56))
|
||||||
|
* strengthen backend eslint profile ([9b3558f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b3558f96c88ff625deae83c07d2087c2ceabfe9))
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24.21.0-bookworm-slim AS base
|
FROM node:24.22.1-bookworm-slim AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS dependencies
|
FROM base AS dependencies
|
||||||
@@ -19,7 +19,8 @@ EXPOSE 3000
|
|||||||
CMD ["npm", "run", "start:dev"]
|
CMD ["npm", "run", "start:dev"]
|
||||||
|
|
||||||
FROM dependencies AS build
|
FROM dependencies AS build
|
||||||
RUN npm run build && npm prune --omit=dev --omit=optional
|
# The client is already generated at the shared dependencies stage (same revision).
|
||||||
|
RUN npx --no-install nest build && npm prune --omit=dev --omit=optional
|
||||||
|
|
||||||
FROM base AS production
|
FROM base AS production
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/* \
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/* \
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ cp .env.example .env
|
|||||||
nvm use --silent
|
nvm use --silent
|
||||||
npm ci
|
npm ci
|
||||||
npx prisma migrate deploy
|
npx prisma migrate deploy
|
||||||
|
npx prisma generate
|
||||||
npm run start:dev
|
npm run start:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -60,12 +61,15 @@ Compose topology принадлежит workspace, эксплуатационн
|
|||||||
npm run verify
|
npm run verify
|
||||||
```
|
```
|
||||||
|
|
||||||
Он запускает zero-warning lint,
|
Он запускает генерацию Prisma client, zero-warning lint,
|
||||||
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
|
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
|
||||||
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
|
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
|
||||||
threshold нет. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
|
threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
|
||||||
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки
|
на новых базах и соответствие генерируемого OpenAPI настроенному приложению.
|
||||||
выполните обе команды.
|
Для полной backend-проверки выполните обе команды. Тяжёлый synthetic benchmark
|
||||||
|
с 300 000 операций и 1 000 000 свечей запускается отдельно командой
|
||||||
|
`npm run test:performance`: его wall-clock budget зависит от выделенного runner CPU
|
||||||
|
и не является функциональным merge gate.
|
||||||
|
|
||||||
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
|
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
|
||||||
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
|
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
|
||||||
@@ -74,17 +78,40 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
|
|||||||
|
|
||||||
### CI и обновления зависимостей
|
### CI и обновления зависимостей
|
||||||
|
|
||||||
`.gitea/workflows/verify.yml` запускает `npm ci`, генерацию Prisma client,
|
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
|
||||||
`npm run verify` и `npm run test:e2e` на push и PR. Используется только checkout
|
последовательно на PR; на push в `develop`/`main` — `security → verify`.
|
||||||
|
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
|
||||||
|
Изменение заголовка PR повторяет только проверку Conventional Commits; `security`
|
||||||
|
и `verify` на metadata-only событии не запускаются.
|
||||||
|
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
||||||
|
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
|
||||||
|
Job `verify` запускает `npm ci` и `npm run verify` (генерация Prisma client
|
||||||
|
внутри gate, один Nest build), затем функциональный `node test/sqlite.cjs --all`
|
||||||
|
на уже собранных артефактах. Используется только checkout
|
||||||
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
|
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
|
||||||
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
||||||
|
|
||||||
`security.yml` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
|
Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
|
||||||
значениями и выполняет `npm audit --include=dev --audit-level=low` при push,
|
значениями, блокирует любой runtime advisory через
|
||||||
PR и ежедневно. `.gitleaksignore` перечисляет только проверенные исторические
|
`npm audit --omit=dev --audit-level=low` и high/critical findings полного дерева
|
||||||
|
через `npm audit --include=dev --audit-level=high`. Moderate advisory
|
||||||
|
`sprintf-js` остаётся только в Jest coverage tooling; исправленной версии пока
|
||||||
|
нет, а предложенный npm downgrade `ts-jest` несовместим с текущим test stack.
|
||||||
|
`.gitleaksignore` перечисляет только проверенные исторические
|
||||||
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
|
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
|
||||||
Новые коммиты этими исключениями не покрываются.
|
Новые коммиты этими исключениями не покрываются.
|
||||||
|
|
||||||
|
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
|
||||||
|
workflow в статус. При внедрении администратор должен заменить required checks
|
||||||
|
для защищённых веток на `Backend verify / commits (pull_request)`,
|
||||||
|
`Backend verify / security (pull_request)` и
|
||||||
|
`Backend verify / verify (pull_request)`, удалив старые имена и push-статусы,
|
||||||
|
если они требовались. Все три PR-проверки должны оставаться обязательными.
|
||||||
|
Публикация образов по тегам остаётся в `image.yml`.
|
||||||
|
Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте
|
||||||
|
`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные
|
||||||
|
runners. Конфигурация runner управляется вне этого репозитория.
|
||||||
|
|
||||||
Renovate группирует minor/patch updates, а major требует согласования в
|
Renovate группирует minor/patch updates, а major требует согласования в
|
||||||
Dependency Dashboard. Автоматический merge не включён. Прямые зависимости
|
Dependency Dashboard. Автоматический merge не включён. Прямые зависимости
|
||||||
фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install,
|
фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install,
|
||||||
@@ -105,6 +132,38 @@ Required checks настраиваются отдельно в Gitea: налич
|
|||||||
runtime-валидацию. В обычном коде нужны инициализация или сужение типа
|
runtime-валидацию. В обычном коде нужны инициализация или сужение типа
|
||||||
после проверки значения.
|
после проверки значения.
|
||||||
|
|
||||||
|
## ESLint
|
||||||
|
|
||||||
|
`npm run lint` проверяет весь authored TypeScript и JavaScript, включая scripts
|
||||||
|
и configs. `npm run lint:fix` (также `npm run format`) применяет безопасные
|
||||||
|
автоисправления. Форматирование принадлежит ESLint Stylistic: два пробела,
|
||||||
|
одинарные кавычки и точки с запятой; Prettier не используется.
|
||||||
|
|
||||||
|
Профиль: ESLint recommended, typescript-eslint recommendedTypeChecked,
|
||||||
|
Node, NestJS typed, Jest, import-x с TypeScript resolver, SonarJS, RegExp,
|
||||||
|
Security, Perfectionist recommended-natural и Stylistic customize.
|
||||||
|
NestJS проверяется в production TS, Jest — в specs/e2e; generated Prisma,
|
||||||
|
dist и coverage исключены. DI imports сохраняют runtime metadata.
|
||||||
|
|
||||||
|
Исключения конфигурации: private backend не публикуется в npm;
|
||||||
|
TS imports разрешает import-x вместо Node resolver; IsIn проверяет domain
|
||||||
|
unions. Автосортировка объектов, классов, модулей и декораторов отключена,
|
||||||
|
поскольку порядок может влиять на runtime. Остальные точечные исключения
|
||||||
|
объясняют false positives непосредственно в коде.
|
||||||
|
|
||||||
|
`eslint-suppressions.json` — штатный ESLint baseline только для существующих
|
||||||
|
SonarJS cognitive-complexity, no-nested-conditional и no-nested-template-literals.
|
||||||
|
Это отложенный refactor, а не исправленные нарушения. Baseline ограничен
|
||||||
|
файлами и количеством сообщений; новые файлы и превышение количества блокируют
|
||||||
|
lint. Он не защищает от усложнения уже учтённой функции при прежнем количестве
|
||||||
|
сообщений — это остаётся предметом review. При сокращении долга обновляйте
|
||||||
|
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
|
||||||
|
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
|
||||||
|
|
||||||
|
Рост baseline — изменение quality gate: он требует отдельного обоснования
|
||||||
|
и review затронутого поведения. При рефакторинге сохраняйте или сокращайте
|
||||||
|
baseline; прежнее число сообщений не доказывает прежнюю сложность функции.
|
||||||
|
|
||||||
## SonarQube
|
## SonarQube
|
||||||
|
|
||||||
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
||||||
@@ -159,7 +218,10 @@ nvm use --silent
|
|||||||
npm run openapi:generate
|
npm run openapi:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
Команда собирает backend и записывает `openapi.json` в корень репозитория.
|
Команда собирает backend и записывает `openapi.json` в корень репозитория
|
||||||
|
атомарно: через временный файл и rename, без частичной записи при сбое.
|
||||||
|
`npm run openapi:check` только сравнивает runtime schema с файлом и завершается
|
||||||
|
с ошибкой при расхождении, не перезаписывая его.
|
||||||
Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания
|
Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания
|
||||||
провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты
|
провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты
|
||||||
не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger
|
не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger
|
||||||
@@ -192,10 +254,14 @@ breaking changes относительно предыдущего выпуска.
|
|||||||
настраивается отдельно. История migrations применяется штатным `migrate deploy`.
|
настраивается отдельно. История migrations применяется штатным `migrate deploy`.
|
||||||
|
|
||||||
Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет
|
Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет
|
||||||
три последовательных и два параллельных полных e2e, проверяет уникальность DB,
|
три последовательных и два параллельных запуска двух обычных e2e suites,
|
||||||
сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup.
|
проверяет уникальность DB, сохранность вымышленного внешнего файла, отказ migration
|
||||||
|
и SIGINT/SIGTERM cleanup. Полный e2e набор остаётся в `npm run test:e2e`.
|
||||||
Логи остаются в каталоге из вывода. Для одной suite:
|
Логи остаются в каталоге из вывода. Для одной suite:
|
||||||
`node test/sqlite.cjs app.e2e-spec.ts`.
|
`node test/sqlite.cjs app.e2e-spec.ts`. Crash-регрессия в
|
||||||
|
`tbank-operations-sync.e2e-spec.ts` загружает классы из `dist/`, поэтому перед
|
||||||
|
адресным запуском этой suite выполните `npm run build`, иначе child/dist может
|
||||||
|
расходиться с parent/src.
|
||||||
|
|
||||||
| Риск | Проверка реальной DB |
|
| Риск | Проверка реальной DB |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -239,35 +305,59 @@ Durability оставляет временные DB, логи и `result.json`
|
|||||||
## Релизы
|
## Релизы
|
||||||
|
|
||||||
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
||||||
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
|
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
|
||||||
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
|
общие правила — в Engineering Workflow по ссылке из AGENTS.
|
||||||
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
|
PR сливается squash или rebase-merge; merge-коммит не используется. `main`
|
||||||
|
обновляется только fast-forward из `develop`, поэтому история линейна:
|
||||||
|
|
||||||
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
|
```text
|
||||||
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
|
<type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
|
||||||
`develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в
|
```
|
||||||
`release/hotfix-<name>` и проходит тот же процесс.
|
|
||||||
|
Инвариант: `origin/main` всегда предок `origin/develop`. Проверка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch -q origin
|
||||||
|
git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
|
||||||
|
включая `spec:`. Версии вручную не повышай.
|
||||||
|
|
||||||
|
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь
|
||||||
|
инвариант. Создай `release/X.Y.Z` от проверенного `develop`;
|
||||||
|
`nvm use --silent`, `npm ci`.
|
||||||
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
|
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
|
||||||
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
|
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
|
||||||
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
|
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
|
||||||
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
|
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
|
||||||
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
|
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
|
||||||
открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных
|
открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
|
||||||
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `main`
|
`verify`, `security`, `commits` выполни fast-forward-only merge. Если
|
||||||
изменился, согласуй историю и повтори проверки кандидата.
|
`develop` изменился, обнови release-ветку и повтори проверки кандидата.
|
||||||
4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
|
||||||
|
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
||||||
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
|
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
|
||||||
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
|
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
|
||||||
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
|
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
|
||||||
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
|
Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
|
||||||
что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после
|
Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег
|
||||||
merge создай отсутствующий тег без нового bump; до merge исправь и проверь
|
без нового bump; до merge исправь и проверь существующий кандидат без
|
||||||
существующий кандидат без повторного повышения версии.
|
повторного повышения версии.
|
||||||
|
|
||||||
|
Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают.
|
||||||
|
Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом;
|
||||||
|
пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение
|
||||||
|
требует явного решения: `release/hotfix-<name>` от `main`, PR в `main`, тег,
|
||||||
|
затем merge `main → develop` для восстановления инварианта.
|
||||||
|
|
||||||
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
|
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
|
||||||
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
|
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
|
||||||
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
|
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
|
||||||
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
|
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
|
||||||
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
|
администратором, включена блокировка устаревшей ветки, обязательны `verify`,
|
||||||
|
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
|
||||||
|
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
|
||||||
|
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
|
||||||
Workflow сам не включает эти настройки. Отдельное review approval для одного
|
Workflow сам не включает эти настройки. Отдельное review approval для одного
|
||||||
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
|
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
|
||||||
|
|||||||
+10
-2
@@ -1,4 +1,12 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ["@commitlint/config-conventional"],
|
extends: ['@commitlint/config-conventional'],
|
||||||
rules: { "type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]] },
|
defaultIgnores: false,
|
||||||
|
ignores: [
|
||||||
|
(message) => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
|
||||||
|
(message) => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']],
|
||||||
|
'references-empty': [2, 'never'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
{
|
||||||
|
"src/common/decimal.ts": {
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/integrations/tbank/instrument-metadata.repository.ts": {
|
||||||
|
"sonarjs/cognitive-complexity": {
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/integrations/tbank/instrument-metadata.service.ts": {
|
||||||
|
"sonarjs/cognitive-complexity": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/integrations/tbank/tbank.client.ts": {
|
||||||
|
"sonarjs/cognitive-complexity": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/banking/bank-cards/application/bank-operations.service.ts": {
|
||||||
|
"sonarjs/cognitive-complexity": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts": {
|
||||||
|
"sonarjs/cognitive-complexity": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/income/income-analytics.service.ts": {
|
||||||
|
"sonarjs/no-nested-template-literals": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/income/income-period.ts": {
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/instruments/application/candle.service.ts": {
|
||||||
|
"sonarjs/cognitive-complexity": {
|
||||||
|
"count": 3
|
||||||
|
},
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
"sonarjs/no-nested-template-literals": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/instruments/bonds/application/bond-income.service.ts": {
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/instruments/bonds/application/bond.spec.ts": {
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/instruments/infrastructure/candle.repository.ts": {
|
||||||
|
"sonarjs/cognitive-complexity": {
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts": {
|
||||||
|
"sonarjs/cognitive-complexity": {
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/instruments/stocks/application/stock-income.service.ts": {
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/investment-sync-plan.ts": {
|
||||||
|
"sonarjs/cognitive-complexity": {
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/operations/application/brokerage-operations.service.ts": {
|
||||||
|
"sonarjs/cognitive-complexity": {
|
||||||
|
"count": 2
|
||||||
|
},
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 9
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/operations/domain/operations-sync-plan.ts": {
|
||||||
|
"sonarjs/cognitive-complexity": {
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts": {
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts": {
|
||||||
|
"sonarjs/cognitive-complexity": {
|
||||||
|
"count": 3
|
||||||
|
},
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/portfolio/application/forecast.service.ts": {
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/tbank-sync/tbank-screen.ts": {
|
||||||
|
"sonarjs/cognitive-complexity": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test/bank-cards-durability.cjs": {
|
||||||
|
"sonarjs/no-nested-template-literals": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test/sqlite-regression.cjs": {
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test/sqlite.cjs": {
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test/tbank-ledger-local.e2e-spec.ts": {
|
||||||
|
"sonarjs/no-nested-conditional": {
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
"sonarjs/no-nested-template-literals": {
|
||||||
|
"count": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
-56
@@ -1,59 +1,94 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
import nest from '@darraghor/eslint-plugin-nestjs-typed';
|
||||||
import eslint from '@eslint/js';
|
import eslint from '@eslint/js';
|
||||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
import stylistic from '@stylistic/eslint-plugin';
|
||||||
|
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript';
|
||||||
|
import { importX } from 'eslint-plugin-import-x';
|
||||||
|
import jest from 'eslint-plugin-jest';
|
||||||
|
import node from 'eslint-plugin-n';
|
||||||
|
import perfectionist from 'eslint-plugin-perfectionist';
|
||||||
|
import regexp from 'eslint-plugin-regexp';
|
||||||
|
import security from 'eslint-plugin-security';
|
||||||
|
import sonarjs from 'eslint-plugin-sonarjs';
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
export default tseslint.config(
|
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
||||||
|
const prismaImports = [
|
||||||
|
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
|
||||||
{
|
{
|
||||||
ignores: ['eslint.config.mjs', 'dist/', 'generated/'],
|
files: ['**/*.{ts,js,cjs,mjs}'],
|
||||||
},
|
extends: [
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
node.configs['flat/recommended'],
|
||||||
eslintPluginPrettierRecommended,
|
importX.flatConfigs.recommended,
|
||||||
{
|
sonarjs.configs.recommended,
|
||||||
languageOptions: {
|
regexp.configs['flat/recommended'],
|
||||||
globals: {
|
security.configs.recommended,
|
||||||
...globals.node,
|
perfectionist.configs['recommended-natural'],
|
||||||
...globals.jest,
|
stylistic.configs.customize({ indent: 2, quotes: 'single', semi: true, jsx: false }),
|
||||||
|
],
|
||||||
|
languageOptions: { globals: globals.node },
|
||||||
|
linterOptions: { reportUnusedDisableDirectives: 'error' },
|
||||||
|
settings: {
|
||||||
|
'import-x/resolver-next': [createTypeScriptImportResolver()],
|
||||||
|
'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.22.1' },
|
||||||
},
|
},
|
||||||
sourceType: 'commonjs',
|
|
||||||
parserOptions: {
|
|
||||||
projectService: true,
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rules: {
|
rules: {
|
||||||
|
'curly': ['error', 'multi-line'],
|
||||||
|
// This private application is deployed as a whole, never published to npm.
|
||||||
|
'n/no-unpublished-import': 'off',
|
||||||
|
'n/no-unpublished-require': 'off',
|
||||||
|
// Decorator execution and object insertion order can be observable at runtime.
|
||||||
|
'perfectionist/sort-decorators': 'off',
|
||||||
|
'perfectionist/sort-objects': 'off',
|
||||||
|
'perfectionist/sort-modules': 'off',
|
||||||
|
'perfectionist/sort-classes': 'off',
|
||||||
|
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }],
|
||||||
|
'@stylistic/arrow-parens': ['error', 'always'],
|
||||||
|
'@stylistic/operator-linebreak': [
|
||||||
|
'error',
|
||||||
|
'after',
|
||||||
|
{ overrides: { '?': 'before', ':': 'before' } },
|
||||||
|
],
|
||||||
|
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.mjs'],
|
||||||
|
extends: [node.configs['flat/recommended-module']],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['scripts/generate-openapi.cjs', 'test/fixtures/tbank-sync-crash.cjs', 'test/runtime-check.cjs'],
|
||||||
|
rules: {
|
||||||
|
// These scripts run after build; lint validates their source paths before dist exists.
|
||||||
|
'n/no-missing-require': ['error', {
|
||||||
|
resolverConfig: {
|
||||||
|
alias: { '../dist': import.meta.dirname, '../../dist': import.meta.dirname },
|
||||||
|
extensionAlias: { '.js': ['.ts', '.js'] },
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
|
||||||
|
languageOptions: {
|
||||||
|
sourceType: 'module',
|
||||||
|
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// TypeScript resolves source imports; import-x checks them with the TS resolver.
|
||||||
|
'n/no-missing-import': 'off',
|
||||||
'no-magic-numbers': 'off',
|
'no-magic-numbers': 'off',
|
||||||
'@typescript-eslint/no-magic-numbers': [
|
'@typescript-eslint/no-magic-numbers': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
ignore: [
|
|
||||||
-60,
|
|
||||||
-24,
|
|
||||||
-10,
|
|
||||||
-2,
|
|
||||||
-1,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
10,
|
|
||||||
24,
|
|
||||||
60,
|
|
||||||
'-60n',
|
|
||||||
'-24n',
|
|
||||||
'-10n',
|
|
||||||
'-2n',
|
|
||||||
'-1n',
|
|
||||||
'0n',
|
|
||||||
'1n',
|
|
||||||
'2n',
|
|
||||||
'10n',
|
|
||||||
'24n',
|
|
||||||
'60n',
|
|
||||||
],
|
|
||||||
ignoreArrayIndexes: true,
|
ignoreArrayIndexes: true,
|
||||||
ignoreDefaultValues: true,
|
ignoreDefaultValues: true,
|
||||||
ignoreEnums: true,
|
ignoreEnums: true,
|
||||||
@@ -63,24 +98,117 @@ export default tseslint.config(
|
|||||||
enforceConst: true,
|
enforceConst: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||||
|
'no-restricted-syntax': ['error', {
|
||||||
|
selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
|
||||||
|
message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
ignores: tests,
|
||||||
|
extends: [nest.configs.flatRecommended],
|
||||||
|
rules: {
|
||||||
|
// Domain string unions are validated with IsIn rather than runtime TS enums.
|
||||||
|
'@darraghor/nestjs-typed/validated-non-primitive-property-needs-type-decorator': ['error', { additionalTypeDecorators: ['IsIn'] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: tests,
|
||||||
|
extends: [jest.configs['flat/recommended']],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-magic-numbers': 'off',
|
||||||
|
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/modules/**/domain/**/*.ts'],
|
||||||
|
ignores: tests,
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': ['error', {
|
||||||
|
patterns: [{
|
||||||
|
group: [
|
||||||
|
'@nestjs/*', ...prismaImports, '**/*.dto', '**/*.repository',
|
||||||
|
'**/*.client', '**/application/**', '**/infrastructure/**',
|
||||||
|
],
|
||||||
|
message: 'Domain uses pure rules and value types, not transport or persistence.',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.controller.ts'],
|
||||||
|
ignores: tests,
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': ['error', {
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [...prismaImports, '**/*.repository', '**/*.client'],
|
||||||
|
message: 'Controllers delegate to application services or use cases.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: ['**'],
|
||||||
|
importNamePattern: '(?:Client|Repository|PrismaService)$',
|
||||||
|
message: 'Use application orchestration rather than data providers in controllers.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.use-case.ts'],
|
||||||
|
ignores: tests,
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': ['error', {
|
||||||
|
patterns: [{
|
||||||
|
group: prismaImports,
|
||||||
|
message: 'Use cases obtain data through concrete repositories.',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'src/**/*.ts',
|
||||||
|
],
|
||||||
|
ignores: [
|
||||||
|
'**/*.spec.ts',
|
||||||
|
'test/**/*.ts',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
'no-restricted-syntax': [
|
'no-restricted-syntax': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
selector:
|
selector: 'Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)',
|
||||||
'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
|
message: 'Use the owning enum member for machine codes and protocol values.',
|
||||||
message:
|
|
||||||
'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'@typescript-eslint/no-floating-promises': 'warn',
|
|
||||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
|
||||||
'prettier/prettier': ['error', { endOfLine: 'auto' }],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.spec.ts', 'test/**/*.ts'],
|
selector: 'TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)',
|
||||||
rules: { '@typescript-eslint/no-magic-numbers': 'off' },
|
message: 'Use the owning enum member instead of a template machine code.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]',
|
||||||
|
message: 'Use an enum member instead of an inline code or protocol value.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value',
|
||||||
|
message: 'Use an enum member instead of constructing a code or protocol value.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]',
|
||||||
|
message: 'Use a computed enum member as the protocol mapping key.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]',
|
||||||
|
message: 'Compare codes with their enum members, not string literals.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
|
||||||
|
message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+1620
-1517
File diff suppressed because it is too large
Load Diff
+68
-45
@@ -1,24 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.2.3",
|
"version": "2.3.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "v24.21.0"
|
"node": "v24.22.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "prisma generate && nest build",
|
"build": "prisma generate && nest build",
|
||||||
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
|
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"openapi:check": "npm run build && node scripts/generate-openapi.cjs --check",
|
||||||
|
"format": "npm run lint:fix",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
||||||
"start:debug": "nest start --debug --watch --path tsconfig.dev.json",
|
"start:debug": "NODE_ENV=development nest start --debug --watch --path tsconfig.dev.json",
|
||||||
"start:prod": "node dist/src/main.js",
|
"start:prod": "node dist/src/main.js",
|
||||||
"lint": "npm run lint:check",
|
"lint": "npm run lint:check",
|
||||||
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --max-warnings=0",
|
"lint:check": "eslint . --max-warnings=0",
|
||||||
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||||
"typecheck:app": "tsc --project tsconfig.typecheck.app.json",
|
"typecheck:app": "tsc --project tsconfig.typecheck.app.json",
|
||||||
"typecheck:test": "tsc --project tsconfig.typecheck.test.json",
|
"typecheck:test": "tsc --project tsconfig.typecheck.test.json",
|
||||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
||||||
@@ -26,84 +27,107 @@
|
|||||||
"test:cov": "npm test -- --coverage",
|
"test:cov": "npm test -- --coverage",
|
||||||
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "npm run build && node test/sqlite.cjs --all",
|
"test:e2e": "npm run build && node test/sqlite.cjs --all",
|
||||||
|
"test:performance": "npm run build && node test/sqlite.cjs tbank-read-baseline.e2e-spec.ts",
|
||||||
"test:sqlite": "node test/sqlite.cjs",
|
"test:sqlite": "node test/sqlite.cjs",
|
||||||
"test:container": "node test/container-smoke.cjs",
|
"test:container": "node test/container-smoke.cjs",
|
||||||
"test:durability": "node test/bank-cards-durability.cjs",
|
"test:durability": "node test/bank-cards-durability.cjs",
|
||||||
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
|
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
|
||||||
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build",
|
"verify": "prisma generate && npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && nest build",
|
||||||
"release:preview": "release-it --dry-run --ci",
|
"release:preview": "release-it --dry-run --ci",
|
||||||
"release:prepare": "release-it --ci",
|
"release:prepare": "release-it --ci",
|
||||||
"commitlint": "commitlint"
|
"commitlint": "commitlint",
|
||||||
|
"test:lint": "node --test test/eslint-config.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.4",
|
"@grpc/grpc-js": "1.14.6",
|
||||||
"@grpc/proto-loader": "0.8.1",
|
"@grpc/proto-loader": "0.8.1",
|
||||||
"@nestjs/common": "12.0.1",
|
"@nestjs/common": "12.2.0",
|
||||||
"@nestjs/config": "12.0.0",
|
"@nestjs/config": "12.0.1",
|
||||||
"@nestjs/core": "12.0.1",
|
"@nestjs/core": "12.2.0",
|
||||||
"@nestjs/jwt": "12.0.1",
|
"@nestjs/jwt": "12.0.3",
|
||||||
"@nestjs/passport": "12.0.0",
|
"@nestjs/passport": "12.0.0",
|
||||||
"@nestjs/platform-express": "12.0.1",
|
"@nestjs/platform-express": "12.2.0",
|
||||||
"@nestjs/swagger": "12.0.1",
|
"@nestjs/swagger": "12.0.4",
|
||||||
"@prisma/adapter-better-sqlite3": "7.10.0",
|
"@prisma/adapter-better-sqlite3": "7.10.1",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.1",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
"better-sqlite3": "13.0.3",
|
"better-sqlite3": "12.11.1",
|
||||||
"cheerio": "1.2.0",
|
"cheerio": "1.2.0",
|
||||||
"class-transformer": "0.5.1",
|
"class-transformer": "0.5.1",
|
||||||
"class-validator": "0.15.1",
|
"class-validator": "0.15.1",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"lru-cache": "11.5.2",
|
"dotenv": "17.4.2",
|
||||||
"nestjs-pino": "5.1.0",
|
"lru-cache": "11.5.3",
|
||||||
|
"nestjs-pino": "5.3.1",
|
||||||
"passport": "0.7.0",
|
"passport": "0.7.0",
|
||||||
"passport-jwt": "4.0.1",
|
"passport-jwt": "4.0.1",
|
||||||
"pino-http": "11.0.0",
|
"pino-http": "11.0.0",
|
||||||
"prisma": "7.10.0",
|
"prisma": "7.10.1",
|
||||||
"reflect-metadata": "0.2.2",
|
"reflect-metadata": "0.2.2",
|
||||||
"rxjs": "7.8.2"
|
"rxjs": "7.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "21.2.2",
|
"@commitlint/cli": "21.2.4",
|
||||||
"@commitlint/config-conventional": "21.2.2",
|
"@commitlint/config-conventional": "21.2.4",
|
||||||
"@eslint/eslintrc": "3.3.7",
|
"@darraghor/eslint-plugin-nestjs-typed": "7.5.8",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@nestjs/cli": "12.0.0",
|
"@nestjs/cli": "12.0.10",
|
||||||
"@nestjs/schematics": "12.0.1",
|
"@nestjs/schematics": "12.0.10",
|
||||||
"@nestjs/testing": "12.0.1",
|
"@nestjs/testing": "12.2.0",
|
||||||
"@release-it/conventional-changelog": "12.0.0",
|
"@release-it/conventional-changelog": "12.0.2",
|
||||||
|
"@stylistic/eslint-plugin": "5.10.0",
|
||||||
"@types/bcrypt": "6.0.0",
|
"@types/bcrypt": "6.0.0",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/jest": "30.0.0",
|
"@types/jest": "30.0.0",
|
||||||
"@types/node": "24.13.4",
|
"@types/node": "24.19.2",
|
||||||
"@types/passport-jwt": "4.0.1",
|
"@types/passport-jwt": "4.0.1",
|
||||||
"@types/supertest": "7.2.1",
|
"@types/supertest": "7.2.1",
|
||||||
"conventional-changelog-conventionalcommits": "10.4.0",
|
"conventional-changelog-conventionalcommits": "10.4.1",
|
||||||
"eslint": "10.10.0",
|
"eslint": "10.13.0",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-import-resolver-typescript": "4.4.5",
|
||||||
"eslint-plugin-prettier": "5.5.6",
|
"eslint-plugin-import-x": "4.17.1",
|
||||||
"globals": "17.12.0",
|
"eslint-plugin-jest": "29.17.0",
|
||||||
"jest": "30.5.1",
|
"eslint-plugin-n": "18.5.0",
|
||||||
"prettier": "3.9.6",
|
"eslint-plugin-perfectionist": "5.13.0",
|
||||||
"release-it": "21.0.2",
|
"eslint-plugin-regexp": "3.3.1",
|
||||||
"source-map-support": "0.5.21",
|
"eslint-plugin-security": "4.2.0",
|
||||||
"supertest": "7.2.2",
|
"eslint-plugin-sonarjs": "4.2.2",
|
||||||
"ts-jest": "29.4.12",
|
"globals": "17.13.0",
|
||||||
|
"jest": "30.5.2",
|
||||||
|
"pino-pretty": "13.2.0",
|
||||||
|
"release-it": "21.1.1",
|
||||||
|
"supertest": "7.3.1",
|
||||||
|
"ts-jest": "29.4.15",
|
||||||
"ts-loader": "9.6.2",
|
"ts-loader": "9.6.2",
|
||||||
"ts-node": "10.9.2",
|
"ts-node": "10.9.2",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.70.0"
|
"typescript-eslint": "8.72.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"proxy-addr": "2.0.8",
|
||||||
|
"express": {
|
||||||
|
"body-parser": "2.4.0"
|
||||||
|
},
|
||||||
"@nestjs/platform-express": {
|
"@nestjs/platform-express": {
|
||||||
"multer": "2.3.0"
|
"multer": "2.5.0"
|
||||||
|
},
|
||||||
|
"ajv": {
|
||||||
|
"fast-uri": "3.1.9"
|
||||||
},
|
},
|
||||||
"@prisma/config@7.10.0": {
|
"@prisma/config@7.10.0": {
|
||||||
"deepmerge-ts": "8.0.2"
|
"deepmerge-ts": "8.0.2"
|
||||||
},
|
},
|
||||||
|
"release-it": {
|
||||||
|
"basic-ftp": "6.2.3",
|
||||||
|
"undici": "7.30.1"
|
||||||
|
},
|
||||||
|
"socks": {
|
||||||
|
"ip-address": "10.7.4"
|
||||||
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"mysql2": "3.24.4"
|
"mysql2": "3.24.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
@@ -130,7 +154,6 @@
|
|||||||
"better-sqlite3@12.11.1": true,
|
"better-sqlite3@12.11.1": true,
|
||||||
"fsevents@2.3.3": true,
|
"fsevents@2.3.3": true,
|
||||||
"protobufjs@7.6.5": true,
|
"protobufjs@7.6.5": true,
|
||||||
"unrs-resolver@1.12.2": true,
|
"unrs-resolver@1.12.2": true
|
||||||
"better-sqlite3@13.0.3": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankPortfolioValueHistory" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"deletedAt" DATETIME,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "accountId"),
|
||||||
|
CONSTRAINT "TbankPortfolioValueHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankPortfolioValuePoint" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"attemptId" TEXT NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"capturedAt" DATETIME NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "accountId", "attemptId", "currency"),
|
||||||
|
CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt");
|
||||||
@@ -17,6 +17,7 @@ model User {
|
|||||||
bankCardOperations BankCardOperation[]
|
bankCardOperations BankCardOperation[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
tbankConnection TbankConnection?
|
tbankConnection TbankConnection?
|
||||||
|
tbankValueHistory TbankPortfolioValueHistory[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -69,6 +70,34 @@ model TbankPortfolio {
|
|||||||
@@id([userId, revision, accountId])
|
@@id([userId, revision, accountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Account valuation history survives connection replacement and stays hidden
|
||||||
|
// until the current connection confirms the account again.
|
||||||
|
model TbankPortfolioValueHistory {
|
||||||
|
userId String
|
||||||
|
accountId String
|
||||||
|
deletedAt DateTime?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
points TbankPortfolioValuePoint[]
|
||||||
|
|
||||||
|
@@id([userId, accountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row per currency of a fully valued successful portfolio attempt.
|
||||||
|
model TbankPortfolioValuePoint {
|
||||||
|
userId String
|
||||||
|
accountId String
|
||||||
|
attemptId String
|
||||||
|
currency String
|
||||||
|
value String
|
||||||
|
capturedAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
|
||||||
|
@@id([userId, accountId, attemptId, currency])
|
||||||
|
@@index([userId, accountId, capturedAt])
|
||||||
|
}
|
||||||
|
|
||||||
model TbankOperation {
|
model TbankOperation {
|
||||||
userId String
|
userId String
|
||||||
revision String
|
revision String
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
const assert = require("node:assert/strict");
|
const assert = require('node:assert/strict');
|
||||||
const { execFileSync } = require("node:child_process");
|
const { execFileSync } = require('node:child_process');
|
||||||
const { version } = require("../package.json");
|
|
||||||
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
|
|
||||||
|
|
||||||
assert.equal(git("status", "--porcelain"), "", "Commit all changes before preparing a release");
|
const { version } = require('../package.json');
|
||||||
const tags = git("tag", "--merged", "HEAD", "--list", "v[0-9]*", "--sort=-version:refname")
|
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
|
||||||
.split("\n").filter(tag => /^v\d+\.\d+\.\d+$/.test(tag));
|
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
|
||||||
assert.equal(version, tags[0]?.slice(1) ?? "0.0.0",
|
|
||||||
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.");
|
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
|
||||||
|
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||||
|
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
|
||||||
|
'Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.');
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
const { randomBytes } = require('node:crypto');
|
const { randomBytes } = require('node:crypto');
|
||||||
const { writeFile } = require('node:fs/promises');
|
const { readFile, rename, rm, writeFile } = require('node:fs/promises');
|
||||||
|
const { resolve } = require('node:path');
|
||||||
|
|
||||||
|
// Relative to the working directory, as before: the contract test runs the script in a temporary directory.
|
||||||
|
const target = resolve(process.cwd(), 'openapi.json');
|
||||||
|
const checkOnly = process.argv.includes('--check');
|
||||||
|
|
||||||
// Schema generation needs no deployment configuration or real credentials.
|
// Schema generation needs no deployment configuration or real credentials.
|
||||||
Object.assign(process.env, {
|
Object.assign(process.env, {
|
||||||
@@ -15,26 +20,52 @@ Object.assign(process.env, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { NestFactory } = require('@nestjs/core');
|
const { NestFactory } = require('@nestjs/core');
|
||||||
|
|
||||||
const { AppModule } = require('../dist/src/app.module.js');
|
const { AppModule } = require('../dist/src/app.module.js');
|
||||||
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
|
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
|
||||||
|
|
||||||
async function generate() {
|
async function render() {
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
preview: true,
|
preview: true,
|
||||||
logger: false,
|
logger: false,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
app.setGlobalPrefix(API_PREFIX);
|
app.setGlobalPrefix(API_PREFIX);
|
||||||
await writeFile(
|
return JSON.stringify(createOpenApiDocument(app), null, 2) + '\n';
|
||||||
'openapi.json',
|
|
||||||
JSON.stringify(createOpenApiDocument(app), null, 2) + '\n',
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
generate().catch((error) => {
|
// Read-only: compares the runtime document with the committed artifact and never writes.
|
||||||
|
async function check(content) {
|
||||||
|
const current = await readFile(target, 'utf8').catch(() => null);
|
||||||
|
if (current !== content) {
|
||||||
|
console.error(
|
||||||
|
'openapi.json is out of date with the runtime schema. Run `npm run openapi:generate`.',
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('openapi.json matches the runtime schema.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic update: write a sibling temp file, then rename over the target, so a failed run never leaves partial output.
|
||||||
|
async function update(content) {
|
||||||
|
const temp = `${target}.${process.pid}.tmp`;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input.
|
||||||
|
await writeFile(temp, content);
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input.
|
||||||
|
await rename(temp, target);
|
||||||
|
} finally {
|
||||||
|
await rm(temp, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render()
|
||||||
|
.then((content) => (checkOnly ? check(content) : update(content)))
|
||||||
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-37
@@ -1,10 +1,8 @@
|
|||||||
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { LoggingModule } from './common/logging/logging.module';
|
import { LoggingModule } from './common/logging/logging.module';
|
||||||
import { Environment, validateEnv } from './config/env.validation';
|
import { validateEnv } from './config/env.validation';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
|
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
|
||||||
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
|
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
|
||||||
@@ -13,6 +11,7 @@ import { InstrumentsModule } from './modules/investments/instruments/instruments
|
|||||||
import { OperationsModule } from './modules/investments/operations/operations.module';
|
import { OperationsModule } from './modules/investments/operations/operations.module';
|
||||||
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
|
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
|
||||||
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
|
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
|
||||||
|
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
|
||||||
import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module';
|
import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
|
||||||
@@ -22,38 +21,6 @@ import { PrismaModule } from './prisma/prisma.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
validate: validateEnv,
|
validate: validateEnv,
|
||||||
}),
|
}),
|
||||||
LoggerModule.forRootAsync({
|
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (config: ConfigService<Environment, true>) => ({
|
|
||||||
pinoHttp: {
|
|
||||||
level: config.getOrThrow('LOG_LEVEL'),
|
|
||||||
messageKey: 'message',
|
|
||||||
autoLogging: false,
|
|
||||||
genReqId: (request: unknown, response) => {
|
|
||||||
const existingId = (request as { id?: unknown }).id;
|
|
||||||
const requestId =
|
|
||||||
typeof existingId === 'string' ? existingId : randomUUID();
|
|
||||||
response.setHeader('X-Request-ID', requestId);
|
|
||||||
return requestId;
|
|
||||||
},
|
|
||||||
redact: [
|
|
||||||
'req.headers.authorization',
|
|
||||||
'req.headers.cookie',
|
|
||||||
'req.body',
|
|
||||||
],
|
|
||||||
serializers: {
|
|
||||||
req: (request: unknown) => {
|
|
||||||
const value = request as { id?: unknown; method?: unknown };
|
|
||||||
return {
|
|
||||||
id: typeof value.id === 'string' ? value.id : undefined,
|
|
||||||
method:
|
|
||||||
typeof value.method === 'string' ? value.method : undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
LoggingModule,
|
LoggingModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
|||||||
+45
-23
@@ -1,12 +1,30 @@
|
|||||||
export type Decimal = { value: bigint; scale: number };
|
export const DECIMAL_RADIX = 10n;
|
||||||
|
|
||||||
|
const SIGN_LENGTH = 1;
|
||||||
|
const NEGATIVE_SIGN = -1n;
|
||||||
|
const POSITIVE_SIGN = 1n;
|
||||||
|
const ZERO_UNITS = 0n;
|
||||||
|
const EQUAL = 0;
|
||||||
|
const GREATER_THAN = 1;
|
||||||
|
const LESS_THAN = -1;
|
||||||
|
const INTEGER_SCALE = 0;
|
||||||
|
const MIN_WHOLE_DIGITS = 1;
|
||||||
|
const DIGITS_START = 0;
|
||||||
|
const PREVIOUS_DIGIT_OFFSET = 1;
|
||||||
|
const DIGIT_STEP = 1;
|
||||||
|
const ROUNDING_DIVISOR = 2n;
|
||||||
|
|
||||||
|
export type Decimal = { scale: number; value: bigint };
|
||||||
|
|
||||||
export function decimalFromString(value: string): Decimal {
|
export function decimalFromString(value: string): Decimal {
|
||||||
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
|
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
|
||||||
|
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) {
|
||||||
throw new Error('Invalid decimal string');
|
throw new Error('Invalid decimal string');
|
||||||
|
}
|
||||||
const negative = value.startsWith('-');
|
const negative = value.startsWith('-');
|
||||||
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
|
const [whole, fraction = ''] = (negative ? value.slice(SIGN_LENGTH) : value).split('.');
|
||||||
return {
|
return {
|
||||||
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
|
value: (negative ? NEGATIVE_SIGN : POSITIVE_SIGN) * BigInt(`${whole}${fraction}`),
|
||||||
scale: fraction.length,
|
scale: fraction.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -15,8 +33,8 @@ export function add(left: Decimal, right: Decimal): Decimal {
|
|||||||
const scale = Math.max(left.scale, right.scale);
|
const scale = Math.max(left.scale, right.scale);
|
||||||
return {
|
return {
|
||||||
value:
|
value:
|
||||||
left.value * 10n ** BigInt(scale - left.scale) +
|
left.value * DECIMAL_RADIX ** BigInt(scale - left.scale) +
|
||||||
right.value * 10n ** BigInt(scale - right.scale),
|
right.value * DECIMAL_RADIX ** BigInt(scale - right.scale),
|
||||||
scale,
|
scale,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -30,33 +48,37 @@ export function multiply(left: Decimal, right: Decimal): Decimal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isZero(value: Decimal): boolean {
|
export function isZero(value: Decimal): boolean {
|
||||||
return value.value === 0n;
|
return value.value === ZERO_UNITS;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compare(left: Decimal, right: Decimal): number {
|
export function compare(left: Decimal, right: Decimal): number {
|
||||||
const difference = subtract(left, right).value;
|
const difference = subtract(left, right).value;
|
||||||
return difference === 0n ? 0 : difference > 0n ? 1 : -1;
|
return difference === ZERO_UNITS ? EQUAL : difference > ZERO_UNITS ? GREATER_THAN : LESS_THAN;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDecimal({ value, scale }: Decimal): string {
|
export function formatDecimal({ value, scale }: Decimal): string {
|
||||||
if (value === 0n) return '0';
|
if (value === ZERO_UNITS) return '0';
|
||||||
const sign = value < 0n ? '-' : '';
|
const sign = value < ZERO_UNITS ? '-' : '';
|
||||||
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
|
if (scale === INTEGER_SCALE) return `${sign}${value < ZERO_UNITS ? -value : value}`;
|
||||||
const digits = (value < 0n ? -value : value)
|
const digits = (value < ZERO_UNITS ? -value : value)
|
||||||
.toString()
|
.toString()
|
||||||
.padStart(scale + 1, '0');
|
.padStart(scale + MIN_WHOLE_DIGITS, '0');
|
||||||
const whole = digits.slice(0, -scale) || '0';
|
const whole = digits.slice(DIGITS_START, -scale) || '0';
|
||||||
const fraction = digits.slice(-scale).replace(/0+$/, '');
|
const rawFraction = digits.slice(-scale);
|
||||||
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
|
let fractionEnd = rawFraction.length;
|
||||||
|
while (rawFraction.charAt(fractionEnd - PREVIOUS_DIGIT_OFFSET) === '0') fractionEnd -= DIGIT_STEP;
|
||||||
|
const fraction = rawFraction.slice(DIGITS_START, fractionEnd);
|
||||||
|
const fractionalPart = fraction ? '.' + fraction : '';
|
||||||
|
return `${sign}${whole}${fractionalPart}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round half away from zero; callers choose presentation precision explicitly.
|
// Round half away from zero; callers choose presentation precision explicitly.
|
||||||
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
|
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
|
||||||
if (right.value === 0n) throw new Error('Division by zero');
|
if (right.value === ZERO_UNITS) throw new Error('Division by zero');
|
||||||
const numerator = left.value * 10n ** BigInt(right.scale + scale);
|
const numerator = left.value * DECIMAL_RADIX ** BigInt(right.scale + scale);
|
||||||
const denominator = right.value * 10n ** BigInt(left.scale);
|
const denominator = right.value * DECIMAL_RADIX ** BigInt(left.scale);
|
||||||
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n;
|
const sign = (numerator < ZERO_UNITS) !== (denominator < ZERO_UNITS) ? NEGATIVE_SIGN : POSITIVE_SIGN;
|
||||||
const n = numerator < 0n ? -numerator : numerator;
|
const n = numerator < ZERO_UNITS ? -numerator : numerator;
|
||||||
const d = denominator < 0n ? -denominator : denominator;
|
const d = denominator < ZERO_UNITS ? -denominator : denominator;
|
||||||
return { value: sign * ((n + d / 2n) / d), scale };
|
return { value: sign * ((n + d / ROUNDING_DIVISOR) / d), scale };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { HttpStatus, applyDecorators } from '@nestjs/common';
|
import { applyDecorators, HttpStatus } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiExtraModels,
|
ApiExtraModels,
|
||||||
ApiProperty,
|
ApiProperty,
|
||||||
|
ApiPropertyOptional,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
getSchemaPath,
|
getSchemaPath,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { HttpErrorCode } from '../error-code';
|
||||||
|
|
||||||
export class ProblemDetailsDto {
|
export class ProblemDetailsDto {
|
||||||
@ApiProperty({ example: 401 })
|
@ApiProperty({ example: 401 })
|
||||||
declare status: number;
|
declare status: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 'UNAUTHORIZED' })
|
@ApiProperty({ example: HttpErrorCode.Unauthorized })
|
||||||
declare code: string;
|
declare code: string;
|
||||||
|
|
||||||
@ApiProperty({
|
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
|
||||||
required: false,
|
@ApiPropertyOptional({
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -23,7 +26,7 @@ export class ProblemDetailsDto {
|
|||||||
},
|
},
|
||||||
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?: { detail: string; pointer: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function problemResponse(status: number, description: string) {
|
function problemResponse(status: number, description: string) {
|
||||||
@@ -38,7 +41,7 @@ function problemResponse(status: number, description: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ApiProblemResponses(): MethodDecorator & ClassDecorator {
|
export function ApiProblemResponses(): ClassDecorator & MethodDecorator {
|
||||||
return applyDecorators(
|
return applyDecorators(
|
||||||
ApiExtraModels(ProblemDetailsDto),
|
ApiExtraModels(ProblemDetailsDto),
|
||||||
problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
|
problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||||
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
|
|
||||||
|
import { LoginDto, RegisterDto } from '../modules/auth/presentation/auth.dto';
|
||||||
import {
|
import {
|
||||||
CreateBankCardDto,
|
CreateBankCardDto,
|
||||||
UpdateBankCardDto,
|
UpdateBankCardDto,
|
||||||
} from '../modules/banking/bank-cards/presentation/bank-card.dto';
|
} from '../modules/banking/bank-cards/presentation/bank-card.dto';
|
||||||
|
|
||||||
describe('DTO validation', () => {
|
describe('DTO validation', () => {
|
||||||
const pipe = new ValidationPipe({ transform: true, whitelist: true });
|
const pipe = new ValidationPipe({
|
||||||
|
forbidUnknownValues: true, transform: true, whitelist: true });
|
||||||
|
|
||||||
it('requires credentials and normalizes a valid email', async () => {
|
it('requires credentials and normalizes a valid email', async () => {
|
||||||
const metadata = { type: 'body' as const, metatype: CredentialsDto };
|
const metadata = { type: 'body' as const, metatype: RegisterDto };
|
||||||
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
|
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
@@ -27,6 +29,59 @@ describe('DTO validation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Test intent: register accepts 8..72 printable ASCII characters only (bcrypt
|
||||||
|
// reads 72 bytes, so non-ASCII could be silently truncated); login only caps
|
||||||
|
// bytes so accounts created under the older policy can still sign in.
|
||||||
|
describe('password policy', () => {
|
||||||
|
const register = { type: 'body' as const, metatype: RegisterDto };
|
||||||
|
const login = { type: 'body' as const, metatype: LoginDto };
|
||||||
|
const body = (password: string) => ({ email: 'user@example.com', password });
|
||||||
|
const accepts = (value: unknown, metadata: typeof login | typeof register) =>
|
||||||
|
pipe.transform(value, metadata).then(
|
||||||
|
() => true,
|
||||||
|
(error: unknown) => {
|
||||||
|
if (!(error instanceof BadRequestException)) throw error;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['7 chars', 'a'.repeat(7), false],
|
||||||
|
['8 chars', 'a'.repeat(8), true],
|
||||||
|
['72 chars', 'a'.repeat(72), true],
|
||||||
|
['73 chars', 'a'.repeat(73), false],
|
||||||
|
['all printable symbols and space', 'Aa1 !~"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}', true],
|
||||||
|
['Cyrillic, 8+ chars', 'пароль-пароль', false],
|
||||||
|
['emoji', 'passw😀rd', false],
|
||||||
|
['tab', 'pass\tword1', false],
|
||||||
|
['newline', 'password1\n', false],
|
||||||
|
['DEL', 'password\x7f1', false],
|
||||||
|
['non-string', 12345678, false],
|
||||||
|
])('register: %s', async (_, password, accepted) => {
|
||||||
|
await expect(accepts(body(password as string), register)).resolves.toBe(accepted);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('register keeps raw whitespace untouched', async () => {
|
||||||
|
const password = ' spaced pass ';
|
||||||
|
await expect(pipe.transform(body(password), register)).resolves.toEqual(
|
||||||
|
body(password),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['legacy Cyrillic password', 'пароль-пароль', true],
|
||||||
|
['short legacy password', 'short', true],
|
||||||
|
['72 bytes', 'a'.repeat(72), true],
|
||||||
|
['73 bytes', 'a'.repeat(73), false],
|
||||||
|
['36 Cyrillic letters = 72 bytes', 'я'.repeat(36), true],
|
||||||
|
['37 Cyrillic letters = 74 bytes', 'я'.repeat(37), false],
|
||||||
|
['empty', '', false],
|
||||||
|
['non-string', 12345678, false],
|
||||||
|
])('login: %s', async (_, password, accepted) => {
|
||||||
|
await expect(accepts(body(password as string), login)).resolves.toBe(accepted);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('requires and validates nested money in a new card', async () => {
|
it('requires and validates nested money in a new card', async () => {
|
||||||
const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
|
const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
|
||||||
const card = {
|
const card = {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export enum HttpErrorCode {
|
||||||
|
BadRequest = 'BAD_REQUEST',
|
||||||
|
InternalServerError = 'INTERNAL_SERVER_ERROR',
|
||||||
|
NotFound = 'NOT_FOUND',
|
||||||
|
Unauthorized = 'UNAUTHORIZED',
|
||||||
|
UnsupportedMediaType = 'UNSUPPORTED_MEDIA_TYPE',
|
||||||
|
ValidationError = 'VALIDATION_ERROR',
|
||||||
|
}
|
||||||
@@ -1,23 +1,29 @@
|
|||||||
import { ArgumentsHost } from '@nestjs/common';
|
import { ArgumentsHost, BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { StructuredLogger } from '../logging/structured-logger.service';
|
||||||
import { HttpExceptionFilter } from './http-exception.filter';
|
import { HttpExceptionFilter } from './http-exception.filter';
|
||||||
|
|
||||||
describe('HttpExceptionFilter', () => {
|
describe('HttpExceptionFilter', () => {
|
||||||
it('does not expose internal error details', () => {
|
const setup = () => {
|
||||||
const status = jest.fn().mockReturnThis();
|
const status = jest.fn().mockReturnThis();
|
||||||
const type = jest.fn().mockReturnThis();
|
const type = jest.fn().mockReturnThis();
|
||||||
const json = jest.fn();
|
const json = jest.fn();
|
||||||
const locals: Record<string, unknown> = {};
|
const locals: Record<string, unknown> = {};
|
||||||
const filter = new HttpExceptionFilter();
|
const logger = { event: jest.fn() };
|
||||||
|
const filter = new HttpExceptionFilter(logger as unknown as StructuredLogger);
|
||||||
const host = {
|
const host = {
|
||||||
switchToHttp: () => ({
|
switchToHttp: () => ({
|
||||||
getResponse: () => ({ status, type, json, locals }),
|
getResponse: () => ({ status, type, json, locals }),
|
||||||
}),
|
}),
|
||||||
} as unknown as ArgumentsHost;
|
} as unknown as ArgumentsHost;
|
||||||
|
return { filter, host, status, type, json, locals, logger };
|
||||||
|
};
|
||||||
|
|
||||||
filter.catch(
|
it('logs unhandled errors with name and stack frames but never the message', () => {
|
||||||
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
|
const { filter, host, status, type, json, locals, logger } = setup();
|
||||||
host,
|
const error = new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET');
|
||||||
);
|
|
||||||
|
filter.catch(error, host);
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(500);
|
expect(status).toHaveBeenCalledWith(500);
|
||||||
expect(type).toHaveBeenCalledWith('application/problem+json');
|
expect(type).toHaveBeenCalledWith('application/problem+json');
|
||||||
@@ -26,6 +32,29 @@ 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(logger.event).toHaveBeenCalledTimes(1);
|
||||||
|
const [level, event, fields] = logger.event.mock.calls[0] as [string, string, Record<string, unknown>];
|
||||||
|
expect(level).toBe('error');
|
||||||
|
expect(event).toBe('http.request.unhandled');
|
||||||
|
expect(fields).toMatchObject({ code: 'INTERNAL_SERVER_ERROR', errorName: 'Error' });
|
||||||
|
expect(JSON.stringify(fields)).not.toContain('SYNTHETIC_AUDIT_SECRET');
|
||||||
|
expect(String(fields['errorStack']).split('\n').every((line) => line.startsWith(' at '))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs field pointers without values for validation errors', () => {
|
||||||
|
const { filter, host, status, logger, locals } = setup();
|
||||||
|
const exception = new BadRequestException({
|
||||||
|
message: [
|
||||||
|
{ property: 'amount', constraints: { isPositive: 'amount must be positive' }, value: -42 },
|
||||||
|
{ property: 'amount', constraints: { min: 'amount too small' }, value: -42 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
filter.catch(exception, host);
|
||||||
|
|
||||||
|
expect(status).toHaveBeenCalledWith(400);
|
||||||
|
expect(locals.invalidFields).toBe('#/amount');
|
||||||
|
expect(logger.event).not.toHaveBeenCalled();
|
||||||
|
expect(JSON.stringify(locals)).not.toContain('-42');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArgumentsHost,
|
ArgumentsHost,
|
||||||
Catch,
|
Catch,
|
||||||
@@ -6,10 +8,14 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Response } from 'express';
|
|
||||||
|
import { HttpErrorCode } from '../error-code';
|
||||||
|
import { StructuredLogger } from '../logging/structured-logger.service';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
constructor(private readonly logger: StructuredLogger) {}
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
const context = host.switchToHttp();
|
const context = host.switchToHttp();
|
||||||
const response = context.getResponse<Response>();
|
const response = context.getResponse<Response>();
|
||||||
@@ -24,9 +30,22 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
(payload as { message?: unknown })?.message,
|
(payload as { message?: unknown })?.message,
|
||||||
);
|
);
|
||||||
const code = isValidation
|
const code = isValidation
|
||||||
? 'VALIDATION_ERROR'
|
? HttpErrorCode.ValidationError
|
||||||
: this.codeFor(status, payload);
|
: this.codeFor(status, payload);
|
||||||
(response.locals ??= {}).publicErrorCode = code;
|
response.locals ??= {};
|
||||||
|
response.locals.publicErrorCode = code;
|
||||||
|
if (isValidation) {
|
||||||
|
response.locals.invalidFields = [
|
||||||
|
...new Set(
|
||||||
|
this.validationErrors(
|
||||||
|
(payload as { message: ValidationError[] }).message,
|
||||||
|
).map((error) => error.pointer),
|
||||||
|
),
|
||||||
|
].join(',');
|
||||||
|
}
|
||||||
|
if (!(exception instanceof HttpException)) {
|
||||||
|
this.logUnhandled(exception, code);
|
||||||
|
}
|
||||||
response
|
response
|
||||||
.status(status)
|
.status(status)
|
||||||
.type('application/problem+json')
|
.type('application/problem+json')
|
||||||
@@ -43,26 +62,41 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: stack содержит текст message в первой строке; оставляем только кадры, message не пишем.
|
||||||
|
private logUnhandled(exception: unknown, code: string): void {
|
||||||
|
const error = exception instanceof Error ? exception : undefined;
|
||||||
|
const frames = (error?.stack ?? '')
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.startsWith(' at '))
|
||||||
|
.join('\n');
|
||||||
|
this.logger.event('error', 'http.request.unhandled', {
|
||||||
|
code,
|
||||||
|
errorName: error?.name ?? typeof exception,
|
||||||
|
errorStack: frames,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private codeFor(status: number, payload: unknown): string {
|
private codeFor(status: number, payload: unknown): string {
|
||||||
if (typeof payload === 'object' && payload !== null && 'code' in payload) {
|
if (typeof payload === 'object' && payload !== null && 'code' in payload) {
|
||||||
const code = (payload as { code?: unknown }).code;
|
const code = (payload as { code?: unknown }).code;
|
||||||
if (typeof code === 'string') return code;
|
if (typeof code === 'string') return code;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request.
|
||||||
{
|
{
|
||||||
400: 'BAD_REQUEST',
|
400: HttpErrorCode.BadRequest,
|
||||||
401: 'UNAUTHORIZED',
|
401: HttpErrorCode.Unauthorized,
|
||||||
404: 'NOT_FOUND',
|
404: HttpErrorCode.NotFound,
|
||||||
415: 'UNSUPPORTED_MEDIA_TYPE',
|
415: HttpErrorCode.UnsupportedMediaType,
|
||||||
500: 'INTERNAL_SERVER_ERROR',
|
500: HttpErrorCode.InternalServerError,
|
||||||
}[status] ?? 'INTERNAL_SERVER_ERROR'
|
}[status] ?? HttpErrorCode.InternalServerError
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private validationErrors(
|
private validationErrors(
|
||||||
errors: ValidationError[],
|
errors: ValidationError[],
|
||||||
prefix = '#',
|
prefix = '#',
|
||||||
): { pointer: string; detail: string }[] {
|
): { detail: string; pointer: string }[] {
|
||||||
return errors.flatMap((error) => {
|
return errors.flatMap((error) => {
|
||||||
if (!error || typeof error.property !== 'string') return [];
|
if (!error || typeof error.property !== 'string') return [];
|
||||||
const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`;
|
const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`;
|
||||||
|
|||||||
@@ -1,15 +1,71 @@
|
|||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { Options } from 'pino-http';
|
||||||
|
|
||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { RequestContextService } from './request-context.service';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { constants as httpStatus } from 'node:http2';
|
||||||
|
|
||||||
|
import { Environment } from '../../config/env.validation';
|
||||||
import { StructuredLogger } from './structured-logger.service';
|
import { StructuredLogger } from './structured-logger.service';
|
||||||
|
|
||||||
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function completion(request: Request, response: Response, value: { responseTime: number }) {
|
||||||
|
const route: unknown = request.route;
|
||||||
|
const path = typeof route === 'object' && route !== null && 'path' in route &&
|
||||||
|
typeof route.path === 'string'
|
||||||
|
? `${request.baseUrl}${route.path}`
|
||||||
|
: '<unmatched>';
|
||||||
|
return {
|
||||||
|
method: request.method,
|
||||||
|
route: path,
|
||||||
|
status: response.statusCode,
|
||||||
|
durationMs: Math.round(value.responseTime),
|
||||||
|
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
|
||||||
|
...(typeof response.locals.invalidFields === 'string' ? { invalidFields: response.locals.invalidFields } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loggingOptions(config: ConfigService<Environment, true>): Options<Request, Response> {
|
||||||
|
return {
|
||||||
|
level: config.getOrThrow('LOG_LEVEL'),
|
||||||
|
messageKey: 'message',
|
||||||
|
// ponytail: development-only pretty output; production never loads pino-pretty.
|
||||||
|
...(process.env['NODE_ENV'] === 'development'
|
||||||
|
? { transport: { target: 'pino-pretty', options: { colorize: true, singleLine: true, messageKey: 'message' } } }
|
||||||
|
: {}),
|
||||||
|
genReqId: (_request, response) => {
|
||||||
|
const id = randomUUID();
|
||||||
|
response.setHeader('X-Request-ID', id);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
customProps: (request) => {
|
||||||
|
const incoming = request.header('x-request-id');
|
||||||
|
return { requestId: request.id, ...(incoming && uuid.test(incoming) ? { clientRequestId: incoming } : {}) };
|
||||||
|
},
|
||||||
|
customLogLevel: (_request, response) => {
|
||||||
|
if (response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR) return 'error';
|
||||||
|
if ([httpStatus.HTTP_STATUS_FORBIDDEN, httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS, httpStatus.HTTP_STATUS_UNAUTHORIZED].includes(response.statusCode)) return 'warn';
|
||||||
|
return 'info';
|
||||||
|
},
|
||||||
|
customSuccessMessage: () => 'http.request.completed',
|
||||||
|
customErrorMessage: () => 'http.request.completed',
|
||||||
|
customSuccessObject: completion,
|
||||||
|
customErrorObject: (request, response, _error, value: { responseTime: number }) => completion(request, response, value),
|
||||||
|
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.body'],
|
||||||
|
serializers: { req: () => undefined },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
imports: [LoggerModule.forRootAsync<Request, Response>({
|
||||||
RequestContextService,
|
inject: [ConfigService],
|
||||||
RequestLoggingMiddleware,
|
useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
|
||||||
StructuredLogger,
|
})],
|
||||||
],
|
providers: [StructuredLogger],
|
||||||
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger],
|
exports: [StructuredLogger],
|
||||||
})
|
})
|
||||||
export class LoggingModule {}
|
export class LoggingModule {}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
||||||
|
|
||||||
export type RequestLogContext = {
|
|
||||||
requestId: string;
|
|
||||||
clientRequestId?: string;
|
|
||||||
userId?: string;
|
|
||||||
brokerageAccountId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RequestContextService {
|
|
||||||
private readonly storage = new AsyncLocalStorage<RequestLogContext>();
|
|
||||||
|
|
||||||
run<T>(context: RequestLogContext, callback: () => T): T {
|
|
||||||
return this.storage.run(context, callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
get(): RequestLogContext | undefined {
|
|
||||||
return this.storage.getStore();
|
|
||||||
}
|
|
||||||
|
|
||||||
set(context: Partial<RequestLogContext>): void {
|
|
||||||
const current = this.storage.getStore();
|
|
||||||
if (current) Object.assign(current, context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
|
||||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
|
||||||
|
|
||||||
describe('RequestLoggingMiddleware', () => {
|
|
||||||
it('returns a server ID and emits one safe completion event', () => {
|
|
||||||
const requestContext = {
|
|
||||||
run: jest.fn((_: unknown, next: () => void) => next()),
|
|
||||||
};
|
|
||||||
const logger = { event: jest.fn() };
|
|
||||||
const middleware = new RequestLoggingMiddleware(
|
|
||||||
requestContext as never,
|
|
||||||
logger as never,
|
|
||||||
);
|
|
||||||
const response = Object.assign(new EventEmitter(), {
|
|
||||||
statusCode: 200,
|
|
||||||
locals: {},
|
|
||||||
setHeader: jest.fn(),
|
|
||||||
});
|
|
||||||
const next = jest.fn();
|
|
||||||
|
|
||||||
const request: {
|
|
||||||
method: string;
|
|
||||||
baseUrl: string;
|
|
||||||
route: { path: string };
|
|
||||||
header: jest.Mock<string>;
|
|
||||||
id?: string;
|
|
||||||
} = {
|
|
||||||
method: 'GET',
|
|
||||||
baseUrl: '/api/v1',
|
|
||||||
route: { path: '/accounts/:id' },
|
|
||||||
header: jest.fn(() => 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43'),
|
|
||||||
};
|
|
||||||
middleware.use(request as never, response as never, next);
|
|
||||||
response.emit('finish');
|
|
||||||
|
|
||||||
expect(response.setHeader).toHaveBeenCalledWith(
|
|
||||||
'X-Request-ID',
|
|
||||||
expect.stringMatching(/^[0-9a-f-]{36}$/i),
|
|
||||||
);
|
|
||||||
expect(request.id).toMatch(/^[0-9a-f-]{36}$/i);
|
|
||||||
expect(requestContext.run).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
clientRequestId: 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43',
|
|
||||||
}),
|
|
||||||
next,
|
|
||||||
);
|
|
||||||
expect(logger.event).toHaveBeenCalledWith(
|
|
||||||
'info',
|
|
||||||
'http.request.completed',
|
|
||||||
expect.objectContaining({ route: '/api/v1/accounts/:id', status: 200 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { constants as httpStatus } from 'node:http2';
|
|
||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { RequestContextService } from './request-context.service';
|
|
||||||
import { StructuredLogger } from './structured-logger.service';
|
|
||||||
|
|
||||||
const uuid =
|
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RequestLoggingMiddleware implements NestMiddleware {
|
|
||||||
constructor(
|
|
||||||
private readonly requestContext: RequestContextService,
|
|
||||||
private readonly logger: StructuredLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
use(request: Request, response: Response, next: NextFunction): void {
|
|
||||||
const incoming = request.header('x-request-id');
|
|
||||||
const clientRequestId =
|
|
||||||
incoming && uuid.test(incoming) ? incoming : undefined;
|
|
||||||
const existingRequestId = (request as Request & { id?: unknown }).id;
|
|
||||||
const requestId =
|
|
||||||
typeof existingRequestId === 'string' ? existingRequestId : randomUUID();
|
|
||||||
(request as unknown as { id: string }).id = requestId;
|
|
||||||
const startedAt = performance.now();
|
|
||||||
|
|
||||||
response.setHeader('X-Request-ID', requestId);
|
|
||||||
response.on('finish', () => {
|
|
||||||
const routeRequest: unknown = request;
|
|
||||||
const routePath = (routeRequest as { route?: { path?: unknown } }).route
|
|
||||||
?.path;
|
|
||||||
const route =
|
|
||||||
typeof routePath === 'string'
|
|
||||||
? `${request.baseUrl}${routePath}`
|
|
||||||
: '<unmatched>';
|
|
||||||
const context = {
|
|
||||||
method: request.method,
|
|
||||||
route,
|
|
||||||
status: response.statusCode,
|
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
|
||||||
...(response.locals.publicErrorCode
|
|
||||||
? { code: response.locals.publicErrorCode as string }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
const level =
|
|
||||||
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
|
|
||||||
? 'error'
|
|
||||||
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
|
|
||||||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
|
|
||||||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
|
|
||||||
? 'warn'
|
|
||||||
: 'info';
|
|
||||||
this.logger.event(level, 'http.request.completed', context);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.requestContext.run(
|
|
||||||
{
|
|
||||||
requestId,
|
|
||||||
...(clientRequestId ? { clientRequestId } : {}),
|
|
||||||
},
|
|
||||||
next,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { StructuredLogger } from './structured-logger.service';
|
import { StructuredLogger } from './structured-logger.service';
|
||||||
|
|
||||||
describe('StructuredLogger', () => {
|
describe('StructuredLogger', () => {
|
||||||
const context = { get: jest.fn() };
|
|
||||||
const pino = {
|
const pino = {
|
||||||
debug: jest.fn(),
|
debug: jest.fn(),
|
||||||
info: jest.fn(),
|
info: jest.fn(),
|
||||||
@@ -12,10 +11,10 @@ describe('StructuredLogger', () => {
|
|||||||
beforeEach(() => jest.clearAllMocks());
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
it('passes only allowlisted fields to Pino', () => {
|
it('passes only allowlisted fields to Pino', () => {
|
||||||
context.get.mockReturnValue({ requestId: 'request', token: 'secret' });
|
const logger = new StructuredLogger(pino as never);
|
||||||
const logger = new StructuredLogger(context as never, pino as never);
|
|
||||||
|
|
||||||
logger.info('event', {
|
logger.info('event', {
|
||||||
|
asOf: '2026-10-10T12:00:00.000Z',
|
||||||
userId: 'user',
|
userId: 'user',
|
||||||
brokerageAccountId: 'account',
|
brokerageAccountId: 'account',
|
||||||
password: 'secret',
|
password: 'secret',
|
||||||
@@ -23,7 +22,7 @@ describe('StructuredLogger', () => {
|
|||||||
|
|
||||||
expect(pino.info).toHaveBeenCalledWith(
|
expect(pino.info).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
requestId: 'request',
|
asOf: '2026-10-10T12:00:00.000Z',
|
||||||
userId: 'user',
|
userId: 'user',
|
||||||
brokerageAccountId: 'account',
|
brokerageAccountId: 'account',
|
||||||
},
|
},
|
||||||
@@ -32,8 +31,7 @@ describe('StructuredLogger', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('delegates event levels to Pino', () => {
|
it('delegates event levels to Pino', () => {
|
||||||
context.get.mockReturnValue(undefined);
|
const logger = new StructuredLogger(pino as never);
|
||||||
const logger = new StructuredLogger(context as never, pino as never);
|
|
||||||
|
|
||||||
logger.event('debug', 'debug event');
|
logger.event('debug', 'debug event');
|
||||||
logger.event('warn', 'warn event');
|
logger.event('warn', 'warn event');
|
||||||
|
|||||||
@@ -1,35 +1,40 @@
|
|||||||
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import { RequestContextService } from './request-context.service';
|
|
||||||
|
|
||||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
|
||||||
type LogContext = Record<string, string | number | boolean | undefined>;
|
type LogContext = Record<string, boolean | number | string | undefined>;
|
||||||
|
|
||||||
const allowedFields = new Set([
|
const allowedFields = new Set([
|
||||||
'service',
|
'asOf',
|
||||||
'environment',
|
|
||||||
'requestId',
|
|
||||||
'clientRequestId',
|
|
||||||
'userId',
|
|
||||||
'brokerageAccountId',
|
|
||||||
'method',
|
|
||||||
'route',
|
|
||||||
'status',
|
|
||||||
'durationMs',
|
|
||||||
'code',
|
|
||||||
'operation',
|
|
||||||
'grpcCode',
|
|
||||||
'attempts',
|
'attempts',
|
||||||
|
'brokerageAccountId',
|
||||||
|
'channelState',
|
||||||
|
'clientRequestId',
|
||||||
|
'code',
|
||||||
'count',
|
'count',
|
||||||
|
'durationMs',
|
||||||
|
'environment',
|
||||||
|
'errorName',
|
||||||
|
'errorStack',
|
||||||
'found',
|
'found',
|
||||||
|
'grpcCode',
|
||||||
|
'headersReceived',
|
||||||
|
'invalidFields',
|
||||||
|
'method',
|
||||||
|
'networkMs',
|
||||||
|
'operation',
|
||||||
|
'queueWaitMs',
|
||||||
|
'requestId',
|
||||||
|
'route',
|
||||||
|
'service',
|
||||||
|
'status',
|
||||||
|
'trackingId',
|
||||||
|
'userId',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StructuredLogger implements OnApplicationShutdown {
|
export class StructuredLogger implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(private readonly pino: PinoLogger) {}
|
||||||
private readonly requestContext: RequestContextService,
|
|
||||||
private readonly pino: PinoLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
info(message: string, context?: LogContext): void {
|
info(message: string, context?: LogContext): void {
|
||||||
this.write('info', message, context);
|
this.write('info', message, context);
|
||||||
@@ -48,8 +53,9 @@ export class StructuredLogger implements OnApplicationShutdown {
|
|||||||
message: string,
|
message: string,
|
||||||
context: LogContext = {},
|
context: LogContext = {},
|
||||||
): void {
|
): void {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
|
||||||
this.pino[level](
|
this.pino[level](
|
||||||
this.safeContext({ ...this.requestContext.get(), ...context }),
|
this.safeContext(context),
|
||||||
message,
|
message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
import type { Money } from './money.types';
|
import type { Money } from './money.types';
|
||||||
|
|
||||||
export class MoneyDto implements Money {
|
export class MoneyDto implements Money {
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
|
export const ISO_DATE_LENGTH = 10;
|
||||||
|
export const ISO_DATE_START = 0;
|
||||||
export const MILLISECONDS_PER_SECOND = 1_000;
|
export const MILLISECONDS_PER_SECOND = 1_000;
|
||||||
export const MILLISECONDS_PER_DAY = 86_400_000;
|
export const MILLISECONDS_PER_DAY = 86_400_000;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
const MIN_PORT = 1;
|
||||||
|
const MIN_REQUESTS_PER_SECOND = 1;
|
||||||
|
|
||||||
const DEFAULT_PORT = 3000;
|
const DEFAULT_PORT = 3000;
|
||||||
const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5;
|
const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5;
|
||||||
const MAX_PORT = 65_535;
|
const MAX_PORT = 65_535;
|
||||||
@@ -7,12 +10,12 @@ export interface Environment {
|
|||||||
DATABASE_URL: string;
|
DATABASE_URL: string;
|
||||||
FRONTEND_ORIGIN: string;
|
FRONTEND_ORIGIN: string;
|
||||||
JWT_SECRET: string;
|
JWT_SECRET: string;
|
||||||
|
LOG_LEVEL: 'debug' | 'error' | 'info' | 'warn';
|
||||||
PORT: number;
|
PORT: number;
|
||||||
TOKEN_ENCRYPTION_KEY: string;
|
|
||||||
TBANK_CA_CERT_PATH?: string;
|
TBANK_CA_CERT_PATH?: string;
|
||||||
TBANK_GRPC_ENDPOINT: string;
|
TBANK_GRPC_ENDPOINT: string;
|
||||||
TBANK_RATE_LIMIT_PER_SECOND: number;
|
TBANK_RATE_LIMIT_PER_SECOND: number;
|
||||||
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
|
TOKEN_ENCRYPTION_KEY: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateEnv(config: Record<string, unknown>): Environment {
|
export function validateEnv(config: Record<string, unknown>): Environment {
|
||||||
@@ -56,7 +59,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
|||||||
const parsedPort = Number(port);
|
const parsedPort = Number(port);
|
||||||
if (
|
if (
|
||||||
!Number.isInteger(parsedPort) ||
|
!Number.isInteger(parsedPort) ||
|
||||||
parsedPort < 1 ||
|
parsedPort < MIN_PORT ||
|
||||||
parsedPort > MAX_PORT
|
parsedPort > MAX_PORT
|
||||||
) {
|
) {
|
||||||
throw new Error('PORT must be a valid port number');
|
throw new Error('PORT must be a valid port number');
|
||||||
@@ -71,7 +74,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
typeof tokenEncryptionKey !== 'string' ||
|
typeof tokenEncryptionKey !== 'string' ||
|
||||||
!/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) ||
|
!/^[A-Z0-9+/]+={0,2}$/i.test(tokenEncryptionKey) ||
|
||||||
Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES
|
Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -84,7 +87,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsedTbankRateLimit = Number(tbankRateLimit);
|
const parsedTbankRateLimit = Number(tbankRateLimit);
|
||||||
if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < 1) {
|
if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < MIN_REQUESTS_PER_SECOND) {
|
||||||
throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer');
|
throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +95,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
|||||||
throw new Error('TBANK_CA_CERT_PATH must be a file path');
|
throw new Error('TBANK_CA_CERT_PATH must be a file path');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['debug', 'info', 'warn', 'error'].includes(logLevel as string)) {
|
if (!['debug', 'error', 'info', 'warn'].includes(logLevel as string)) {
|
||||||
throw new Error('LOG_LEVEL must be debug, info, warn, or error');
|
throw new Error('LOG_LEVEL must be debug, info, warn, or error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -199,9 +199,10 @@ describe('MoexClient', () => {
|
|||||||
const url = new URL(input instanceof Request ? input.url : input);
|
const url = new URL(input instanceof Request ? input.url : input);
|
||||||
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
|
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
|
||||||
starts.push(url.searchParams.get('start') ?? '');
|
starts.push(url.searchParams.get('start') ?? '');
|
||||||
if (starts.length > 1)
|
if (starts.length > 1) {
|
||||||
return Promise.reject(new Error('Repeated page'));
|
return Promise.reject(new Error('Repeated page'));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
Response.json({
|
Response.json({
|
||||||
...emptyEvents,
|
...emptyEvents,
|
||||||
@@ -228,13 +229,14 @@ describe('MoexClient', () => {
|
|||||||
const start = Number(url.searchParams.get('start'));
|
const start = Number(url.searchParams.get('start'));
|
||||||
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
|
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
|
||||||
starts.push(String(start));
|
starts.push(String(start));
|
||||||
if (starts.length > 2)
|
if (starts.length > 2) {
|
||||||
return Promise.reject(new Error('Repeated page'));
|
return Promise.reject(new Error('Repeated page'));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
Response.json({
|
Response.json({
|
||||||
...emptyEvents,
|
...emptyEvents,
|
||||||
coupons: {
|
'coupons': {
|
||||||
columns: ['coupondate', 'value', 'faceunit'],
|
columns: ['coupondate', 'value', 'faceunit'],
|
||||||
data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']],
|
data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']],
|
||||||
},
|
},
|
||||||
@@ -246,11 +248,13 @@ describe('MoexClient', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
const result = new MoexClient().bondEvents('BOND');
|
const result = new MoexClient().bondEvents('BOND');
|
||||||
if (repeatIndex)
|
if (repeatIndex) {
|
||||||
|
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
|
||||||
await expect(result).rejects.toMatchObject({
|
await expect(result).rejects.toMatchObject({
|
||||||
response: { code: 'MOEX_UNAVAILABLE' },
|
response: { code: 'MOEX_UNAVAILABLE' },
|
||||||
});
|
});
|
||||||
else
|
} else {
|
||||||
|
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
|
||||||
await expect(result).resolves.toEqual([
|
await expect(result).resolves.toEqual([
|
||||||
{
|
{
|
||||||
type: 'coupon',
|
type: 'coupon',
|
||||||
@@ -265,6 +269,7 @@ describe('MoexClient', () => {
|
|||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
expect(starts).toEqual(['0', '1']);
|
expect(starts).toEqual(['0', '1']);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { constants as httpStatus } from 'node:http2';
|
|
||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { constants as httpStatus } from 'node:http2';
|
||||||
|
|
||||||
|
enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
|
||||||
|
|
||||||
|
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
||||||
|
const FIRST_PAGE_OFFSET = 0;
|
||||||
|
const EXPECTED_CURSOR_ROWS = 1;
|
||||||
|
const EMPTY_PAGE_SIZE = 0;
|
||||||
|
const MIN_CURSOR_VALUE = 0;
|
||||||
|
|
||||||
const FALLBACK_PAGE_SIZE = 100;
|
const FALLBACK_PAGE_SIZE = 100;
|
||||||
|
|
||||||
@@ -7,18 +15,18 @@ const baseUrl = 'https://iss.moex.com/iss';
|
|||||||
const timeoutMs = 10_000;
|
const timeoutMs = 10_000;
|
||||||
|
|
||||||
export type MoexBondEvent =
|
export type MoexBondEvent =
|
||||||
| { type: 'coupon'; date: string; value: string; currency: string }
|
| { currency: string; date: string; type: 'coupon'; value: string } |
|
||||||
| { type: 'maturity'; date: string; value: string; currency: string }
|
{ currency: string; date: string; type: 'maturity'; value: string } |
|
||||||
| { type: 'offer'; date: string };
|
{ date: string; type: 'offer' };
|
||||||
|
|
||||||
type IssBlock = { columns?: unknown; data?: unknown };
|
type IssBlock = { columns?: unknown; data?: unknown };
|
||||||
type IssResponse = Record<string, IssBlock | undefined>;
|
type IssResponse = Record<string, IssBlock | undefined>;
|
||||||
export type MoexBondYield = {
|
export type MoexBondYield = {
|
||||||
value: string;
|
|
||||||
pricePercent: string;
|
|
||||||
date: string;
|
date: string;
|
||||||
dateType: string | null;
|
dateType: null | string;
|
||||||
|
pricePercent: string;
|
||||||
tradeAt: string;
|
tradeAt: string;
|
||||||
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -52,14 +60,15 @@ export class MoexClient {
|
|||||||
if (
|
if (
|
||||||
value === null ||
|
value === null ||
|
||||||
pricePercent === null ||
|
pricePercent === null ||
|
||||||
Number(pricePercent) <= 0 ||
|
Number(pricePercent) <= MIN_PRICE_PERCENT_EXCLUSIVE ||
|
||||||
!date ||
|
!date ||
|
||||||
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
||||||
!Number.isFinite(Date.parse(date)) ||
|
!Number.isFinite(Date.parse(date)) ||
|
||||||
!tradeAt ||
|
!tradeAt ||
|
||||||
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt)
|
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt)
|
||||||
)
|
) {
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`;
|
const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`;
|
||||||
if (!Number.isFinite(Date.parse(tradeIso))) return null;
|
if (!Number.isFinite(Date.parse(tradeIso))) return null;
|
||||||
return {
|
return {
|
||||||
@@ -70,6 +79,7 @@ export class MoexClient {
|
|||||||
tradeAt: new Date(tradeIso).toISOString(),
|
tradeAt: new Date(tradeIso).toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async bondEvents(secid: string): Promise<MoexBondEvent[]> {
|
async bondEvents(secid: string): Promise<MoexBondEvent[]> {
|
||||||
const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
|
const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
|
||||||
const [coupons, offers, description] = await Promise.all([
|
const [coupons, offers, description] = await Promise.all([
|
||||||
@@ -86,9 +96,10 @@ export class MoexClient {
|
|||||||
const date = text(row.coupondate);
|
const date = text(row.coupondate);
|
||||||
const value = numberText(row.value);
|
const value = numberText(row.value);
|
||||||
const currency = currencyText(row.faceunit);
|
const currency = currencyText(row.faceunit);
|
||||||
if (date && value && currency)
|
if (date && value && currency) {
|
||||||
events.push({ type: 'coupon', date, value, currency });
|
events.push({ type: 'coupon', date, value, currency });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for (const row of offers) {
|
for (const row of offers) {
|
||||||
const date = text(row.offerdate);
|
const date = text(row.offerdate);
|
||||||
if (date) events.push({ type: 'offer', date });
|
if (date) events.push({ type: 'offer', date });
|
||||||
@@ -99,8 +110,9 @@ export class MoexClient {
|
|||||||
const date = values.get('MATDATE');
|
const date = values.get('MATDATE');
|
||||||
const value = numberText(values.get('FACEVALUE'));
|
const value = numberText(values.get('FACEVALUE'));
|
||||||
const currency = currencyText(values.get('FACEUNIT'));
|
const currency = currencyText(values.get('FACEUNIT'));
|
||||||
if (date && value && currency)
|
if (date && value && currency) {
|
||||||
events.push({ type: 'maturity', date, value, currency });
|
events.push({ type: 'maturity', date, value, currency });
|
||||||
|
}
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,15 +122,16 @@ export class MoexClient {
|
|||||||
paginated = true,
|
paginated = true,
|
||||||
): Promise<Record<string, unknown>[]> {
|
): Promise<Record<string, unknown>[]> {
|
||||||
const rows: Record<string, unknown>[] = [];
|
const rows: Record<string, unknown>[] = [];
|
||||||
let start = 0;
|
let start = FIRST_PAGE_OFFSET;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
'iss.meta': 'off',
|
'iss.meta': 'off',
|
||||||
'iss.only': `${block},${block}.cursor`,
|
'iss.only': `${block},${block}.cursor`,
|
||||||
start: String(start),
|
'start': String(start),
|
||||||
});
|
});
|
||||||
const response = await this.request(`${baseUrl}/${path}?${query}`);
|
const response = await this.request(`${baseUrl}/${path}?${query}`);
|
||||||
if (!response) return rows;
|
if (!response) return rows;
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
|
||||||
const current = parseRows(response[block]);
|
const current = parseRows(response[block]);
|
||||||
rows.push(...current);
|
rows.push(...current);
|
||||||
if (!paginated) return rows;
|
if (!paginated) return rows;
|
||||||
@@ -134,14 +147,15 @@ export class MoexClient {
|
|||||||
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 ||
|
cursors.length !== EXPECTED_CURSOR_ROWS ||
|
||||||
index !== start ||
|
index !== start ||
|
||||||
total === null ||
|
total === null ||
|
||||||
pageSize === null ||
|
pageSize === null ||
|
||||||
pageSize === 0 ||
|
pageSize === EMPTY_PAGE_SIZE ||
|
||||||
current.length !== Math.min(pageSize, total - start)
|
current.length !== Math.min(pageSize, total - start)
|
||||||
)
|
) {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
|
}
|
||||||
if (index + pageSize >= total) return rows;
|
if (index + pageSize >= total) return rows;
|
||||||
start = index + pageSize;
|
start = index + pageSize;
|
||||||
}
|
}
|
||||||
@@ -155,45 +169,51 @@ export class MoexClient {
|
|||||||
if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
|
if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
|
||||||
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
|
||||||
const body: unknown = await response.json();
|
const body: unknown = await response.json();
|
||||||
if (!body || typeof body !== 'object')
|
if (!body || typeof body !== 'object') {
|
||||||
throw new Error('MOEX malformed response');
|
throw new Error('MOEX malformed response');
|
||||||
|
}
|
||||||
return body as IssResponse;
|
return body as IssResponse;
|
||||||
} catch {
|
} catch {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.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: MoexErrorCode.Unavailable });
|
||||||
|
}
|
||||||
return block.data.map((values) => {
|
return block.data.map((values) => {
|
||||||
if (!Array.isArray(values) || values.length !== columns.length)
|
if (!Array.isArray(values) || values.length !== columns.length) {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
|
}
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
|
||||||
columns.map((column, index) => [column.toLowerCase(), values[index]]),
|
columns.map((column, index) => [column.toLowerCase(), values[index]]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function text(value: unknown): string | null {
|
function text(value: unknown): null | string {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function numberText(value: unknown): string | null {
|
function numberText(value: unknown): null | string {
|
||||||
const result = typeof value === 'number' ? String(value) : text(value);
|
const result = typeof value === 'number' ? String(value) : text(value);
|
||||||
|
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
|
||||||
return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null;
|
return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function currencyText(value: unknown): string | null {
|
function currencyText(value: unknown): null | string {
|
||||||
const result = text(value)?.toUpperCase();
|
const result = text(value)?.toUpperCase();
|
||||||
return result === 'SUR' ? 'RUB' : (result ?? null);
|
return result === 'SUR' ? 'RUB' : (result ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function integer(value: unknown): number | null {
|
function integer(value: unknown): null | number {
|
||||||
const result = Number(numberText(value) ?? NaN);
|
const result = Number(numberText(value) ?? NaN);
|
||||||
return Number.isSafeInteger(result) && result >= 0 ? result : null;
|
return Number.isSafeInteger(result) && result >= MIN_CURSOR_VALUE ? result : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { MoexClient } from './moex.client';
|
import { MoexClient } from './moex.client';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
|
||||||
import { NcrRatingsClient } from './ncr-ratings.client';
|
import { NcrRatingsClient } from './ncr-ratings.client';
|
||||||
const isin = 'RU000A10EW44';
|
const isin = 'RU000A10EW44';
|
||||||
const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') =>
|
const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') =>
|
||||||
@@ -65,6 +66,18 @@ describe('public NCR issue ratings', () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
it.each(['AA+.ru', 'B-.ru', 'BBB+.ru', 'CCC-.ru', 'CC.ru', 'D.ru'])(
|
||||||
|
'accepts the public rating grammar: %s', async (value) => {
|
||||||
|
jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value))));
|
||||||
|
expect(await new NcrRatingsClient().rating(isin)).toMatchObject({ value });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it.each(['AAA+.ru', 'CC-.ru', 'BBBB.ru', 'BBB++.ru'])(
|
||||||
|
'rejects invalid rating grades: %s', async (value) => {
|
||||||
|
jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value))));
|
||||||
|
await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
},
|
||||||
|
);
|
||||||
it('bounds outage retries then recovers without retaining a false missing rating', async () => {
|
it('bounds outage retries then recovers without retaining a false missing rating', async () => {
|
||||||
const fetchMock = jest
|
const fetchMock = jest
|
||||||
.spyOn(global, 'fetch')
|
.spyOn(global, 'fetch')
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
|||||||
import { load } from 'cheerio';
|
import { load } from 'cheerio';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
|
|
||||||
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
||||||
|
|
||||||
|
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
|
||||||
|
|
||||||
|
const EXPECTED_REGISTRY_TABLES = 1;
|
||||||
|
const RATING_COLUMN = 2;
|
||||||
|
|
||||||
const REGISTRY_CACHE_TTL_MS = 21_600_000;
|
const REGISTRY_CACHE_TTL_MS = 21_600_000;
|
||||||
const REQUEST_TIMEOUT_MS = 10_000;
|
const REQUEST_TIMEOUT_MS = 10_000;
|
||||||
const COLUMN_COUNT = 6;
|
const COLUMN_COUNT = 6;
|
||||||
@@ -11,11 +18,11 @@ const DATE_COLUMN = 5;
|
|||||||
const registryUrl = 'https://ratings.ru/ratings/issues/';
|
const registryUrl = 'https://ratings.ru/ratings/issues/';
|
||||||
export type NcrRating = {
|
export type NcrRating = {
|
||||||
agency: 'NCR';
|
agency: 'NCR';
|
||||||
value: string | null;
|
|
||||||
status: 'active' | 'withdrawn';
|
|
||||||
date: string;
|
date: string;
|
||||||
sourceUrl: string;
|
|
||||||
observedAt: string;
|
observedAt: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
status: 'active' | 'withdrawn';
|
||||||
|
value: null | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -33,9 +40,10 @@ export class NcrRatingsClient {
|
|||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = this.registry().catch(() => {
|
pending = this.registry().catch(() => {
|
||||||
// Bound outage traffic too; never present a failed download as no rating.
|
// Bound outage traffic too; never present a failed download as no rating.
|
||||||
if (this.cache.get('registry') === pending)
|
if (this.cache.get('registry') === pending) {
|
||||||
this.cache.set('registry', pending, { ttl: 60_000 });
|
this.cache.set('registry', pending, { ttl: 60_000 });
|
||||||
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' });
|
}
|
||||||
|
throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable });
|
||||||
});
|
});
|
||||||
this.cache.set('registry', pending);
|
this.cache.set('registry', pending);
|
||||||
}
|
}
|
||||||
@@ -54,13 +62,16 @@ export class NcrRatingsClient {
|
|||||||
.map((_, cell) => $(cell).text().trim())
|
.map((_, cell) => $(cell).text().trim())
|
||||||
.get();
|
.get();
|
||||||
if (
|
if (
|
||||||
table.length !== 1 ||
|
table.length !== EXPECTED_REGISTRY_TABLES ||
|
||||||
headings.length !== COLUMN_COUNT ||
|
headings.length !== COLUMN_COUNT ||
|
||||||
headings[2] !== 'Рейтинг' ||
|
headings[2] !== 'Рейтинг' ||
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
|
||||||
headings[ISIN_COLUMN] !== 'ISIN' ||
|
headings[ISIN_COLUMN] !== 'ISIN' ||
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
|
||||||
headings[DATE_COLUMN] !== 'Дата'
|
headings[DATE_COLUMN] !== 'Дата'
|
||||||
)
|
) {
|
||||||
throw new Error('NCR registry changed');
|
throw new Error('NCR registry changed');
|
||||||
|
}
|
||||||
const observedAt = new Date().toISOString();
|
const observedAt = new Date().toISOString();
|
||||||
const result = new Map<string, NcrRating>();
|
const result = new Map<string, NcrRating>();
|
||||||
table.find('tbody tr').each((_, row) => {
|
table.find('tbody tr').each((_, row) => {
|
||||||
@@ -69,27 +80,30 @@ export class NcrRatingsClient {
|
|||||||
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
|
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
|
||||||
// Unassigned issues cannot safely be matched to a held instrument.
|
// Unassigned issues cannot safely be matched to a held instrument.
|
||||||
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
|
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
|
||||||
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim();
|
const raw = cells.eq(RATING_COLUMN).text().replace(/\s+/g, ' ').trim();
|
||||||
const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
|
const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
|
||||||
if (
|
if (
|
||||||
!withdrawn &&
|
!withdrawn &&
|
||||||
!/^(AAA|AA[+-]?|A[+-]?|BBB[+-]?|BB[+-]?|B[+-]?|CCC[+-]?|CC|C|D)\.ru$/.test(
|
!/^(?:AAA|A{1,2}[+-]?|B{1,3}[+-]?|CCC[+-]?|C{1,2}|D)\.ru$/.test(
|
||||||
raw,
|
raw,
|
||||||
)
|
)
|
||||||
)
|
) {
|
||||||
throw new Error('NCR rating changed');
|
throw new Error('NCR rating changed');
|
||||||
|
}
|
||||||
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
|
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
|
||||||
cells.eq(DATE_COLUMN).text().trim(),
|
cells.eq(DATE_COLUMN).text().trim(),
|
||||||
);
|
);
|
||||||
const href = cells.eq(ISIN_COLUMN).find('a').attr('href');
|
const href = cells.eq(ISIN_COLUMN).find('a').attr('href');
|
||||||
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href))
|
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) {
|
||||||
throw new Error('NCR identity missing');
|
throw new Error('NCR identity missing');
|
||||||
|
}
|
||||||
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
|
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(Date.parse(date)) ||
|
!Number.isFinite(Date.parse(date)) ||
|
||||||
new Date(date).toISOString().slice(0, 10) !== date
|
new Date(date).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== date
|
||||||
)
|
) {
|
||||||
throw new Error('NCR date invalid');
|
throw new Error('NCR date invalid');
|
||||||
|
}
|
||||||
const rating: NcrRating = {
|
const rating: NcrRating = {
|
||||||
agency: 'NCR',
|
agency: 'NCR',
|
||||||
value: withdrawn ? null : raw,
|
value: withdrawn ? null : raw,
|
||||||
@@ -103,8 +117,9 @@ export class NcrRatingsClient {
|
|||||||
previous &&
|
previous &&
|
||||||
previous.date === date &&
|
previous.date === date &&
|
||||||
(previous.value !== rating.value || previous.status !== rating.status)
|
(previous.value !== rating.value || previous.status !== rating.status)
|
||||||
)
|
) {
|
||||||
throw new Error('NCR rating ambiguous');
|
throw new Error('NCR rating ambiguous');
|
||||||
|
}
|
||||||
if (!previous || previous.date < date) result.set(isin, rating);
|
if (!previous || previous.date < date) result.set(isin, rating);
|
||||||
});
|
});
|
||||||
if (!result.size) throw new Error('NCR registry empty');
|
if (!result.size) throw new Error('NCR registry empty');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { NcrRatingsClient } from './ncr-ratings.client';
|
import { NcrRatingsClient } from './ncr-ratings.client';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|||||||
@@ -21,3 +21,18 @@ describe('decimal', () => {
|
|||||||
).toBe('1');
|
).toBe('1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('decimal formatting', () => {
|
||||||
|
it.each([
|
||||||
|
[120000n, 4, '12'],
|
||||||
|
[-120030n, 4, '-12.003'],
|
||||||
|
[1n, 4, '0.0001'],
|
||||||
|
[0n, 4, '0'],
|
||||||
|
])('preserves value %s at scale %s while trimming only trailing zeros', (value, scale, expected) => {
|
||||||
|
expect(formatDecimal({ value, scale })).toBe(expected);
|
||||||
|
});
|
||||||
|
it('preserves a long fraction with a trailing zero run', () => {
|
||||||
|
const significant = '1'.repeat(10_000);
|
||||||
|
expect(formatDecimal({ value: BigInt(significant + '0'.repeat(10_000)), scale: 20_000 })).toBe('0.' + significant);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Decimal } from '../../common/decimal';
|
import { Decimal } from '../../common/decimal';
|
||||||
|
|
||||||
|
const MISSING_PROTO_COMPONENT = 0;
|
||||||
|
|
||||||
const NANO = 1_000_000_000n;
|
const NANO = 1_000_000_000n;
|
||||||
|
|
||||||
export type ProtoDecimal = { units?: string | number; nano?: string | number };
|
export type ProtoDecimal = { nano?: number | string; units?: number | string };
|
||||||
|
|
||||||
export function decimal(value: ProtoDecimal): Decimal {
|
export function decimal(value: ProtoDecimal): Decimal {
|
||||||
return {
|
return {
|
||||||
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0),
|
value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
|
||||||
scale: 9,
|
scale: 9,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Prisma,
|
|
||||||
TbankInstrumentLookup as MetadataLookupRow,
|
TbankInstrumentLookup as MetadataLookupRow,
|
||||||
TbankInstrument as MetadataRow,
|
TbankInstrument as MetadataRow,
|
||||||
|
Prisma,
|
||||||
} from '../../../generated/prisma/client';
|
} from '../../../generated/prisma/client';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import type { TbankSyncAttempt } from './tbank-access.service';
|
import type { TbankSyncAttempt } from './tbank-access.service';
|
||||||
import type { TbankInstrument } from './tbank.client';
|
import type { TbankInstrument } from './tbank.client';
|
||||||
import { tbankSourceTtlMs } from './tbank-source.policy';
|
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
|
|
||||||
|
const FIRST_BATCH_OFFSET = 0;
|
||||||
|
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||||
|
const REVISION_INCREMENT = 1;
|
||||||
|
const MISSING_DATA_REVISION = 0;
|
||||||
|
|
||||||
export type MetadataIdentity = {
|
export type MetadataIdentity = {
|
||||||
instrumentUid?: string | null;
|
figi?: null | string;
|
||||||
figi?: string | null;
|
instrumentUid?: null | string;
|
||||||
};
|
};
|
||||||
export type MetadataLookup = {
|
export type MetadataLookup = {
|
||||||
type: 'uid' | 'figi';
|
data: null | TbankInstrument;
|
||||||
id: string;
|
id: string;
|
||||||
data: TbankInstrument | null;
|
type: 'figi' | 'uid';
|
||||||
};
|
};
|
||||||
const provider = 'tbank';
|
const provider = 'tbank';
|
||||||
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates.
|
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates.
|
||||||
@@ -43,8 +50,9 @@ export class InstrumentMetadataRepository {
|
|||||||
? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
|
? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
|
||||||
: []),
|
: []),
|
||||||
]);
|
]);
|
||||||
if (!identifiers.length)
|
if (!identifiers.length) {
|
||||||
return new Map<string, ReturnType<typeof selectMetadata>>();
|
return new Map<string, ReturnType<typeof selectMetadata>>();
|
||||||
|
}
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const lookups: MetadataLookupRow[] = [];
|
const lookups: MetadataLookupRow[] = [];
|
||||||
for (const identifierType of ['uid', 'figi']) {
|
for (const identifierType of ['uid', 'figi']) {
|
||||||
@@ -56,10 +64,10 @@ export class InstrumentMetadataRepository {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
for (
|
for (
|
||||||
let offset = 0;
|
let offset = FIRST_BATCH_OFFSET;
|
||||||
offset < values.length;
|
offset < values.length;
|
||||||
offset += metadataReadBatchSize
|
offset += metadataReadBatchSize
|
||||||
)
|
) {
|
||||||
lookups.push(
|
lookups.push(
|
||||||
...(await tx.tbankInstrumentLookup.findMany({
|
...(await tx.tbankInstrumentLookup.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -72,15 +80,16 @@ export class InstrumentMetadataRepository {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const uids = [
|
const uids = [
|
||||||
...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
|
...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
|
||||||
];
|
];
|
||||||
const instruments: MetadataRow[] = [];
|
const instruments: MetadataRow[] = [];
|
||||||
for (
|
for (
|
||||||
let offset = 0;
|
let offset = FIRST_BATCH_OFFSET;
|
||||||
offset < uids.length;
|
offset < uids.length;
|
||||||
offset += metadataReadBatchSize
|
offset += metadataReadBatchSize
|
||||||
)
|
) {
|
||||||
instruments.push(
|
instruments.push(
|
||||||
...(await tx.tbankInstrument.findMany({
|
...(await tx.tbankInstrument.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -89,6 +98,7 @@ export class InstrumentMetadataRepository {
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
const byIdentifier = new Map(
|
const byIdentifier = new Map(
|
||||||
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
|
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
|
||||||
);
|
);
|
||||||
@@ -103,7 +113,7 @@ export class InstrumentMetadataRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async reserve(
|
async reserve(
|
||||||
attemptOwner: { userId: string; revision: string },
|
attemptOwner: { revision: string; userId: string },
|
||||||
identity: MetadataIdentity,
|
identity: MetadataIdentity,
|
||||||
runId: string,
|
runId: string,
|
||||||
active: ReadonlySet<string>,
|
active: ReadonlySet<string>,
|
||||||
@@ -125,8 +135,9 @@ export class InstrumentMetadataRepository {
|
|||||||
previous?.state === 'running' &&
|
previous?.state === 'running' &&
|
||||||
previous.processRunId === runId &&
|
previous.processRunId === runId &&
|
||||||
active.has(previous.attemptId)
|
active.has(previous.attemptId)
|
||||||
)
|
) {
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
|
}
|
||||||
const data = {
|
const data = {
|
||||||
attemptId: randomUUID(),
|
attemptId: randomUUID(),
|
||||||
processRunId: runId,
|
processRunId: runId,
|
||||||
@@ -183,7 +194,7 @@ export class InstrumentMetadataRepository {
|
|||||||
['uid', uid],
|
['uid', uid],
|
||||||
['figi', figi],
|
['figi', figi],
|
||||||
] as const) {
|
] as const) {
|
||||||
if (id && uid && !aliases.has(`${type}:${id}`))
|
if (id && uid && !aliases.has(`${type}:${id}`)) {
|
||||||
aliases.set(`${type}:${id}`, {
|
aliases.set(`${type}:${id}`, {
|
||||||
type,
|
type,
|
||||||
id,
|
id,
|
||||||
@@ -191,13 +202,15 @@ export class InstrumentMetadataRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for (const result of aliases.values()) {
|
for (const result of aliases.values()) {
|
||||||
const instrument = result.data;
|
const instrument = result.data;
|
||||||
const uid =
|
const uid =
|
||||||
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
|
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
|
||||||
// FIGI is an alias, never a fabricated provider UID.
|
// FIGI is an alias, never a fabricated provider UID.
|
||||||
if (instrument && !uid)
|
if (instrument && !uid) {
|
||||||
throw new Error('Metadata response has no instrument UID');
|
throw new Error('Metadata response has no instrument UID');
|
||||||
|
}
|
||||||
if (instrument && uid) {
|
if (instrument && uid) {
|
||||||
const key = { provider, uid };
|
const key = { provider, uid };
|
||||||
const previous = await tx.tbankInstrument.findUnique({
|
const previous = await tx.tbankInstrument.findUnique({
|
||||||
@@ -221,8 +234,8 @@ export class InstrumentMetadataRepository {
|
|||||||
...data,
|
...data,
|
||||||
dataRevision: {
|
dataRevision: {
|
||||||
increment: isDeepStrictEqual(previous?.metadata, payload)
|
increment: isDeepStrictEqual(previous?.metadata, payload)
|
||||||
? 0
|
? UNCHANGED_REVISION_INCREMENT
|
||||||
: 1,
|
: REVISION_INCREMENT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -244,8 +257,8 @@ export class InstrumentMetadataRepository {
|
|||||||
fetchedAt: now,
|
fetchedAt: now,
|
||||||
dataRevision: {
|
dataRevision: {
|
||||||
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
|
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
|
||||||
? 0
|
? UNCHANGED_REVISION_INCREMENT
|
||||||
: 1,
|
: REVISION_INCREMENT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -263,19 +276,20 @@ export class InstrumentMetadataRepository {
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
state: success ? 'complete' : 'failed',
|
state: success ? 'complete' : 'failed',
|
||||||
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (success)
|
if (success) {
|
||||||
await this.prisma.tbankSyncWork.updateMany({
|
await this.prisma.tbankSyncWork.updateMany({
|
||||||
where: {
|
where: {
|
||||||
...attempt,
|
...attempt,
|
||||||
state: 'running',
|
state: 'running',
|
||||||
connection: { invalidToken: true },
|
connection: { invalidToken: true },
|
||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectMetadata(
|
function selectMetadata(
|
||||||
@@ -283,7 +297,7 @@ function selectMetadata(
|
|||||||
lookups: ReadonlyMap<string, MetadataLookupRow>,
|
lookups: ReadonlyMap<string, MetadataLookupRow>,
|
||||||
instruments: ReadonlyMap<string, MetadataRow>,
|
instruments: ReadonlyMap<string, MetadataRow>,
|
||||||
) {
|
) {
|
||||||
const resolve = (type: string, id: string | null | undefined) => {
|
const resolve = (type: string, id: null | string | undefined) => {
|
||||||
const lookup = lookups.get(`${type}:${id?.trim()}`);
|
const lookup = lookups.get(`${type}:${id?.trim()}`);
|
||||||
const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
|
const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
|
||||||
const instrument = uid ? instruments.get(uid) : undefined;
|
const instrument = uid ? instruments.get(uid) : undefined;
|
||||||
@@ -305,15 +319,12 @@ function selectMetadata(
|
|||||||
return {
|
return {
|
||||||
data: usefulUid ? uid.data : (figi.data ?? uid.data),
|
data: usefulUid ? uid.data : (figi.data ?? uid.data),
|
||||||
complete,
|
complete,
|
||||||
fresh:
|
fresh: complete,
|
||||||
complete &&
|
|
||||||
fetchedAt !== null &&
|
|
||||||
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
|
|
||||||
fetchedAt,
|
fetchedAt,
|
||||||
dataRevision: used
|
dataRevision: used
|
||||||
.map(
|
.map(
|
||||||
(row) =>
|
(row) =>
|
||||||
`${row.lookup?.dataRevision ?? 0}:${row.instrument?.dataRevision ?? 0}`,
|
`${row.lookup?.dataRevision ?? MISSING_DATA_REVISION}:${row.instrument?.dataRevision ?? MISSING_DATA_REVISION}`,
|
||||||
)
|
)
|
||||||
.join('/'),
|
.join('/'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
|
import {
|
||||||
|
InstrumentMetadataRepository,
|
||||||
|
type MetadataIdentity,
|
||||||
|
metadataKey,
|
||||||
|
type MetadataLookup,
|
||||||
|
} from './instrument-metadata.repository';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||||
import {
|
|
||||||
InstrumentMetadataRepository,
|
|
||||||
metadataKey,
|
|
||||||
type MetadataIdentity,
|
|
||||||
type MetadataLookup,
|
|
||||||
} from './instrument-metadata.repository';
|
|
||||||
export { metadataKey } from './instrument-metadata.repository';
|
export { metadataKey } from './instrument-metadata.repository';
|
||||||
export type { MetadataIdentity } from './instrument-metadata.repository';
|
export type { MetadataIdentity } from './instrument-metadata.repository';
|
||||||
|
|
||||||
type Producer = {
|
type Producer = {
|
||||||
identity: MetadataIdentity;
|
|
||||||
attempt: TbankSyncAttempt;
|
attempt: TbankSyncAttempt;
|
||||||
|
identity: MetadataIdentity;
|
||||||
observers: TbankSyncAttempt[];
|
observers: TbankSyncAttempt[];
|
||||||
};
|
};
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -49,8 +51,9 @@ export class InstrumentMetadataService {
|
|||||||
const connection = await this.access.local(userId);
|
const connection = await this.access.local(userId);
|
||||||
if (revision && connection.revision !== revision) return null;
|
if (revision && connection.revision !== revision) return null;
|
||||||
const data = (await this.savedMany([identity])).get(metadataKey(identity));
|
const data = (await this.savedMany([identity])).get(metadataKey(identity));
|
||||||
if (!observe && !data?.fresh)
|
if (!observe && !data?.fresh) {
|
||||||
await this.request(userId, connection.revision, identity);
|
await this.request(userId, connection.revision, identity);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
work: await this.repository.work(userId, connection.revision, identity),
|
work: await this.repository.work(userId, connection.revision, identity),
|
||||||
@@ -119,7 +122,7 @@ export class InstrumentMetadataService {
|
|||||||
let useful = false;
|
let useful = false;
|
||||||
let uidFailed = false;
|
let uidFailed = false;
|
||||||
if (identity.instrumentUid) {
|
if (identity.instrumentUid) {
|
||||||
let data: TbankInstrument | null | undefined;
|
let data: null | TbankInstrument | undefined;
|
||||||
try {
|
try {
|
||||||
data = await this.tbank.getInstrumentByUid(
|
data = await this.tbank.getInstrumentByUid(
|
||||||
token,
|
token,
|
||||||
@@ -129,30 +132,38 @@ export class InstrumentMetadataService {
|
|||||||
if (
|
if (
|
||||||
!identity.figi ||
|
!identity.figi ||
|
||||||
(error instanceof Error &&
|
(error instanceof Error &&
|
||||||
error.message === 'INVALID_TBANK_TOKEN')
|
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken))
|
||||||
)
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
|
}
|
||||||
uidFailed = true;
|
uidFailed = true;
|
||||||
}
|
}
|
||||||
if (data?.uid && data.uid !== identity.instrumentUid)
|
if (data?.uid && data.uid !== identity.instrumentUid) {
|
||||||
throw new Error('Metadata UID mismatch');
|
throw new Error('Metadata UID mismatch');
|
||||||
if (data !== undefined)
|
}
|
||||||
|
if (data !== undefined) {
|
||||||
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||||
|
}
|
||||||
useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
|
useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
|
||||||
if (!useful && data && !identity.figi)
|
if (!useful && data && !identity.figi) {
|
||||||
throw new Error('Incomplete metadata');
|
throw new Error('Incomplete metadata');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!useful && identity.figi) {
|
if (!useful && identity.figi) {
|
||||||
const data = await this.tbank.getInstrumentByFigi(
|
const data = await this.tbank.getInstrumentByFigi(
|
||||||
token,
|
token,
|
||||||
identity.figi,
|
identity.figi,
|
||||||
);
|
);
|
||||||
if (data?.figi && data.figi !== identity.figi)
|
if (data?.figi && data.figi !== identity.figi) {
|
||||||
throw new Error('Metadata FIGI mismatch');
|
throw new Error('Metadata FIGI mismatch');
|
||||||
if (data && !data.name?.trim() && !data.ticker?.trim())
|
}
|
||||||
|
if (data && !data.name?.trim() && !data.ticker?.trim()) {
|
||||||
throw new Error('Incomplete metadata');
|
throw new Error('Incomplete metadata');
|
||||||
if (data === undefined || (uidFailed && !data))
|
}
|
||||||
|
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation at an untrusted upstream boundary includes missing payloads.
|
||||||
|
if (data === undefined || (uidFailed && !data)) {
|
||||||
throw new Error('Metadata source unavailable');
|
throw new Error('Metadata source unavailable');
|
||||||
|
}
|
||||||
results.push({ type: 'figi', id: identity.figi, data });
|
results.push({ type: 'figi', id: identity.figi, data });
|
||||||
// A successful FIGI response identifies its canonical UID too;
|
// A successful FIGI response identifies its canonical UID too;
|
||||||
// a failed UID RPC is never persisted as a negative lookup.
|
// a failed UID RPC is never persisted as a negative lookup.
|
||||||
@@ -160,9 +171,10 @@ export class InstrumentMetadataService {
|
|||||||
uidFailed &&
|
uidFailed &&
|
||||||
data?.uid === identity.instrumentUid &&
|
data?.uid === identity.instrumentUid &&
|
||||||
identity.instrumentUid
|
identity.instrumentUid
|
||||||
)
|
) {
|
||||||
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!results.length ||
|
!results.length ||
|
||||||
(results.some((result) => result.data) &&
|
(results.some((result) => result.data) &&
|
||||||
@@ -170,8 +182,9 @@ export class InstrumentMetadataService {
|
|||||||
(result) =>
|
(result) =>
|
||||||
result.data?.name?.trim() || result.data?.ticker?.trim(),
|
result.data?.name?.trim() || result.data?.ticker?.trim(),
|
||||||
))
|
))
|
||||||
)
|
) {
|
||||||
throw new Error('Incomplete metadata');
|
throw new Error('Incomplete metadata');
|
||||||
|
}
|
||||||
success = await this.repository.publish(
|
success = await this.repository.publish(
|
||||||
attempt,
|
attempt,
|
||||||
results,
|
results,
|
||||||
@@ -179,13 +192,14 @@ export class InstrumentMetadataService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||||
await this.access.rejectMembershipToken(attempt);
|
await this.access.rejectMembershipToken(attempt);
|
||||||
else
|
} else {
|
||||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Remove the producer before awaiting finalization: a new request cannot
|
// Remove the producer before awaiting finalization: a new request cannot
|
||||||
// attach to a producer whose observer list has already been drained.
|
// attach to a producer whose observer list has already been drained.
|
||||||
this.producers.delete(metadataKey(identity));
|
this.producers.delete(metadataKey(identity));
|
||||||
@@ -196,7 +210,7 @@ export class InstrumentMetadataService {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.event('error', 'instrument-metadata.persistence-failed', {
|
this.logger.event('error', 'instrument-metadata.persistence-failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
this.active.clear();
|
this.active.clear();
|
||||||
this.producers.clear();
|
this.producers.clear();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
@@ -6,16 +5,24 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
|
||||||
import type { TbankAccount } from './tbank.client';
|
import type { TbankAccount } from './tbank.client';
|
||||||
|
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||||
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
|
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||||
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
|
|
||||||
|
const EXPECTED_UPDATED_CONNECTIONS = 1;
|
||||||
|
const MEMBERSHIP_REVISION_INCREMENT = 1;
|
||||||
|
const UNCHANGED_MEMBERSHIP_REVISION_INCREMENT = 0;
|
||||||
|
|
||||||
export type TbankSyncAttempt = {
|
export type TbankSyncAttempt = {
|
||||||
userId: string;
|
|
||||||
revision: string;
|
|
||||||
id: string;
|
|
||||||
attemptId: string;
|
attemptId: string;
|
||||||
|
id: string;
|
||||||
|
revision: string;
|
||||||
|
userId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -48,8 +55,9 @@ export class TbankAccessService {
|
|||||||
connection.membershipAt,
|
connection.membershipAt,
|
||||||
new Date(),
|
new Date(),
|
||||||
)
|
)
|
||||||
)
|
) {
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
const scope = { userId, revision, scopeKey: 'membership' };
|
const scope = { userId, revision, scopeKey: 'membership' };
|
||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
@@ -58,8 +66,9 @@ export class TbankAccessService {
|
|||||||
previous?.state === 'running' &&
|
previous?.state === 'running' &&
|
||||||
previous.processRunId === processRunId &&
|
previous.processRunId === processRunId &&
|
||||||
active.has(previous.attemptId)
|
active.has(previous.attemptId)
|
||||||
)
|
) {
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
|
}
|
||||||
const data = {
|
const data = {
|
||||||
attemptId: randomUUID(),
|
attemptId: randomUUID(),
|
||||||
processRunId,
|
processRunId,
|
||||||
@@ -95,12 +104,12 @@ export class TbankAccessService {
|
|||||||
state: 'running',
|
state: 'running',
|
||||||
connection: { invalidToken: false },
|
connection: { invalidToken: false },
|
||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
||||||
});
|
});
|
||||||
return result.count === 1;
|
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<string | null> {
|
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
|
||||||
const work = await this.prisma.tbankSyncWork.findFirst({
|
const work = await this.prisma.tbankSyncWork.findFirst({
|
||||||
where: {
|
where: {
|
||||||
...attempt,
|
...attempt,
|
||||||
@@ -112,6 +121,23 @@ export class TbankAccessService {
|
|||||||
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
|
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Online reads: a permanent upstream auth refusal becomes 400/INVALID_TBANK_TOKEN here, once, for every caller.
|
||||||
|
async online<T>(
|
||||||
|
userId: string,
|
||||||
|
run: (connection: { revision: string; token: string }) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const connection = await this.prisma.tbankConnection.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { encryptedToken: true, invalidToken: true, revision: true },
|
||||||
|
});
|
||||||
|
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
|
||||||
|
if (connection.invalidToken) {
|
||||||
|
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||||
|
}
|
||||||
|
const token = this.tokens.decrypt(connection.encryptedToken);
|
||||||
|
return this.tokens.translateInvalidToken(() => run({ revision: connection.revision, token }));
|
||||||
|
}
|
||||||
|
|
||||||
async publishMembership(
|
async publishMembership(
|
||||||
attempt: TbankSyncAttempt,
|
attempt: TbankSyncAttempt,
|
||||||
accounts: TbankAccount[],
|
accounts: TbankAccount[],
|
||||||
@@ -119,8 +145,9 @@ export class TbankAccessService {
|
|||||||
completeWork = false,
|
completeWork = false,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const supported = supportedTbankAccounts(accounts);
|
const supported = supportedTbankAccounts(accounts);
|
||||||
if (new Set(supported.map(({ id }) => id)).size !== supported.length)
|
if (new Set(supported.map(({ id }) => id)).size !== supported.length) {
|
||||||
throw new Error('Duplicate T-Bank account identity');
|
throw new Error('Duplicate T-Bank account identity');
|
||||||
|
}
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
// Acquire the write fence before reading membership. Replacement and
|
// Acquire the write fence before reading membership. Replacement and
|
||||||
// attempt supersession cannot interleave with the publication below.
|
// attempt supersession cannot interleave with the publication below.
|
||||||
@@ -146,6 +173,7 @@ export class TbankAccessService {
|
|||||||
const changed =
|
const changed =
|
||||||
previous.length !== next.length ||
|
previous.length !== next.length ||
|
||||||
previous.some((account, index) => {
|
previous.some((account, index) => {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- The index comes from the paired account array iteration.
|
||||||
const incoming = next[index];
|
const incoming = next[index];
|
||||||
return (
|
return (
|
||||||
account.accountId !== incoming.accountId ||
|
account.accountId !== incoming.accountId ||
|
||||||
@@ -178,14 +206,26 @@ export class TbankAccessService {
|
|||||||
where: { userId_revision: { userId, revision } },
|
where: { userId_revision: { userId, revision } },
|
||||||
data: {
|
data: {
|
||||||
membershipAt: fetchedAt,
|
membershipAt: fetchedAt,
|
||||||
membershipRevision: { increment: changed ? 1 : 0 },
|
membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (completeWork)
|
// Value history visibility follows the confirmed membership only; a
|
||||||
|
// failed fetch never reaches this transaction and changes nothing.
|
||||||
|
const supportedIds = supported.map(({ id }) => id);
|
||||||
|
await tx.tbankPortfolioValueHistory.updateMany({
|
||||||
|
where: { userId, accountId: { in: supportedIds } },
|
||||||
|
data: { deletedAt: null },
|
||||||
|
});
|
||||||
|
await tx.tbankPortfolioValueHistory.updateMany({
|
||||||
|
where: { userId, accountId: { notIn: supportedIds }, deletedAt: null },
|
||||||
|
data: { deletedAt: fetchedAt },
|
||||||
|
});
|
||||||
|
if (completeWork) {
|
||||||
await tx.tbankSyncWork.update({
|
await tx.tbankSyncWork.update({
|
||||||
where: { id: attempt.id },
|
where: { id: attempt.id },
|
||||||
data: { state: 'complete', progress: { accounts: supported.length } },
|
data: { state: 'complete', progress: { accounts: supported.length } },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -194,7 +234,7 @@ export class TbankAccessService {
|
|||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
where: { ...attempt, state: 'running' },
|
where: { ...attempt, state: 'running' },
|
||||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||||
});
|
});
|
||||||
if (!claimed.count) return false;
|
if (!claimed.count) return false;
|
||||||
await tx.tbankConnection.update({
|
await tx.tbankConnection.update({
|
||||||
@@ -224,17 +264,20 @@ export class TbankAccessService {
|
|||||||
accounts: { orderBy: { upstreamOrder: 'asc' } },
|
accounts: { orderBy: { upstreamOrder: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!connection)
|
if (!connection) {
|
||||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
throw new ConflictException({ code: TbankErrorCode.NotConnected });
|
||||||
if (connection.invalidToken)
|
}
|
||||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
if (connection.invalidToken) {
|
||||||
|
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
connection.membershipAt &&
|
connection.membershipAt &&
|
||||||
accountId &&
|
accountId &&
|
||||||
accountId !== 'all' &&
|
accountId !== 'all' &&
|
||||||
!connection.accounts.some((account) => account.accountId === accountId)
|
!connection.accounts.some((account) => account.accountId === accountId)
|
||||||
)
|
) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||||
|
import { TbankAccountType } from './tbank-protocol';
|
||||||
import { TbankAccount } from './tbank.client';
|
import { TbankAccount } from './tbank.client';
|
||||||
|
|
||||||
const accountTypes = {
|
const accountTypes = {
|
||||||
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' },
|
[TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' },
|
||||||
ACCOUNT_TYPE_TINKOFF_IIS: {
|
[TbankAccountType.TinkoffIis]: {
|
||||||
type: 'INDIVIDUAL_INVESTMENT',
|
type: 'INDIVIDUAL_INVESTMENT',
|
||||||
name: 'ИИС',
|
name: 'ИИС',
|
||||||
},
|
},
|
||||||
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
|
[TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
|
||||||
ACCOUNT_TYPE_INVEST_FUND: {
|
[TbankAccountType.InvestFund]: {
|
||||||
type: 'MONEY_MARKET_FUND',
|
type: 'MONEY_MARKET_FUND',
|
||||||
name: 'Фонд денежного рынка',
|
name: 'Фонд денежного рынка',
|
||||||
},
|
},
|
||||||
@@ -18,8 +19,8 @@ type AccountType = keyof typeof accountTypes;
|
|||||||
export type SupportedTbankAccount = {
|
export type SupportedTbankAccount = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
openedOn: null | string;
|
||||||
type: (typeof accountTypes)[AccountType]['type'];
|
type: (typeof accountTypes)[AccountType]['type'];
|
||||||
openedOn: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function supportedTbankAccounts(
|
export function supportedTbankAccounts(
|
||||||
@@ -39,9 +40,9 @@ export function supportedTbankAccounts(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openedOn(seconds: string | number | undefined): string | null {
|
function openedOn(seconds: number | string | undefined): null | string {
|
||||||
if (seconds === undefined) return null;
|
if (seconds === undefined) return null;
|
||||||
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
|
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
|
||||||
if (Number.isNaN(date.valueOf())) return null;
|
if (Number.isNaN(date.valueOf())) return null;
|
||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export enum TbankErrorCode {
|
||||||
|
InvalidToken = 'INVALID_TBANK_TOKEN',
|
||||||
|
NotConnected = 'TBANK_NOT_CONNECTED',
|
||||||
|
Unavailable = 'TBANK_UNAVAILABLE',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error messages and persisted codes cross an open string boundary.
|
||||||
|
export function isTbankErrorCode(value: unknown, expected: TbankErrorCode): boolean {
|
||||||
|
return value === expected;
|
||||||
|
}
|
||||||
@@ -6,23 +6,25 @@ import type {
|
|||||||
} from './tbank.client';
|
} from './tbank.client';
|
||||||
|
|
||||||
export type TbankShare = TbankInstrument & {
|
export type TbankShare = TbankInstrument & {
|
||||||
uid?: string;
|
|
||||||
figi?: string;
|
|
||||||
assetUid?: string;
|
|
||||||
classCode?: string;
|
|
||||||
realExchange?: string;
|
|
||||||
apiTradeAvailableFlag?: boolean;
|
apiTradeAvailableFlag?: boolean;
|
||||||
|
assetUid?: string;
|
||||||
|
buyAvailableFlag?: boolean;
|
||||||
|
classCode?: string;
|
||||||
|
countryOfRisk?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
shareType?: string;
|
figi?: string;
|
||||||
instrumentKind?: string;
|
|
||||||
sector?: string;
|
|
||||||
lot?: number;
|
|
||||||
first_1dayCandleDate?: TbankTimestamp;
|
first_1dayCandleDate?: TbankTimestamp;
|
||||||
|
instrumentKind?: string;
|
||||||
|
lot?: number;
|
||||||
|
realExchange?: string;
|
||||||
|
sector?: string;
|
||||||
|
shareType?: string;
|
||||||
|
uid?: string;
|
||||||
};
|
};
|
||||||
export type TbankCandle = {
|
export type TbankCandle = {
|
||||||
time?: TbankTimestamp;
|
|
||||||
close?: TbankDecimal;
|
close?: TbankDecimal;
|
||||||
isComplete?: boolean;
|
isComplete?: boolean;
|
||||||
|
time?: TbankTimestamp;
|
||||||
};
|
};
|
||||||
export type TbankLastPrice = {
|
export type TbankLastPrice = {
|
||||||
instrumentUid?: string;
|
instrumentUid?: string;
|
||||||
@@ -32,38 +34,38 @@ export type TbankLastPrice = {
|
|||||||
export type TbankFundamentals = {
|
export type TbankFundamentals = {
|
||||||
assetUid?: string;
|
assetUid?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
ebitdaTtm?: number;
|
||||||
marketCapitalization?: number;
|
marketCapitalization?: number;
|
||||||
|
netDebtToEbitda?: number;
|
||||||
|
netIncomeTtm?: number;
|
||||||
peRatioTtm?: number;
|
peRatioTtm?: number;
|
||||||
priceToBookTtm?: number;
|
priceToBookTtm?: number;
|
||||||
revenueTtm?: number;
|
revenueTtm?: number;
|
||||||
netIncomeTtm?: number;
|
|
||||||
ebitdaTtm?: number;
|
|
||||||
roe?: number;
|
roe?: number;
|
||||||
totalDebtMrq?: number;
|
totalDebtMrq?: number;
|
||||||
netDebtToEbitda?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TbankBond = Omit<TbankShare, 'shareType'> & {
|
export type TbankBond = Omit<TbankShare, 'shareType'> & {
|
||||||
nominal?: TbankMoney;
|
|
||||||
initialNominal?: TbankMoney;
|
|
||||||
aciValue?: TbankMoney;
|
aciValue?: TbankMoney;
|
||||||
maturityDate?: TbankTimestamp;
|
amortizationFlag?: boolean;
|
||||||
|
bondType?: string;
|
||||||
callDate?: TbankTimestamp;
|
callDate?: TbankTimestamp;
|
||||||
couponQuantityPerYear?: number;
|
couponQuantityPerYear?: number;
|
||||||
floatingCouponFlag?: boolean;
|
floatingCouponFlag?: boolean;
|
||||||
|
initialNominal?: TbankMoney;
|
||||||
|
maturityDate?: TbankTimestamp;
|
||||||
|
nominal?: TbankMoney;
|
||||||
perpetualFlag?: boolean;
|
perpetualFlag?: boolean;
|
||||||
amortizationFlag?: boolean;
|
|
||||||
subordinatedFlag?: boolean;
|
|
||||||
bondType?: string;
|
|
||||||
riskLevel?: string;
|
riskLevel?: string;
|
||||||
|
subordinatedFlag?: boolean;
|
||||||
};
|
};
|
||||||
export type TbankCoupon = {
|
export type TbankCoupon = {
|
||||||
couponNumber?: string;
|
|
||||||
couponDate?: TbankTimestamp;
|
couponDate?: TbankTimestamp;
|
||||||
|
couponEndDate?: TbankTimestamp;
|
||||||
|
couponNumber?: string;
|
||||||
|
couponPeriod?: number;
|
||||||
|
couponStartDate?: TbankTimestamp;
|
||||||
|
couponType?: string;
|
||||||
fixDate?: TbankTimestamp;
|
fixDate?: TbankTimestamp;
|
||||||
payOneBond?: TbankMoney;
|
payOneBond?: TbankMoney;
|
||||||
couponType?: string;
|
|
||||||
couponStartDate?: TbankTimestamp;
|
|
||||||
couponEndDate?: TbankTimestamp;
|
|
||||||
couponPeriod?: number;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -36,7 +38,7 @@ export class TbankMembershipService {
|
|||||||
.catch(() =>
|
.catch(() =>
|
||||||
this.logger.event('error', 'tbank.membership.persistence_failed', {
|
this.logger.event('error', 'tbank.membership.persistence_failed', {
|
||||||
userId,
|
userId,
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.finally(() => this.active.delete(attemptId));
|
.finally(() => this.active.delete(attemptId));
|
||||||
@@ -61,7 +63,7 @@ export class TbankMembershipService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.access.failMembership(
|
await this.access.failMembership(
|
||||||
attempt,
|
attempt,
|
||||||
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
|
error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { loadSync } from '@grpc/proto-loader';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||||
|
import {
|
||||||
|
InstrumentIdType,
|
||||||
|
isTbankProtocolValue,
|
||||||
|
TbankAccountStatus,
|
||||||
|
TbankAccountType,
|
||||||
|
TbankCandleInterval,
|
||||||
|
TbankCouponType,
|
||||||
|
TbankInstrumentType,
|
||||||
|
TbankOperationState,
|
||||||
|
TbankOperationType,
|
||||||
|
} from './tbank-protocol';
|
||||||
|
|
||||||
|
it('uses the string enum values declared by the vendored protocol', () => {
|
||||||
|
const directory = join(__dirname, 'proto/contracts');
|
||||||
|
const definitions = loadSync(['users.proto', 'instruments.proto', 'operations.proto', 'marketdata.proto'].map((file) => join(directory, file)), { includeDirs: [directory], enums: String });
|
||||||
|
for (const [name, values] of Object.entries({
|
||||||
|
InstrumentIdType,
|
||||||
|
InstrumentType: TbankInstrumentType,
|
||||||
|
AccountStatus: TbankAccountStatus,
|
||||||
|
AccountType: TbankAccountType,
|
||||||
|
CandleInterval: TbankCandleInterval,
|
||||||
|
CouponType: TbankCouponType,
|
||||||
|
OperationState: TbankOperationState,
|
||||||
|
OperationType: TbankOperationType,
|
||||||
|
})) {
|
||||||
|
const definition = definitions[`tinkoff.public.invest.api.contract.v1.${name}`];
|
||||||
|
expect(definition).toHaveProperty('format', 'Protocol Buffer 3 EnumDescriptorProto');
|
||||||
|
const descriptor = definition as { type: { value: { name: string }[] } };
|
||||||
|
expect(descriptor.type.value.map((member) => member.name)).toEqual(expect.arrayContaining(Object.values(values)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes supported wire values without accepting unknown ones', () => {
|
||||||
|
expect(isTbankProtocolValue('OPERATION_STATE_EXECUTED', TbankOperationState.Executed)).toBe(true);
|
||||||
|
expect(isTbankProtocolValue('NEW_UPSTREAM_STATE', TbankOperationState.Executed)).toBe(false);
|
||||||
|
expect(isTbankErrorCode('INVALID_TBANK_TOKEN', TbankErrorCode.InvalidToken)).toBe(true);
|
||||||
|
expect(isTbankErrorCode('NEW_UPSTREAM_ERROR', TbankErrorCode.InvalidToken)).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// String wire values for the protocol subset used by this integration.
|
||||||
|
// proto-loader decodes enums as strings; vendor contracts remain the source.
|
||||||
|
export enum InstrumentIdType {
|
||||||
|
Figi = 'INSTRUMENT_ID_TYPE_FIGI',
|
||||||
|
Uid = 'INSTRUMENT_ID_TYPE_UID',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankInstrumentStatus {
|
||||||
|
All = 'INSTRUMENT_STATUS_ALL',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankInstrumentType {
|
||||||
|
Bond = 'INSTRUMENT_TYPE_BOND',
|
||||||
|
Share = 'INSTRUMENT_TYPE_SHARE',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankAccountStatus {
|
||||||
|
Open = 'ACCOUNT_STATUS_OPEN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankAccountType {
|
||||||
|
InvestBox = 'ACCOUNT_TYPE_INVEST_BOX',
|
||||||
|
InvestFund = 'ACCOUNT_TYPE_INVEST_FUND',
|
||||||
|
Tinkoff = 'ACCOUNT_TYPE_TINKOFF',
|
||||||
|
TinkoffIis = 'ACCOUNT_TYPE_TINKOFF_IIS',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankCandleInterval {
|
||||||
|
Day = 'CANDLE_INTERVAL_DAY',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankOperationState {
|
||||||
|
Executed = 'OPERATION_STATE_EXECUTED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankCouponType {
|
||||||
|
Floating = 'COUPON_TYPE_FLOATING',
|
||||||
|
Unspecified = 'COUPON_TYPE_UNSPECIFIED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankOperationType {
|
||||||
|
BondTax = 'OPERATION_TYPE_BOND_TAX',
|
||||||
|
BrokerFee = 'OPERATION_TYPE_BROKER_FEE',
|
||||||
|
Buy = 'OPERATION_TYPE_BUY',
|
||||||
|
BuyCard = 'OPERATION_TYPE_BUY_CARD',
|
||||||
|
BuyMargin = 'OPERATION_TYPE_BUY_MARGIN',
|
||||||
|
Coupon = 'OPERATION_TYPE_COUPON',
|
||||||
|
DivExt = 'OPERATION_TYPE_DIV_EXT',
|
||||||
|
Dividend = 'OPERATION_TYPE_DIVIDEND',
|
||||||
|
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
|
||||||
|
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
|
||||||
|
Input = 'OPERATION_TYPE_INPUT',
|
||||||
|
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
|
||||||
|
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
|
||||||
|
Output = 'OPERATION_TYPE_OUTPUT',
|
||||||
|
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
|
||||||
|
Sell = 'OPERATION_TYPE_SELL',
|
||||||
|
SellCard = 'OPERATION_TYPE_SELL_CARD',
|
||||||
|
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
|
||||||
|
ServiceFee = 'OPERATION_TYPE_SERVICE_FEE',
|
||||||
|
SuccessFee = 'OPERATION_TYPE_SUCCESS_FEE',
|
||||||
|
Tax = 'OPERATION_TYPE_TAX',
|
||||||
|
TransBsBs = 'OPERATION_TYPE_TRANS_BS_BS',
|
||||||
|
TransIisBs = 'OPERATION_TYPE_TRANS_IIS_BS',
|
||||||
|
Unspecified = 'OPERATION_TYPE_UNSPECIFIED',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider responses may contain values newer than our supported protocol subset.
|
||||||
|
export function isTbankProtocolValue(
|
||||||
|
value: unknown,
|
||||||
|
expected: TbankCouponType | TbankInstrumentType | TbankOperationState | TbankOperationType,
|
||||||
|
): boolean {
|
||||||
|
return value === expected;
|
||||||
|
}
|
||||||
@@ -3,11 +3,12 @@ import {
|
|||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
} from './tbank-source.policy';
|
} from './tbank-source.policy';
|
||||||
|
|
||||||
describe('request-driven source freshness', () => {
|
describe('mutable source freshness', () => {
|
||||||
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
|
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
|
||||||
'%s expires at its boundary without changing source time',
|
'%s expires at its boundary without changing source time',
|
||||||
(source) => {
|
(source) => {
|
||||||
const fetchedAt = new Date('2026-09-01Z');
|
const fetchedAt = new Date('2026-09-01Z');
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- The key comes from the local source-policy test table.
|
||||||
const ttl = tbankSourceTtlMs[source];
|
const ttl = tbankSourceTtlMs[source];
|
||||||
expect(
|
expect(
|
||||||
tbankSourceNeedsRefresh(
|
tbankSourceNeedsRefresh(
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
// Freshness never establishes completeness. Immutable ranges are planned by
|
// Freshness never establishes completeness. Immutable ranges are planned by
|
||||||
// their coverage owners; these TTLs apply only to mutable source snapshots.
|
// their coverage owners; these TTLs apply only to mutable source snapshots.
|
||||||
export const tbankSourceTtlMs = {
|
export const tbankSourceTtlMs = {
|
||||||
membership: 300_000,
|
membership: 3_600_000,
|
||||||
portfolio: 60_000,
|
portfolio: 3_600_000,
|
||||||
quote: 60_000,
|
quote: 3_600_000,
|
||||||
candleTail: 60_000,
|
candleTail: 3_600_000,
|
||||||
operationsTail: 300_000,
|
|
||||||
metadata: 86_400_000,
|
|
||||||
catalog: 86_400_000,
|
|
||||||
company: 86_400_000,
|
|
||||||
calendar: 86_400_000,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function tbankSourceNeedsRefresh(
|
export function tbankSourceNeedsRefresh(
|
||||||
@@ -21,6 +16,7 @@ export function tbankSourceNeedsRefresh(
|
|||||||
return (
|
return (
|
||||||
force ||
|
force ||
|
||||||
!fetchedAt ||
|
!fetchedAt ||
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- The key is a member of the closed TbankSource union.
|
||||||
now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
|
now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
import { Environment } from '../../config/env.validation';
|
import { Environment } from '../../config/env.validation';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||||
import { TbankCrypto } from './tbank.crypto';
|
import { TbankCrypto } from './tbank.crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -25,8 +27,9 @@ export class TbankTokenService {
|
|||||||
try {
|
try {
|
||||||
return await operation();
|
return await operation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||||
export { InstrumentMetadataService } from './instrument-metadata.service';
|
export { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
|
export { metadataKey } from './instrument-metadata.service';
|
||||||
|
export type { MetadataIdentity } from './instrument-metadata.service';
|
||||||
export {
|
export {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
|
export { supportedTbankAccounts } from './tbank-account.policy';
|
||||||
export {
|
export {
|
||||||
type TbankBond,
|
type TbankBond,
|
||||||
type TbankCandle,
|
type TbankCandle,
|
||||||
@@ -12,8 +15,15 @@ export {
|
|||||||
type TbankLastPrice,
|
type TbankLastPrice,
|
||||||
type TbankShare,
|
type TbankShare,
|
||||||
} from './tbank-market.types';
|
} from './tbank-market.types';
|
||||||
|
|
||||||
|
export { TbankMembershipService } from './tbank-membership.service';
|
||||||
|
|
||||||
|
export {
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
|
tbankSourceTtlMs,
|
||||||
|
} from './tbank-source.policy';
|
||||||
|
|
||||||
export { TbankTokenService } from './tbank-token.service';
|
export { TbankTokenService } from './tbank-token.service';
|
||||||
export { supportedTbankAccounts } from './tbank-account.policy';
|
|
||||||
export {
|
export {
|
||||||
TbankClient,
|
TbankClient,
|
||||||
type TbankDividend,
|
type TbankDividend,
|
||||||
@@ -25,13 +35,3 @@ export {
|
|||||||
type TbankPositions,
|
type TbankPositions,
|
||||||
type TbankTimestamp,
|
type TbankTimestamp,
|
||||||
} from './tbank.client';
|
} from './tbank.client';
|
||||||
|
|
||||||
export { TbankMembershipService } from './tbank-membership.service';
|
|
||||||
|
|
||||||
export {
|
|
||||||
tbankSourceTtlMs,
|
|
||||||
tbankSourceNeedsRefresh,
|
|
||||||
} from './tbank-source.policy';
|
|
||||||
|
|
||||||
export { metadataKey } from './instrument-metadata.service';
|
|
||||||
export type { MetadataIdentity } from './instrument-metadata.service';
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { status } from '@grpc/grpc-js';
|
import { Metadata, status } from '@grpc/grpc-js';
|
||||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
|
import { TbankOperationState } from './tbank-protocol';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
describe('TbankClient operations', () => {
|
describe('TbankClient operations', () => {
|
||||||
@@ -9,7 +12,7 @@ describe('TbankClient operations', () => {
|
|||||||
to: { seconds: '2' },
|
to: { seconds: '2' },
|
||||||
cursor: 'cursor',
|
cursor: 'cursor',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
state: 'OPERATION_STATE_EXECUTED' as const,
|
state: TbankOperationState.Executed,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('passes a cursor operations request to OperationsService', async () => {
|
it('passes a cursor operations request to OperationsService', async () => {
|
||||||
@@ -57,6 +60,7 @@ describe('TbankClient operations', () => {
|
|||||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||||
const call = jest.fn().mockResolvedValue({});
|
const call = jest.fn().mockResolvedValue({});
|
||||||
(client as unknown as { call: jest.Mock }).call = call;
|
(client as unknown as { call: jest.Mock }).call = call;
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
|
||||||
await client[method]('token', 'uid', '2024-02-29', '2024-02-29');
|
await client[method]('token', 'uid', '2024-02-29', '2024-02-29');
|
||||||
expect(call).toHaveBeenCalledWith('token', undefined, method, {
|
expect(call).toHaveBeenCalledWith('token', undefined, method, {
|
||||||
instrumentId: 'uid',
|
instrumentId: 'uid',
|
||||||
@@ -111,11 +115,10 @@ describe('TbankClient operations', () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
});
|
});
|
||||||
const failed = invoke('token', {}, 'getPortfolio', {});
|
const failed = invoke('token', {}, 'getPortfolio', {});
|
||||||
const rejected = expect(failed).rejects.toEqual(
|
await Promise.all([
|
||||||
expect.any(ServiceUnavailableException),
|
expect(failed).rejects.toEqual(expect.any(ServiceUnavailableException)),
|
||||||
);
|
jest.advanceTimersByTimeAsync(1000),
|
||||||
await jest.advanceTimersByTimeAsync(1000);
|
]);
|
||||||
await rejected;
|
|
||||||
|
|
||||||
expect(logger.event).toHaveBeenCalledWith(
|
expect(logger.event).toHaveBeenCalledWith(
|
||||||
'debug',
|
'debug',
|
||||||
@@ -134,13 +137,77 @@ describe('TbankClient operations', () => {
|
|||||||
);
|
);
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('distinguishes a gRPC deadline from queue delay without logging upstream secrets', async () => {
|
||||||
|
const logger = { event: jest.fn() };
|
||||||
|
const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, {
|
||||||
|
logger,
|
||||||
|
queue: { size: 0, run: (operation: () => Promise<unknown>) => operation() },
|
||||||
|
});
|
||||||
|
const channel = { getConnectivityState: () => 1 };
|
||||||
|
const service = {
|
||||||
|
getChannel: () => channel,
|
||||||
|
getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: unknown) => void) => {
|
||||||
|
const call = new EventEmitter();
|
||||||
|
void Promise.resolve().then(() => callback({ code: status.DEADLINE_EXCEEDED, details: 'Bearer secret' }));
|
||||||
|
return call;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise<unknown> }).call.bind(client);
|
||||||
|
jest.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const failed = invoke('secret', service, 'getAccounts', {});
|
||||||
|
await Promise.all([
|
||||||
|
expect(failed).rejects.toBeInstanceOf(ServiceUnavailableException),
|
||||||
|
jest.advanceTimersByTimeAsync(1000),
|
||||||
|
]);
|
||||||
|
expect(logger.event).toHaveBeenLastCalledWith('error', 'tbank.rpc.failed', expect.objectContaining({
|
||||||
|
operation: 'getAccounts', grpcCode: status.DEADLINE_EXCEEDED,
|
||||||
|
queueWaitMs: 0, channelState: 'CONNECTING', headersReceived: false,
|
||||||
|
}));
|
||||||
|
const [, , fields] = logger.event.mock.calls.at(-1) as [string, string, { networkMs: unknown }];
|
||||||
|
expect(typeof fields.networkMs).toBe('number');
|
||||||
|
expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret');
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records response headers and a validated upstream tracking ID', async () => {
|
||||||
|
const logger = { event: jest.fn() };
|
||||||
|
const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, {
|
||||||
|
logger,
|
||||||
|
queue: { size: 0, run: (operation: () => Promise<unknown>) => operation() },
|
||||||
|
});
|
||||||
|
const headers = new Metadata();
|
||||||
|
headers.set('x-tracking-id', '123e4567-e89b-12d3-a456-426614174000');
|
||||||
|
headers.set('authorization', 'Bearer secret');
|
||||||
|
const service = {
|
||||||
|
getChannel: () => ({ getConnectivityState: () => 2 }),
|
||||||
|
getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: null, response: object) => void) => {
|
||||||
|
const call = new EventEmitter();
|
||||||
|
void Promise.resolve().then(() => {
|
||||||
|
call.emit('metadata', headers);
|
||||||
|
callback(null, { accounts: [] });
|
||||||
|
});
|
||||||
|
return call;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise<unknown> }).call.bind(client);
|
||||||
|
await expect(invoke('secret', service, 'getAccounts', {})).resolves.toEqual({ accounts: [] });
|
||||||
|
expect(logger.event).toHaveBeenLastCalledWith('debug', 'tbank.rpc.completed', expect.objectContaining({
|
||||||
|
channelState: 'READY', headersReceived: true,
|
||||||
|
trackingId: '123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
}));
|
||||||
|
expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TbankClient metadata retries', () => {
|
describe('TbankClient metadata retries', () => {
|
||||||
const instrument = { name: 'Облигация', ticker: 'BOND' };
|
const instrument = { name: 'Облигация', ticker: 'BOND' };
|
||||||
let client: TbankClient;
|
let client: TbankClient;
|
||||||
let requestRpc: jest.Mock;
|
let requestRpc: jest.Mock;
|
||||||
let queue: { size: number; run: jest.Mock };
|
let queue: { run: jest.Mock; size: number };
|
||||||
let logger: {
|
let logger: {
|
||||||
event: jest.Mock<void, [string, string, Record<string, unknown>]>;
|
event: jest.Mock<void, [string, string, Record<string, unknown>]>;
|
||||||
};
|
};
|
||||||
@@ -174,46 +241,30 @@ describe('TbankClient metadata retries', () => {
|
|||||||
status.DEADLINE_EXCEEDED,
|
status.DEADLINE_EXCEEDED,
|
||||||
status.RESOURCE_EXHAUSTED,
|
status.RESOURCE_EXHAUSTED,
|
||||||
])(
|
])(
|
||||||
'recovers metadata after transient gRPC status %s with 2s/4s pauses',
|
'recovers metadata after one retry for transient gRPC status %s',
|
||||||
async (code) => {
|
async (code) => {
|
||||||
requestRpc
|
requestRpc
|
||||||
.mockRejectedValueOnce({ code })
|
|
||||||
.mockRejectedValueOnce({ code })
|
.mockRejectedValueOnce({ code })
|
||||||
.mockResolvedValueOnce({ instrument });
|
.mockResolvedValueOnce({ instrument });
|
||||||
|
|
||||||
const result = client.getInstrumentByUid('token', 'uid');
|
const result = client.getInstrumentByUid('token', 'uid');
|
||||||
await jest.advanceTimersByTimeAsync(1999);
|
await jest.advanceTimersByTimeAsync(999);
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
expect(requestRpc).toHaveBeenCalledTimes(2);
|
||||||
await jest.advanceTimersByTimeAsync(3999);
|
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(instrument);
|
await expect(result).resolves.toEqual(instrument);
|
||||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('rechecks a full queue before admitting a metadata retry', async () => {
|
it('rejects metadata immediately when the queue is full', async () => {
|
||||||
queue.size = 50;
|
queue.size = 50;
|
||||||
requestRpc.mockResolvedValue({ instrument });
|
requestRpc.mockResolvedValue({ instrument });
|
||||||
|
|
||||||
const result = client.getInstrumentByFigi('token', 'figi');
|
await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
await jest.advanceTimersByTimeAsync(1999);
|
|
||||||
expect(queue.run).not.toHaveBeenCalled();
|
expect(queue.run).not.toHaveBeenCalled();
|
||||||
queue.size = 49;
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(instrument);
|
|
||||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
|
||||||
expect(logger.event).toHaveBeenCalledWith(
|
|
||||||
'warn',
|
|
||||||
'tbank.rpc.retry',
|
|
||||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
|
|
||||||
);
|
|
||||||
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not bypass queue admission after an upstream failure', async () => {
|
it('does not bypass queue admission after an upstream failure', async () => {
|
||||||
@@ -221,46 +272,22 @@ describe('TbankClient metadata retries', () => {
|
|||||||
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
||||||
.mockResolvedValueOnce({ instrument });
|
.mockResolvedValueOnce({ instrument });
|
||||||
|
|
||||||
const result = client.getInstrumentByUid('token', 'uid');
|
const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error);
|
||||||
queue.size = 50;
|
queue.size = 50;
|
||||||
await jest.advanceTimersByTimeAsync(2000);
|
await jest.advanceTimersByTimeAsync(1000);
|
||||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
expect(queue.run).toHaveBeenCalledTimes(1);
|
||||||
queue.size = 0;
|
expect(await result).toBeInstanceOf(ServiceUnavailableException);
|
||||||
await jest.advanceTimersByTimeAsync(4000);
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(instrument);
|
|
||||||
expect(queue.run).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops retrying a saturated queue after three admission attempts', async () => {
|
|
||||||
queue.size = 50;
|
|
||||||
const result = expect(
|
|
||||||
client.getInstrumentByUid('token', 'uid'),
|
|
||||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
|
||||||
|
|
||||||
await jest.advanceTimersByTimeAsync(6000);
|
|
||||||
await result;
|
|
||||||
|
|
||||||
expect(queue.run).not.toHaveBeenCalled();
|
|
||||||
expect(logger.event).toHaveBeenCalledTimes(3);
|
|
||||||
expect(logger.event).toHaveBeenLastCalledWith(
|
|
||||||
'error',
|
|
||||||
'tbank.rpc.failed',
|
|
||||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
|
|
||||||
);
|
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stops retrying an unavailable upstream after three RPCs', async () => {
|
it('stops retrying an unavailable upstream after two RPCs', async () => {
|
||||||
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
||||||
const result = expect(
|
await Promise.all([
|
||||||
client.getInstrumentByUid('token', 'uid'),
|
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
||||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
jest.advanceTimersByTimeAsync(1000),
|
||||||
|
]);
|
||||||
|
|
||||||
await jest.advanceTimersByTimeAsync(6000);
|
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||||
await result;
|
|
||||||
|
|
||||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,6 +309,7 @@ describe('TbankClient metadata retries', () => {
|
|||||||
async (method) => {
|
async (method) => {
|
||||||
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
|
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
|
||||||
|
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
|
||||||
await expect(client[method]('token', 'instrument')).resolves.toBeNull();
|
await expect(client[method]('token', 'instrument')).resolves.toBeNull();
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
@@ -319,3 +347,141 @@ describe('TbankClient metadata retries', () => {
|
|||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TbankClient share catalog', () => {
|
||||||
|
const token = 'token';
|
||||||
|
const catalogSize = 40;
|
||||||
|
let client: TbankClient;
|
||||||
|
let call: jest.Mock;
|
||||||
|
let logger: {
|
||||||
|
event: jest.Mock<void, [string, string, Record<string, number | string>]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
call = jest.fn();
|
||||||
|
logger = {
|
||||||
|
event: jest.fn<void, [string, string, Record<string, number | string>]>(),
|
||||||
|
};
|
||||||
|
client = Object.assign(
|
||||||
|
Object.create(TbankClient.prototype) as TbankClient,
|
||||||
|
{ call, logger },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the full share catalog through Shares with INSTRUMENT_STATUS_ALL', async () => {
|
||||||
|
const instruments = [{ ticker: 'AFLT', uid: 'uid-aflt' }];
|
||||||
|
call.mockResolvedValue({ instruments });
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).resolves.toEqual(instruments);
|
||||||
|
expect(call).toHaveBeenCalledWith('token', undefined, 'shares', {
|
||||||
|
instrumentStatus: 'INSTRUMENT_STATUS_ALL',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the whole catalog including a fitting instrument beyond the first 30 text matches', async () => {
|
||||||
|
const catalog = Array.from({ length: catalogSize }, (_, index) => {
|
||||||
|
const fitting = index === catalogSize - 1;
|
||||||
|
return {
|
||||||
|
apiTradeAvailableFlag: fitting,
|
||||||
|
buyAvailableFlag: fitting,
|
||||||
|
ticker: fitting ? 'FIT' : `T${index}`,
|
||||||
|
uid: fitting ? 'uid-fit' : `uid-${index}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
call.mockResolvedValue({ instruments: catalog });
|
||||||
|
|
||||||
|
const shares = await client.getAllShares(token);
|
||||||
|
|
||||||
|
expect(shares).toEqual(catalog);
|
||||||
|
expect(shares).toHaveLength(catalogSize);
|
||||||
|
expect(shares).toContainEqual({
|
||||||
|
apiTradeAvailableFlag: true,
|
||||||
|
buyAvailableFlag: true,
|
||||||
|
ticker: 'FIT',
|
||||||
|
uid: 'uid-fit',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when the source refuses instead of returning an empty success', async () => {
|
||||||
|
const error = new ServiceUnavailableException({
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
call.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).rejects.toBe(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when the response carries no instrument list', async () => {
|
||||||
|
call.mockResolvedValue({});
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty instrument list with the public unavailable code', async () => {
|
||||||
|
call.mockResolvedValue({ instruments: [] });
|
||||||
|
|
||||||
|
const failure = await client
|
||||||
|
.getAllShares(token)
|
||||||
|
.catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(failure).toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
expect((failure as ServiceUnavailableException).getResponse()).toEqual({
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-list instrument payload as uninterpretable', async () => {
|
||||||
|
call.mockResolvedValue({ instruments: 'corrupted' });
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an uninterpretable entry without instrument identity', async () => {
|
||||||
|
call.mockResolvedValue({
|
||||||
|
instruments: [
|
||||||
|
{ ticker: 'FIT', uid: 'uid-fit' },
|
||||||
|
{ ticker: 'BROKEN' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers asOf and source instrument count on a successful catalog fetch', async () => {
|
||||||
|
call.mockResolvedValue({
|
||||||
|
instruments: [{ uid: 'uid-a' }, { uid: 'uid-b' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.getAllShares(token);
|
||||||
|
|
||||||
|
expect(logger.event).toHaveBeenCalledWith(
|
||||||
|
'info',
|
||||||
|
'tbank.shares.catalog.fetched',
|
||||||
|
expect.objectContaining({ count: 2, operation: 'shares' }),
|
||||||
|
);
|
||||||
|
expect(logger.event.mock.calls[0][2].asOf).toEqual(expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a distinguishable failure without secrets when the catalog is unusable', async () => {
|
||||||
|
call.mockResolvedValue({ instruments: [] });
|
||||||
|
|
||||||
|
await expect(client.getAllShares('secret-token')).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logger.event).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
'tbank.shares.catalog.unusable',
|
||||||
|
expect.objectContaining({ operation: 'shares' }),
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(logger.event.mock.calls)).not.toContain(
|
||||||
|
'secret-token',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
|
||||||
import {
|
import {
|
||||||
ChannelCredentials,
|
ChannelCredentials,
|
||||||
Client,
|
Client,
|
||||||
ClientUnaryCall,
|
ClientUnaryCall,
|
||||||
|
connectivityState,
|
||||||
loadPackageDefinition,
|
loadPackageDefinition,
|
||||||
Metadata,
|
Metadata,
|
||||||
ServiceError,
|
ServiceError,
|
||||||
@@ -17,8 +17,6 @@ import {
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
|
||||||
import { Environment } from '../../config/env.validation';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
TbankBond,
|
TbankBond,
|
||||||
@@ -29,11 +27,29 @@ import type {
|
|||||||
TbankShare,
|
TbankShare,
|
||||||
} from './tbank-market.types';
|
} from './tbank-market.types';
|
||||||
|
|
||||||
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
|
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||||
|
import { Environment } from '../../config/env.validation';
|
||||||
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
|
import {
|
||||||
|
InstrumentIdType,
|
||||||
|
TbankAccountStatus,
|
||||||
|
TbankCandleInterval,
|
||||||
|
TbankInstrumentStatus,
|
||||||
|
TbankInstrumentType,
|
||||||
|
TbankOperationState,
|
||||||
|
} from './tbank-protocol';
|
||||||
|
|
||||||
|
const INITIAL_REQUEST_COUNT = 0;
|
||||||
|
const MIN_RATE_DELAY_MS = 1;
|
||||||
|
const FIRST_ATTEMPT = 1;
|
||||||
|
const ATTEMPT_INCREMENT = 1;
|
||||||
|
const NO_RPC_ATTEMPTS = 0;
|
||||||
|
const NEXT_DAY_OFFSET = 1;
|
||||||
|
|
||||||
const RATE_WINDOW_MS = 1_000;
|
const RATE_WINDOW_MS = 1_000;
|
||||||
const METADATA_MAX_ATTEMPTS = 3;
|
|
||||||
const RPC_MAX_ATTEMPTS = 2;
|
const RPC_MAX_ATTEMPTS = 2;
|
||||||
const MAX_QUEUE_SIZE = 50;
|
const MAX_QUEUE_SIZE = 50;
|
||||||
const METADATA_RETRY_DELAY_MS = 2_000;
|
|
||||||
const RPC_RETRY_DELAY_MS = 1_000;
|
const RPC_RETRY_DELAY_MS = 1_000;
|
||||||
|
|
||||||
const requestDeadlineMs = 15_000;
|
const requestDeadlineMs = 15_000;
|
||||||
@@ -42,78 +58,85 @@ type Unary<Request, Response> = (
|
|||||||
request: Request,
|
request: Request,
|
||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
options: { deadline: Date },
|
options: { deadline: Date },
|
||||||
callback: (error: ServiceError | null, response: Response) => void,
|
callback: (error: null | ServiceError, response: Response) => void,
|
||||||
) => ClientUnaryCall;
|
) => ClientUnaryCall;
|
||||||
type TbankClientService = Client & Record<string, Unary<never, never>>;
|
type TbankClientService = Client & Record<string, Unary<never, never>>;
|
||||||
export type TbankDecimal = { units?: string | number; nano?: string | number };
|
type RpcDiagnostics = {
|
||||||
|
channelState?: string;
|
||||||
|
headersReceived?: boolean;
|
||||||
|
networkMs?: number;
|
||||||
|
queueWaitMs?: number;
|
||||||
|
trackingId?: string;
|
||||||
|
};
|
||||||
|
export type TbankDecimal = { nano?: number | string; units?: number | string };
|
||||||
export type TbankMoney = TbankDecimal & { currency?: string };
|
export type TbankMoney = TbankDecimal & { currency?: string };
|
||||||
export type TbankAccount = {
|
export type TbankAccount = {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
openedDate?: { seconds?: string | number };
|
openedDate?: { seconds?: number | string };
|
||||||
|
type: string;
|
||||||
};
|
};
|
||||||
export type TbankPortfolio = {
|
export type TbankPortfolio = {
|
||||||
positions?: {
|
positions?: {
|
||||||
figi?: string;
|
|
||||||
instrumentUid?: string;
|
|
||||||
instrumentType?: string;
|
|
||||||
ticker?: string;
|
|
||||||
quantity?: TbankDecimal;
|
|
||||||
currentPrice?: TbankMoney;
|
|
||||||
averagePositionPrice?: TbankMoney;
|
averagePositionPrice?: TbankMoney;
|
||||||
|
currentPrice?: TbankMoney;
|
||||||
|
figi?: string;
|
||||||
|
instrumentType?: string;
|
||||||
|
instrumentUid?: string;
|
||||||
|
quantity?: TbankDecimal;
|
||||||
|
ticker?: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
export type TbankPositions = { money?: TbankMoney[] };
|
export type TbankPositions = { money?: TbankMoney[] };
|
||||||
export type TbankInstrument = {
|
export type TbankInstrument = {
|
||||||
uid?: string;
|
brand?: { logoName?: null | string };
|
||||||
figi?: string;
|
figi?: string;
|
||||||
instrumentType?: string;
|
instrumentType?: string;
|
||||||
ticker?: string;
|
|
||||||
name?: string;
|
|
||||||
isin?: string;
|
isin?: string;
|
||||||
brand?: { logoName?: string | null };
|
name?: string;
|
||||||
|
ticker?: string;
|
||||||
|
uid?: string;
|
||||||
};
|
};
|
||||||
export type TbankDividend = {
|
export type TbankDividend = {
|
||||||
dividendNet?: TbankMoney;
|
dividendNet?: TbankMoney;
|
||||||
recordDate?: TbankTimestamp;
|
|
||||||
paymentDate?: TbankTimestamp;
|
|
||||||
lastBuyDate?: TbankTimestamp;
|
|
||||||
dividendType?: string;
|
dividendType?: string;
|
||||||
|
lastBuyDate?: TbankTimestamp;
|
||||||
|
paymentDate?: TbankTimestamp;
|
||||||
|
recordDate?: TbankTimestamp;
|
||||||
};
|
};
|
||||||
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
|
export type TbankTimestamp = { nanos?: number; seconds?: number | string };
|
||||||
export type TbankOperationItem = {
|
export type TbankOperationItem = {
|
||||||
state?: string;
|
accruedInt?: TbankMoney;
|
||||||
|
commission?: TbankMoney;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
date?: TbankTimestamp;
|
date?: TbankTimestamp;
|
||||||
type?: string;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
figi?: string;
|
figi?: string;
|
||||||
instrumentUid?: string;
|
id?: string;
|
||||||
instrumentType?: string;
|
instrumentType?: string;
|
||||||
ticker?: string;
|
instrumentUid?: string;
|
||||||
|
name?: string;
|
||||||
payment?: TbankMoney;
|
payment?: TbankMoney;
|
||||||
price?: TbankMoney;
|
price?: TbankMoney;
|
||||||
commission?: TbankMoney;
|
quantity?: number | string;
|
||||||
accruedInt?: TbankMoney;
|
quantityDone?: number | string;
|
||||||
quantity?: string | number;
|
state?: string;
|
||||||
quantityDone?: string | number;
|
ticker?: string;
|
||||||
|
type?: string;
|
||||||
};
|
};
|
||||||
export type TbankOperationsByCursorRequest = {
|
export type TbankOperationsByCursorRequest = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
instrumentId?: string;
|
|
||||||
from?: TbankTimestamp;
|
|
||||||
to?: TbankTimestamp;
|
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
from?: TbankTimestamp;
|
||||||
|
instrumentId?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
state: 'OPERATION_STATE_EXECUTED';
|
state: TbankOperationState.Executed;
|
||||||
|
to?: TbankTimestamp;
|
||||||
};
|
};
|
||||||
export type TbankOperationsByCursorResponse = {
|
export type TbankOperationsByCursorResponse = {
|
||||||
hasNext?: boolean;
|
hasNext?: boolean;
|
||||||
nextCursor?: string;
|
|
||||||
items?: TbankOperationItem[];
|
items?: TbankOperationItem[];
|
||||||
|
nextCursor?: string;
|
||||||
};
|
};
|
||||||
type TbankNamespace = {
|
type TbankNamespace = {
|
||||||
tinkoff: {
|
tinkoff: {
|
||||||
@@ -123,7 +146,7 @@ type TbankNamespace = {
|
|||||||
|
|
||||||
class RateLimiter {
|
class RateLimiter {
|
||||||
private readonly waiting: (() => void)[] = [];
|
private readonly waiting: (() => void)[] = [];
|
||||||
private starts = 0;
|
private starts = INITIAL_REQUEST_COUNT;
|
||||||
private windowStartedAt = Date.now();
|
private windowStartedAt = Date.now();
|
||||||
|
|
||||||
constructor(private readonly perSecond: number) {}
|
constructor(private readonly perSecond: number) {}
|
||||||
@@ -148,16 +171,16 @@ class RateLimiter {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
|
if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
|
||||||
this.windowStartedAt = now;
|
this.windowStartedAt = now;
|
||||||
this.starts = 0;
|
this.starts = INITIAL_REQUEST_COUNT;
|
||||||
}
|
}
|
||||||
while (this.starts < this.perSecond && this.waiting.length) {
|
while (this.starts < this.perSecond && this.waiting.length) {
|
||||||
this.starts += 1;
|
this.starts++;
|
||||||
this.waiting.shift()?.();
|
this.waiting.shift()?.();
|
||||||
}
|
}
|
||||||
if (this.waiting.length && this.starts === this.perSecond) {
|
if (this.waiting.length && this.starts === this.perSecond) {
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => this.drain(),
|
() => this.drain(),
|
||||||
Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)),
|
Math.max(MIN_RATE_DELAY_MS, RATE_WINDOW_MS - (now - this.windowStartedAt)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,11 +221,13 @@ export class TbankClient {
|
|||||||
).tinkoff.public.invest.api.contract.v1;
|
).tinkoff.public.invest.api.contract.v1;
|
||||||
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
|
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
|
||||||
const credentials = caPath
|
const credentials = caPath
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request.
|
||||||
? ChannelCredentials.createSsl(readFileSync(caPath))
|
? ChannelCredentials.createSsl(readFileSync(caPath))
|
||||||
: ChannelCredentials.createSsl();
|
: ChannelCredentials.createSsl();
|
||||||
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
|
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
|
||||||
const create = (name: string) =>
|
const create = (name: string) =>
|
||||||
new (
|
new (
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
|
||||||
services[name] as new (
|
services[name] as new (
|
||||||
address: string,
|
address: string,
|
||||||
credentials: ChannelCredentials,
|
credentials: ChannelCredentials,
|
||||||
@@ -246,7 +271,7 @@ export class TbankClient {
|
|||||||
token,
|
token,
|
||||||
this.users,
|
this.users,
|
||||||
'getAccounts',
|
'getAccounts',
|
||||||
{ status: 'ACCOUNT_STATUS_OPEN' },
|
{ status: TbankAccountStatus.Open },
|
||||||
).then((response) => response.accounts ?? []);
|
).then((response) => response.accounts ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,15 +293,15 @@ export class TbankClient {
|
|||||||
getInstrumentByFigi(
|
getInstrumentByFigi(
|
||||||
token: string,
|
token: string,
|
||||||
figi: string,
|
figi: string,
|
||||||
): Promise<TbankInstrument | null> {
|
): Promise<null | TbankInstrument> {
|
||||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
|
return this.getInstrument(token, InstrumentIdType.Figi, figi);
|
||||||
}
|
}
|
||||||
|
|
||||||
getInstrumentByUid(
|
getInstrumentByUid(
|
||||||
token: string,
|
token: string,
|
||||||
instrumentUid: string,
|
instrumentUid: string,
|
||||||
): Promise<TbankInstrument | null> {
|
): Promise<null | TbankInstrument> {
|
||||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
|
return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid);
|
||||||
}
|
}
|
||||||
|
|
||||||
getDividends(
|
getDividends(
|
||||||
@@ -304,11 +329,40 @@ export class TbankClient {
|
|||||||
'findInstrument',
|
'findInstrument',
|
||||||
{
|
{
|
||||||
query,
|
query,
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
instrumentKind: TbankInstrumentType.Share,
|
||||||
},
|
},
|
||||||
).then((response) => response.instruments ?? []);
|
).then((response) => response.instruments ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllShares(token: string): Promise<TbankShare[]> {
|
||||||
|
const response = await this.call<{ instruments?: TbankShare[] }>(
|
||||||
|
token,
|
||||||
|
this.instruments,
|
||||||
|
'shares',
|
||||||
|
{ instrumentStatus: TbankInstrumentStatus.All },
|
||||||
|
);
|
||||||
|
const { instruments } = response;
|
||||||
|
if (
|
||||||
|
!Array.isArray(instruments) ||
|
||||||
|
!instruments.length ||
|
||||||
|
instruments.some((item) => !item?.uid)
|
||||||
|
) {
|
||||||
|
this.logger.event('error', 'tbank.shares.catalog.unusable', {
|
||||||
|
code: TbankErrorCode.Unavailable,
|
||||||
|
operation: 'shares',
|
||||||
|
});
|
||||||
|
throw new ServiceUnavailableException({
|
||||||
|
code: TbankErrorCode.Unavailable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logger.event('info', 'tbank.shares.catalog.fetched', {
|
||||||
|
asOf: new Date().toISOString(),
|
||||||
|
count: instruments.length,
|
||||||
|
operation: 'shares',
|
||||||
|
});
|
||||||
|
return instruments;
|
||||||
|
}
|
||||||
|
|
||||||
findBonds(token: string, query: string): Promise<TbankBond[]> {
|
findBonds(token: string, query: string): Promise<TbankBond[]> {
|
||||||
return this.call<{ instruments?: TbankBond[] }>(
|
return this.call<{ instruments?: TbankBond[] }>(
|
||||||
token,
|
token,
|
||||||
@@ -316,21 +370,23 @@ export class TbankClient {
|
|||||||
'findInstrument',
|
'findInstrument',
|
||||||
{
|
{
|
||||||
query,
|
query,
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_BOND',
|
instrumentKind: TbankInstrumentType.Bond,
|
||||||
},
|
},
|
||||||
).then((response) => response.instruments ?? []);
|
).then((response) => response.instruments ?? []);
|
||||||
}
|
}
|
||||||
getBond(token: string, uid: string): Promise<TbankBond | null> {
|
|
||||||
|
getBond(token: string, uid: string): Promise<null | TbankBond> {
|
||||||
return this.call<{ instrument?: TbankBond }>(
|
return this.call<{ instrument?: TbankBond }>(
|
||||||
token,
|
token,
|
||||||
this.sparseInstruments,
|
this.sparseInstruments,
|
||||||
'bondBy',
|
'bondBy',
|
||||||
{
|
{
|
||||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
idType: InstrumentIdType.Uid,
|
||||||
id: uid,
|
id: uid,
|
||||||
},
|
},
|
||||||
).then((response) => response.instrument ?? null);
|
).then((response) => response.instrument ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBondCoupons(
|
getBondCoupons(
|
||||||
token: string,
|
token: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
@@ -349,19 +405,19 @@ export class TbankClient {
|
|||||||
).then((response) => response.events ?? []);
|
).then((response) => response.events ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
getShare(token: string, uid: string): Promise<TbankShare | null> {
|
getShare(token: string, uid: string): Promise<null | TbankShare> {
|
||||||
return this.call<{ instrument?: TbankShare }>(
|
return this.call<{ instrument?: TbankShare }>(
|
||||||
token,
|
token,
|
||||||
this.instruments,
|
this.instruments,
|
||||||
'shareBy',
|
'shareBy',
|
||||||
{
|
{
|
||||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
idType: InstrumentIdType.Uid,
|
||||||
id: uid,
|
id: uid,
|
||||||
},
|
},
|
||||||
).then((response) => response.instrument ?? null);
|
).then((response) => response.instrument ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAssetDescription(token: string, assetUid: string): Promise<string | null> {
|
getAssetDescription(token: string, assetUid: string): Promise<null | string> {
|
||||||
return this.call<{ asset?: { description?: string } }>(
|
return this.call<{ asset?: { description?: string } }>(
|
||||||
token,
|
token,
|
||||||
this.instruments,
|
this.instruments,
|
||||||
@@ -375,7 +431,7 @@ export class TbankClient {
|
|||||||
getFundamentals(
|
getFundamentals(
|
||||||
token: string,
|
token: string,
|
||||||
assetUid: string,
|
assetUid: string,
|
||||||
): Promise<TbankFundamentals | null> {
|
): Promise<null | TbankFundamentals> {
|
||||||
return this.call<{ fundamentals?: TbankFundamentals[] }>(
|
return this.call<{ fundamentals?: TbankFundamentals[] }>(
|
||||||
token,
|
token,
|
||||||
this.sparseInstruments,
|
this.sparseInstruments,
|
||||||
@@ -399,13 +455,7 @@ export class TbankClient {
|
|||||||
).then((response) => response.lastPrices ?? []);
|
).then((response) => response.lastPrices ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
getLastPrice(token: string, uid: string): Promise<TbankLastPrice | null> {
|
getTradingStatus(token: string, uid: string): Promise<null | string> {
|
||||||
return this.getLastPrices(token, [uid]).then(
|
|
||||||
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getTradingStatus(token: string, uid: string): Promise<string | null> {
|
|
||||||
return this.call<{ tradingStatus?: string }>(
|
return this.call<{ tradingStatus?: string }>(
|
||||||
token,
|
token,
|
||||||
this.marketData,
|
this.marketData,
|
||||||
@@ -434,7 +484,7 @@ export class TbankClient {
|
|||||||
to: {
|
to: {
|
||||||
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
|
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
|
||||||
},
|
},
|
||||||
interval: 'CANDLE_INTERVAL_DAY',
|
interval: TbankCandleInterval.Day,
|
||||||
limit: 2400,
|
limit: 2400,
|
||||||
},
|
},
|
||||||
).then((response) => response.candles ?? []);
|
).then((response) => response.candles ?? []);
|
||||||
@@ -442,9 +492,9 @@ export class TbankClient {
|
|||||||
|
|
||||||
private async getInstrument(
|
private async getInstrument(
|
||||||
token: string,
|
token: string,
|
||||||
idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI',
|
idType: InstrumentIdType,
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<TbankInstrument | null> {
|
): Promise<null | TbankInstrument> {
|
||||||
try {
|
try {
|
||||||
const response = await this.call<{ instrument?: TbankInstrument }>(
|
const response = await this.call<{ instrument?: TbankInstrument }>(
|
||||||
token,
|
token,
|
||||||
@@ -466,18 +516,26 @@ export class TbankClient {
|
|||||||
request: object,
|
request: object,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const metadataCall = method === 'getInstrumentBy';
|
const metadataCall = method === 'getInstrumentBy';
|
||||||
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
|
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
|
||||||
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
|
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||||
|
const diagnostics: RpcDiagnostics = {};
|
||||||
try {
|
try {
|
||||||
if (queueFull)
|
if (queueFull) {
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||||
const response = await this.queue.run(() =>
|
}
|
||||||
this.request<Response>(token, client, method, request),
|
const response = await this.queue.run(() => {
|
||||||
);
|
const sentAt = performance.now();
|
||||||
|
diagnostics.queueWaitMs = Math.round(sentAt - startedAt);
|
||||||
|
return this.request<Response>(
|
||||||
|
token, client, method, request, diagnostics,
|
||||||
|
).finally(() => {
|
||||||
|
diagnostics.networkMs = Math.round(performance.now() - sentAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
this.logger.event('debug', 'tbank.rpc.completed', {
|
this.logger.event('debug', 'tbank.rpc.completed', {
|
||||||
operation: method,
|
operation: method,
|
||||||
|
...diagnostics,
|
||||||
attempts,
|
attempts,
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
});
|
});
|
||||||
@@ -485,52 +543,47 @@ export class TbankClient {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const grpcCode = (error as ServiceError).code;
|
const grpcCode = (error as ServiceError).code;
|
||||||
const failureContext = queueFull
|
const failureContext = queueFull
|
||||||
? { code: 'TBANK_UNAVAILABLE' }
|
? { code: TbankErrorCode.Unavailable }
|
||||||
: { grpcCode };
|
: { grpcCode };
|
||||||
const retryable = queueFull
|
const retryable = !queueFull && [
|
||||||
? metadataCall
|
|
||||||
: [
|
|
||||||
status.UNAVAILABLE,
|
|
||||||
status.DEADLINE_EXCEEDED,
|
status.DEADLINE_EXCEEDED,
|
||||||
status.RESOURCE_EXHAUSTED,
|
status.RESOURCE_EXHAUSTED,
|
||||||
|
status.UNAVAILABLE,
|
||||||
].includes(grpcCode);
|
].includes(grpcCode);
|
||||||
if (retryable && attempts < maxAttempts) {
|
if (retryable && attempts < RPC_MAX_ATTEMPTS) {
|
||||||
this.logger.event('warn', 'tbank.rpc.retry', {
|
this.logger.event('warn', 'tbank.rpc.retry', {
|
||||||
operation: method,
|
operation: method,
|
||||||
|
...diagnostics,
|
||||||
...failureContext,
|
...failureContext,
|
||||||
attempts,
|
attempts,
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
});
|
});
|
||||||
await new Promise((resolve) =>
|
await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS));
|
||||||
setTimeout(
|
|
||||||
resolve,
|
|
||||||
metadataCall
|
|
||||||
? attempts * METADATA_RETRY_DELAY_MS
|
|
||||||
: RPC_RETRY_DELAY_MS,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.logger.event('error', 'tbank.rpc.failed', {
|
this.logger.event('error', 'tbank.rpc.failed', {
|
||||||
operation: method,
|
operation: method,
|
||||||
|
...diagnostics,
|
||||||
...failureContext,
|
...failureContext,
|
||||||
attempts: queueFull && !metadataCall ? 0 : attempts,
|
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
[status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode)
|
[status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
|
||||||
)
|
) {
|
||||||
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
|
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
|
||||||
throw new Error('INVALID_TBANK_TOKEN');
|
throw new Error(TbankErrorCode.InvalidToken);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
grpcCode === status.NOT_FOUND &&
|
grpcCode === status.NOT_FOUND &&
|
||||||
(method === 'shareBy' || metadataCall)
|
(method === 'shareBy' || metadataCall)
|
||||||
)
|
) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
}
|
||||||
|
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
|
|
||||||
private request<Response>(
|
private request<Response>(
|
||||||
@@ -538,16 +591,42 @@ export class TbankClient {
|
|||||||
client: TbankClientService,
|
client: TbankClientService,
|
||||||
method: string,
|
method: string,
|
||||||
request: object,
|
request: object,
|
||||||
|
diagnostics?: RpcDiagnostics,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const metadata = new Metadata();
|
const metadata = new Metadata();
|
||||||
metadata.set('Authorization', `Bearer ${token}`);
|
metadata.set('Authorization', `Bearer ${token}`);
|
||||||
client[method](
|
// A channel state is a snapshot, not proof that this particular call reached the server.
|
||||||
|
const channel = diagnostics && client.getChannel();
|
||||||
|
if (diagnostics) diagnostics.headersReceived = false;
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
|
||||||
|
const call = client[method](
|
||||||
request as never,
|
request as never,
|
||||||
metadata,
|
metadata,
|
||||||
{ deadline: new Date(Date.now() + requestDeadlineMs) },
|
{ deadline: new Date(Date.now() + requestDeadlineMs) },
|
||||||
(error, response) => (error ? reject(error) : resolve(response)),
|
(error, response) => {
|
||||||
|
if (diagnostics && channel) {
|
||||||
|
diagnostics.channelState = connectivityState[
|
||||||
|
channel.getConnectivityState(false)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(response);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
if (diagnostics) {
|
||||||
|
call.on('metadata', (headers: Metadata) => {
|
||||||
|
diagnostics.headersReceived = true;
|
||||||
|
const trackingId = headers.get('x-tracking-id')[0];
|
||||||
|
// Only a UUID may enter logs; never forward arbitrary upstream metadata.
|
||||||
|
if (
|
||||||
|
typeof trackingId === 'string' &&
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trackingId)
|
||||||
|
) {
|
||||||
|
diagnostics.trackingId = trackingId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -555,6 +634,6 @@ export class TbankClient {
|
|||||||
// Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)).
|
// Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)).
|
||||||
function utcSeconds(date: string, nextDay = false): number {
|
function utcSeconds(date: string, nextDay = false): number {
|
||||||
const instant = new Date(`${date}T00:00:00Z`);
|
const instant = new Date(`${date}T00:00:00Z`);
|
||||||
if (nextDay) instant.setUTCDate(instant.getUTCDate() + 1);
|
if (nextDay) instant.setUTCDate(instant.getUTCDate() + NEXT_DAY_OFFSET);
|
||||||
return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND);
|
return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const VERSION_SIZE_BYTES = 1;
|
||||||
|
|
||||||
const KEY_SIZE_BYTES = 32;
|
const KEY_SIZE_BYTES = 32;
|
||||||
|
|
||||||
const VERSION = 1;
|
const VERSION = 1;
|
||||||
@@ -11,8 +13,9 @@ export class TbankCrypto {
|
|||||||
|
|
||||||
static fromBase64(key: string): TbankCrypto {
|
static fromBase64(key: string): TbankCrypto {
|
||||||
const decoded = Buffer.from(key, 'base64');
|
const decoded = Buffer.from(key, 'base64');
|
||||||
if (decoded.length !== KEY_SIZE_BYTES)
|
if (decoded.length !== KEY_SIZE_BYTES) {
|
||||||
throw new Error('Invalid token encryption key');
|
throw new Error('Invalid token encryption key');
|
||||||
|
}
|
||||||
return new TbankCrypto(decoded);
|
return new TbankCrypto(decoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,15 +36,15 @@ export class TbankCrypto {
|
|||||||
|
|
||||||
decrypt(payload: string): string {
|
decrypt(payload: string): string {
|
||||||
const bytes = Buffer.from(payload, 'base64');
|
const bytes = Buffer.from(payload, 'base64');
|
||||||
if (bytes[0] !== VERSION || bytes.length < 1 + NONCE_SIZE + TAG_SIZE) {
|
if (bytes[0] !== VERSION || bytes.length < VERSION_SIZE_BYTES + NONCE_SIZE + TAG_SIZE) {
|
||||||
throw new Error('Invalid encrypted token');
|
throw new Error('Invalid encrypted token');
|
||||||
}
|
}
|
||||||
const nonce = bytes.subarray(1, 1 + NONCE_SIZE);
|
const nonce = bytes.subarray(VERSION_SIZE_BYTES, VERSION_SIZE_BYTES + NONCE_SIZE);
|
||||||
const tag = bytes.subarray(-TAG_SIZE);
|
const tag = bytes.subarray(-TAG_SIZE);
|
||||||
const decipher = createDecipheriv('aes-256-gcm', this.key, nonce);
|
const decipher = createDecipheriv('aes-256-gcm', this.key, nonce);
|
||||||
decipher.setAuthTag(tag);
|
decipher.setAuthTag(tag);
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
decipher.update(bytes.subarray(1 + NONCE_SIZE, -TAG_SIZE)),
|
decipher.update(bytes.subarray(VERSION_SIZE_BYTES + NONCE_SIZE, -TAG_SIZE)),
|
||||||
decipher.final(),
|
decipher.final(),
|
||||||
]).toString('utf8');
|
]).toString('utf8');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TbankMembershipService } from './tbank-membership.service';
|
|
||||||
import { InstrumentMetadataRepository } from './instrument-metadata.repository';
|
import { InstrumentMetadataRepository } from './instrument-metadata.repository';
|
||||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
|
import { TbankMembershipService } from './tbank-membership.service';
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
|
|||||||
+6
-7
@@ -3,18 +3,18 @@ import {
|
|||||||
INestApplication,
|
INestApplication,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
|
||||||
import cookieParser from 'cookie-parser';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { SwaggerModule } from '@nestjs/swagger';
|
import { SwaggerModule } from '@nestjs/swagger';
|
||||||
import { API_PREFIX, createOpenApiDocument } from './openapi';
|
import cookieParser from 'cookie-parser';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
|
|
||||||
import { StructuredLogger } from './common/logging/structured-logger.service';
|
import { StructuredLogger } from './common/logging/structured-logger.service';
|
||||||
import { Environment } from './config/env.validation';
|
import { Environment } from './config/env.validation';
|
||||||
import { shouldExposeSwagger } from './config/swagger.config';
|
import { shouldExposeSwagger } from './config/swagger.config';
|
||||||
|
import { API_PREFIX, createOpenApiDocument } from './openapi';
|
||||||
|
|
||||||
export function configureApp(app: INestApplication): void {
|
export function configureApp(app: INestApplication): void {
|
||||||
const config = app.get<ConfigService<Environment, true>>(ConfigService);
|
const config = app.get<ConfigService<Environment, true>>(ConfigService);
|
||||||
@@ -28,17 +28,16 @@ export function configureApp(app: INestApplication): void {
|
|||||||
});
|
});
|
||||||
app.setGlobalPrefix(API_PREFIX);
|
app.setGlobalPrefix(API_PREFIX);
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
const requestLogging = app.get(RequestLoggingMiddleware);
|
|
||||||
app.use(requestLogging.use.bind(requestLogging));
|
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
|
forbidUnknownValues: true,
|
||||||
transform: true,
|
transform: true,
|
||||||
whitelist: true,
|
whitelist: true,
|
||||||
validationError: { target: false, value: false },
|
validationError: { target: false, value: false },
|
||||||
exceptionFactory: (message) => new BadRequestException({ message }),
|
exceptionFactory: (message) => new BadRequestException({ message }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter(app.get(StructuredLogger)));
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum AuthErrorCode {
|
||||||
|
EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED',
|
||||||
|
InvalidCredentials = 'INVALID_CREDENTIALS',
|
||||||
|
}
|
||||||
@@ -114,8 +114,8 @@ describe('AuthService', () => {
|
|||||||
where,
|
where,
|
||||||
data,
|
data,
|
||||||
}: {
|
}: {
|
||||||
where: { id?: string; refreshTokenHash?: string };
|
|
||||||
data: { refreshTokenHash: string };
|
data: { refreshTokenHash: string };
|
||||||
|
where: { id?: string; refreshTokenHash?: string };
|
||||||
}) => {
|
}) => {
|
||||||
if (where.id === session.id && where.refreshTokenHash === currentHash) {
|
if (where.id === session.id && where.refreshTokenHash === currentHash) {
|
||||||
currentHash = data.refreshTokenHash;
|
currentHash = data.refreshTokenHash;
|
||||||
|
|||||||
@@ -6,16 +6,23 @@ import {
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { createHash, randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
|
||||||
import { Session, User } from '../../../../generated/prisma/client';
|
import { Session, User } from '../../../../generated/prisma/client';
|
||||||
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
|
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { AuthUser, RefreshPayload } from './auth.types';
|
import { AuthErrorCode } from './auth-error-code';
|
||||||
|
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
||||||
|
|
||||||
|
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||||
|
|
||||||
const PASSWORD_HASH_ROUNDS = 12;
|
const PASSWORD_HASH_ROUNDS = 12;
|
||||||
const REFRESH_TTL_MS = 604_800_000;
|
export const REFRESH_TTL_MS = 604_800_000;
|
||||||
|
|
||||||
const ACCESS_TTL = '15m';
|
const ACCESS_TTL = '15m';
|
||||||
const REFRESH_TTL = '7d';
|
const REFRESH_TTL = '7d';
|
||||||
const DUMMY_PASSWORD_HASH =
|
const DUMMY_PASSWORD_HASH =
|
||||||
|
// eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Public dummy bcrypt hash preserves login timing for nonexistent users; it is not a credential.
|
||||||
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
|
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -28,12 +35,13 @@ export class AuthService {
|
|||||||
async register(
|
async register(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
): Promise<AuthenticationResult> {
|
||||||
const existing = await this.prisma.user.findUnique({
|
const existing = await this.prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
if (existing)
|
if (existing) {
|
||||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||||
|
}
|
||||||
|
|
||||||
let user: User;
|
let user: User;
|
||||||
try {
|
try {
|
||||||
@@ -45,8 +53,9 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUniqueConstraintError(error))
|
if (isUniqueConstraintError(error)) {
|
||||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return this.createSession(user);
|
return this.createSession(user);
|
||||||
@@ -55,7 +64,7 @@ export class AuthService {
|
|||||||
async login(
|
async login(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
): Promise<AuthenticationResult> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
@@ -63,24 +72,26 @@ export class AuthService {
|
|||||||
password,
|
password,
|
||||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||||
);
|
);
|
||||||
if (!user || !valid)
|
if (!user || !valid) {
|
||||||
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
|
throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials });
|
||||||
|
}
|
||||||
return this.createSession(user);
|
return this.createSession(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh(
|
async refresh(
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
): Promise<AuthenticationResult> {
|
||||||
let payload: RefreshPayload;
|
let payload: RefreshPayload;
|
||||||
try {
|
try {
|
||||||
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||||
algorithms: ['HS256'],
|
algorithms: ['HS256'],
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
|
}
|
||||||
|
if (payload.type !== 'refresh') {
|
||||||
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
if (payload.type !== 'refresh')
|
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
|
||||||
const session = await this.prisma.session.findUnique({
|
const session = await this.prisma.session.findUnique({
|
||||||
where: { id: payload.sid },
|
where: { id: payload.sid },
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
@@ -91,12 +102,14 @@ export class AuthService {
|
|||||||
session.revokedAt ||
|
session.revokedAt ||
|
||||||
session.expiresAt <= new Date()
|
session.expiresAt <= new Date()
|
||||||
) {
|
) {
|
||||||
if (session && !session.revokedAt)
|
if (session && !session.revokedAt) {
|
||||||
await this.prisma.session.delete({ where: { id: session.id } });
|
await this.prisma.session.delete({ where: { id: session.id } });
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
|
||||||
}
|
}
|
||||||
if (session.refreshTokenHash !== this.hash(token))
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
}
|
||||||
|
if (session.refreshTokenHash !== this.hash(token)) {
|
||||||
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
|
}
|
||||||
return this.rotateSession(session, session.refreshTokenHash);
|
return this.rotateSession(session, session.refreshTokenHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,13 +145,13 @@ export class AuthService {
|
|||||||
|
|
||||||
async userById(id: string): Promise<User> {
|
async userById(id: string): Promise<User> {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createSession(
|
private async createSession(
|
||||||
user: User,
|
user: User,
|
||||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
): Promise<AuthenticationResult> {
|
||||||
const session = await this.prisma.session.create({
|
const session = await this.prisma.session.create({
|
||||||
data: {
|
data: {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -153,7 +166,7 @@ export class AuthService {
|
|||||||
private async rotateSession(
|
private async rotateSession(
|
||||||
session: Session & { user: User },
|
session: Session & { user: User },
|
||||||
expectedRefreshTokenHash?: string,
|
expectedRefreshTokenHash?: string,
|
||||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
): Promise<AuthenticationResult> {
|
||||||
const refreshToken = await this.jwt.signAsync(
|
const refreshToken = await this.jwt.signAsync(
|
||||||
{
|
{
|
||||||
sub: session.userId,
|
sub: session.userId,
|
||||||
@@ -172,8 +185,9 @@ export class AuthService {
|
|||||||
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
|
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
if (updated.count !== 1)
|
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.prisma.session.update({ where: { id: session.id }, data });
|
await this.prisma.session.update({ where: { id: session.id }, data });
|
||||||
}
|
}
|
||||||
@@ -197,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean {
|
|||||||
typeof error === 'object' &&
|
typeof error === 'object' &&
|
||||||
error !== null &&
|
error !== null &&
|
||||||
'code' in error &&
|
'code' in error &&
|
||||||
(error as { code?: unknown }).code === 'P2002'
|
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
|
||||||
email: string;
|
email: string;
|
||||||
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthenticationResult = {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: AuthUser;
|
||||||
|
};
|
||||||
|
|
||||||
export interface AccessPayload {
|
export interface AccessPayload {
|
||||||
sub: string;
|
|
||||||
sid: string;
|
sid: string;
|
||||||
|
sub: string;
|
||||||
type: 'access';
|
type: 'access';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RefreshPayload {
|
export interface RefreshPayload {
|
||||||
sub: string;
|
|
||||||
sid: string;
|
|
||||||
type: 'refresh';
|
|
||||||
jti: string;
|
jti: string;
|
||||||
|
sid: string;
|
||||||
|
sub: string;
|
||||||
|
type: 'refresh';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
|
||||||
import { Environment } from '../../config/env.validation';
|
import { Environment } from '../../config/env.validation';
|
||||||
import { AuthController } from './presentation/auth.controller';
|
|
||||||
import { AuthService } from './application/auth.service';
|
import { AuthService } from './application/auth.service';
|
||||||
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
|
||||||
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
||||||
|
import { AuthController } from './presentation/auth.controller';
|
||||||
|
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
import { Environment } from '../../../config/env.validation';
|
import { Environment } from '../../../config/env.validation';
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
|
|
||||||
@@ -17,8 +19,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validate(payload: AccessPayload): AccessPayload {
|
validate(payload: AccessPayload): AccessPayload {
|
||||||
if (payload.type !== 'access')
|
if (payload.type !== 'access') {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -17,11 +19,12 @@ import {
|
|||||||
ApiProperty,
|
ApiProperty,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { CredentialsDto } from './auth.dto';
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
import { AuthService } from '../application/auth.service';
|
import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
||||||
|
import { LoginDto, RegisterDto } from './auth.dto';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
const REFRESH_COOKIE = 'refresh_token';
|
const REFRESH_COOKIE = 'refresh_token';
|
||||||
@@ -53,7 +56,7 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOkResponse({ type: AuthenticationDto })
|
@ApiOkResponse({ type: AuthenticationDto })
|
||||||
async register(
|
async register(
|
||||||
@Body() credentials: CredentialsDto,
|
@Body() credentials: RegisterDto,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
return this.respond(
|
return this.respond(
|
||||||
@@ -66,7 +69,7 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOkResponse({ type: AuthenticationDto })
|
@ApiOkResponse({ type: AuthenticationDto })
|
||||||
async login(
|
async login(
|
||||||
@Body() credentials: CredentialsDto,
|
@Body() credentials: LoginDto,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
return this.respond(
|
return this.respond(
|
||||||
@@ -82,14 +85,9 @@ export class AuthController {
|
|||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
try {
|
|
||||||
const token = this.refreshToken(request);
|
const token = this.refreshToken(request);
|
||||||
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
return this.respond(response, await this.auth.refresh(token));
|
return this.respond(response, await this.auth.refresh(token));
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UnauthorizedException) this.clearCookie(response);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@@ -114,11 +112,7 @@ export class AuthController {
|
|||||||
|
|
||||||
private respond(
|
private respond(
|
||||||
response: Response,
|
response: Response,
|
||||||
result: {
|
result: AuthenticationResult,
|
||||||
user: { id: string; email: string };
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
||||||
return { user: result.user, accessToken: result.accessToken };
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
@@ -129,6 +123,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private refreshToken(request: Request): string | undefined {
|
private refreshToken(request: Request): string | undefined {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- The cookie name is a fixed application constant; its value is narrowed below.
|
||||||
const token: unknown = request.cookies?.[REFRESH_COOKIE];
|
const token: unknown = request.cookies?.[REFRESH_COOKIE];
|
||||||
return typeof token === 'string' ? token : undefined;
|
return typeof token === 'string' ? token : undefined;
|
||||||
}
|
}
|
||||||
@@ -139,6 +134,7 @@ export class AuthController {
|
|||||||
sameSite: 'lax' as const,
|
sameSite: 'lax' as const,
|
||||||
secure: process.env['NODE_ENV'] !== 'development',
|
secure: process.env['NODE_ENV'] !== 'development',
|
||||||
path: COOKIE_PATH,
|
path: COOKIE_PATH,
|
||||||
|
maxAge: REFRESH_TTL_MS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
IsString,
|
IsString,
|
||||||
MinLength,
|
Matches,
|
||||||
Validate,
|
Validate,
|
||||||
ValidatorConstraint,
|
ValidatorConstraint,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
const EMAIL_PART_COUNT = 2;
|
||||||
|
const FIRST_CHARACTER_INDEX = 0;
|
||||||
|
|
||||||
const MAX_EMAIL_LENGTH = 254;
|
const MAX_EMAIL_LENGTH = 254;
|
||||||
const ASCII_SPACE = 32;
|
const ASCII_SPACE = 32;
|
||||||
const ASCII_DELETE = 127;
|
const ASCII_DELETE = 127;
|
||||||
const MIN_PASSWORD_LENGTH = 12;
|
// bcrypt reads at most 72 bytes; printable ASCII keeps characters == bytes.
|
||||||
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
const MAX_PASSWORD_BYTES = 72;
|
const MAX_PASSWORD_BYTES = 72;
|
||||||
|
const PASSWORD_PATTERN = `^[\\x20-\\x7E]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_BYTES}}$`;
|
||||||
|
|
||||||
@ValidatorConstraint({ name: 'email' })
|
@ValidatorConstraint({ name: 'email' })
|
||||||
class EmailConstraint {
|
class EmailConstraint {
|
||||||
@@ -19,37 +25,40 @@ class EmailConstraint {
|
|||||||
return (
|
return (
|
||||||
typeof value === 'string' &&
|
typeof value === 'string' &&
|
||||||
value.length <= MAX_EMAIL_LENGTH &&
|
value.length <= MAX_EMAIL_LENGTH &&
|
||||||
value.split('@').length === 2 &&
|
value.split('@').length === EMAIL_PART_COUNT &&
|
||||||
!value
|
!value
|
||||||
.split('')
|
.split('')
|
||||||
.some(
|
.some(
|
||||||
(character) =>
|
(character) =>
|
||||||
/\s/.test(character) ||
|
/\s/.test(character) ||
|
||||||
character.charCodeAt(0) < ASCII_SPACE ||
|
character.charCodeAt(FIRST_CHARACTER_INDEX) < ASCII_SPACE ||
|
||||||
character.charCodeAt(0) === ASCII_DELETE,
|
character.charCodeAt(FIRST_CHARACTER_INDEX) === ASCII_DELETE,
|
||||||
) &&
|
) &&
|
||||||
value.split('@').every(Boolean)
|
value.split('@').every(Boolean)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultMessage(): string {
|
defaultMessage(): string {
|
||||||
return 'must be a valid email address';
|
return 'must be a valid email address';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ValidatorConstraint({ name: 'passwordBytes' })
|
// Login stays lenient: accounts created under an older policy must still sign in.
|
||||||
class PasswordBytesConstraint {
|
@ValidatorConstraint({ name: 'passwordMaxBytes' })
|
||||||
|
class PasswordMaxBytesConstraint {
|
||||||
validate(value: unknown): boolean {
|
validate(value: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
typeof value === 'string' &&
|
typeof value === 'string' &&
|
||||||
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
|
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultMessage(): string {
|
defaultMessage(): string {
|
||||||
return 'must not exceed 72 UTF-8 bytes';
|
return `must not exceed ${MAX_PASSWORD_BYTES} UTF-8 bytes`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CredentialsDto {
|
class EmailDto {
|
||||||
@ApiProperty({ example: 'user@example.com' })
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Transform(({ value }: { value: unknown }) =>
|
@Transform(({ value }: { value: unknown }) =>
|
||||||
@@ -57,14 +66,28 @@ export class CredentialsDto {
|
|||||||
)
|
)
|
||||||
@Validate(EmailConstraint)
|
@Validate(EmailConstraint)
|
||||||
declare email: string;
|
declare email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RegisterDto extends EmailDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
minLength: MIN_PASSWORD_LENGTH,
|
description: `Raw password: ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.`,
|
||||||
maxLength: MAX_PASSWORD_BYTES,
|
pattern: PASSWORD_PATTERN,
|
||||||
example: 'a secure password',
|
example: 'a secure password',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(MIN_PASSWORD_LENGTH)
|
@Matches(new RegExp(PASSWORD_PATTERN), {
|
||||||
@Validate(PasswordBytesConstraint)
|
message: `must be ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters`,
|
||||||
|
})
|
||||||
|
declare password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LoginDto extends EmailDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: `Raw password, at most ${MAX_PASSWORD_BYTES} UTF-8 bytes. Not trimmed or normalized.`,
|
||||||
|
example: 'a secure password',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Validate(PasswordMaxBytesConstraint)
|
||||||
declare password: string;
|
declare password: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { RequestContextService } from '../../../common/logging/request-context.service';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
constructor(private readonly requestContext: RequestContextService) {
|
constructor(private readonly logger: PinoLogger) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,8 +15,9 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
|||||||
const request = context
|
const request = context
|
||||||
.switchToHttp()
|
.switchToHttp()
|
||||||
.getRequest<{ user?: AccessPayload }>();
|
.getRequest<{ user?: AccessPayload }>();
|
||||||
if (request.user?.sub)
|
if (request.user?.sub) {
|
||||||
this.requestContext.set({ userId: request.user.sub });
|
this.logger.assign({ userId: request.user.sub });
|
||||||
|
}
|
||||||
return Boolean(allowed);
|
return Boolean(allowed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,44 @@
|
|||||||
import {
|
|
||||||
MAX_CARD_NAME_LENGTH,
|
|
||||||
MAX_OPERATION_DESCRIPTION_LENGTH,
|
|
||||||
} from '../domain/bank-card-limits';
|
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
||||||
|
import {
|
||||||
|
MAX_CARD_NAME_LENGTH,
|
||||||
|
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||||
|
} from '../domain/bank-card-limits';
|
||||||
|
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
|
||||||
|
import { parseRubMoney } from '../domain/rub-money';
|
||||||
import {
|
import {
|
||||||
bankPaymentSystems,
|
bankPaymentSystems,
|
||||||
BankPeriodQueryDto,
|
BankPeriodQueryDto,
|
||||||
CreateBankCardDto,
|
CreateBankCardDto,
|
||||||
CreateBankOperationDto,
|
CreateBankOperationDto,
|
||||||
} from '../presentation/bank-card.dto';
|
} from '../presentation/bank-card.dto';
|
||||||
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
|
|
||||||
import { parseRubMoney } from '../domain/rub-money';
|
|
||||||
|
|
||||||
export const BankErrorCode = {
|
const NEXT_MONTH_OFFSET = 1;
|
||||||
InvalidInput: 'BANK_INVALID_INPUT',
|
const PREVIOUS_MONTH_LAST_DAY = 0;
|
||||||
VersionConflict: 'BANK_VERSION_CONFLICT',
|
|
||||||
NotFound: 'BANK_NOT_FOUND',
|
export enum BankErrorCode {
|
||||||
InvalidMoney: 'BANK_INVALID_MONEY',
|
CardArchived = 'BANK_CARD_ARCHIVED',
|
||||||
InvalidDate: 'BANK_INVALID_DATE',
|
DependentRefunds = 'BANK_DEPENDENT_REFUNDS',
|
||||||
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION',
|
DuplicateCategory = 'BANK_DUPLICATE_CATEGORY',
|
||||||
InvalidRefund: 'BANK_INVALID_REFUND',
|
ImmutableOperation = 'BANK_IMMUTABLE_OPERATION',
|
||||||
RefundLimit: 'BANK_REFUND_LIMIT',
|
IntentConflict = 'BANK_INTENT_CONFLICT',
|
||||||
DependentRefunds: 'BANK_DEPENDENT_REFUNDS',
|
InvalidDate = 'BANK_INVALID_DATE',
|
||||||
IntentConflict: 'BANK_INTENT_CONFLICT',
|
InvalidInput = 'BANK_INVALID_INPUT',
|
||||||
OperationDeleted: 'BANK_OPERATION_DELETED',
|
InvalidMoney = 'BANK_INVALID_MONEY',
|
||||||
CardArchived: 'BANK_CARD_ARCHIVED',
|
InvalidRefund = 'BANK_INVALID_REFUND',
|
||||||
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT',
|
NotFound = 'BANK_NOT_FOUND',
|
||||||
WriteConflict: 'BANK_WRITE_CONFLICT',
|
OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT',
|
||||||
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY',
|
OperationDeleted = 'BANK_OPERATION_DELETED',
|
||||||
} as const;
|
RefundLimit = 'BANK_REFUND_LIMIT',
|
||||||
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
|
VersionConflict = 'BANK_VERSION_CONFLICT',
|
||||||
|
WriteConflict = 'BANK_WRITE_CONFLICT',
|
||||||
|
}
|
||||||
|
|
||||||
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
|
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
|
||||||
throw new BadRequestException({ code });
|
throw new BadRequestException({ code });
|
||||||
@@ -44,8 +48,8 @@ export function periodBounds(query: BankPeriodQueryDto) {
|
|||||||
const from = `${query.month}-01`;
|
const from = `${query.month}-01`;
|
||||||
if (!isCalendarDate(from)) return fail();
|
if (!isCalendarDate(from)) return fail();
|
||||||
const end = new Date(`${from}T00:00:00Z`);
|
const end = new Date(`${from}T00:00:00Z`);
|
||||||
end.setUTCMonth(end.getUTCMonth() + 1, 0);
|
end.setUTCMonth(end.getUTCMonth() + NEXT_MONTH_OFFSET, PREVIOUS_MONTH_LAST_DAY);
|
||||||
return { from, to: end.toISOString().slice(0, 10) };
|
return { from, to: end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
query.month ||
|
query.month ||
|
||||||
@@ -54,8 +58,9 @@ export function periodBounds(query: BankPeriodQueryDto) {
|
|||||||
!isCalendarDate(query.from) ||
|
!isCalendarDate(query.from) ||
|
||||||
!isCalendarDate(query.to) ||
|
!isCalendarDate(query.to) ||
|
||||||
query.from > query.to
|
query.from > query.to
|
||||||
)
|
) {
|
||||||
return fail();
|
return fail();
|
||||||
|
}
|
||||||
return { from: query.from, to: query.to };
|
return { from: query.from, to: query.to };
|
||||||
}
|
}
|
||||||
export function conflict(
|
export function conflict(
|
||||||
@@ -93,8 +98,9 @@ export function currentDate(value: string) {
|
|||||||
if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
|
if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
|
||||||
}
|
}
|
||||||
export function name(value: string, max: number) {
|
export function name(value: string, max: number) {
|
||||||
if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
|
if (typeof value !== 'string' || !value.trim() || value.trim().length > max) {
|
||||||
fail();
|
fail();
|
||||||
|
}
|
||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
export function cardInput(dto: CreateBankCardDto) {
|
export function cardInput(dto: CreateBankCardDto) {
|
||||||
@@ -104,8 +110,9 @@ export function cardInput(dto: CreateBankCardDto) {
|
|||||||
if (
|
if (
|
||||||
dto.paymentSystem != null &&
|
dto.paymentSystem != null &&
|
||||||
!bankPaymentSystems.some((system) => system === dto.paymentSystem)
|
!bankPaymentSystems.some((system) => system === dto.paymentSystem)
|
||||||
)
|
) {
|
||||||
fail();
|
fail();
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
|
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
|
||||||
name: name(dto.name, MAX_CARD_NAME_LENGTH),
|
name: name(dto.name, MAX_CARD_NAME_LENGTH),
|
||||||
@@ -120,8 +127,9 @@ export function operationInput(dto: CreateBankOperationDto) {
|
|||||||
dto.description != null &&
|
dto.description != null &&
|
||||||
(typeof dto.description !== 'string' ||
|
(typeof dto.description !== 'string' ||
|
||||||
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
|
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
|
||||||
)
|
) {
|
||||||
fail();
|
fail();
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
kind: dto.kind,
|
kind: dto.kind,
|
||||||
cardId: dto.cardId,
|
cardId: dto.cardId,
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
|
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import { bankBalance } from '../domain/bank-card.calculations';
|
||||||
|
import { rubMoney } from '../domain/rub-money';
|
||||||
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
import {
|
import {
|
||||||
BankCardDto,
|
BankCardDto,
|
||||||
CreateBankCardDto,
|
CreateBankCardDto,
|
||||||
UpdateBankCardDto,
|
UpdateBankCardDto,
|
||||||
} from '../presentation/bank-card.dto';
|
} from '../presentation/bank-card.dto';
|
||||||
import { bankBalance } from '../domain/bank-card.calculations';
|
|
||||||
import { rubMoney } from '../domain/rub-money';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cardInput,
|
|
||||||
BankErrorCode,
|
BankErrorCode,
|
||||||
|
cardInput,
|
||||||
conflict,
|
conflict,
|
||||||
currentDate,
|
currentDate,
|
||||||
fail,
|
fail,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
missing,
|
missing,
|
||||||
} from './bank-card-input';
|
} from './bank-card-input';
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
|
||||||
|
const EXPECTED_UPDATED_CARDS = 1;
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BankCardsService {
|
export class BankCardsService {
|
||||||
@@ -26,6 +29,7 @@ export class BankCardsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly repository: BankCardsRepository,
|
private readonly repository: BankCardsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
|
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
|
||||||
const rows = await tx.bankCardOperation.findMany({
|
const rows = await tx.bankCardOperation.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -47,6 +51,7 @@ export class BankCardsService {
|
|||||||
archivedAt: card.archivedAt?.toISOString() ?? null,
|
archivedAt: card.archivedAt?.toISOString() ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
list(userId: string, includeArchived = false) {
|
list(userId: string, includeArchived = false) {
|
||||||
return this.prisma.$transaction(async (tx) =>
|
return this.prisma.$transaction(async (tx) =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
@@ -59,11 +64,13 @@ export class BankCardsService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(userId: string, id: string) {
|
get(userId: string, id: string) {
|
||||||
return this.prisma.$transaction(async (tx) =>
|
return this.prisma.$transaction(async (tx) =>
|
||||||
this.cardView(tx, await this.repository.card(tx, userId, id)),
|
this.cardView(tx, await this.repository.card(tx, userId, id)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
createCard(userId: string, dto: CreateBankCardDto) {
|
createCard(userId: string, dto: CreateBankCardDto) {
|
||||||
const input = cardInput(dto),
|
const input = cardInput(dto),
|
||||||
hash = fingerprint(input);
|
hash = fingerprint(input);
|
||||||
@@ -71,8 +78,10 @@ export class BankCardsService {
|
|||||||
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
|
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.userId !== userId) missing();
|
if (existing.userId !== userId) missing();
|
||||||
if (existing.createFingerprint !== hash)
|
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
||||||
|
if (existing.createFingerprint !== hash) {
|
||||||
conflict(BankErrorCode.IntentConflict);
|
conflict(BankErrorCode.IntentConflict);
|
||||||
|
}
|
||||||
return this.cardView(tx, existing);
|
return this.cardView(tx, existing);
|
||||||
}
|
}
|
||||||
currentDate(input.openingDate);
|
currentDate(input.openingDate);
|
||||||
@@ -85,6 +94,7 @@ export class BankCardsService {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
|
updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
const previous = await this.repository.card(tx, userId, id);
|
const previous = await this.repository.card(tx, userId, id);
|
||||||
@@ -93,13 +103,9 @@ export class BankCardsService {
|
|||||||
id,
|
id,
|
||||||
name: dto.name ?? previous.name,
|
name: dto.name ?? previous.name,
|
||||||
bankName:
|
bankName:
|
||||||
dto.bankName === undefined
|
dto.bankName === undefined ? previous.bankName : dto.bankName,
|
||||||
? (previous.bankName ?? undefined)
|
|
||||||
: (dto.bankName ?? undefined),
|
|
||||||
lastFour:
|
lastFour:
|
||||||
dto.lastFour === undefined
|
dto.lastFour === undefined ? previous.lastFour : dto.lastFour,
|
||||||
? (previous.lastFour ?? undefined)
|
|
||||||
: (dto.lastFour ?? undefined),
|
|
||||||
paymentSystem:
|
paymentSystem:
|
||||||
dto.paymentSystem === undefined
|
dto.paymentSystem === undefined
|
||||||
? previous.paymentSystem
|
? previous.paymentSystem
|
||||||
@@ -111,8 +117,9 @@ export class BankCardsService {
|
|||||||
const financial =
|
const financial =
|
||||||
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
|
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
|
||||||
input.openingDate !== previous.openingDate;
|
input.openingDate !== previous.openingDate;
|
||||||
if (financial && previous.archivedAt)
|
if (financial && previous.archivedAt) {
|
||||||
conflict(BankErrorCode.CardArchived);
|
conflict(BankErrorCode.CardArchived);
|
||||||
|
}
|
||||||
currentDate(input.openingDate);
|
currentDate(input.openingDate);
|
||||||
if (
|
if (
|
||||||
await tx.bankCardOperation.findFirst({
|
await tx.bankCardOperation.findFirst({
|
||||||
@@ -123,8 +130,9 @@ export class BankCardsService {
|
|||||||
OR: [{ cardId: id }, { targetCardId: id }],
|
OR: [{ cardId: id }, { targetCardId: id }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
) {
|
||||||
fail(BankErrorCode.OpeningDateConflict);
|
fail(BankErrorCode.OpeningDateConflict);
|
||||||
|
}
|
||||||
const changed = await tx.bankCard.updateMany({
|
const changed = await tx.bankCard.updateMany({
|
||||||
where: { id, userId, version: dto.version },
|
where: { id, userId, version: dto.version },
|
||||||
data: {
|
data: {
|
||||||
@@ -140,7 +148,7 @@ export class BankCardsService {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (changed.count !== 1) conflict();
|
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
|
||||||
return this.cardView(tx, await this.repository.card(tx, userId, id));
|
return this.cardView(tx, await this.repository.card(tx, userId, id));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
|
||||||
import { GoneException, Injectable } from '@nestjs/common';
|
import { GoneException, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BankCardOperation,
|
BankCardOperation,
|
||||||
Prisma,
|
Prisma,
|
||||||
} from '../../../../../generated/prisma/client';
|
} from '../../../../../generated/prisma/client';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
||||||
|
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||||
|
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
|
||||||
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
import {
|
import {
|
||||||
BankOperationDto,
|
BankOperationDto,
|
||||||
DEFAULT_BANK_PAGE_SIZE,
|
|
||||||
BankOperationsQueryDto,
|
BankOperationsQueryDto,
|
||||||
BankPageQueryDto,
|
BankPageQueryDto,
|
||||||
CreateBankOperationDto,
|
CreateBankOperationDto,
|
||||||
|
DEFAULT_BANK_PAGE_SIZE,
|
||||||
UpdateBankOperationDto,
|
UpdateBankOperationDto,
|
||||||
} from '../presentation/bank-card.dto';
|
} from '../presentation/bank-card.dto';
|
||||||
import { rubMoney } from '../domain/rub-money';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BankErrorCode,
|
BankErrorCode,
|
||||||
conflict,
|
conflict,
|
||||||
@@ -25,7 +27,9 @@ import {
|
|||||||
operationInput,
|
operationInput,
|
||||||
periodBounds,
|
periodBounds,
|
||||||
} from './bank-card-input';
|
} from './bank-card-input';
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
|
||||||
|
const EXPECTED_UPDATED_ROWS = 1;
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BankOperationsService {
|
export class BankOperationsService {
|
||||||
@@ -33,6 +37,7 @@ export class BankOperationsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly repository: BankCardsRepository,
|
private readonly repository: BankCardsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async operationView(
|
private async operationView(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
row: BankCardOperation,
|
row: BankCardOperation,
|
||||||
@@ -76,7 +81,7 @@ export class BankOperationsService {
|
|||||||
row.kind === BankOperationKind.Expense || source
|
row.kind === BankOperationKind.Expense || source
|
||||||
? rubMoney(
|
? rubMoney(
|
||||||
(source?.amountKopecks ?? row.amountKopecks) -
|
(source?.amountKopecks ?? row.amountKopecks) -
|
||||||
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
|
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
date: row.date,
|
date: row.date,
|
||||||
@@ -85,6 +90,102 @@ export class BankOperationsService {
|
|||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
|
||||||
|
const userId = rows[0]?.userId;
|
||||||
|
if (!userId) return [];
|
||||||
|
const cardIds = new Set(
|
||||||
|
rows.flatMap((row) =>
|
||||||
|
[row.cardId, row.targetCardId].filter(
|
||||||
|
(id): id is string => id !== null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const sourceIds = new Set(
|
||||||
|
rows.flatMap((row) => (row.originalExpenseId ? [row.originalExpenseId] : [])),
|
||||||
|
);
|
||||||
|
const [cards, sources] = await Promise.all([
|
||||||
|
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
|
||||||
|
tx.bankCardOperation.findMany({
|
||||||
|
where: { userId, id: { in: [...sourceIds] } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const categoryIds = new Set(
|
||||||
|
rows
|
||||||
|
.map(
|
||||||
|
(row) =>
|
||||||
|
sources.find((source) => source.id === row.originalExpenseId)
|
||||||
|
?.categoryId ?? row.categoryId,
|
||||||
|
)
|
||||||
|
.filter((id): id is string => id !== null),
|
||||||
|
);
|
||||||
|
const expenseIds = new Set(
|
||||||
|
rows.flatMap((row) => {
|
||||||
|
const source = sources.find((item) => item.id === row.originalExpenseId);
|
||||||
|
return row.kind === BankOperationKind.Expense || source
|
||||||
|
? [source?.id ?? row.id]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const [categories, refunds] = await Promise.all([
|
||||||
|
tx.expenseCategory.findMany({
|
||||||
|
where: { userId, id: { in: [...categoryIds] } },
|
||||||
|
}),
|
||||||
|
tx.bankCardOperation.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
originalExpenseId: { in: [...expenseIds] },
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const cardsById = new Map(cards.map((card) => [card.id, card]));
|
||||||
|
const sourcesById = new Map(sources.map((source) => [source.id, source]));
|
||||||
|
const categoriesById = new Map(categories.map((category) => [category.id, category]));
|
||||||
|
return rows.map((row) => {
|
||||||
|
const card = cardsById.get(row.cardId) as { name: string };
|
||||||
|
const target = row.targetCardId ? cardsById.get(row.targetCardId) : null;
|
||||||
|
const source = row.originalExpenseId
|
||||||
|
? sourcesById.get(row.originalExpenseId)
|
||||||
|
: null;
|
||||||
|
const categoryId = source?.categoryId ?? row.categoryId;
|
||||||
|
const relatedRefunds =
|
||||||
|
row.kind === BankOperationKind.Expense || source
|
||||||
|
? refunds.filter(
|
||||||
|
(refund) =>
|
||||||
|
refund.originalExpenseId === (source?.id ?? row.id) &&
|
||||||
|
(!source || refund.id !== row.id),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind as BankOperationDto['kind'],
|
||||||
|
cardId: row.cardId,
|
||||||
|
cardName: card.name,
|
||||||
|
targetCardId: row.targetCardId,
|
||||||
|
targetCardName: target?.name ?? null,
|
||||||
|
categoryId,
|
||||||
|
categoryName: categoriesById.get(categoryId ?? '')?.name ?? null,
|
||||||
|
originalExpenseId: row.originalExpenseId,
|
||||||
|
amount: rubMoney(row.amountKopecks),
|
||||||
|
availableRefund:
|
||||||
|
row.kind === BankOperationKind.Expense || source
|
||||||
|
? rubMoney(
|
||||||
|
(source?.amountKopecks ?? row.amountKopecks) -
|
||||||
|
relatedRefunds.reduce(
|
||||||
|
(sum, refund) => sum + refund.amountKopecks,
|
||||||
|
ZERO_KOPECKS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
date: row.date,
|
||||||
|
description: row.description,
|
||||||
|
version: row.version,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async validateOperation(
|
private async validateOperation(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -102,23 +203,26 @@ export class BankOperationsService {
|
|||||||
if (
|
if (
|
||||||
(id === input.cardId || id === input.targetCardId) &&
|
(id === input.cardId || id === input.targetCardId) &&
|
||||||
input.date < card.openingDate
|
input.date < card.openingDate
|
||||||
)
|
) {
|
||||||
fail(BankErrorCode.InvalidDate);
|
fail(BankErrorCode.InvalidDate);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
old &&
|
old &&
|
||||||
(old.kind !== input.kind ||
|
(old.kind !== input.kind ||
|
||||||
old.originalExpenseId !== input.originalExpenseId)
|
old.originalExpenseId !== input.originalExpenseId)
|
||||||
)
|
) {
|
||||||
fail(BankErrorCode.ImmutableOperation);
|
fail(BankErrorCode.ImmutableOperation);
|
||||||
|
}
|
||||||
if (input.kind === BankOperationKind.Transfer) {
|
if (input.kind === BankOperationKind.Transfer) {
|
||||||
if (
|
if (
|
||||||
!input.targetCardId ||
|
!input.targetCardId ||
|
||||||
input.targetCardId === input.cardId ||
|
input.targetCardId === input.cardId ||
|
||||||
input.categoryId ||
|
input.categoryId ||
|
||||||
input.originalExpenseId
|
input.originalExpenseId
|
||||||
)
|
) {
|
||||||
fail();
|
fail();
|
||||||
|
}
|
||||||
} else if (input.targetCardId) fail();
|
} else if (input.targetCardId) fail();
|
||||||
if (input.kind === BankOperationKind.Expense) {
|
if (input.kind === BankOperationKind.Expense) {
|
||||||
if (input.originalExpenseId) fail();
|
if (input.originalExpenseId) fail();
|
||||||
@@ -133,11 +237,13 @@ export class BankOperationsService {
|
|||||||
!(await tx.expenseCategory.findFirst({
|
!(await tx.expenseCategory.findFirst({
|
||||||
where: { userId, id: input.categoryId },
|
where: { userId, id: input.categoryId },
|
||||||
}))
|
}))
|
||||||
)
|
) {
|
||||||
missing();
|
missing();
|
||||||
|
}
|
||||||
} else if (input.categoryId) fail();
|
} else if (input.categoryId) fail();
|
||||||
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId)
|
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
|
||||||
fail();
|
fail();
|
||||||
|
}
|
||||||
if (input.kind === BankOperationKind.Refund) {
|
if (input.kind === BankOperationKind.Refund) {
|
||||||
if (!input.originalExpenseId) fail();
|
if (!input.originalExpenseId) fail();
|
||||||
const source = await this.repository.operation(
|
const source = await this.repository.operation(
|
||||||
@@ -149,8 +255,9 @@ export class BankOperationsService {
|
|||||||
source.deletedAt ||
|
source.deletedAt ||
|
||||||
source.kind !== BankOperationKind.Expense ||
|
source.kind !== BankOperationKind.Expense ||
|
||||||
source.cardId !== input.cardId
|
source.cardId !== input.cardId
|
||||||
)
|
) {
|
||||||
fail(BankErrorCode.InvalidRefund);
|
fail(BankErrorCode.InvalidRefund);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(
|
(
|
||||||
await tx.bankCardOperation.updateMany({
|
await tx.bankCardOperation.updateMany({
|
||||||
@@ -162,9 +269,10 @@ export class BankOperationsService {
|
|||||||
},
|
},
|
||||||
data: { version: { increment: 1 } },
|
data: { version: { increment: 1 } },
|
||||||
})
|
})
|
||||||
).count !== 1
|
).count !== EXPECTED_UPDATED_ROWS
|
||||||
)
|
) {
|
||||||
conflict();
|
conflict();
|
||||||
|
}
|
||||||
const refunds = await tx.bankCardOperation.findMany({
|
const refunds = await tx.bankCardOperation.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
@@ -175,12 +283,13 @@ export class BankOperationsService {
|
|||||||
});
|
});
|
||||||
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
||||||
if (
|
if (
|
||||||
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
|
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
|
||||||
input.amountKopecks >
|
input.amountKopecks >
|
||||||
source.amountKopecks
|
source.amountKopecks
|
||||||
)
|
) {
|
||||||
conflict(BankErrorCode.RefundLimit);
|
conflict(BankErrorCode.RefundLimit);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (old?.kind === BankOperationKind.Expense) {
|
if (old?.kind === BankOperationKind.Expense) {
|
||||||
const refunds = await tx.bankCardOperation.findMany({
|
const refunds = await tx.bankCardOperation.findMany({
|
||||||
where: { userId, originalExpenseId: old.id, deletedAt: null },
|
where: { userId, originalExpenseId: old.id, deletedAt: null },
|
||||||
@@ -189,12 +298,14 @@ export class BankOperationsService {
|
|||||||
refunds.length &&
|
refunds.length &&
|
||||||
(input.cardId !== old.cardId ||
|
(input.cardId !== old.cardId ||
|
||||||
refunds.some((row) => row.date < input.date) ||
|
refunds.some((row) => row.date < input.date) ||
|
||||||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
|
refunds.reduce((sum, row) => sum + row.amountKopecks, ZERO_KOPECKS) >
|
||||||
input.amountKopecks)
|
input.amountKopecks)
|
||||||
)
|
) {
|
||||||
conflict(BankErrorCode.DependentRefunds);
|
conflict(BankErrorCode.DependentRefunds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
createOperation(userId: string, dto: CreateBankOperationDto) {
|
createOperation(userId: string, dto: CreateBankOperationDto) {
|
||||||
const normalized = operationInput(dto),
|
const normalized = operationInput(dto),
|
||||||
hash = fingerprint(normalized);
|
hash = fingerprint(normalized);
|
||||||
@@ -204,10 +315,13 @@ export class BankOperationsService {
|
|||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.userId !== userId) missing();
|
if (existing.userId !== userId) missing();
|
||||||
if (existing.createFingerprint !== hash)
|
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
||||||
|
if (existing.createFingerprint !== hash) {
|
||||||
conflict(BankErrorCode.IntentConflict);
|
conflict(BankErrorCode.IntentConflict);
|
||||||
if (existing.deletedAt)
|
}
|
||||||
|
if (existing.deletedAt) {
|
||||||
throw new GoneException({ code: BankErrorCode.OperationDeleted });
|
throw new GoneException({ code: BankErrorCode.OperationDeleted });
|
||||||
|
}
|
||||||
return this.operationView(tx, existing);
|
return this.operationView(tx, existing);
|
||||||
}
|
}
|
||||||
const input = { ...normalized };
|
const input = { ...normalized };
|
||||||
@@ -220,6 +334,7 @@ export class BankOperationsService {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getOperation(userId: string, id: string) {
|
getOperation(userId: string, id: string) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const row = await this.repository.operation(tx, userId, id);
|
const row = await this.repository.operation(tx, userId, id);
|
||||||
@@ -227,6 +342,7 @@ export class BankOperationsService {
|
|||||||
return this.operationView(tx, row);
|
return this.operationView(tx, row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
|
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
const old = await this.repository.operation(tx, userId, id);
|
const old = await this.repository.operation(tx, userId, id);
|
||||||
@@ -251,30 +367,34 @@ export class BankOperationsService {
|
|||||||
where: { id, userId, version: dto.version, deletedAt: null },
|
where: { id, userId, version: dto.version, deletedAt: null },
|
||||||
data: { ...input, version: { increment: 1 } },
|
data: { ...input, version: { increment: 1 } },
|
||||||
})
|
})
|
||||||
).count !== 1
|
).count !== EXPECTED_UPDATED_ROWS
|
||||||
)
|
) {
|
||||||
conflict();
|
conflict();
|
||||||
|
}
|
||||||
return this.operationView(
|
return this.operationView(
|
||||||
tx,
|
tx,
|
||||||
await this.repository.operation(tx, userId, id),
|
await this.repository.operation(tx, userId, id),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteOperation(userId: string, id: string, version: number) {
|
deleteOperation(userId: string, id: string, version: number) {
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
const row = await this.repository.operation(tx, userId, id);
|
const row = await this.repository.operation(tx, userId, id);
|
||||||
if (row.deletedAt) return;
|
if (row.deletedAt) return;
|
||||||
if (row.version !== version) conflict();
|
if (row.version !== version) conflict();
|
||||||
await this.repository.card(tx, userId, row.cardId, true);
|
await this.repository.card(tx, userId, row.cardId, true);
|
||||||
if (row.targetCardId)
|
if (row.targetCardId) {
|
||||||
await this.repository.card(tx, userId, row.targetCardId, true);
|
await this.repository.card(tx, userId, row.targetCardId, true);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
row.kind === BankOperationKind.Expense &&
|
row.kind === BankOperationKind.Expense &&
|
||||||
(await tx.bankCardOperation.findFirst({
|
(await tx.bankCardOperation.findFirst({
|
||||||
where: { userId, originalExpenseId: id, deletedAt: null },
|
where: { userId, originalExpenseId: id, deletedAt: null },
|
||||||
}))
|
}))
|
||||||
)
|
) {
|
||||||
conflict(BankErrorCode.DependentRefunds);
|
conflict(BankErrorCode.DependentRefunds);
|
||||||
|
}
|
||||||
if (row.originalExpenseId) {
|
if (row.originalExpenseId) {
|
||||||
const source = await this.repository.operation(
|
const source = await this.repository.operation(
|
||||||
tx,
|
tx,
|
||||||
@@ -287,21 +407,24 @@ export class BankOperationsService {
|
|||||||
where: { id: source.id, userId, version: source.version },
|
where: { id: source.id, userId, version: source.version },
|
||||||
data: { version: { increment: 1 } },
|
data: { version: { increment: 1 } },
|
||||||
})
|
})
|
||||||
).count !== 1
|
).count !== EXPECTED_UPDATED_ROWS
|
||||||
)
|
) {
|
||||||
conflict();
|
conflict();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(
|
(
|
||||||
await tx.bankCardOperation.updateMany({
|
await tx.bankCardOperation.updateMany({
|
||||||
where: { id, userId, version, deletedAt: null },
|
where: { id, userId, version, deletedAt: null },
|
||||||
data: { deletedAt: new Date(), version: { increment: 1 } },
|
data: { deletedAt: new Date(), version: { increment: 1 } },
|
||||||
})
|
})
|
||||||
).count !== 1
|
).count !== EXPECTED_UPDATED_ROWS
|
||||||
)
|
) {
|
||||||
conflict();
|
conflict();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
history(
|
history(
|
||||||
userId: string,
|
userId: string,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -313,8 +436,8 @@ export class BankOperationsService {
|
|||||||
: periodBounds(query as BankOperationsQueryDto);
|
: periodBounds(query as BankOperationsQueryDto);
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
await this.repository.card(tx, userId, id);
|
await this.repository.card(tx, userId, id);
|
||||||
const page = query.page ?? 1,
|
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
|
||||||
limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE;
|
page = query.page ?? FIRST_PAGE;
|
||||||
const where: Prisma.BankCardOperationWhereInput = {
|
const where: Prisma.BankCardOperationWhereInput = {
|
||||||
userId,
|
userId,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
@@ -327,13 +450,11 @@ export class BankOperationsService {
|
|||||||
const rows = await tx.bankCardOperation.findMany({
|
const rows = await tx.bankCardOperation.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||||
skip: (page - 1) * limit,
|
skip: (page - FIRST_PAGE) * limit,
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
items: await Promise.all(
|
items: await this.pageViews(tx, rows),
|
||||||
rows.map((row) => this.operationView(tx, row)),
|
|
||||||
),
|
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
hasNext: page * limit < total,
|
hasNext: page * limit < total,
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
|
||||||
|
|
||||||
import { conflict, missing, name } from './bank-card-input';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
|
import { conflict, missing, name } from './bank-card-input';
|
||||||
|
|
||||||
|
const EXPECTED_UPDATED_CATEGORIES = 1;
|
||||||
|
const VERSION_INCREMENT = 1;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpenseCategoriesService {
|
export class ExpenseCategoriesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly repository: BankCardsRepository,
|
private readonly repository: BankCardsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async categories(userId: string) {
|
async categories(userId: string) {
|
||||||
return (
|
return (
|
||||||
await this.prisma.expenseCategory.findMany({
|
await this.prisma.expenseCategory.findMany({
|
||||||
@@ -24,6 +29,7 @@ export class ExpenseCategoriesService {
|
|||||||
version,
|
version,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
createCategory(userId: string, value: string) {
|
createCategory(userId: string, value: string) {
|
||||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
@@ -44,6 +50,7 @@ export class ExpenseCategoriesService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCategory(userId: string, id: string, value: string, version: number) {
|
updateCategory(userId: string, id: string, value: string, version: number) {
|
||||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
@@ -59,14 +66,15 @@ export class ExpenseCategoriesService {
|
|||||||
version: { increment: 1 },
|
version: { increment: 1 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
).count !== 1
|
).count !== EXPECTED_UPDATED_CATEGORIES
|
||||||
)
|
) {
|
||||||
conflict();
|
conflict();
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: label,
|
name: label,
|
||||||
isDefault: row.seedKey === 'other',
|
isDefault: row.seedKey === 'other',
|
||||||
version: version + 1,
|
version: version + VERSION_INCREMENT,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
|
|
||||||
import { bankSummary } from '../domain/bank-card.calculations';
|
import { bankSummary } from '../domain/bank-card.calculations';
|
||||||
import { rubMoney } from '../domain/rub-money';
|
import { rubMoney } from '../domain/rub-money';
|
||||||
|
|
||||||
import { periodBounds } from './bank-card-input';
|
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
|
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
|
||||||
|
import { periodBounds } from './bank-card-input';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GetBankSummaryUseCase {
|
export class GetBankSummaryUseCase {
|
||||||
constructor(private readonly repository: BankCardsRepository) {}
|
constructor(private readonly repository: BankCardsRepository) {}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { BankCardsController } from './presentation/bank-cards.controller';
|
|
||||||
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
|
|
||||||
import { BankCardsService } from './application/bank-cards.service';
|
import { BankCardsService } from './application/bank-cards.service';
|
||||||
import { BankOperationsService } from './application/bank-operations.service';
|
import { BankOperationsService } from './application/bank-operations.service';
|
||||||
import { ExpenseCategoriesService } from './application/expense-categories.service';
|
import { ExpenseCategoriesService } from './application/expense-categories.service';
|
||||||
import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case';
|
import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case';
|
||||||
|
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
|
||||||
|
import { BankCardsController } from './presentation/bank-cards.controller';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule],
|
||||||
controllers: [BankCardsController],
|
controllers: [BankCardsController],
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
export const FIRST_PAGE = 1;
|
||||||
|
|
||||||
|
export const MIN_ENTITY_VERSION = 1;
|
||||||
|
|
||||||
|
export const MIN_CATEGORY_NAME_LENGTH = 1;
|
||||||
|
|
||||||
|
export const MIN_CARD_NAME_LENGTH = 1;
|
||||||
export const MAX_CARD_NAME_LENGTH = 100;
|
export const MAX_CARD_NAME_LENGTH = 100;
|
||||||
export const MAX_CATEGORY_NAME_LENGTH = 80;
|
export const MAX_CATEGORY_NAME_LENGTH = 80;
|
||||||
export const MAX_OPERATION_DESCRIPTION_LENGTH = 500;
|
export const MAX_OPERATION_DESCRIPTION_LENGTH = 500;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
||||||
import { BankOperationKind } from './bank-operation-kind';
|
import { BankOperationKind } from './bank-operation-kind';
|
||||||
|
import { ZERO_KOPECKS } from './rub-money';
|
||||||
|
|
||||||
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', {
|
||||||
@@ -10,11 +12,11 @@ export function moscowToday(now = new Date()): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isCalendarDate(value: string): boolean {
|
export function isCalendarDate(value: string): boolean {
|
||||||
if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
if (value.length !== ISO_DATE_LENGTH || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||||
if (value.startsWith('0000')) return false;
|
if (value.startsWith('0000')) return false;
|
||||||
const date = new Date(`${value}T00:00:00.000Z`);
|
const date = new Date(`${value}T00:00:00.000Z`);
|
||||||
return (
|
return (
|
||||||
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
!Number.isNaN(date.getTime()) && date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === value
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,10 +24,10 @@ export function bankBalance(
|
|||||||
cardId: string,
|
cardId: string,
|
||||||
opening: bigint,
|
opening: bigint,
|
||||||
operations: Array<{
|
operations: Array<{
|
||||||
kind: string;
|
|
||||||
cardId: string;
|
|
||||||
targetCardId: string | null;
|
|
||||||
amountKopecks: bigint;
|
amountKopecks: bigint;
|
||||||
|
cardId: string;
|
||||||
|
kind: string;
|
||||||
|
targetCardId: null | string;
|
||||||
}>,
|
}>,
|
||||||
): bigint {
|
): bigint {
|
||||||
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||||
@@ -35,37 +37,39 @@ export function bankBalance(
|
|||||||
if (
|
if (
|
||||||
operation.kind === BankOperationKind.Expense ||
|
operation.kind === BankOperationKind.Expense ||
|
||||||
operation.kind === BankOperationKind.Transfer
|
operation.kind === BankOperationKind.Transfer
|
||||||
)
|
) {
|
||||||
balance -= operation.amountKopecks;
|
balance -= operation.amountKopecks;
|
||||||
else if (
|
} else if (
|
||||||
operation.kind === BankOperationKind.Topup ||
|
operation.kind === BankOperationKind.Topup ||
|
||||||
operation.kind === BankOperationKind.Refund
|
operation.kind === BankOperationKind.Refund
|
||||||
)
|
) {
|
||||||
balance += operation.amountKopecks;
|
balance += operation.amountKopecks;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
operation.kind === BankOperationKind.Transfer &&
|
operation.kind === BankOperationKind.Transfer &&
|
||||||
operation.targetCardId === cardId
|
operation.targetCardId === cardId
|
||||||
)
|
) {
|
||||||
balance += operation.amountKopecks;
|
balance += operation.amountKopecks;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return balance;
|
return balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bankSummary(
|
export function bankSummary(
|
||||||
period: { from: string; to: string },
|
period: { from: string; to: string },
|
||||||
operations: Array<{
|
operations: Array<{
|
||||||
kind: string;
|
|
||||||
date: string;
|
|
||||||
amountKopecks: bigint;
|
amountKopecks: bigint;
|
||||||
categoryId: string | null;
|
categoryId: null | string;
|
||||||
|
date: string;
|
||||||
|
kind: string;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
let expenses = 0n;
|
let expenses = ZERO_KOPECKS;
|
||||||
let refunds = 0n;
|
let refunds = ZERO_KOPECKS;
|
||||||
const categories = new Map<
|
const categories = new Map<
|
||||||
string,
|
string,
|
||||||
{ categoryId: string; expenses: bigint; refunds: bigint; net: bigint }
|
{ categoryId: string; expenses: bigint; net: bigint; refunds: bigint }
|
||||||
>();
|
>();
|
||||||
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||||
for (const operation of operations) {
|
for (const operation of operations) {
|
||||||
@@ -73,24 +77,25 @@ export function bankSummary(
|
|||||||
if (
|
if (
|
||||||
operation.kind !== BankOperationKind.Expense &&
|
operation.kind !== BankOperationKind.Expense &&
|
||||||
operation.kind !== BankOperationKind.Refund
|
operation.kind !== BankOperationKind.Refund
|
||||||
)
|
) {
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
const expense =
|
const expense =
|
||||||
operation.kind === BankOperationKind.Expense
|
operation.kind === BankOperationKind.Expense
|
||||||
? operation.amountKopecks
|
? operation.amountKopecks
|
||||||
: 0n;
|
: ZERO_KOPECKS;
|
||||||
const refund =
|
const refund =
|
||||||
operation.kind === BankOperationKind.Refund
|
operation.kind === BankOperationKind.Refund
|
||||||
? operation.amountKopecks
|
? operation.amountKopecks
|
||||||
: 0n;
|
: ZERO_KOPECKS;
|
||||||
expenses += expense;
|
expenses += expense;
|
||||||
refunds += refund;
|
refunds += refund;
|
||||||
if (operation.categoryId !== null) {
|
if (operation.categoryId !== null) {
|
||||||
const category = categories.get(operation.categoryId) ?? {
|
const category = categories.get(operation.categoryId) ?? {
|
||||||
categoryId: operation.categoryId,
|
categoryId: operation.categoryId,
|
||||||
expenses: 0n,
|
expenses: ZERO_KOPECKS,
|
||||||
refunds: 0n,
|
refunds: ZERO_KOPECKS,
|
||||||
net: 0n,
|
net: ZERO_KOPECKS,
|
||||||
};
|
};
|
||||||
category.expenses += expense;
|
category.expenses += expense;
|
||||||
category.refunds += refund;
|
category.refunds += refund;
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ describe('RUB money', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('requires positive operation amounts, including at the maximum', () => {
|
it('requires positive operation amounts, including at the maximum', () => {
|
||||||
for (const value of ['0', '-0.00', '-0.01'])
|
for (const value of ['0', '-0.00', '-0.01']) {
|
||||||
expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow();
|
expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow();
|
||||||
|
}
|
||||||
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
|
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
|
||||||
expect(
|
expect(
|
||||||
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
|
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
|
|
||||||
import type { Money } from '../../../../common/money.types';
|
import type { Money } from '../../../../common/money.types';
|
||||||
|
|
||||||
|
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||||
|
|
||||||
|
export const ZERO_KOPECKS = 0n;
|
||||||
|
|
||||||
|
const RUB_DECIMAL_PLACES = 2;
|
||||||
|
|
||||||
const MIN_KOPECKS = -9_223_372_036_854_775_808n;
|
const MIN_KOPECKS = -9_223_372_036_854_775_808n;
|
||||||
const MAX_KOPECKS = 9_223_372_036_854_775_807n;
|
const MAX_KOPECKS = 9_223_372_036_854_775_807n;
|
||||||
|
|
||||||
@@ -9,22 +14,26 @@ export function parseRubMoney(money: Money, positive = false): bigint {
|
|||||||
money?.currency !== 'RUB' ||
|
money?.currency !== 'RUB' ||
|
||||||
typeof money.value !== 'string' ||
|
typeof money.value !== 'string' ||
|
||||||
money.value !== money.value.trim()
|
money.value !== money.value.trim()
|
||||||
)
|
) {
|
||||||
throw new TypeError('Expected RUB money with a decimal string');
|
throw new TypeError('Expected RUB money with a decimal string');
|
||||||
|
}
|
||||||
const amount = decimalFromString(money.value);
|
const amount = decimalFromString(money.value);
|
||||||
if (amount.scale > 2)
|
if (amount.scale > RUB_DECIMAL_PLACES) {
|
||||||
throw new RangeError('RUB money supports at most two decimal places');
|
throw new RangeError('RUB money supports at most two decimal places');
|
||||||
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
|
}
|
||||||
if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS)
|
const kopecks = amount.value * DECIMAL_RADIX ** BigInt(RUB_DECIMAL_PLACES - amount.scale);
|
||||||
|
if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) {
|
||||||
throw new RangeError('RUB money is outside the signed 64-bit range');
|
throw new RangeError('RUB money is outside the signed 64-bit range');
|
||||||
if (positive && kopecks <= 0n)
|
}
|
||||||
|
if (positive && kopecks <= ZERO_KOPECKS) {
|
||||||
throw new RangeError('Operation amount must be positive');
|
throw new RangeError('Operation amount must be positive');
|
||||||
|
}
|
||||||
return kopecks;
|
return kopecks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rubMoney(kopecks: bigint): Money {
|
export function rubMoney(kopecks: bigint): Money {
|
||||||
return {
|
return {
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
value: formatDecimal({ value: kopecks, scale: 2 }),
|
value: formatDecimal({ value: kopecks, scale: RUB_DECIMAL_PLACES }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Prisma } from '../../../../../generated/prisma/client';
|
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
|
||||||
|
|
||||||
|
import { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
|
||||||
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
BankErrorCode,
|
BankErrorCode,
|
||||||
conflict,
|
conflict,
|
||||||
missing,
|
missing,
|
||||||
} from '../application/bank-card-input';
|
} from '../application/bank-card-input';
|
||||||
|
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||||
|
|
||||||
|
const INITIAL_RETRY_COUNT = 0;
|
||||||
|
const LAST_SEED_OFFSET = 1;
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
const TRANSACTION_MAX_WAIT_MS = 10_000;
|
const TRANSACTION_MAX_WAIT_MS = 10_000;
|
||||||
const TRANSACTION_TIMEOUT_MS = 15_000;
|
const TRANSACTION_TIMEOUT_MS = 15_000;
|
||||||
@@ -28,7 +33,7 @@ const seeds = [
|
|||||||
export class BankCardsRepository {
|
export class BankCardsRepository {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
for (let attempt = 0; ; attempt++) {
|
for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
|
||||||
try {
|
try {
|
||||||
return await this.prisma.$transaction(run, {
|
return await this.prisma.$transaction(run, {
|
||||||
maxWait: TRANSACTION_MAX_WAIT_MS,
|
maxWait: TRANSACTION_MAX_WAIT_MS,
|
||||||
@@ -40,37 +45,41 @@ export class BankCardsRepository {
|
|||||||
? error.code
|
? error.code
|
||||||
: '';
|
: '';
|
||||||
const busy =
|
const busy =
|
||||||
code === 'P2034' ||
|
isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) ||
|
||||||
code === 'P2028' ||
|
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
|
||||||
(error instanceof Error &&
|
(error instanceof Error &&
|
||||||
/SQLITE_BUSY|database is locked/.test(error.message));
|
/SQLITE_BUSY|database is locked/.test(error.message));
|
||||||
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT)
|
if (busy && attempt < MAX_WRITE_RETRY_COUNT) {
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
if (busy) conflict(BankErrorCode.WriteConflict);
|
if (busy) conflict(BankErrorCode.WriteConflict);
|
||||||
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
|
if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async card(tx: Tx, userId: string, id: string, active = false) {
|
async card(tx: Tx, userId: string, id: string, active = false) {
|
||||||
const card = await tx.bankCard.findFirst({ where: { id, userId } });
|
const card = await tx.bankCard.findFirst({ where: { id, userId } });
|
||||||
if (!card) return missing();
|
if (!card) return missing();
|
||||||
if (active && card.archivedAt) conflict(BankErrorCode.CardArchived);
|
if (active && card.archivedAt) conflict(BankErrorCode.CardArchived);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
async operation(tx: Tx, userId: string, id: string) {
|
async operation(tx: Tx, userId: string, id: string) {
|
||||||
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
|
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
|
||||||
if (!row) return missing();
|
if (!row) return missing();
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
async seed(tx: Tx, userId: string) {
|
async seed(tx: Tx, userId: string) {
|
||||||
for (const [index, label] of seeds.entries()) {
|
for (const [index, label] of seeds.entries()) {
|
||||||
const seedKey = index === seeds.length - 1 ? 'other' : String(index);
|
const seedKey = index === seeds.length - LAST_SEED_OFFSET ? 'other' : String(index);
|
||||||
if (
|
if (
|
||||||
!(await tx.expenseCategory.findUnique({
|
!(await tx.expenseCategory.findUnique({
|
||||||
where: { userId_seedKey: { userId, seedKey } },
|
where: { userId_seedKey: { userId, seedKey } },
|
||||||
}))
|
}))
|
||||||
)
|
) {
|
||||||
await tx.expenseCategory.create({
|
await tx.expenseCategory.create({
|
||||||
data: {
|
data: {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -82,6 +91,8 @@ export class BankCardsRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
summaryData(
|
summaryData(
|
||||||
userId: string,
|
userId: string,
|
||||||
period: { from: string; to: string },
|
period: { from: string; to: string },
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import {
|
|
||||||
MAX_CARD_NAME_LENGTH,
|
|
||||||
MAX_CATEGORY_NAME_LENGTH,
|
|
||||||
MAX_OPERATION_DESCRIPTION_LENGTH,
|
|
||||||
} from '../domain/bank-card-limits';
|
|
||||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
|
||||||
import {
|
import {
|
||||||
ApiProperty,
|
ApiProperty,
|
||||||
ApiPropertyOptional,
|
ApiPropertyOptional,
|
||||||
@@ -28,7 +22,20 @@ import {
|
|||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
import { MoneyDto } from '../../../../common/money.dto';
|
import { MoneyDto } from '../../../../common/money.dto';
|
||||||
|
import {
|
||||||
|
FIRST_PAGE,
|
||||||
|
MAX_CARD_NAME_LENGTH,
|
||||||
|
MAX_CATEGORY_NAME_LENGTH,
|
||||||
|
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||||
|
MIN_CARD_NAME_LENGTH,
|
||||||
|
MIN_CATEGORY_NAME_LENGTH,
|
||||||
|
MIN_ENTITY_VERSION,
|
||||||
|
} from '../domain/bank-card-limits';
|
||||||
|
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||||
|
|
||||||
|
const MIN_PAGE_SIZE = 1;
|
||||||
|
|
||||||
export const DEFAULT_BANK_PAGE_SIZE = 50;
|
export const DEFAULT_BANK_PAGE_SIZE = 50;
|
||||||
const MAX_BANK_PAGE_SIZE = 100;
|
const MAX_BANK_PAGE_SIZE = 100;
|
||||||
@@ -41,17 +48,19 @@ export class RubMoneyInputDto {
|
|||||||
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
|
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
|
||||||
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
|
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
|
||||||
@Matches(/^-?\d+(\.\d{1,2})?$/)
|
@Matches(/^-?\d+(\.\d{1,2})?$/)
|
||||||
declare value: string;
|
declare value: string;
|
||||||
}
|
}
|
||||||
export class CreateBankCardDto {
|
export class CreateBankCardDto {
|
||||||
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
|
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
|
||||||
@ApiProperty({ minLength: 1, maxLength: MAX_CARD_NAME_LENGTH })
|
@ApiProperty({ minLength: MIN_CARD_NAME_LENGTH, maxLength: MAX_CARD_NAME_LENGTH })
|
||||||
@Transform(trim)
|
@Transform(trim)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(MIN_CARD_NAME_LENGTH)
|
||||||
@MaxLength(MAX_CARD_NAME_LENGTH)
|
@MaxLength(MAX_CARD_NAME_LENGTH)
|
||||||
declare name: string;
|
declare name: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: String,
|
type: String,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
@@ -61,20 +70,24 @@ export class CreateBankCardDto {
|
|||||||
@Transform(trim)
|
@Transform(trim)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(MAX_CARD_NAME_LENGTH)
|
@MaxLength(MAX_CARD_NAME_LENGTH)
|
||||||
bankName?: string | null;
|
bankName?: null | string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
|
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(/^[0-9]{4}$/)
|
@Matches(/^\d{4}$/)
|
||||||
lastFour?: string | null;
|
lastFour?: null | string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true })
|
@ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(bankPaymentSystems)
|
@IsIn(bankPaymentSystems)
|
||||||
paymentSystem?: string | null;
|
paymentSystem?: null | string;
|
||||||
|
|
||||||
@ApiProperty({ type: RubMoneyInputDto })
|
@ApiProperty({ type: RubMoneyInputDto })
|
||||||
@IsDefined()
|
@IsDefined()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => RubMoneyInputDto)
|
@Type(() => RubMoneyInputDto)
|
||||||
declare openingBalance: MoneyDto;
|
declare openingBalance: MoneyDto;
|
||||||
|
|
||||||
@ApiProperty({ format: 'date' })
|
@ApiProperty({ format: 'date' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
@@ -84,50 +97,56 @@ export class UpdateBankCardDto extends PartialType(
|
|||||||
OmitType(CreateBankCardDto, ['id'] as const),
|
OmitType(CreateBankCardDto, ['id'] as const),
|
||||||
{ skipNullProperties: false },
|
{ skipNullProperties: false },
|
||||||
) {
|
) {
|
||||||
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
|
@ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
}
|
}
|
||||||
export class CategoryNameDto {
|
export class CategoryNameDto {
|
||||||
@ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH })
|
@ApiProperty({ minLength: MIN_CATEGORY_NAME_LENGTH, maxLength: MAX_CATEGORY_NAME_LENGTH })
|
||||||
@Transform(trim)
|
@Transform(trim)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(MIN_CATEGORY_NAME_LENGTH)
|
||||||
@MaxLength(MAX_CATEGORY_NAME_LENGTH)
|
@MaxLength(MAX_CATEGORY_NAME_LENGTH)
|
||||||
declare name: string;
|
declare name: string;
|
||||||
}
|
}
|
||||||
export class UpdateExpenseCategoryDto extends CategoryNameDto {
|
export class UpdateExpenseCategoryDto extends CategoryNameDto {
|
||||||
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
|
@ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
|
||||||
}
|
}
|
||||||
export class CreateBankOperationDto {
|
export class CreateBankOperationDto {
|
||||||
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
|
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
|
||||||
@ApiProperty({ enum: bankOperationKinds })
|
@ApiProperty({ enum: bankOperationKinds })
|
||||||
@IsIn(bankOperationKinds)
|
@IsIn(bankOperationKinds)
|
||||||
declare kind: BankOperationKind;
|
declare kind: BankOperationKind;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string;
|
@ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string;
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
targetCardId?: string;
|
targetCardId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
originalExpenseId?: string;
|
originalExpenseId?: string;
|
||||||
|
|
||||||
@ApiProperty({ type: RubMoneyInputDto })
|
@ApiProperty({ type: RubMoneyInputDto })
|
||||||
@IsDefined()
|
@IsDefined()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => RubMoneyInputDto)
|
@Type(() => RubMoneyInputDto)
|
||||||
declare amount: MoneyDto;
|
declare amount: MoneyDto;
|
||||||
|
|
||||||
@ApiProperty({ format: 'date' })
|
@ApiProperty({ format: 'date' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
declare date: string;
|
declare date: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH })
|
@ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH })
|
||||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -138,7 +157,7 @@ export class UpdateBankOperationDto extends PartialType(
|
|||||||
OmitType(CreateBankOperationDto, ['id'] as const),
|
OmitType(CreateBankOperationDto, ['id'] as const),
|
||||||
{ skipNullProperties: false },
|
{ skipNullProperties: false },
|
||||||
) {
|
) {
|
||||||
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
|
@ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
|
||||||
}
|
}
|
||||||
export class BankCardsQueryDto {
|
export class BankCardsQueryDto {
|
||||||
@ApiPropertyOptional({ enum: ['true', 'false'] })
|
@ApiPropertyOptional({ enum: ['true', 'false'] })
|
||||||
@@ -147,21 +166,22 @@ export class BankCardsQueryDto {
|
|||||||
includeArchived?: string;
|
includeArchived?: string;
|
||||||
}
|
}
|
||||||
export class BankPageQueryDto {
|
export class BankPageQueryDto {
|
||||||
@ApiPropertyOptional({ minimum: 1, default: 1 })
|
@ApiPropertyOptional({ minimum: FIRST_PAGE, default: FIRST_PAGE })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(FIRST_PAGE)
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
minimum: 1,
|
minimum: MIN_PAGE_SIZE,
|
||||||
maximum: MAX_BANK_PAGE_SIZE,
|
maximum: MAX_BANK_PAGE_SIZE,
|
||||||
default: DEFAULT_BANK_PAGE_SIZE,
|
default: DEFAULT_BANK_PAGE_SIZE,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(MIN_PAGE_SIZE)
|
||||||
@Max(MAX_BANK_PAGE_SIZE)
|
@Max(MAX_BANK_PAGE_SIZE)
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
@@ -173,6 +193,7 @@ export class BankPeriodQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
|
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
|
||||||
month?: string;
|
month?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'date',
|
format: 'date',
|
||||||
description: 'Inclusive start date; requires to.',
|
description: 'Inclusive start date; requires to.',
|
||||||
@@ -180,6 +201,7 @@ export class BankPeriodQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
from?: string;
|
from?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'date',
|
format: 'date',
|
||||||
description: 'Inclusive end date; requires from.',
|
description: 'Inclusive end date; requires from.',
|
||||||
@@ -199,10 +221,10 @@ export class BankSummaryQueryDto extends BankPeriodQueryDto {
|
|||||||
cardId?: string;
|
cardId?: string;
|
||||||
}
|
}
|
||||||
export class BankVersionQueryDto {
|
export class BankVersionQueryDto {
|
||||||
@ApiProperty({ minimum: 1 })
|
@ApiProperty({ minimum: MIN_ENTITY_VERSION })
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(MIN_ENTITY_VERSION)
|
||||||
declare version: number;
|
declare version: number;
|
||||||
}
|
}
|
||||||
export class ExpenseCategoryDto {
|
export class ExpenseCategoryDto {
|
||||||
@@ -215,16 +237,20 @@ export class BankCardDto {
|
|||||||
@ApiProperty({ type: String }) declare id: string;
|
@ApiProperty({ type: String }) declare id: string;
|
||||||
@ApiProperty({ type: String }) declare name: string;
|
@ApiProperty({ type: String }) declare name: string;
|
||||||
@ApiProperty({ type: String, nullable: true }) declare bankName:
|
@ApiProperty({ type: String, nullable: true }) declare bankName:
|
||||||
string | null;
|
null | string;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true }) declare lastFour:
|
@ApiProperty({ type: String, nullable: true }) declare lastFour:
|
||||||
string | null;
|
null | string;
|
||||||
|
|
||||||
@ApiProperty({ enum: bankPaymentSystems, nullable: true })
|
@ApiProperty({ enum: bankPaymentSystems, nullable: true })
|
||||||
declare paymentSystem: string | null;
|
declare paymentSystem: null | string;
|
||||||
|
|
||||||
@ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto;
|
||||||
@ApiProperty({ type: String }) declare openingDate: string;
|
@ApiProperty({ type: String }) declare openingDate: string;
|
||||||
@ApiProperty({ type: MoneyDto }) declare balance: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare balance: MoneyDto;
|
||||||
@ApiProperty({ type: String, nullable: true }) declare archivedAt:
|
@ApiProperty({ type: String, nullable: true }) declare archivedAt:
|
||||||
string | null;
|
null | string;
|
||||||
|
|
||||||
@ApiProperty({ type: Number }) declare version: number;
|
@ApiProperty({ type: Number }) declare version: number;
|
||||||
}
|
}
|
||||||
export class BankOperationDto {
|
export class BankOperationDto {
|
||||||
@@ -233,27 +259,35 @@ export class BankOperationDto {
|
|||||||
@ApiProperty({ type: String }) declare cardId: string;
|
@ApiProperty({ type: String }) declare cardId: string;
|
||||||
@ApiProperty({ type: String }) declare cardName: string;
|
@ApiProperty({ type: String }) declare cardName: string;
|
||||||
@ApiProperty({ type: String, nullable: true }) declare targetCardId:
|
@ApiProperty({ type: String, nullable: true }) declare targetCardId:
|
||||||
string | null;
|
null | string;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true }) declare targetCardName:
|
@ApiProperty({ type: String, nullable: true }) declare targetCardName:
|
||||||
string | null;
|
null | string;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true }) declare categoryId:
|
@ApiProperty({ type: String, nullable: true }) declare categoryId:
|
||||||
string | null;
|
null | string;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true }) declare categoryName:
|
@ApiProperty({ type: String, nullable: true }) declare categoryName:
|
||||||
string | null;
|
null | string;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true }) declare originalExpenseId:
|
@ApiProperty({ type: String, nullable: true }) declare originalExpenseId:
|
||||||
string | null;
|
null | string;
|
||||||
|
|
||||||
@ApiProperty({ type: MoneyDto }) declare amount: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare amount: MoneyDto;
|
||||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||||
declare availableRefund: MoneyDto | null;
|
declare availableRefund: MoneyDto | null;
|
||||||
|
|
||||||
@ApiProperty({ type: String }) declare date: string;
|
@ApiProperty({ type: String }) declare date: string;
|
||||||
@ApiProperty({ type: String, nullable: true }) declare description:
|
@ApiProperty({ type: String, nullable: true }) declare description:
|
||||||
string | null;
|
null | string;
|
||||||
|
|
||||||
@ApiProperty({ type: Number }) declare version: number;
|
@ApiProperty({ type: Number }) declare version: number;
|
||||||
@ApiProperty({ type: String }) declare createdAt: string;
|
@ApiProperty({ type: String }) declare createdAt: string;
|
||||||
}
|
}
|
||||||
export class BankOperationsPageDto {
|
export class BankOperationsPageDto {
|
||||||
@ApiProperty({ type: BankOperationDto, isArray: true })
|
@ApiProperty({ type: BankOperationDto, isArray: true })
|
||||||
declare items: BankOperationDto[];
|
declare items: BankOperationDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: Number }) declare page: number;
|
@ApiProperty({ type: Number }) declare page: number;
|
||||||
@ApiProperty({ type: Number }) declare total: number;
|
@ApiProperty({ type: Number }) declare total: number;
|
||||||
@ApiProperty({ type: Boolean }) declare hasNext: boolean;
|
@ApiProperty({ type: Boolean }) declare hasNext: boolean;
|
||||||
@@ -269,7 +303,7 @@ export class BankSummaryDto {
|
|||||||
@ApiPropertyOptional({ type: String }) month?: string;
|
@ApiPropertyOptional({ type: String }) month?: string;
|
||||||
@ApiPropertyOptional({ format: 'date' }) from?: string;
|
@ApiPropertyOptional({ format: 'date' }) from?: string;
|
||||||
@ApiPropertyOptional({ format: 'date' }) to?: string;
|
@ApiPropertyOptional({ format: 'date' }) to?: string;
|
||||||
@ApiProperty({ type: String, nullable: true }) declare cardId: string | null;
|
@ApiProperty({ type: String, nullable: true }) declare cardId: null | string;
|
||||||
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
|
||||||
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
|
||||||
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -21,12 +23,16 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
getSchemaPath,
|
getSchemaPath,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import type { Request } from 'express';
|
|
||||||
import {
|
import {
|
||||||
ApiProblemResponses,
|
ApiProblemResponses,
|
||||||
ProblemDetailsDto,
|
ProblemDetailsDto,
|
||||||
} from '../../../../common/decorators/api-safe-error-response.decorator';
|
} from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||||
|
import { BankCardsService } from '../application/bank-cards.service';
|
||||||
|
import { BankOperationsService } from '../application/bank-operations.service';
|
||||||
|
import { ExpenseCategoriesService } from '../application/expense-categories.service';
|
||||||
|
import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case';
|
||||||
import {
|
import {
|
||||||
BankCardDto,
|
BankCardDto,
|
||||||
BankCardsQueryDto,
|
BankCardsQueryDto,
|
||||||
@@ -45,10 +51,6 @@ import {
|
|||||||
UpdateBankOperationDto,
|
UpdateBankOperationDto,
|
||||||
UpdateExpenseCategoryDto,
|
UpdateExpenseCategoryDto,
|
||||||
} from './bank-card.dto';
|
} from './bank-card.dto';
|
||||||
import { BankCardsService } from '../application/bank-cards.service';
|
|
||||||
import { BankOperationsService } from '../application/bank-operations.service';
|
|
||||||
import { ExpenseCategoriesService } from '../application/expense-categories.service';
|
|
||||||
import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case';
|
|
||||||
type AuthRequest = Request & { user: AccessPayload };
|
type AuthRequest = Request & { user: AccessPayload };
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 409,
|
status: 409,
|
||||||
@@ -82,26 +84,31 @@ export class BankCardsController {
|
|||||||
private readonly expenseCategories: ExpenseCategoriesService,
|
private readonly expenseCategories: ExpenseCategoriesService,
|
||||||
private readonly bankSummary: GetBankSummaryUseCase,
|
private readonly bankSummary: GetBankSummaryUseCase,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ type: BankCardDto, isArray: true })
|
@ApiOkResponse({ type: BankCardDto, isArray: true })
|
||||||
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
|
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
|
||||||
return this.cards.list(req.user.sub, query.includeArchived === 'true');
|
return this.cards.list(req.user.sub, query.includeArchived === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiCreatedResponse({ type: BankCardDto })
|
@ApiCreatedResponse({ type: BankCardDto })
|
||||||
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
|
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
|
||||||
return this.cards.createCard(req.user.sub, body);
|
return this.cards.createCard(req.user.sub, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('categories')
|
@Get('categories')
|
||||||
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
|
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
|
||||||
categories(@Req() req: AuthRequest) {
|
categories(@Req() req: AuthRequest) {
|
||||||
return this.expenseCategories.categories(req.user.sub);
|
return this.expenseCategories.categories(req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('categories')
|
@Post('categories')
|
||||||
@ApiCreatedResponse({ type: ExpenseCategoryDto })
|
@ApiCreatedResponse({ type: ExpenseCategoryDto })
|
||||||
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
|
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
|
||||||
return this.expenseCategories.createCategory(req.user.sub, body.name);
|
return this.expenseCategories.createCategory(req.user.sub, body.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('categories/:id')
|
@Patch('categories/:id')
|
||||||
@ApiOkResponse({ type: ExpenseCategoryDto })
|
@ApiOkResponse({ type: ExpenseCategoryDto })
|
||||||
updateCategory(
|
updateCategory(
|
||||||
@@ -116,11 +123,13 @@ export class BankCardsController {
|
|||||||
body.version,
|
body.version,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
@ApiOkResponse({ type: BankSummaryDto })
|
@ApiOkResponse({ type: BankSummaryDto })
|
||||||
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
|
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
|
||||||
return this.bankSummary.execute(req.user.sub, query);
|
return this.bankSummary.execute(req.user.sub, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('operations')
|
@Post('operations')
|
||||||
@ApiCreatedResponse({ type: BankOperationDto })
|
@ApiCreatedResponse({ type: BankOperationDto })
|
||||||
createOperation(
|
createOperation(
|
||||||
@@ -129,11 +138,13 @@ export class BankCardsController {
|
|||||||
) {
|
) {
|
||||||
return this.operations.createOperation(req.user.sub, body);
|
return this.operations.createOperation(req.user.sub, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('operations/:id')
|
@Get('operations/:id')
|
||||||
@ApiOkResponse({ type: BankOperationDto })
|
@ApiOkResponse({ type: BankOperationDto })
|
||||||
operation(@Req() req: AuthRequest, @Param('id') id: string) {
|
operation(@Req() req: AuthRequest, @Param('id') id: string) {
|
||||||
return this.operations.getOperation(req.user.sub, id);
|
return this.operations.getOperation(req.user.sub, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('operations/:id')
|
@Patch('operations/:id')
|
||||||
@ApiOkResponse({ type: BankOperationDto })
|
@ApiOkResponse({ type: BankOperationDto })
|
||||||
updateOperation(
|
updateOperation(
|
||||||
@@ -143,6 +154,7 @@ export class BankCardsController {
|
|||||||
) {
|
) {
|
||||||
return this.operations.updateOperation(req.user.sub, id, body);
|
return this.operations.updateOperation(req.user.sub, id, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('operations/:id')
|
@Delete('operations/:id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiNoContentResponse()
|
@ApiNoContentResponse()
|
||||||
@@ -153,6 +165,7 @@ export class BankCardsController {
|
|||||||
) {
|
) {
|
||||||
return this.operations.deleteOperation(req.user.sub, id, query.version);
|
return this.operations.deleteOperation(req.user.sub, id, query.version);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/operations')
|
@Get(':id/operations')
|
||||||
@ApiOkResponse({ type: BankOperationsPageDto })
|
@ApiOkResponse({ type: BankOperationsPageDto })
|
||||||
history(
|
history(
|
||||||
@@ -162,6 +175,7 @@ export class BankCardsController {
|
|||||||
) {
|
) {
|
||||||
return this.operations.history(req.user.sub, id, query);
|
return this.operations.history(req.user.sub, id, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/expenses')
|
@Get(':id/expenses')
|
||||||
@ApiOkResponse({ type: BankOperationsPageDto })
|
@ApiOkResponse({ type: BankOperationsPageDto })
|
||||||
expenses(
|
expenses(
|
||||||
@@ -171,11 +185,13 @@ export class BankCardsController {
|
|||||||
) {
|
) {
|
||||||
return this.operations.history(req.user.sub, id, query, true);
|
return this.operations.history(req.user.sub, id, query, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOkResponse({ type: BankCardDto })
|
@ApiOkResponse({ type: BankCardDto })
|
||||||
get(@Req() req: AuthRequest, @Param('id') id: string) {
|
get(@Req() req: AuthRequest, @Param('id') id: string) {
|
||||||
return this.cards.get(req.user.sub, id);
|
return this.cards.get(req.user.sub, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ApiOkResponse({ type: BankCardDto })
|
@ApiOkResponse({ type: BankCardDto })
|
||||||
update(
|
update(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { MoneyDto } from '../../../common/money.dto';
|
import { MoneyDto } from '../../../common/money.dto';
|
||||||
|
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||||
|
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
|
||||||
|
|
||||||
export class PortfolioSummaryDto {
|
export class PortfolioSummaryDto {
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||||
@@ -40,15 +42,26 @@ export class BrokerageAccountDto {
|
|||||||
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
|
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||||
declare openedOn: string | null;
|
declare openedOn: null | string;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Preserve the published required nullable schema; partial internal models may omit the field.
|
||||||
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
|
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
|
||||||
portfolioSummary?: PortfolioSummaryDto | null;
|
portfolioSummary?: null | PortfolioSummaryDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerageAccountsResourceDto {
|
export class BrokerageAccountsResourceDto {
|
||||||
|
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema.
|
||||||
@ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true })
|
@ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true })
|
||||||
declare data: BrokerageAccountDto[] | null;
|
declare data: BrokerageAccountDto[] | null;
|
||||||
|
|
||||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
declare meta: InvestmentResourceMetaDto;
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: HourlySyncStatusDto,
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'Read-only status of the last hourly walk for the current connection. Null when no walk attempt exists yet.',
|
||||||
|
})
|
||||||
|
declare sync: HourlySyncStatusDto | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { BrokerageAccountsService } from './brokerage-accounts.service';
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
|
||||||
import { Controller, Get, Req, Query, UseGuards } from '@nestjs/common';
|
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||||
@@ -17,10 +18,7 @@ export class BrokerageAccountsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ type: BrokerageAccountsResourceDto })
|
@ApiOkResponse({ type: BrokerageAccountsResourceDto })
|
||||||
list(
|
list(@Req() request: Request & { user: AccessPayload }) {
|
||||||
@Req() request: Request & { user: AccessPayload },
|
return this.accounts.list(request.user.sub);
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
|
||||||
return this.accounts.list(request.user.sub, query.sync === 'observe');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
|
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
|
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||||
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TbankModule, PortfolioModule],
|
imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncRepositoryModule],
|
||||||
controllers: [BrokerageAccountsController],
|
controllers: [BrokerageAccountsController],
|
||||||
providers: [BrokerageAccountsService],
|
providers: [BrokerageAccountsService],
|
||||||
exports: [BrokerageAccountsService],
|
exports: [BrokerageAccountsService],
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
jest.mock('../../../prisma/prisma.service', () => ({
|
jest.mock('../../../prisma/prisma.service', () => ({
|
||||||
PrismaService: class {},
|
PrismaService: class {},
|
||||||
}));
|
}));
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
|
||||||
jest.mock('../portfolio/application/portfolio.service', () => ({
|
jest.mock('../portfolio/application/portfolio.service', () => ({
|
||||||
PortfolioService: class PortfolioService {},
|
PortfolioService: class PortfolioService {},
|
||||||
@@ -19,16 +19,17 @@ const meta = investmentResourceMeta({
|
|||||||
|
|
||||||
describe('saved brokerage account projection', () => {
|
describe('saved brokerage account projection', () => {
|
||||||
const portfolio = { saved: jest.fn() };
|
const portfolio = { saved: jest.fn() };
|
||||||
const service = new BrokerageAccountsService(portfolio as never);
|
const sync = { hourly: jest.fn().mockResolvedValue(null) };
|
||||||
|
const service = new BrokerageAccountsService(portfolio as never, sync as never);
|
||||||
|
|
||||||
it('distinguishes unknown membership from a confirmed empty list', async () => {
|
it('distinguishes unknown membership from a confirmed empty list', async () => {
|
||||||
portfolio.saved.mockResolvedValueOnce({
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
membership: { ...meta, availability: 'missing' },
|
membership: { ...meta, completeness: 'incomplete' },
|
||||||
accounts: [],
|
accounts: [],
|
||||||
});
|
});
|
||||||
expect((await service.list('owner', true)).data).toBeNull();
|
expect((await service.list('owner')).data).toBeNull();
|
||||||
portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] });
|
portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] });
|
||||||
expect((await service.list('owner', true)).data).toEqual([]);
|
expect((await service.list('owner')).data).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps exact saved totals and independent missing summaries', async () => {
|
it('keeps exact saved totals and independent missing summaries', async () => {
|
||||||
@@ -58,13 +59,46 @@ describe('saved brokerage account projection', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const response = await service.list('owner', true);
|
const response = await service.list('owner');
|
||||||
expect(response.data?.[0].portfolioSummary).toEqual({
|
expect(response.data?.[0].portfolioSummary).toEqual({
|
||||||
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
||||||
stockPositionCount: 1,
|
stockPositionCount: 1,
|
||||||
bondPositionCount: 1,
|
bondPositionCount: 1,
|
||||||
});
|
});
|
||||||
expect(response.data?.[1].portfolioSummary).toBeNull();
|
expect(response.data?.[1].portfolioSummary).toBeNull();
|
||||||
expect(response.meta.availability).toBe('partial');
|
expect(response.meta.availability).toBe('available');
|
||||||
|
expect(response.meta.completeness).toBe('complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('observes the latest hourly walk without starting another one', async () => {
|
||||||
|
portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] });
|
||||||
|
sync.hourly.mockResolvedValue({
|
||||||
|
startedAt: new Date('2026-09-25T12:00:00Z'),
|
||||||
|
finishedAt: new Date('2026-09-25T12:01:00Z'),
|
||||||
|
state: 'partial',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
section: 'portfolio',
|
||||||
|
accountId: 'a',
|
||||||
|
errorCode: 'TBANK_UNAVAILABLE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await service.list('owner');
|
||||||
|
|
||||||
|
expect(response.sync).toEqual({
|
||||||
|
startedAt: '2026-09-25T12:00:00.000Z',
|
||||||
|
finishedAt: '2026-09-25T12:01:00.000Z',
|
||||||
|
state: 'partial',
|
||||||
|
problems: [
|
||||||
|
{
|
||||||
|
section: 'portfolio',
|
||||||
|
accountId: 'a',
|
||||||
|
errorCode: 'TBANK_UNAVAILABLE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(sync.hourly).toHaveBeenCalledWith('owner');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||||
|
import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.repository';
|
||||||
|
import { BrokerageAccountDto, BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerageAccountsService {
|
export class BrokerageAccountsService {
|
||||||
constructor(private readonly portfolio: PortfolioService) {}
|
constructor(
|
||||||
|
private readonly portfolio: PortfolioService,
|
||||||
|
private readonly sync: TbankSyncRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
async list(userId: string, observe = false) {
|
async list(userId: string): Promise<BrokerageAccountsResourceDto> {
|
||||||
const saved = await this.portfolio.saved(userId, 'all', observe);
|
const saved = await this.portfolio.saved(userId, 'all');
|
||||||
const data: BrokerageAccountDto[] | null =
|
const data: BrokerageAccountDto[] | null =
|
||||||
saved.membership.availability === 'missing'
|
saved.membership.completeness === 'incomplete'
|
||||||
? null
|
? null
|
||||||
: saved.accounts.map((account) => ({
|
: saved.accounts.map((account) => ({
|
||||||
id: account.id,
|
id: account.id,
|
||||||
@@ -30,6 +35,8 @@ export class BrokerageAccountsService {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
|
// The read-only result of the last hourly walk; never starts one.
|
||||||
|
const hourly = await this.sync.hourly(userId);
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
@@ -48,18 +55,26 @@ export class BrokerageAccountsService {
|
|||||||
availability:
|
availability:
|
||||||
data === null
|
data === null
|
||||||
? 'missing'
|
? 'missing'
|
||||||
: saved.accounts.some((account) => !account.snapshot)
|
|
||||||
? 'partial'
|
|
||||||
: 'complete',
|
: 'complete',
|
||||||
fetchedAt: saved.membership.lastSuccessfulAt
|
fetchedAt: saved.membership.lastSuccessfulAt
|
||||||
? new Date(saved.membership.lastSuccessfulAt)
|
? new Date(saved.membership.lastSuccessfulAt)
|
||||||
: null,
|
: null,
|
||||||
freshForMs: 300_000,
|
freshForMs: 3_600_000,
|
||||||
work: saved.membership.sync.id
|
work: null,
|
||||||
? { ...saved.membership.sync, id: saved.membership.sync.id }
|
lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']),
|
||||||
: null,
|
|
||||||
dependencies: saved.accounts.map((account) => account.meta),
|
dependencies: saved.accounts.map((account) => account.meta),
|
||||||
}),
|
}),
|
||||||
|
sync: hourly
|
||||||
|
? {
|
||||||
|
state: hourly.state,
|
||||||
|
startedAt: hourly.startedAt.toISOString(),
|
||||||
|
finishedAt: hourly.finishedAt?.toISOString() ?? null,
|
||||||
|
problems: hourly.sections.map((section) => ({
|
||||||
|
...section,
|
||||||
|
accountId: section.accountId ?? null,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user