Compare commits
20
Commits
v0.1.0
...
ca5dbcb382
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca5dbcb382 | ||
|
|
4796d9d3f0 | ||
|
|
5d3835e796 | ||
|
|
516e43203e | ||
|
|
e4232138d0 | ||
|
|
d6e6c4ace5 | ||
|
|
0862d56c4e | ||
|
|
7c0150c56b | ||
|
|
39f2ce5bc4 | ||
|
|
4c916829ea | ||
|
|
386c91670e | ||
|
|
9437b6a311 | ||
|
|
0ec7ae51af | ||
|
|
1946192be9 | ||
|
|
0557f4ad1f | ||
|
|
d7cb7e3e20 | ||
|
|
1a6be432f2 | ||
|
|
32c12fb10d | ||
|
|
016782657a | ||
|
|
9810d47e42 |
@@ -1,25 +0,0 @@
|
|||||||
# Backend router
|
|
||||||
|
|
||||||
Объединяй triggers из смысла задачи, путей и обнаруженных boundaries.
|
|
||||||
Читай выбранные guides до решения; якорь допускает чтение только нужного раздела.
|
|
||||||
Просто backend-файл не требует всех conventions.
|
|
||||||
|
|
||||||
| Решение / сигнал | Читать |
|
|
||||||
| --- | --- |
|
|
||||||
| Новый файл/suffix/layer; поиск feature owner; progressive structure; перенос responsibility | [Architecture](conventions/backend-architecture.md) |
|
|
||||||
| TS vocabulary, domain literals, magic values | [Domain literals](conventions/backend-architecture.md#domain-literals-and-magic-values) |
|
|
||||||
| Controller, DTO, route/status, validation/exception, публичная HTTP-семантика | [HTTP API](conventions/backend-http-api.md) |
|
|
||||||
| Repository/data access; Prisma/schema/query, transaction, migration, atomic write | [Data](conventions/backend-data.md) |
|
|
||||||
| Тесты или behavior с testing risk | [Testing](conventions/backend-testing.md) |
|
|
||||||
| JWT, cookie, login/refresh/logout, guard/access, token encryption, sensitive logs | [Security](conventions/security-auth.md) |
|
|
||||||
| External client/integration boundary; T-Bank/gRPC/proto, retry/rate limit/cache | [Integrations](conventions/backend-integrations.md) |
|
|
||||||
| Logging/request context без изменения secrets/auth | [Logging](conventions/backend-architecture.md#logging) |
|
|
||||||
| Новый abstraction/dependency, refactor, неясный scope | [Общий выбор реализации](../../AGENTS.md) |
|
|
||||||
| Изменение request/response/route/status/error shape или Swagger metadata | HTTP API + [Источник HTTP-контракта](../../docs/architecture/http-api.md#источник-контракта) |
|
|
||||||
|
|
||||||
Новый endpoint обычно требует HTTP API + Testing; Architecture/Data/Security
|
|
||||||
добавляются по новым ответственности, persistence или access rules.
|
|
||||||
Изменение одного Prisma query без HTTP/auth границы не требует HTTP API/Security.
|
|
||||||
Auth refresh активирует Security + Data + Testing; HTTP API добавляется, если
|
|
||||||
меняется controller/HTTP contract. Exact docs/spec sources выбирай через SOURCES.
|
|
||||||
Повтори routing при расширении diff, включая новые и unstaged файлы.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Источники backend
|
|
||||||
|
|
||||||
| Что нужно установить | Владелец |
|
|
||||||
| --- | --- |
|
|
||||||
| Implementation decision и безопасный pattern | [CONVENTIONS](CONVENTIONS.md) |
|
|
||||||
| HTTP errors, DTO semantics, public wire shape | [docs: HTTP API](../../docs/architecture/http-api.md); runtime OpenAPI из Nest — единственный editable source |
|
|
||||||
| Auth/session, account access, token encryption | [Аутентификация](../../docs/domains/authentication/overview.md), [доступ к данным](../../docs/architecture/containers.md#хранимые-данные-и-границы-доступа), [подключение брокера](../../docs/integrations/tbank.md) |
|
|
||||||
| Prisma schema/data ownership и migration context | [docs: Data](../../docs/architecture/containers.md#хранимые-данные-и-границы-доступа) |
|
|
||||||
| T-Bank/gRPC/cache, logging | [T-Bank](../../docs/integrations/tbank.md), [свежесть данных](../../docs/architecture/cross-cutting/data-freshness.md), [наблюдаемость](../../docs/architecture/http-api.md#наблюдаемость) |
|
|
||||||
| Money, account/position/income/cache meanings | [docs: Domain](../../docs/glossary.md) |
|
|
||||||
| Поведение конкретной feature | [docs: Features](../../docs/README.md#предметные-области) |
|
|
||||||
| Существующие behavioral requirements при необходимости | [specs index](../../specs/AI_INDEX.md) |
|
|
||||||
| Versions/scripts и scope автоматических checks | package.json/lockfile, ESLint/TypeScript/Jest/Prisma configs |
|
|
||||||
|
|
||||||
SOURCES — карта, не требование прочитать все ссылки. Code показывает реализацию,
|
|
||||||
ADR — rationale, docs — текущие факты; старый пример не отменяет контракт.
|
|
||||||
Исследование предлагает варианты, а actual source разрешает выбор. При missing
|
|
||||||
или conflicting security/data facts сначала установи источник. Для обычной
|
|
||||||
работы OpenSpec/SDD не требуется.
|
|
||||||
@@ -1,12 +1,54 @@
|
|||||||
# Backend architecture
|
# Backend architecture
|
||||||
|
|
||||||
|
## Устройство и data flow
|
||||||
|
|
||||||
|
NestJS + TypeScript modular monolith. Composition root `AppModule` подключает
|
||||||
|
бизнес-модули, validated configuration, Prisma и Pino. Общая `configureApp`
|
||||||
|
задаёт `/api/v1`, CORS/cookies, validation, request logging и error filter.
|
||||||
|
Точные versions/scripts — в [package.json](../package.json); source code
|
||||||
|
определяет фактическую реализацию.
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP controller → application service/use case → pure domain rules
|
||||||
|
↓
|
||||||
|
repository/data access → Prisma → SQLite
|
||||||
|
↓
|
||||||
|
integration client → T-Bank gRPC / MOEX / NCR HTTP
|
||||||
|
```
|
||||||
|
|
||||||
|
`modules/auth`, `modules/banking/bank-cards` и области `modules/investments`
|
||||||
|
владеют пользовательскими возможностями. Сложные модули разделены на
|
||||||
|
presentation/application/domain/infrastructure; компактные остаются плоскими.
|
||||||
|
`*.module.ts` — Nest DI, `*.api.ts` — используемые cross-module exports.
|
||||||
|
Application использует конкретные providers; обязательных ports на каждый
|
||||||
|
класс нет.
|
||||||
|
|
||||||
|
Stocks/bonds — slices одного instruments module: catalog владеет identity/cache,
|
||||||
|
market — котировками, income — выплатами/личными позициями. Общая orchestration
|
||||||
|
в `instruments/application` использует DTO/provider records и не становится pure
|
||||||
|
domain из-за extraction в функцию. Boundary mappers преобразуют внешние records;
|
||||||
|
чистые расчёты portfolio/banking остаются у domain owner.
|
||||||
|
|
||||||
|
PrismaService владеет DB lifecycle. Самостоятельные use cases получают данные
|
||||||
|
через concrete repositories; существующие связные CRUD/transaction services
|
||||||
|
сохраняют прямые queries по [data convention](conventions/data.md).
|
||||||
|
Money — currency + decimal string; расчёты используют точные общие helpers.
|
||||||
|
|
||||||
|
Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и
|
||||||
|
connection generation. Integration clients владеют protocol/deadline/retry;
|
||||||
|
provider records преобразуются перед чистыми domain calculations.
|
||||||
|
Пользовательский `tbank-connection` не владеет низкоуровневым gRPC client.
|
||||||
|
|
||||||
|
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
|
||||||
|
для публичных consumers.
|
||||||
|
Problem Details фильтруются общей HTTP boundary; StructuredLogger добавляет
|
||||||
|
request context и ограничивает поля событий. Jest unit tests, изолированные
|
||||||
|
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
|
||||||
|
Локальные источники выбираются через [Источники](../AGENTS.md#источники).
|
||||||
|
|
||||||
## Module-first organization
|
## Module-first organization
|
||||||
|
|
||||||
Backend — domain-oriented modular monolith с лёгким разделением
|
Сначала выбери business owner в `modules/`, затем ответственность внутри него.
|
||||||
по ответственности. `src/modules` группирует код по бизнес-возможности: `auth`, `banking/bank-cards`, области `investments`.
|
|
||||||
В инвестициях отдельные Nest modules владеют `brokerage-accounts`, `portfolio`,
|
|
||||||
`operations`, `income`, `upcoming-events`, `instruments`, `tbank-connection`.
|
|
||||||
`instruments/stocks` и `instruments/bonds` — slices внутри одного модуля.
|
|
||||||
|
|
||||||
### Progressive structure
|
### Progressive structure
|
||||||
|
|
||||||
@@ -36,15 +78,6 @@ feature/
|
|||||||
Для одного controller или пары DTO дополнительная поддиректория не нужна.
|
Для одного controller или пары DTO дополнительная поддиректория не нужна.
|
||||||
Пустые папки, новые barrels, CQRS и interfaces не требуются для этой организации.
|
Пустые папки, новые barrels, CQRS и interfaces не требуются для этой организации.
|
||||||
|
|
||||||
В текущем коде слои используют `auth`, `bank-cards`, `portfolio`, `operations`
|
|
||||||
и slices `instruments/stocks`, `instruments/bonds`. Общие application helpers
|
|
||||||
инструментов находятся в `instruments/application`; `instruments.module.ts`
|
|
||||||
компонует оба slice. `stock-position.ts` и `instrument-position.ts` формируют
|
|
||||||
результат из portfolio DTO; `instrument-market.ts` обрабатывает внешние данные
|
|
||||||
и ошибки Nest. Это application code, а не независимая domain model.
|
|
||||||
`brokerage-accounts`, `income`, `upcoming-events`, `tbank-connection` остаются
|
|
||||||
компактными плоскими modules. Наличие tests само по себе не требует слоёв.
|
|
||||||
|
|
||||||
## Поиск и создание файла
|
## Поиск и создание файла
|
||||||
|
|
||||||
При работе над feature проходи владельцев в порядке:
|
При работе над feature проходи владельцев в порядке:
|
||||||
@@ -116,7 +149,7 @@ Cross-module consumers во всех строках ограничены пуб
|
|||||||
`*.decorator.ts`, `*.config.ts`, `*.validation.ts`) сохраняют соответствующие
|
`*.decorator.ts`, `*.config.ts`, `*.validation.ts`) сохраняют соответствующие
|
||||||
существующие роли в common/config. `tbank.crypto.ts` — локальная реализация
|
существующие роли в common/config. `tbank.crypto.ts` — локальная реализация
|
||||||
шифрования токена; имя не является разрешением создавать generic crypto layer.
|
шифрования токена; имя не является разрешением создавать generic crypto layer.
|
||||||
HTTP/e2e naming и расположение — в [Testing](backend-testing.md).
|
HTTP/e2e naming и расположение — в [Testing](conventions/testing.md).
|
||||||
|
|
||||||
Без зафиксированной необходимости не вводи `*.helper.ts`, `*.utils.ts`,
|
Без зафиксированной необходимости не вводи `*.helper.ts`, `*.utils.ts`,
|
||||||
`*.manager.ts`, `*.processor.ts`, `*.handler.ts`, `*.provider.ts`, `*.factory.ts`.
|
`*.manager.ts`, `*.processor.ts`, `*.handler.ts`, `*.provider.ts`, `*.factory.ts`.
|
||||||
@@ -127,13 +160,19 @@ HTTP/e2e naming и расположение — в [Testing](backend-testing.md)
|
|||||||
|
|
||||||
`*.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` и
|
||||||
публичный вход для protobuf conversion: импорт арифметики не загружает DI/Prisma.
|
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
|
||||||
|
публичные входы для protobuf conversion и interval rules: импорт расчётов
|
||||||
|
не загружает DI/Prisma.
|
||||||
Внутри модуля используй прямые локальные импорты.
|
Внутри модуля используй прямые локальные импорты.
|
||||||
Tests могут импортировать внутренний owner для проверки поведения и mocks.
|
Tests могут импортировать внутренний owner для проверки поведения и mocks.
|
||||||
Новый export требует текущего потребителя; controller и persistence остаются
|
Новый export требует текущего потребителя; controller и persistence остаются
|
||||||
приватными. Не добавляй `index.ts` на каждом уровне.
|
приватными. Не добавляй `index.ts` на каждом уровне.
|
||||||
|
|
||||||
|
Circular dependencies устраняй изменением ownership/composition. `forwardRef()`
|
||||||
|
не является стандартным исправлением boundaries; исключение требует конкретной
|
||||||
|
неизбежной связи и проверки DI initialization.
|
||||||
|
|
||||||
Каждый provider регистрируется у одного владельца. Потребители импортируют
|
Каждый provider регистрируется у одного владельца. Потребители импортируют
|
||||||
его module, а не повторяют provider: это сохраняет единственный кэш и лимитер.
|
его module, а не повторяют provider: это сохраняет единственный кэш и лимитер.
|
||||||
`AuthModule` экспортирует guard; наличие JWT не заменяет account access check.
|
`AuthModule` экспортирует guard; наличие JWT не заменяет account access check.
|
||||||
@@ -162,37 +201,25 @@ Tests могут импортировать внутренний owner для п
|
|||||||
может использовать application types/input и domain rules; новые interfaces
|
может использовать application types/input и domain rules; новые interfaces
|
||||||
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
|
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
|
||||||
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries
|
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries
|
||||||
по [Data](backend-data.md); use case получает данные через repository и сам
|
по [Data](conventions/data.md); use case получает данные через repository и сам
|
||||||
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется.
|
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется.
|
||||||
DTO остаются транспортными runtime classes; существующее использование DTO
|
DTO остаются транспортными runtime classes; существующее использование DTO
|
||||||
на application boundary допустимо по [HTTP API](backend-http-api.md).
|
на application boundary допустимо по [HTTP API](conventions/http-api.md).
|
||||||
|
|
||||||
Сохраняй существующий error contract: HTTP exceptions остаются на application/
|
Сохраняй существующий error contract: HTTP exceptions остаются на application/
|
||||||
transport boundary. Чистому расчёту не нужен новый framework ошибок.
|
transport boundary. Чистому расчёту не нужен новый framework ошибок.
|
||||||
Точные ограничения — [HTTP](backend-http-api.md), [Data](backend-data.md),
|
Mapping и stable codes — по [Errors](conventions/error-handling.md).
|
||||||
[Security](security-auth.md), [Integrations](backend-integrations.md).
|
Точные ограничения — [HTTP](conventions/http-api.md), [Data](conventions/data.md),
|
||||||
|
[Security](conventions/security-auth.md), [Integrations](conventions/integrations.md).
|
||||||
|
|
||||||
## Сценарии и размер сервисов
|
## Сценарии и размер сервисов
|
||||||
|
|
||||||
Разделяй независимые операции, а не строки. `BankCardsService` отвечает за карты,
|
Разделяй независимые операции, а не строки. Пример: `BankCardsService` владеет
|
||||||
`BankOperationsService` — журнал/переводы/возвраты, `ExpenseCategoriesService` —
|
картами, `BankOperationsService` — журналом/переводами, `GetBankSummaryUseCase` —
|
||||||
категории, `GetBankSummaryUseCase` — сводку. `BankCardsRepository` сохраняет
|
сводкой через repository и чистый расчёт. Не разрывай денежную transaction,
|
||||||
общий transaction retry и owner lookup; денежные транзакции не разрываются.
|
общий cache/limiter или pending promises при extraction.
|
||||||
Чистые расчёты банковских карт находятся в `domain` этого модуля.
|
|
||||||
|
|
||||||
У инструментов `StockCatalogService` / `BondCatalogService` хранят идентичность
|
Расширение abstractions — по [change scope](conventions/engineering.md#change-scope).
|
||||||
листинга и общий кэш slice; market service — котировки/свечи, income service — выплаты/личные позиции.
|
|
||||||
`GetStockCompanyUseCase` владеет сведениями об эмитенте. Ключи, TTL и pending
|
|
||||||
promises разделяют прежний экземпляр кэша; extract не должен менять generation.
|
|
||||||
Portfolio и operations сохраняют связную координацию снимков/страниц и кэшей.
|
|
||||||
Расчёты портфеля находятся в `portfolio/domain`; преобразование брокерских
|
|
||||||
позиций — в `tbank-portfolio.mapper.ts`. `bond-money.mapper.ts` преобразует
|
|
||||||
ответ клиента и не является value object.
|
|
||||||
|
|
||||||
Выбирай обычную функцию для чистого вычисления; repository contract — только
|
|
||||||
для реальной границы. CQRS/buses/events, BaseRepository/BaseService, DI token
|
|
||||||
для каждого класса, mapper/presenter для каждого DTO и новые dependencies
|
|
||||||
не нужны для структурного переноса.
|
|
||||||
|
|
||||||
## Common
|
## Common
|
||||||
|
|
||||||
@@ -205,45 +232,17 @@ protobuf units/nano conversion — T-Bank integration. Domain используе
|
|||||||
|
|
||||||
## Domain literals and magic values
|
## Domain literals and magic values
|
||||||
|
|
||||||
Finite business vocabulary must be modeled explicitly; incidental implementation
|
Общий выбор symbolic values и числовых policies —
|
||||||
literals stay inline. Это относится к error codes, statuses, operation/account/
|
[Domain values](conventions/engineering.md#domain-values).
|
||||||
instrument/transaction types, permissions, roles, feature states и бизнес-режимам.
|
Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай
|
||||||
|
`unknown` + runtime narrowing; отключённый lint `no-explicit-any` не делает
|
||||||
Перед новым определением найди existing owner/type/value. В branching и comparisons
|
`any` нормой domain/application model. `declare` допустим для заполняемых извне
|
||||||
используй символическое значение владельца; не повторяй raw domain literals.
|
DTO fields, не для сокрытия неинициализированного owned state.
|
||||||
Храни словарь у domain/module/entity с соблюдением границ слоёв, без глобальной
|
|
||||||
свалки `constants.ts`. Сохраняй принятый pattern; для нового project-owned словаря
|
|
||||||
без подходящего аналога используй `as const` с derived union:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const BankErrorCode = {
|
|
||||||
InvalidDate: "BANK_INVALID_DATE",
|
|
||||||
ImmutableOperation: "BANK_IMMUTABLE_OPERATION",
|
|
||||||
} as const;
|
|
||||||
type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
|
|
||||||
|
|
||||||
// Вместо code === "BANK_INVALID_DATE":
|
|
||||||
if (code === BankErrorCode.InvalidDate) { /* existing handling */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
Значимые numeric policies (timeout, retry, page size, threshold, coefficient)
|
|
||||||
называй по смыслу и единицам: `REQUEST_TIMEOUT_MS`, `CACHE_TTL_SECONDS`,
|
|
||||||
`MAX_RETRY_COUNT`, `PAGE_SIZE`. Оставляй inline UI labels в принятом месте,
|
|
||||||
log messages, индексы, `index + 1`, `Math.max(value, 0)`, `page ?? 1`,
|
|
||||||
`percentage * 100` и очевидные локальные детали без semantic benefit.
|
|
||||||
При неясном owner/смысле оставь значение и укажи ограничение в review.
|
|
||||||
|
|
||||||
Не мигрируй корректные existing enum/union styles массово. Не добавляй ради
|
|
||||||
правила factories, registries, schemas, loaders, abstraction layers, dependencies,
|
|
||||||
custom lint/AST rules, generation/validation scripts или тесты существования
|
|
||||||
констант. Semantic review проверяет ownership, единое написание и неизменность
|
|
||||||
wire values; обычные behavior tests проверяют поведение.
|
|
||||||
|
|
||||||
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
|
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
|
||||||
что application/domain. Например, `BankOperationKind` принадлежит bank-cards/domain,
|
что application/domain. Например, `BankOperationKind` принадлежит bank-cards/domain,
|
||||||
а DTO получает допустимые значения через `Object.values(BankOperationKind)`.
|
а DTO получает допустимые значения через `Object.values(BankOperationKind)`.
|
||||||
Frontend переиспользует generated OpenAPI types/values по возможностям генератора,
|
Публичные enum values входят в OpenAPI artifact. Внешние/generated enums
|
||||||
а не определяет независимый backend-owned словарь. Внешние/generated enums
|
|
||||||
сохраняй; generated Prisma/protocol code вручную не меняй.
|
сохраняй; generated Prisma/protocol code вручную не меняй.
|
||||||
|
|
||||||
`@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule
|
`@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule
|
||||||
@@ -255,14 +254,8 @@ semantic magic strings проверяются conventions/review без ново
|
|||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
Единственный logger — nestjs-pino; application events используют существующий
|
События, levels, correlation и safe fields — по
|
||||||
[StructuredLogger](../../src/common/logging/structured-logger.service.ts) и
|
[Observability](conventions/observability.md).
|
||||||
request context. Выбирай стабильное имя события и разрешённые scalar fields.
|
|
||||||
Wrapper отбрасывает неизвестные поля, но это не разрешение помещать secret
|
|
||||||
в `message`/`stack`: review sensitive logging обязателен.
|
|
||||||
Не добавляй второй logger или сырой request/body/error payload.
|
|
||||||
Источник rationale — [ADR 008](../../../docs/adr/008-pino-backend-logging.md);
|
|
||||||
при secrets/user data читай также Security.
|
|
||||||
|
|
||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Backend HTTP API
|
|
||||||
|
|
||||||
## Выбор boundary
|
|
||||||
|
|
||||||
Сначала определи, меняется wire contract или только реализация существующего
|
|
||||||
use case. Для route/request/response/status/error shape читай
|
|
||||||
[HTTP semantics и источник контракта](../../../docs/architecture/http-api.md).
|
|
||||||
|
|
||||||
| Проверка | Где выполнять |
|
|
||||||
| --- | --- |
|
|
||||||
| Тип/формат/диапазон входа | Runtime DTO class с class-validator/class-transformer |
|
|
||||||
| Согласованность полей, бизнес-ограничение | Feature service/domain helper |
|
|
||||||
| Session и identity | Existing guard/strategy |
|
|
||||||
| Доступ user к конкретному account | Existing access service |
|
|
||||||
| HTTP envelope/безопасная ошибка | Общий HttpExceptionFilter |
|
|
||||||
|
|
||||||
Controller объявляет contract и делегирует; не обращается напрямую к Prisma/gRPC
|
|
||||||
и не выполняет финансовые расчёты или сложную оркестрацию.
|
|
||||||
Controller вызывает application use case/service. Identity берётся из verified
|
|
||||||
`request.user`, не из body/userId, предоставленного клиентом. Access rules активируют Security.
|
|
||||||
|
|
||||||
## DTO и errors
|
|
||||||
|
|
||||||
DTO — runtime class: TS interface не предоставляет validation metadata.
|
|
||||||
`*.controller.ts` и `*.dto.ts` остаются около HTTP boundary, в root компактного
|
|
||||||
module или presentation. DTO можно передать application boundary при совпадении
|
|
||||||
формы; отдельный Input с теми же полями не нужен. Domain не импортирует DTO:
|
|
||||||
например, чистые денежные расчёты используют `Money`, а Swagger — `MoneyDto`.
|
|
||||||
Нормализованная форма input нужна только при смысловом отличии от wire contract;
|
|
||||||
выбор suffix принадлежит [Architecture](backend-architecture.md).
|
|
||||||
ValidationPipe уже настроен в `configureApp` (`transform`, `whitelist`).
|
|
||||||
Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён.
|
|
||||||
Для query numbers/arrays выбирай явное преобразование и валидаторы, как в
|
|
||||||
[operations DTO](../../src/modules/investments/operations/presentation/brokerage-operation.dto.ts). Swagger metadata
|
|
||||||
описывает те же required/optional/nullable/enum/range значения.
|
|
||||||
|
|
||||||
Используй типизированные response DTO, `ApiOkResponse`/другой фактический status
|
|
||||||
и существующий `ApiProblemResponses`. При новых ошибках задай публичный stable
|
|
||||||
`code`; сообщение внутреннего exception не является UI contract. Не возвращай
|
|
||||||
raw upstream error, Prisma record с secrets или stack. Текущая форма ошибок
|
|
||||||
описана у HTTP owner; не добавляй поля RFC только потому, что они встречаются в tutorial.
|
|
||||||
|
|
||||||
**Безопасный путь:** используй exception и общий filter, а не локальный
|
|
||||||
`res.status(...).json(...)`. Исключение — транспортная операция вроде cookie;
|
|
||||||
следуй существующему auth controller с `passthrough` и явно заданным status.
|
|
||||||
|
|
||||||
## Пример и проверка
|
|
||||||
|
|
||||||
[Operations controller](../../src/modules/investments/operations/presentation/brokerage-operations.controller.ts)
|
|
||||||
показывает guard, user.sub, DTO query и service delegation.
|
|
||||||
Для нового field обновляй canonical source и generated artifacts в определённом
|
|
||||||
направлении; body type frontend не исправляется вручную.
|
|
||||||
|
|
||||||
Проверь happy path и применимые invalid input/access/not-found/domain/upstream
|
|
||||||
errors на boundary, где они возникают. Unit вызов controller не запускает pipes,
|
|
||||||
guards или serialization. `npm run test:openapi-contract` проверяет schema parity,
|
|
||||||
но не поведение invalid request; выбирай соответствующий test по
|
|
||||||
[Testing](backend-testing.md). Финальный gate не заменяет semantic review ошибок.
|
|
||||||
@@ -15,13 +15,20 @@ PrismaService — существующий shared DB provider с lifecycle пр
|
|||||||
| Независимые reads | Не добавляй transaction ради внешнего вида |
|
| Независимые reads | Не добавляй transaction ради внешнего вида |
|
||||||
| Общая persistence abstraction | Только при реальной общей ответственности; generic repository для одной Prisma реализации не нужен |
|
| Общая persistence abstraction | Только при реальной общей ответственности; generic repository для одной Prisma реализации не нужен |
|
||||||
|
|
||||||
Размещение repository определяется [Architecture](backend-architecture.md):
|
Размещение repository определяется [Architecture](../architecture.md):
|
||||||
в слоистом module это `infrastructure/`.
|
в слоистом module это `infrastructure/`.
|
||||||
Repository — конкретный класс `*.repository.ts` у business owner, без обязательных
|
Repository — конкретный класс `*.repository.ts` у business owner, без обязательных
|
||||||
interface/port/adapter. Он владеет queries, includes/filtering, DB mapping и
|
interface/port/adapter. Он владеет queries, includes/filtering, DB mapping и
|
||||||
согласованным чтением. Суффиксы `*.persistence.ts`, `*.store.ts`, `*.dao.ts`
|
согласованным чтением. Суффиксы `*.persistence.ts`, `*.store.ts`, `*.dao.ts`
|
||||||
для той же роли не используем. Nest module регистрирует repository приватно.
|
для той же роли не используем. Nest module регистрирует repository приватно.
|
||||||
|
|
||||||
|
Новые business decisions (можно ли менять архивную карту, допустим ли перевод)
|
||||||
|
принадлежат application/domain; repository сохраняет persistence и ownership
|
||||||
|
ограничения. Existing checks/HTTP helpers в BankCardsRepository — migration
|
||||||
|
candidate, не повод дублировать их при новом use case или разрывать transaction.
|
||||||
|
`BaseRepository<T>`, `GenericRepository<T>` и RepositoryFactory поверх Prisma
|
||||||
|
не нужны без отдельной общей ответственности.
|
||||||
|
|
||||||
`BankCardsRepository.summaryData` проверяет владельца карты и получает операции
|
`BankCardsRepository.summaryData` проверяет владельца карты и получает операции
|
||||||
с категориями в одной read transaction. `GetBankSummaryUseCase` выбирает период,
|
с категориями в одной read transaction. `GetBankSummaryUseCase` выбирает период,
|
||||||
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
|
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
|
||||||
@@ -44,16 +51,16 @@ application checks. Предварительный lookup не заменяет
|
|||||||
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
|
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
|
||||||
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
|
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
|
||||||
lock во время retry внешней сети. Access/cache generation semantics — у
|
lock во время retry внешней сети. Access/cache generation semantics — у
|
||||||
[T-Bank source](../../../docs/integrations/tbank.md#граница-финансового-кэша).
|
[T-Bank integration](integrations.md#retry-cache-и-protocol).
|
||||||
|
|
||||||
## Деньги и точность
|
## Деньги и точность
|
||||||
|
|
||||||
Для денежных значений используй existing decimal helpers и строковый wire value,
|
Для денежных значений используй existing decimal helpers и строковый wire value,
|
||||||
не JS floating-point арифметику для сумм. Currency — отдельная часть Money;
|
не JS floating-point арифметику для сумм. Currency — отдельная часть Money;
|
||||||
разные валюты не складываются без явно заданного conversion contract.
|
разные валюты не складываются без явно заданного conversion contract.
|
||||||
Источник: [Money](../../../docs/glossary.md#money),
|
Источник формы — `src/common/money.types.ts`;
|
||||||
пример: [decimal.ts](../../src/common/decimal.ts). UI formatting не меняет
|
пример точной арифметики: [decimal.ts](../../src/common/decimal.ts).
|
||||||
backend ownership расчётов и доменную семантику.
|
Форматирование ответа не меняет доменную семантику и точность расчёта.
|
||||||
|
|
||||||
## Schema и migration
|
## Schema и migration
|
||||||
|
|
||||||
@@ -66,12 +73,12 @@ client обновляй из schema. Не переписывай уже прим
|
|||||||
|
|
||||||
**Безопасный путь:** additive изменение, заполнение/проверка данных и только затем
|
**Безопасный путь:** additive изменение, заполнение/проверка данных и только затем
|
||||||
удаление прежней формы, если задача этого требует. Примеры schema/migrations:
|
удаление прежней формы, если задача этого требует. Примеры schema/migrations:
|
||||||
[Prisma source](../../prisma/schema.prisma); исторический rationale —
|
[Prisma source](../../prisma/schema.prisma). SQLite — embedded storage текущего
|
||||||
[ADR 001](../../../docs/adr/001-prisma-sqlite-local-mvp.md), не текущий список таблиц.
|
приложения; файл БД и encryption key требуют согласованного backup/restore.
|
||||||
|
|
||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
`npx prisma validate`; после schema change `npx prisma generate` и
|
`npx prisma validate`; после schema change `npx prisma generate` и
|
||||||
`npm run typecheck:app`. Invariants/atomicity/migration safety требуют tests
|
`npm run typecheck:app`. Invariants/atomicity/migration safety требуют tests
|
||||||
на соответствующей boundary: mock Prisma не доказывает реальную transaction.
|
на соответствующей boundary: mock Prisma не доказывает реальную transaction.
|
||||||
Выбор integration/e2e и известные ограничения — в [Testing](backend-testing.md).
|
Выбор integration/e2e и известные ограничения — в [Testing](testing.md).
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Работа в репозитории
|
||||||
|
|
||||||
|
## Граница и порядок работы
|
||||||
|
|
||||||
|
Репозиторий сам содержит инструкции, исходники, конфигурацию и входы своих
|
||||||
|
проверок. Сборка, CI и обязательный маршрут чтения работают без соседних
|
||||||
|
checkout. Внешний API задаётся предоставленным публичным контрактом;
|
||||||
|
исходники или инструкции его реализации не являются зависимостью разработки.
|
||||||
|
При нехватке требования сообщи, какое решение не определено, до его реализации.
|
||||||
|
|
||||||
|
Начни с результата и affected areas. До edits проследи flow/invariants, найди
|
||||||
|
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
|
||||||
|
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
|
||||||
|
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
|
||||||
|
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
|
||||||
|
|
||||||
|
## Change scope
|
||||||
|
|
||||||
|
Сначала расширь существующего владельца. Иначе используй standard library,
|
||||||
|
платформу или установленную dependency; небольшая новая реализация допустима
|
||||||
|
при собственной ответственности. Не создавай helper/interface/wrapper ради
|
||||||
|
возможного reuse. Общее имя не означает общую бизнес-ответственность.
|
||||||
|
Сохраняй validation, authorization, atomicity, accessibility и migration safety.
|
||||||
|
|
||||||
|
После diff выполни Boy Scout review: проверь новое дублирование, owner, callers,
|
||||||
|
небольшие связанные legacy issues и соответствие local conventions. Исправляй
|
||||||
|
непосредственно связанные low-risk проблемы; широкий cleanup — отдельная задача.
|
||||||
|
|
||||||
|
## Domain values
|
||||||
|
|
||||||
|
Finite business vocabulary принадлежит одному domain/module/entity owner.
|
||||||
|
Переиспользуй его symbols; для нового словаря без аналога — `as const` + union.
|
||||||
|
Сохраняй wire values; generated/external enums не переписывай вручную.
|
||||||
|
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
|
||||||
|
обычные labels не выноси в constants без semantic benefit.
|
||||||
|
Локальный TypeScript/lint профиль определяет дополнительные ограничения.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Проверь аналоги и direct dependencies в местном manifest до добавления пакета.
|
||||||
|
Новая dependency оправдана текущей потребностью и меньшей общей сложностью;
|
||||||
|
оцени compatibility, runtime/bundle и maintenance/security. Импортируемый пакет
|
||||||
|
объявляется напрямую; транзитивная установка не является контрактом.
|
||||||
|
Сохраняй exact versions и lockfile; воспроизводимая установка — `npm ci`.
|
||||||
|
Peer-конфликт решай совместимыми версиями без `--force`/`--legacy-peer-deps`.
|
||||||
|
Используй существующий analyzer; не добавляй второй для той же проверки.
|
||||||
|
|
||||||
|
## Test Intent
|
||||||
|
|
||||||
|
Перед нетривиальным тестом кратко определи наблюдаемый исход, regression risk,
|
||||||
|
значимые edge/error/concurrency cases и самую дешёвую надёжную boundary.
|
||||||
|
Отдельный artifact не нужен. Проверяй результат, а не private call order,
|
||||||
|
декларации, тривиальные wrappers или поведение framework.
|
||||||
|
При bug сначала воспроизведи исход. Mock race не доказывает DB atomicity.
|
||||||
|
Уровни и runnable commands выбирай по местной testing convention.
|
||||||
|
|
||||||
|
## Проверки и завершение
|
||||||
|
|
||||||
|
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
|
||||||
|
risk checks из местной testing convention. Targeted tests ускоряют итерацию,
|
||||||
|
но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
|
||||||
|
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
|
||||||
|
чтения и `git diff --check`; application build ради текста не нужен.
|
||||||
|
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
|
||||||
|
и ограничения. Зелёный lint не заменяет semantic/security review.
|
||||||
|
|
||||||
|
## Git и релизы
|
||||||
|
|
||||||
|
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
|
||||||
|
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
|
||||||
|
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
|
||||||
|
содержит одну связанную задачу. Root другого checkout не управляет этим repo.
|
||||||
|
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
|
||||||
|
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Backend errors
|
||||||
|
|
||||||
|
## Поток
|
||||||
|
|
||||||
|
```text
|
||||||
|
application/domain outcome → application mapping → HttpExceptionFilter
|
||||||
|
→ application/problem+json
|
||||||
|
```
|
||||||
|
|
||||||
|
Сохраняй принятый профиль RFC 9457: `status`, стабильный публичный `code`,
|
||||||
|
для validation — `errors: [{ pointer, detail }]`. Точный контракт validation
|
||||||
|
описан в [HTTP API](http-api.md#источник-контракта).
|
||||||
|
Не добавляй `type/title/instance` как обязательные поля без contract change.
|
||||||
|
|
||||||
|
Чистый domain не импортирует Nest HTTP exceptions. Существующий application
|
||||||
|
service может бросить подходящий `HttpException` с public code; не нужен новый
|
||||||
|
error framework/registry. Если domain outcome требует HTTP interpretation,
|
||||||
|
маппинг принадлежит application/transport boundary.
|
||||||
|
|
||||||
|
## Codes и безопасность
|
||||||
|
|
||||||
|
- Используй existing code владельца, не сравнивай внутренние сообщения.
|
||||||
|
Новое значение имеет один symbolic owner; metadata/validation и consumers
|
||||||
|
согласуются по [domain values](engineering.md#domain-values).
|
||||||
|
- Общий envelope, default status mapping и unexpected errors принадлежат
|
||||||
|
[HttpExceptionFilter](../../src/common/filters/http-exception.filter.ts).
|
||||||
|
Не дублируй `res.status().json()` в controllers/services.
|
||||||
|
- Неожиданный exception → `500 / INTERNAL_SERVER_ERROR`, без message/stack,
|
||||||
|
SQL, credentials или raw upstream payload в ответе.
|
||||||
|
- External auth failure и временная недоступность имеют разные outcomes;
|
||||||
|
translation выполняется на existing integration/access boundary.
|
||||||
|
- Логи используют [observability](observability.md). Redaction не
|
||||||
|
делает произвольное сообщение ошибки безопасным.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
Для изменённого кода проверь observable status/code и сохранность данных.
|
||||||
|
Для validation нужен request через `configureApp`: fabricated `ValidationError`
|
||||||
|
в unit test filter не доказывает форму, выходящую из реального ValidationPipe.
|
||||||
|
OpenAPI parity проверяет schema, а не response pipeline; [Testing](testing.md).
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Backend HTTP API
|
||||||
|
|
||||||
|
## Выбор boundary
|
||||||
|
|
||||||
|
Сначала определи, меняется wire contract или только реализация существующего
|
||||||
|
use case. Для route/request/response/status/error shape используй
|
||||||
|
[локальный публичный контракт](#источник-контракта) и configured HTTP tests.
|
||||||
|
|
||||||
|
| Проверка | Где выполнять |
|
||||||
|
| --- | --- |
|
||||||
|
| Тип/формат/диапазон входа | Runtime DTO class с class-validator/class-transformer |
|
||||||
|
| Согласованность полей, бизнес-ограничение | Feature service/domain helper |
|
||||||
|
| Session и identity | Existing guard/strategy |
|
||||||
|
| Доступ user к конкретному account | Existing access service |
|
||||||
|
| HTTP envelope/безопасная ошибка | Общий HttpExceptionFilter |
|
||||||
|
|
||||||
|
Controller объявляет contract и делегирует; не обращается напрямую к Prisma/gRPC
|
||||||
|
и не выполняет финансовые расчёты или сложную оркестрацию.
|
||||||
|
Controller вызывает application use case/service. Identity берётся из verified
|
||||||
|
`request.user`, не из body/userId, предоставленного клиентом. Access rules активируют Security.
|
||||||
|
|
||||||
|
## DTO и errors
|
||||||
|
|
||||||
|
DTO — runtime class: TS interface не предоставляет validation metadata.
|
||||||
|
`*.controller.ts` и `*.dto.ts` остаются около HTTP boundary, в root компактного
|
||||||
|
module или presentation. DTO можно передать application boundary при совпадении
|
||||||
|
формы; отдельный Input с теми же полями не нужен. Domain не импортирует DTO:
|
||||||
|
например, чистые денежные расчёты используют `Money`, а Swagger — `MoneyDto`.
|
||||||
|
Нормализованная форма input нужна только при смысловом отличии от wire contract;
|
||||||
|
выбор suffix принадлежит [Architecture](../architecture.md).
|
||||||
|
ValidationPipe уже настроен в `configureApp` (`transform`, `whitelist`).
|
||||||
|
Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён.
|
||||||
|
Для query numbers/arrays выбирай явное преобразование и валидаторы, как в
|
||||||
|
[operations DTO](../../src/modules/investments/operations/presentation/brokerage-operation.dto.ts). Swagger metadata
|
||||||
|
описывает те же required/optional/nullable/enum/range значения.
|
||||||
|
|
||||||
|
Используй типизированные response DTO, `ApiOkResponse`/другой фактический status
|
||||||
|
и существующий `ApiProblemResponses`. Prisma model не является public contract:
|
||||||
|
response выбирает поля явно. Stable codes, RFC 9457 профиль и mapping принадлежат
|
||||||
|
[Errors](error-handling.md).
|
||||||
|
|
||||||
|
**Безопасный путь:** используй exception и общий filter, а не локальный
|
||||||
|
`res.status(...).json(...)`. Исключение — транспортная операция вроде cookie;
|
||||||
|
следуй существующему auth controller с `passthrough` и явно заданным status.
|
||||||
|
|
||||||
|
## Пример и проверка
|
||||||
|
|
||||||
|
Prefix `/api/v1` задаёт `configureApp`. Route/status/request/response/OpenAPI
|
||||||
|
change — изменение публичного backend contract; несовместимое изменение требует
|
||||||
|
согласованного перехода consumers, а не незаметной правки DTO. Новый API version
|
||||||
|
не вводи для внутреннего refactor.
|
||||||
|
|
||||||
|
Pagination сохраняет contract endpoint: operations использует opaque cursor,
|
||||||
|
limit и DTO bounds. Не заменяй cursor offset/page и не добавляй global pagination
|
||||||
|
helper ради первого endpoint. Новая pagination явно определяет порядок,
|
||||||
|
границу страницы и end-of-list; reused cursor не должен менять filters/owner.
|
||||||
|
|
||||||
|
[Operations controller](../../src/modules/investments/operations/presentation/brokerage-operations.controller.ts)
|
||||||
|
показывает guard, user.sub, DTO query и service delegation.
|
||||||
|
Для нового field обновляй Nest DTO/metadata и публикуемый OpenAPI artifact.
|
||||||
|
Потребители получают публичный контракт независимо от внутреннего устройства API.
|
||||||
|
|
||||||
|
Проверь happy path и применимые invalid input/access/not-found/domain/upstream
|
||||||
|
errors на boundary, где они возникают. Unit вызов controller не запускает pipes,
|
||||||
|
guards или serialization. `npm run test:openapi-contract` проверяет schema parity,
|
||||||
|
но не поведение invalid request; выбирай соответствующий test по
|
||||||
|
[Testing](testing.md). Финальный gate не заменяет semantic review ошибок.
|
||||||
|
|
||||||
|
## Источник контракта
|
||||||
|
|
||||||
|
Nest DTO/controllers/Swagger metadata определяют публичный HTTP API.
|
||||||
|
`npm run openapi:generate` собирает backend и записывает корневой `openapi.json`.
|
||||||
|
Nest preview читает metadata без запуска HTTP-порта, провайдеров, БД и T-Bank;
|
||||||
|
команда не требует deployment environment. Artifact исключён из Git.
|
||||||
|
|
||||||
|
При изменении API обнови DTO/metadata и выполни генерацию. Потребители получают
|
||||||
|
artifact отдельно; backend не читает их репозитории. `test:openapi-contract`
|
||||||
|
сравнивает вывод генератора с runtime schema configured app во временном каталоге.
|
||||||
|
Проверка подтверждает правильность генерации, но без versioned baseline не
|
||||||
|
выявляет breaking change относительно предыдущего выпуска. Для такого изменения
|
||||||
|
согласуй переход consumers и проверь relevant HTTP behavior.
|
||||||
|
|
||||||
|
HTTP prefix — `/api/v1`; Swagger доступен согласно `src/config/swagger.config.ts`.
|
||||||
|
Общий error response — `application/problem+json` со `status` и публичным `code`.
|
||||||
|
Validation требует `400 / VALIDATION_ERROR` и `errors: [{ pointer, detail }]`.
|
||||||
|
ValidationPipe сохраняет структурированные field errors без `target/value`,
|
||||||
|
а filter формирует JSON pointers, включая вложенные поля. Schema parity не
|
||||||
|
доказывает правильность `errors`; изменения проверяются через реальный HTTP pipeline.
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
## Выбор boundary
|
## Выбор boundary
|
||||||
|
|
||||||
T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient. Сервисы use case
|
T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient; NCR ratings —
|
||||||
|
NcrRatingsClient. Сервисы use case
|
||||||
используют объявленные клиентом records; SDK clients, metadata и transport errors
|
используют объявленные клиентом records; SDK clients, metadata и transport errors
|
||||||
остаются внутри integration. Frontend не вызывает
|
остаются внутри integration. Credentials и proto client не входят в публичный
|
||||||
T-Bank и не получает credentials/proto client. Reference source —
|
HTTP API. Connection lifecycle проверяется на границе соответствующего module
|
||||||
[ADR 003](../../../docs/adr/003-tbank-server-side-grpc-boundary.md) и
|
и [Security](security-auth.md).
|
||||||
[connection behavior](../../../docs/integrations/tbank.md).
|
|
||||||
|
|
||||||
`modules/investments/tbank-connection` — пользовательская capability чтения,
|
`modules/investments/tbank-connection` — пользовательская capability чтения,
|
||||||
создания/замены и удаления подключения. `integrations/tbank` — технические
|
создания/замены и удаления подключения. `integrations/tbank` — технические
|
||||||
@@ -17,7 +17,7 @@ RPC, credentials/access и resilience. Эти владельцы не объед
|
|||||||
`*.client.ts` содержит transport и его retries; `*.api.ts` только перечисляет
|
`*.client.ts` содержит transport и его retries; `*.api.ts` только перечисляет
|
||||||
публичные exports. Все шесть `auth/operations/portfolio/tbank/moex/ncr.api.ts`
|
публичные exports. Все шесть `auth/operations/portfolio/tbank/moex/ncr.api.ts`
|
||||||
имеют одинаковую роль программной границы, определённую в
|
имеют одинаковую роль программной границы, определённую в
|
||||||
[Architecture](backend-architecture.md). Логика, DTO definitions и HTTP routes
|
[Architecture](../architecture.md). Логика, DTO definitions и HTTP routes
|
||||||
в эти файлы не помещаются. Type exports обозначаются `export type`.
|
в эти файлы не помещаются. Type exports обозначаются `export type`.
|
||||||
|
|
||||||
Преобразование provider records происходит на boundary: например,
|
Преобразование provider records происходит на boundary: например,
|
||||||
@@ -30,7 +30,7 @@ client или mapper для простого копирования полей.
|
|||||||
Для нового account use case сначала используй TbankAccessService: access,
|
Для нового account use case сначала используй TbankAccessService: access,
|
||||||
connection generation и invalid-token translation не дублируются в callers.
|
connection generation и invalid-token translation не дублируются в callers.
|
||||||
Mapper внешних финансовых значений остаётся внутри business capability,
|
Mapper внешних финансовых значений остаётся внутри business capability,
|
||||||
обычно в infrastructure по [Architecture](backend-architecture.md).
|
обычно в infrastructure по [Architecture](../architecture.md).
|
||||||
|
|
||||||
## Retry, cache и protocol
|
## Retry, cache и protocol
|
||||||
|
|
||||||
@@ -38,13 +38,19 @@ Existing client уже владеет bounded FIFO queue, rate limit, deadline
|
|||||||
классификацией. Новый RPC проходит тот же путь; не добавляй вложенный retry loop
|
классификацией. Новый RPC проходит тот же путь; не добавляй вложенный retry loop
|
||||||
в service. Не отключай TLS verification ради окружения: используй документированный
|
в service. Не отключай TLS verification ради окружения: используй документированный
|
||||||
CA mechanism. Точные параметры и ограничения принадлежат
|
CA mechanism. Точные параметры и ограничения принадлежат
|
||||||
[connection configuration](../../../docs/integrations/tbank.md#конфигурация-и-ограничения).
|
[validated environment](../../src/config/env.validation.ts) и
|
||||||
|
[TbankClient](../../src/integrations/tbank/tbank.client.ts).
|
||||||
|
|
||||||
|
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
|
||||||
|
собственный bounded timeout и безопасный unavailable outcome; не приписывай им
|
||||||
|
gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
|
||||||
|
|
||||||
При query/cache change установи key, user/account/generation ownership,
|
При query/cache change установи key, user/account/generation ownership,
|
||||||
freshness и поведение late response. Старый результат не может обслужить новую
|
freshness и поведение late response. Старый результат не может обслужить новую
|
||||||
connection generation. Client Cached Snapshot frontend отличается от backend
|
connection generation. Успешная замена/deletion подключения меняет generation
|
||||||
cache/stale policy; semantics конкретного use case читай через
|
даже при сохранении прежнего token; ошибка оставляет прежнее подключение.
|
||||||
[feature index](../../../docs/README.md#предметные-области).
|
Кэш конкретного use case принадлежит его service и tests. Не переноси
|
||||||
|
сохранённый snapshot между пользователями или поколениями подключения.
|
||||||
|
|
||||||
Внешний auth failure и временная недоступность имеют разные public codes;
|
Внешний auth failure и временная недоступность имеют разные public codes;
|
||||||
raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService
|
raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService
|
||||||
@@ -64,4 +70,4 @@ Generated artifacts, если есть, создавай из source, без р
|
|||||||
upstream responses. Проверь queue/deadline/retry limit, token translation,
|
upstream responses. Проверь queue/deadline/retry limit, token translation,
|
||||||
cache isolation и late response для затронутых ветвей. Fixtures синтетические;
|
cache isolation и late response для затронутых ветвей. Fixtures синтетические;
|
||||||
реальная сеть, секреты и пользовательские accounts не нужны.
|
реальная сеть, секреты и пользовательские accounts не нужны.
|
||||||
Logging использует [existing logger](backend-architecture.md#logging).
|
Logging использует [existing logger](../architecture.md#logging).
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Observability
|
||||||
|
|
||||||
|
- Единственный logger — nestjs-pino. Application events используют
|
||||||
|
[StructuredLogger](../../src/common/logging/structured-logger.service.ts),
|
||||||
|
стабильное имя события и разрешённые scalar fields. `console.log` и второй
|
||||||
|
logger в production не используются.
|
||||||
|
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
|
||||||
|
`warn` — восстановимая проблема/retry; `error` — окончательный сбой.
|
||||||
|
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
|
||||||
|
- RequestContextService и request middleware владеют correlation: серверный
|
||||||
|
request ID возвращается как `X-Request-ID`, входящий client ID хранится отдельно.
|
||||||
|
Передавай существующий context, не создавай собственный UUID в каждом service.
|
||||||
|
- Wrapper ограничивает поля, Pino redacts request secrets. Не логируй passwords,
|
||||||
|
tokens, cookie, Authorization, credentials, connection generation, raw body
|
||||||
|
или upstream error. Secret в `message/stack` не становится безопасным из-за
|
||||||
|
allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
|
||||||
|
- Для ошибки используй public code и безопасный context операции/попыток;
|
||||||
|
[error mapping](error-handling.md) не отдаёт внутренние детали клиенту.
|
||||||
|
|
||||||
|
Проверяй новые поля/redaction/correlation existing logging tests; не добавляй
|
||||||
|
тест на каждую строку log. Existing ESLint не запрещает все вызовы `console`
|
||||||
|
отдельным правилом: до настройки встроенного `no-console` это semantic review.
|
||||||
@@ -10,20 +10,23 @@ account access/connection generation — TbankAccessService; token crypto —
|
|||||||
TbankTokenService/TbankCrypto. Расширяй существующую boundary, не добавляй
|
TbankTokenService/TbankCrypto. Расширяй существующую boundary, не добавляй
|
||||||
второй JWT middleware, decrypt helper или refresh coordinator.
|
второй JWT middleware, decrypt helper или refresh coordinator.
|
||||||
|
|
||||||
[Authentication](../../../docs/domains/authentication/overview.md) и
|
Access проверяется JWT strategy; refresh передаётся httpOnly cookie.
|
||||||
[ADR 006](../../../docs/adr/006-auth-session-policy.md)/[009](../../../docs/adr/009-concurrent-refresh-rotation-policy.md)
|
Сроки token/session принадлежат auth source и configured HTTP tests.
|
||||||
задают session semantics. Access хранится только в памяти frontend; refresh
|
Source code конфигурации cookie —
|
||||||
передаётся httpOnly cookie. Source code конфигурации cookie —
|
|
||||||
[AuthController](../../src/modules/auth/presentation/auth.controller.ts); изменение flags/path требует
|
[AuthController](../../src/modules/auth/presentation/auth.controller.ts); изменение flags/path требует
|
||||||
проверки browser/HTTP поведения, не только TypeScript.
|
проверки browser/HTTP поведения, не только TypeScript.
|
||||||
|
|
||||||
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
|
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
|
||||||
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace
|
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace
|
||||||
или иной security contract без текущей задачи. Logout идемпотентен; in-memory
|
или иной security contract без текущей задачи. Logout идемпотентен, отзывает
|
||||||
frontend session/cache очищается и при network failure.
|
сессию и очищает refresh cookie на HTTP-границе.
|
||||||
|
|
||||||
## Trust boundaries
|
## Trust boundaries
|
||||||
|
|
||||||
|
Клиентский интерфейс не является security boundary. DTO runtime validation выполняется
|
||||||
|
server-side; whitelist не заменяет access checks. Environment/secret inputs
|
||||||
|
проверяет existing config owner; реальные credentials не попадают в fixtures.
|
||||||
|
|
||||||
Verified JWT не доказывает доступ к accountId из URL. Используй user.sub и
|
Verified JWT не доказывает доступ к accountId из URL. Используй user.sub и
|
||||||
existing account access policy; не доверяй userId/token из клиента как authority.
|
existing account access policy; не доверяй userId/token из клиента как authority.
|
||||||
Cross-user, invalid/expired/superseded token и concurrent requests — обязательные
|
Cross-user, invalid/expired/superseded token и concurrent requests — обязательные
|
||||||
@@ -32,13 +35,20 @@ cases для соответствующего изменения.
|
|||||||
T-Bank credentials остаются server-side и хранятся encrypted. Используй
|
T-Bank credentials остаются server-side и хранятся encrypted. Используй
|
||||||
existing AES-256-GCM boundary с validated 32-byte key; не печатай plaintext,
|
existing AES-256-GCM boundary с validated 32-byte key; не печатай plaintext,
|
||||||
шифротекст, cookie, Authorization или connection generation в logs/errors/API.
|
шифротекст, cookie, Authorization или connection generation в logs/errors/API.
|
||||||
[Connection source](../../../docs/integrations/tbank.md) определяет
|
Успешная замена/deletion меняет connection generation и инвалидирует её кэши;
|
||||||
cache invalidation при замене/deletion и ограничение key rotation.
|
ошибка сохраняет прежнее подключение. Ключ не ротируется автоматически:
|
||||||
|
новый ключ не расшифрует сохранённые credentials. Нужен исходный ключ или
|
||||||
|
повторное сохранение token пользователем; сохраняй ключ отдельно от backup БД.
|
||||||
|
|
||||||
Не передавай Prisma User/Session целиком наружу; выбирай public DTO. Пароль
|
Не передавай Prisma User/Session целиком наружу; выбирай public DTO. Пароль
|
||||||
обрабатывает существующий bcrypt flow. Не копируй требования о нормализации
|
обрабатывает существующий bcrypt flow. Не копируй требования о нормализации
|
||||||
или длине из tutorial: сверяй runtime DTO, tests и feature source.
|
или длине из tutorial: сверяй runtime DTO, tests и feature source.
|
||||||
|
|
||||||
|
Предпочитай Prisma query API. Raw SQL допускается только по конкретной нужде
|
||||||
|
с параметризованными значениями; не конкатенируй untrusted input и не передавай
|
||||||
|
его в `queryRawUnsafe`/`executeRawUnsafe`. Dynamic identifiers выбирай из
|
||||||
|
закрытого server-owned набора. Проверка user ownership сохраняется и в SQL.
|
||||||
|
|
||||||
## Безопасный путь и проверка
|
## Безопасный путь и проверка
|
||||||
|
|
||||||
При неясной ownership/crypto/session семантике прочитай exact source до edits.
|
При неясной ownership/crypto/session семантике прочитай exact source до edits.
|
||||||
@@ -46,6 +56,6 @@ cache invalidation при замене/deletion и ограничение key ro
|
|||||||
фиктивным success или log. Logger allowlist/redaction не делает произвольный
|
фиктивным success или log. Logger allowlist/redaction не делает произвольный
|
||||||
message безопасным.
|
message безопасным.
|
||||||
|
|
||||||
Targeted auth/access/crypto/logging tests + [Testing](backend-testing.md).
|
Targeted auth/access/crypto/logging tests + [Testing](testing.md).
|
||||||
Cookie/guard/HTTP changes требуют configured HTTP proof; frontend Bearer/retry
|
Cookie/guard/HTTP changes требуют configured HTTP proof.
|
||||||
проверяется MSW. Schema parity не доказывает отсутствие утечки secret.
|
Schema parity не доказывает отсутствие утечки secret.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Backend testing
|
# Backend testing
|
||||||
|
|
||||||
|
Перед нетривиальными тестами сформулируй
|
||||||
|
[общий Test Intent](engineering.md#test-intent); отдельный документ
|
||||||
|
не требуется. Trivial low-risk изменения обходятся без него.
|
||||||
|
|
||||||
## Выбор уровня
|
## Выбор уровня
|
||||||
|
|
||||||
Проверяй риски доступа, денежных расчётов, целостности данных, конкурентных
|
Проверяй риски доступа, денежных расчётов, целостности данных, конкурентных
|
||||||
@@ -11,7 +15,7 @@
|
|||||||
| Pure mapper/decimal/domain branch | Unit со значимыми edge cases |
|
| Pure mapper/decimal/domain branch | Unit со значимыми edge cases |
|
||||||
| Service orchestration/access/retry policy | Jest с mocks внешних boundaries и observable outcome |
|
| Service orchestration/access/retry policy | Jest с mocks внешних boundaries и observable outcome |
|
||||||
| Pipe/guard/cookie/status/HTTP error | Configured Nest HTTP boundary; прямого вызова метода controller недостаточно |
|
| Pipe/guard/cookie/status/HTTP error | Configured Nest HTTP boundary; прямого вызова метода controller недостаточно |
|
||||||
| OpenAPI metadata/wire schema | `test:openapi-contract` + потребительский contract check |
|
| OpenAPI metadata/wire schema | `test:openapi-contract` + configured HTTP tests |
|
||||||
| DB constraint/CAS/transaction/migration | Integration с изолированной реальной DB |
|
| DB constraint/CAS/transaction/migration | Integration с изолированной реальной DB |
|
||||||
| Только DTO declarations/thin wiring | Existing contract/type/build без искусственного unit test |
|
| Только DTO declarations/thin wiring | Existing contract/type/build без искусственного unit test |
|
||||||
|
|
||||||
@@ -25,7 +29,7 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
|
|||||||
- [BrokerageAccountsService tests](../../src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts): отсутствие connection не вызывает upstream, нормализация и точные суммы.
|
- [BrokerageAccountsService tests](../../src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts): отсутствие connection не вызывает upstream, нормализация и точные суммы.
|
||||||
- [AuthService tests](../../src/modules/auth/application/auth.service.spec.ts): invalid/expired/superseded token и concurrent rotation; mock race test не является доказательством DB transaction.
|
- [AuthService tests](../../src/modules/auth/application/auth.service.spec.ts): invalid/expired/superseded token и concurrent rotation; mock race test не является доказательством DB transaction.
|
||||||
- [HTTP filter tests](../../src/common/filters/http-exception.filter.spec.ts): безопасный error envelope.
|
- [HTTP filter tests](../../src/common/filters/http-exception.filter.spec.ts): безопасный error envelope.
|
||||||
- [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): configured app + frontend snapshot, без записи snapshot и без T-Bank.
|
- [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): генератор без сервера + configured app; временный JSON, без versioned snapshot и без T-Bank.
|
||||||
|
|
||||||
При bug сначала воспроизведи именно его исход, затем исправь responsible owner.
|
При bug сначала воспроизведи именно его исход, затем исправь responsible owner.
|
||||||
Не создавай tests для каждой декларации, logs строки или поведения Prisma/Nest
|
Не создавай tests для каждой декларации, logs строки или поведения Prisma/Nest
|
||||||
@@ -40,8 +44,8 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
|
|||||||
`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-запросов.
|
||||||
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`,
|
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
|
||||||
вызывающего ошибку fresh migration на Prisma 7.9.1. Подробности и регрессия запуска —
|
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
|
||||||
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
|
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
|
||||||
HTTP suites требуют разрешения открывать локальные порты. Ошибка sandbox
|
HTTP suites требуют разрешения открывать локальные порты. Ошибка sandbox
|
||||||
не считается успешным тестом; повторите проверку в подходящем окружении.
|
не считается успешным тестом; повторите проверку в подходящем окружении.
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"frontend": "0244acf429c525b23117f165bbe52ef5403eefd3"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
**
|
||||||
|
!package.json
|
||||||
|
!package-lock.json
|
||||||
|
!.nvmrc
|
||||||
|
!nest-cli.json
|
||||||
|
!tsconfig*.json
|
||||||
|
!prisma.config.ts
|
||||||
|
!prisma/
|
||||||
|
!prisma/schema.prisma
|
||||||
|
!prisma/migrations/
|
||||||
|
!prisma/migrations/**
|
||||||
|
!src/
|
||||||
|
!src/**
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Backend image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: backend-image-publication
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
IMAGE: nexus.personal.ksv741.ru/docker-hosted/wealthledger/backend
|
||||||
|
RELEASE_TAG: ${{ gitea.ref_name }}
|
||||||
|
REVISION: ${{ gitea.sha }}
|
||||||
|
DOCKER_CONFIG: ${{ runner.temp }}/wealthledger-backend-docker
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
- name: Validate release version
|
||||||
|
run: |
|
||||||
|
node --input-type=commonjs -e 'const p = require("./package.json"); const l = require("./package-lock.json"); if (!/^v\d+\.\d+\.\d+$/.test(process.env.RELEASE_TAG) || process.env.RELEASE_TAG !== `v${p.version}` || l.version !== p.version || l.packages[""].version !== p.version) process.exit(1)'
|
||||||
|
test "$(git rev-parse HEAD)" = "$REVISION"
|
||||||
|
docker version
|
||||||
|
docker buildx version
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx --no-install prisma generate
|
||||||
|
- run: npm run verify
|
||||||
|
- run: npm run test:e2e
|
||||||
|
- name: Login with dedicated Nexus writer
|
||||||
|
env:
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.NEXUS_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
test -n "$REGISTRY_USERNAME" && test -n "$REGISTRY_TOKEN"
|
||||||
|
install -d -m 700 "$DOCKER_CONFIG"
|
||||||
|
printf '%s' "$REGISTRY_TOKEN" | docker login nexus.personal.ksv741.ru --username "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
- name: Publish immutable release tag
|
||||||
|
run: |
|
||||||
|
VERSION="${RELEASE_TAG#v}"
|
||||||
|
if docker manifest inspect "$IMAGE:$VERSION" >/dev/null 2>manifest-error.log; then
|
||||||
|
echo 'Refusing to overwrite an existing release tag'; exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -Eq 'manifest unknown|no such manifest' manifest-error.log; then
|
||||||
|
echo 'Cannot prove tag absence; refusing publication'; exit 1
|
||||||
|
fi
|
||||||
|
docker buildx build --target production --build-arg VERSION="$VERSION" --build-arg REVISION="$REVISION" --tag "$IMAGE:$VERSION" --metadata-file image-metadata.json --push .
|
||||||
|
DIGEST="$(node --input-type=commonjs -p 'require("./image-metadata.json")["containerimage.digest"]')"
|
||||||
|
docker pull "$IMAGE@$DIGEST"
|
||||||
|
test "$(docker image inspect "$IMAGE@$DIGEST" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$REVISION"
|
||||||
|
npm run test:container -- "$IMAGE@$DIGEST"
|
||||||
|
printf 'Backend image: %s@%s\n' "$IMAGE" "$DIGEST"
|
||||||
|
- name: Remove registry session
|
||||||
|
if: always()
|
||||||
|
run: rm -rf "$DOCKER_CONFIG"
|
||||||
+12
-28
@@ -8,39 +8,15 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: backend
|
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
path: backend
|
fetch-depth: 0
|
||||||
- name: Checkout compatible peer revisions
|
- name: Record checked revision
|
||||||
env:
|
run: git rev-parse HEAD
|
||||||
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
|
|
||||||
TARGET_BRANCH: ${{ github.base_ref || github.ref_name }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
for repo in frontend; do
|
|
||||||
ref=develop
|
|
||||||
case "$SOURCE_BRANCH:$TARGET_BRANCH" in
|
|
||||||
release/*:*|*:main)
|
|
||||||
ref=$(jq -er --arg repo "$repo" '.[$repo]' .ci/refs.json)
|
|
||||||
printf '%s\n' "$ref" | grep -Eq '^[0-9a-f]{40}$'
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
target="../$repo"
|
|
||||||
if [ "$repo" = main ]; then target=..; fi
|
|
||||||
git init "$target"
|
|
||||||
git -C "$target" remote add origin "https://git.personal.ksv741.ru/WealthLedger/$repo.git"
|
|
||||||
git -C "$target" fetch --depth 1 origin "$ref"
|
|
||||||
git -C "$target" checkout --detach FETCH_HEAD
|
|
||||||
git -C "$target" rev-parse HEAD
|
|
||||||
done
|
|
||||||
git rev-parse HEAD
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: backend/.nvmrc
|
node-version-file: .nvmrc
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- name: Generate Prisma client before typechecks
|
- name: Generate Prisma client before typechecks
|
||||||
run: npx --no-install prisma generate
|
run: npx --no-install prisma generate
|
||||||
@@ -48,3 +24,11 @@ jobs:
|
|||||||
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: npm run test:e2e
|
||||||
|
- name: Analyze develop with SonarQube
|
||||||
|
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop'
|
||||||
|
# v2.3.0: same Docker-based scanner used for SonarQube 9.9.
|
||||||
|
uses: https://github.com/SonarSource/sonarqube-scan-action@aecaf43ae57e412bd97d70ef9ce6076e672fe0a9
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
with:
|
||||||
|
args: -Dsonar.qualitygate.wait=true
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ lerna-debug.log*
|
|||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
/coverage
|
/coverage
|
||||||
|
/.scannerwork
|
||||||
/.nyc_output
|
/.nyc_output
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
@@ -64,3 +65,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|||||||
/dev.db-*
|
/dev.db-*
|
||||||
/prisma/*.db
|
/prisma/*.db
|
||||||
/prisma/*.db-*
|
/prisma/*.db-*
|
||||||
|
|
||||||
|
# Generated public API artifact
|
||||||
|
/openapi.json
|
||||||
|
|||||||
@@ -15,3 +15,5 @@ dfca11c81f7e6dadce3ca6f961d265c5e3f8ff8e:src/tbank/portfolio.service.spec.ts:gen
|
|||||||
# Historical CircleCI badge copied verbatim from nestjs/typescript-starter README.
|
# Historical CircleCI badge copied verbatim from nestjs/typescript-starter README.
|
||||||
# Verified against https://github.com/nestjs/typescript-starter/blob/master/README.md
|
# Verified against https://github.com/nestjs/typescript-starter/blob/master/README.md
|
||||||
f8dcfb71c1767696a9aa6741ddaf6575416c901e:README.md:generic-api-key:5
|
f8dcfb71c1767696a9aa6741ddaf6575416c901e:README.md:generic-api-key:5
|
||||||
|
# Container smoke fixture: same deterministic 32-byte test key, never a runtime secret.
|
||||||
|
1a6be432f24f757728a03415051392069112af1f:test/container-smoke.cjs:generic-api-key:14
|
||||||
|
|||||||
@@ -1,30 +1,38 @@
|
|||||||
# Backend
|
# Backend router
|
||||||
|
|
||||||
NestJS API; общие правила работы — в root `AGENTS.md`.
|
Репозиторий самостоятельный: обязательные инструкции и проверки используют
|
||||||
До backend-решения прочитай [router](.ai/CONVENTIONS.md) и
|
только его файлы и предоставленные публичные контракты. Перед работой прочитай
|
||||||
[sources](.ai/SOURCES.md), затем только применимые документы/разделы.
|
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
|
||||||
|
объединяй triggers задачи и путей; читай активированные темы до решения.
|
||||||
|
|
||||||
## Проверка
|
| Решение / сигнал | Читать |
|
||||||
|
| --- | --- |
|
||||||
|
| Устройство/data flow, module/layer/public API/DI/suffix, owner | [Architecture](.ai/architecture.md) |
|
||||||
|
| TS vocabulary, domain literals, magic values | [Types/values](.ai/architecture.md#domain-literals-and-magic-values) |
|
||||||
|
| Controller/DTO/validation, route/status/response, OpenAPI | [HTTP API](.ai/conventions/http-api.md) |
|
||||||
|
| Application/domain/HTTP error, exception, code | [Errors](.ai/conventions/error-handling.md) |
|
||||||
|
| Repository/Prisma/query, transaction, migration, atomic write | [Data](.ai/conventions/data.md) |
|
||||||
|
| Tests или behavior с regression risk | [Testing](.ai/conventions/testing.md) |
|
||||||
|
| JWT/cookie/session/guard/access, encryption, secrets | [Security](.ai/conventions/security-auth.md) |
|
||||||
|
| External client/gateway/SDK/proto, retry/rate limit/cache | [Integrations](.ai/conventions/integrations.md) |
|
||||||
|
| Logging/context/levels, sensitive logs | [Observability](.ai/conventions/observability.md); sensitive data также Security |
|
||||||
|
| Изменение публичного wire contract / Swagger metadata | [HTTP API](.ai/conventions/http-api.md#источник-контракта) |
|
||||||
|
| Helper/dependency/новый pattern, refactor/scope | [Change scope](.ai/conventions/engineering.md#change-scope), для пакета — [Dependencies](.ai/conventions/engineering.md#dependencies) |
|
||||||
|
| Выбор gate, tooling, завершение | [Проверки](.ai/conventions/engineering.md#проверки-и-завершение) |
|
||||||
|
|
||||||
Перед Node-командами: `nvm use --silent` по `.nvmrc`.
|
Новый endpoint обычно требует HTTP API + Testing; Data/Security добавляются
|
||||||
После lockfile install `npm ci` полный gate — `npm run verify`:
|
по persistence/access trigger. Auth refresh активирует Security + Data + Testing.
|
||||||
zero-warning ESLint, application/test TypeScript, Jest и build.
|
Перед завершением повтори routing по новым и изменённым файлам. Для Git/release
|
||||||
`lint:fix`/`format` пишут файлы и запускаются отдельно от доказательства проверки.
|
прочитай [Git и релизы](.ai/conventions/engineering.md#git-и-релизы).
|
||||||
`test:cov` — диагностика без общего threshold; scripts принадлежат `package.json`.
|
|
||||||
|
|
||||||
HTTP contract changes сохраняют [направление контракта](../docs/architecture/http-api.md#источник-контракта)
|
## Источники
|
||||||
и проходят contract check. Общий e2e запускается на отдельных временных DB;
|
|
||||||
применимость и маршрут проверки
|
|
||||||
описаны в [backend testing](.ai/conventions/backend-testing.md).
|
|
||||||
Generated Prisma client не редактируется вручную. Credentials, авторизация
|
|
||||||
доступа к счетам и внешние интеграции остаются server-side.
|
|
||||||
|
|
||||||
ESLint/TypeScript не доказывают правильность domain boundaries, security или
|
| Вопрос | Локальный источник |
|
||||||
денежных вычислений: для них нужны semantic review и risk-based tests.
|
| --- | --- |
|
||||||
|
| HTTP errors/DTO/wire shape | [HTTP API](.ai/conventions/http-api.md), генерация `npm run openapi:generate`, configured HTTP tests |
|
||||||
## Ветки и релизы
|
| Auth/session, account access, secrets | [Security](.ai/conventions/security-auth.md) и auth/access tests |
|
||||||
|
| Prisma/data ownership | [Data](.ai/conventions/data.md), Prisma schema/migrations |
|
||||||
Работа — в отдельной ветке от `develop`, затем PR в `develop`; в `main` напрямую
|
| T-Bank/cache/logging | [Integrations](.ai/conventions/integrations.md), [Observability](.ai/conventions/observability.md) |
|
||||||
не пушить. Release/hotfix и синхронизация веток — по [root README](../README.md#релизы-и-ветки).
|
| Money и финансовые расчёты | [Data](.ai/conventions/data.md#деньги-и-точность), domain source и tests владельца |
|
||||||
Commits и заголовки PR — Conventional Commits; для спецификаций допустим `spec:`.
|
| Поведение области | Module source и tests; неясное ожидание уточни до изменения |
|
||||||
Перед подготовкой релиза прочитай root README; версии вручную не повышать.
|
| Versions/scripts/check scope | Manifest/lockfile, ESLint/TypeScript/Jest/Prisma configs |
|
||||||
|
|||||||
@@ -0,0 +1,673 @@
|
|||||||
|
# Аудит backend WealthLedger и план исправлений
|
||||||
|
|
||||||
|
Дата аудита: 2026-09-27. Репозиторий: `/Users/ksv741/Project/vibe/WealthLedger/backend`.
|
||||||
|
База: `516e43203eb3fee8e6a0c7018be2fee11f1b900c`, ветка `codex/investment-contracts`.
|
||||||
|
При аудите рабочее дерево было чистым; production-код не изменялся.
|
||||||
|
Этот документ сохраняет результаты аудита и задаёт проверяемый план исправлений.
|
||||||
|
Само сохранение документа не означает выполнения исправлений.
|
||||||
|
|
||||||
|
Номера строк относятся к указанному commit. Перед изменением повторно найти
|
||||||
|
symbol и проверить актуальный flow: после предыдущих исправлений строки сдвинутся.
|
||||||
|
Все относительные пути и ссылки относятся только к backend.
|
||||||
|
|
||||||
|
## Общая оценка
|
||||||
|
|
||||||
|
Backend имеет понятных владельцев ответственности: контроллеры тонкие,
|
||||||
|
денежные расчёты преимущественно точные, транзакции и очереди сосредоточены
|
||||||
|
у своих владельцев. Массовое дробление файлов не требуется.
|
||||||
|
Наиболее проблемные области — обработка ошибок auth, проверка ответов MOEX,
|
||||||
|
согласование состояния фоновой синхронизации и временных признаков кешированных данных.
|
||||||
|
|
||||||
|
Изучены локальные [AGENTS.md](AGENTS.md), [порядок работы](.ai/conventions/engineering.md),
|
||||||
|
[архитектура](.ai/architecture.md) и активированные темы HTTP, errors, data,
|
||||||
|
testing, security, integrations и observability. Соседние репозитории не использовались.
|
||||||
|
|
||||||
|
Инвентаризированы 135 production-файлов TypeScript, 16 004 строки, 17 Nest module-файлов.
|
||||||
|
Generated Prisma, vendor protocols, миграции и тесты отделены от production-рейтинга.
|
||||||
|
|
||||||
|
Приоритеты: **высокий** — существенный риск безопасности или искажения данных;
|
||||||
|
**средний** — ограниченный дефект поведения, контракта или доказанная стоимость связности;
|
||||||
|
**низкий** — локальное упрощение. Уверенность всех перечисленных находок высокая
|
||||||
|
для описанного механизма; ограничения конкретного воспроизведения указаны отдельно.
|
||||||
|
|
||||||
|
## План выполнения и границы
|
||||||
|
|
||||||
|
1. Прочитать действующий backend AGENTS и активированные conventions; зафиксировать
|
||||||
|
текущие HEAD/status. Сохранить существующие staged/unstaged/untracked изменения.
|
||||||
|
2. Работать по находкам A01–A10 и cleanup C01–C07 ниже, в порядке пакетов из таблицы.
|
||||||
|
Перед каждым исправлением перечитать владельца целиком, callers, связанные tests
|
||||||
|
и 1–3 релевантных примера. Если факт больше не воспроизводится, отметить это
|
||||||
|
с доказательством вместо механического применения рекомендации.
|
||||||
|
3. Для дефекта сначала получить падающую проверку наблюдаемого результата на самой
|
||||||
|
дешёвой надёжной boundary. Для concurrency/atomicity использовать реальную
|
||||||
|
изолированную SQLite; mock race не доказывает DB-инвариант.
|
||||||
|
4. Исправлять у существующего владельца минимальным связанным diff. Сохранять
|
||||||
|
precision, ownership, public error codes, transaction/fencing, cache isolation,
|
||||||
|
limiter и выполняющиеся promises. Предложения ниже задают результат, а не
|
||||||
|
обязательную форму реализации.
|
||||||
|
5. После каждого пакета выполнить targeted checks; в конце — актуальные обязательные
|
||||||
|
gates по backend conventions. Перед каждой Node-командой выполнить `nvm use --silent`.
|
||||||
|
6. Сохранить исходный снимок аудита; результаты реализации записывать в таблицу
|
||||||
|
«Статус исправлений». Для каждого ID указать diff/решение, команды и результат,
|
||||||
|
остаточный риск либо точную причину невыполнения.
|
||||||
|
|
||||||
|
| Пакет | Пункты | Условие завершения |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1. Auth и безопасные ошибки | A01, A02 | Отказы хранилища не маскируются; секреты не попадают в completion log |
|
||||||
|
| 2. MOEX trust boundary | A03 | Malformed response не публикуется как полный результат; pagination завершается |
|
||||||
|
| 3. Статус синхронизации | A04 | Screen учитывает состояние successor через владельца operations |
|
||||||
|
| 4. Свечи | A05 | Force не теряется при coalescing; forced publication остаётся атомарной |
|
||||||
|
| 5. Свежесть и время | A06, A07 | Stale-флаг актуален; wire timestamp совпадает с используемым при фильтрации |
|
||||||
|
| 6. HTTP validation | A08 | Реальный pipeline возвращает структурированные field errors |
|
||||||
|
| 7. Связность и удаление legacy | A09, A10, C01–C07 | Живые boundaries сохранены; лишние реализации и exports удалены |
|
||||||
|
|
||||||
|
Пакеты независимы по цели, но могут пересекаться файлами и общими fixtures.
|
||||||
|
Параллелить только области с непересекающимся владением файлами, затем выполнить
|
||||||
|
общую проверку. A02 и A08 изменяют один HTTP filter и должны быть согласованы.
|
||||||
|
|
||||||
|
В scope входят исправления подтверждённых находок и их regression tests.
|
||||||
|
Гипотезы в конце документа не являются задачами на изменение кода.
|
||||||
|
Новые зависимости, generic repositories, scheduler framework, интерфейсы ради
|
||||||
|
паттерна, schema migrations и size-only splits для этих исправлений не требуются.
|
||||||
|
Отдельную необходимость такого расширения следует доказать до реализации.
|
||||||
|
Коммиты, push и PR выполняются только по отдельному запросу пользователя.
|
||||||
|
|
||||||
|
## Подтверждённые находки
|
||||||
|
|
||||||
|
### A01. Auth lifecycle смешивает invalid credentials и operational failure
|
||||||
|
|
||||||
|
**Приоритет:** высокий. **Уверенность:** высокая.
|
||||||
|
|
||||||
|
**Места и потребители:** [AuthService.logout, строки 104–126](src/modules/auth/application/auth.service.ts),
|
||||||
|
[AuthController.logout, 95–104; refresh, 85–91](src/modules/auth/presentation/auth.controller.ts).
|
||||||
|
|
||||||
|
Общий catch в logout подавляет JWT verification errors и отказ Prisma при записи
|
||||||
|
`revokedAt`. Controller затем очищает cookie и возвращает 204, хотя серверная
|
||||||
|
сессия осталась действительной. Сохранённая копия refresh token продолжает работать.
|
||||||
|
Смежное проявление: refresh controller удаляет cookie при любой ошибке, включая
|
||||||
|
временный отказ БД, без доказательства недействительности токена.
|
||||||
|
|
||||||
|
**Доказательство:** inline fault injection на настоящих AuthService, JwtService
|
||||||
|
и AuthController с синтетической сессией подтвердил:
|
||||||
|
|
||||||
|
```text
|
||||||
|
logoutResolved=true
|
||||||
|
cookieCleared=true
|
||||||
|
sessionNotRevoked=true
|
||||||
|
sameRefreshTokenStillUsable=true
|
||||||
|
unexpectedRefreshErrorPropagated=true
|
||||||
|
validCookieRemoved=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Это доказательство обработки injected boundary failure, а не реального отказа БД.
|
||||||
|
[Auth HTTP suite](test/app.e2e-spec.ts) проверяет обычный logout, но не этот отказ.
|
||||||
|
Нарушен [контракт отзыва сессии](.ai/conventions/security-auth.md): идемпотентность
|
||||||
|
не превращает несостоявшуюся запись в успешную.
|
||||||
|
|
||||||
|
**Минимальное действие:** ограничить подавление ошибок logout недействительным JWT;
|
||||||
|
ошибки хранилища пробрасывать. При refresh очищать cookie только при установленном
|
||||||
|
auth failure. Существующий AuthService.refresh уже отделяет verification от DB reads;
|
||||||
|
BankCardsRepository.write также пробрасывает неизвестные ошибки.
|
||||||
|
|
||||||
|
**Приёмка:**
|
||||||
|
|
||||||
|
- Отсутствующий/недействительный/уже отозванный refresh сохраняет идемпотентный logout.
|
||||||
|
- Ошибка записи revocation не возвращает фиктивный 204.
|
||||||
|
- Успешный logout делает последующий refresh невозможным.
|
||||||
|
- Временный отказ refresh не удаляет действующую cookie, повтор после восстановления возможен.
|
||||||
|
- Проверены status/code/cookie через configured HTTP; успешный отзыв — с SQLite.
|
||||||
|
|
||||||
|
**Риск:** сохранить существующую семантику недействительных токенов. Изменение
|
||||||
|
access-JWT lifetime или введение replay history не входят в этот пункт.
|
||||||
|
|
||||||
|
### A02. Многострочное сообщение исключения попадает в completion log
|
||||||
|
|
||||||
|
**Приоритет:** высокий. **Уверенность:** высокая.
|
||||||
|
|
||||||
|
**Места:** [HttpExceptionFilter, 30–37](src/common/filters/http-exception.filter.ts).
|
||||||
|
**Потребители:** [RequestLoggingMiddleware, 43–57](src/common/logging/request-logging.middleware.ts),
|
||||||
|
[StructuredLogger, 47–63](src/common/logging/structured-logger.service.ts).
|
||||||
|
|
||||||
|
Фильтр удаляет только первую строку `exception.stack`. Остальные строки
|
||||||
|
многострочного Error.message сохраняются. Поле stack разрешено logger allowlist;
|
||||||
|
redaction HTTP body к нему не применяется.
|
||||||
|
|
||||||
|
**Воспроизведение:** configured HTTP app; AuthService.login выбрасывает синтетический
|
||||||
|
`Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET')`. Ответ остаётся
|
||||||
|
`500 / INTERNAL_SERVER_ERROR`, но completion log содержит вторую строку сообщения.
|
||||||
|
Реальные секреты не использовались. Существующий filter spec проверяет однострочное сообщение.
|
||||||
|
Нарушены [observability](.ai/conventions/observability.md) и security rules.
|
||||||
|
|
||||||
|
**Минимальное действие:** убрать передачу сырого stack через общую HTTP boundary,
|
||||||
|
сохранив code/request context. Если stack необходим, нужна проверенная форма кадров
|
||||||
|
без произвольного текста исключения; удаление только первой строки недостаточно.
|
||||||
|
|
||||||
|
**Приёмка:** multiline synthetic secrets отсутствуют в response и в конечном
|
||||||
|
payload logger, а status/code/correlation сохраняются. Проверить настоящий
|
||||||
|
filter → middleware → logger, не только mock вызова filter.
|
||||||
|
|
||||||
|
**Риск:** диагностическая подробность может уменьшиться; raw message возвращать нельзя.
|
||||||
|
|
||||||
|
### A03. MOEX malformed response превращается в полный пустой прогноз
|
||||||
|
|
||||||
|
**Приоритет:** высокий. **Уверенность:** высокая.
|
||||||
|
|
||||||
|
**Место:** [MoexClient, 122–142 и 163–165](src/integrations/moex/moex.client.ts).
|
||||||
|
Отсутствующий запрошенный ISS block преобразуется в `[]`. Неполный cursor может
|
||||||
|
завершить чтение с частичными данными; cursor с нулевым pageSize может зациклить его.
|
||||||
|
|
||||||
|
**Реальная цепочка:** [CatalogEnrichmentService, 444 и 509–548](src/modules/investments/instruments/application/catalog-enrichment.service.ts)
|
||||||
|
публикует `bondEvents: []`; readMany считает существующий row complete;
|
||||||
|
[UpcomingEventsService, 137–197](src/modules/investments/upcoming-events/upcoming-events.service.ts)
|
||||||
|
строит пустые events/totals; [ForecastRepository, 124–150](src/modules/investments/portfolio/infrastructure/forecast.repository.ts)
|
||||||
|
может заменить прежний непустой forecast. Владелец protocol validation — integration client.
|
||||||
|
|
||||||
|
**Воспроизведение:** настоящий MoexClient с mock fetch HTTP 200 без coupons/offers
|
||||||
|
вернул `[]`. Cursor `INDEX=0,TOTAL=1,PAGESIZE=0` дал пять запросов `start=0`;
|
||||||
|
проба остановлена искусственной ошибкой. Timeout каждого fetch не ограничивает весь цикл.
|
||||||
|
|
||||||
|
**Расхождение тестов:** [moex.client.spec, 97–107](src/integrations/moex/moex.client.spec.ts)
|
||||||
|
считает отсутствующий block пустым результатом;
|
||||||
|
[upcoming-events.e2e, 439–451](test/upcoming-events.e2e-spec.ts) обещает обратное,
|
||||||
|
но подменяет весь клиент готовым rejection и не проверяет parser.
|
||||||
|
|
||||||
|
**Минимальное действие:** различать отсутствующий/повреждённый block и корректный
|
||||||
|
`data: []`; проверять структуру строк и cursor, положительный pageSize и строгое
|
||||||
|
продвижение start. Нарушение протокола → `MOEX_UNAVAILABLE`, без частичного success.
|
||||||
|
|
||||||
|
**Приёмка:**
|
||||||
|
|
||||||
|
- Malformed block/cursor не меняет сохранённый полный календарь/forecast.
|
||||||
|
- Корректный пустой block остаётся успешным пустым результатом.
|
||||||
|
- Непродвигающаяся pagination завершается ошибкой, без бесконечных fetch.
|
||||||
|
- Семантика HTTP 404 проверена отдельно и не изменена случайно.
|
||||||
|
- Downstream regression использует настоящий MoexClient с mock fetch, а не mock rejection клиента.
|
||||||
|
|
||||||
|
**Риск:** перепутать законное отсутствие данных с ошибкой protocol shape.
|
||||||
|
|
||||||
|
### A04. Screen sync сообщает succeeded до завершения successor
|
||||||
|
|
||||||
|
**Приоритет:** средний. **Уверенность:** высокая.
|
||||||
|
|
||||||
|
**Места:** [TbankSyncService.status, 330–345](src/modules/investments/tbank-sync/tbank-sync.service.ts),
|
||||||
|
[TbankSyncRepository.read, 70–80](src/modules/investments/tbank-sync/tbank-sync.repository.ts).
|
||||||
|
[Operations repository, 441–445](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts)
|
||||||
|
помечает объединяемую группу `superseded` и сохраняет successorId. Владелец операций
|
||||||
|
умеет разрешать цепочку в observe, строки 564–590, но screen читает прямого child.
|
||||||
|
|
||||||
|
**Сценарий:** две активные screen-группы на части января; третья объединяет интервалы.
|
||||||
|
Screen вытесненной группы сообщает succeeded, хотя successor ещё running; его
|
||||||
|
последующая ошибка также теряется.
|
||||||
|
|
||||||
|
**Воспроизведение настоящего status с mocked persistence:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
screenState=succeeded
|
||||||
|
resource.availability=missing
|
||||||
|
resource.sync.state=running
|
||||||
|
actualOperationState=running
|
||||||
|
```
|
||||||
|
|
||||||
|
SQLite bridge покрыт [operations-sync e2e, 455–474](test/tbank-operations-sync.e2e-spec.ts),
|
||||||
|
но существующая проверка наблюдает operations.observe, а не screen status.
|
||||||
|
|
||||||
|
**Минимальное действие:** разрешать operations successors через существующего
|
||||||
|
владельца перед агрегацией screen state/progress. Не копировать второй алгоритм
|
||||||
|
разрешения цепочки в screen service.
|
||||||
|
|
||||||
|
**Приёмка:** configured HTTP + SQLite bridge; задержанный successor → running,
|
||||||
|
затем успех → succeeded или отказ → failed; progress согласован, чужая revision
|
||||||
|
не видна. **Риск:** fencing, цепочки successors и корректный подсчёт progress.
|
||||||
|
|
||||||
|
### A05. Force обновления свечей теряется при coalescing
|
||||||
|
|
||||||
|
**Приоритет:** средний. **Уверенность:** высокая.
|
||||||
|
|
||||||
|
**Места:** [CandleService.request/load, 326–365](src/modules/investments/instruments/application/candle.service.ts),
|
||||||
|
[CandleRepository.reserve, 76–82](src/modules/investments/instruments/infrastructure/candle.repository.ts).
|
||||||
|
Публичный caller: [TbankSyncService, 288–306](src/modules/investments/tbank-sync/tbank-sync.service.ts)
|
||||||
|
вызывает candles.request с force=true; candleRange необязателен, ключ может совпасть
|
||||||
|
с ordinary GET/prefetch.
|
||||||
|
|
||||||
|
При launch=false service возвращает существующий id без сохранения новых force/priority.
|
||||||
|
При присоединении cross-user observer требования также не повышаются.
|
||||||
|
|
||||||
|
**Воспроизведение настоящего класса с mocked repository/upstream:** январь покрыт,
|
||||||
|
ordinary period=all заблокирован перед загрузкой detail, затем приходит force all.
|
||||||
|
После release единственный RPC идёт от `2026-02-01T00:00:00.000Z` до
|
||||||
|
`2026-02-02T12:00:00.000Z`; январь не перечитан. Исправленные исторические значения
|
||||||
|
остаются прежними. Existing candle suite отдельно проверяет force и coalescing.
|
||||||
|
|
||||||
|
**Минимальное действие:** сохранить forced требование и выполнить полную reconciliation.
|
||||||
|
Если gap-план уже начат, безопасно перепланировать или выполнить следующую forced
|
||||||
|
попытку. Простого `force ||= true` после публикации первых окон недостаточно.
|
||||||
|
Аналог повышения требований есть в CatalogDiscoveryService, но его алгоритм нельзя
|
||||||
|
механически переносить на staged свечи.
|
||||||
|
|
||||||
|
**Приёмка:** ordinary load + force того же scope действительно обновляют уже
|
||||||
|
закрытую свечу. Возвращённый work ID завершается только после выполнения forced
|
||||||
|
требования с актуальным диапазоном запроса. Проверить join до запуска и после
|
||||||
|
публикации первого обычного окна, включая observer другого пользователя;
|
||||||
|
экранный запрос получает экранный приоритет. Ошибка следующего forced окна сохраняет
|
||||||
|
прежнюю историю и timestamps; user/revision fencing сохраняется.
|
||||||
|
Proof атомарности — существующая SQLite boundary.
|
||||||
|
|
||||||
|
**Риск:** высокий риск неверного локального исправления из-за staged/atomic publication.
|
||||||
|
|
||||||
|
### A06. Payout snapshot замораживает positionIsStale
|
||||||
|
|
||||||
|
**Приоритет:** средний. **Уверенность:** высокая.
|
||||||
|
|
||||||
|
**Место:** [ForecastRepository.resolve, 132–137](src/modules/investments/portfolio/infrastructure/forecast.repository.ts).
|
||||||
|
**Потребители:** [StockIncomeService, 67–87 и 161–163](src/modules/investments/instruments/stocks/application/stock-income.service.ts),
|
||||||
|
[BondIncomeService, 180–182](src/modules/investments/instruments/bonds/application/bond-income.service.ts).
|
||||||
|
|
||||||
|
Истечение 60-секундного portfolio TTL меняет isStale, но не source revision/timestamps.
|
||||||
|
Repository возвращает прежний payload вместо нового candidate. Возможны одновременно
|
||||||
|
`meta.freshness: stale` и `data.positionIsStale: false`.
|
||||||
|
|
||||||
|
**Воспроизведение:** настоящий repository с mocked transaction; candidate false,
|
||||||
|
затем true при тех же версиях → повторный результат false, всего одна запись.
|
||||||
|
Pure payout tests принимают stale input, но не проверяют aging persisted forecast.
|
||||||
|
|
||||||
|
**Минимальное действие:** вычислять возраст сохранённой позиции при выдаче по её
|
||||||
|
positionAt и действующей freshness policy. Учитывать именно позицию сохранённого
|
||||||
|
fallback, а не случайно подставлять свежесть более нового несовместимого портфеля.
|
||||||
|
Не сравнивать весь candidate без разбора: текущий calculatedAt у events вызовет
|
||||||
|
запись на каждом чтении. Не менять financial revision только из-за хода времени.
|
||||||
|
|
||||||
|
**Приёмка:** controllable clock, TTL истёк без изменения источников, sync=observe:
|
||||||
|
stale=true, суммы и source timestamps прежние, новых RPC нет; fallback остаётся
|
||||||
|
согласованным. **Риск:** смешение возраста сохранённой и текущей позиции.
|
||||||
|
|
||||||
|
### A07. Timestamp операций расходится между persistence и wire
|
||||||
|
|
||||||
|
**Приоритет:** средний. **Уверенность:** высокая.
|
||||||
|
|
||||||
|
**Место:** [brokerage-operation.mapper, 85–90](src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts)
|
||||||
|
игнорирует nanos; [repository, 653–657 и publish](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts)
|
||||||
|
учитывает миллисекунды. Consumers mapper: BrokerageOperationsService, обычный ledger
|
||||||
|
и income reads (строки 359 и 619 на базе аудита).
|
||||||
|
|
||||||
|
**Воспроизведение:** seconds=1768000000, nanos=600000000 → persistence
|
||||||
|
`2026-01-09T23:06:40.600Z`, wire `2026-01-09T23:06:40.000Z`.
|
||||||
|
Запрос from=`…40.500Z` возвращает строку с видимым временем раньше from.
|
||||||
|
Existing mapper specs проверяют money/quantity, но не это соответствие.
|
||||||
|
|
||||||
|
**Минимальное действие:** согласовать конвертацию protobuf timestamp; при возможности
|
||||||
|
использовать уже сохранённый occurredAt для wire. Предотвратить дальнейшее расхождение
|
||||||
|
реализаций без создания универсальной библиотеки дат.
|
||||||
|
|
||||||
|
**Приёмка:** subsecond mapper case и HTTP boundary внутри секунды; порядок/cursor,
|
||||||
|
обычный ledger и income используют согласованное время. **Риск:** исправляется
|
||||||
|
публичное значение timestamp; формат и точность Money сохраняются.
|
||||||
|
|
||||||
|
### A08. Configured validation response теряет ошибки полей
|
||||||
|
|
||||||
|
**Приоритет:** средний. **Уверенность:** высокая.
|
||||||
|
|
||||||
|
**Места:** [configureApp, 29](src/main.ts),
|
||||||
|
[HttpExceptionFilter, 44–48 и 70–78](src/common/filters/http-exception.filter.ts).
|
||||||
|
ValidationPipe выдаёт message:string[], filter приводит его к ValidationError[]
|
||||||
|
и ищет constraints. children вложенных ошибок также не обходятся.
|
||||||
|
|
||||||
|
**Configured HTTP reproduction:** invalid email и отсутствующий password:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":400,"code":"VALIDATION_ERROR","errors":[]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый [HTTP contract](.ai/conventions/http-api.md) требует pointer/detail.
|
||||||
|
Conventions уже фиксируют это известное расхождение. OpenAPI parity проверяет
|
||||||
|
schema, но не наблюдаемый response pipeline.
|
||||||
|
|
||||||
|
**Минимальное действие:** согласовать exceptionFactory и filter, сформировать
|
||||||
|
структурированные ошибки, включая вложенные поля, без target/value исходного DTO.
|
||||||
|
|
||||||
|
**Приёмка:** реальные requests credentials и nested Money дают непустые корректные
|
||||||
|
pointer/detail; status/code/content-type сохраняются; неизвестные исключения
|
||||||
|
по-прежнему безопасны. Проверить согласование с A02 и публичной metadata.
|
||||||
|
**Риск:** public error shape и корректность вложенных путей.
|
||||||
|
|
||||||
|
### A09. Planner зависит от service barrel и образует runtime import cycle
|
||||||
|
|
||||||
|
**Приоритет:** средний. **Уверенность:** высокая.
|
||||||
|
|
||||||
|
**Места:** [investment-sync-plan, 3–6](src/modules/investments/investment-sync-plan.ts)
|
||||||
|
→ [operations.api](src/modules/investments/operations/operations.api.ts)
|
||||||
|
→ operations services → investment-sync-plan.
|
||||||
|
|
||||||
|
Алгоритму нужны interval rules, но barrel загружает services, TbankClient и Prisma.
|
||||||
|
[Planner spec, строка 1](src/modules/investments/investment-sync-plan.spec.ts) уже
|
||||||
|
вынужден mock Prisma. Это доказанная стоимость связности; production crash из-за
|
||||||
|
цикла не обнаружен. Type-only цикл T-Bank records — отдельный безвредный случай.
|
||||||
|
|
||||||
|
**Минимальное действие:** предоставить interval rules чистый публичный вход,
|
||||||
|
аналогичный разрешённому integrations/tbank/decimal.ts; service API оставить отдельно.
|
||||||
|
Не заменять cycle на forwardRef и не вводить новый DI слой.
|
||||||
|
|
||||||
|
**Приёмка:** planner импортируется/тестируется без Prisma mock; cycle устранён,
|
||||||
|
interval semantics и DI initialization сохранены. **Риск:** низкий, import/export surface.
|
||||||
|
|
||||||
|
### A10. Неиспользуемый legacy T-Bank access flow
|
||||||
|
|
||||||
|
**Приоритет:** низкий. **Уверенность:** высокая.
|
||||||
|
|
||||||
|
**Место:** [TbankAccessService, 255–330](src/integrations/tbank/tbank-access.service.ts).
|
||||||
|
listSupportedAccounts/withSupportedAccounts/withConnection/withAccount/
|
||||||
|
withAccountForConnection/private connection вызываются только внутри собственной
|
||||||
|
цепочки и tests. Проверены production consumers, DI и decorators.
|
||||||
|
|
||||||
|
Legacy flow поддерживает generation=hash(ciphertext), тогда как живой persisted
|
||||||
|
flow использует DB revision, local и tokenForAttempt. Поддерживаются две модели
|
||||||
|
без production-потребности; около 76 строк, context types и TbankClient dependency.
|
||||||
|
|
||||||
|
**Минимальное действие:** повторно проверить callers на актуальном HEAD, удалить
|
||||||
|
legacy-цепочку, неиспользуемые context exports и зависимость; отдельно проверить
|
||||||
|
оставшихся consumers accessibleTbankAccount до его удаления.
|
||||||
|
|
||||||
|
**Приёмка:** нет мёртвой цепочки, живые access/invalid-token/revision/fencing cases
|
||||||
|
сохранены. Перенести полезные security assertions, если они были только в удаляемых
|
||||||
|
specs. **Риск:** удалить общее правило или полезную проверку вместе с legacy.
|
||||||
|
|
||||||
|
## Локальные упрощения
|
||||||
|
|
||||||
|
Все пункты: низкий приоритет, высокая уверенность. Перед удалением повторить поиск
|
||||||
|
callers; отсутствие production callers в базе аудита не гарантирует его на новом HEAD.
|
||||||
|
|
||||||
|
| ID | Место и доказательство | Минимальное действие и приёмка |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C01 | [portfolio.service, 515–522](src/modules/investments/portfolio/application/portfolio.service.ts): локальный decimalFromString повторяет common parser без validation; callers position/value | Импортировать существующий common owner; удалить дубликат. Exact-money tests и typecheck. Текущая ошибка сумм не доказана; проверить допустимые входы. |
|
||||||
|
| C02 | [tbank.api](src/integrations/tbank/tbank.api.ts): TbankSyncAttempt импортируют напрямую семь внешних файлов sync/instruments; enrichment также обходит API для market types | Экспортировать используемые типы, перенаправить imports через API. TbankCoupon уже экспортируется. Typecheck и поиск обходов; runtime-риск минимален. |
|
||||||
|
| C03 | [auth.service, 190–192](src/modules/auth/application/auth.service.ts): normalizeEmail возвращает аргумент; register/login вызывают его, реальная нормализация в CredentialsDto | Удалить no-op, сохранить DTO transform. Existing HTTP case/whitespace tests; дополнительную нормализацию без необходимости не вводить. |
|
||||||
|
| C04 | [bank-card.calculations, 56–76](src/modules/banking/bank-cards/domain/bank-card.calculations.ts): string period используется только tests; production summary передаёт from/to | Сузить API до рабочего диапазона; перевести полезные unit cases. Проверить границы месяца/range. |
|
||||||
|
| C05 | [instrument-market, 39–51](src/modules/investments/instruments/application/instrument-market.ts): optional без consumers; [stock-position wrapper](src/modules/investments/instruments/stocks/application/stock-position.ts) нужен только своему spec | Удалить optional, перенаправить полезные wrapper tests на instrumentPosition. Сохранить fractional/mixed/offset holdings cases. |
|
||||||
|
| C06 | [portfolio.api, 2](src/modules/investments/portfolio/portfolio.api.ts): totalsByCurrency не имеет внешнего production consumer; внутренний использует mapper напрямую | Удалить только лишний export, mapper сохранить; typecheck. |
|
||||||
|
| C07 | [auth.controller.spec, 9–20](src/modules/auth/presentation/auth.controller.spec.ts): проверяет HTTP_CODE_METADATA через prototype; те же 200 проверяются configured HTTP suite | Удалить дублирующий wiring spec только при сохранении реальных HTTP status/cookie assertions. |
|
||||||
|
|
||||||
|
## Карта ответственности и проверенные сценарии
|
||||||
|
|
||||||
|
| Область / владелец | Интерфейс и consumers | Состояние, зависимости, инварианты и эффекты |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| App/config/common/Prisma | Composition root, configureApp, Money/decimal, logging | Env validation, HTTP pipeline, DB lifecycle, request context; общая арифметика без Nest/Prisma |
|
||||||
|
| Auth | Auth HTTP; guard/AccessPayload API для protected controllers | User/Session, bcrypt/JWT, refresh hash/CAS; cookies у controller |
|
||||||
|
| Bank cards/operations/categories | BankCardsController → профильные services | Ownership, archive/version/replay/tombstones; refund budget и перевод в общей transaction boundary |
|
||||||
|
| Bank summary | Controller → use case → repository → pure calculation | Согласованное чтение и BigInt-сводка по периоду |
|
||||||
|
| Brokerage accounts | HTTP/read API для screen → service → PortfolioService | Собственной persistence нет; missing membership отличается от confirmed empty |
|
||||||
|
| Portfolio/forecast | HTTP и API для instruments/events/accounts | Private user/revision/account snapshots; mapping/decimal; aggregate completeness; atomic dependency version checks |
|
||||||
|
| Operations | HTTP/API для income/screen | Local journal, signed cursor binding; durable work/staging, intervals, recovery, atomic reconciliation |
|
||||||
|
| Income analytics | HTTP/read API → Operations API | Period roles/completeness, grouping и точные signed totals по валютам; своей DB нет |
|
||||||
|
| Upcoming events | HTTP/read API → portfolio/calendars → forecast | Events по positions; missing calendar не равен пустому; redemption исключён из income totals |
|
||||||
|
| Catalog discovery/enrichment | Stock/bond services и public source APIs | Shared identity/aliases/source rows; private work, TTL, batching, coalescing и generation-fenced publication |
|
||||||
|
| Candles | Market services/screen → CandleService/Repository | Shared candles/coverage; mutable tail, gaps, windows, очередь и staged force replacement |
|
||||||
|
| Stock/bond position/income | Controllers → application services | UID/type matching, exact weighted cost, mixed currencies, payout estimates; Portfolio/Catalog/Forecast APIs |
|
||||||
|
| T-Bank connection | Guarded GET/PUT/DELETE | Проверка токена до replacement, AES-GCM, новая revision/cascade; failed validation сохраняет connection |
|
||||||
|
| T-Bank client/access/membership/metadata | Integration API для business modules | gRPC/TLS, единые limiter/retry/deadline; credentials, account access, membership и publication fencing |
|
||||||
|
| Screen sync | POST descriptor / GET status | Orchestration через APIs; durable children/attempts; passive observation |
|
||||||
|
| MOEX/NCR | Integration APIs → enrichment | ISS parser/pagination; NCR strict registry parser/exact ISIN/shared cache |
|
||||||
|
|
||||||
|
Прослежены auth/session flows; создание/изменение карт, операций, переводов,
|
||||||
|
возвратов и summary; ledger→sync→staging→publication; portfolio→mapping→snapshot;
|
||||||
|
payouts/events→forecast; catalog/candles и screen orchestration.
|
||||||
|
|
||||||
|
Повторной регистрации владельцев caches/queues и циклов Nest modules не обнаружено.
|
||||||
|
InstrumentSourcesModule позволяет portfolio пользоваться источниками без DI-цикла
|
||||||
|
с InstrumentsModule. Type-only цикл T-Bank records не является finding.
|
||||||
|
|
||||||
|
## Размеры production-файлов
|
||||||
|
|
||||||
|
Количество строк включает comments/blank lines. Размер — основание для чтения,
|
||||||
|
а не самостоятельный дефект. Обязательные splits не обоснованы.
|
||||||
|
|
||||||
|
| Файл | Строк | Решение и причина |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| [brokerage-operations.repository](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts) | 924 | Оставить: overlap groups, staging, recovery и publication держат общий transactional invariant |
|
||||||
|
| [catalog-enrichment.service](src/modules/investments/instruments/application/catalog-enrichment.service.ts) | 665 | Упростить локально: protocol boundary/imports; сохранить producers, batching, completions вместе |
|
||||||
|
| [brokerage-operations.service](src/modules/investments/operations/application/brokerage-operations.service.ts) | 636 | Оставить: ledger/income имеют различную completeness; generic builder добавит скрытые режимы |
|
||||||
|
| [tbank-sync.service](src/modules/investments/tbank-sync/tbank-sync.service.ts) | 595 | Упростить: разрешение successors вернуть operations owner, generic job framework не нужен |
|
||||||
|
| [tbank.client](src/integrations/tbank/tbank.client.ts) | 560 | Оставить: records/RPC methods и единый limiter/retry owner |
|
||||||
|
| [portfolio.service](src/modules/investments/portfolio/application/portfolio.service.ts) | 522 | Упростить: удалить duplicate parser, scheduling state не разрывать |
|
||||||
|
| [candle.service](src/modules/investments/instruments/application/candle.service.ts) | 481 | Оставить: исправить force join, сохранить queue/publication invariant |
|
||||||
|
| [catalog-discovery.repository](src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts) | 480 | Оставить: identity/aliases/source publication и CAS связны |
|
||||||
|
| [catalog-discovery.service](src/modules/investments/instruments/application/catalog-discovery.service.ts) | 390 | Оставить: search/detail и producer lifecycle имеют одного owner |
|
||||||
|
| [bank-operations.service](src/modules/banking/bank-cards/application/bank-operations.service.ts) | 343 | Оставить: разрыв validation/refund/transfer writes повышает риск |
|
||||||
|
| [tbank-access.service](src/integrations/tbank/tbank-access.service.ts) | 331 | Упростить удалением legacy flow |
|
||||||
|
| [investment-sync-plan](src/modules/investments/investment-sync-plan.ts) | 327 | Упростить зависимость: убрать service barrel из алгоритма |
|
||||||
|
| [instrument-metadata.repository](src/integrations/tbank/instrument-metadata.repository.ts) | 320 | Оставить: identity, batching и publication согласованы |
|
||||||
|
| [income-analytics.service](src/modules/investments/income/income-analytics.service.ts) | 318 | Оставить: связные projections, выгода дополнительных моделей не доказана |
|
||||||
|
| [candle.repository](src/modules/investments/instruments/infrastructure/candle.repository.ts) | 306 | Оставить: reconciliation, coverage, fencing и stage cleanup |
|
||||||
|
| [upcoming-events.service](src/modules/investments/upcoming-events/upcoming-events.service.ts) | 297 | Оставить: один сценарий, основной дефект у MOEX boundary |
|
||||||
|
| [brokerage-operations-sync.service](src/modules/investments/operations/application/brokerage-operations-sync.service.ts) | 294 | Оставить: связный владелец durable work execution |
|
||||||
|
|
||||||
|
Отдельные категории: 37 unit specs / 3319 строк; 21 HTTP/SQLite specs / 7878 строк;
|
||||||
|
10 test support/fixtures / 1993 строки; 6 vendor proto / 3588 строк;
|
||||||
|
5 SQL migrations / 345 строк. Generated Prisma и build artifacts не ранжировались.
|
||||||
|
|
||||||
|
## Удачные решения, которые требуется сохранить
|
||||||
|
|
||||||
|
- BigInt/decimal и string Money; разные валюты не складываются неявно.
|
||||||
|
- Refresh CAS, ownership queries, версии операций, replay fingerprints/tombstones.
|
||||||
|
- Network calls отделены от DB transactions.
|
||||||
|
- Revision/attempt fencing, late-result discard, staging перед force reconciliation.
|
||||||
|
- Missing/partial/confirmed empty/saved complete различаются явно.
|
||||||
|
- Concrete providers, компактные плоские модули и разрешённый прямой Prisma
|
||||||
|
в связных CRUD/transaction services не являются автоматическими дефектами.
|
||||||
|
- SQLite behavior tests проверяют реальные денежные границы, races и rollback.
|
||||||
|
|
||||||
|
## Выполненные проверки и пределы доказательства
|
||||||
|
|
||||||
|
Перед успешными Node-командами загружалась `~/.nvm/nvm.sh`, затем `nvm use --silent`.
|
||||||
|
Первый вызов nvm без загрузки shell function завершился command not found;
|
||||||
|
после загрузки проверок это ограничение не осталось.
|
||||||
|
|
||||||
|
| Команда / проверка | Результат аудита |
|
||||||
|
| --- | --- |
|
||||||
|
| git status, git rev-parse HEAD, git diff --check | Чистое дерево, неизменный commit, diff errors отсутствуют |
|
||||||
|
| npm run lint | Успешно |
|
||||||
|
| npm run typecheck:app; npm run typecheck:test | Успешно |
|
||||||
|
| npm test -- --runInBand | 37 suites / 200 tests passed |
|
||||||
|
| Build в npm run test:e2e | Prisma generation и Nest build успешны |
|
||||||
|
| Полный e2e в sandbox | listen EPERM на localhost; не засчитан |
|
||||||
|
| LOG_LEVEL=error node test/sqlite.cjs --all, с разрешением localhost | all-accounts: 18 passed; auth-suite затем завершилась native SIGABRT |
|
||||||
|
| node test/sqlite.cjs tbank-screen-sync.e2e-spec.ts tbank-operations-persistence.e2e-spec.ts tbank-candles-persistence.e2e-spec.ts tbank-metadata-persistence.e2e-spec.ts tbank-local-reads.e2e-spec.ts openapi-contract.e2e-spec.ts | Первые три: 27 + 5 + 9 passed; metadata native abort; последующие не достигнуты |
|
||||||
|
| node test/sqlite.cjs openapi-contract.e2e-spec.ts, отдельно | 3 tests passed |
|
||||||
|
| Inline reproductions | Подтверждены описанные случаи; validation/logging через configureApp, остальные с указанными mocked boundaries |
|
||||||
|
|
||||||
|
Native abort произошёл в `node::RemoveEnvironmentCleanupHook` / `better_sqlite3.node`
|
||||||
|
на Node 24.21.0. Причина не установлена; связывать его с конкретной source-находкой
|
||||||
|
нельзя. Полный e2e gate не подтверждён. При реализации сначала повторить в штатном
|
||||||
|
окружении; если проблема остаётся, диагностировать отдельно и явно сообщить,
|
||||||
|
не отключая падающие suites и не выдавая неполную проверку за зелёную.
|
||||||
|
|
||||||
|
Логи текущей сессии (временные файлы, могут исчезнуть):
|
||||||
|
`/tmp/wealthledger-audit-e2e.log`, `/tmp/wealthledger-audit-targeted-e2e.log`,
|
||||||
|
`/tmp/wealthledger-audit-openapi.log`. Документ не зависит от сохранности этих файлов.
|
||||||
|
|
||||||
|
**Проверено:** все production-модули, composition/API, основные consumers,
|
||||||
|
значимые flows, связанные tests кандидатов. Все production auth/banking,
|
||||||
|
integrations/sync/operations и остальные investment slices прочитаны.
|
||||||
|
|
||||||
|
**Частично проверено:** весь набор HTTP fixtures и integration suites не проходил
|
||||||
|
одинаково глубокий построчный аудит; реальное выполнение e2e ограничено указанным
|
||||||
|
native abort. Schema прочитана; история миграций не проходила полный compatibility audit.
|
||||||
|
|
||||||
|
**Не проверено:** clean npm ci, полный verify с coverage как единая команда,
|
||||||
|
durability/restart/backup, container/deployment, production performance,
|
||||||
|
реальные внешние API и полная сверка vendor proto с upstream.
|
||||||
|
|
||||||
|
### Финальная проверка реализации
|
||||||
|
|
||||||
|
Использовать актуальные scripts/conventions backend. На базе аудита для code changes
|
||||||
|
требуются npm ci, npm run verify, relevant SQLite/HTTP checks; для этих HTTP изменений
|
||||||
|
также OpenAPI generation/parity. Полный npm run test:e2e нужен для итоговой проверки
|
||||||
|
совокупных изменений. Перед Node — nvm use --silent.
|
||||||
|
|
||||||
|
Автоисправления lint/format не являются доказательством. Не запускать миграции,
|
||||||
|
reset/drop или tests на пользовательской БД. Штатный SQLite runner создаёт временные
|
||||||
|
базы и защищает от использования внешнего DATABASE_URL.
|
||||||
|
|
||||||
|
По завершении повторить routing по staged/unstaged/new files, выполнить Boy Scout
|
||||||
|
и semantic review: callers, owners, money/access/atomicity/error contracts,
|
||||||
|
generation/cache/async lifecycle. Указать точные команды, outcomes и ограничения.
|
||||||
|
|
||||||
|
## Неподтверждённые гипотезы — вне scope исправлений
|
||||||
|
|
||||||
|
- Logout/refresh interleaving и browser cookie overwrite требуют явных ordering
|
||||||
|
semantics и отдельного HTTP/browser proof.
|
||||||
|
- N+1 в карточных views виден, но измеренная latency/timeout проблема не установлена.
|
||||||
|
- JSON casts внутренних сохранённых payload сами по себе не доказывают небезопасную boundary.
|
||||||
|
- Cash/accrued-interest valuation: локального контракта недостаточно для обвинения в ошибке.
|
||||||
|
- Native SQLite abort требует отдельной диагностики runtime/dependency lifecycle.
|
||||||
|
|
||||||
|
## Статус исправлений
|
||||||
|
|
||||||
|
Начало исправлений: HEAD `516e43203eb3fee8e6a0c7018be2fee11f1b900c`,
|
||||||
|
ветка `codex/investment-contracts`; `git status --short`: только
|
||||||
|
`?? BACKEND_AUDIT.md`, staged/unstaged tracked changes отсутствовали.
|
||||||
|
Исходные разделы аудита сохранены. `npm ci` успешно: 1065 packages, 44s;
|
||||||
|
manifest/lockfile не менялись. Перед Node-командами загружается `~/.nvm/nvm.sh`
|
||||||
|
и выполняется `nvm use --silent`.
|
||||||
|
|
||||||
|
Все A01–A10 и C01–C07 повторно подтверждены и реализованы в порядке пакетов.
|
||||||
|
Статусы ниже разделяют готовность исправления и полноту проверки: native abort
|
||||||
|
не засчитывается как успешный behavioral test. Неподтвердившихся и уже исправленных
|
||||||
|
на исходном HEAD пунктов не обнаружено.
|
||||||
|
|
||||||
|
| ID | Статус | Изменение / доказательство | Проверки и ограничения |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| A01 | Исправлено и проверено | Logout подавляет только JWT verification failure; refresh удаляет cookie только при UnauthorizedException. Configured HTTP red: logout 204 вместо 500, refresh удалил cookie при отказе БД. | `node test/sqlite.cjs app.e2e-spec.ts`: regression воспроизведена на временной SQLite; macOS green повтор и первый полный e2e столкнулись с native SIGABRT. Итоговый Linux e2e: auth 12/12 passed, включая revocation failure и сохранение cookie для retry. |
|
||||||
|
| A02 | Исправлено и проверено | Удалены raw stack и его неиспользуемый transport/allowlist; regression проверяет multiline synthetic secret в конечном Pino payload и correlation. | Targeted filter/logging: 3 suites / 4 tests passed, unit red → green; macOS configured HTTP runner завершился native SIGABRT; итоговый Linux auth 12/12 passed, включая настоящий filter → middleware → Pino payload. |
|
||||||
|
| A03 | Исправлено и проверено | MoexClient отклоняет отсутствующий/повреждённый block, неверную ширину row, malformed/nonprogressing cursor. Пустые блоки и 404 сохранены. | Unit red 12 failures → 21 passed; `node test/sqlite.cjs upcoming-events.e2e-spec.ts`: downstream red 2 failures → 18 passed, настоящий client + mock fetch сохраняют calendar/forecast payload/revisions/timestamps. Typecheck и scoped lint passed. |
|
||||||
|
| A04 | Исправлено и проверено | Screen status разрешает superseded child через operations.observe перед агрегацией state/progress; attempt/revision fencing сохранён. | `node test/sqlite.cjs tbank-screen-sync.e2e-spec.ts`: HTTP/SQLite red succeeded вместо running → 29 passed; successor success/failure, progress, чужой user и удалённая revision. |
|
||||||
|
| A05 | Исправлено и проверено | Каждый join повышает force/priority при более сильном требовании; идентичный join не меняет updatedAt. Load фиксирует force/now на проход, при новой forced потребности выполняет полную reconciliation. Финальная публикация и observers сериализованы с reservations; screen priority=0. | Service unit red → green (полный исторический диапазон и более поздний now); `node test/sqlite.cjs tbank-candles-persistence.e2e-spec.ts`: 14 passed на финальном коде, same/cross-user join до загрузки и после обычного окна, rollback второго forced окна, timestamps и fencing. Native abort в первом финальном запуске; единственный повтор без изменений прошёл. |
|
||||||
|
| A06 | Исправлено и проверено | ForecastService вычисляет возвращаемый positionIsStale из сохранённого positionAt через существующую portfolio TTL policy; не меняет финансовый snapshot. | `node test/sqlite.cjs upcoming-events.e2e-spec.ts`: red stock/bond false на TTL → 20 passed; TTL−1/TTL, observe без RPC, прежние суммы/revisions/timestamps, fallback при несовместимом свежем candidate. Review дополнительно воспроизвёл meta.freshness=fresh для старого fallback (2 red); freshForMs теперь учитывает сохранённый stale flag, repeat 20 passed, stateVersion рассчитывается штатно. |
|
||||||
|
| A07 | Исправлено и проверено | Одна operationTimestamp conversion у mapper для stage/publish; ledger/income wire используют сохранённый occurredAt. Missing seconds по-прежнему отклоняется. | Unit red .600→.000 → 9 passed; `node test/sqlite.cjs tbank-ledger-local.e2e-spec.ts`: HTTP red → 11 passed (from=.500, cursor .800/.600, income timestamp/exact total). Оба typecheck/lint passed. На macOS operations-persistence завершилась native SIGABRT и не засчитана. Итоговый Linux полный e2e: ledger 11/11 и operations-persistence 5/5 passed. |
|
||||||
|
| A08 | Исправлено и проверено | Global/screen ValidationPipe сохраняют structured errors без target/value; filter обходит children и экранирует JSON pointers. Удалён descriptorErrors; OpenAPI errors.items согласован с object shape. | Nested Money HTTP red errors:[] → bank22 / screen29 passed; escaped pointer и отсутствие дублей/secret проверены. Unit7 и scoped lint passed. Metadata red items:string → финальный OpenAPI parity 3 passed. На macOS auth suite native SIGABRT; итоговый Linux auth 12/12 passed, включая credential field errors без input leak. |
|
||||||
|
| A09 | Исправлено и проверено | Planner импортирует существующий чистый operations/domain/operations-sync-plan как явно документированный публичный вход; service barrel больше не экспортирует runtime interval rules. | Planner и CandleService tests выполняются без Prisma mock; targeted пакет 7 suites / 49 tests passed. DI подтверждается configured HTTP и итоговым build. |
|
||||||
|
| A10 | Исправлено и проверено | Удалены шесть legacy methods, context types, TbankClient constructor dependency и accessibleTbankAccount без живых callers; legacy-only spec удалён. | Поиск src/test не находит legacy symbols; 5 unit suites / 11 tests, оба typecheck, scoped lint/build; connection HTTP/SQLite4 passed: missing/invalid/unsupported/cross-user, replacement/deletion, revision/fencing. Token error/crypto assertions сохранены. MacOS metadata-persistence15 passed, operations-sync/catalog-discovery native SIGABRT. Итоговый Linux e2e: metadata15, operations-sync17 и catalog-discovery10 passed. |
|
||||||
|
| C01 | Исправлено и проверено | PortfolioService использует common decimalFromString; локальный parser удалён. Входы из money/formatDecimal уже canonical; текущая ошибка сумм не заявляется. | Exact-money portfolio tests в targeted49 passed; итоговые types/verify ниже. |
|
||||||
|
| C02 | Исправлено и проверено | TbankSyncAttempt/TbankLastPrice экспортируются type-only через tbank.api; семь production consumers используют API, TbankCoupon переиспользован. | Поиск прямых imports tbank-access.service/tbank-market.types в src/modules пуст; оба typecheck и scoped lint passed. |
|
||||||
|
| C03 | Исправлено и проверено | Удалён no-op normalizeEmail; CredentialsDto transform trim/lowercase сохранён. | Auth unit passed; реальные case/whitespace HTTP assertions сохранены. После macOS native SIGABRT полный Linux auth gate прошёл: 12/12. |
|
||||||
|
| C04 | Исправлено и проверено | bankSummary принимает только рабочий диапазон from/to; строковый month overload удалён, полезные unit cases переведены на диапазоны. | Targeted49 passed: month start/end, outside range, signed64/BigInt суммы и refunds. |
|
||||||
|
| C05 | Исправлено и проверено | Удалены optional без callers и stockPosition wrapper; spec перенесён к instrumentPosition, проверяет напрямую владельца. | Targeted49 passed: fractional/large quantity, weighted costs, mixed/unknown currencies, no position и offset long/short. |
|
||||||
|
| C06 | Исправлено и проверено | Удалён неиспользуемый внешний totalsByCurrency export; живой mapper и внутренний caller сохранены. | Поиск imports подтверждает единственный внутренний caller; exact-money targeted tests passed, итоговый typecheck ниже. |
|
||||||
|
| C07 | Исправлено и проверено | Удалён HTTP_CODE_METADATA wiring spec; реальные register/login/refresh status200 и logout/cookie assertions сохранены в app.e2e. | Проверены callers/оставшиеся HTTP assertions; полный Linux auth gate прошёл: 12/12. Предыдущий macOS native SIGABRT остаётся отдельным ограничением среды. |
|
||||||
|
|
||||||
|
### Gates первого прогона (macOS)
|
||||||
|
|
||||||
|
Все команды выполнены из backend на Node `v24.21.0` после `nvm use --silent`.
|
||||||
|
Проверки данных использовали штатный runner, fresh temporary SQLite и штатные
|
||||||
|
migrations; рабочая БД не использовалась. Для localhost/Prisma cache запуски
|
||||||
|
получили разрешение вне sandbox; native abort повторялся и там.
|
||||||
|
|
||||||
|
| Команда | Итог |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm ci` | PASS; manifest и lockfile без изменений. |
|
||||||
|
| `npm run verify` | PASS: lint, app/test typecheck, 36 suites / 209 unit tests с coverage, build. |
|
||||||
|
| `npm run openapi:generate` | PASS; актуальный игнорируемый `openapi.json` сформирован. |
|
||||||
|
| `npm run test:openapi-contract` | PASS: 1 suite / 3 tests, включая object shape validation errors. |
|
||||||
|
| `npx prisma validate` | PASS. Schema и migrations не менялись. |
|
||||||
|
| `npm run test:durability -- /tmp/wealthledger-fix-baseline-current-runtime-fjftdF` | PASS. Baseline `0862d56`, до последней T-Bank migration, отдельно установлен и собран на текущем Node; результат `/var/folders/j_/h2ft23gd3qq35ps3v5bcftfm0000gn/T/wealthledger-bank-durability-SIeCq0`. |
|
||||||
|
| `LOG_LEVEL=error npm run test:e2e` | INCOMPLETE, exit 1: all-accounts 18/18 passed, затем app.e2e native SIGABRT до Jest summary. Остальные suites общий gate не выполнил. |
|
||||||
|
| `node test/sqlite.cjs tbank-candles-persistence.e2e-spec.ts` | PASS на финальном коде: 14/14 после одного native abort и повтора без edits. |
|
||||||
|
| `node test/sqlite.cjs tbank-metadata-persistence.e2e-spec.ts` | PASS на финальном коде: 15/15. |
|
||||||
|
| `node test/sqlite.cjs tbank-operations-sync.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. |
|
||||||
|
| `node test/sqlite.cjs tbank-operations-persistence.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. |
|
||||||
|
| `node test/sqlite.cjs tbank-catalog-discovery.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. |
|
||||||
|
| `git diff --check` | PASS. |
|
||||||
|
|
||||||
|
Логи итоговых gates: `/tmp/wealthledger-fix-verify.log`,
|
||||||
|
`/tmp/wealthledger-fix-openapi-generate.log`, `/tmp/wealthledger-fix-openapi-contract.log`,
|
||||||
|
`/tmp/wealthledger-fix-prisma-validate.log`, `/tmp/wealthledger-fix-durability.log`,
|
||||||
|
`/tmp/wealthledger-fix-final-e2e.log`. Дополнительные SQLite логи:
|
||||||
|
`/tmp/wealthledger-fix-a05-latest-sqlite-retry.log`,
|
||||||
|
`/tmp/wealthledger-fix-final-metadata-persistence.log`,
|
||||||
|
`/tmp/wealthledger-final-operations-sync.log`,
|
||||||
|
`/tmp/wealthledger-final-operations-persistence.log`,
|
||||||
|
`/tmp/wealthledger-fix-final-catalog-discovery.log`.
|
||||||
|
|
||||||
|
Native failure: assertion `(env) != nullptr` в `node::RemoveEnvironmentCleanupHook`,
|
||||||
|
stack содержит destructor из `better_sqlite3.node`. Это конкретное ограничение
|
||||||
|
текущего native runtime, уже наблюдавшееся при исходном аудите; его первопричина
|
||||||
|
в рамках исправлений не установлена. Тесты не отключались, runner/dependencies
|
||||||
|
не изменялись. Эти macOS запуски остаются INCOMPLETE. Полный Linux прогон ниже
|
||||||
|
закрыл e2e gate и auth/operations/catalog acceptance; локальный native abort
|
||||||
|
на macOS при этом не исправлен.
|
||||||
|
|
||||||
|
### Повторная полная проверка (Linux, 2026-09-27)
|
||||||
|
|
||||||
|
**PASS: 21 e2e suites / 262 tests, exit 0**, ни одного skipped/disabled test.
|
||||||
|
Выполнена вся штатная `npm run test:e2e`: build и `node test/sqlite.cjs --all`.
|
||||||
|
Каждая suite получила отдельную temporary SQLite со всеми пятью migrations.
|
||||||
|
Закрыты прежде неполные auth12, operations-persistence5, operations-sync17,
|
||||||
|
catalog-discovery10; также прошли candles14, forecast/upcoming20, screen29,
|
||||||
|
OpenAPI3 и большой read-baseline с 300 000 операций / 1 000 000 свечей.
|
||||||
|
|
||||||
|
Перед повтором зафиксирован тот же HEAD и hashes всех tracked/untracked files;
|
||||||
|
до завершения тестов ни один исходник, тест, config или audit не менялся.
|
||||||
|
Поэтому предыдущие `verify` (209 unit tests), OpenAPI и durability сохраняют
|
||||||
|
актуальность. В этом повторе изменён только статус данного документа.
|
||||||
|
|
||||||
|
MacOS `LOG_LEVEL=error node test/sqlite.cjs app.e2e-spec.ts` снова завершился
|
||||||
|
native SIGABRT. Для Linux использован существующий Dockerfile target
|
||||||
|
`development`, Node `v24.21.0` (проверен против `.nvmrc`) и неизменённый lockfile:
|
||||||
|
SHA-256 `f986979b7922d684fead3a4d32532d11f1e469cec0e35669509343be41cbb2cd`.
|
||||||
|
Image `wealthledger-backend:audit-verification-20260927`,
|
||||||
|
ID `sha256:69264cf3dba23566d99fded450104bbe56f92755604e88232beb97825155ae73`.
|
||||||
|
Сборка выполнила `npm ci`; зависимости и runner не подменялись. Test/scripts
|
||||||
|
подключались read-only, внешняя сеть отключена, host ports не публиковались;
|
||||||
|
одноразовый контейнер удалён после завершения, рабочая БД не подключалась.
|
||||||
|
|
||||||
|
Команды воспроизведения из backend (на host предварительно `source ~/.nvm/nvm.sh`
|
||||||
|
и `nvm use --silent`; внутри image закреплён тот же Node):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --target development --tag wealthledger-backend:audit-verification-20260927 .
|
||||||
|
docker run --rm --network none \
|
||||||
|
--mount type=bind,source=/Users/ksv741/Project/vibe/WealthLedger/backend/test,target=/app/test,readonly \
|
||||||
|
--mount type=bind,source=/Users/ksv741/Project/vibe/WealthLedger/backend/scripts,target=/app/scripts,readonly \
|
||||||
|
-e LOG_LEVEL=error wealthledger-backend:audit-verification-20260927 npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Логи: `/tmp/wealthledger-recheck-auth.log` (macOS abort),
|
||||||
|
`/tmp/wealthledger-recheck-linux-build.log` (Linux image build),
|
||||||
|
`/tmp/wealthledger-recheck-linux-e2e.log` (полный PASS).
|
||||||
|
|
||||||
|
Read-only диагностика локализовала macOS crash в GC finalizer Statement/Database
|
||||||
|
вложенного `better-sqlite3 12.11.1`, используемого Prisma adapter 7.10.0.
|
||||||
|
Node 24.21 ObjectWrap destructor вызывает RemoveEnvironmentCleanupHook, который
|
||||||
|
падает на `env != nullptr`. Связь с Jest VM context и происхождением native
|
||||||
|
binary остаётся гипотезой, а не доказанной первопричиной. Ошибка application
|
||||||
|
lifecycle не обнаружена: close/disconnect ожидаются. Linux PASS доказывает
|
||||||
|
поведение backend в этом окружении, но не устранение native crash на macOS.
|
||||||
|
|
||||||
|
### Semantic / Boy Scout review и состояние дерева
|
||||||
|
|
||||||
|
Повторный routing по staged/unstaged/new files активировал Architecture,
|
||||||
|
HTTP/errors, Data, Testing, Security, Integrations и Observability. Проверены
|
||||||
|
owners/callers и связанная документация; независимый review охватил auth/logging,
|
||||||
|
candle coalescing/publication, forecast fallback и cleanup. После устранения
|
||||||
|
найденной при review stale metadata у старого forecast fallback оставшихся
|
||||||
|
semantic замечаний нет. Проверены точная денежная арифметика, ownership,
|
||||||
|
transactions, generation fencing, сохранение cache/pending promises и отсутствие
|
||||||
|
новых дублирующих helpers.
|
||||||
|
|
||||||
|
На момент завершения проверки, до запроса публикации, HEAD оставался
|
||||||
|
`516e43203eb3fee8e6a0c7018be2fee11f1b900c`, ветка
|
||||||
|
`codex/investment-contracts`; staged files отсутствуют. Все изменения локальные,
|
||||||
|
без commit/push/PR. Новые untracked files: исходный `BACKEND_AUDIT.md`,
|
||||||
|
`src/modules/investments/instruments/application/candle.service.spec.ts` и
|
||||||
|
`src/modules/investments/instruments/application/instrument-position.spec.ts`.
|
||||||
|
Остальные изменения — tracked source/tests и три связанных convention-файла.
|
||||||
|
Новые зависимости, scripts, migrations и разделение production-файлов не добавлены.
|
||||||
|
Исходный текст до раздела «Статус исправлений» побайтово совпадает со снимком
|
||||||
|
`/tmp/wealthledger-backend-audit-before-fixes.md`.
|
||||||
|
|
||||||
|
### Подготовка публикации
|
||||||
|
|
||||||
|
После успешной полной проверки пользователь разрешил commit, PR и merge.
|
||||||
|
Ветка публикации — `codex/backend-audit-fixes` от актуального `origin/develop`
|
||||||
|
`5d3835e`; его tree совпадает с исходным HEAD проверки, поэтому исходники и
|
||||||
|
результаты gates не изменились. Pre-commit review не обнаружил посторонних
|
||||||
|
файлов, секретов или незакрытых semantic findings. PR направляется в `develop`;
|
||||||
|
merge выполняется после обязательных `verify`, `security`, `commits`.
|
||||||
@@ -1,5 +1,44 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.2.3](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.2...v0.2.3) (2026-09-19)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* correct Nexus routing and SonarQube host ([7c0150c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7c0150c56b2dab44347b55344e04c821689543e7))
|
||||||
|
|
||||||
|
## [0.2.2](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.1...v0.2.2) (2026-09-19)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* update Nexus registry host ([4c91682](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4c916829eac23326a42c09873e32908cfc9f422c))
|
||||||
|
|
||||||
|
## [0.2.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.0...v0.2.1) (2026-09-18)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* use shared Nexus account [#25](https://git.personal.ksv741.ru/WealthLedger/backend/issues/25) ([9437b6a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9437b6a311cd51230b8381c8643c6ea308454d38))
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
* add backend SonarQube analysis [#23](https://git.personal.ksv741.ru/WealthLedger/backend/issues/23) ([1946192](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1946192be975eeea75432ec39b27d19952742b15))
|
||||||
|
* publish backend images to Nexus [#25](https://git.personal.ksv741.ru/WealthLedger/backend/issues/25) ([0ec7ae5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ec7ae51af080288a31dc6e65a738a4bd6396436))
|
||||||
|
|
||||||
|
## [0.2.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.1.0...v0.2.0) (2026-09-16)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add backend container delivery ([1a6be43](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1a6be432f24f757728a03415051392069112af1f))
|
||||||
|
* generate OpenAPI without peer repositories ([32c12fb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/32c12fb10d4f739c6af0ce32b8f2ea6e94c42090))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* allow reviewed container smoke fixture ([d7cb7e3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7cb7e3e206c995ef2fa6f1ee61ef7e93599d88f))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* refine backend conventions and routing ([9810d47](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9810d47e42d9275cf2520556f517d411058ddcb3))
|
||||||
|
* simplify conventions layout ([0167826](https://git.personal.ksv741.ru/WealthLedger/backend/commit/016782657acfd35ff3fd755462007938fac35156))
|
||||||
|
|
||||||
## 0.1.0 (2026-09-14)
|
## 0.1.0 (2026-09-14)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
FROM node:24.21.0-bookworm-slim AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS dependencies
|
||||||
|
# Native SQLite/bcrypt builds happen only here; compilers stay out of runtime.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends python3=3.11.2-1+b1 make=4.3-4.1 g++=4:12.2.0-3 ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npx --no-install prisma generate
|
||||||
|
|
||||||
|
FROM dependencies AS development
|
||||||
|
# Nest watch uses ps to stop the previous process tree before restarting.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN mkdir /data && chown node:node /data && chown -R node:node /app
|
||||||
|
USER node
|
||||||
|
ENV NODE_ENV=development DATABASE_URL=file:/data/wealthledger.db
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "run", "start:dev"]
|
||||||
|
|
||||||
|
FROM dependencies AS build
|
||||||
|
RUN npm run build && npm prune --omit=dev --omit=optional
|
||||||
|
|
||||||
|
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/* \
|
||||||
|
&& mkdir /data && chown node:node /data
|
||||||
|
ARG VERSION
|
||||||
|
ARG REVISION
|
||||||
|
LABEL org.opencontainers.image.version=$VERSION \
|
||||||
|
org.opencontainers.image.revision=$REVISION
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY --from=build /app/prisma ./prisma
|
||||||
|
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
|
||||||
|
COPY --from=build /app/package.json /app/package-lock.json ./
|
||||||
|
ENV NODE_ENV=production DATABASE_URL=file:/data/wealthledger.db
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "dist/src/main.js"]
|
||||||
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=6 \
|
||||||
|
CMD node -e 'fetch("http://127.0.0.1:3000/api/v1/auth/me").then(async r=>{await r.arrayBuffer();process.exit(r.status===401?0:1)}).catch(()=>process.exit(1))'
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# WealthLedger backend
|
# WealthLedger backend
|
||||||
|
|
||||||
NestJS API WealthLedger. Backend владеет auth, SQLite-данными и server-side
|
NestJS API WealthLedger. Backend владеет auth, SQLite-данными и server-side
|
||||||
интеграцией T-Bank; frontend не обращается к T-Bank напрямую.
|
интеграциями. Потребители используют публичный HTTP API и OpenAPI artifact;
|
||||||
|
их репозитории не нужны для разработки, сборки и проверок backend.
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
@@ -21,6 +22,36 @@ npm run start:dev
|
|||||||
T-Bank token в `.env`: он вводится пользователем и хранится в зашифрованном
|
T-Bank token в `.env`: он вводится пользователем и хранится в зашифрованном
|
||||||
виде.
|
виде.
|
||||||
|
|
||||||
|
## Контейнерный image
|
||||||
|
|
||||||
|
`Dockerfile` имеет targets `development` (Nest watch, generated Prisma client и Linux native modules)
|
||||||
|
и `production` (compiled Node process от UID 1000, Prisma CLI/migrations и T-Bank proto assets). Node соответствует `.nvmrc`.
|
||||||
|
Build context ограничен `.dockerignore`; `.env`, local DB и host node_modules
|
||||||
|
не попадают в image.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --target development -t wealthledger-backend:development .
|
||||||
|
docker build --target production -t wealthledger-backend:production .
|
||||||
|
nvm use --silent
|
||||||
|
npm run test:container -- wealthledger-backend:production
|
||||||
|
```
|
||||||
|
|
||||||
|
Gitea `image.yml` запускается на защищённом release tag `vX.Y.Z`, проверяет
|
||||||
|
package/lock version и canonical gates. Runner `ubuntu-latest` должен иметь
|
||||||
|
Docker/buildx. Образы хранятся в Nexus:
|
||||||
|
`nexus.personal.ksv741.ru/docker-hosted/wealthledger/backend`. Пользователь `wealthledger-ci`
|
||||||
|
задаётся Actions secrets `NEXUS_USERNAME`/`NEXUS_PASSWORD`; эта же учётная
|
||||||
|
запись используется сервером для загрузки образов. Docker endpoint и token realm должны использовать
|
||||||
|
HTTPS; в `docker-hosted` включается запрет redeploy. Прежние Gitea secrets
|
||||||
|
сохраняются на время перехода и не передаются в Nexus.
|
||||||
|
Публикуются semver tags, без `latest`; существующие release tags не перезаписываются. Production выбирает опубликованный version tag.
|
||||||
|
|
||||||
|
Compose topology принадлежит workspace, эксплуатационная последовательность —
|
||||||
|
[документация](https://git.personal.ksv741.ru/WealthLedger/docs),
|
||||||
|
страница `architecture/deployment.md`.
|
||||||
|
Проверка публикации требует настроенного registry/runner; локальный build
|
||||||
|
не доказывает package permissions.
|
||||||
|
|
||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
Единый completion gate не изменяет versioned-файлы:
|
Единый completion gate не изменяет versioned-файлы:
|
||||||
@@ -30,10 +61,10 @@ npm run verify
|
|||||||
```
|
```
|
||||||
|
|
||||||
Он запускает zero-warning lint,
|
Он запускает 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 на новых базах
|
||||||
и runtime OpenAPI с соседним frontend checkout. Для полной backend-проверки
|
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки
|
||||||
выполните обе команды.
|
выполните обе команды.
|
||||||
|
|
||||||
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
|
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
|
||||||
@@ -44,11 +75,9 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
|
|||||||
### CI и обновления зависимостей
|
### CI и обновления зависимостей
|
||||||
|
|
||||||
`.gitea/workflows/verify.yml` запускает `npm ci`, генерацию Prisma client,
|
`.gitea/workflows/verify.yml` запускает `npm ci`, генерацию Prisma client,
|
||||||
`npm run verify` и `npm run test:e2e` на push и PR. Frontend snapshot для
|
`npm run verify` и `npm run test:e2e` на push и PR. Используется только checkout
|
||||||
разработки берётся из `develop`; для `main`, PR в `main` и `release/*` — из
|
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
|
||||||
полного SHA в `.ci/refs.json`. Лог сохраняет оба SHA. Изменение
|
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
||||||
контракта согласуется с этой frontend revision. Успешный unit gate не заменяет
|
|
||||||
SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
|
||||||
|
|
||||||
`security.yml` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
|
`security.yml` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
|
||||||
значениями и выполняет `npm audit --include=dev --audit-level=low` при push,
|
значениями и выполняет `npm audit --include=dev --audit-level=low` при push,
|
||||||
@@ -76,27 +105,73 @@ Required checks настраиваются отдельно в Gitea: налич
|
|||||||
runtime-валидацию. В обычном коде нужны инициализация или сужение типа
|
runtime-валидацию. В обычном коде нужны инициализация или сужение типа
|
||||||
после проверки значения.
|
после проверки значения.
|
||||||
|
|
||||||
|
## SonarQube
|
||||||
|
|
||||||
|
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
||||||
|
использует `sonar-project.properties`. Анализируются исходники `src`; unit specs
|
||||||
|
и HTTP specs учитываются как тесты. Внешние T-Bank proto contracts исключены,
|
||||||
|
generated Prisma client находится за пределами `sonar.sources`.
|
||||||
|
|
||||||
|
`npm run verify` запускает unit tests с покрытием один раз. Jest штатно создаёт
|
||||||
|
`coverage/lcov.info`; scanner импортирует этот отчёт. Покрытие HTTP/e2e tests
|
||||||
|
в него не входит, сами HTTP suites остаются обязательным отдельным шагом CI.
|
||||||
|
|
||||||
|
Создайте в SonarQube проект с ключом `wealthledger-backend`, а в Gitea → backend →
|
||||||
|
Settings → Actions → Secrets добавьте `SONAR_TOKEN` с правом анализа этого проекта.
|
||||||
|
Токен не сохраняется в файлах. Community Edition 9.9.8 получает анализ только
|
||||||
|
при push в `develop`, после успешных `verify` и SQLite/e2e checks. CI ожидает
|
||||||
|
Quality Gate; отрицательный результат или ошибка scanner завершает job с ошибкой.
|
||||||
|
PR и другие ветки проходят штатные проверки без отправки анализа.
|
||||||
|
Переименование основной ветки SonarQube не требуется; `sonar.branch.name` не задаётся.
|
||||||
|
|
||||||
|
Runner должен поддерживать Docker actions и иметь доступ к GitHub, Docker Hub
|
||||||
|
и HTTPS-адресу SonarQube с действующим сертификатом для этого имени.
|
||||||
|
Официальный Docker action закреплён на commit версии 2.3.0, как в frontend.
|
||||||
|
`tsconfig.sonar.json` содержит совместимые со старым анализатором настройки
|
||||||
|
модулей и decorators с target ES2022, без новых compiler options приложения.
|
||||||
|
Штатная компиляция приложения остаётся на ES2023.
|
||||||
|
После обновления сервера пересмотрите scanner и необходимость отдельного tsconfig.
|
||||||
|
|
||||||
|
Локально после `nvm use --silent`, `npm ci` и генерации Prisma client выполните
|
||||||
|
`npm run verify`, затем `sonar-scanner` с совместимым SonarScanner CLI и
|
||||||
|
`SONAR_TOKEN` в окружении. Первый запуск CI после merge должен подтвердить
|
||||||
|
загрузку отчёта и покрытия: локальные проверки не доказывают доступность сервера.
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
- [Router conventions](.ai/CONVENTIONS.md) и [источники](.ai/SOURCES.md)
|
- [AGENTS: правила и источники](AGENTS.md)
|
||||||
- [Backend architecture](.ai/conventions/backend-architecture.md)
|
- [Backend architecture](.ai/architecture.md)
|
||||||
- [HTTP API](.ai/conventions/backend-http-api.md), [data](.ai/conventions/backend-data.md) и [testing](.ai/conventions/backend-testing.md)
|
- [HTTP API](.ai/conventions/http-api.md), [data](.ai/conventions/data.md) и [testing](.ai/conventions/testing.md)
|
||||||
- [Security/auth](.ai/conventions/security-auth.md) и [integrations](.ai/conventions/backend-integrations.md)
|
- [Security/auth](.ai/conventions/security-auth.md) и [integrations](.ai/conventions/integrations.md)
|
||||||
|
|
||||||
- [Карта проектных фактов](../docs/README.md)
|
- [Генерация OpenAPI](#генерация-openapi)
|
||||||
- [API contract и runtime OpenAPI](../docs/architecture/http-api.md#источник-контракта)
|
|
||||||
- [Обзор поставленной системы](../docs/overview.md)
|
|
||||||
- [Архитектурные решения](../docs/adr/README.md)
|
|
||||||
|
|
||||||
Из backend runtime OpenAPI можно read-only сверить с frontend snapshot:
|
Генерация без сервера сверяется с OpenAPI настроенного приложения:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test:openapi-contract
|
npm run test:openapi-contract
|
||||||
```
|
```
|
||||||
|
|
||||||
Разработка идёт напрямую по задаче пользователя. Каждый affected repo проходит
|
## Генерация OpenAPI
|
||||||
свой verify; при изменении wire contract дополнительно выполняется contract check.
|
|
||||||
Обязательного OpenSpec/SDD lifecycle нет.
|
```bash
|
||||||
|
nvm use --silent
|
||||||
|
npm run openapi:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Команда собирает backend и записывает `openapi.json` в корень репозитория.
|
||||||
|
Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания
|
||||||
|
провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты
|
||||||
|
не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger
|
||||||
|
metadata; передай полученный artifact потребителю отдельно.
|
||||||
|
|
||||||
|
`npm run test:openapi-contract` собирает backend и сравнивает результат генератора
|
||||||
|
с runtime schema настроенного приложения, используя временный каталог и SQLite.
|
||||||
|
`npm run test:e2e` также сначала собирает backend. Изменение публичного API
|
||||||
|
проверяй relevant HTTP tests: без versioned snapshot сравнение не отслеживает
|
||||||
|
breaking changes относительно предыдущего выпуска.
|
||||||
|
|
||||||
|
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу.
|
||||||
|
|
||||||
## Реальная SQLite и сохранность данных
|
## Реальная SQLite и сохранность данных
|
||||||
|
|
||||||
@@ -161,4 +236,38 @@ npm run test:durability -- "$baseline_dir"
|
|||||||
Durability оставляет временные DB, логи и `result.json` по пути из вывода для
|
Durability оставляет временные DB, логи и `result.json` по пути из вывода для
|
||||||
диагностики; он не откатывает schema пользовательской базы.
|
диагностики; он не откатывает schema пользовательской базы.
|
||||||
|
|
||||||
Версии и ветки: [процесс релиза](../README.md#релизы-и-ветки).
|
## Релизы
|
||||||
|
|
||||||
|
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
||||||
|
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
|
||||||
|
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
|
||||||
|
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
|
||||||
|
|
||||||
|
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
|
||||||
|
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
|
||||||
|
`develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в
|
||||||
|
`release/hotfix-<name>` и проходит тот же процесс.
|
||||||
|
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
|
||||||
|
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
|
||||||
|
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
|
||||||
|
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
|
||||||
|
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
|
||||||
|
открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных
|
||||||
|
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `main`
|
||||||
|
изменился, согласуй историю и повтори проверки кандидата.
|
||||||
|
4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
||||||
|
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
|
||||||
|
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
|
||||||
|
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
|
||||||
|
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
|
||||||
|
что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после
|
||||||
|
merge создай отсутствующий тег без нового bump; до merge исправь и проверь
|
||||||
|
существующий кандидат без повторного повышения версии.
|
||||||
|
|
||||||
|
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
|
||||||
|
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
|
||||||
|
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
|
||||||
|
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
|
||||||
|
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
|
||||||
|
Workflow сам не включает эти настройки. Отдельное review approval для одного
|
||||||
|
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
|
||||||
|
|||||||
Generated
+3
-131
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.1.0",
|
"version": "0.2.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.1.0",
|
"version": "0.2.3",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.4",
|
"@grpc/grpc-js": "1.14.4",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"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",
|
||||||
"reflect-metadata": "0.2.2",
|
"reflect-metadata": "0.2.2",
|
||||||
"rxjs": "7.8.2"
|
"rxjs": "7.8.2"
|
||||||
},
|
},
|
||||||
@@ -57,7 +58,6 @@
|
|||||||
"globals": "17.12.0",
|
"globals": "17.12.0",
|
||||||
"jest": "30.5.1",
|
"jest": "30.5.1",
|
||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"prisma": "7.10.0",
|
|
||||||
"release-it": "21.0.2",
|
"release-it": "21.0.2",
|
||||||
"source-map-support": "0.5.21",
|
"source-map-support": "0.5.21",
|
||||||
"supertest": "7.2.2",
|
"supertest": "7.2.2",
|
||||||
@@ -1280,14 +1280,12 @@
|
|||||||
"version": "0.4.3",
|
"version": "0.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
|
||||||
"integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==",
|
"integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@electric-sql/pglite-socket": {
|
"node_modules/@electric-sql/pglite-socket": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz",
|
||||||
"integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==",
|
"integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"pglite-server": "dist/scripts/server.js"
|
"pglite-server": "dist/scripts/server.js"
|
||||||
@@ -1300,7 +1298,6 @@
|
|||||||
"version": "0.3.3",
|
"version": "0.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz",
|
||||||
"integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==",
|
"integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@electric-sql/pglite": "0.4.3"
|
"@electric-sql/pglite": "0.4.3"
|
||||||
@@ -3872,7 +3869,6 @@
|
|||||||
"version": "7.10.0",
|
"version": "7.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.10.0.tgz",
|
||||||
"integrity": "sha512-Rcg828gIRE3HOQ3pOATFjV5d/P0U9OIobxhd/IMxlfWjA4vru0eGwb0AIwFw0rmcLMVShohZYWPixVxkBHsxUA==",
|
"integrity": "sha512-Rcg828gIRE3HOQ3pOATFjV5d/P0U9OIobxhd/IMxlfWjA4vru0eGwb0AIwFw0rmcLMVShohZYWPixVxkBHsxUA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"c12": "3.3.4",
|
"c12": "3.3.4",
|
||||||
@@ -3891,7 +3887,6 @@
|
|||||||
"version": "0.24.17",
|
"version": "0.24.17",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.17.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.17.tgz",
|
||||||
"integrity": "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==",
|
"integrity": "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electric-sql/pglite": "0.4.3",
|
"@electric-sql/pglite": "0.4.3",
|
||||||
@@ -3924,7 +3919,6 @@
|
|||||||
"version": "7.10.0",
|
"version": "7.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz",
|
||||||
"integrity": "sha512-KNumN6NHFwybvfdYzTee9pqwx5PvknpWAaHn6L5NsbrKdl+SQrsVZs9opKs6U6SAsvB26HDt3WybRjOhgoWOYQ==",
|
"integrity": "sha512-KNumN6NHFwybvfdYzTee9pqwx5PvknpWAaHn6L5NsbrKdl+SQrsVZs9opKs6U6SAsvB26HDt3WybRjOhgoWOYQ==",
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3938,14 +3932,12 @@
|
|||||||
"version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3",
|
"version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3.tgz",
|
||||||
"integrity": "sha512-8OJ6RuZTZ06eFUOtBwxVmv8XMmOW6HWN5F+uxUbZkGxR0Bfab1dfAdXaHPmR5mb59E+fmUo8IOzXlbLY1SClbw==",
|
"integrity": "sha512-8OJ6RuZTZ06eFUOtBwxVmv8XMmOW6HWN5F+uxUbZkGxR0Bfab1dfAdXaHPmR5mb59E+fmUo8IOzXlbLY1SClbw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
|
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
|
||||||
"version": "7.10.0",
|
"version": "7.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz",
|
||||||
"integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==",
|
"integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "7.10.0"
|
"@prisma/debug": "7.10.0"
|
||||||
@@ -3955,7 +3947,6 @@
|
|||||||
"version": "7.10.0",
|
"version": "7.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.10.0.tgz",
|
||||||
"integrity": "sha512-Zqyu8DY14t6W/xwmAxUYWCXtHrvQnSvT644EAZSsdM8NSmCS74vJJbBKdVsK3ucFpnUWkEpbO1a0CxJXrg130g==",
|
"integrity": "sha512-Zqyu8DY14t6W/xwmAxUYWCXtHrvQnSvT644EAZSsdM8NSmCS74vJJbBKdVsK3ucFpnUWkEpbO1a0CxJXrg130g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "7.10.0",
|
"@prisma/debug": "7.10.0",
|
||||||
@@ -3967,7 +3958,6 @@
|
|||||||
"version": "7.10.0",
|
"version": "7.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz",
|
||||||
"integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==",
|
"integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "7.10.0"
|
"@prisma/debug": "7.10.0"
|
||||||
@@ -3977,7 +3967,6 @@
|
|||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz",
|
||||||
"integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==",
|
"integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "7.2.0"
|
"@prisma/debug": "7.2.0"
|
||||||
@@ -3987,21 +3976,18 @@
|
|||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz",
|
||||||
"integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
|
"integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/query-plan-executor": {
|
"node_modules/@prisma/query-plan-executor": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz",
|
||||||
"integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==",
|
"integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/streams-local": {
|
"node_modules/@prisma/streams-local": {
|
||||||
"version": "0.1.11",
|
"version": "0.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz",
|
||||||
"integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==",
|
"integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
@@ -4018,7 +4004,6 @@
|
|||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@@ -4035,14 +4020,12 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/studio-core": {
|
"node_modules/@prisma/studio-core": {
|
||||||
"version": "0.33.0",
|
"version": "0.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz",
|
||||||
"integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==",
|
"integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-toggle": "1.1.10",
|
"@radix-ui/react-toggle": "1.1.10",
|
||||||
@@ -4128,14 +4111,12 @@
|
|||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-compose-refs": {
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -4151,7 +4132,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-slot": "1.2.3"
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
@@ -4175,7 +4155,6 @@
|
|||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
@@ -4194,7 +4173,6 @@
|
|||||||
"version": "1.1.10",
|
"version": "1.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
||||||
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
|
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/primitive": "1.1.3",
|
"@radix-ui/primitive": "1.1.3",
|
||||||
@@ -4220,7 +4198,6 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||||
@@ -4240,7 +4217,6 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||||
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
@@ -4259,7 +4235,6 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -4564,35 +4539,30 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
|
||||||
"integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==",
|
"integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-color": {
|
"node_modules/@types/d3-color": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
"integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==",
|
"integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-delaunay": {
|
"node_modules/@types/d3-delaunay": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
|
||||||
"integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==",
|
"integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-format": {
|
"node_modules/@types/d3-format": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
|
||||||
"integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==",
|
"integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-geo": {
|
"node_modules/@types/d3-geo": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/geojson": "*"
|
"@types/geojson": "*"
|
||||||
@@ -4602,7 +4572,6 @@
|
|||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
"integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
|
"integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-color": "*"
|
"@types/d3-color": "*"
|
||||||
@@ -4612,14 +4581,12 @@
|
|||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-scale": {
|
"node_modules/@types/d3-scale": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
"integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==",
|
"integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-time": "*"
|
"@types/d3-time": "*"
|
||||||
@@ -4629,7 +4596,6 @@
|
|||||||
"version": "3.1.7",
|
"version": "3.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-path": "*"
|
"@types/d3-path": "*"
|
||||||
@@ -4639,14 +4605,12 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
|
||||||
"integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==",
|
"integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-time-format": {
|
"node_modules/@types/d3-time-format": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz",
|
||||||
"integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==",
|
"integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/esrecurse": {
|
"node_modules/@types/esrecurse": {
|
||||||
@@ -4692,7 +4656,6 @@
|
|||||||
"version": "7946.0.16",
|
"version": "7946.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/http-errors": {
|
"node_modules/@types/http-errors": {
|
||||||
@@ -4761,7 +4724,6 @@
|
|||||||
"version": "4.17.25",
|
"version": "4.17.25",
|
||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
|
||||||
"integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
|
"integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/methods": {
|
"node_modules/@types/methods": {
|
||||||
@@ -4843,7 +4805,6 @@
|
|||||||
"version": "19.2.18",
|
"version": "19.2.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -5560,7 +5521,6 @@
|
|||||||
"version": "4.0.1-alpha.0",
|
"version": "4.0.1-alpha.0",
|
||||||
"resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz",
|
"resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz",
|
||||||
"integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==",
|
"integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@visx/vendor": "4.0.0-alpha.0"
|
"@visx/vendor": "4.0.0-alpha.0"
|
||||||
@@ -5570,7 +5530,6 @@
|
|||||||
"version": "4.0.1-alpha.0",
|
"version": "4.0.1-alpha.0",
|
||||||
"resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz",
|
"resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz",
|
||||||
"integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==",
|
"integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -5581,7 +5540,6 @@
|
|||||||
"version": "4.0.1-alpha.0",
|
"version": "4.0.1-alpha.0",
|
||||||
"resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz",
|
"resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz",
|
||||||
"integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==",
|
"integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -5600,7 +5558,6 @@
|
|||||||
"version": "4.0.1-alpha.0",
|
"version": "4.0.1-alpha.0",
|
||||||
"resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz",
|
"resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz",
|
||||||
"integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==",
|
"integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -5614,14 +5571,12 @@
|
|||||||
"version": "4.0.1-alpha.0",
|
"version": "4.0.1-alpha.0",
|
||||||
"resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz",
|
"resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz",
|
||||||
"integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==",
|
"integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@visx/responsive": {
|
"node_modules/@visx/responsive": {
|
||||||
"version": "4.0.1-alpha.0",
|
"version": "4.0.1-alpha.0",
|
||||||
"resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz",
|
"resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz",
|
||||||
"integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==",
|
"integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash": "^4.17.13",
|
"@types/lodash": "^4.17.13",
|
||||||
@@ -5636,7 +5591,6 @@
|
|||||||
"version": "4.0.1-alpha.0",
|
"version": "4.0.1-alpha.0",
|
||||||
"resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz",
|
"resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz",
|
||||||
"integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==",
|
"integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@visx/vendor": "4.0.0-alpha.0"
|
"@visx/vendor": "4.0.0-alpha.0"
|
||||||
@@ -5646,7 +5600,6 @@
|
|||||||
"version": "4.0.1-alpha.0",
|
"version": "4.0.1-alpha.0",
|
||||||
"resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz",
|
"resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz",
|
||||||
"integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==",
|
"integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash": "^4.17.13",
|
"@types/lodash": "^4.17.13",
|
||||||
@@ -5666,7 +5619,6 @@
|
|||||||
"version": "4.0.0-alpha.0",
|
"version": "4.0.0-alpha.0",
|
||||||
"resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz",
|
"resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz",
|
||||||
"integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==",
|
"integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT and ISC",
|
"license": "MIT and ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-array": "3.0.3",
|
"@types/d3-array": "3.0.3",
|
||||||
@@ -5698,7 +5650,6 @@
|
|||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz",
|
||||||
"integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==",
|
"integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"internmap": "1 - 2"
|
"internmap": "1 - 2"
|
||||||
@@ -6193,7 +6144,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6.0.0"
|
"node": ">= 6.0.0"
|
||||||
@@ -6373,7 +6323,6 @@
|
|||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz",
|
||||||
"integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==",
|
"integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
@@ -6595,7 +6544,6 @@
|
|||||||
"version": "3.3.4",
|
"version": "3.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
||||||
"integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==",
|
"integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
@@ -6624,7 +6572,6 @@
|
|||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readdirp": "^5.0.0"
|
"readdirp": "^5.0.0"
|
||||||
@@ -6640,7 +6587,6 @@
|
|||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
|
||||||
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
|
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20.19.0"
|
"node": ">= 20.19.0"
|
||||||
@@ -6871,7 +6817,6 @@
|
|||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cli-cursor": {
|
"node_modules/cli-cursor": {
|
||||||
@@ -7069,7 +7014,6 @@
|
|||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
@@ -7365,7 +7309,6 @@
|
|||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-key": "^3.1.0",
|
"path-key": "^3.1.0",
|
||||||
@@ -7408,14 +7351,12 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-array": {
|
"node_modules/d3-array": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"internmap": "1 - 2"
|
"internmap": "1 - 2"
|
||||||
@@ -7428,7 +7369,6 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -7438,7 +7378,6 @@
|
|||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz",
|
||||||
"integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==",
|
"integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"delaunator": "5"
|
"delaunator": "5"
|
||||||
@@ -7451,7 +7390,6 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -7461,7 +7399,6 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||||
"integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==",
|
"integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3-array": "2.5.0 - 3"
|
"d3-array": "2.5.0 - 3"
|
||||||
@@ -7474,7 +7411,6 @@
|
|||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3-color": "1 - 3"
|
"d3-color": "1 - 3"
|
||||||
@@ -7487,7 +7423,6 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -7497,7 +7432,6 @@
|
|||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3-array": "2.10.0 - 3",
|
"d3-array": "2.10.0 - 3",
|
||||||
@@ -7514,7 +7448,6 @@
|
|||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3-path": "^3.1.0"
|
"d3-path": "^3.1.0"
|
||||||
@@ -7527,7 +7460,6 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3-array": "2 - 3"
|
"d3-array": "2 - 3"
|
||||||
@@ -7540,7 +7472,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3-time": "1 - 3"
|
"d3-time": "1 - 3"
|
||||||
@@ -7636,7 +7567,6 @@
|
|||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
|
||||||
"integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
|
"integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
|
||||||
"devOptional": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "ko-fi",
|
"type": "ko-fi",
|
||||||
@@ -7699,7 +7629,6 @@
|
|||||||
"version": "6.1.7",
|
"version": "6.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/degenerator": {
|
"node_modules/degenerator": {
|
||||||
@@ -7724,7 +7653,6 @@
|
|||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
||||||
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"robust-predicates": "^3.0.2"
|
"robust-predicates": "^3.0.2"
|
||||||
@@ -7753,7 +7681,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
@@ -7918,7 +7845,6 @@
|
|||||||
"version": "3.20.0",
|
"version": "3.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
||||||
"integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==",
|
"integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
@@ -7936,7 +7862,6 @@
|
|||||||
"version": "0.11.1",
|
"version": "0.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
|
||||||
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==",
|
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "EPL-2.0"
|
"license": "EPL-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/emittery": {
|
"node_modules/emittery": {
|
||||||
@@ -7969,7 +7894,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||||
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
@@ -8049,7 +7973,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||||
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
@@ -8628,14 +8551,12 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
|
||||||
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
|
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-check": {
|
"node_modules/fast-check": {
|
||||||
"version": "3.23.2",
|
"version": "3.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||||
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
||||||
"devOptional": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -8658,7 +8579,6 @@
|
|||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
||||||
"devOptional": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -8675,14 +8595,12 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
||||||
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
|
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-diff": {
|
"node_modules/fast-diff": {
|
||||||
@@ -8710,7 +8628,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
|
||||||
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
|
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-decode-uri-component": "^1.0.1"
|
"fast-decode-uri-component": "^1.0.1"
|
||||||
@@ -8743,7 +8660,6 @@
|
|||||||
"version": "3.1.7",
|
"version": "3.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||||
"devOptional": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -8863,7 +8779,6 @@
|
|||||||
"version": "9.7.0",
|
"version": "9.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz",
|
||||||
"integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==",
|
"integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@@ -8914,7 +8829,6 @@
|
|||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "^7.0.6",
|
"cross-spawn": "^7.0.6",
|
||||||
@@ -9022,7 +8936,6 @@
|
|||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-property": "^1.0.2"
|
"is-property": "^1.0.2"
|
||||||
@@ -9098,7 +9011,6 @@
|
|||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
|
||||||
"integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==",
|
"integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/get-proto": {
|
"node_modules/get-proto": {
|
||||||
@@ -9146,7 +9058,6 @@
|
|||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz",
|
||||||
"integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==",
|
"integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"giget": "dist/cli.mjs"
|
"giget": "dist/cli.mjs"
|
||||||
@@ -9304,21 +9215,18 @@
|
|||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/grammex": {
|
"node_modules/grammex": {
|
||||||
"version": "3.1.13",
|
"version": "3.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz",
|
||||||
"integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==",
|
"integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/graphmatch": {
|
"node_modules/graphmatch": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz",
|
||||||
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
|
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/handlebars": {
|
"node_modules/handlebars": {
|
||||||
@@ -9630,7 +9538,6 @@
|
|||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -9788,7 +9695,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/is-ssh": {
|
"node_modules/is-ssh": {
|
||||||
@@ -9847,7 +9753,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/issue-parser": {
|
"node_modules/issue-parser": {
|
||||||
@@ -10528,7 +10433,6 @@
|
|||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
@@ -10754,7 +10658,6 @@
|
|||||||
"version": "4.18.1",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.camelcase": {
|
"node_modules/lodash.camelcase": {
|
||||||
@@ -10876,7 +10779,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||||
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"bun": ">=1.0.0",
|
"bun": ">=1.0.0",
|
||||||
@@ -11353,7 +11255,6 @@
|
|||||||
"version": "3.24.4",
|
"version": "3.24.4",
|
||||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
|
||||||
"integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
|
"integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"aws-ssl-profiles": "^1.1.2",
|
"aws-ssl-profiles": "^1.1.2",
|
||||||
@@ -11375,7 +11276,6 @@
|
|||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||||
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lru.min": "^1.1.0"
|
"lru.min": "^1.1.0"
|
||||||
@@ -11609,7 +11509,6 @@
|
|||||||
"version": "2.0.12",
|
"version": "2.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz",
|
||||||
"integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==",
|
"integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/on-exit-leak-free": {
|
"node_modules/on-exit-leak-free": {
|
||||||
@@ -12046,7 +11945,6 @@
|
|||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -12083,7 +11981,6 @@
|
|||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pause": {
|
"node_modules/pause": {
|
||||||
@@ -12095,7 +11992,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
@@ -12250,7 +12146,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz",
|
||||||
"integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==",
|
"integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"confbox": "^0.3.1",
|
"confbox": "^0.3.1",
|
||||||
@@ -12262,7 +12157,6 @@
|
|||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz",
|
||||||
"integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==",
|
"integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pluralize": {
|
"node_modules/pluralize": {
|
||||||
@@ -12279,7 +12173,6 @@
|
|||||||
"version": "3.4.7",
|
"version": "3.4.7",
|
||||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
|
||||||
"integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==",
|
"integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -12401,7 +12294,6 @@
|
|||||||
"version": "7.10.0",
|
"version": "7.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.0.tgz",
|
||||||
"integrity": "sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw==",
|
"integrity": "sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw==",
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -12451,7 +12343,6 @@
|
|||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.4",
|
"graceful-fs": "^4.2.4",
|
||||||
@@ -12463,7 +12354,6 @@
|
|||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/protobufjs": {
|
"node_modules/protobufjs": {
|
||||||
@@ -12709,7 +12599,6 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz",
|
||||||
"integrity": "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==",
|
"integrity": "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"defu": "^6.1.7",
|
"defu": "^6.1.7",
|
||||||
@@ -12720,7 +12609,6 @@
|
|||||||
"version": "19.2.8",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -12731,7 +12619,6 @@
|
|||||||
"version": "19.2.8",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -12861,7 +12748,6 @@
|
|||||||
"version": "2.33.4",
|
"version": "2.33.4",
|
||||||
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
||||||
"integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==",
|
"integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/remeda"
|
"url": "https://github.com/sponsors/remeda"
|
||||||
@@ -12880,7 +12766,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -12956,7 +12841,6 @@
|
|||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
|
||||||
"integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
|
"integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -12966,7 +12850,6 @@
|
|||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
@@ -12976,7 +12859,6 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||||
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
|
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Unlicense"
|
"license": "Unlicense"
|
||||||
},
|
},
|
||||||
"node_modules/router": {
|
"node_modules/router": {
|
||||||
@@ -13041,7 +12923,6 @@
|
|||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
|
||||||
"integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==",
|
"integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==",
|
||||||
"devOptional": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -13079,7 +12960,6 @@
|
|||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
@@ -13150,7 +13030,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shebang-regex": "^3.0.0"
|
"shebang-regex": "^3.0.0"
|
||||||
@@ -13163,7 +13042,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -13245,7 +13123,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
@@ -13423,7 +13300,6 @@
|
|||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
|
||||||
"integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==",
|
"integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"bun": ">=1.0.0",
|
"bun": ">=1.0.0",
|
||||||
@@ -13471,7 +13347,6 @@
|
|||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/stdin-discarder": {
|
"node_modules/stdin-discarder": {
|
||||||
@@ -14484,7 +14359,6 @@
|
|||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
|
||||||
"integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==",
|
"integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=5"
|
"typescript": ">=5"
|
||||||
@@ -14737,7 +14611,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"isexe": "^2.0.0"
|
"isexe": "^2.0.0"
|
||||||
@@ -15026,7 +14899,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
|
||||||
"integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==",
|
"integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"grammex": "^3.1.11",
|
"grammex": "^3.1.11",
|
||||||
|
|||||||
+7
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.1.0",
|
"version": "0.2.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "prisma generate && nest build",
|
"build": "prisma generate && nest build",
|
||||||
|
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
||||||
@@ -24,11 +25,12 @@
|
|||||||
"test:watch": "npm test -- --watch",
|
"test:watch": "npm test -- --watch",
|
||||||
"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": "node test/sqlite.cjs --all",
|
"test:e2e": "npm run build && node test/sqlite.cjs --all",
|
||||||
"test:sqlite": "node test/sqlite.cjs",
|
"test:sqlite": "node test/sqlite.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": "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 test -- --runInBand && npm run build",
|
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build",
|
||||||
"release:preview": "release-it --dry-run --ci",
|
"release:preview": "release-it --dry-run --ci",
|
||||||
"release:prepare": "release-it --ci",
|
"release:prepare": "release-it --ci",
|
||||||
"commitlint": "commitlint"
|
"commitlint": "commitlint"
|
||||||
@@ -56,6 +58,7 @@
|
|||||||
"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",
|
||||||
"reflect-metadata": "0.2.2",
|
"reflect-metadata": "0.2.2",
|
||||||
"rxjs": "7.8.2"
|
"rxjs": "7.8.2"
|
||||||
},
|
},
|
||||||
@@ -82,7 +85,6 @@
|
|||||||
"globals": "17.12.0",
|
"globals": "17.12.0",
|
||||||
"jest": "30.5.1",
|
"jest": "30.5.1",
|
||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"prisma": "7.10.0",
|
|
||||||
"release-it": "21.0.2",
|
"release-it": "21.0.2",
|
||||||
"source-map-support": "0.5.21",
|
"source-map-support": "0.5.21",
|
||||||
"supertest": "7.2.2",
|
"supertest": "7.2.2",
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
BEGIN IMMEDIATE;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankAccount" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"openedOn" TEXT,
|
||||||
|
"upstreamOrder" INTEGER NOT NULL,
|
||||||
|
"operationsRevision" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"historyFrom" DATETIME,
|
||||||
|
"historyBoundary" TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "revision", "accountId"),
|
||||||
|
CONSTRAINT "TbankAccount_userId_revision_fkey" FOREIGN KEY ("userId", "revision") REFERENCES "TbankConnection" ("userId", "revision") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankPortfolio" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"mappingVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "revision", "accountId"),
|
||||||
|
CONSTRAINT "TbankPortfolio_userId_revision_accountId_fkey" FOREIGN KEY ("userId", "revision", "accountId") REFERENCES "TbankAccount" ("userId", "revision", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankOperation" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"brokerOperationId" TEXT NOT NULL,
|
||||||
|
"occurredAt" DATETIME NOT NULL,
|
||||||
|
"instrumentUid" TEXT,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"mappingVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "revision", "accountId", "brokerOperationId"),
|
||||||
|
CONSTRAINT "TbankOperation_userId_revision_accountId_fkey" FOREIGN KEY ("userId", "revision", "accountId") REFERENCES "TbankAccount" ("userId", "revision", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankOperationCoverage" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"instrumentPredicate" TEXT NOT NULL DEFAULT '',
|
||||||
|
"from" DATETIME NOT NULL,
|
||||||
|
"to" DATETIME NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"asOf" DATETIME NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "revision", "accountId", "instrumentPredicate", "from", "to"),
|
||||||
|
CONSTRAINT "TbankOperationCoverage_userId_revision_accountId_fkey" FOREIGN KEY ("userId", "revision", "accountId") REFERENCES "TbankAccount" ("userId", "revision", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankForecast" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"scopeKey" TEXT NOT NULL,
|
||||||
|
"membershipRevision" INTEGER NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"dependencyRevisions" JSONB NOT NULL,
|
||||||
|
"sourceTimestamps" JSONB NOT NULL,
|
||||||
|
"calculatedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "revision", "scopeKey"),
|
||||||
|
CONSTRAINT "TbankForecast_userId_revision_fkey" FOREIGN KEY ("userId", "revision") REFERENCES "TbankConnection" ("userId", "revision") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankInstrument" (
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"uid" TEXT NOT NULL,
|
||||||
|
"figi" TEXT,
|
||||||
|
"ticker" TEXT,
|
||||||
|
"instrumentType" TEXT NOT NULL,
|
||||||
|
"metadata" JSONB NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("provider", "uid")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankCatalogDiscovery" (
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"instrumentType" TEXT NOT NULL,
|
||||||
|
"query" TEXT NOT NULL,
|
||||||
|
"instrumentUids" JSONB NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("provider", "instrumentType", "query")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankInstrumentLookup" (
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"identifierType" TEXT NOT NULL,
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"matchedUids" JSONB NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("provider", "identifierType", "identifier")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankInstrumentSource" (
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"instrumentUid" TEXT NOT NULL,
|
||||||
|
"source" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"rangeKey" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("provider", "instrumentUid", "source", "kind", "rangeKey"),
|
||||||
|
CONSTRAINT "TbankInstrumentSource_provider_instrumentUid_fkey" FOREIGN KEY ("provider", "instrumentUid") REFERENCES "TbankInstrument" ("provider", "uid") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankCandle" (
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"instrumentUid" TEXT NOT NULL,
|
||||||
|
"interval" TEXT NOT NULL,
|
||||||
|
"time" DATETIME NOT NULL,
|
||||||
|
"close" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"isComplete" BOOLEAN NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("provider", "instrumentUid", "interval", "time"),
|
||||||
|
CONSTRAINT "TbankCandle_provider_instrumentUid_fkey" FOREIGN KEY ("provider", "instrumentUid") REFERENCES "TbankInstrument" ("provider", "uid") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankCandleCoverage" (
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"instrumentUid" TEXT NOT NULL,
|
||||||
|
"interval" TEXT NOT NULL,
|
||||||
|
"from" DATETIME NOT NULL,
|
||||||
|
"to" DATETIME NOT NULL,
|
||||||
|
"isComplete" BOOLEAN NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("provider", "instrumentUid", "interval", "from", "to"),
|
||||||
|
CONSTRAINT "TbankCandleCoverage_provider_instrumentUid_fkey" FOREIGN KEY ("provider", "instrumentUid") REFERENCES "TbankInstrument" ("provider", "uid") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankSyncWork" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"scopeKey" TEXT NOT NULL,
|
||||||
|
"intent" JSONB NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"attemptId" TEXT NOT NULL,
|
||||||
|
"processRunId" TEXT NOT NULL,
|
||||||
|
"priority" INTEGER NOT NULL,
|
||||||
|
"progress" JSONB NOT NULL,
|
||||||
|
"errorCode" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "TbankSyncWork_userId_revision_fkey" FOREIGN KEY ("userId", "revision") REFERENCES "TbankConnection" ("userId", "revision") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankSyncStage" (
|
||||||
|
"workId" TEXT NOT NULL,
|
||||||
|
"attemptId" TEXT NOT NULL,
|
||||||
|
"sourceKey" TEXT NOT NULL,
|
||||||
|
"itemKey" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("workId", "attemptId", "sourceKey", "itemKey"),
|
||||||
|
CONSTRAINT "TbankSyncStage_workId_attemptId_fkey" FOREIGN KEY ("workId", "attemptId") REFERENCES "TbankSyncWork" ("id", "attemptId") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
CREATE TABLE "new_TbankConnection" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"encryptedToken" TEXT NOT NULL,
|
||||||
|
"connectedAt" DATETIME NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"invalidToken" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"membershipAt" DATETIME,
|
||||||
|
"membershipRevision" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT "TbankConnection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
-- Preserve credentials and assign an opaque revision to each existing connection.
|
||||||
|
INSERT INTO "new_TbankConnection" ("connectedAt", "encryptedToken", "userId", "revision") SELECT "connectedAt", "encryptedToken", "userId", lower(hex(randomblob(16))) FROM "TbankConnection";
|
||||||
|
DROP TABLE "TbankConnection";
|
||||||
|
ALTER TABLE "new_TbankConnection" RENAME TO "TbankConnection";
|
||||||
|
CREATE UNIQUE INDEX "TbankConnection_userId_key" ON "TbankConnection"("userId");
|
||||||
|
CREATE UNIQUE INDEX "TbankConnection_userId_revision_key" ON "TbankConnection"("userId", "revision");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TbankOperation_userId_revision_accountId_occurredAt_brokerOperationId_idx" ON "TbankOperation"("userId", "revision", "accountId", "occurredAt", "brokerOperationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TbankOperation_userId_revision_accountId_instrumentUid_occurredAt_idx" ON "TbankOperation"("userId", "revision", "accountId", "instrumentUid", "occurredAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TbankOperation_userId_revision_accountId_category_occurredAt_idx" ON "TbankOperation"("userId", "revision", "accountId", "category", "occurredAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TbankInstrument_provider_figi_idx" ON "TbankInstrument"("provider", "figi");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TbankInstrument_provider_ticker_instrumentType_idx" ON "TbankInstrument"("provider", "ticker", "instrumentType");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TbankSyncWork_userId_revision_state_priority_idx" ON "TbankSyncWork"("userId", "revision", "state", "priority");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TbankSyncWork_userId_revision_scopeKey_key" ON "TbankSyncWork"("userId", "revision", "scopeKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TbankSyncWork_id_attemptId_key" ON "TbankSyncWork"("id", "attemptId");
|
||||||
|
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -25,7 +25,212 @@ model TbankConnection {
|
|||||||
userId String @unique
|
userId String @unique
|
||||||
encryptedToken String
|
encryptedToken String
|
||||||
connectedAt DateTime
|
connectedAt DateTime
|
||||||
|
revision String @default(uuid())
|
||||||
|
invalidToken Boolean @default(false)
|
||||||
|
membershipAt DateTime?
|
||||||
|
membershipRevision Int @default(0)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
accounts TbankAccount[]
|
||||||
|
forecasts TbankForecast[]
|
||||||
|
work TbankSyncWork[]
|
||||||
|
|
||||||
|
@@unique([userId, revision])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankAccount {
|
||||||
|
userId String
|
||||||
|
revision String
|
||||||
|
accountId String
|
||||||
|
type String
|
||||||
|
name String
|
||||||
|
openedOn String?
|
||||||
|
upstreamOrder Int
|
||||||
|
operationsRevision Int @default(0)
|
||||||
|
historyFrom DateTime?
|
||||||
|
historyBoundary String @default("unknown")
|
||||||
|
connection TbankConnection @relation(fields: [userId, revision], references: [userId, revision], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
portfolio TbankPortfolio?
|
||||||
|
operations TbankOperation[]
|
||||||
|
coverage TbankOperationCoverage[]
|
||||||
|
|
||||||
|
@@id([userId, revision, accountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankPortfolio {
|
||||||
|
userId String
|
||||||
|
revision String
|
||||||
|
accountId String
|
||||||
|
payload Json
|
||||||
|
mappingVersion Int @default(1)
|
||||||
|
fetchedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
account TbankAccount @relation(fields: [userId, revision, accountId], references: [userId, revision, accountId], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
|
||||||
|
@@id([userId, revision, accountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankOperation {
|
||||||
|
userId String
|
||||||
|
revision String
|
||||||
|
accountId String
|
||||||
|
brokerOperationId String
|
||||||
|
occurredAt DateTime
|
||||||
|
instrumentUid String?
|
||||||
|
category String
|
||||||
|
payload Json
|
||||||
|
mappingVersion Int @default(1)
|
||||||
|
account TbankAccount @relation(fields: [userId, revision, accountId], references: [userId, revision, accountId], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
|
||||||
|
@@id([userId, revision, accountId, brokerOperationId])
|
||||||
|
@@index([userId, revision, accountId, occurredAt, brokerOperationId])
|
||||||
|
@@index([userId, revision, accountId, instrumentUid, occurredAt])
|
||||||
|
@@index([userId, revision, accountId, category, occurredAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankOperationCoverage {
|
||||||
|
userId String
|
||||||
|
revision String
|
||||||
|
accountId String
|
||||||
|
instrumentPredicate String @default("")
|
||||||
|
from DateTime
|
||||||
|
to DateTime
|
||||||
|
fetchedAt DateTime
|
||||||
|
asOf DateTime
|
||||||
|
account TbankAccount @relation(fields: [userId, revision, accountId], references: [userId, revision, accountId], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
|
||||||
|
@@id([userId, revision, accountId, instrumentPredicate, from, to])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankForecast {
|
||||||
|
userId String
|
||||||
|
revision String
|
||||||
|
scopeKey String
|
||||||
|
membershipRevision Int
|
||||||
|
payload Json
|
||||||
|
dependencyRevisions Json
|
||||||
|
sourceTimestamps Json
|
||||||
|
calculatedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
connection TbankConnection @relation(fields: [userId, revision], references: [userId, revision], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
|
||||||
|
@@id([userId, revision, scopeKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankInstrument {
|
||||||
|
provider String
|
||||||
|
uid String
|
||||||
|
figi String?
|
||||||
|
ticker String?
|
||||||
|
instrumentType String
|
||||||
|
metadata Json
|
||||||
|
fetchedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
sources TbankInstrumentSource[]
|
||||||
|
candles TbankCandle[]
|
||||||
|
candleCoverage TbankCandleCoverage[]
|
||||||
|
|
||||||
|
@@id([provider, uid])
|
||||||
|
@@index([provider, figi])
|
||||||
|
@@index([provider, ticker, instrumentType])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankCatalogDiscovery {
|
||||||
|
provider String
|
||||||
|
instrumentType String
|
||||||
|
query String
|
||||||
|
instrumentUids Json
|
||||||
|
fetchedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
|
||||||
|
@@id([provider, instrumentType, query])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty and ambiguous successful UID/FIGI/ticker lookups are distinct from failures.
|
||||||
|
model TbankInstrumentLookup {
|
||||||
|
provider String
|
||||||
|
identifierType String
|
||||||
|
identifier String
|
||||||
|
matchedUids Json
|
||||||
|
fetchedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
|
||||||
|
@@id([provider, identifierType, identifier])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source records contain no holdings, personal estimates or credentials errors.
|
||||||
|
model TbankInstrumentSource {
|
||||||
|
provider String
|
||||||
|
instrumentUid String
|
||||||
|
source String
|
||||||
|
kind String
|
||||||
|
rangeKey String
|
||||||
|
payload Json
|
||||||
|
fetchedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
instrument TbankInstrument @relation(fields: [provider, instrumentUid], references: [provider, uid], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([provider, instrumentUid, source, kind, rangeKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankCandle {
|
||||||
|
provider String
|
||||||
|
instrumentUid String
|
||||||
|
interval String
|
||||||
|
time DateTime
|
||||||
|
close String
|
||||||
|
payload Json
|
||||||
|
isComplete Boolean
|
||||||
|
fetchedAt DateTime
|
||||||
|
instrument TbankInstrument @relation(fields: [provider, instrumentUid], references: [provider, uid], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([provider, instrumentUid, interval, time])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankCandleCoverage {
|
||||||
|
provider String
|
||||||
|
instrumentUid String
|
||||||
|
interval String
|
||||||
|
from DateTime
|
||||||
|
to DateTime
|
||||||
|
isComplete Boolean
|
||||||
|
fetchedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
instrument TbankInstrument @relation(fields: [provider, instrumentUid], references: [provider, uid], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([provider, instrumentUid, interval, from, to])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankSyncWork {
|
||||||
|
id String @id
|
||||||
|
userId String
|
||||||
|
revision String
|
||||||
|
scopeKey String
|
||||||
|
intent Json
|
||||||
|
state String
|
||||||
|
attemptId String
|
||||||
|
processRunId String
|
||||||
|
priority Int
|
||||||
|
progress Json
|
||||||
|
errorCode String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
connection TbankConnection @relation(fields: [userId, revision], references: [userId, revision], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
staged TbankSyncStage[]
|
||||||
|
|
||||||
|
@@unique([userId, revision, scopeKey])
|
||||||
|
@@index([userId, revision, state, priority])
|
||||||
|
@@unique([id, attemptId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankSyncStage {
|
||||||
|
workId String
|
||||||
|
attemptId String
|
||||||
|
sourceKey String
|
||||||
|
itemKey String
|
||||||
|
payload Json
|
||||||
|
work TbankSyncWork @relation(fields: [workId, attemptId], references: [id, attemptId], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
|
||||||
|
@@id([workId, attemptId, sourceKey, itemKey])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const { randomBytes } = require('node:crypto');
|
||||||
|
const { writeFile } = require('node:fs/promises');
|
||||||
|
|
||||||
|
// Schema generation needs no deployment configuration or real credentials.
|
||||||
|
Object.assign(process.env, {
|
||||||
|
DATABASE_URL: 'file::memory:',
|
||||||
|
FRONTEND_ORIGIN: 'http://localhost',
|
||||||
|
JWT_SECRET: randomBytes(32).toString('hex'),
|
||||||
|
TOKEN_ENCRYPTION_KEY: randomBytes(32).toString('base64'),
|
||||||
|
PORT: '3000',
|
||||||
|
LOG_LEVEL: 'error',
|
||||||
|
TBANK_GRPC_ENDPOINT: 'localhost:443',
|
||||||
|
TBANK_RATE_LIMIT_PER_SECOND: '5',
|
||||||
|
TBANK_CA_CERT_PATH: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { NestFactory } = require('@nestjs/core');
|
||||||
|
const { AppModule } = require('../dist/src/app.module.js');
|
||||||
|
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
preview: true,
|
||||||
|
logger: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
app.setGlobalPrefix(API_PREFIX);
|
||||||
|
await writeFile(
|
||||||
|
'openapi.json',
|
||||||
|
JSON.stringify(createOpenApiDocument(app), null, 2) + '\n',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generate().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
sonar.projectKey=wealthledger-backend
|
||||||
|
sonar.projectName=WealthLedger backend
|
||||||
|
sonar.host.url=https://sonarqube.personal.ksv741.ru
|
||||||
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
|
sonar.sources=src
|
||||||
|
sonar.tests=src,test
|
||||||
|
sonar.test.inclusions=src/**/*.spec.ts,test/**/*.e2e-spec.ts
|
||||||
|
sonar.exclusions=src/**/*.spec.ts,src/integrations/tbank/proto/contracts/**
|
||||||
|
sonar.javascript.lcov.reportPaths=coverage/lcov.info
|
||||||
|
# SonarQube 9.9 embeds an older TypeScript analyzer than the application compiler.
|
||||||
|
sonar.typescript.tsconfigPaths=tsconfig.sonar.json
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
@@ -64,6 +65,7 @@ import { PrismaModule } from './prisma/prisma.module';
|
|||||||
UpcomingEventsModule,
|
UpcomingEventsModule,
|
||||||
InstrumentsModule,
|
InstrumentsModule,
|
||||||
TbankConnectionModule,
|
TbankConnectionModule,
|
||||||
|
TbankSyncModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export class ProblemDetailsDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
required: false,
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['pointer', 'detail'],
|
||||||
|
properties: { pointer: { type: 'string' }, detail: { type: 'string' } },
|
||||||
|
},
|
||||||
example: [{ pointer: '#/email', detail: 'must be a valid email address' }],
|
example: [{ pointer: '#/email', detail: 'must be a valid email address' }],
|
||||||
})
|
})
|
||||||
errors?: { pointer: string; detail: string }[];
|
errors?: { pointer: string; detail: string }[];
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ describe('HttpExceptionFilter', () => {
|
|||||||
}),
|
}),
|
||||||
} as unknown as ArgumentsHost;
|
} as unknown as ArgumentsHost;
|
||||||
|
|
||||||
filter.catch(new Error('database password: secret'), host);
|
filter.catch(
|
||||||
|
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
|
||||||
|
host,
|
||||||
|
);
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(500);
|
expect(status).toHaveBeenCalledWith(500);
|
||||||
expect(type).toHaveBeenCalledWith('application/problem+json');
|
expect(type).toHaveBeenCalledWith('application/problem+json');
|
||||||
@@ -23,7 +26,6 @@ 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(typeof locals.errorStack).toBe('string');
|
expect(locals.errorStack).toBeUndefined();
|
||||||
expect(locals.errorStack).not.toContain('database password: secret');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,14 +27,6 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
? 'VALIDATION_ERROR'
|
? 'VALIDATION_ERROR'
|
||||||
: this.codeFor(status, payload);
|
: this.codeFor(status, payload);
|
||||||
(response.locals ??= {}).publicErrorCode = code;
|
(response.locals ??= {}).publicErrorCode = code;
|
||||||
if (
|
|
||||||
status >= Number(HttpStatus.INTERNAL_SERVER_ERROR) &&
|
|
||||||
exception instanceof Error
|
|
||||||
)
|
|
||||||
response.locals.errorStack = exception.stack
|
|
||||||
?.split('\n')
|
|
||||||
.slice(1)
|
|
||||||
.join('\n');
|
|
||||||
response
|
response
|
||||||
.status(status)
|
.status(status)
|
||||||
.type('application/problem+json')
|
.type('application/problem+json')
|
||||||
@@ -69,12 +61,18 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
|
|
||||||
private validationErrors(
|
private validationErrors(
|
||||||
errors: ValidationError[],
|
errors: ValidationError[],
|
||||||
|
prefix = '#',
|
||||||
): { pointer: string; detail: string }[] {
|
): { pointer: string; detail: string }[] {
|
||||||
return errors.flatMap((error) =>
|
return errors.flatMap((error) => {
|
||||||
Object.values(error.constraints ?? {}).map((detail) => ({
|
if (!error || typeof error.property !== 'string') return [];
|
||||||
pointer: `#/${error.property}`,
|
const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`;
|
||||||
|
return [
|
||||||
|
...Object.values(error.constraints ?? {}).map((detail) => ({
|
||||||
|
pointer,
|
||||||
detail,
|
detail,
|
||||||
})),
|
})),
|
||||||
);
|
...this.validationErrors(error.children ?? [], pointer),
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ export class RequestLoggingMiddleware implements NestMiddleware {
|
|||||||
...(response.locals.publicErrorCode
|
...(response.locals.publicErrorCode
|
||||||
? { code: response.locals.publicErrorCode as string }
|
? { code: response.locals.publicErrorCode as string }
|
||||||
: {}),
|
: {}),
|
||||||
...(response.locals.errorStack
|
|
||||||
? { stack: response.locals.errorStack as string }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
const level =
|
const level =
|
||||||
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
|
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const allowedFields = new Set([
|
|||||||
'status',
|
'status',
|
||||||
'durationMs',
|
'durationMs',
|
||||||
'code',
|
'code',
|
||||||
'stack',
|
|
||||||
'operation',
|
'operation',
|
||||||
'grpcCode',
|
'grpcCode',
|
||||||
'attempts',
|
'attempts',
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { MoexClient } from './moex.client';
|
import { MoexClient } from './moex.client';
|
||||||
|
|
||||||
|
const emptyEvents = {
|
||||||
|
coupons: { columns: ['coupondate', 'value', 'faceunit'], data: [] },
|
||||||
|
offers: { columns: ['offerdate'], data: [] },
|
||||||
|
description: { columns: ['name', 'value'], data: [] },
|
||||||
|
};
|
||||||
|
|
||||||
describe('MoexClient', () => {
|
describe('MoexClient', () => {
|
||||||
afterEach(() => jest.restoreAllMocks());
|
afterEach(() => jest.restoreAllMocks());
|
||||||
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
|
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
|
||||||
@@ -92,17 +98,174 @@ describe('MoexClient', () => {
|
|||||||
expect(result?.value ?? null).toBe(value === null ? null : '0');
|
expect(result?.value ?? null).toBe(value === null ? null : '0');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
it('treats a successful response without the requested block as no events', async () => {
|
it('accepts confirmed empty blocks', async () => {
|
||||||
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
|
jest
|
||||||
ok: true,
|
.spyOn(global, 'fetch')
|
||||||
json: () =>
|
.mockImplementation(() => Promise.resolve(Response.json(emptyEvents)));
|
||||||
Promise.resolve({
|
|
||||||
description: { columns: ['name', 'value'], data: [] },
|
|
||||||
boards: { columns: ['secid'], data: [] },
|
|
||||||
}),
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]);
|
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]);
|
||||||
fetchMock.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves not-found semantics independently of malformed HTTP 200', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(global, 'fetch')
|
||||||
|
.mockImplementation(() =>
|
||||||
|
Promise.resolve(new Response(null, { status: 404 })),
|
||||||
|
);
|
||||||
|
const client = new MoexClient();
|
||||||
|
await expect(client.bondEvents('MISSING')).resolves.toEqual([]);
|
||||||
|
await expect(
|
||||||
|
client.bondYield('MISSING', 'ISIN', 'TQOB'),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['missing block', { coupons: undefined }],
|
||||||
|
['missing columns', { coupons: { data: [] } }],
|
||||||
|
['invalid column', { coupons: { columns: [null], data: [] } }],
|
||||||
|
['invalid row', { coupons: { columns: ['coupondate'], data: [null] } }],
|
||||||
|
[
|
||||||
|
'short row',
|
||||||
|
{ coupons: { columns: ['coupondate', 'value'], data: [['2099-02-01']] } },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'long row',
|
||||||
|
{ coupons: { columns: ['coupondate'], data: [['2099-02-01', 2]] } },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'empty cursor',
|
||||||
|
{
|
||||||
|
'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'missing cursor field',
|
||||||
|
{ 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'null cursor index',
|
||||||
|
{
|
||||||
|
'coupons.cursor': {
|
||||||
|
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||||
|
data: [[null, 0, 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'boolean cursor total',
|
||||||
|
{
|
||||||
|
'coupons.cursor': {
|
||||||
|
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||||
|
data: [[0, false, 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'empty cursor total',
|
||||||
|
{
|
||||||
|
'coupons.cursor': {
|
||||||
|
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||||
|
data: [[0, '', 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'short page',
|
||||||
|
{
|
||||||
|
'coupons.cursor': {
|
||||||
|
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||||
|
data: [[0, 1, 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
'rejects %s without returning a partial calendar',
|
||||||
|
async (_name, invalid) => {
|
||||||
|
jest
|
||||||
|
.spyOn(global, 'fetch')
|
||||||
|
.mockImplementation(() =>
|
||||||
|
Promise.resolve(Response.json({ ...emptyEvents, ...invalid })),
|
||||||
|
);
|
||||||
|
await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({
|
||||||
|
response: { code: 'MOEX_UNAVAILABLE' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([0, -1])(
|
||||||
|
'rejects nonpositive page size %s before repeating a fetch',
|
||||||
|
async (pageSize) => {
|
||||||
|
const starts: string[] = [];
|
||||||
|
jest.spyOn(global, 'fetch').mockImplementation((input) => {
|
||||||
|
const url = new URL(input instanceof Request ? input.url : input);
|
||||||
|
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
|
||||||
|
starts.push(url.searchParams.get('start') ?? '');
|
||||||
|
if (starts.length > 1)
|
||||||
|
return Promise.reject(new Error('Repeated page'));
|
||||||
|
}
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
...emptyEvents,
|
||||||
|
'coupons.cursor': {
|
||||||
|
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||||
|
data: [[0, 1, pageSize]],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({
|
||||||
|
response: { code: 'MOEX_UNAVAILABLE' },
|
||||||
|
});
|
||||||
|
expect(starts).toEqual(['0']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
'reads all cursor pages and rejects a repeated index: %s',
|
||||||
|
async (repeatIndex) => {
|
||||||
|
const starts: string[] = [];
|
||||||
|
jest.spyOn(global, 'fetch').mockImplementation((input) => {
|
||||||
|
const url = new URL(input instanceof Request ? input.url : input);
|
||||||
|
const start = Number(url.searchParams.get('start'));
|
||||||
|
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
|
||||||
|
starts.push(String(start));
|
||||||
|
if (starts.length > 2)
|
||||||
|
return Promise.reject(new Error('Repeated page'));
|
||||||
|
}
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
...emptyEvents,
|
||||||
|
coupons: {
|
||||||
|
columns: ['coupondate', 'value', 'faceunit'],
|
||||||
|
data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']],
|
||||||
|
},
|
||||||
|
'coupons.cursor': {
|
||||||
|
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||||
|
data: [[repeatIndex ? 0 : start, 2, 1]],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const result = new MoexClient().bondEvents('BOND');
|
||||||
|
if (repeatIndex)
|
||||||
|
await expect(result).rejects.toMatchObject({
|
||||||
|
response: { code: 'MOEX_UNAVAILABLE' },
|
||||||
|
});
|
||||||
|
else
|
||||||
|
await expect(result).resolves.toEqual([
|
||||||
|
{
|
||||||
|
type: 'coupon',
|
||||||
|
date: '2099-02-01',
|
||||||
|
value: '2.000000001',
|
||||||
|
currency: 'RUB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'coupon',
|
||||||
|
date: '2099-02-02',
|
||||||
|
value: '2.000000001',
|
||||||
|
currency: 'RUB',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(starts).toEqual(['0', '1']);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,22 +123,26 @@ export class MoexClient {
|
|||||||
rows.push(...current);
|
rows.push(...current);
|
||||||
if (!paginated) return rows;
|
if (!paginated) return rows;
|
||||||
const cursorBlock = response[`${block}.cursor`];
|
const cursorBlock = response[`${block}.cursor`];
|
||||||
if (!cursorBlock) {
|
if (cursorBlock === undefined) {
|
||||||
if (current.length < FALLBACK_PAGE_SIZE) return rows;
|
if (current.length < FALLBACK_PAGE_SIZE) return rows;
|
||||||
start += FALLBACK_PAGE_SIZE;
|
start += FALLBACK_PAGE_SIZE;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cursor = parseRows(cursorBlock)[0];
|
const cursors = parseRows(cursorBlock);
|
||||||
|
const cursor = cursors[0];
|
||||||
const index = integer(cursor?.index);
|
const index = integer(cursor?.index);
|
||||||
const total = integer(cursor?.total);
|
const total = integer(cursor?.total);
|
||||||
const pageSize = integer(cursor?.pagesize);
|
const pageSize = integer(cursor?.pagesize);
|
||||||
if (
|
if (
|
||||||
index === null ||
|
cursors.length !== 1 ||
|
||||||
|
index !== start ||
|
||||||
total === null ||
|
total === null ||
|
||||||
pageSize === null ||
|
pageSize === null ||
|
||||||
index + pageSize >= total
|
pageSize === 0 ||
|
||||||
|
current.length !== Math.min(pageSize, total - start)
|
||||||
)
|
)
|
||||||
return rows;
|
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||||
|
if (index + pageSize >= total) return rows;
|
||||||
start = index + pageSize;
|
start = index + pageSize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,14 +165,13 @@ export class MoexClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
||||||
if (!block) return [];
|
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: 'MOEX_UNAVAILABLE' });
|
||||||
const columns = block.columns;
|
const columns = block.columns;
|
||||||
if (!columns.every((column) => typeof column === 'string'))
|
if (!columns.every((column) => typeof column === 'string'))
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||||
return block.data.map((values) => {
|
return block.data.map((values) => {
|
||||||
if (!Array.isArray(values))
|
if (!Array.isArray(values) || values.length !== columns.length)
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
columns.map((column, index) => [column.toLowerCase(), values[index]]),
|
columns.map((column, index) => [column.toLowerCase(), values[index]]),
|
||||||
@@ -191,6 +194,6 @@ function currencyText(value: unknown): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function integer(value: unknown): number | null {
|
function integer(value: unknown): number | null {
|
||||||
const result = Number(value);
|
const result = Number(numberText(value) ?? NaN);
|
||||||
return Number.isSafeInteger(result) && result >= 0 ? result : null;
|
return Number.isSafeInteger(result) && result >= 0 ? result : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
import type {
|
||||||
|
Prisma,
|
||||||
|
TbankInstrumentLookup as MetadataLookupRow,
|
||||||
|
TbankInstrument as MetadataRow,
|
||||||
|
} from '../../../generated/prisma/client';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import type { TbankSyncAttempt } from './tbank-access.service';
|
||||||
|
import type { TbankInstrument } from './tbank.client';
|
||||||
|
import { tbankSourceTtlMs } from './tbank-source.policy';
|
||||||
|
|
||||||
|
export type MetadataIdentity = {
|
||||||
|
instrumentUid?: string | null;
|
||||||
|
figi?: string | null;
|
||||||
|
};
|
||||||
|
export type MetadataLookup = {
|
||||||
|
type: 'uid' | 'figi';
|
||||||
|
id: string;
|
||||||
|
data: TbankInstrument | null;
|
||||||
|
};
|
||||||
|
const provider = 'tbank';
|
||||||
|
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates.
|
||||||
|
const metadataReadBatchSize = 400;
|
||||||
|
export function metadataKey(identity: MetadataIdentity) {
|
||||||
|
return JSON.stringify([
|
||||||
|
identity.instrumentUid?.trim() || null,
|
||||||
|
identity.figi?.trim() || null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InstrumentMetadataRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async readMany(identities: MetadataIdentity[]) {
|
||||||
|
const identifiers = identities.flatMap((identity) => [
|
||||||
|
...(identity.instrumentUid?.trim()
|
||||||
|
? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }]
|
||||||
|
: []),
|
||||||
|
...(identity.figi?.trim()
|
||||||
|
? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
if (!identifiers.length)
|
||||||
|
return new Map<string, ReturnType<typeof selectMetadata>>();
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const lookups: MetadataLookupRow[] = [];
|
||||||
|
for (const identifierType of ['uid', 'figi']) {
|
||||||
|
const values = [
|
||||||
|
...new Set(
|
||||||
|
identifiers
|
||||||
|
.filter((entry) => entry.identifierType === identifierType)
|
||||||
|
.map((entry) => entry.identifier),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (
|
||||||
|
let offset = 0;
|
||||||
|
offset < values.length;
|
||||||
|
offset += metadataReadBatchSize
|
||||||
|
)
|
||||||
|
lookups.push(
|
||||||
|
...(await tx.tbankInstrumentLookup.findMany({
|
||||||
|
where: {
|
||||||
|
provider,
|
||||||
|
identifierType,
|
||||||
|
identifier: {
|
||||||
|
in: values.slice(offset, offset + metadataReadBatchSize),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const uids = [
|
||||||
|
...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
|
||||||
|
];
|
||||||
|
const instruments: MetadataRow[] = [];
|
||||||
|
for (
|
||||||
|
let offset = 0;
|
||||||
|
offset < uids.length;
|
||||||
|
offset += metadataReadBatchSize
|
||||||
|
)
|
||||||
|
instruments.push(
|
||||||
|
...(await tx.tbankInstrument.findMany({
|
||||||
|
where: {
|
||||||
|
provider,
|
||||||
|
uid: { in: uids.slice(offset, offset + metadataReadBatchSize) },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const byIdentifier = new Map(
|
||||||
|
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
|
||||||
|
);
|
||||||
|
const byUid = new Map(instruments.map((row) => [row.uid, row]));
|
||||||
|
return new Map(
|
||||||
|
identities.map((identity) => [
|
||||||
|
metadataKey(identity),
|
||||||
|
selectMetadata(identity, byIdentifier, byUid),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reserve(
|
||||||
|
attemptOwner: { userId: string; revision: string },
|
||||||
|
identity: MetadataIdentity,
|
||||||
|
runId: string,
|
||||||
|
active: ReadonlySet<string>,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const valid = await tx.tbankConnection.updateMany({
|
||||||
|
where: { ...attemptOwner, invalidToken: false },
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
if (!valid.count) return null;
|
||||||
|
const scope = {
|
||||||
|
...attemptOwner,
|
||||||
|
scopeKey: `metadata:${metadataKey(identity)}`,
|
||||||
|
};
|
||||||
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
previous?.state === 'running' &&
|
||||||
|
previous.processRunId === runId &&
|
||||||
|
active.has(previous.attemptId)
|
||||||
|
)
|
||||||
|
return { work: previous, launch: false };
|
||||||
|
const data = {
|
||||||
|
attemptId: randomUUID(),
|
||||||
|
processRunId: runId,
|
||||||
|
state: 'running',
|
||||||
|
priority: 1,
|
||||||
|
intent: { source: 'metadata', ...identity },
|
||||||
|
progress: {},
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
const work = await tx.tbankSyncWork.upsert({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
create: { ...scope, ...data, id: randomUUID() },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
return { work, launch: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
work(userId: string, revision: string, identity: MetadataIdentity) {
|
||||||
|
return this.prisma.tbankSyncWork.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_revision_scopeKey: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
scopeKey: `metadata:${metadataKey(identity)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, state: true, errorCode: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { state: 'complete', progress: {} },
|
||||||
|
});
|
||||||
|
if (!claimed.count) return false;
|
||||||
|
const aliases = new Map(
|
||||||
|
results.map((result) => [`${result.type}:${result.id}`, result]),
|
||||||
|
);
|
||||||
|
for (const result of results) {
|
||||||
|
if (!result.data) continue;
|
||||||
|
const uid =
|
||||||
|
result.data.uid?.trim() || (result.type === 'uid' ? result.id : null);
|
||||||
|
const figi =
|
||||||
|
result.data.figi?.trim() ||
|
||||||
|
(result.type === 'figi' ? result.id : null);
|
||||||
|
for (const [type, id] of [
|
||||||
|
['uid', uid],
|
||||||
|
['figi', figi],
|
||||||
|
] as const) {
|
||||||
|
if (id && uid && !aliases.has(`${type}:${id}`))
|
||||||
|
aliases.set(`${type}:${id}`, {
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
data: { ...result.data, uid },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const result of aliases.values()) {
|
||||||
|
const instrument = result.data;
|
||||||
|
const uid =
|
||||||
|
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
|
||||||
|
// FIGI is an alias, never a fabricated provider UID.
|
||||||
|
if (instrument && !uid)
|
||||||
|
throw new Error('Metadata response has no instrument UID');
|
||||||
|
if (instrument && uid) {
|
||||||
|
const key = { provider, uid };
|
||||||
|
const previous = await tx.tbankInstrument.findUnique({
|
||||||
|
where: { provider_uid: key },
|
||||||
|
});
|
||||||
|
const payload = JSON.parse(
|
||||||
|
JSON.stringify(instrument),
|
||||||
|
) as Prisma.InputJsonObject;
|
||||||
|
const data = {
|
||||||
|
metadata: payload,
|
||||||
|
figi:
|
||||||
|
instrument.figi ?? (result.type === 'figi' ? result.id : null),
|
||||||
|
ticker: instrument.ticker ?? null,
|
||||||
|
instrumentType: instrument.instrumentType ?? '',
|
||||||
|
fetchedAt: now,
|
||||||
|
};
|
||||||
|
await tx.tbankInstrument.upsert({
|
||||||
|
where: { provider_uid: key },
|
||||||
|
create: { ...key, ...data },
|
||||||
|
update: {
|
||||||
|
...data,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(previous?.metadata, payload)
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const key = {
|
||||||
|
provider,
|
||||||
|
identifierType: result.type,
|
||||||
|
identifier: result.id,
|
||||||
|
};
|
||||||
|
const matchedUids = instrument && uid ? [uid] : [];
|
||||||
|
const previous = await tx.tbankInstrumentLookup.findUnique({
|
||||||
|
where: { provider_identifierType_identifier: key },
|
||||||
|
});
|
||||||
|
await tx.tbankInstrumentLookup.upsert({
|
||||||
|
where: { provider_identifierType_identifier: key },
|
||||||
|
create: { ...key, matchedUids, fetchedAt: now },
|
||||||
|
update: {
|
||||||
|
matchedUids,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
|
||||||
|
await this.prisma.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
...(success ? { connection: { invalidToken: false } } : {}),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
state: success ? 'complete' : 'failed',
|
||||||
|
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (success)
|
||||||
|
await this.prisma.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: true },
|
||||||
|
},
|
||||||
|
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMetadata(
|
||||||
|
identity: MetadataIdentity,
|
||||||
|
lookups: ReadonlyMap<string, MetadataLookupRow>,
|
||||||
|
instruments: ReadonlyMap<string, MetadataRow>,
|
||||||
|
) {
|
||||||
|
const resolve = (type: string, id: string | null | undefined) => {
|
||||||
|
const lookup = lookups.get(`${type}:${id?.trim()}`);
|
||||||
|
const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
|
||||||
|
const instrument = uid ? instruments.get(uid) : undefined;
|
||||||
|
const data = instrument ? (instrument.metadata as TbankInstrument) : null;
|
||||||
|
return { lookup, instrument, data };
|
||||||
|
};
|
||||||
|
const instrumentUid = identity.instrumentUid?.trim();
|
||||||
|
const instrumentFigi = identity.figi?.trim();
|
||||||
|
const uid = resolve('uid', instrumentUid);
|
||||||
|
const figi = resolve('figi', instrumentFigi);
|
||||||
|
const usefulUid = Boolean(uid.data?.name?.trim() || uid.data?.ticker?.trim());
|
||||||
|
const used =
|
||||||
|
usefulUid || !instrumentFigi ? [uid] : instrumentUid ? [uid, figi] : [figi];
|
||||||
|
const complete = used.every((row) => Boolean(row.lookup));
|
||||||
|
const fetchedAt =
|
||||||
|
used
|
||||||
|
.flatMap((row) => (row.lookup ? [row.lookup.fetchedAt] : []))
|
||||||
|
.sort((a, b) => a.valueOf() - b.valueOf())[0] ?? null;
|
||||||
|
return {
|
||||||
|
data: usefulUid ? uid.data : (figi.data ?? uid.data),
|
||||||
|
complete,
|
||||||
|
fresh:
|
||||||
|
complete &&
|
||||||
|
fetchedAt !== null &&
|
||||||
|
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
|
||||||
|
fetchedAt,
|
||||||
|
dataRevision: used
|
||||||
|
.map(
|
||||||
|
(row) =>
|
||||||
|
`${row.lookup?.dataRevision ?? 0}:${row.instrument?.dataRevision ?? 0}`,
|
||||||
|
)
|
||||||
|
.join('/'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
|
||||||
import type { LRUCache } from 'lru-cache';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
|
||||||
import type { TbankInstrument } from './tbank.client';
|
|
||||||
|
|
||||||
describe('InstrumentMetadataService', () => {
|
|
||||||
const tbank = {
|
|
||||||
getInstrumentByFigi: jest.fn(),
|
|
||||||
getInstrumentByUid: jest.fn(),
|
|
||||||
};
|
|
||||||
const logger = { event: jest.fn() };
|
|
||||||
let service: InstrumentMetadataService;
|
|
||||||
|
|
||||||
function rememberExpired(key: string, value: TbankInstrument): void {
|
|
||||||
const cache = (
|
|
||||||
service as unknown as { metadata: LRUCache<string, TbankInstrument> }
|
|
||||||
).metadata;
|
|
||||||
cache.set(key, value, { ttl: 1, start: performance.now() - 1000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
tbank.getInstrumentByFigi.mockReset();
|
|
||||||
tbank.getInstrumentByUid.mockReset();
|
|
||||||
service = new InstrumentMetadataService(tbank as never, logger as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prefers the instrument UID when metadata is available', async () => {
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue({
|
|
||||||
name: 'Инструмент',
|
|
||||||
brand: { logoName: 'instrument.png' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.resolve('token', 'uid-1', 'figi-1'),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
brand: { logoName: 'instrument.png' },
|
|
||||||
});
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to FIGI when the UID has no matching instrument', async () => {
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue(null);
|
|
||||||
tbank.getInstrumentByFigi.mockResolvedValue({
|
|
||||||
ticker: 'SBER',
|
|
||||||
brand: { logoName: 'sber.png' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.resolve('token', 'uid-1', 'figi-1'),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
ticker: 'SBER',
|
|
||||||
});
|
|
||||||
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'figi-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a nullable fallback when instrument metadata is unavailable', async () => {
|
|
||||||
tbank.getInstrumentByUid.mockRejectedValue(new Error('unavailable'));
|
|
||||||
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caches negative lookups and avoids repeated upstream requests', async () => {
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
|
||||||
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tries FIGI when UID metadata has no usable name or ticker', async () => {
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue({ name: ' ', ticker: '' });
|
|
||||||
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Облигация' });
|
|
||||||
await expect(
|
|
||||||
service.resolve('token', 'uid-1', 'figi-1'),
|
|
||||||
).resolves.toMatchObject({ name: 'Облигация' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retains expired successful metadata through temporary failures and refreshes it later', async () => {
|
|
||||||
const known = {
|
|
||||||
name: 'Облигация',
|
|
||||||
ticker: 'BOND',
|
|
||||||
brand: { logoName: 'bond.png' },
|
|
||||||
};
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValueOnce(known);
|
|
||||||
await service.resolve('token', 'uid-1', null);
|
|
||||||
rememberExpired('uid:uid-1', known);
|
|
||||||
tbank.getInstrumentByUid.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
|
||||||
known,
|
|
||||||
);
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
|
||||||
known,
|
|
||||||
);
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue({
|
|
||||||
...known,
|
|
||||||
name: 'Новое название',
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
service.resolve('token', 'uid-1', null),
|
|
||||||
).resolves.toMatchObject({ name: 'Новое название' });
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mask an invalid token as missing metadata or retry by FIGI', async () => {
|
|
||||||
const error = new Error('INVALID_TBANK_TOKEN');
|
|
||||||
tbank.getInstrumentByUid.mockRejectedValue(error);
|
|
||||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).rejects.toBe(
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps known metadata through an incomplete refresh without renewing its freshness', async () => {
|
|
||||||
const known = { name: 'Облигация', ticker: 'BOND' };
|
|
||||||
rememberExpired('uid:uid-1', known);
|
|
||||||
tbank.getInstrumentByUid
|
|
||||||
.mockResolvedValueOnce({ name: ' ', ticker: '', isin: 'RU0000000001' })
|
|
||||||
.mockRejectedValueOnce(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
|
|
||||||
known,
|
|
||||||
);
|
|
||||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
|
|
||||||
known,
|
|
||||||
);
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a known FIGI name after partial UID metadata and does not cache the partial response', async () => {
|
|
||||||
const known = { name: 'Облигация', ticker: 'BOND' };
|
|
||||||
rememberExpired('figi:figi-1', known);
|
|
||||||
tbank.getInstrumentByUid
|
|
||||||
.mockResolvedValueOnce({ isin: 'RU0000000001' })
|
|
||||||
.mockResolvedValueOnce({ name: 'Новое название' });
|
|
||||||
tbank.getInstrumentByFigi.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
|
|
||||||
known,
|
|
||||||
);
|
|
||||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual({
|
|
||||||
name: 'Новое название',
|
|
||||||
});
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
|
||||||
expect(tbank.getInstrumentByFigi).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns cold partial fields without caching them', async () => {
|
|
||||||
const partial = { isin: 'RU0000000001' };
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue(partial);
|
|
||||||
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
|
||||||
partial,
|
|
||||||
);
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
|
||||||
partial,
|
|
||||||
);
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replaces an expired name with authoritative confirmed absence', async () => {
|
|
||||||
rememberExpired('uid:uid-1', { name: 'Старое название' });
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isolates in-flight credentials while sharing successful public metadata', async () => {
|
|
||||||
const error = new Error('INVALID_TBANK_TOKEN');
|
|
||||||
const known = { name: 'Облигация' };
|
|
||||||
let rejectInvalid: ((reason: Error) => void) | undefined;
|
|
||||||
tbank.getInstrumentByUid.mockImplementation((token: string) =>
|
|
||||||
token === 'invalid-token'
|
|
||||||
? new Promise((_resolve, reject) => {
|
|
||||||
rejectInvalid = reject;
|
|
||||||
})
|
|
||||||
: Promise.resolve(known),
|
|
||||||
);
|
|
||||||
|
|
||||||
const invalid = expect(
|
|
||||||
service.resolve('invalid-token', 'uid-1', null),
|
|
||||||
).rejects.toBe(error);
|
|
||||||
const valid = service.resolve('valid-token', 'uid-1', null);
|
|
||||||
assert.ok(rejectInvalid);
|
|
||||||
rejectInvalid(error);
|
|
||||||
|
|
||||||
await invalid;
|
|
||||||
await expect(valid).resolves.toEqual(known);
|
|
||||||
await expect(
|
|
||||||
service.resolve('another-token', 'uid-1', null),
|
|
||||||
).resolves.toEqual(known);
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns cached or nullable metadata without starting requests after abort', async () => {
|
|
||||||
const known = { name: 'Облигация' };
|
|
||||||
rememberExpired('figi:figi-1', known);
|
|
||||||
const controller = new AbortController();
|
|
||||||
controller.abort();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.resolve('token', 'uid-1', 'figi-1', controller.signal),
|
|
||||||
).resolves.toEqual(known);
|
|
||||||
await expect(
|
|
||||||
service.resolve('token', 'uid-2', 'figi-2', controller.signal),
|
|
||||||
).resolves.toBeNull();
|
|
||||||
expect(tbank.getInstrumentByUid).not.toHaveBeenCalled();
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns expired metadata promptly when the waiting caller aborts', async () => {
|
|
||||||
const known = { name: 'Облигация' };
|
|
||||||
rememberExpired('uid:uid-1', known);
|
|
||||||
tbank.getInstrumentByUid.mockReturnValue(new Promise(() => {}));
|
|
||||||
const controller = new AbortController();
|
|
||||||
const result = service.resolve(
|
|
||||||
'token',
|
|
||||||
'uid-1',
|
|
||||||
'figi-1',
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
|
|
||||||
controller.abort();
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(known);
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a shared fetch alive after one caller aborts and caches its result', async () => {
|
|
||||||
const known = { name: 'Облигация' };
|
|
||||||
let finish: ((value: TbankInstrument) => void) | undefined;
|
|
||||||
tbank.getInstrumentByUid.mockReturnValue(
|
|
||||||
new Promise((resolve) => {
|
|
||||||
finish = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const controller = new AbortController();
|
|
||||||
const aborted = service.resolve(
|
|
||||||
'token',
|
|
||||||
'uid-1',
|
|
||||||
'figi-1',
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
controller.abort();
|
|
||||||
await expect(aborted).resolves.toBeNull();
|
|
||||||
|
|
||||||
const waiting = service.resolve('token', 'uid-1', null);
|
|
||||||
assert.ok(finish);
|
|
||||||
finish(known);
|
|
||||||
|
|
||||||
await expect(waiting).resolves.toEqual(known);
|
|
||||||
await expect(
|
|
||||||
service.resolve('another-token', 'uid-1', null),
|
|
||||||
).resolves.toEqual(known);
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not start FIGI lookup after an aborted UID lookup fails later', async () => {
|
|
||||||
let fail: ((reason: Error) => void) | undefined;
|
|
||||||
tbank.getInstrumentByUid.mockReturnValue(
|
|
||||||
new Promise((_resolve, reject) => {
|
|
||||||
fail = reject;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const controller = new AbortController();
|
|
||||||
const result = service.resolve(
|
|
||||||
'token',
|
|
||||||
'uid-1',
|
|
||||||
'figi-1',
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
controller.abort();
|
|
||||||
await expect(result).resolves.toBeNull();
|
|
||||||
|
|
||||||
assert.ok(fail);
|
|
||||||
fail(new Error('INVALID_TBANK_TOKEN'));
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,118 +1,208 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
|
import {
|
||||||
|
TbankAccessService,
|
||||||
|
type TbankSyncAttempt,
|
||||||
|
} from './tbank-access.service';
|
||||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||||
|
import {
|
||||||
|
InstrumentMetadataRepository,
|
||||||
|
metadataKey,
|
||||||
|
type MetadataIdentity,
|
||||||
|
type MetadataLookup,
|
||||||
|
} from './instrument-metadata.repository';
|
||||||
|
export { metadataKey } from './instrument-metadata.repository';
|
||||||
|
export type { MetadataIdentity } from './instrument-metadata.repository';
|
||||||
|
|
||||||
const metadataForMs = 86_400_000;
|
type Producer = {
|
||||||
const maxCachedInstruments = 500;
|
identity: MetadataIdentity;
|
||||||
const missingInstrument = Symbol('missingInstrument');
|
attempt: TbankSyncAttempt;
|
||||||
|
observers: TbankSyncAttempt[];
|
||||||
|
};
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InstrumentMetadataService {
|
export class InstrumentMetadataService {
|
||||||
private readonly metadata = new LRUCache<
|
private readonly runId = randomUUID();
|
||||||
string,
|
private readonly active = new Set<string>();
|
||||||
TbankInstrument | typeof missingInstrument
|
private readonly producers = new Map<string, Producer>();
|
||||||
>({
|
private readonly queue: Producer[] = [];
|
||||||
max: maxCachedInstruments,
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
ttl: metadataForMs,
|
private running = false;
|
||||||
noDeleteOnStaleGet: true,
|
|
||||||
});
|
|
||||||
private readonly pending = new Map<string, Promise<TbankInstrument | null>>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
|
private readonly repository: InstrumentMetadataRepository,
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async resolve(
|
savedMany(identities: MetadataIdentity[]) {
|
||||||
token: string,
|
return this.repository.readMany(identities);
|
||||||
instrumentUid: string | null | undefined,
|
}
|
||||||
figi: string | null | undefined,
|
|
||||||
signal?: AbortSignal,
|
async read(
|
||||||
): Promise<TbankInstrument | null> {
|
userId: string,
|
||||||
const uid = instrumentUid?.trim();
|
identity: MetadataIdentity,
|
||||||
let byUid: TbankInstrument | null = null;
|
observe = false,
|
||||||
if (uid) {
|
revision?: string,
|
||||||
byUid = await this.lookup(
|
) {
|
||||||
token,
|
const connection = await this.access.local(userId);
|
||||||
`uid:${uid}`,
|
if (revision && connection.revision !== revision) return null;
|
||||||
() => this.tbank.getInstrumentByUid(token, uid),
|
const data = (await this.savedMany([identity])).get(metadataKey(identity));
|
||||||
signal,
|
if (!observe && !data?.fresh)
|
||||||
|
await this.request(userId, connection.revision, identity);
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
work: await this.repository.work(userId, connection.revision, identity),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
identity: MetadataIdentity,
|
||||||
|
force = false,
|
||||||
|
) {
|
||||||
|
identity = {
|
||||||
|
instrumentUid: identity.instrumentUid?.trim() || null,
|
||||||
|
figi: identity.figi?.trim() || null,
|
||||||
|
};
|
||||||
|
if (!identity.instrumentUid && !identity.figi) return null;
|
||||||
|
const normalized = identity;
|
||||||
|
const reserved = this.reservations.then(async () => {
|
||||||
|
const snapshot = (await this.savedMany([normalized])).get(
|
||||||
|
metadataKey(normalized),
|
||||||
);
|
);
|
||||||
if (byUid?.name?.trim() || byUid?.ticker?.trim()) return byUid;
|
if (!force && snapshot?.fresh) return null;
|
||||||
|
const result = await this.repository.reserve(
|
||||||
|
{ userId, revision },
|
||||||
|
normalized,
|
||||||
|
this.runId,
|
||||||
|
this.active,
|
||||||
|
);
|
||||||
|
if (!result) return null;
|
||||||
|
const { id, attemptId } = result.work;
|
||||||
|
if (!result.launch) return id;
|
||||||
|
const attempt = { userId, revision, id, attemptId };
|
||||||
|
this.active.add(attemptId);
|
||||||
|
const key = metadataKey(normalized);
|
||||||
|
const producer = this.producers.get(key);
|
||||||
|
if (producer) producer.observers.push(attempt);
|
||||||
|
else {
|
||||||
|
const queued = { identity: normalized, attempt, observers: [] };
|
||||||
|
this.producers.set(key, queued);
|
||||||
|
this.queue.push(queued);
|
||||||
}
|
}
|
||||||
const instrumentFigi = figi?.trim();
|
if (!this.running) {
|
||||||
const byFigi = instrumentFigi
|
this.running = true;
|
||||||
? await this.lookup(
|
setImmediate(() => {
|
||||||
token,
|
void this.drain();
|
||||||
`figi:${instrumentFigi}`,
|
|
||||||
() => this.tbank.getInstrumentByFigi(token, instrumentFigi),
|
|
||||||
signal,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
return byFigi ?? byUid;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async lookup(
|
|
||||||
token: string,
|
|
||||||
key: string,
|
|
||||||
fetch: () => Promise<TbankInstrument | null>,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<TbankInstrument | null> {
|
|
||||||
const cached = this.metadata.get(key);
|
|
||||||
if (cached !== undefined)
|
|
||||||
return cached === missingInstrument ? null : cached;
|
|
||||||
if (signal?.aborted) return this.lastKnown(key);
|
|
||||||
const owner = createHash('sha256').update(token).digest('hex');
|
|
||||||
const requestKey = `${owner}:${key}`;
|
|
||||||
let pending = this.pending.get(requestKey);
|
|
||||||
if (!pending) {
|
|
||||||
pending = this.fetch(key, fetch, this.lastKnown(key)).finally(() => {
|
|
||||||
this.pending.delete(requestKey);
|
|
||||||
});
|
|
||||||
this.pending.set(requestKey, pending);
|
|
||||||
}
|
|
||||||
if (!signal) return pending;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const onAbort = () => resolve(this.lastKnown(key));
|
|
||||||
signal.addEventListener('abort', onAbort, { once: true });
|
|
||||||
void pending.then(resolve, reject).finally(() => {
|
|
||||||
signal.removeEventListener('abort', onAbort);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return id;
|
||||||
private lastKnown(key: string): TbankInstrument | null {
|
});
|
||||||
const value = this.metadata.peek(key, { allowStale: true });
|
this.reservations = reserved.catch(() => undefined);
|
||||||
return value === missingInstrument ? null : (value ?? null);
|
return reserved;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetch(
|
private async drain() {
|
||||||
key: string,
|
|
||||||
request: () => Promise<TbankInstrument | null>,
|
|
||||||
previous: TbankInstrument | null,
|
|
||||||
): Promise<TbankInstrument | null> {
|
|
||||||
try {
|
try {
|
||||||
const value = await request();
|
while (this.queue.length) {
|
||||||
const found = Boolean(value?.name?.trim() || value?.ticker?.trim());
|
const producer = this.queue.shift();
|
||||||
if (value === null || found)
|
if (!producer) break;
|
||||||
this.metadata.set(key, value ?? missingInstrument);
|
const { attempt, identity } = producer;
|
||||||
this.logger.event('debug', 'instrument-metadata.resolved', {
|
let success = false;
|
||||||
found,
|
try {
|
||||||
});
|
const token = await this.access.tokenForAttempt(attempt);
|
||||||
return found || value === null ? value : (previous ?? value);
|
if (token) {
|
||||||
|
const results: MetadataLookup[] = [];
|
||||||
|
let useful = false;
|
||||||
|
let uidFailed = false;
|
||||||
|
if (identity.instrumentUid) {
|
||||||
|
let data: TbankInstrument | null | undefined;
|
||||||
|
try {
|
||||||
|
data = await this.tbank.getInstrumentByUid(
|
||||||
|
token,
|
||||||
|
identity.instrumentUid,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
if (
|
||||||
code:
|
!identity.figi ||
|
||||||
error instanceof Error && 'response' in error
|
(error instanceof Error &&
|
||||||
? ((error as { response?: { code?: string } }).response?.code ??
|
error.message === 'INVALID_TBANK_TOKEN')
|
||||||
'UNKNOWN')
|
)
|
||||||
: 'UNKNOWN',
|
|
||||||
});
|
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
|
||||||
throw error;
|
throw error;
|
||||||
return error instanceof ServiceUnavailableException ? previous : null;
|
uidFailed = true;
|
||||||
|
}
|
||||||
|
if (data?.uid && data.uid !== identity.instrumentUid)
|
||||||
|
throw new Error('Metadata UID mismatch');
|
||||||
|
if (data !== undefined)
|
||||||
|
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||||
|
useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
|
||||||
|
if (!useful && data && !identity.figi)
|
||||||
|
throw new Error('Incomplete metadata');
|
||||||
|
}
|
||||||
|
if (!useful && identity.figi) {
|
||||||
|
const data = await this.tbank.getInstrumentByFigi(
|
||||||
|
token,
|
||||||
|
identity.figi,
|
||||||
|
);
|
||||||
|
if (data?.figi && data.figi !== identity.figi)
|
||||||
|
throw new Error('Metadata FIGI mismatch');
|
||||||
|
if (data && !data.name?.trim() && !data.ticker?.trim())
|
||||||
|
throw new Error('Incomplete metadata');
|
||||||
|
if (data === undefined || (uidFailed && !data))
|
||||||
|
throw new Error('Metadata source unavailable');
|
||||||
|
results.push({ type: 'figi', id: identity.figi, data });
|
||||||
|
// A successful FIGI response identifies its canonical UID too;
|
||||||
|
// a failed UID RPC is never persisted as a negative lookup.
|
||||||
|
if (
|
||||||
|
uidFailed &&
|
||||||
|
data?.uid === identity.instrumentUid &&
|
||||||
|
identity.instrumentUid
|
||||||
|
)
|
||||||
|
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!results.length ||
|
||||||
|
(results.some((result) => result.data) &&
|
||||||
|
!results.some(
|
||||||
|
(result) =>
|
||||||
|
result.data?.name?.trim() || result.data?.ticker?.trim(),
|
||||||
|
))
|
||||||
|
)
|
||||||
|
throw new Error('Incomplete metadata');
|
||||||
|
success = await this.repository.publish(
|
||||||
|
attempt,
|
||||||
|
results,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
|
await this.access.rejectMembershipToken(attempt);
|
||||||
|
else
|
||||||
|
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Remove the producer before awaiting finalization: a new request cannot
|
||||||
|
// attach to a producer whose observer list has already been drained.
|
||||||
|
this.producers.delete(metadataKey(identity));
|
||||||
|
for (const observer of [attempt, ...producer.observers]) {
|
||||||
|
await this.repository.finishObserver(observer, success);
|
||||||
|
this.active.delete(observer.attemptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.logger.event('error', 'instrument-metadata.persistence-failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
this.active.clear();
|
||||||
|
this.producers.clear();
|
||||||
|
this.queue.length = 0;
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
NotFoundException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
|
|
||||||
jest.mock('../../prisma/prisma.service', () => ({
|
|
||||||
PrismaService: class PrismaService {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import type { TbankAccountContext } from './tbank-access.service';
|
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
|
||||||
|
|
||||||
describe('TbankAccessService', () => {
|
|
||||||
const prisma = { tbankConnection: { findUnique: jest.fn() } };
|
|
||||||
const token = {
|
|
||||||
decrypt: jest.fn(),
|
|
||||||
translateInvalidToken: jest.fn((operation: () => Promise<unknown>) =>
|
|
||||||
new TbankTokenService({
|
|
||||||
getOrThrow: () => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=',
|
|
||||||
} as never).translateInvalidToken(operation),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
const tbank = { getOpenAccounts: jest.fn() };
|
|
||||||
const service = new TbankAccessService(
|
|
||||||
prisma as never,
|
|
||||||
token as never,
|
|
||||||
tbank as never,
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: 'encrypted',
|
|
||||||
});
|
|
||||||
token.decrypt.mockReturnValue('plain-token');
|
|
||||||
tbank.getOpenAccounts.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'account',
|
|
||||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
|
||||||
name: ' Основной ',
|
|
||||||
openedDate: { seconds: '1723507200' },
|
|
||||||
},
|
|
||||||
{ id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists supported accounts through the saved decrypted token', async () => {
|
|
||||||
await expect(service.listSupportedAccounts('user')).resolves.toEqual([
|
|
||||||
{
|
|
||||||
id: 'account',
|
|
||||||
type: 'BROKERAGE',
|
|
||||||
name: 'Основной',
|
|
||||||
openedOn: '2024-08-13',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(prisma.tbankConnection.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: { userId: 'user' },
|
|
||||||
});
|
|
||||||
expect(token.decrypt).toHaveBeenCalledWith('encrypted');
|
|
||||||
expect(tbank.getOpenAccounts).toHaveBeenCalledWith('plain-token');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects missing connections before calling T-Bank', async () => {
|
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
|
|
||||||
expect.any(ConflictException),
|
|
||||||
);
|
|
||||||
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves the persisted connection generation without an upstream account call', async () => {
|
|
||||||
const context = await service.withConnection('user', (value) => value);
|
|
||||||
|
|
||||||
expect(context).toEqual({
|
|
||||||
token: 'plain-token',
|
|
||||||
generation: expect.any(String) as string,
|
|
||||||
});
|
|
||||||
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('translates invalid tokens and preserves unavailable upstream errors', async () => {
|
|
||||||
tbank.getOpenAccounts.mockRejectedValueOnce(
|
|
||||||
new Error('INVALID_TBANK_TOKEN'),
|
|
||||||
);
|
|
||||||
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
|
|
||||||
expect.any(BadRequestException),
|
|
||||||
);
|
|
||||||
|
|
||||||
const unavailable = new ServiceUnavailableException({
|
|
||||||
code: 'TBANK_UNAVAILABLE',
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockRejectedValueOnce(unavailable);
|
|
||||||
await expect(service.listSupportedAccounts('user')).rejects.toBe(
|
|
||||||
unavailable,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wraps account-scoped handlers with resolved account access', async () => {
|
|
||||||
const handler = jest.fn((context: TbankAccountContext) =>
|
|
||||||
Promise.resolve({
|
|
||||||
resolvedToken: context.token,
|
|
||||||
generation: context.generation,
|
|
||||||
accountId: context.account.id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.withAccount('user', 'account', handler),
|
|
||||||
).resolves.toEqual({
|
|
||||||
resolvedToken: 'plain-token',
|
|
||||||
generation: expect.any(String) as string,
|
|
||||||
accountId: 'account',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derives a stable opaque generation from each persisted encrypted payload', async () => {
|
|
||||||
const generation = () =>
|
|
||||||
service.withAccount('user', 'account', ({ generation: value }) =>
|
|
||||||
Promise.resolve(value),
|
|
||||||
);
|
|
||||||
prisma.tbankConnection.findUnique
|
|
||||||
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
|
|
||||||
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
|
|
||||||
.mockResolvedValueOnce({ encryptedToken: 'encrypted:second-nonce' });
|
|
||||||
|
|
||||||
const first = await generation();
|
|
||||||
const sameRecord = await generation();
|
|
||||||
const replaced = await generation();
|
|
||||||
|
|
||||||
expect(sameRecord).toBe(first);
|
|
||||||
expect(replaced).not.toBe(first);
|
|
||||||
expect(first).not.toContain('plain-token');
|
|
||||||
expect(first).not.toContain('encrypted:first-nonce');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects inaccessible accounts before running the handler', async () => {
|
|
||||||
const handler = jest.fn();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.withAccount('user', 'missing', handler),
|
|
||||||
).rejects.toEqual(expect.any(NotFoundException));
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('translates invalid-token handler failures and preserves unrelated handler failures', async () => {
|
|
||||||
await expect(
|
|
||||||
service.withAccount('user', 'account', () =>
|
|
||||||
Promise.reject(new Error('INVALID_TBANK_TOKEN')),
|
|
||||||
),
|
|
||||||
).rejects.toEqual(expect.any(BadRequestException));
|
|
||||||
|
|
||||||
const failure = new Error('source failed');
|
|
||||||
await expect(
|
|
||||||
service.withAccount('user', 'account', () => Promise.reject(failure)),
|
|
||||||
).rejects.toBe(failure);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +1,21 @@
|
|||||||
|
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { createHash } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import {
|
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||||
SupportedTbankAccount,
|
|
||||||
accessibleTbankAccount,
|
|
||||||
supportedTbankAccounts,
|
|
||||||
} from './tbank-account.policy';
|
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
import { TbankAccount, TbankClient } from './tbank.client';
|
import type { TbankAccount } from './tbank.client';
|
||||||
|
|
||||||
export type TbankConnectionContext = {
|
export type TbankSyncAttempt = {
|
||||||
token: string;
|
userId: string;
|
||||||
generation: string;
|
revision: string;
|
||||||
};
|
id: string;
|
||||||
|
attemptId: string;
|
||||||
export type TbankAccountContext = TbankConnectionContext & {
|
|
||||||
account: TbankAccount;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -27,81 +23,218 @@ export class TbankAccessService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly tokens: TbankTokenService,
|
private readonly tokens: TbankTokenService,
|
||||||
private readonly tbank: TbankClient,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listSupportedAccounts(
|
async reserveMembership(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<SupportedTbankAccount[]> {
|
revision: string,
|
||||||
return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
|
processRunId: string,
|
||||||
}
|
active: ReadonlySet<string>,
|
||||||
|
force: boolean,
|
||||||
async withSupportedAccounts<T>(
|
) {
|
||||||
userId: string,
|
return this.prisma.$transaction(async (tx) => {
|
||||||
handler: (context: {
|
const current = await tx.tbankConnection.updateMany({
|
||||||
token: string;
|
where: { userId, revision, invalidToken: false },
|
||||||
generation: string;
|
data: { membershipRevision: { increment: 0 } },
|
||||||
accounts: SupportedTbankAccount[];
|
});
|
||||||
}) => Promise<T> | T,
|
if (!current.count) return null;
|
||||||
): Promise<T> {
|
const connection = await tx.tbankConnection.findUniqueOrThrow({
|
||||||
return this.withConnection(userId, async ({ token, generation }) =>
|
where: { userId },
|
||||||
handler({
|
});
|
||||||
token,
|
if (
|
||||||
generation,
|
!force &&
|
||||||
accounts: supportedTbankAccounts(
|
!tbankSourceNeedsRefresh(
|
||||||
await this.tbank.getOpenAccounts(token),
|
'membership',
|
||||||
),
|
connection.membershipAt,
|
||||||
}),
|
new Date(),
|
||||||
);
|
)
|
||||||
}
|
)
|
||||||
|
return null;
|
||||||
async withConnection<T>(
|
const scope = { userId, revision, scopeKey: 'membership' };
|
||||||
userId: string,
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
handler: (context: TbankConnectionContext) => Promise<T> | T,
|
where: { userId_revision_scopeKey: scope },
|
||||||
): Promise<T> {
|
});
|
||||||
const connection = await this.connection(userId);
|
if (
|
||||||
return this.tokens.translateInvalidToken(async () => handler(connection));
|
previous?.state === 'running' &&
|
||||||
}
|
previous.processRunId === processRunId &&
|
||||||
|
active.has(previous.attemptId)
|
||||||
async withAccount<T>(
|
)
|
||||||
userId: string,
|
return { work: previous, launch: false };
|
||||||
accountId: string,
|
const data = {
|
||||||
handler: (context: TbankAccountContext) => Promise<T>,
|
attemptId: randomUUID(),
|
||||||
): Promise<T> {
|
processRunId,
|
||||||
return this.withConnection(userId, (connection) =>
|
state: 'running',
|
||||||
this.withAccountForConnection(connection, accountId, handler),
|
intent: { source: 'membership' },
|
||||||
);
|
progress: {},
|
||||||
}
|
priority: 0,
|
||||||
|
errorCode: null,
|
||||||
async withAccountForConnection<T>(
|
};
|
||||||
connection: TbankConnectionContext,
|
const work = await tx.tbankSyncWork.upsert({
|
||||||
accountId: string,
|
where: { userId_revision_scopeKey: scope },
|
||||||
handler: (context: TbankAccountContext) => Promise<T>,
|
create: { ...scope, ...data, id: randomUUID() },
|
||||||
): Promise<T> {
|
update: data,
|
||||||
const { token, generation } = connection;
|
});
|
||||||
return this.tokens.translateInvalidToken(async () => {
|
return { work, launch: true };
|
||||||
const account = accessibleTbankAccount(
|
|
||||||
await this.tbank.getOpenAccounts(token),
|
|
||||||
accountId,
|
|
||||||
);
|
|
||||||
if (!account) throw new NotFoundException();
|
|
||||||
return handler({ token, generation, account });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async connection(
|
membershipWork(userId: string, revision: string) {
|
||||||
userId: string,
|
return this.prisma.tbankSyncWork.findUnique({
|
||||||
): Promise<{ token: string; generation: string }> {
|
where: {
|
||||||
|
userId_revision_scopeKey: { userId, revision, scopeKey: 'membership' },
|
||||||
|
},
|
||||||
|
select: { id: true, state: true, errorCode: true, updatedAt: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
|
||||||
|
if (invalidToken) return this.rejectMembershipToken(attempt);
|
||||||
|
const result = await this.prisma.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||||
|
});
|
||||||
|
return result.count === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<string | null> {
|
||||||
|
const work = await this.prisma.tbankSyncWork.findFirst({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
select: { connection: { select: { encryptedToken: true } } },
|
||||||
|
});
|
||||||
|
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishMembership(
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
accounts: TbankAccount[],
|
||||||
|
fetchedAt: Date,
|
||||||
|
completeWork = false,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const supported = supportedTbankAccounts(accounts);
|
||||||
|
if (new Set(supported.map(({ id }) => id)).size !== supported.length)
|
||||||
|
throw new Error('Duplicate T-Bank account identity');
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// Acquire the write fence before reading membership. Replacement and
|
||||||
|
// attempt supersession cannot interleave with the publication below.
|
||||||
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
if (!claimed.count) return false;
|
||||||
|
const { userId, revision } = attempt;
|
||||||
|
const previous = await tx.tbankAccount.findMany({
|
||||||
|
where: { userId, revision },
|
||||||
|
orderBy: { upstreamOrder: 'asc' },
|
||||||
|
select: { accountId: true, type: true, name: true, openedOn: true },
|
||||||
|
});
|
||||||
|
const next = supported.map(({ id, ...account }) => ({
|
||||||
|
accountId: id,
|
||||||
|
...account,
|
||||||
|
}));
|
||||||
|
const changed =
|
||||||
|
previous.length !== next.length ||
|
||||||
|
previous.some((account, index) => {
|
||||||
|
const incoming = next[index];
|
||||||
|
return (
|
||||||
|
account.accountId !== incoming.accountId ||
|
||||||
|
account.type !== incoming.type ||
|
||||||
|
account.name !== incoming.name ||
|
||||||
|
account.openedOn !== incoming.openedOn
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await tx.tbankAccount.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
accountId: { notIn: supported.map(({ id }) => id) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const [upstreamOrder, account] of next.entries()) {
|
||||||
|
await tx.tbankAccount.upsert({
|
||||||
|
where: {
|
||||||
|
userId_revision_accountId: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
accountId: account.accountId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: { userId, revision, ...account, upstreamOrder },
|
||||||
|
update: { ...account, upstreamOrder },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.tbankConnection.update({
|
||||||
|
where: { userId_revision: { userId, revision } },
|
||||||
|
data: {
|
||||||
|
membershipAt: fetchedAt,
|
||||||
|
membershipRevision: { increment: changed ? 1 : 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (completeWork)
|
||||||
|
await tx.tbankSyncWork.update({
|
||||||
|
where: { id: attempt.id },
|
||||||
|
data: { state: 'complete', progress: { accounts: supported.length } },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: { ...attempt, state: 'running' },
|
||||||
|
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||||
|
});
|
||||||
|
if (!claimed.count) return false;
|
||||||
|
await tx.tbankConnection.update({
|
||||||
|
where: {
|
||||||
|
userId_revision: {
|
||||||
|
userId: attempt.userId,
|
||||||
|
revision: attempt.revision,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: { invalidToken: true },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// This boundary neither decrypts credentials nor starts discovery/sync.
|
||||||
|
// Missing membership is distinct from a successfully observed empty list.
|
||||||
|
async local(userId: string, accountId?: string) {
|
||||||
const connection = await this.prisma.tbankConnection.findUnique({
|
const connection = await this.prisma.tbankConnection.findUnique({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
revision: true,
|
||||||
|
invalidToken: true,
|
||||||
|
membershipAt: true,
|
||||||
|
membershipRevision: true,
|
||||||
|
accounts: { orderBy: { upstreamOrder: 'asc' } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!connection)
|
if (!connection)
|
||||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||||
return {
|
if (connection.invalidToken)
|
||||||
token: this.tokens.decrypt(connection.encryptedToken),
|
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||||
generation: createHash('sha256')
|
if (
|
||||||
.update(connection.encryptedToken)
|
connection.membershipAt &&
|
||||||
.digest('base64url'),
|
accountId &&
|
||||||
};
|
accountId !== 'all' &&
|
||||||
|
!connection.accounts.some((account) => account.accountId === accountId)
|
||||||
|
)
|
||||||
|
throw new NotFoundException();
|
||||||
|
return connection;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||||
accessibleTbankAccount,
|
|
||||||
supportedTbankAccounts,
|
|
||||||
} from './tbank-account.policy';
|
|
||||||
|
|
||||||
describe('tbank-account-policy', () => {
|
describe('tbank-account-policy', () => {
|
||||||
it('normalizes supported account types and fallback names', () => {
|
it('normalizes supported account types and fallback names', () => {
|
||||||
@@ -45,26 +42,4 @@ describe('tbank-account-policy', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches account-scoped access only for supported account types', () => {
|
|
||||||
expect(
|
|
||||||
accessibleTbankAccount(
|
|
||||||
[
|
|
||||||
{ id: 'account', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
|
|
||||||
{ id: 'other', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Other' },
|
|
||||||
],
|
|
||||||
'account',
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
accessibleTbankAccount(
|
|
||||||
[{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Brokerage' }],
|
|
||||||
'account',
|
|
||||||
),
|
|
||||||
).toEqual({
|
|
||||||
id: 'account',
|
|
||||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
|
||||||
name: 'Brokerage',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,17 +39,6 @@ export function supportedTbankAccounts(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function accessibleTbankAccount(
|
|
||||||
accounts: TbankAccount[],
|
|
||||||
accountId: string,
|
|
||||||
): TbankAccount | null {
|
|
||||||
return (
|
|
||||||
accounts.find(
|
|
||||||
(account) => account.id === accountId && account.type in accountTypes,
|
|
||||||
) ?? null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openedOn(seconds: string | number | undefined): string | null {
|
function openedOn(seconds: string | number | undefined): string | null {
|
||||||
if (seconds === undefined) return null;
|
if (seconds === undefined) return null;
|
||||||
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
|
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
|
import {
|
||||||
|
TbankAccessService,
|
||||||
|
type TbankSyncAttempt,
|
||||||
|
} from './tbank-access.service';
|
||||||
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TbankMembershipService {
|
||||||
|
private readonly processRunId = randomUUID();
|
||||||
|
private readonly active = new Map<string, Promise<void>>();
|
||||||
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
request(userId: string, revision: string, force = false) {
|
||||||
|
const reservation = this.reservations.then(async () => {
|
||||||
|
const requested = await this.access.reserveMembership(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
this.processRunId,
|
||||||
|
new Set(this.active.keys()),
|
||||||
|
force,
|
||||||
|
);
|
||||||
|
if (!requested) return null;
|
||||||
|
const { id, attemptId } = requested.work;
|
||||||
|
if (requested.launch) {
|
||||||
|
const done = new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
|
.then(() => this.execute({ userId, revision, id, attemptId }))
|
||||||
|
.catch(() =>
|
||||||
|
this.logger.event('error', 'tbank.membership.persistence_failed', {
|
||||||
|
userId,
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.finally(() => this.active.delete(attemptId));
|
||||||
|
this.active.set(attemptId, done);
|
||||||
|
}
|
||||||
|
return { id, done: this.active.get(attemptId) ?? Promise.resolve() };
|
||||||
|
});
|
||||||
|
this.reservations = reservation.catch(() => undefined);
|
||||||
|
return reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async execute(attempt: TbankSyncAttempt) {
|
||||||
|
try {
|
||||||
|
const token = await this.access.tokenForAttempt(attempt);
|
||||||
|
if (!token) return;
|
||||||
|
await this.access.publishMembership(
|
||||||
|
attempt,
|
||||||
|
await this.tbank.getOpenAccounts(token),
|
||||||
|
new Date(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await this.access.failMembership(
|
||||||
|
attempt,
|
||||||
|
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
|
tbankSourceTtlMs,
|
||||||
|
} from './tbank-source.policy';
|
||||||
|
|
||||||
|
describe('request-driven source freshness', () => {
|
||||||
|
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
|
||||||
|
'%s expires at its boundary without changing source time',
|
||||||
|
(source) => {
|
||||||
|
const fetchedAt = new Date('2026-09-01Z');
|
||||||
|
const ttl = tbankSourceTtlMs[source];
|
||||||
|
expect(
|
||||||
|
tbankSourceNeedsRefresh(
|
||||||
|
source,
|
||||||
|
fetchedAt,
|
||||||
|
new Date(fetchedAt.valueOf() + ttl - 1000),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
tbankSourceNeedsRefresh(
|
||||||
|
source,
|
||||||
|
fetchedAt,
|
||||||
|
new Date(fetchedAt.valueOf() + ttl),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(tbankSourceNeedsRefresh(source, fetchedAt, fetchedAt, true)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(tbankSourceNeedsRefresh(source, null, fetchedAt)).toBe(true);
|
||||||
|
expect(fetchedAt.toISOString()).toBe('2026-09-01T00:00:00.000Z');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Freshness never establishes completeness. Immutable ranges are planned by
|
||||||
|
// their coverage owners; these TTLs apply only to mutable source snapshots.
|
||||||
|
export const tbankSourceTtlMs = {
|
||||||
|
membership: 300_000,
|
||||||
|
portfolio: 60_000,
|
||||||
|
quote: 60_000,
|
||||||
|
candleTail: 60_000,
|
||||||
|
operationsTail: 300_000,
|
||||||
|
metadata: 86_400_000,
|
||||||
|
catalog: 86_400_000,
|
||||||
|
company: 86_400_000,
|
||||||
|
calendar: 86_400_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function tbankSourceNeedsRefresh(
|
||||||
|
source: keyof typeof tbankSourceTtlMs,
|
||||||
|
fetchedAt: Date | null,
|
||||||
|
now: Date,
|
||||||
|
force = false,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
force ||
|
||||||
|
!fetchedAt ||
|
||||||
|
now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,22 +2,36 @@
|
|||||||
export { InstrumentMetadataService } from './instrument-metadata.service';
|
export { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
export {
|
export {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankConnectionContext,
|
type TbankSyncAttempt,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
export {
|
export {
|
||||||
type TbankBond,
|
type TbankBond,
|
||||||
type TbankCandle,
|
type TbankCandle,
|
||||||
|
type TbankCoupon,
|
||||||
type TbankFundamentals,
|
type TbankFundamentals,
|
||||||
|
type TbankLastPrice,
|
||||||
type TbankShare,
|
type TbankShare,
|
||||||
} from './tbank-market.types';
|
} from './tbank-market.types';
|
||||||
export { TbankTokenService } from './tbank-token.service';
|
export { TbankTokenService } from './tbank-token.service';
|
||||||
|
export { supportedTbankAccounts } from './tbank-account.policy';
|
||||||
export {
|
export {
|
||||||
TbankClient,
|
TbankClient,
|
||||||
type TbankDividend,
|
type TbankDividend,
|
||||||
type TbankInstrument,
|
type TbankInstrument,
|
||||||
type TbankMoney,
|
type TbankMoney,
|
||||||
type TbankOperationItem,
|
type TbankOperationItem,
|
||||||
|
type TbankOperationsByCursorResponse,
|
||||||
type TbankPortfolio,
|
type TbankPortfolio,
|
||||||
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';
|
||||||
|
|||||||
@@ -46,11 +46,26 @@ describe('TbankClient operations', () => {
|
|||||||
seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)),
|
seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)),
|
||||||
},
|
},
|
||||||
to: {
|
to: {
|
||||||
seconds: String(Math.floor(Date.parse('2026-09-18T00:00:00Z') / 1000)),
|
seconds: String(Math.floor(Date.parse('2026-09-19T00:00:00Z') / 1000)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(['getDividends', 'getBondCoupons'] as const)(
|
||||||
|
'%s converts a leap-day calendar window to a full half-open UTC day',
|
||||||
|
async (method) => {
|
||||||
|
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||||
|
const call = jest.fn().mockResolvedValue({});
|
||||||
|
(client as unknown as { call: jest.Mock }).call = call;
|
||||||
|
await client[method]('token', 'uid', '2024-02-29', '2024-02-29');
|
||||||
|
expect(call).toHaveBeenCalledWith('token', undefined, method, {
|
||||||
|
instrumentId: 'uid',
|
||||||
|
from: { seconds: String(Date.parse('2024-02-29T00:00:00Z') / 1000) },
|
||||||
|
to: { seconds: String(Date.parse('2024-03-01T00:00:00Z') / 1000) },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('propagates the normalized upstream error', async () => {
|
it('propagates the normalized upstream error', async () => {
|
||||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||||
const error = new ServiceUnavailableException({
|
const error = new ServiceUnavailableException({
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { existsSync, 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 { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
import { Environment } from '../../config/env.validation';
|
import { Environment } from '../../config/env.validation';
|
||||||
@@ -66,6 +66,9 @@ export type TbankPortfolio = {
|
|||||||
};
|
};
|
||||||
export type TbankPositions = { money?: TbankMoney[] };
|
export type TbankPositions = { money?: TbankMoney[] };
|
||||||
export type TbankInstrument = {
|
export type TbankInstrument = {
|
||||||
|
uid?: string;
|
||||||
|
figi?: string;
|
||||||
|
instrumentType?: string;
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
isin?: string;
|
isin?: string;
|
||||||
@@ -80,6 +83,7 @@ export type TbankDividend = {
|
|||||||
};
|
};
|
||||||
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
|
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
|
||||||
export type TbankOperationItem = {
|
export type TbankOperationItem = {
|
||||||
|
state?: string;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -172,13 +176,7 @@ export class TbankClient {
|
|||||||
config: ConfigService<Environment, true>,
|
config: ConfigService<Environment, true>,
|
||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
) {
|
) {
|
||||||
const sourceProtoRoot = join(
|
const protoRoot = join(__dirname, 'proto/contracts');
|
||||||
process.cwd(),
|
|
||||||
'src/integrations/tbank/proto/contracts',
|
|
||||||
);
|
|
||||||
const protoRoot = existsSync(sourceProtoRoot)
|
|
||||||
? sourceProtoRoot
|
|
||||||
: join(process.cwd(), 'dist/integrations/tbank/proto/contracts');
|
|
||||||
const definition = loadSync(
|
const definition = loadSync(
|
||||||
[
|
[
|
||||||
'users.proto',
|
'users.proto',
|
||||||
@@ -239,8 +237,8 @@ export class TbankClient {
|
|||||||
this.logger.info('tbank.client.configured');
|
this.logger.info('tbank.client.configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateToken(token: string): Promise<void> {
|
validateToken(token: string): Promise<TbankAccount[]> {
|
||||||
await this.getOpenAccounts(token);
|
return this.getOpenAccounts(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
getOpenAccounts(token: string): Promise<TbankAccount[]> {
|
getOpenAccounts(token: string): Promise<TbankAccount[]> {
|
||||||
@@ -294,7 +292,7 @@ export class TbankClient {
|
|||||||
{
|
{
|
||||||
instrumentId,
|
instrumentId,
|
||||||
from: { seconds: String(utcSeconds(from)) },
|
from: { seconds: String(utcSeconds(from)) },
|
||||||
to: { seconds: String(utcSeconds(to)) },
|
to: { seconds: String(utcSeconds(to, true)) },
|
||||||
},
|
},
|
||||||
).then((response) => response.dividends ?? []);
|
).then((response) => response.dividends ?? []);
|
||||||
}
|
}
|
||||||
@@ -346,7 +344,7 @@ export class TbankClient {
|
|||||||
{
|
{
|
||||||
instrumentId: uid,
|
instrumentId: uid,
|
||||||
from: { seconds: String(utcSeconds(from)) },
|
from: { seconds: String(utcSeconds(from)) },
|
||||||
to: { seconds: String(utcSeconds(to)) },
|
to: { seconds: String(utcSeconds(to, true)) },
|
||||||
},
|
},
|
||||||
).then((response) => response.events ?? []);
|
).then((response) => response.events ?? []);
|
||||||
}
|
}
|
||||||
@@ -554,6 +552,9 @@ export class TbankClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function utcSeconds(date: string): number {
|
// Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)).
|
||||||
return Math.floor(Date.parse(`${date}T00:00:00Z`) / MILLISECONDS_PER_SECOND);
|
function utcSeconds(date: string, nextDay = false): number {
|
||||||
|
const instant = new Date(`${date}T00:00:00Z`);
|
||||||
|
if (nextDay) instant.setUTCDate(instant.getUTCDate() + 1);
|
||||||
|
return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TbankMembershipService } from './tbank-membership.service';
|
||||||
|
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 { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
@@ -9,12 +11,15 @@ import { TbankClient } from './tbank.client';
|
|||||||
TbankClient,
|
TbankClient,
|
||||||
TbankTokenService,
|
TbankTokenService,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
|
TbankMembershipService,
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
|
InstrumentMetadataRepository,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TbankClient,
|
TbankClient,
|
||||||
TbankTokenService,
|
TbankTokenService,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
|
TbankMembershipService,
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
+17
-11
@@ -1,8 +1,13 @@
|
|||||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
INestApplication,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { API_PREFIX, createOpenApiDocument } from './openapi';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
@@ -21,11 +26,18 @@ export function configureApp(app: INestApplication): void {
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
exposedHeaders: ['X-Request-ID'],
|
exposedHeaders: ['X-Request-ID'],
|
||||||
});
|
});
|
||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix(API_PREFIX);
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
const requestLogging = app.get(RequestLoggingMiddleware);
|
const requestLogging = app.get(RequestLoggingMiddleware);
|
||||||
app.use(requestLogging.use.bind(requestLogging));
|
app.use(requestLogging.use.bind(requestLogging));
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
validationError: { target: false, value: false },
|
||||||
|
exceptionFactory: (message) => new BadRequestException({ message }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
}
|
}
|
||||||
@@ -36,13 +48,7 @@ async function bootstrap() {
|
|||||||
const config = app.get<ConfigService<Environment, true>>(ConfigService);
|
const config = app.get<ConfigService<Environment, true>>(ConfigService);
|
||||||
|
|
||||||
if (shouldExposeSwagger(process.env['NODE_ENV'])) {
|
if (shouldExposeSwagger(process.env['NODE_ENV'])) {
|
||||||
const document = SwaggerModule.createDocument(
|
const document = createOpenApiDocument(app);
|
||||||
app,
|
|
||||||
new DocumentBuilder()
|
|
||||||
.setTitle('WealthLedger API')
|
|
||||||
.setVersion('v1')
|
|
||||||
.build(),
|
|
||||||
);
|
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,8 @@ export class AuthService {
|
|||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||||
const normalizedEmail = this.normalizeEmail(email);
|
|
||||||
const existing = await this.prisma.user.findUnique({
|
const existing = await this.prisma.user.findUnique({
|
||||||
where: { email: normalizedEmail },
|
where: { email },
|
||||||
});
|
});
|
||||||
if (existing)
|
if (existing)
|
||||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||||
@@ -41,7 +40,7 @@ export class AuthService {
|
|||||||
user = await this.prisma.user.create({
|
user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
email: normalizedEmail,
|
email,
|
||||||
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -58,7 +57,7 @@ export class AuthService {
|
|||||||
password: string,
|
password: string,
|
||||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { email: this.normalizeEmail(email) },
|
where: { email },
|
||||||
});
|
});
|
||||||
const valid = await bcrypt.compare(
|
const valid = await bcrypt.compare(
|
||||||
password,
|
password,
|
||||||
@@ -103,10 +102,14 @@ export class AuthService {
|
|||||||
|
|
||||||
async logout(token?: string): Promise<void> {
|
async logout(token?: string): Promise<void> {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
let payload: RefreshPayload;
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||||
algorithms: ['HS256'],
|
algorithms: ['HS256'],
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (payload.type !== 'refresh') return;
|
if (payload.type !== 'refresh') return;
|
||||||
const session = await this.prisma.session.findUnique({
|
const session = await this.prisma.session.findUnique({
|
||||||
where: { id: payload.sid },
|
where: { id: payload.sid },
|
||||||
@@ -121,9 +124,6 @@ export class AuthService {
|
|||||||
data: { revokedAt: new Date() },
|
data: { revokedAt: new Date() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Logout is intentionally idempotent.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toUser(user: User): AuthUser {
|
toUser(user: User): AuthUser {
|
||||||
@@ -187,10 +187,6 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeEmail(email: string): string {
|
|
||||||
return email;
|
|
||||||
}
|
|
||||||
|
|
||||||
private hash(token: string): string {
|
private hash(token: string): string {
|
||||||
return createHash('sha256').update(token).digest('hex');
|
return createHash('sha256').update(token).digest('hex');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
|
|
||||||
|
|
||||||
jest.mock('../application/auth.service', () => ({
|
|
||||||
AuthService: class AuthService {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { AuthController } from './auth.controller';
|
|
||||||
|
|
||||||
describe('AuthController response statuses', () => {
|
|
||||||
it.each(['register', 'login', 'refresh'] as const)(
|
|
||||||
'%s explicitly returns HTTP 200',
|
|
||||||
(handler) => {
|
|
||||||
// Nest stores @HttpCode metadata on the unbound handler function.
|
|
||||||
const handlerTarget = Object.getOwnPropertyDescriptor(
|
|
||||||
AuthController.prototype,
|
|
||||||
handler,
|
|
||||||
)?.value as object;
|
|
||||||
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handlerTarget)).toBe(200);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -87,7 +87,7 @@ export class AuthController {
|
|||||||
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
return this.respond(response, await this.auth.refresh(token));
|
return this.respond(response, await this.auth.refresh(token));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.clearCookie(response);
|
if (error instanceof UnauthorizedException) this.clearCookie(response);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ describe('bank card calculations', () => {
|
|||||||
amountKopecks: 5000n,
|
amountKopecks: 5000n,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
expect(bankSummary('2026-09', operations)).toEqual({
|
expect(
|
||||||
|
bankSummary({ from: '2026-09-01', to: '2026-09-30' }, operations),
|
||||||
|
).toEqual({
|
||||||
expenses: 120n,
|
expenses: 120n,
|
||||||
refunds: 500n,
|
refunds: 500n,
|
||||||
net: -380n,
|
net: -380n,
|
||||||
@@ -125,7 +127,9 @@ describe('bank card calculations', () => {
|
|||||||
{ categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n },
|
{ categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(bankSummary('2026-07', operations)).toEqual({
|
expect(
|
||||||
|
bankSummary({ from: '2026-07-01', to: '2026-07-31' }, operations),
|
||||||
|
).toEqual({
|
||||||
expenses: 0n,
|
expenses: 0n,
|
||||||
refunds: 0n,
|
refunds: 0n,
|
||||||
net: 0n,
|
net: 0n,
|
||||||
@@ -141,7 +145,12 @@ describe('bank card calculations', () => {
|
|||||||
amountKopecks: 2n ** 63n - 1n,
|
amountKopecks: 2n ** 63n - 1n,
|
||||||
};
|
};
|
||||||
const total = 2n * (2n ** 63n - 1n);
|
const total = 2n * (2n ** 63n - 1n);
|
||||||
expect(bankSummary('2026-09', [operation, operation])).toEqual({
|
expect(
|
||||||
|
bankSummary({ from: '2026-09-01', to: '2026-09-30' }, [
|
||||||
|
operation,
|
||||||
|
operation,
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
expenses: total,
|
expenses: total,
|
||||||
refunds: 0n,
|
refunds: 0n,
|
||||||
net: total,
|
net: total,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { BankOperationKind } from './bank-operation-kind';
|
import { BankOperationKind } from './bank-operation-kind';
|
||||||
|
|
||||||
const ISO_MONTH_LENGTH = 7;
|
|
||||||
export function moscowToday(now = new Date()): string {
|
export function moscowToday(now = new Date()): string {
|
||||||
return new Intl.DateTimeFormat('en-CA', {
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
timeZone: 'Europe/Moscow',
|
timeZone: 'Europe/Moscow',
|
||||||
@@ -54,7 +53,7 @@ export function bankBalance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bankSummary(
|
export function bankSummary(
|
||||||
period: string | { from: string; to: string },
|
period: { from: string; to: string },
|
||||||
operations: Array<{
|
operations: Array<{
|
||||||
kind: string;
|
kind: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -70,12 +69,7 @@ export function bankSummary(
|
|||||||
>();
|
>();
|
||||||
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||||
for (const operation of operations) {
|
for (const operation of operations) {
|
||||||
if (
|
if (operation.date < period.from || operation.date > period.to) continue;
|
||||||
typeof period === 'string'
|
|
||||||
? operation.date.slice(0, ISO_MONTH_LENGTH) !== period
|
|
||||||
: operation.date < period.from || operation.date > period.to
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
if (
|
if (
|
||||||
operation.kind !== BankOperationKind.Expense &&
|
operation.kind !== BankOperationKind.Expense &&
|
||||||
operation.kind !== BankOperationKind.Refund
|
operation.kind !== BankOperationKind.Refund
|
||||||
|
|||||||
@@ -1,429 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
jest.mock('../../integrations/tbank/tbank-access.service', () => ({
|
|
||||||
TbankAccessService: class {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import type { TbankConnectionContext } from '../../integrations/tbank/tbank-access.service';
|
|
||||||
import { IncomeAnalyticsService } from './income/income-analytics.service';
|
|
||||||
import { BrokerageOperationsService } from './operations/application/brokerage-operations.service';
|
|
||||||
import { PortfolioService } from './portfolio/application/portfolio.service';
|
|
||||||
import { UpcomingEventsService } from './upcoming-events/upcoming-events.service';
|
|
||||||
|
|
||||||
function setup() {
|
|
||||||
const context = {
|
|
||||||
token: 'token',
|
|
||||||
generation: 'first',
|
|
||||||
accounts: [
|
|
||||||
{ id: 'a', name: 'Основной', openedOn: '2020-01-01' },
|
|
||||||
{ id: 'b', name: 'ИИС', openedOn: '2026-01-01' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const access = {
|
|
||||||
withSupportedAccounts: jest.fn(
|
|
||||||
(_user: string, handler: (value: typeof context) => unknown) =>
|
|
||||||
handler(context),
|
|
||||||
),
|
|
||||||
withAccountForConnection: jest.fn(
|
|
||||||
(
|
|
||||||
_connection: TbankConnectionContext,
|
|
||||||
_id: string,
|
|
||||||
handler: () => unknown,
|
|
||||||
) => handler(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
const tbank = {
|
|
||||||
getOperationsByCursor: jest.fn(),
|
|
||||||
getPortfolio: jest.fn().mockResolvedValue({ positions: [] }),
|
|
||||||
getPositions: jest.fn().mockResolvedValue({ money: [] }),
|
|
||||||
getDividends: jest.fn().mockResolvedValue([]),
|
|
||||||
getInstrumentByFigi: jest.fn().mockResolvedValue({ name: 'Облигация' }),
|
|
||||||
};
|
|
||||||
const metadata = { resolve: jest.fn().mockResolvedValue(null) };
|
|
||||||
const requestContext = { set: jest.fn() };
|
|
||||||
const operations = new BrokerageOperationsService(
|
|
||||||
access as never,
|
|
||||||
tbank as never,
|
|
||||||
requestContext as never,
|
|
||||||
metadata as never,
|
|
||||||
);
|
|
||||||
const portfolio = new PortfolioService(
|
|
||||||
access as never,
|
|
||||||
tbank as never,
|
|
||||||
requestContext as never,
|
|
||||||
metadata as never,
|
|
||||||
);
|
|
||||||
const moex = { bondEvents: jest.fn().mockResolvedValue([]) };
|
|
||||||
const events = new UpcomingEventsService(
|
|
||||||
access as never,
|
|
||||||
tbank as never,
|
|
||||||
moex as never,
|
|
||||||
requestContext as never,
|
|
||||||
{ info: jest.fn(), event: jest.fn() } as never,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
context,
|
|
||||||
access,
|
|
||||||
tbank,
|
|
||||||
operations,
|
|
||||||
portfolio,
|
|
||||||
events,
|
|
||||||
moex,
|
|
||||||
income: new IncomeAnalyticsService(operations),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const operation = (
|
|
||||||
id: string,
|
|
||||||
second: number,
|
|
||||||
type = 'OPERATION_TYPE_COUPON',
|
|
||||||
value = 1,
|
|
||||||
) => ({
|
|
||||||
id,
|
|
||||||
date: { seconds: String(second) },
|
|
||||||
type,
|
|
||||||
payment: { currency: 'RUB', units: value },
|
|
||||||
});
|
|
||||||
const query = { from: '2025-01-01', to: '2026-12-31', limit: 2 };
|
|
||||||
|
|
||||||
describe('All accounts', () => {
|
|
||||||
it('does not truncate instrument history when an account opening date is missing', async () => {
|
|
||||||
const { context, operations, tbank } = setup();
|
|
||||||
Object.assign(context.accounts[0], { openedOn: null });
|
|
||||||
tbank.getOperationsByCursor.mockResolvedValue({ items: [] });
|
|
||||||
await operations.getAll('user', { instrumentUid: 'common' });
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
|
||||||
'token',
|
|
||||||
expect.objectContaining({
|
|
||||||
from: { seconds: '0' },
|
|
||||||
instrumentId: 'common',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters upstream by UID, keeps sold-position history and binds continuations to the instrument', async () => {
|
|
||||||
const { operations, tbank } = setup();
|
|
||||||
tbank.getOperationsByCursor.mockImplementation(
|
|
||||||
(
|
|
||||||
_token: string,
|
|
||||||
request: { instrumentId: string; accountId: string },
|
|
||||||
) => ({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
...operation(
|
|
||||||
request.accountId + '-dividend',
|
|
||||||
9,
|
|
||||||
'OPERATION_TYPE_DIVIDEND',
|
|
||||||
),
|
|
||||||
instrumentUid: request.instrumentId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...operation('wrong-paper', 8, 'OPERATION_TYPE_DIVIDEND'),
|
|
||||||
instrumentUid: 'preferred',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const first = await operations.getAll('user', {
|
|
||||||
instrumentUid: 'common',
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
assert.ok(first.nextCursor);
|
|
||||||
expect(first.items).toHaveLength(1);
|
|
||||||
expect(first.items[0].instrumentUid).toBe('common');
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
|
||||||
'token',
|
|
||||||
expect.objectContaining({
|
|
||||||
instrumentId: 'common',
|
|
||||||
from: { seconds: '1577836800' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
operations.getAll('user', {
|
|
||||||
instrumentUid: 'preferred',
|
|
||||||
limit: 1,
|
|
||||||
cursor: first.nextCursor,
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
const second = await operations.getAll('user', {
|
|
||||||
instrumentUid: 'common',
|
|
||||||
limit: 1,
|
|
||||||
cursor: first.nextCursor,
|
|
||||||
});
|
|
||||||
expect(second.items[0].instrumentUid).toBe('common');
|
|
||||||
expect(second.items[0].id).not.toBe(first.items[0].id);
|
|
||||||
expect(second.hasNext).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => jest.useRealTimers());
|
|
||||||
|
|
||||||
it('merges exact amounts by currency and keeps equal instruments on different accounts', async () => {
|
|
||||||
const { portfolio, tbank } = setup();
|
|
||||||
tbank.getPortfolio.mockResolvedValue({
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
figi: 'SAME',
|
|
||||||
instrumentType: 'share',
|
|
||||||
quantity: { units: 1 },
|
|
||||||
currentPrice: { currency: 'RUB', nano: 100000000 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
tbank.getPositions.mockImplementation((_token: string, id: string) => ({
|
|
||||||
money: [{ currency: id === 'a' ? 'RUB' : 'USD', nano: 200000000 }],
|
|
||||||
}));
|
|
||||||
const result = await portfolio.getAll('user');
|
|
||||||
expect(result.structure.currencies).toMatchObject([
|
|
||||||
{
|
|
||||||
currency: 'RUB',
|
|
||||||
totalValue: '0.4',
|
|
||||||
categories: [
|
|
||||||
{ type: 'STOCK', value: '0.2', sharePercent: '50' },
|
|
||||||
{ type: 'BOND', value: '0' },
|
|
||||||
{ type: 'OTHER', value: '0' },
|
|
||||||
{ type: 'CASH', value: '0.2', sharePercent: '50' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ currency: 'USD', totalValue: '0.2' },
|
|
||||||
]);
|
|
||||||
expect(result.totalsByCurrency).toEqual([
|
|
||||||
{ currency: 'RUB', value: '0.4' },
|
|
||||||
{ currency: 'USD', value: '0.2' },
|
|
||||||
]);
|
|
||||||
expect(result.cashBalances).toEqual([
|
|
||||||
{ currency: 'RUB', value: '0.2' },
|
|
||||||
{ currency: 'USD', value: '0.2' },
|
|
||||||
]);
|
|
||||||
expect(result.positions.map((position) => position.account?.id)).toEqual([
|
|
||||||
'a',
|
|
||||||
'b',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects missing access and a partial portfolio instead of publishing incomplete totals', async () => {
|
|
||||||
const { portfolio, access, tbank } = setup();
|
|
||||||
access.withSupportedAccounts.mockImplementationOnce(() => {
|
|
||||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
|
||||||
});
|
|
||||||
await expect(portfolio.getAll('user')).rejects.toBeInstanceOf(
|
|
||||||
ConflictException,
|
|
||||||
);
|
|
||||||
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
|
||||||
tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException());
|
|
||||||
await expect(portfolio.getAll('user')).rejects.toBeInstanceOf(
|
|
||||||
ServiceUnavailableException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks an aggregate stale and uses its oldest snapshot timestamp', async () => {
|
|
||||||
jest.useFakeTimers().setSystemTime(new Date('2026-09-01T10:00:00Z'));
|
|
||||||
const { portfolio, tbank } = setup();
|
|
||||||
const first = await portfolio.getAll('user');
|
|
||||||
jest.setSystemTime(new Date('2026-09-01T10:01:00Z'));
|
|
||||||
tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException());
|
|
||||||
const result = await portfolio.getAll('user');
|
|
||||||
expect(result.isStale).toBe(true);
|
|
||||||
expect(result.lastSuccessfulAt).toBe(first.lastSuccessfulAt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('paginates interleaved streams without dropping buffered rows, preserving duplicate IDs', async () => {
|
|
||||||
const { operations, tbank } = setup();
|
|
||||||
tbank.getOperationsByCursor.mockImplementation(
|
|
||||||
(
|
|
||||||
_token: string,
|
|
||||||
{ accountId, cursor }: { accountId: string; cursor?: string },
|
|
||||||
) => {
|
|
||||||
if (accountId === 'a')
|
|
||||||
return cursor
|
|
||||||
? { items: [operation('last', 3)] }
|
|
||||||
: {
|
|
||||||
items: [operation('same', 9), operation('a2', 5)],
|
|
||||||
hasNext: true,
|
|
||||||
nextCursor: 'a-next',
|
|
||||||
};
|
|
||||||
return { items: [operation('same', 8), operation('b2', 4)] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const first = await operations.getAll('user', query);
|
|
||||||
assert.ok(first.nextCursor);
|
|
||||||
const second = await operations.getAll('user', {
|
|
||||||
...query,
|
|
||||||
cursor: first.nextCursor,
|
|
||||||
});
|
|
||||||
const repeated = await operations.getAll('user', {
|
|
||||||
...query,
|
|
||||||
cursor: first.nextCursor,
|
|
||||||
});
|
|
||||||
assert.ok(second.nextCursor);
|
|
||||||
const third = await operations.getAll('user', {
|
|
||||||
...query,
|
|
||||||
cursor: second.nextCursor,
|
|
||||||
});
|
|
||||||
expect(first.items.map((item) => [item.id, item.account?.id])).toEqual([
|
|
||||||
['same', 'a'],
|
|
||||||
['same', 'b'],
|
|
||||||
]);
|
|
||||||
expect(second.items.map((item) => item.id)).toEqual(['a2', 'b2']);
|
|
||||||
expect(repeated).toEqual(second);
|
|
||||||
expect(third.items.map((item) => item.id)).toEqual(['last']);
|
|
||||||
expect(third.hasNext).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('continues through empty filtered pages and breaks ties by account', async () => {
|
|
||||||
const { operations, tbank } = setup();
|
|
||||||
tbank.getOperationsByCursor.mockImplementation(
|
|
||||||
(_token: string, { cursor }: { cursor?: string }) =>
|
|
||||||
cursor
|
|
||||||
? { items: [operation('income', 5)] }
|
|
||||||
: {
|
|
||||||
items: [operation('fee', 8, 'OPERATION_TYPE_BROKER_FEE')],
|
|
||||||
hasNext: true,
|
|
||||||
nextCursor: 'next',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const page = await operations.getAll('user', {
|
|
||||||
...query,
|
|
||||||
categories: ['income'],
|
|
||||||
});
|
|
||||||
expect(page.items.map((item) => item.account?.id)).toEqual(['a', 'b']);
|
|
||||||
expect(page.items.every((item) => item.category === 'income')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects damaged cursor chains and partial streams', async () => {
|
|
||||||
const { operations, tbank } = setup();
|
|
||||||
tbank.getOperationsByCursor.mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
hasNext: true,
|
|
||||||
nextCursor: 'loop',
|
|
||||||
});
|
|
||||||
await expect(operations.getAll('user', query)).rejects.toBeInstanceOf(
|
|
||||||
ServiceUnavailableException,
|
|
||||||
);
|
|
||||||
tbank.getOperationsByCursor.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException(),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
operations.getAll('another-user', query),
|
|
||||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('binds continuations to user, connection, accounts and filters', async () => {
|
|
||||||
const { operations, context, tbank } = setup();
|
|
||||||
tbank.getOperationsByCursor.mockResolvedValue({
|
|
||||||
items: [operation('first', 8), operation('last', 4)],
|
|
||||||
});
|
|
||||||
const first = await operations.getAll('user', query);
|
|
||||||
assert.ok(first.nextCursor);
|
|
||||||
const next = { ...query, cursor: first.nextCursor };
|
|
||||||
await expect(operations.getAll('other', next)).rejects.toBeInstanceOf(
|
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
operations.getAll('user', { ...next, categories: ['fee'] }),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
context.generation = 'replaced';
|
|
||||||
await expect(operations.getAll('user', next)).rejects.toBeInstanceOf(
|
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
context.generation = 'first';
|
|
||||||
context.accounts.pop();
|
|
||||||
await expect(operations.getAll('user', next)).rejects.toBeInstanceOf(
|
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collects income across the same cohort and computes comparisons from all receipts', async () => {
|
|
||||||
const { income, tbank } = setup();
|
|
||||||
tbank.getOperationsByCursor.mockImplementation(
|
|
||||||
(
|
|
||||||
_token: string,
|
|
||||||
{ accountId, from }: { accountId: string; from: { seconds: string } },
|
|
||||||
) => {
|
|
||||||
const year = new Date(Number(from.seconds) * 1000).getUTCFullYear();
|
|
||||||
const second =
|
|
||||||
Date.parse(
|
|
||||||
year === 2025 ? '2025-08-15T12:00:00Z' : '2026-08-15T12:00:00Z',
|
|
||||||
) / 1000;
|
|
||||||
return {
|
|
||||||
items:
|
|
||||||
year === 2025 && accountId === 'b'
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
operation(
|
|
||||||
'same',
|
|
||||||
second,
|
|
||||||
'OPERATION_TYPE_COUPON',
|
|
||||||
year === 2025 ? 2 : 3,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const result = await income.analytics(
|
|
||||||
'user',
|
|
||||||
'all',
|
|
||||||
'2026-08-01',
|
|
||||||
'2026-08-31',
|
|
||||||
);
|
|
||||||
expect(result.totalsByCurrency[0].total.value).toBe('6');
|
|
||||||
expect(
|
|
||||||
result.yearComparison?.totalsByCurrency[0].compared.total.value,
|
|
||||||
).toBe('2');
|
|
||||||
expect(
|
|
||||||
result.yearComparison?.totalsByCurrency[0].difference.total.value,
|
|
||||||
).toBe('4');
|
|
||||||
expect(result.entries.map((entry) => entry.account?.id)).toEqual([
|
|
||||||
'a',
|
|
||||||
'b',
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
new Set(result.entries.map((entry) => entry.instrumentKey)).size,
|
|
||||||
).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves separate future events and fails the section when one source fails', async () => {
|
|
||||||
const { events, tbank, moex } = setup();
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
tbank.getPortfolio.mockResolvedValue({
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
figi: 'BOND',
|
|
||||||
ticker: 'BOND',
|
|
||||||
instrumentType: 'bond',
|
|
||||||
quantity: { units: 2 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
moex.bondEvents.mockResolvedValue([
|
|
||||||
{ type: 'coupon', date: today, value: '0.1', currency: 'RUB' },
|
|
||||||
]);
|
|
||||||
const result = await events.getAll('user');
|
|
||||||
expect(result.events.map((event) => event.account?.id)).toEqual(['a', 'b']);
|
|
||||||
expect(result.incomeTotalsByCurrency).toEqual([
|
|
||||||
{ currency: 'RUB', value: '0.4' },
|
|
||||||
]);
|
|
||||||
tbank.getPortfolio
|
|
||||||
.mockResolvedValueOnce({ positions: [] })
|
|
||||||
.mockRejectedValueOnce(new ServiceUnavailableException());
|
|
||||||
await expect(events.getAll('user')).rejects.toBeInstanceOf(
|
|
||||||
ServiceUnavailableException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns valid empty results for an empty account set', async () => {
|
|
||||||
const { context, portfolio, operations, income, events, tbank } = setup();
|
|
||||||
context.accounts = [];
|
|
||||||
expect((await portfolio.getAll('user')).positions).toEqual([]);
|
|
||||||
expect((await operations.getAll('user', query)).hasNext).toBe(false);
|
|
||||||
expect((await income.summary('user', 'all')).totalsByCurrency).toEqual([]);
|
|
||||||
expect((await events.getAll('user')).events).toEqual([]);
|
|
||||||
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
|
|
||||||
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
@@ -44,3 +45,10 @@ export class BrokerageAccountDto {
|
|||||||
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
|
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
|
||||||
portfolioSummary?: PortfolioSummaryDto | null;
|
portfolioSummary?: PortfolioSummaryDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BrokerageAccountsResourceDto {
|
||||||
|
@ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true })
|
||||||
|
declare data: BrokerageAccountDto[] | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||||
|
import { Controller, Get, Req, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { 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 { BrokerageAccountDto } from './brokerage-account.dto';
|
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
|
||||||
@ApiTags('brokerage-accounts')
|
@ApiTags('brokerage-accounts')
|
||||||
@@ -15,8 +16,11 @@ export class BrokerageAccountsController {
|
|||||||
constructor(private readonly accounts: BrokerageAccountsService) {}
|
constructor(private readonly accounts: BrokerageAccountsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ type: BrokerageAccountDto, isArray: true })
|
@ApiOkResponse({ type: BrokerageAccountsResourceDto })
|
||||||
list(@Req() request: Request & { user: AccessPayload }) {
|
list(
|
||||||
return this.accounts.list(request.user.sub);
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
|
) {
|
||||||
|
return this.accounts.list(request.user.sub, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
@@ -5,8 +6,9 @@ import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
|||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TbankModule],
|
imports: [AuthModule, TbankModule, PortfolioModule],
|
||||||
controllers: [BrokerageAccountsController],
|
controllers: [BrokerageAccountsController],
|
||||||
providers: [BrokerageAccountsService],
|
providers: [BrokerageAccountsService],
|
||||||
|
exports: [BrokerageAccountsService],
|
||||||
})
|
})
|
||||||
export class BrokerageAccountsModule {}
|
export class BrokerageAccountsModule {}
|
||||||
|
|||||||
@@ -1,249 +1,70 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
|
|
||||||
jest.mock('../../../prisma/prisma.service', () => ({
|
jest.mock('../../../prisma/prisma.service', () => ({
|
||||||
PrismaService: class PrismaService {},
|
PrismaService: class {},
|
||||||
|
}));
|
||||||
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
|
|
||||||
|
jest.mock('../portfolio/application/portfolio.service', () => ({
|
||||||
|
PortfolioService: class PortfolioService {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
|
const meta = investmentResourceMeta({
|
||||||
import { TbankTokenService } from '../../../integrations/tbank/tbank-token.service';
|
resourceKey: 'membership',
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
dataRevision: '1',
|
||||||
|
availability: 'complete',
|
||||||
|
fetchedAt: new Date(),
|
||||||
|
freshForMs: 300_000,
|
||||||
|
work: null,
|
||||||
|
});
|
||||||
|
|
||||||
describe('BrokerageAccountsService', () => {
|
describe('saved brokerage account projection', () => {
|
||||||
const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
const portfolio = { saved: jest.fn() };
|
||||||
const prisma = { tbankConnection: { findUnique: jest.fn() } };
|
const service = new BrokerageAccountsService(portfolio as never);
|
||||||
const config = { getOrThrow: jest.fn(() => encryptionKey) };
|
|
||||||
const tbank = {
|
|
||||||
getOpenAccounts: jest.fn(),
|
|
||||||
getPortfolio: jest.fn(),
|
|
||||||
getPositions: jest.fn(),
|
|
||||||
};
|
|
||||||
const tokens = new TbankTokenService(config as never);
|
|
||||||
const access = new TbankAccessService(
|
|
||||||
prisma as never,
|
|
||||||
tokens,
|
|
||||||
tbank as never,
|
|
||||||
);
|
|
||||||
const service = new BrokerageAccountsService(access, tbank as never);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
it('distinguishes unknown membership from a confirmed empty list', async () => {
|
||||||
jest.clearAllMocks();
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
tbank.getPortfolio.mockResolvedValue({ positions: [] });
|
membership: { ...meta, availability: 'missing' },
|
||||||
tbank.getPositions.mockResolvedValue({ money: [] });
|
accounts: [],
|
||||||
|
});
|
||||||
|
expect((await service.list('owner', true)).data).toBeNull();
|
||||||
|
portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] });
|
||||||
|
expect((await service.list('owner', true)).data).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not call T-Bank without a connection', async () => {
|
it('keeps exact saved totals and independent missing summaries', async () => {
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue(null);
|
portfolio.saved.mockResolvedValue({
|
||||||
|
membership: meta,
|
||||||
await expect(service.list('user-1')).rejects.toEqual(
|
accounts: [
|
||||||
expect.any(ConflictException),
|
|
||||||
);
|
|
||||||
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes supported accounts and ignores unsupported ones', async () => {
|
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: tokens.encrypt('token'),
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockResolvedValue([
|
|
||||||
{
|
{
|
||||||
id: 'brokerage',
|
id: 'a',
|
||||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
name: 'A',
|
||||||
name: ' Основной ',
|
|
||||||
openedDate: { seconds: '1723507200' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'debit',
|
|
||||||
type: 'ACCOUNT_TYPE_DEBIT',
|
|
||||||
name: 'Дебетовый',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'iis',
|
|
||||||
type: 'ACCOUNT_TYPE_TINKOFF_IIS',
|
|
||||||
name: ' ',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).resolves.toEqual([
|
|
||||||
{
|
|
||||||
id: 'brokerage',
|
|
||||||
type: 'BROKERAGE',
|
type: 'BROKERAGE',
|
||||||
name: 'Основной',
|
|
||||||
openedOn: '2024-08-13',
|
|
||||||
portfolioSummary: {
|
|
||||||
totalsByCurrency: [],
|
|
||||||
stockPositionCount: 0,
|
|
||||||
bondPositionCount: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'iis',
|
|
||||||
type: 'INDIVIDUAL_INVESTMENT',
|
|
||||||
name: 'ИИС',
|
|
||||||
openedOn: null,
|
openedOn: null,
|
||||||
portfolioSummary: {
|
meta,
|
||||||
totalsByCurrency: [],
|
snapshot: {
|
||||||
stockPositionCount: 0,
|
totalsByCurrency: [
|
||||||
bondPositionCount: 0,
|
{ currency: 'RUB', value: '100000000.000000001' },
|
||||||
|
],
|
||||||
|
positions: [{ type: 'STOCK' }, { type: 'BOND' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps upstream order and adds exact summaries without loading metadata', async () => {
|
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: tokens.encrypt('token'),
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockResolvedValue([
|
|
||||||
{ id: 'first', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' },
|
|
||||||
{ id: 'second', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: 'Второй' },
|
|
||||||
]);
|
|
||||||
tbank.getPortfolio
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
positions: [
|
|
||||||
{
|
{
|
||||||
quantity: { units: 2 },
|
id: 'b',
|
||||||
currentPrice: { currency: 'rub', units: 3 },
|
name: 'B',
|
||||||
|
type: 'BROKERAGE',
|
||||||
|
openedOn: null,
|
||||||
|
meta: { ...meta, availability: 'missing' },
|
||||||
|
snapshot: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ positions: [] });
|
|
||||||
tbank.getPositions
|
|
||||||
.mockResolvedValueOnce({ money: [{ currency: 'RUB', nano: 500000000 }] })
|
|
||||||
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 4 }] });
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).resolves.toEqual([
|
|
||||||
{
|
|
||||||
id: 'first',
|
|
||||||
type: 'BROKERAGE',
|
|
||||||
name: 'Первый',
|
|
||||||
openedOn: null,
|
|
||||||
portfolioSummary: {
|
|
||||||
totalsByCurrency: [{ currency: 'RUB', value: '6.5' }],
|
|
||||||
stockPositionCount: 0,
|
|
||||||
bondPositionCount: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'second',
|
|
||||||
type: 'INDIVIDUAL_INVESTMENT',
|
|
||||||
name: 'Второй',
|
|
||||||
openedOn: null,
|
|
||||||
portfolioSummary: {
|
|
||||||
totalsByCurrency: [{ currency: 'USD', value: '4' }],
|
|
||||||
stockPositionCount: 0,
|
|
||||||
bondPositionCount: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(tbank.getPortfolio).toHaveBeenCalledWith('token', 'first');
|
|
||||||
expect(tbank.getPositions).toHaveBeenCalledWith('token', 'second');
|
|
||||||
});
|
});
|
||||||
|
const response = await service.list('owner', true);
|
||||||
it('counts non-zero stock and bond positions even without a valuation', async () => {
|
expect(response.data?.[0].portfolioSummary).toEqual({
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
||||||
encryptedToken: tokens.encrypt('token'),
|
stockPositionCount: 1,
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockResolvedValue([
|
|
||||||
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' },
|
|
||||||
]);
|
|
||||||
tbank.getPortfolio.mockResolvedValue({
|
|
||||||
positions: [
|
|
||||||
{ instrumentType: 'share', quantity: { units: 200 } },
|
|
||||||
{ instrumentType: 'SHARE', quantity: { units: -5 } },
|
|
||||||
{ instrumentType: 'share', quantity: { units: 0, nano: 1 } },
|
|
||||||
{ instrumentType: 'bond', quantity: { units: 80 } },
|
|
||||||
{ instrumentType: 'bond', quantity: { units: 0 } },
|
|
||||||
{ instrumentType: 'share' },
|
|
||||||
{ instrumentType: 'etf', quantity: { units: 10 } },
|
|
||||||
{ instrumentType: 'currency', quantity: { units: 100 } },
|
|
||||||
{ quantity: { units: 1 } },
|
|
||||||
],
|
|
||||||
virtualPositions: [{ instrumentType: 'bond', quantity: { units: 1 } }],
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).resolves.toMatchObject([
|
|
||||||
{
|
|
||||||
portfolioSummary: {
|
|
||||||
totalsByCurrency: [],
|
|
||||||
stockPositionCount: 3,
|
|
||||||
bondPositionCount: 1,
|
bondPositionCount: 1,
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
expect(response.data?.[1].portfolioSummary).toBeNull();
|
||||||
it('localizes an unavailable summary without suppressing another account', async () => {
|
expect(response.meta.availability).toBe('partial');
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: tokens.encrypt('token'),
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockResolvedValue([
|
|
||||||
{ id: 'unavailable', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' },
|
|
||||||
{ id: 'available', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Второй' },
|
|
||||||
]);
|
|
||||||
tbank.getPortfolio
|
|
||||||
.mockRejectedValueOnce(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce({ positions: [] });
|
|
||||||
tbank.getPositions.mockResolvedValue({ money: [] });
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).resolves.toMatchObject([
|
|
||||||
{ id: 'unavailable', portfolioSummary: null },
|
|
||||||
{ id: 'available', portfolioSummary: { totalsByCurrency: [] } },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mask an invalid token when the other summary input is unavailable', async () => {
|
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: tokens.encrypt('token'),
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockResolvedValue([
|
|
||||||
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' },
|
|
||||||
]);
|
|
||||||
tbank.getPortfolio.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
tbank.getPositions.mockRejectedValue(new Error('INVALID_TBANK_TOKEN'));
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).rejects.toEqual(
|
|
||||||
expect.any(BadRequestException),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retains the connection when T-Bank rejects its token', async () => {
|
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: tokens.encrypt('token'),
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockRejectedValue(new Error('INVALID_TBANK_TOKEN'));
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).rejects.toEqual(
|
|
||||||
expect.any(BadRequestException),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns an empty list from T-Bank unchanged', async () => {
|
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: tokens.encrypt('token'),
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockResolvedValue([]);
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).resolves.toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('propagates an unavailable T-Bank response', async () => {
|
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: tokens.encrypt('token'),
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).rejects.toEqual(
|
|
||||||
expect.any(ServiceUnavailableException),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,80 +1,65 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isZero } from '../../../common/decimal';
|
import { createHash } from 'node:crypto';
|
||||||
import {
|
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||||
TbankAccessService,
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
TbankClient,
|
|
||||||
} from '../../../integrations/tbank/tbank.api';
|
|
||||||
import { decimal } from '../../../integrations/tbank/decimal';
|
|
||||||
import { totalsByCurrency } from '../portfolio/portfolio.api';
|
|
||||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerageAccountsService {
|
export class BrokerageAccountsService {
|
||||||
constructor(
|
constructor(private readonly portfolio: PortfolioService) {}
|
||||||
private readonly access: TbankAccessService,
|
|
||||||
private readonly tbank: TbankClient,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async list(userId: string): Promise<BrokerageAccountDto[]> {
|
async list(userId: string, observe = false) {
|
||||||
return this.access.withSupportedAccounts(
|
const saved = await this.portfolio.saved(userId, 'all', observe);
|
||||||
userId,
|
const data: BrokerageAccountDto[] | null =
|
||||||
async ({ token, accounts }) =>
|
saved.membership.availability === 'missing'
|
||||||
Promise.all(
|
? null
|
||||||
accounts.map(async (account) => {
|
: saved.accounts.map((account) => ({
|
||||||
const [portfolioResult, positionsResult] = await Promise.allSettled(
|
id: account.id,
|
||||||
[
|
name: account.name,
|
||||||
this.tbank.getPortfolio(token, account.id),
|
type: account.type as BrokerageAccountDto['type'],
|
||||||
this.tbank.getPositions(token, account.id),
|
openedOn: account.openedOn,
|
||||||
],
|
portfolioSummary: account.snapshot
|
||||||
);
|
? {
|
||||||
if (
|
totalsByCurrency: account.snapshot.totalsByCurrency,
|
||||||
portfolioResult.status === 'rejected' &&
|
stockPositionCount: account.snapshot.positions.filter(
|
||||||
!isTbankUnavailable(portfolioResult.reason)
|
(position) => position.type === 'STOCK',
|
||||||
)
|
).length,
|
||||||
throw portfolioResult.reason;
|
bondPositionCount: account.snapshot.positions.filter(
|
||||||
if (
|
(position) => position.type === 'BOND',
|
||||||
positionsResult.status === 'rejected' &&
|
).length,
|
||||||
!isTbankUnavailable(positionsResult.reason)
|
|
||||||
)
|
|
||||||
throw positionsResult.reason;
|
|
||||||
if (
|
|
||||||
portfolioResult.status === 'rejected' ||
|
|
||||||
positionsResult.status === 'rejected'
|
|
||||||
)
|
|
||||||
return { ...account, portfolioSummary: null };
|
|
||||||
const positions = portfolioResult.value.positions ?? [];
|
|
||||||
let stockPositionCount = 0;
|
|
||||||
let bondPositionCount = 0;
|
|
||||||
for (const position of positions) {
|
|
||||||
if (isZero(decimal(position.quantity ?? {}))) continue;
|
|
||||||
const type = position.instrumentType?.toLowerCase();
|
|
||||||
if (type === 'share') stockPositionCount += 1;
|
|
||||||
if (type === 'bond') bondPositionCount += 1;
|
|
||||||
}
|
}
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
return {
|
return {
|
||||||
...account,
|
data,
|
||||||
portfolioSummary: {
|
meta: investmentResourceMeta({
|
||||||
totalsByCurrency: totalsByCurrency(
|
resourceKey: 'accounts',
|
||||||
positions,
|
dataRevision: createHash('sha256')
|
||||||
positionsResult.value.money ?? [],
|
.update(
|
||||||
),
|
JSON.stringify([
|
||||||
stockPositionCount,
|
saved.membership.dataRevision,
|
||||||
bondPositionCount,
|
saved.accounts.map((account) => [
|
||||||
},
|
account.id,
|
||||||
};
|
account.meta.dataRevision,
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.digest('base64url'),
|
||||||
|
availability:
|
||||||
|
data === null
|
||||||
|
? 'missing'
|
||||||
|
: saved.accounts.some((account) => !account.snapshot)
|
||||||
|
? 'partial'
|
||||||
|
: 'complete',
|
||||||
|
fetchedAt: saved.membership.lastSuccessfulAt
|
||||||
|
? new Date(saved.membership.lastSuccessfulAt)
|
||||||
|
: null,
|
||||||
|
freshForMs: 300_000,
|
||||||
|
work: saved.membership.sync.id
|
||||||
|
? { ...saved.membership.sync, id: saved.membership.sync.id }
|
||||||
|
: null,
|
||||||
|
dependencies: saved.accounts.map((account) => account.meta),
|
||||||
}),
|
}),
|
||||||
),
|
};
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTbankUnavailable(error: unknown): boolean {
|
|
||||||
if (!(error instanceof ServiceUnavailableException)) return false;
|
|
||||||
const response = error.getResponse();
|
|
||||||
return (
|
|
||||||
typeof response === 'object' &&
|
|
||||||
response !== null &&
|
|
||||||
'code' in response &&
|
|
||||||
response.code === 'TBANK_UNAVAILABLE'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import type { Request } from 'express';
|
|||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import {
|
import {
|
||||||
IncomeAnalyticsDto,
|
IncomeAnalyticsResourceDto,
|
||||||
IncomeAnalyticsQueryDto,
|
IncomeAnalyticsQueryDto,
|
||||||
IncomeSummaryDto,
|
IncomeSummaryResourceDto,
|
||||||
} from './income-analytics.dto';
|
} from './income-analytics.dto';
|
||||||
|
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||||
import { IncomeAnalyticsService } from './income-analytics.service';
|
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||||
|
|
||||||
@ApiTags('brokerage-accounts')
|
@ApiTags('brokerage-accounts')
|
||||||
@@ -19,31 +20,49 @@ export class IncomeAnalyticsController {
|
|||||||
constructor(private readonly income: IncomeAnalyticsService) {}
|
constructor(private readonly income: IncomeAnalyticsService) {}
|
||||||
|
|
||||||
@Get('all/income-summary')
|
@Get('all/income-summary')
|
||||||
@ApiOkResponse({ type: IncomeSummaryDto })
|
@ApiOkResponse({ type: IncomeSummaryResourceDto })
|
||||||
allSummary(@Req() request: Request & { user: AccessPayload }) {
|
allSummary(
|
||||||
return this.income.summary(request.user.sub, 'all');
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
|
) {
|
||||||
|
return this.income.summary(
|
||||||
|
request.user.sub,
|
||||||
|
'all',
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('all/income-analytics')
|
@Get('all/income-analytics')
|
||||||
@ApiOkResponse({ type: IncomeAnalyticsDto })
|
@ApiOkResponse({ type: IncomeAnalyticsResourceDto })
|
||||||
allAnalytics(
|
allAnalytics(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Query() query: IncomeAnalyticsQueryDto,
|
@Query() query: IncomeAnalyticsQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.analytics(request.user.sub, 'all', query.from, query.to);
|
return this.income.analytics(
|
||||||
|
request.user.sub,
|
||||||
|
'all',
|
||||||
|
query.from,
|
||||||
|
query.to,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':accountId/income-summary')
|
@Get(':accountId/income-summary')
|
||||||
@ApiOkResponse({ type: IncomeSummaryDto })
|
@ApiOkResponse({ type: IncomeSummaryResourceDto })
|
||||||
summary(
|
summary(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.summary(request.user.sub, accountId);
|
return this.income.summary(
|
||||||
|
request.user.sub,
|
||||||
|
accountId,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':accountId/income-analytics')
|
@Get(':accountId/income-analytics')
|
||||||
@ApiOkResponse({ type: IncomeAnalyticsDto })
|
@ApiOkResponse({ type: IncomeAnalyticsResourceDto })
|
||||||
analytics(
|
analytics(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
@@ -54,6 +73,7 @@ export class IncomeAnalyticsController {
|
|||||||
accountId,
|
accountId,
|
||||||
query.from,
|
query.from,
|
||||||
query.to,
|
query.to,
|
||||||
|
query.sync === 'observe',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,29 @@ import { IsOptional, Matches } from 'class-validator';
|
|||||||
import { MoneyDto } from '../../../common/money.dto';
|
import { MoneyDto } from '../../../common/money.dto';
|
||||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||||
|
|
||||||
|
import {
|
||||||
|
InvestmentDependencyDto,
|
||||||
|
InvestmentReadQueryDto,
|
||||||
|
InvestmentResourceMetaDto,
|
||||||
|
} from '../investment-resource.dto';
|
||||||
|
|
||||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
export class IncomeAnalyticsQueryDto {
|
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
|
||||||
@ApiPropertyOptional({ format: 'date' })
|
@ApiPropertyOptional({
|
||||||
|
format: 'date',
|
||||||
|
description:
|
||||||
|
'Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(dateOnly)
|
@Matches(dateOnly)
|
||||||
from?: string;
|
from?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'date' })
|
@ApiPropertyOptional({
|
||||||
|
format: 'date',
|
||||||
|
description:
|
||||||
|
'Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(dateOnly)
|
@Matches(dateOnly)
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -121,8 +135,8 @@ export class IncomeAnalyticsEntryDto extends BrokerageOperationDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
||||||
@ApiProperty({ type: IncomeComparisonDto })
|
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
|
||||||
declare previous: IncomeComparisonDto;
|
declare previous: IncomeComparisonDto | null;
|
||||||
|
|
||||||
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
|
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
|
||||||
declare yearComparison: IncomeComparisonDto | null;
|
declare yearComparison: IncomeComparisonDto | null;
|
||||||
@@ -139,3 +153,35 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
|||||||
@ApiProperty({ type: IncomeAnalyticsEntryDto, isArray: true })
|
@ApiProperty({ type: IncomeAnalyticsEntryDto, isArray: true })
|
||||||
declare entries: IncomeAnalyticsEntryDto[];
|
declare entries: IncomeAnalyticsEntryDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class IncomeSummaryResourceDto {
|
||||||
|
@ApiProperty({ type: IncomeSummaryDto, nullable: true })
|
||||||
|
declare data: IncomeSummaryDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
const incomeDependencyRoles = ['selected', 'previous', 'year'] as const;
|
||||||
|
|
||||||
|
export class IncomeDependencyDto extends InvestmentDependencyDto {
|
||||||
|
@ApiProperty({ enum: incomeDependencyRoles })
|
||||||
|
declare role: (typeof incomeDependencyRoles)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: IncomeDependencyDto,
|
||||||
|
isArray: true,
|
||||||
|
required: true,
|
||||||
|
description:
|
||||||
|
'Dependencies identified by role, not array order or resourceKey. A missing role is unavailable.',
|
||||||
|
})
|
||||||
|
declare dependencies: IncomeDependencyDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncomeAnalyticsResourceDto {
|
||||||
|
@ApiProperty({ type: IncomeAnalyticsDto, nullable: true })
|
||||||
|
declare data: IncomeAnalyticsDto | null;
|
||||||
|
@ApiProperty({ type: IncomeAnalyticsMetaDto })
|
||||||
|
declare meta: IncomeAnalyticsMetaDto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
jest.mock('../../../prisma/prisma.service', () => ({
|
||||||
|
PrismaService: class {},
|
||||||
|
}));
|
||||||
jest.mock('../operations/application/brokerage-operations.service', () => ({
|
jest.mock('../operations/application/brokerage-operations.service', () => ({
|
||||||
BrokerageOperationsService: class BrokerageOperationsService {},
|
BrokerageOperationsService: class BrokerageOperationsService {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
|
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
|
||||||
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
import { IncomeAnalyticsService } from './income-analytics.service';
|
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||||
|
|
||||||
describe('IncomeAnalyticsService', () => {
|
describe('IncomeAnalyticsService', () => {
|
||||||
@@ -11,6 +16,44 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
|
|
||||||
beforeEach(() => jest.clearAllMocks());
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('labels each comparison dependency independently of its resource key', async () => {
|
||||||
|
seed([]);
|
||||||
|
const response = await service.analytics(
|
||||||
|
'user',
|
||||||
|
'account',
|
||||||
|
'2026-01-01',
|
||||||
|
'2026-01-31',
|
||||||
|
);
|
||||||
|
expect(response.meta.dependencies).toEqual([
|
||||||
|
expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }),
|
||||||
|
expect.objectContaining({ role: 'previous', resourceKey: 'period/1' }),
|
||||||
|
expect.objectContaining({ role: 'year', resourceKey: 'period/2' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels an unavailable leap-day comparison as year', async () => {
|
||||||
|
operations.collectRanges.mockImplementation(
|
||||||
|
(_user: string, _account: string, build: () => unknown[]) =>
|
||||||
|
build().map((_, index) => ({ items: [], meta: completeMeta(index) })),
|
||||||
|
);
|
||||||
|
const response = await service.analytics(
|
||||||
|
'user',
|
||||||
|
'account',
|
||||||
|
'2024-02-29',
|
||||||
|
'2024-02-29',
|
||||||
|
);
|
||||||
|
expect(response.meta.dependencies).toEqual([
|
||||||
|
expect.objectContaining({ role: 'selected' }),
|
||||||
|
expect.objectContaining({ role: 'previous' }),
|
||||||
|
expect.objectContaining({
|
||||||
|
role: 'year',
|
||||||
|
availability: 'missing',
|
||||||
|
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(response.data?.yearComparison).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
function seed(items: BrokerageOperationDto[]) {
|
function seed(items: BrokerageOperationDto[]) {
|
||||||
operations.collectRanges.mockImplementation(
|
operations.collectRanges.mockImplementation(
|
||||||
(
|
(
|
||||||
@@ -19,7 +62,10 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
build: (opened?: Date) => unknown[],
|
build: (opened?: Date) => unknown[],
|
||||||
) => {
|
) => {
|
||||||
build(undefined);
|
build(undefined);
|
||||||
return { pages: [[{ items }], [{ items: [] }], [{ items: [] }]] };
|
return [items, [], []].map((items, index) => ({
|
||||||
|
items,
|
||||||
|
meta: completeMeta(index),
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,13 +103,15 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
};
|
};
|
||||||
seed([payment]);
|
seed([payment]);
|
||||||
|
|
||||||
const result = await service.analytics(
|
const response = await service.analytics(
|
||||||
'user',
|
'user',
|
||||||
'account',
|
'account',
|
||||||
'2026-01-01',
|
'2026-01-01',
|
||||||
'2026-01-31',
|
'2026-01-31',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const result = response.data;
|
||||||
|
assert(result);
|
||||||
expect(result.entries).toEqual([{ ...payment, instrumentKey: key }]);
|
expect(result.entries).toEqual([{ ...payment, instrumentKey: key }]);
|
||||||
expect(result.instruments).toEqual([
|
expect(result.instruments).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -92,13 +140,15 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
];
|
];
|
||||||
seed(selected);
|
seed(selected);
|
||||||
|
|
||||||
const result = await service.analytics(
|
const response = await service.analytics(
|
||||||
'user',
|
'user',
|
||||||
'account',
|
'account',
|
||||||
'2026-01-01',
|
'2026-01-01',
|
||||||
'2026-01-31',
|
'2026-01-31',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const result = response.data;
|
||||||
|
assert(result);
|
||||||
expect(result.entries).toEqual(
|
expect(result.entries).toEqual(
|
||||||
selected.map((item) => ({
|
selected.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -146,37 +196,28 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
build: (opened?: Date) => unknown[],
|
build: (opened?: Date) => unknown[],
|
||||||
) => {
|
) => {
|
||||||
build(undefined);
|
build(undefined);
|
||||||
return {
|
return [
|
||||||
pages: [
|
|
||||||
[
|
[
|
||||||
{
|
|
||||||
items: [
|
|
||||||
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
||||||
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
|
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
|
||||||
entry(
|
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
|
||||||
'transfer',
|
|
||||||
'OPERATION_TYPE_DIVIDEND_TRANSFER',
|
|
||||||
'99',
|
|
||||||
'RUB',
|
|
||||||
),
|
|
||||||
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
|
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
|
||||||
],
|
],
|
||||||
},
|
[],
|
||||||
],
|
[],
|
||||||
[{ items: [] }],
|
].map((items, index) => ({ items, meta: completeMeta(index) }));
|
||||||
[{ items: [] }],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await service.analytics(
|
const response = await service.analytics(
|
||||||
'user',
|
'user',
|
||||||
'account',
|
'account',
|
||||||
'2026-01-01',
|
'2026-01-01',
|
||||||
'2026-01-31',
|
'2026-01-31',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const result = response.data;
|
||||||
|
assert(result);
|
||||||
expect(result.totalsByCurrency).toEqual([
|
expect(result.totalsByCurrency).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
@@ -222,3 +263,14 @@ function entry(
|
|||||||
commission: null,
|
commission: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function completeMeta(index: number) {
|
||||||
|
return investmentResourceMeta({
|
||||||
|
resourceKey: `period/${index}`,
|
||||||
|
dataRevision: '1',
|
||||||
|
availability: 'complete',
|
||||||
|
fetchedAt: new Date('2026-02-01Z'),
|
||||||
|
freshForMs: Infinity,
|
||||||
|
work: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
@@ -6,6 +7,12 @@ import {
|
|||||||
formatDecimal,
|
formatDecimal,
|
||||||
subtract,
|
subtract,
|
||||||
} from '../../../common/decimal';
|
} from '../../../common/decimal';
|
||||||
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
|
import {
|
||||||
|
IncomeAnalyticsResourceDto,
|
||||||
|
IncomeDependencyDto,
|
||||||
|
IncomeSummaryResourceDto,
|
||||||
|
} from './income-analytics.dto';
|
||||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||||
import {
|
import {
|
||||||
BrokerageOperationDto,
|
BrokerageOperationDto,
|
||||||
@@ -13,7 +20,6 @@ import {
|
|||||||
} from '../operations/operations.api';
|
} from '../operations/operations.api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IncomeAnalyticsDto,
|
|
||||||
IncomeAnalyticsEntryDto,
|
IncomeAnalyticsEntryDto,
|
||||||
IncomeComparisonDto,
|
IncomeComparisonDto,
|
||||||
IncomeCurrencySummaryDto,
|
IncomeCurrencySummaryDto,
|
||||||
@@ -35,24 +41,32 @@ type Total = {
|
|||||||
export class IncomeAnalyticsService {
|
export class IncomeAnalyticsService {
|
||||||
constructor(private readonly operations: BrokerageOperationsService) {}
|
constructor(private readonly operations: BrokerageOperationsService) {}
|
||||||
|
|
||||||
async summary(userId: string, accountId: string): Promise<IncomeSummaryDto> {
|
async summary(
|
||||||
let plan: ReturnType<typeof planIncomePeriod> | undefined;
|
userId: string,
|
||||||
const collected = await this.operations.collectRanges(
|
accountId: string,
|
||||||
|
observe = false,
|
||||||
|
): Promise<IncomeSummaryResourceDto> {
|
||||||
|
let plan = planIncomePeriod(undefined, undefined);
|
||||||
|
const [selected] = await this.operations.collectRanges(
|
||||||
userId,
|
userId,
|
||||||
accountId,
|
accountId,
|
||||||
(openedAt) => {
|
(openedAt) => {
|
||||||
const planned = planIncomePeriod(undefined, undefined, openedAt);
|
plan = planIncomePeriod(undefined, undefined, openedAt);
|
||||||
plan = planned;
|
return [plan.selected];
|
||||||
return [planned.selected];
|
|
||||||
},
|
},
|
||||||
|
observe,
|
||||||
);
|
);
|
||||||
if (!plan) throw new Error('Income period was not planned');
|
if (selected.items === null) return { data: null, meta: selected.meta };
|
||||||
return summaryResponse(
|
assert(selected.meta.lastSuccessfulAt);
|
||||||
|
return {
|
||||||
|
data: summaryResponse(
|
||||||
accountId,
|
accountId,
|
||||||
plan.selected,
|
plan.selected,
|
||||||
qualifying(collected.pages[0].flatMap((page) => page.items)),
|
qualifying(selected.items),
|
||||||
new Date().toISOString(),
|
selected.meta.lastSuccessfulAt,
|
||||||
);
|
),
|
||||||
|
meta: selected.meta,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async analytics(
|
async analytics(
|
||||||
@@ -60,52 +74,89 @@ export class IncomeAnalyticsService {
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
from: string | undefined,
|
from: string | undefined,
|
||||||
to: string | undefined,
|
to: string | undefined,
|
||||||
): Promise<IncomeAnalyticsDto> {
|
observe = false,
|
||||||
let plan: ReturnType<typeof planIncomePeriod> | undefined;
|
): Promise<IncomeAnalyticsResourceDto> {
|
||||||
const collected = await this.operations.collectRanges(
|
let plan = planIncomePeriod(from, to);
|
||||||
|
const periods = await this.operations.collectRanges(
|
||||||
userId,
|
userId,
|
||||||
accountId,
|
accountId,
|
||||||
(openedAt) => {
|
(openedAt) => {
|
||||||
const planned = planIncomePeriod(from, to, openedAt);
|
plan = planIncomePeriod(from, to, openedAt);
|
||||||
plan = planned;
|
|
||||||
return [
|
return [
|
||||||
planned.selected,
|
plan.selected,
|
||||||
planned.previous,
|
plan.previous,
|
||||||
...(planned.yearAgo ? [planned.yearAgo] : []),
|
...(plan.yearAgo ? [plan.yearAgo] : []),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
observe,
|
||||||
);
|
);
|
||||||
if (!plan) throw new Error('Income period was not planned');
|
const [current, prior, year] = periods;
|
||||||
const selected = qualifying(
|
// Preserve the legacy order while consumers migrate to explicit roles.
|
||||||
collected.pages[0].flatMap((page) => page.items),
|
const dependencies: IncomeDependencyDto[] = [
|
||||||
).map((entry) => ({
|
{ ...current.meta, role: 'selected' },
|
||||||
|
{ ...prior.meta, role: 'previous' },
|
||||||
|
{
|
||||||
|
...(plan.yearAgo && year
|
||||||
|
? year.meta
|
||||||
|
: investmentResourceMeta({
|
||||||
|
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||||
|
dataRevision: 'unavailable',
|
||||||
|
availability: 'missing',
|
||||||
|
fetchedAt: null,
|
||||||
|
freshForMs: 0,
|
||||||
|
work: null,
|
||||||
|
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||||
|
})),
|
||||||
|
role: 'year',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const states = periods.map((period) => period.meta.sync);
|
||||||
|
const work =
|
||||||
|
states.find((state) => state.state === 'running') ??
|
||||||
|
states.find((state) => state.state === 'failed');
|
||||||
|
const meta = {
|
||||||
|
...investmentResourceMeta({
|
||||||
|
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
||||||
|
dataRevision: current.meta.dataRevision,
|
||||||
|
availability: current.meta.availability,
|
||||||
|
fetchedAt: current.meta.lastSuccessfulAt
|
||||||
|
? new Date(current.meta.lastSuccessfulAt)
|
||||||
|
: null,
|
||||||
|
freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0,
|
||||||
|
work: work?.id ? { ...work, id: work.id } : null,
|
||||||
|
coverage: current.meta.coverage,
|
||||||
|
dependencies,
|
||||||
|
}),
|
||||||
|
dependencies,
|
||||||
|
};
|
||||||
|
if (current.items === null) return { data: null, meta };
|
||||||
|
assert(current.meta.lastSuccessfulAt);
|
||||||
|
const selected = qualifying(current.items).map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`,
|
instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`,
|
||||||
}));
|
}));
|
||||||
const previous = qualifying(
|
return {
|
||||||
collected.pages[1].flatMap((page) => page.items),
|
data: {
|
||||||
);
|
...summaryResponse(
|
||||||
const yearAgo = plan.yearAgo
|
|
||||||
? qualifying(collected.pages[2].flatMap((page) => page.items))
|
|
||||||
: null;
|
|
||||||
const calculatedAt = new Date().toISOString();
|
|
||||||
const base = summaryResponse(
|
|
||||||
accountId,
|
accountId,
|
||||||
plan.selected,
|
plan.selected,
|
||||||
selected,
|
selected,
|
||||||
calculatedAt,
|
current.meta.lastSuccessfulAt,
|
||||||
);
|
),
|
||||||
return {
|
previous:
|
||||||
...base,
|
prior.items === null
|
||||||
previous: comparison(selected, plan.previous, previous),
|
? null
|
||||||
|
: comparison(selected, plan.previous, qualifying(prior.items)),
|
||||||
yearComparison:
|
yearComparison:
|
||||||
plan.yearAgo && yearAgo
|
plan.yearAgo && year?.items !== null && year
|
||||||
? comparison(selected, plan.yearAgo, yearAgo)
|
? comparison(selected, plan.yearAgo, qualifying(year.items))
|
||||||
: null,
|
: null,
|
||||||
bucketGranularity: plan.granularity,
|
bucketGranularity: plan.granularity,
|
||||||
buckets: buckets(plan.selected, plan.granularity, selected),
|
buckets: buckets(plan.selected, plan.granularity, selected),
|
||||||
instruments: instruments(selected),
|
instruments: instruments(selected),
|
||||||
entries: selected,
|
entries: selected,
|
||||||
|
},
|
||||||
|
meta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,4 +53,18 @@ describe('income period planning', () => {
|
|||||||
to: new Date('2026-08-27T21:44:18.510Z'),
|
to: new Date('2026-08-27T21:44:18.510Z'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it.each([
|
||||||
|
['2024-02-29', '2024-02-28T21:00:00.000Z', '2024-02-29T21:00:00.000Z'],
|
||||||
|
['2025-12-31', '2025-12-30T21:00:00.000Z', '2025-12-31T21:00:00.000Z'],
|
||||||
|
])('maps inclusive %s to one full half-open Moscow day', (date, from, to) => {
|
||||||
|
expect(
|
||||||
|
dateOnlyBounds(
|
||||||
|
{ from: date, to: date },
|
||||||
|
new Date('2026-01-01T12:00:00Z'),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
from: new Date(from),
|
||||||
|
to: new Date(to),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { IncomeAnalyticsService } from './income-analytics.service';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { planIncomePeriod } from './income-period';
|
||||||
@@ -8,5 +8,6 @@ import { IncomeAnalyticsService } from './income-analytics.service';
|
|||||||
imports: [AuthModule, OperationsModule],
|
imports: [AuthModule, OperationsModule],
|
||||||
controllers: [IncomeAnalyticsController],
|
controllers: [IncomeAnalyticsController],
|
||||||
providers: [IncomeAnalyticsService],
|
providers: [IncomeAnalyticsService],
|
||||||
|
exports: [IncomeAnalyticsService],
|
||||||
})
|
})
|
||||||
export class IncomeModule {}
|
export class IncomeModule {}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
jest.mock('../infrastructure/candle.repository', () => ({
|
||||||
|
CandleRepository: class {},
|
||||||
|
}));
|
||||||
|
jest.mock('./catalog-enrichment.service', () => ({
|
||||||
|
CatalogEnrichmentService: class {},
|
||||||
|
}));
|
||||||
|
jest.mock('../../../../integrations/tbank/tbank.api', () => ({
|
||||||
|
TbankAccessService: class {},
|
||||||
|
TbankClient: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CandleService } from './candle.service';
|
||||||
|
|
||||||
|
describe('CandleService forced demand', () => {
|
||||||
|
it.each([false, true])(
|
||||||
|
'reconciles the full range through the latest forced timestamp while force=%s is loading',
|
||||||
|
async (initialForce) => {
|
||||||
|
let entered = () => {};
|
||||||
|
let release = () => {};
|
||||||
|
let complete = () => {};
|
||||||
|
const started = new Promise<void>((resolve) => (entered = resolve));
|
||||||
|
const blocked = new Promise<void>((resolve) => (release = resolve));
|
||||||
|
const finished = new Promise<void>((resolve) => (complete = resolve));
|
||||||
|
let completed = 0;
|
||||||
|
const work = { id: 'work', attemptId: 'attempt' };
|
||||||
|
const repository = {
|
||||||
|
reserve: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ work, launch: true })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
work: { id: 'other', attemptId: 'other-attempt' },
|
||||||
|
launch: true,
|
||||||
|
})
|
||||||
|
.mockResolvedValue({ work, launch: false }),
|
||||||
|
read: jest.fn().mockResolvedValue({
|
||||||
|
coverage: [
|
||||||
|
{
|
||||||
|
from: new Date('2026-01-01Z'),
|
||||||
|
to: new Date('2026-02-01Z'),
|
||||||
|
isComplete: true,
|
||||||
|
fetchedAt: new Date('2026-02-01Z'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
stage: jest.fn().mockResolvedValue(true),
|
||||||
|
publish: jest.fn((_attempt, _intent, done = true) => {
|
||||||
|
if (done && ++completed === 2) complete();
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}),
|
||||||
|
fail: jest.fn(),
|
||||||
|
};
|
||||||
|
const sources = {
|
||||||
|
ensureDetail: jest.fn().mockResolvedValue({
|
||||||
|
first_1dayCandleDate: {
|
||||||
|
seconds: String(Date.parse('2026-01-01Z') / 1000),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const upstream = { getCandles: jest.fn().mockResolvedValue([]) };
|
||||||
|
upstream.getCandles.mockImplementationOnce(async () => {
|
||||||
|
entered();
|
||||||
|
await blocked;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const service = new CandleService(
|
||||||
|
repository as never,
|
||||||
|
sources as never,
|
||||||
|
{ tokenForAttempt: jest.fn().mockResolvedValue('token') } as never,
|
||||||
|
upstream as never,
|
||||||
|
{ event: jest.fn() } as never,
|
||||||
|
);
|
||||||
|
const input = {
|
||||||
|
uid: 'uid',
|
||||||
|
instrumentType: 'share' as const,
|
||||||
|
period: 'all' as const,
|
||||||
|
};
|
||||||
|
await service.request(
|
||||||
|
'owner',
|
||||||
|
'revision',
|
||||||
|
input,
|
||||||
|
new Date('2026-02-02T12:00:00Z'),
|
||||||
|
initialForce,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
await started;
|
||||||
|
const latest = new Date('2026-02-02T13:00:00Z');
|
||||||
|
await service.request(
|
||||||
|
'owner',
|
||||||
|
'revision',
|
||||||
|
{ ...input, uid: 'other' },
|
||||||
|
latest,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await service.request('owner', 'revision', input, latest, true, 0),
|
||||||
|
).toBe('work');
|
||||||
|
release();
|
||||||
|
await finished;
|
||||||
|
|
||||||
|
expect(upstream.getCandles).toHaveBeenCalledWith(
|
||||||
|
'token',
|
||||||
|
'uid',
|
||||||
|
new Date('2026-01-01Z'),
|
||||||
|
latest,
|
||||||
|
);
|
||||||
|
expect(repository.fail).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
upstream.getCandles.mock.calls.map((call: unknown[]) => call[1]),
|
||||||
|
).toEqual(['uid', 'uid', 'other']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
import { planInvestmentScreen } from '../../investment-sync-plan';
|
||||||
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||||
|
import { formatDecimal } from '../../../../common/decimal';
|
||||||
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
|
import {
|
||||||
|
TbankAccessService,
|
||||||
|
TbankClient,
|
||||||
|
type TbankShare,
|
||||||
|
type TbankSyncAttempt,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||||
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
|
import {
|
||||||
|
candleGaps,
|
||||||
|
candleWindows,
|
||||||
|
utcDay,
|
||||||
|
type CandleRange,
|
||||||
|
} from '../domain/candle-coverage';
|
||||||
|
import { timestamp } from './instrument-market';
|
||||||
|
|
||||||
|
type CandleInput = {
|
||||||
|
uid: string;
|
||||||
|
instrumentType: 'share' | 'bond';
|
||||||
|
period: 'month' | 'halfYear' | 'year' | 'all';
|
||||||
|
range?: CandleRange;
|
||||||
|
};
|
||||||
|
type Producer = {
|
||||||
|
input: CandleInput;
|
||||||
|
attempt: TbankSyncAttempt;
|
||||||
|
now: Date;
|
||||||
|
force: boolean;
|
||||||
|
priority: number;
|
||||||
|
observers: TbankSyncAttempt[];
|
||||||
|
pass?: {
|
||||||
|
now: Date;
|
||||||
|
force: boolean;
|
||||||
|
ranges: CandleRange[];
|
||||||
|
nextWindow: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const months = { month: 1, halfYear: 6, year: 12 };
|
||||||
|
const tailFreshForMs = 60_000;
|
||||||
|
const prefetchReadBatchSize = 100;
|
||||||
|
function scope(input: CandleInput) {
|
||||||
|
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`;
|
||||||
|
}
|
||||||
|
function rangeFor(
|
||||||
|
input: CandleInput,
|
||||||
|
instrument: TbankShare,
|
||||||
|
now: Date,
|
||||||
|
): CandleRange {
|
||||||
|
if (input.range) return input.range;
|
||||||
|
const from = utcDay(now);
|
||||||
|
const history = timestamp(instrument.first_1dayCandleDate);
|
||||||
|
if (input.period === 'all') {
|
||||||
|
if (!history)
|
||||||
|
throw new ServiceUnavailableException({
|
||||||
|
code:
|
||||||
|
input.instrumentType === 'share'
|
||||||
|
? 'STOCK_HISTORY_START_UNAVAILABLE'
|
||||||
|
: 'BOND_HISTORY_START_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
from.setTime(utcDay(new Date(history)).valueOf());
|
||||||
|
} else {
|
||||||
|
from.setUTCMonth(from.getUTCMonth() - months[input.period]);
|
||||||
|
if (history && Date.parse(history) > from.valueOf())
|
||||||
|
from.setTime(utcDay(new Date(history)).valueOf());
|
||||||
|
}
|
||||||
|
return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) };
|
||||||
|
}
|
||||||
|
@Injectable()
|
||||||
|
export class CandleService {
|
||||||
|
private readonly runId = randomUUID();
|
||||||
|
private readonly active = new Set<string>();
|
||||||
|
private readonly producers = new Map<string, Producer>();
|
||||||
|
private readonly queue: Producer[] = [];
|
||||||
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
private running = false;
|
||||||
|
private readonly prefetches = new Map<string, Promise<void>>();
|
||||||
|
constructor(
|
||||||
|
private readonly repository: CandleRepository,
|
||||||
|
private readonly sources: CatalogEnrichmentService,
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async read(userId: string, input: CandleInput, observe = false) {
|
||||||
|
const { revision } = await this.access.local(userId);
|
||||||
|
const [detail] = await this.sources.readMany(
|
||||||
|
userId,
|
||||||
|
input.instrumentType,
|
||||||
|
[input.uid],
|
||||||
|
true,
|
||||||
|
['detail'],
|
||||||
|
);
|
||||||
|
if (detail.meta.availability === 'complete' && !detail.instrument)
|
||||||
|
throw new NotFoundException();
|
||||||
|
const now = new Date();
|
||||||
|
const range = detail.instrument
|
||||||
|
? rangeFor(input, detail.instrument, now)
|
||||||
|
: null;
|
||||||
|
const saved = range ? await this.repository.read(input.uid, range) : null;
|
||||||
|
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
|
||||||
|
if (!observe && (!saved || gaps.length))
|
||||||
|
await this.request(userId, revision, input, now);
|
||||||
|
const work = await this.repository.work(userId, revision, scope(input));
|
||||||
|
const fetchedAt =
|
||||||
|
saved?.coverage
|
||||||
|
.map((row) => row.fetchedAt)
|
||||||
|
.sort((a, b) => a.valueOf() - b.valueOf())[0] ?? null;
|
||||||
|
const covered = saved?.coverage.length ? true : false;
|
||||||
|
const completeCoverage = (saved?.coverage ?? [])
|
||||||
|
.filter((row) => row.isComplete || row.from >= utcDay(now))
|
||||||
|
.map((row) => ({ ...row, isComplete: true }));
|
||||||
|
const missing = range ? candleGaps(range, completeCoverage, now) : [];
|
||||||
|
const complete = Boolean(saved && range && !missing.length);
|
||||||
|
const publicRange = (value: CandleRange) => ({
|
||||||
|
from: value.from.toISOString(),
|
||||||
|
to: value.to.toISOString(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data:
|
||||||
|
!saved || !covered
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
candles: saved.data,
|
||||||
|
currency:
|
||||||
|
input.instrumentType === 'share'
|
||||||
|
? detail.instrument?.currency?.toUpperCase() || null
|
||||||
|
: null,
|
||||||
|
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
|
||||||
|
observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
|
||||||
|
},
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: scope(input),
|
||||||
|
dataRevision: saved?.dataRevision ?? '0',
|
||||||
|
availability: !covered ? 'missing' : complete ? 'complete' : 'partial',
|
||||||
|
fetchedAt,
|
||||||
|
freshForMs: Number.POSITIVE_INFINITY,
|
||||||
|
work,
|
||||||
|
coverage: {
|
||||||
|
requested: range ? [publicRange(range)] : [],
|
||||||
|
completeRanges: range
|
||||||
|
? completeCoverage.flatMap((row) => {
|
||||||
|
const from = new Date(
|
||||||
|
Math.max(row.from.valueOf(), range.from.valueOf()),
|
||||||
|
);
|
||||||
|
const to = new Date(
|
||||||
|
Math.min(row.to.valueOf(), range.to.valueOf()),
|
||||||
|
);
|
||||||
|
return from < to ? [publicRange({ from, to })] : [];
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
missingRanges: missing.map(publicRange),
|
||||||
|
asOf: fetchedAt?.toISOString() ?? null,
|
||||||
|
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate)
|
||||||
|
? 'known'
|
||||||
|
: 'unknown',
|
||||||
|
historyState: complete
|
||||||
|
? 'complete'
|
||||||
|
: work?.state === 'failed'
|
||||||
|
? 'failed'
|
||||||
|
: work?.state === 'running'
|
||||||
|
? 'loading'
|
||||||
|
: covered
|
||||||
|
? 'partial'
|
||||||
|
: 'missing',
|
||||||
|
},
|
||||||
|
dependencies: [
|
||||||
|
detail.meta,
|
||||||
|
...(saved?.coverage ?? []).map((row) =>
|
||||||
|
investmentResourceMeta({
|
||||||
|
resourceKey: `${scope(input)}/${row.from.toISOString()}/${row.to.toISOString()}`,
|
||||||
|
dataRevision: String(row.dataRevision),
|
||||||
|
availability: 'complete',
|
||||||
|
fetchedAt: row.fetchedAt,
|
||||||
|
freshForMs: row.isComplete
|
||||||
|
? Number.POSITIVE_INFINITY
|
||||||
|
: tailFreshForMs,
|
||||||
|
work: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
prefetch(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
positions: { instrumentUid?: string | null; type: string }[],
|
||||||
|
) {
|
||||||
|
const key = JSON.stringify([
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
positions
|
||||||
|
.map((position) => [position.instrumentUid, position.type])
|
||||||
|
.sort(),
|
||||||
|
]);
|
||||||
|
const existing = this.prefetches.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
const work = this.prefetchPositions(userId, revision, positions).finally(
|
||||||
|
() => this.prefetches.delete(key),
|
||||||
|
);
|
||||||
|
this.prefetches.set(key, work);
|
||||||
|
return work;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prefetchPositions(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
positions: { instrumentUid?: string | null; type: string }[],
|
||||||
|
) {
|
||||||
|
const now = new Date();
|
||||||
|
const unique = new Map(
|
||||||
|
positions
|
||||||
|
.filter(
|
||||||
|
(position) =>
|
||||||
|
position.instrumentUid && ['STOCK', 'BOND'].includes(position.type),
|
||||||
|
)
|
||||||
|
.map((position) => [position.instrumentUid ?? '', position]),
|
||||||
|
);
|
||||||
|
const details: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
|
||||||
|
[];
|
||||||
|
const coverage: Awaited<ReturnType<CandleRepository['coverage']>> = [];
|
||||||
|
for (const instrumentType of ['share', 'bond'] as const) {
|
||||||
|
const uids = [...unique]
|
||||||
|
.filter(
|
||||||
|
([, position]) =>
|
||||||
|
position.type === (instrumentType === 'share' ? 'STOCK' : 'BOND'),
|
||||||
|
)
|
||||||
|
.map(([uid]) => uid);
|
||||||
|
for (
|
||||||
|
let offset = 0;
|
||||||
|
offset < uids.length;
|
||||||
|
offset += prefetchReadBatchSize
|
||||||
|
) {
|
||||||
|
const batch = uids.slice(offset, offset + prefetchReadBatchSize);
|
||||||
|
details.push(
|
||||||
|
...(await this.sources.readMany(userId, instrumentType, batch, true, [
|
||||||
|
'detail',
|
||||||
|
])),
|
||||||
|
);
|
||||||
|
coverage.push(...(await this.repository.coverage(batch)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const byUid = new Map(details.map((detail) => [detail.input.uid, detail]));
|
||||||
|
for (const [uid, position] of unique) {
|
||||||
|
const instrumentType: CandleInput['instrumentType'] =
|
||||||
|
position.type === 'STOCK' ? 'share' : 'bond';
|
||||||
|
const detail = byUid.get(uid);
|
||||||
|
const history = timestamp(detail?.instrument?.first_1dayCandleDate);
|
||||||
|
const plan = planInvestmentScreen(
|
||||||
|
{ screen: 'account' },
|
||||||
|
{
|
||||||
|
membershipKnown: true,
|
||||||
|
accounts: [],
|
||||||
|
instruments: [
|
||||||
|
{
|
||||||
|
uid,
|
||||||
|
type: position.type as 'STOCK' | 'BOND',
|
||||||
|
historyFrom: history ? new Date(history) : null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!detail?.instrument ||
|
||||||
|
plan.sources.some((source) => source.kind === 'candles')
|
||||||
|
) {
|
||||||
|
const input = { uid, instrumentType, period: 'all' as const };
|
||||||
|
const range =
|
||||||
|
detail?.instrument && history
|
||||||
|
? rangeFor(input, detail.instrument, now)
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
!range ||
|
||||||
|
candleGaps(
|
||||||
|
range,
|
||||||
|
coverage.filter((row) => row.instrumentUid === uid),
|
||||||
|
now,
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
await this.request(userId, revision, input, now, false, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
input: CandleInput,
|
||||||
|
now = new Date(),
|
||||||
|
force = false,
|
||||||
|
priority = 1,
|
||||||
|
) {
|
||||||
|
const pending = this.reservations.then(async () => {
|
||||||
|
const reserved = await this.repository.reserve(
|
||||||
|
{ userId, revision },
|
||||||
|
scope(input),
|
||||||
|
{
|
||||||
|
uid: input.uid,
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
ranges: [],
|
||||||
|
period: input.period,
|
||||||
|
now: now.toISOString(),
|
||||||
|
force,
|
||||||
|
},
|
||||||
|
this.runId,
|
||||||
|
this.active,
|
||||||
|
priority,
|
||||||
|
);
|
||||||
|
if (!reserved) return null;
|
||||||
|
const attempt = {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
id: reserved.work.id,
|
||||||
|
attemptId: reserved.work.attemptId,
|
||||||
|
};
|
||||||
|
const producer = this.producers.get(scope(input));
|
||||||
|
if (producer) {
|
||||||
|
producer.force ||= force;
|
||||||
|
producer.priority = Math.min(producer.priority, priority);
|
||||||
|
if (force && now > producer.now) producer.now = now;
|
||||||
|
}
|
||||||
|
if (reserved.launch) {
|
||||||
|
this.active.add(attempt.attemptId);
|
||||||
|
if (producer) producer.observers.push(attempt);
|
||||||
|
else {
|
||||||
|
const next = { input, attempt, now, force, priority, observers: [] };
|
||||||
|
this.producers.set(scope(input), next);
|
||||||
|
this.queue.push(next);
|
||||||
|
}
|
||||||
|
if (!this.running) {
|
||||||
|
this.running = true;
|
||||||
|
setImmediate(() => {
|
||||||
|
void this.drain();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attempt.id;
|
||||||
|
});
|
||||||
|
this.reservations = pending.catch(() => undefined);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(producer: Producer) {
|
||||||
|
const { input, attempt } = producer;
|
||||||
|
const instrument = await this.sources.ensureDetail(
|
||||||
|
attempt.userId,
|
||||||
|
attempt.revision,
|
||||||
|
input.instrumentType,
|
||||||
|
input.uid,
|
||||||
|
);
|
||||||
|
if (!instrument) throw new NotFoundException();
|
||||||
|
let pass = producer.pass;
|
||||||
|
if (!pass || (producer.force && (!pass.force || producer.now > pass.now))) {
|
||||||
|
const { now, force } = producer;
|
||||||
|
const range = rangeFor(input, instrument, now);
|
||||||
|
const saved = await this.repository.read(input.uid, range);
|
||||||
|
pass = producer.pass = {
|
||||||
|
now,
|
||||||
|
force,
|
||||||
|
ranges: (force ? [range] : candleGaps(range, saved.coverage, now))
|
||||||
|
.flatMap(candleWindows)
|
||||||
|
.reverse(),
|
||||||
|
nextWindow: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { ranges, now, force } = pass;
|
||||||
|
const index = pass.nextWindow;
|
||||||
|
let window = ranges[index];
|
||||||
|
if (window && !force) {
|
||||||
|
const latest = await this.repository.read(input.uid, window);
|
||||||
|
const gaps = candleGaps(window, latest.coverage, now)
|
||||||
|
.flatMap(candleWindows)
|
||||||
|
.reverse();
|
||||||
|
ranges.splice(index, 1, ...gaps);
|
||||||
|
window = ranges[index];
|
||||||
|
}
|
||||||
|
if (window) {
|
||||||
|
const token = await this.access.tokenForAttempt(attempt);
|
||||||
|
if (!token) return false;
|
||||||
|
const candles = await this.tbank.getCandles(
|
||||||
|
token,
|
||||||
|
input.uid,
|
||||||
|
window.from,
|
||||||
|
new Date(Math.min(window.to.valueOf(), now.valueOf())),
|
||||||
|
);
|
||||||
|
const rows = candles.flatMap((candle) => {
|
||||||
|
const time = timestamp(candle.time);
|
||||||
|
if (!time || !candle.close) throw new Error('Invalid candle response');
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
time,
|
||||||
|
close: formatDecimal(decimal(candle.close)),
|
||||||
|
isComplete: candle.isComplete === true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
if (!(await this.repository.stage(attempt, window, rows, now)))
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
!force &&
|
||||||
|
!(await this.repository.publish(
|
||||||
|
attempt,
|
||||||
|
{ uid: input.uid, ranges: [window] },
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
pass.nextWindow = index + 1;
|
||||||
|
if (pass.nextWindow < ranges.length) return null;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drain() {
|
||||||
|
try {
|
||||||
|
while (this.queue.length) {
|
||||||
|
this.queue.sort((a, b) => a.priority - b.priority);
|
||||||
|
const producer = this.queue.shift();
|
||||||
|
if (!producer) break;
|
||||||
|
let success = false;
|
||||||
|
let code = 'TBANK_UNAVAILABLE';
|
||||||
|
try {
|
||||||
|
const result = await this.load(producer);
|
||||||
|
if (result === null) {
|
||||||
|
this.queue.push(producer);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
success = result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
|
await this.access.rejectMembershipToken(producer.attempt);
|
||||||
|
if (error instanceof ServiceUnavailableException) {
|
||||||
|
const response = error.getResponse();
|
||||||
|
if (
|
||||||
|
typeof response === 'object' &&
|
||||||
|
'code' in response &&
|
||||||
|
typeof response.code === 'string'
|
||||||
|
)
|
||||||
|
code = response.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Seal publication with reservations so a late forced join cannot be
|
||||||
|
// acknowledged after the final demand check but before work completion.
|
||||||
|
const finished = this.reservations.then(async () => {
|
||||||
|
const pass = producer.pass;
|
||||||
|
if (success && pass) {
|
||||||
|
if (producer.force && (!pass.force || producer.now > pass.now))
|
||||||
|
return false;
|
||||||
|
try {
|
||||||
|
success = await this.repository.publish(producer.attempt, {
|
||||||
|
uid: producer.input.uid,
|
||||||
|
ranges: pass.force ? pass.ranges : [],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
success = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!success) await this.repository.fail(producer.attempt, code);
|
||||||
|
this.active.delete(producer.attempt.attemptId);
|
||||||
|
for (const observer of producer.observers) {
|
||||||
|
if (
|
||||||
|
!success ||
|
||||||
|
!(await this.repository.completeObserver(
|
||||||
|
observer,
|
||||||
|
producer.attempt,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
await this.repository.fail(observer, code);
|
||||||
|
this.active.delete(observer.attemptId);
|
||||||
|
}
|
||||||
|
this.producers.delete(scope(producer.input));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
this.reservations = finished.catch(() => undefined);
|
||||||
|
if (!(await finished)) {
|
||||||
|
this.queue.push(producer);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (success && !producer.force && producer.input.period !== 'all')
|
||||||
|
await this.request(
|
||||||
|
producer.attempt.userId,
|
||||||
|
producer.attempt.revision,
|
||||||
|
{ ...producer.input, period: 'all' },
|
||||||
|
producer.now,
|
||||||
|
false,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.logger.event('error', 'candles.persistence-failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.active.clear();
|
||||||
|
this.producers.clear();
|
||||||
|
this.queue.length = 0;
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
|
import {
|
||||||
|
TbankAccessService,
|
||||||
|
TbankClient,
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
|
tbankSourceTtlMs,
|
||||||
|
type TbankSyncAttempt,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
|
import {
|
||||||
|
CatalogDiscoveryRepository,
|
||||||
|
catalogQuery,
|
||||||
|
catalogScope,
|
||||||
|
type CatalogQuery,
|
||||||
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
|
import {
|
||||||
|
catalogSearchLimit,
|
||||||
|
selectCatalogInstruments,
|
||||||
|
} from './catalog-selection';
|
||||||
|
|
||||||
|
const tickerAmbiguityLimit = 2;
|
||||||
|
|
||||||
|
type Producer = {
|
||||||
|
input: CatalogQuery;
|
||||||
|
attempt: TbankSyncAttempt;
|
||||||
|
observers: TbankSyncAttempt[];
|
||||||
|
force: boolean;
|
||||||
|
};
|
||||||
|
@Injectable()
|
||||||
|
export class CatalogDiscoveryService {
|
||||||
|
private readonly runId = randomUUID();
|
||||||
|
private readonly active = new Set<string>();
|
||||||
|
private readonly producers = new Map<string, Producer>();
|
||||||
|
private readonly queue: Producer[] = [];
|
||||||
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
private running = false;
|
||||||
|
private readonly completions = new Map<
|
||||||
|
string,
|
||||||
|
{ workId: string; done: Promise<void>; resolve: () => void }
|
||||||
|
>();
|
||||||
|
constructor(
|
||||||
|
private readonly repository: CatalogDiscoveryRepository,
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
private readonly enrichment: CatalogEnrichmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async read(userId: string, input: CatalogQuery, observe = false) {
|
||||||
|
input = catalogQuery(input);
|
||||||
|
const connection = await this.access.local(userId);
|
||||||
|
const saved = await this.repository.read(input);
|
||||||
|
if (!observe) await this.request(userId, connection.revision, input);
|
||||||
|
return {
|
||||||
|
data: saved.data,
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: catalogScope(input),
|
||||||
|
dataRevision: saved.dataRevision,
|
||||||
|
availability: saved.data === null ? 'missing' : 'complete',
|
||||||
|
fetchedAt: saved.fetchedAt,
|
||||||
|
freshForMs: tbankSourceTtlMs.catalog,
|
||||||
|
work: await this.repository.work(userId, connection.revision, input),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(userId: string, input: CatalogQuery, observe = false) {
|
||||||
|
const discovery = await this.read(userId, input, observe);
|
||||||
|
const sources = discovery.data
|
||||||
|
? await this.enrichment.readMany(
|
||||||
|
userId,
|
||||||
|
input.instrumentType,
|
||||||
|
discovery.data.map((item) => item.uid),
|
||||||
|
observe,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const dependencies = [
|
||||||
|
discovery.meta,
|
||||||
|
...sources.map((source) => source.meta),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
data:
|
||||||
|
discovery.data?.map((short) => ({
|
||||||
|
...short,
|
||||||
|
detail:
|
||||||
|
sources.find(
|
||||||
|
(source) =>
|
||||||
|
source.input.uid === short.uid &&
|
||||||
|
source.input.kind === 'detail',
|
||||||
|
)?.instrument ?? null,
|
||||||
|
price:
|
||||||
|
sources.find(
|
||||||
|
(source) =>
|
||||||
|
source.input.uid === short.uid &&
|
||||||
|
source.input.kind === 'last-price',
|
||||||
|
)?.price ?? null,
|
||||||
|
})) ?? null,
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: `search:${catalogScope(catalogQuery(input))}`,
|
||||||
|
dataRevision: dependencies
|
||||||
|
.map((dependency) => dependency.dataRevision)
|
||||||
|
.join('/'),
|
||||||
|
availability:
|
||||||
|
discovery.data === null
|
||||||
|
? 'missing'
|
||||||
|
: sources.every((source) => source.meta.availability === 'complete')
|
||||||
|
? 'complete'
|
||||||
|
: 'partial',
|
||||||
|
fetchedAt: discovery.meta.lastSuccessfulAt
|
||||||
|
? new Date(discovery.meta.lastSuccessfulAt)
|
||||||
|
: null,
|
||||||
|
freshForMs: tbankSourceTtlMs.catalog,
|
||||||
|
work: discovery.meta.sync.id
|
||||||
|
? {
|
||||||
|
id: discovery.meta.sync.id,
|
||||||
|
state: discovery.meta.sync.state,
|
||||||
|
errorCode: discovery.meta.sync.errorCode,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
dependencies,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(
|
||||||
|
userId: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
uid: string,
|
||||||
|
observe = false,
|
||||||
|
) {
|
||||||
|
const sources = await this.enrichment.readMany(
|
||||||
|
userId,
|
||||||
|
instrumentType,
|
||||||
|
[uid],
|
||||||
|
observe,
|
||||||
|
['detail'],
|
||||||
|
);
|
||||||
|
const source = sources[0];
|
||||||
|
if (source.meta.availability === 'complete' && !source.instrument)
|
||||||
|
throw new NotFoundException();
|
||||||
|
const instrument = source.instrument;
|
||||||
|
let slug = uid;
|
||||||
|
const dependencies = [source.meta];
|
||||||
|
if (
|
||||||
|
instrument?.ticker &&
|
||||||
|
/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(instrument.ticker)
|
||||||
|
) {
|
||||||
|
const alias = await this.read(
|
||||||
|
userId,
|
||||||
|
{ instrumentType, query: instrument.ticker, exactTicker: true },
|
||||||
|
observe,
|
||||||
|
);
|
||||||
|
if (alias.data?.length === 1 && alias.data[0].uid === uid)
|
||||||
|
slug = instrument.ticker.toUpperCase();
|
||||||
|
dependencies.push(alias.meta);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: instrument ? { ...instrument, uid, slug } : null,
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: `${instrumentType}/detail/${uid}`,
|
||||||
|
dataRevision: dependencies
|
||||||
|
.map((dependency) => dependency.dataRevision)
|
||||||
|
.join('/'),
|
||||||
|
availability: source.meta.availability,
|
||||||
|
fetchedAt: source.meta.lastSuccessfulAt
|
||||||
|
? new Date(source.meta.lastSuccessfulAt)
|
||||||
|
: null,
|
||||||
|
freshForMs: tbankSourceTtlMs.metadata,
|
||||||
|
work: source.meta.sync.id
|
||||||
|
? {
|
||||||
|
id: source.meta.sync.id,
|
||||||
|
state: source.meta.sync.state,
|
||||||
|
errorCode: source.meta.sync.errorCode,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
dependencies,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async byTicker(
|
||||||
|
userId: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
ticker: string,
|
||||||
|
observe = false,
|
||||||
|
) {
|
||||||
|
const lookup = await this.read(
|
||||||
|
userId,
|
||||||
|
{ instrumentType, query: ticker, exactTicker: true },
|
||||||
|
observe,
|
||||||
|
);
|
||||||
|
if (lookup.data?.length === 0) throw new NotFoundException();
|
||||||
|
if (lookup.data && lookup.data.length > 1)
|
||||||
|
throw new ConflictException({
|
||||||
|
code:
|
||||||
|
instrumentType === 'share'
|
||||||
|
? 'STOCK_TICKER_AMBIGUOUS'
|
||||||
|
: 'BOND_TICKER_AMBIGUOUS',
|
||||||
|
});
|
||||||
|
const result = lookup.data
|
||||||
|
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
|
||||||
|
: null;
|
||||||
|
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
|
||||||
|
return {
|
||||||
|
data: result?.data
|
||||||
|
? { ...result.data, slug: ticker.trim().toUpperCase() }
|
||||||
|
: null,
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
|
||||||
|
dataRevision: dependencies
|
||||||
|
.map((dependency) => dependency.dataRevision)
|
||||||
|
.join('/'),
|
||||||
|
availability: result?.meta.availability ?? 'missing',
|
||||||
|
fetchedAt: lookup.meta.lastSuccessfulAt
|
||||||
|
? new Date(lookup.meta.lastSuccessfulAt)
|
||||||
|
: null,
|
||||||
|
freshForMs: tbankSourceTtlMs.metadata,
|
||||||
|
work: lookup.meta.sync.id
|
||||||
|
? {
|
||||||
|
id: lookup.meta.sync.id,
|
||||||
|
state: lookup.meta.sync.state,
|
||||||
|
errorCode: lookup.meta.sync.errorCode,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
dependencies,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
request(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
query: CatalogQuery,
|
||||||
|
force = false,
|
||||||
|
) {
|
||||||
|
const input = catalogQuery(query);
|
||||||
|
const reserved = this.reservations.then(async () => {
|
||||||
|
const saved = await this.repository.read(input);
|
||||||
|
if (
|
||||||
|
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const reservation = await this.repository.reserve(
|
||||||
|
{ userId, revision },
|
||||||
|
input,
|
||||||
|
this.runId,
|
||||||
|
this.active,
|
||||||
|
);
|
||||||
|
if (!reservation) return null;
|
||||||
|
const { id, attemptId } = reservation.work;
|
||||||
|
if (!reservation.launch) {
|
||||||
|
const current = this.producers.get(catalogScope(input));
|
||||||
|
if (current) current.force ||= force;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
const attempt = { userId, revision, id, attemptId };
|
||||||
|
this.active.add(attemptId);
|
||||||
|
let resolve = () => {};
|
||||||
|
const done = new Promise<void>((finish) => {
|
||||||
|
resolve = finish;
|
||||||
|
});
|
||||||
|
this.completions.set(attemptId, { workId: id, done, resolve });
|
||||||
|
const key = catalogScope(input);
|
||||||
|
const producer = this.producers.get(key);
|
||||||
|
if (producer) {
|
||||||
|
producer.observers.push(attempt);
|
||||||
|
producer.force ||= force;
|
||||||
|
} else {
|
||||||
|
const queued = { input, attempt, observers: [], force };
|
||||||
|
this.producers.set(key, queued);
|
||||||
|
this.queue.push(queued);
|
||||||
|
}
|
||||||
|
if (!this.running) {
|
||||||
|
this.running = true;
|
||||||
|
setImmediate(() => {
|
||||||
|
void this.drain();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
this.reservations = reserved.catch(() => undefined);
|
||||||
|
return reserved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(userId: string, revision: string, query: CatalogQuery) {
|
||||||
|
const id = await this.request(userId, revision, query, true);
|
||||||
|
if (id)
|
||||||
|
await Promise.all(
|
||||||
|
[...this.completions.values()]
|
||||||
|
.filter((entry) => entry.workId === id)
|
||||||
|
.map((entry) => entry.done),
|
||||||
|
);
|
||||||
|
const saved = await this.repository.read(catalogQuery(query));
|
||||||
|
const sources = await this.enrichment.readMany(
|
||||||
|
userId,
|
||||||
|
query.instrumentType,
|
||||||
|
saved.data?.map((item) => item.uid) ?? [],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
...(id ? [id] : []),
|
||||||
|
...sources.flatMap((source) =>
|
||||||
|
source.meta.sync.id ? [source.meta.sync.id] : [],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drain() {
|
||||||
|
try {
|
||||||
|
while (this.queue.length) {
|
||||||
|
const producer = this.queue.shift();
|
||||||
|
if (!producer) break;
|
||||||
|
const { input, attempt } = producer;
|
||||||
|
let success = false;
|
||||||
|
try {
|
||||||
|
const token = await this.access.tokenForAttempt(attempt);
|
||||||
|
if (token) {
|
||||||
|
const found =
|
||||||
|
input.instrumentType === 'share'
|
||||||
|
? await this.tbank.findShares(token, input.query)
|
||||||
|
: await this.tbank.findBonds(token, input.query);
|
||||||
|
success = await this.repository.publish(
|
||||||
|
attempt,
|
||||||
|
input,
|
||||||
|
selectCatalogInstruments(
|
||||||
|
input.exactTicker
|
||||||
|
? found.filter(
|
||||||
|
(item) =>
|
||||||
|
item.ticker?.trim().toUpperCase() === input.query,
|
||||||
|
)
|
||||||
|
: found,
|
||||||
|
input.query,
|
||||||
|
input.instrumentType,
|
||||||
|
).slice(
|
||||||
|
0,
|
||||||
|
input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit,
|
||||||
|
),
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
|
await this.access.rejectMembershipToken(attempt);
|
||||||
|
else
|
||||||
|
this.logger.event('warn', 'catalog-discovery.failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
const saved = await this.repository.read(input);
|
||||||
|
for (const observer of [attempt, ...producer.observers])
|
||||||
|
if (!input.exactTicker || saved.data?.length === 1)
|
||||||
|
await this.enrichment.requestMany(
|
||||||
|
observer.userId,
|
||||||
|
observer.revision,
|
||||||
|
input.instrumentType,
|
||||||
|
saved.data?.map((item) => item.uid) ?? [],
|
||||||
|
producer.force,
|
||||||
|
input.exactTicker ? ['detail'] : ['last-price', 'detail'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.producers.delete(catalogScope(input));
|
||||||
|
for (const observer of [attempt, ...producer.observers]) {
|
||||||
|
await this.repository.finish(observer, success);
|
||||||
|
this.active.delete(observer.attemptId);
|
||||||
|
this.completions.get(observer.attemptId)?.resolve();
|
||||||
|
this.completions.delete(observer.attemptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
for (const completion of this.completions.values()) completion.resolve();
|
||||||
|
this.completions.clear();
|
||||||
|
this.active.clear();
|
||||||
|
this.producers.clear();
|
||||||
|
this.queue.length = 0;
|
||||||
|
this.logger.event('error', 'catalog-discovery.persistence-failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
import type { MoexBondEvent } from '../../../../integrations/moex/moex.api';
|
||||||
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
|
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||||
|
import {
|
||||||
|
decimalFromString,
|
||||||
|
divide,
|
||||||
|
formatDecimal,
|
||||||
|
} from '../../../../common/decimal';
|
||||||
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
|
import {
|
||||||
|
MILLISECONDS_PER_DAY,
|
||||||
|
MILLISECONDS_PER_SECOND,
|
||||||
|
} from '../../../../common/time';
|
||||||
|
import { MoexClient } from '../../../../integrations/moex/moex.api';
|
||||||
|
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||||
|
import { previousClose, timestamp } from './instrument-market';
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
|
import {
|
||||||
|
TbankAccessService,
|
||||||
|
TbankClient,
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
|
tbankSourceTtlMs,
|
||||||
|
type TbankBond,
|
||||||
|
type TbankCandle,
|
||||||
|
type TbankCoupon,
|
||||||
|
type TbankDividend,
|
||||||
|
type TbankFundamentals,
|
||||||
|
type TbankLastPrice,
|
||||||
|
type TbankShare,
|
||||||
|
type TbankSyncAttempt,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
|
import {
|
||||||
|
CatalogDiscoveryRepository,
|
||||||
|
catalogScope,
|
||||||
|
type CatalogSource,
|
||||||
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
|
import { catalogSearchLimit } from './catalog-selection';
|
||||||
|
|
||||||
|
type Producer = {
|
||||||
|
input: CatalogSource;
|
||||||
|
attempt: TbankSyncAttempt;
|
||||||
|
observers: { attempt: TbankSyncAttempt; input: CatalogSource }[];
|
||||||
|
};
|
||||||
|
@Injectable()
|
||||||
|
export class CatalogEnrichmentService {
|
||||||
|
private readonly runId = randomUUID();
|
||||||
|
private readonly active = new Set<string>();
|
||||||
|
private readonly producers = new Map<string, Producer>();
|
||||||
|
private readonly queue: Producer[] = [];
|
||||||
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
private running = false;
|
||||||
|
private readonly completions = new Map<
|
||||||
|
string,
|
||||||
|
{ workId: string; done: Promise<void>; resolve: () => void }
|
||||||
|
>();
|
||||||
|
|
||||||
|
async ensureDetail(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
uid: string,
|
||||||
|
) {
|
||||||
|
const ids = await this.requestMany(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
instrumentType,
|
||||||
|
[uid],
|
||||||
|
false,
|
||||||
|
['detail'],
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
[...this.completions.values()]
|
||||||
|
.filter((entry) => ids.includes(entry.workId))
|
||||||
|
.map((entry) => entry.done),
|
||||||
|
);
|
||||||
|
const rows = await this.repository.readSources([
|
||||||
|
{ instrumentType, uid, kind: 'detail' },
|
||||||
|
]);
|
||||||
|
if (!rows.length) throw new Error('Instrument dependency missing');
|
||||||
|
return (rows[0].payload as CatalogPayload).instrument ?? null;
|
||||||
|
}
|
||||||
|
constructor(
|
||||||
|
private readonly repository: CatalogDiscoveryRepository,
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
private readonly moex: MoexClient,
|
||||||
|
private readonly ratings: NcrRatingsClient,
|
||||||
|
private readonly candles: CandleRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async readMany(
|
||||||
|
userId: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
uids: string[],
|
||||||
|
observe: boolean,
|
||||||
|
kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
|
||||||
|
range?: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
ticker?: string;
|
||||||
|
tickers?: Record<string, string>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { revision } = await this.access.local(userId);
|
||||||
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
|
const rows = inputs.length ? await this.repository.readSources(inputs) : [];
|
||||||
|
if (!observe)
|
||||||
|
await this.requestMany(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
instrumentType,
|
||||||
|
uids,
|
||||||
|
false,
|
||||||
|
kinds,
|
||||||
|
range,
|
||||||
|
);
|
||||||
|
const work = inputs.length
|
||||||
|
? await this.repository.workMany(userId, revision, inputs)
|
||||||
|
: [];
|
||||||
|
const byScope = new Map(work.map((row) => [row.scopeKey, row]));
|
||||||
|
return inputs.map((input) => {
|
||||||
|
const row = rows.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.instrumentUid === input.uid && entry.kind === input.kind,
|
||||||
|
);
|
||||||
|
const payload = row?.payload as CatalogPayload | undefined;
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
version: row
|
||||||
|
? {
|
||||||
|
instrumentUid: row.instrumentUid,
|
||||||
|
source: row.source,
|
||||||
|
kind: row.kind,
|
||||||
|
rangeKey: row.rangeKey,
|
||||||
|
dataRevision: row.dataRevision,
|
||||||
|
fetchedAt: row.fetchedAt,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
payload,
|
||||||
|
instrument: payload?.instrument ?? null,
|
||||||
|
price: payload?.price ?? null,
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: catalogScope(input),
|
||||||
|
dataRevision: String(row?.dataRevision ?? 0),
|
||||||
|
availability: row ? 'complete' : 'missing',
|
||||||
|
fetchedAt: row?.fetchedAt ?? null,
|
||||||
|
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)],
|
||||||
|
work: byScope.get(catalogScope(input)) ?? null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMany(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
uids: string[],
|
||||||
|
force = false,
|
||||||
|
kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
|
||||||
|
range?: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
ticker?: string;
|
||||||
|
tickers?: Record<string, string>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
|
const reservation = this.reservations.then(async () => {
|
||||||
|
const rows = inputs.length
|
||||||
|
? await this.repository.readSources(inputs)
|
||||||
|
: [];
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const input of inputs) {
|
||||||
|
const row = rows.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.instrumentUid === input.uid && entry.kind === input.kind,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!tbankSourceNeedsRefresh(
|
||||||
|
sourcePolicy(input.kind),
|
||||||
|
row?.fetchedAt ?? null,
|
||||||
|
new Date(),
|
||||||
|
force,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const reserved = await this.repository.reserve(
|
||||||
|
{ userId, revision },
|
||||||
|
input,
|
||||||
|
this.runId,
|
||||||
|
this.active,
|
||||||
|
);
|
||||||
|
if (!reserved) continue;
|
||||||
|
const { id, attemptId } = reserved.work;
|
||||||
|
ids.push(id);
|
||||||
|
if (!reserved.launch) continue;
|
||||||
|
const attempt = { userId, revision, id, attemptId };
|
||||||
|
this.active.add(attemptId);
|
||||||
|
let resolve = () => {};
|
||||||
|
const done = new Promise<void>((finish) => {
|
||||||
|
resolve = finish;
|
||||||
|
});
|
||||||
|
this.completions.set(attemptId, { workId: id, done, resolve });
|
||||||
|
const key = producerKey(input);
|
||||||
|
const producer = this.producers.get(key);
|
||||||
|
if (producer) producer.observers.push({ attempt, input });
|
||||||
|
else {
|
||||||
|
const queued = { input, attempt, observers: [] };
|
||||||
|
this.producers.set(key, queued);
|
||||||
|
this.queue.push(queued);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.queue.length && !this.running) {
|
||||||
|
this.running = true;
|
||||||
|
setImmediate(() => {
|
||||||
|
void this.drain();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
});
|
||||||
|
this.reservations = reservation.catch(() => undefined);
|
||||||
|
return reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async section(
|
||||||
|
userId: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
uid: string,
|
||||||
|
section: 'quote' | 'company' | 'metrics',
|
||||||
|
observe = false,
|
||||||
|
) {
|
||||||
|
const kinds: CatalogSource['kind'][] =
|
||||||
|
section === 'company'
|
||||||
|
? ['detail', 'description', 'fundamentals']
|
||||||
|
: [
|
||||||
|
...(instrumentType === 'bond'
|
||||||
|
? ['bond-current' as const]
|
||||||
|
: ['detail' as const]),
|
||||||
|
'last-price',
|
||||||
|
'trading-status',
|
||||||
|
'comparison',
|
||||||
|
...(section === 'metrics'
|
||||||
|
? ['coupons' as const, 'yield' as const, 'rating' as const]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
const sources = await this.readMany(
|
||||||
|
userId,
|
||||||
|
instrumentType,
|
||||||
|
[uid],
|
||||||
|
observe,
|
||||||
|
kinds,
|
||||||
|
);
|
||||||
|
const first = sources[0];
|
||||||
|
if (first.meta.availability === 'complete' && !first.instrument)
|
||||||
|
throw new NotFoundException();
|
||||||
|
const values = sources.reduce<CatalogPayload>(
|
||||||
|
(result, source) => ({ ...result, ...source.payload }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const dependencies = sources.map((source) => source.meta);
|
||||||
|
const complete = dependencies.every(
|
||||||
|
(dependency) => dependency.availability === 'complete',
|
||||||
|
);
|
||||||
|
const meta = investmentResourceMeta({
|
||||||
|
resourceKey: `${instrumentType}/${section}/${uid}`,
|
||||||
|
dataRevision: dependencies
|
||||||
|
.map((dependency) => dependency.dataRevision)
|
||||||
|
.join('/'),
|
||||||
|
availability: !first.instrument
|
||||||
|
? 'missing'
|
||||||
|
: complete
|
||||||
|
? 'complete'
|
||||||
|
: 'partial',
|
||||||
|
fetchedAt: first.meta.lastSuccessfulAt
|
||||||
|
? new Date(first.meta.lastSuccessfulAt)
|
||||||
|
: null,
|
||||||
|
freshForMs:
|
||||||
|
section === 'company'
|
||||||
|
? tbankSourceTtlMs.company
|
||||||
|
: tbankSourceTtlMs.quote,
|
||||||
|
work: null,
|
||||||
|
dependencies,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
meta,
|
||||||
|
unavailable: sources
|
||||||
|
.filter(
|
||||||
|
(source) =>
|
||||||
|
source.meta.availability !== 'complete' ||
|
||||||
|
source.meta.sync.state === 'failed',
|
||||||
|
)
|
||||||
|
.map((source) => source.input.kind),
|
||||||
|
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(
|
||||||
|
input: CatalogSource,
|
||||||
|
token: string,
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
): Promise<CatalogPayload> {
|
||||||
|
if (input.kind === 'detail' || input.kind === 'bond-current') {
|
||||||
|
const instrument =
|
||||||
|
input.instrumentType === 'share'
|
||||||
|
? await this.tbank.getShare(token, input.uid)
|
||||||
|
: await this.tbank.getBond(token, input.uid);
|
||||||
|
if (instrument && instrument.uid !== input.uid)
|
||||||
|
throw new Error('Catalog UID mismatch');
|
||||||
|
if (instrument === undefined) throw new Error('Missing catalog response');
|
||||||
|
return { instrument: instrument ? publicInstrument(instrument) : null };
|
||||||
|
}
|
||||||
|
if (input.kind === 'trading-status')
|
||||||
|
return {
|
||||||
|
tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
|
||||||
|
};
|
||||||
|
if (input.kind === 'comparison') {
|
||||||
|
const prices = await this.repository.readSources([
|
||||||
|
{ ...input, kind: 'last-price', rangeKey: undefined },
|
||||||
|
]);
|
||||||
|
if (!prices.length) throw new Error('Price dependency missing');
|
||||||
|
const price = (prices[0].payload as CatalogPayload).price;
|
||||||
|
const priceAt = timestamp(price?.time);
|
||||||
|
if (!priceAt) return { candle: null };
|
||||||
|
const now = new Date();
|
||||||
|
const range = {
|
||||||
|
from: utcDay(new Date(Date.parse(priceAt) - previousCloseLookbackMs)),
|
||||||
|
to: new Date(priceAt),
|
||||||
|
};
|
||||||
|
const saved = await this.candles.read(input.uid, range);
|
||||||
|
const gaps = candleGaps(range, saved.coverage, now);
|
||||||
|
for (const gap of gaps) {
|
||||||
|
const response = await this.tbank.getCandles(
|
||||||
|
token,
|
||||||
|
input.uid,
|
||||||
|
gap.from,
|
||||||
|
gap.to,
|
||||||
|
);
|
||||||
|
const rows = response.map((candle) => {
|
||||||
|
const time = timestamp(candle.time);
|
||||||
|
if (!time || !candle.close)
|
||||||
|
throw new Error('Invalid candle response');
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
close: formatDecimal(decimal(candle.close)),
|
||||||
|
isComplete: candle.isComplete === true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!(await this.candles.stage(attempt, gap, rows, now)))
|
||||||
|
throw new Error('Cancelled candle comparison');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
gaps.length &&
|
||||||
|
!(await this.candles.publish(
|
||||||
|
attempt,
|
||||||
|
{ uid: input.uid, ranges: gaps },
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
throw new Error('Cancelled candle comparison');
|
||||||
|
const values = (await this.candles.read(input.uid, range)).data.map(
|
||||||
|
(row) => {
|
||||||
|
const value = divide(
|
||||||
|
decimalFromString(row.close),
|
||||||
|
decimalFromString('1'),
|
||||||
|
quotationScale,
|
||||||
|
).value;
|
||||||
|
return {
|
||||||
|
time: {
|
||||||
|
seconds: String(Date.parse(row.time) / MILLISECONDS_PER_SECOND),
|
||||||
|
},
|
||||||
|
close: {
|
||||||
|
units: String(value / quotationUnits),
|
||||||
|
nano: Number(value % quotationUnits),
|
||||||
|
},
|
||||||
|
isComplete: row.isComplete,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { candle: previousClose(values, priceAt) };
|
||||||
|
}
|
||||||
|
const details = await this.repository.readSources([
|
||||||
|
{ ...input, kind: 'detail', rangeKey: undefined },
|
||||||
|
...(input.instrumentType === 'bond'
|
||||||
|
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
const instrument = details
|
||||||
|
.map((row) => (row.payload as CatalogPayload).instrument)
|
||||||
|
.find(Boolean);
|
||||||
|
if (
|
||||||
|
!instrument &&
|
||||||
|
!['dividends', 'coupons', 'bond-events'].includes(input.kind)
|
||||||
|
)
|
||||||
|
throw new Error('Instrument dependency missing');
|
||||||
|
if (input.kind === 'description')
|
||||||
|
return {
|
||||||
|
description: instrument?.assetUid
|
||||||
|
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
if (input.kind === 'fundamentals')
|
||||||
|
return {
|
||||||
|
fundamentals: instrument?.assetUid
|
||||||
|
? await this.tbank.getFundamentals(token, instrument?.assetUid)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
if (input.kind === 'coupons' || input.kind === 'dividends') {
|
||||||
|
const year = Number(input.rangeKey);
|
||||||
|
const [from, to] = input.rangeKey?.startsWith('[')
|
||||||
|
? (JSON.parse(input.rangeKey) as [string, string])
|
||||||
|
: [
|
||||||
|
`${year - payoutHistoryYears}-01-01`,
|
||||||
|
`${year + payoutFutureYears}-01-01`,
|
||||||
|
];
|
||||||
|
return input.kind === 'coupons'
|
||||||
|
? {
|
||||||
|
coupons: await this.tbank.getBondCoupons(
|
||||||
|
token,
|
||||||
|
input.uid,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
dividends: await this.tbank.getDividends(
|
||||||
|
token,
|
||||||
|
input.uid,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (input.kind === 'bond-events') {
|
||||||
|
const ticker = instrument?.ticker || input.ticker;
|
||||||
|
if (!ticker) throw new Error('Bond ticker missing');
|
||||||
|
return { bondEvents: await this.moex.bondEvents(ticker) };
|
||||||
|
}
|
||||||
|
if (input.kind === 'yield')
|
||||||
|
return {
|
||||||
|
effectiveYield:
|
||||||
|
instrument?.ticker && instrument.isin && instrument.classCode
|
||||||
|
? await this.moex.bondYield(
|
||||||
|
instrument.ticker,
|
||||||
|
instrument.isin,
|
||||||
|
instrument.classCode,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
if (input.kind === 'rating')
|
||||||
|
return {
|
||||||
|
creditRating: instrument?.isin
|
||||||
|
? await this.ratings.rating(instrument.isin)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
throw new Error('Unsupported catalog source');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drain() {
|
||||||
|
try {
|
||||||
|
while (this.queue.length) {
|
||||||
|
const producer = this.queue.shift();
|
||||||
|
if (!producer) break;
|
||||||
|
const batch = [producer];
|
||||||
|
// One batch RPC for queued prices sharing the same authorized producer.
|
||||||
|
if (producer.input.kind === 'last-price') {
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < this.queue.length && batch.length < catalogSearchLimit;
|
||||||
|
) {
|
||||||
|
const next = this.queue[i];
|
||||||
|
if (
|
||||||
|
next.input.kind === 'last-price' &&
|
||||||
|
next.attempt.userId === producer.attempt.userId &&
|
||||||
|
next.attempt.revision === producer.attempt.revision
|
||||||
|
) {
|
||||||
|
batch.push(next);
|
||||||
|
this.queue.splice(i, 1);
|
||||||
|
} else i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let prices: TbankLastPrice[] | undefined;
|
||||||
|
let payload: CatalogPayload | undefined;
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
const token = await this.access.tokenForAttempt(producer.attempt);
|
||||||
|
if (!token) failed = true;
|
||||||
|
else if (producer.input.kind === 'last-price')
|
||||||
|
prices = await this.tbank.getLastPrices(
|
||||||
|
token,
|
||||||
|
batch.map((item) => item.input.uid),
|
||||||
|
);
|
||||||
|
else
|
||||||
|
payload = await this.load(producer.input, token, producer.attempt);
|
||||||
|
} catch (error) {
|
||||||
|
failed = true;
|
||||||
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
|
await this.access.rejectMembershipToken(producer.attempt);
|
||||||
|
else
|
||||||
|
this.logger.event('warn', 'catalog-enrichment.failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const item of batch) {
|
||||||
|
const price =
|
||||||
|
prices?.find((entry) => entry.instrumentUid === item.input.uid) ??
|
||||||
|
null;
|
||||||
|
const value =
|
||||||
|
item.input.kind === 'last-price'
|
||||||
|
? {
|
||||||
|
price: price
|
||||||
|
? {
|
||||||
|
instrumentUid: item.input.uid,
|
||||||
|
price: price.price,
|
||||||
|
time: price.time,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: payload;
|
||||||
|
// Keep accepting observers while publication awaits SQLite.
|
||||||
|
item.observers.unshift({ attempt: item.attempt, input: item.input });
|
||||||
|
for (const observer of item.observers) {
|
||||||
|
const success =
|
||||||
|
!failed &&
|
||||||
|
value !== undefined &&
|
||||||
|
(await this.repository.publishSource(
|
||||||
|
observer.attempt,
|
||||||
|
observer.input,
|
||||||
|
value,
|
||||||
|
new Date(),
|
||||||
|
producer.attempt,
|
||||||
|
));
|
||||||
|
await this.repository.finish(observer.attempt, success);
|
||||||
|
this.active.delete(observer.attempt.attemptId);
|
||||||
|
this.completions.get(observer.attempt.attemptId)?.resolve();
|
||||||
|
this.completions.delete(observer.attempt.attemptId);
|
||||||
|
}
|
||||||
|
this.producers.delete(producerKey(item.input));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
for (const completion of this.completions.values()) completion.resolve();
|
||||||
|
this.completions.clear();
|
||||||
|
this.active.clear();
|
||||||
|
this.producers.clear();
|
||||||
|
this.queue.length = 0;
|
||||||
|
this.logger.event('error', 'catalog-enrichment.persistence-failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function sourceInputs(
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
uids: string[],
|
||||||
|
kinds: CatalogSource['kind'][],
|
||||||
|
range?: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
ticker?: string;
|
||||||
|
tickers?: Record<string, string>;
|
||||||
|
},
|
||||||
|
): CatalogSource[] {
|
||||||
|
return kinds.flatMap((kind) =>
|
||||||
|
[...new Set(uids)].map((uid) => ({
|
||||||
|
instrumentType,
|
||||||
|
uid,
|
||||||
|
kind,
|
||||||
|
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker)
|
||||||
|
? { ticker: range?.tickers?.[uid] || range?.ticker }
|
||||||
|
: {}),
|
||||||
|
...(['coupons', 'dividends'].includes(kind)
|
||||||
|
? {
|
||||||
|
rangeKey: range
|
||||||
|
? JSON.stringify([range.from, range.to])
|
||||||
|
: String(new Date().getUTCFullYear()),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function publicInstrument(value: TbankShare | TbankBond) {
|
||||||
|
const bond = value as TbankBond;
|
||||||
|
return {
|
||||||
|
uid: value.uid,
|
||||||
|
figi: value.figi,
|
||||||
|
assetUid: value.assetUid,
|
||||||
|
name: value.name,
|
||||||
|
ticker: value.ticker,
|
||||||
|
isin: value.isin,
|
||||||
|
classCode: value.classCode,
|
||||||
|
realExchange: value.realExchange,
|
||||||
|
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
|
||||||
|
currency: value.currency,
|
||||||
|
shareType: (value as TbankShare).shareType,
|
||||||
|
sector: value.sector,
|
||||||
|
lot: value.lot,
|
||||||
|
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
|
||||||
|
first_1dayCandleDate: value.first_1dayCandleDate,
|
||||||
|
nominal: bond.nominal,
|
||||||
|
initialNominal: bond.initialNominal,
|
||||||
|
aciValue: bond.aciValue,
|
||||||
|
maturityDate: bond.maturityDate,
|
||||||
|
callDate: bond.callDate,
|
||||||
|
couponQuantityPerYear: bond.couponQuantityPerYear,
|
||||||
|
floatingCouponFlag: bond.floatingCouponFlag,
|
||||||
|
perpetualFlag: bond.perpetualFlag,
|
||||||
|
amortizationFlag: bond.amortizationFlag,
|
||||||
|
subordinatedFlag: bond.subordinatedFlag,
|
||||||
|
bondType: bond.bondType,
|
||||||
|
riskLevel: bond.riskLevel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousCloseLookbackDays = 20;
|
||||||
|
const previousCloseLookbackMs =
|
||||||
|
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
|
||||||
|
const payoutHistoryYears = 5;
|
||||||
|
const payoutFutureYears = 2;
|
||||||
|
export type CatalogPayload = {
|
||||||
|
instrument?: TbankShare | TbankBond | null;
|
||||||
|
price?: TbankLastPrice | null;
|
||||||
|
candle?: TbankCandle | null;
|
||||||
|
tradingStatus?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
fundamentals?: TbankFundamentals | null;
|
||||||
|
coupons?: TbankCoupon[];
|
||||||
|
dividends?: TbankDividend[];
|
||||||
|
bondEvents?: MoexBondEvent[];
|
||||||
|
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
|
||||||
|
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
|
||||||
|
};
|
||||||
|
function sourcePolicy(kind: CatalogSource['kind']) {
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
'last-price',
|
||||||
|
'bond-current',
|
||||||
|
'trading-status',
|
||||||
|
'comparison',
|
||||||
|
'yield',
|
||||||
|
].includes(kind)
|
||||||
|
)
|
||||||
|
return 'quote';
|
||||||
|
if (kind === 'description' || kind === 'fundamentals') return 'company';
|
||||||
|
if (['coupons', 'dividends', 'bond-events'].includes(kind)) return 'calendar';
|
||||||
|
return 'metadata';
|
||||||
|
}
|
||||||
|
function producerKey(input: CatalogSource) {
|
||||||
|
return input.instrumentType === 'bond' &&
|
||||||
|
(input.kind === 'detail' || input.kind === 'bond-current')
|
||||||
|
? `bond-metadata:${input.uid}`
|
||||||
|
: catalogScope(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotationScale = 9;
|
||||||
|
const quotationUnits = 1_000_000_000n;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
export const catalogSearchLimit = 30;
|
||||||
|
|
||||||
|
export function selectCatalogInstruments<T extends TbankShare>(
|
||||||
|
found: T[],
|
||||||
|
query: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
): (T & { uid: string })[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const kind =
|
||||||
|
instrumentType === 'share'
|
||||||
|
? 'INSTRUMENT_TYPE_SHARE'
|
||||||
|
: 'INSTRUMENT_TYPE_BOND';
|
||||||
|
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
|
||||||
|
return found
|
||||||
|
.filter(
|
||||||
|
(instrument): instrument is T & { uid: string } =>
|
||||||
|
Boolean(instrument.uid?.trim()) && instrument.instrumentKind === kind,
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.apiTradeAvailableFlag === true) -
|
||||||
|
Number(a.apiTradeAvailableFlag === true) ||
|
||||||
|
Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
|
||||||
|
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
|
||||||
|
Number(b.classCode === preferredClass) -
|
||||||
|
Number(a.classCode === preferredClass) ||
|
||||||
|
a.uid.localeCompare(b.uid),
|
||||||
|
)
|
||||||
|
.filter((instrument) => {
|
||||||
|
const key = instrument.isin?.trim()
|
||||||
|
? `isin:${instrument.isin.trim().toUpperCase()}`
|
||||||
|
: `uid:${instrument.uid}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
|
||||||
import type {
|
import type {
|
||||||
TbankCandle,
|
TbankCandle,
|
||||||
TbankTimestamp,
|
TbankTimestamp,
|
||||||
@@ -36,16 +35,3 @@ export function previousClose(
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export async function optional<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
name: string,
|
|
||||||
unavailable: string[],
|
|
||||||
): Promise<T | null> {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof ServiceUnavailableException)) throw error;
|
|
||||||
unavailable.push(name);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+20
-11
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
PortfolioDto,
|
PortfolioDto,
|
||||||
PortfolioPositionDto,
|
PortfolioPositionDto,
|
||||||
} from '../../../portfolio/presentation/portfolio.dto';
|
} from '../../portfolio/presentation/portfolio.dto';
|
||||||
import { stockPosition } from './stock-position';
|
import { instrumentPosition } from './instrument-position';
|
||||||
|
|
||||||
const money = (value: string, currency = 'RUB') => ({ value, currency });
|
const money = (value: string, currency = 'RUB') => ({ value, currency });
|
||||||
const row = (
|
const row = (
|
||||||
@@ -34,13 +34,14 @@ const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
|
|||||||
|
|
||||||
describe('stock position', () => {
|
describe('stock position', () => {
|
||||||
it('weights costs exactly and keeps preferred shares separate', () => {
|
it('weights costs exactly and keeps preferred shares separate', () => {
|
||||||
const result = stockPosition(
|
const result = instrumentPosition(
|
||||||
portfolio([
|
portfolio([
|
||||||
row(),
|
row(),
|
||||||
row('3', '20', '70'),
|
row('3', '20', '70'),
|
||||||
row('100', '1', '100', 'preferred'),
|
row('100', '1', '100', 'preferred'),
|
||||||
]),
|
]),
|
||||||
'common',
|
'common',
|
||||||
|
'STOCK',
|
||||||
);
|
);
|
||||||
expect(result.quantity).toBe('5');
|
expect(result.quantity).toBe('5');
|
||||||
expect(result.averagePurchasePrice).toEqual(money('16'));
|
expect(result.averagePurchasePrice).toEqual(money('16'));
|
||||||
@@ -49,14 +50,18 @@ describe('stock position', () => {
|
|||||||
expect(result.returnPercent).toBe('25');
|
expect(result.returnPercent).toBe('25');
|
||||||
expect(result.accounts[0].returnPercent).toBe('50');
|
expect(result.accounts[0].returnPercent).toBe('50');
|
||||||
});
|
});
|
||||||
it('does not round large amounts through floating point', () => {
|
it.each(['9007199254740993', '0.25'])(
|
||||||
const result = stockPosition(
|
'preserves exact quantity %s',
|
||||||
portfolio([row('9007199254740993', '0.1', '900719925474099.3')]),
|
(quantity) => {
|
||||||
|
const result = instrumentPosition(
|
||||||
|
portfolio([row(quantity, '0.1', '900719925474099.3')]),
|
||||||
'common',
|
'common',
|
||||||
|
'STOCK',
|
||||||
);
|
);
|
||||||
expect(result.quantity).toBe('9007199254740993');
|
expect(result.quantity).toBe(quantity);
|
||||||
expect(result.averagePurchasePrice?.value).toBe('0.1');
|
expect(result.averagePurchasePrice?.value).toBe('0.1');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
it('does not sum currencies or treat unknown values as zero', () => {
|
it('does not sum currencies or treat unknown values as zero', () => {
|
||||||
const second = {
|
const second = {
|
||||||
...row(),
|
...row(),
|
||||||
@@ -64,7 +69,11 @@ describe('stock position', () => {
|
|||||||
averagePurchasePrice: null,
|
averagePurchasePrice: null,
|
||||||
absoluteReturn: null,
|
absoluteReturn: null,
|
||||||
};
|
};
|
||||||
const result = stockPosition(portfolio([row(), second]), 'common');
|
const result = instrumentPosition(
|
||||||
|
portfolio([row(), second]),
|
||||||
|
'common',
|
||||||
|
'STOCK',
|
||||||
|
);
|
||||||
expect(result.quantity).toBe('4');
|
expect(result.quantity).toBe('4');
|
||||||
expect(result.currentValue).toBeNull();
|
expect(result.currentValue).toBeNull();
|
||||||
expect(result.averagePurchasePrice).toBeNull();
|
expect(result.averagePurchasePrice).toBeNull();
|
||||||
@@ -72,14 +81,14 @@ describe('stock position', () => {
|
|||||||
expect(result.returnPercent).toBeNull();
|
expect(result.returnPercent).toBeNull();
|
||||||
});
|
});
|
||||||
it('keeps no-position and offsetting long/short holdings honest', () => {
|
it('keeps no-position and offsetting long/short holdings honest', () => {
|
||||||
expect(stockPosition(portfolio([]), 'common')).toMatchObject({
|
expect(instrumentPosition(portfolio([]), 'common', 'STOCK')).toMatchObject({
|
||||||
quantity: '0',
|
quantity: '0',
|
||||||
accounts: [],
|
accounts: [],
|
||||||
currentValue: null,
|
currentValue: null,
|
||||||
returnPercent: null,
|
returnPercent: null,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
stockPosition(portfolio([row(), row('-2')]), 'common')
|
instrumentPosition(portfolio([row(), row('-2')]), 'common', 'STOCK')
|
||||||
.averagePurchasePrice,
|
.averagePurchasePrice,
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -1,205 +1,85 @@
|
|||||||
import {
|
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||||
ConflictException,
|
import { Injectable } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { LRUCache } from 'lru-cache';
|
|
||||||
import { formatDecimal } from '../../../../../common/decimal';
|
import { formatDecimal } from '../../../../../common/decimal';
|
||||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||||
import {
|
|
||||||
TbankAccessService,
|
|
||||||
TbankClient,
|
|
||||||
type TbankConnectionContext,
|
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||||
import { optional, timestamp } from '../../application/instrument-market';
|
import { timestamp } from '../../application/instrument-market';
|
||||||
import { BondDto, BondSearchResultDto } from '../presentation/bond.dto';
|
import {
|
||||||
|
BondDto,
|
||||||
|
BondResourceDto,
|
||||||
|
BondSearchResourceDto,
|
||||||
|
} from '../presentation/bond.dto';
|
||||||
|
|
||||||
type IdentifiedBond = TbankBond & { uid: string };
|
type IdentifiedBond = TbankBond & { uid: string };
|
||||||
|
|
||||||
import { money } from '../infrastructure/bond-money.mapper';
|
import { money } from '../infrastructure/bond-money.mapper';
|
||||||
|
|
||||||
const SEARCH_RESULTS_LIMIT = 30;
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BondCatalogService {
|
export class BondCatalogService {
|
||||||
private readonly cache = new LRUCache<string, Promise<object>>({
|
constructor(private readonly discovery: CatalogDiscoveryService) {}
|
||||||
max: 300,
|
async search(
|
||||||
ttl: 30_000,
|
userId: string,
|
||||||
});
|
query: string,
|
||||||
constructor(
|
observe = false,
|
||||||
private readonly access: TbankAccessService,
|
): Promise<BondSearchResourceDto> {
|
||||||
private readonly tbank: TbankClient,
|
const result = await this.discovery.search(
|
||||||
) {}
|
userId,
|
||||||
search(userId: string, query: string): Promise<BondSearchResultDto[]> {
|
{ instrumentType: 'bond', query },
|
||||||
return this.cached(userId, `search:${query}`, async (connection) => {
|
observe,
|
||||||
const { token } = connection;
|
|
||||||
const found = await this.tbank.findBonds(token, query);
|
|
||||||
const bonds = selectBonds(found, query).slice(0, SEARCH_RESULTS_LIMIT);
|
|
||||||
if (!bonds.length) return [];
|
|
||||||
// Batch prices and share metadata with detail sections. Resolve readable
|
|
||||||
// aliases only when an issue is opened, not for every search result.
|
|
||||||
const [details, prices] = await Promise.all([
|
|
||||||
Promise.all(
|
|
||||||
bonds.map(
|
|
||||||
async (bond) =>
|
|
||||||
(await optional(
|
|
||||||
() =>
|
|
||||||
this.bond(userId, connection, bond.uid)
|
|
||||||
.then(toBond)
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (error instanceof NotFoundException)
|
|
||||||
return toBond(bond);
|
|
||||||
throw error;
|
|
||||||
}),
|
|
||||||
'details',
|
|
||||||
[],
|
|
||||||
)) ?? toBond(bond),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
optional(
|
|
||||||
() =>
|
|
||||||
this.tbank.getLastPrices(
|
|
||||||
token,
|
|
||||||
bonds.map((bond) => bond.uid),
|
|
||||||
),
|
|
||||||
'prices',
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
return details.map((detail, index) => {
|
|
||||||
const short = toBond(bonds[index]);
|
|
||||||
const last = prices?.find(
|
|
||||||
(price) => price.instrumentUid === detail.instrumentUid,
|
|
||||||
);
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data:
|
||||||
|
result.data?.map((item) => {
|
||||||
|
const short = toBond(item);
|
||||||
|
const detail = item.detail
|
||||||
|
? toBond({ ...item.detail, uid: item.uid })
|
||||||
|
: short;
|
||||||
|
const last = item.price;
|
||||||
return {
|
return {
|
||||||
...detail,
|
...detail,
|
||||||
classCode: detail.classCode ?? short.classCode,
|
classCode: detail.classCode ?? short.classCode,
|
||||||
isin: detail.isin ?? short.isin,
|
isin: detail.isin ?? short.isin,
|
||||||
apiTradeAvailable: short.apiTradeAvailable,
|
apiTradeAvailable: short.apiTradeAvailable,
|
||||||
pricePercent: last?.price ? formatDecimal(decimal(last.price)) : null,
|
pricePercent: last?.price
|
||||||
|
? formatDecimal(decimal(last.price))
|
||||||
|
: null,
|
||||||
priceAt: timestamp(last?.time),
|
priceAt: timestamp(last?.time),
|
||||||
};
|
};
|
||||||
});
|
}) ?? null,
|
||||||
});
|
|
||||||
}
|
|
||||||
get(userId: string, uid: string): Promise<BondDto> {
|
|
||||||
return this.cached(userId, `bond:${uid}`, async (connection) => {
|
|
||||||
const result = toBond(await this.bond(userId, connection, uid));
|
|
||||||
if (
|
|
||||||
!result.ticker ||
|
|
||||||
!/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker)
|
|
||||||
)
|
|
||||||
return result;
|
|
||||||
try {
|
|
||||||
const match = await this.tickerMatch(userId, result.ticker);
|
|
||||||
if (match.uid === uid) result.slug = result.ticker.toUpperCase();
|
|
||||||
} catch (error) {
|
|
||||||
// A readable alias is optional for an exact UID URL, never a reason to substitute another listing.
|
|
||||||
if (
|
|
||||||
!(error instanceof NotFoundException) &&
|
|
||||||
!(error instanceof ConflictException) &&
|
|
||||||
!(error instanceof ServiceUnavailableException)
|
|
||||||
)
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async getByTicker(userId: string, ticker: string): Promise<BondDto> {
|
|
||||||
const match = await this.tickerMatch(userId, ticker);
|
|
||||||
return {
|
|
||||||
...(await this.get(userId, match.uid)),
|
|
||||||
slug: ticker.trim().toUpperCase(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
private tickerMatch(userId: string, ticker: string): Promise<IdentifiedBond> {
|
async get(
|
||||||
const normalized = ticker.trim().toUpperCase();
|
|
||||||
return this.cached(userId, `ticker:${normalized}`, async (connection) => {
|
|
||||||
const { token } = connection;
|
|
||||||
const found = await this.tbank.findBonds(token, normalized);
|
|
||||||
const matches = selectBonds(
|
|
||||||
found.filter(
|
|
||||||
(item) => item.ticker?.trim().toUpperCase() === normalized,
|
|
||||||
),
|
|
||||||
normalized,
|
|
||||||
);
|
|
||||||
if (!matches.length) throw new NotFoundException();
|
|
||||||
if (matches.length > 1)
|
|
||||||
throw new ConflictException({ code: 'BOND_TICKER_AMBIGUOUS' });
|
|
||||||
return matches[0];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
bond(
|
|
||||||
userId: string,
|
userId: string,
|
||||||
connection: TbankConnectionContext,
|
|
||||||
uid: string,
|
uid: string,
|
||||||
): Promise<IdentifiedBond> {
|
observe = false,
|
||||||
return this.cachedForConnection(
|
): Promise<BondResourceDto> {
|
||||||
|
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data
|
||||||
|
? { ...toBond(result.data), slug: result.data.slug }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async getByTicker(
|
||||||
|
userId: string,
|
||||||
|
ticker: string,
|
||||||
|
observe = false,
|
||||||
|
): Promise<BondResourceDto> {
|
||||||
|
const result = await this.discovery.byTicker(
|
||||||
userId,
|
userId,
|
||||||
connection,
|
'bond',
|
||||||
`metadata:${uid}`,
|
ticker,
|
||||||
async () => {
|
observe,
|
||||||
const bond = await this.tbank.getBond(connection.token, uid);
|
|
||||||
if (!bond || bond.uid !== uid) throw new NotFoundException();
|
|
||||||
return { ...bond, uid };
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data
|
||||||
|
? { ...toBond(result.data), slug: result.data.slug }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
cached<T extends object>(
|
|
||||||
userId: string,
|
|
||||||
key: string,
|
|
||||||
operation: (connection: TbankConnectionContext) => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
return this.access.withConnection(userId, (connection) =>
|
|
||||||
this.cachedForConnection(userId, connection, key, () =>
|
|
||||||
operation(connection),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
cachedForConnection<T extends object>(
|
|
||||||
userId: string,
|
|
||||||
{ generation }: TbankConnectionContext,
|
|
||||||
key: string,
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
const cacheKey = JSON.stringify([userId, generation, key]);
|
|
||||||
const cached = this.cache.get(cacheKey);
|
|
||||||
if (cached) return cached as Promise<T>;
|
|
||||||
const pending = operation();
|
|
||||||
this.cache.set(cacheKey, pending);
|
|
||||||
return pending.catch((error) => {
|
|
||||||
if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey);
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectBonds(found: TbankBond[], query: string): IdentifiedBond[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return found
|
|
||||||
.filter(
|
|
||||||
(bond): bond is IdentifiedBond =>
|
|
||||||
Boolean(bond.uid) && bond.instrumentKind === 'INSTRUMENT_TYPE_BOND',
|
|
||||||
)
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
Number(b.apiTradeAvailableFlag === true) -
|
|
||||||
Number(a.apiTradeAvailableFlag === true) ||
|
|
||||||
Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
|
|
||||||
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
|
|
||||||
Number(b.classCode === 'TQOB') - Number(a.classCode === 'TQOB') ||
|
|
||||||
a.uid.localeCompare(b.uid),
|
|
||||||
)
|
|
||||||
.filter((bond) => {
|
|
||||||
// A missing ISIN must not collapse unrelated catalog instruments.
|
|
||||||
const key = bond.isin?.trim()
|
|
||||||
? `isin:${bond.isin.trim().toUpperCase()}`
|
|
||||||
: `uid:${bond.uid}`;
|
|
||||||
if (seen.has(key)) return false;
|
|
||||||
seen.add(key);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toBond(bond: IdentifiedBond): BondDto {
|
function toBond(bond: IdentifiedBond): BondDto {
|
||||||
|
|||||||
@@ -1,80 +1,207 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
|
divide,
|
||||||
} from '../../../../../common/decimal';
|
} from '../../../../../common/decimal';
|
||||||
import { MoexClient } from '../../../../../integrations/moex/moex.api';
|
|
||||||
import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr.api';
|
|
||||||
import {
|
import {
|
||||||
TbankClient,
|
TbankAccessService,
|
||||||
type TbankConnectionContext,
|
type TbankCoupon,
|
||||||
|
type TbankBond,
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
} from '../../../../../integrations/tbank/tbank.api';
|
||||||
import { PortfolioService } from '../../../portfolio/portfolio.api';
|
|
||||||
import {
|
import {
|
||||||
moscowDay,
|
PortfolioService,
|
||||||
optional,
|
ForecastService,
|
||||||
timestamp,
|
} from '../../../portfolio/portfolio.api';
|
||||||
} from '../../application/instrument-market';
|
import {
|
||||||
|
CatalogEnrichmentService,
|
||||||
|
type CatalogPayload,
|
||||||
|
} from '../../application/catalog-enrichment.service';
|
||||||
|
import { moscowDay, timestamp } from '../../application/instrument-market';
|
||||||
import { instrumentPosition } from '../../application/instrument-position';
|
import { instrumentPosition } from '../../application/instrument-position';
|
||||||
import {
|
import {
|
||||||
BondCouponsDto,
|
BondCouponsDto,
|
||||||
BondMetricsDto,
|
BondCouponsResourceDto,
|
||||||
BondPositionDto,
|
BondPositionDto,
|
||||||
|
BondMetricsDto,
|
||||||
|
BondMetricsResourceDto,
|
||||||
} from '../presentation/bond.dto';
|
} from '../presentation/bond.dto';
|
||||||
|
|
||||||
import { BondMarketService } from './bond-market.service';
|
|
||||||
import { money } from '../infrastructure/bond-money.mapper';
|
import { money } from '../infrastructure/bond-money.mapper';
|
||||||
import { BondCatalogService } from './bond-catalog.service';
|
import { bondQuote } from './bond-market.service';
|
||||||
|
|
||||||
const ISO_YEAR_LENGTH = 4;
|
const ISO_YEAR_LENGTH = 4;
|
||||||
const INCOME_HISTORY_YEARS = 5;
|
const INCOME_HISTORY_YEARS = 5;
|
||||||
const COUPON_YIELD_DECIMAL_PLACES = 4;
|
const COUPON_YIELD_DECIMAL_PLACES = 4;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BondIncomeService {
|
export class BondIncomeService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly catalog: BondCatalogService,
|
|
||||||
private readonly tbank: TbankClient,
|
|
||||||
private readonly portfolios: PortfolioService,
|
private readonly portfolios: PortfolioService,
|
||||||
private readonly market: BondMarketService,
|
private readonly sources: CatalogEnrichmentService,
|
||||||
private readonly moex: MoexClient,
|
private readonly access: TbankAccessService,
|
||||||
private readonly ratings: NcrRatingsClient,
|
private readonly forecasts: ForecastService,
|
||||||
) {}
|
) {}
|
||||||
metrics(userId: string, uid: string): Promise<BondMetricsDto> {
|
async metrics(
|
||||||
return this.catalog.cached(userId, `metrics:${uid}`, async (connection) => {
|
userId: string,
|
||||||
const instrument = await this.catalog.bond(userId, connection, uid);
|
uid: string,
|
||||||
const { ticker, isin, classCode } = instrument;
|
observe = false,
|
||||||
|
): Promise<BondMetricsResourceDto> {
|
||||||
|
const result = await this.sources.section(
|
||||||
|
userId,
|
||||||
|
'bond',
|
||||||
|
uid,
|
||||||
|
'metrics',
|
||||||
|
observe,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: result.values.instrument
|
||||||
|
? bondMetrics(result.values, result.unavailable, result.observedAt)
|
||||||
|
: null,
|
||||||
|
meta: result.meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async readPosition(userId: string, uid: string, observe = false) {
|
||||||
|
const saved = await this.portfolios.read(userId, 'all', observe);
|
||||||
|
return {
|
||||||
|
data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null,
|
||||||
|
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async coupons(
|
||||||
|
userId: string,
|
||||||
|
uid: string,
|
||||||
|
observe = false,
|
||||||
|
): Promise<BondCouponsResourceDto> {
|
||||||
|
const connection = await this.access.local(userId);
|
||||||
const today = moscowDay(new Date().toISOString());
|
const today = moscowDay(new Date().toISOString());
|
||||||
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||||
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||||
const unavailable: string[] = [];
|
const portfolio = await this.portfolios.read(userId, 'all', observe);
|
||||||
const [events, quote, effectiveYield, creditRating] = await Promise.all([
|
const sources = await this.sources.readMany(
|
||||||
optional(
|
userId,
|
||||||
() => this.couponEvents(userId, connection, uid, from, to),
|
'bond',
|
||||||
'coupons',
|
[uid],
|
||||||
unavailable,
|
observe,
|
||||||
),
|
['detail', 'coupons'],
|
||||||
optional(() => this.market.quote(userId, uid), 'quote', unavailable),
|
{ from, to },
|
||||||
ticker && isin && classCode
|
);
|
||||||
? optional(
|
if (sources[0].meta.availability === 'complete' && !sources[0].instrument)
|
||||||
() => this.moex.bondYield(ticker, isin, classCode),
|
throw new NotFoundException();
|
||||||
'yield',
|
const events = sources[1].payload?.coupons;
|
||||||
unavailable,
|
const position = portfolio.data
|
||||||
|
? instrumentPosition(portfolio.data, uid, 'BOND')
|
||||||
|
: null;
|
||||||
|
const projected = events
|
||||||
|
? bondCoupons(
|
||||||
|
events,
|
||||||
|
position,
|
||||||
|
{ from, to, today },
|
||||||
|
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||||
|
!portfolio.data,
|
||||||
)
|
)
|
||||||
|
: undefined;
|
||||||
|
const result = await this.forecasts.result({
|
||||||
|
userId,
|
||||||
|
revision: connection.revision,
|
||||||
|
membershipRevision: connection.membershipRevision,
|
||||||
|
scopeKey: `bond/coupons/${JSON.stringify([uid, from, to])}`,
|
||||||
|
portfolio: portfolio.meta,
|
||||||
|
sources,
|
||||||
|
candidate:
|
||||||
|
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||||
|
});
|
||||||
|
return result.data || !projected
|
||||||
|
? result
|
||||||
|
: {
|
||||||
|
data: bondCoupons(
|
||||||
|
events ?? [],
|
||||||
|
null,
|
||||||
|
{ from, to, today },
|
||||||
|
projected.observedAt,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
meta: { ...result.meta, availability: 'partial' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bondCoupons(
|
||||||
|
events: TbankCoupon[],
|
||||||
|
position: BondPositionDto | null,
|
||||||
|
window: { from: string; to: string; today: string },
|
||||||
|
observedAt: string,
|
||||||
|
positionUnavailable: boolean,
|
||||||
|
): BondCouponsDto {
|
||||||
|
const { from, to, today } = window;
|
||||||
|
const quantity =
|
||||||
|
position?.accounts.length &&
|
||||||
|
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
|
||||||
|
? decimalFromString(position.quantity)
|
||||||
|
: null;
|
||||||
|
const coupons = events
|
||||||
|
.map((event) => {
|
||||||
|
const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null;
|
||||||
|
const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null;
|
||||||
|
const perBond = money(event.payOneBond);
|
||||||
|
const timing = !paymentDate
|
||||||
|
? ('undated' as const)
|
||||||
|
: paymentDate >= today
|
||||||
|
? ('upcoming' as const)
|
||||||
|
: ('past' as const);
|
||||||
|
return {
|
||||||
|
number: event.couponNumber ?? null,
|
||||||
|
paymentDate,
|
||||||
|
recordDate,
|
||||||
|
perBond,
|
||||||
|
couponType: event.couponType ?? null,
|
||||||
|
periodDays: event.couponPeriod ?? null,
|
||||||
|
timing,
|
||||||
|
estimatedAmount:
|
||||||
|
quantity &&
|
||||||
|
perBond &&
|
||||||
|
timing === 'upcoming' &&
|
||||||
|
recordDate &&
|
||||||
|
recordDate >= today
|
||||||
|
? {
|
||||||
|
currency: perBond.currency,
|
||||||
|
value: formatDecimal(
|
||||||
|
multiply(quantity, decimalFromString(perBond.value)),
|
||||||
|
),
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
isin
|
};
|
||||||
? optional(() => this.ratings.rating(isin), 'rating', unavailable)
|
})
|
||||||
: null,
|
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''));
|
||||||
]);
|
return {
|
||||||
|
coupons,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
observedAt: observedAt,
|
||||||
|
positionAt: quantity ? (position?.observedAt ?? null) : null,
|
||||||
|
estimateUnavailable: positionUnavailable,
|
||||||
|
positionIsStale: position?.isStale ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bondMetrics(
|
||||||
|
values: CatalogPayload,
|
||||||
|
unavailable: string[],
|
||||||
|
observedAt: string,
|
||||||
|
): BondMetricsDto {
|
||||||
|
const instrument = values.instrument as TbankBond;
|
||||||
|
const events = values.coupons;
|
||||||
|
const quote = bondQuote(values, unavailable, observedAt);
|
||||||
|
const effectiveYield = values.effectiveYield ?? null;
|
||||||
|
const creditRating = values.creditRating ?? null;
|
||||||
|
const today = moscowDay(new Date().toISOString());
|
||||||
|
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||||
const next = events
|
const next = events
|
||||||
?.filter((event) => {
|
?.filter((event) => {
|
||||||
const date = timestamp(event.couponDate)?.slice(0, 10);
|
const date = timestamp(event.couponDate)?.slice(0, 10);
|
||||||
return date && date >= today;
|
return date && date >= today && date <= to;
|
||||||
})
|
})
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) => Number(a.couponDate?.seconds) - Number(b.couponDate?.seconds),
|
||||||
Number(a.couponDate?.seconds) - Number(b.couponDate?.seconds),
|
|
||||||
)[0];
|
)[0];
|
||||||
const coupon = money(next?.payOneBond);
|
const coupon = money(next?.payOneBond);
|
||||||
const period = next?.couponPeriod;
|
const period = next?.couponPeriod;
|
||||||
@@ -119,90 +246,6 @@ export class BondIncomeService {
|
|||||||
couponYieldPercent !== null ? (quote?.priceAt ?? null) : null,
|
couponYieldPercent !== null ? (quote?.priceAt ?? null) : null,
|
||||||
effectiveYield,
|
effectiveYield,
|
||||||
unavailable,
|
unavailable,
|
||||||
observedAt: new Date().toISOString(),
|
observedAt,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
private couponEvents(
|
|
||||||
userId: string,
|
|
||||||
connection: TbankConnectionContext,
|
|
||||||
uid: string,
|
|
||||||
from: string,
|
|
||||||
to: string,
|
|
||||||
) {
|
|
||||||
return this.catalog.cachedForConnection(
|
|
||||||
userId,
|
|
||||||
connection,
|
|
||||||
`coupon-events:${uid}:${from}:${to}`,
|
|
||||||
() => this.tbank.getBondCoupons(connection.token, uid, from, to),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
coupons(userId: string, uid: string): Promise<BondCouponsDto> {
|
|
||||||
return this.catalog.cached(userId, `coupons:${uid}`, async (connection) => {
|
|
||||||
await this.catalog.bond(userId, connection, uid);
|
|
||||||
const today = moscowDay(new Date().toISOString());
|
|
||||||
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
|
||||||
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
|
||||||
const unavailable: string[] = [];
|
|
||||||
const [events, position] = await Promise.all([
|
|
||||||
this.couponEvents(userId, connection, uid, from, to),
|
|
||||||
optional(() => this.position(userId, uid), 'position', unavailable),
|
|
||||||
]);
|
|
||||||
const quantity =
|
|
||||||
position?.accounts.length &&
|
|
||||||
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
|
|
||||||
? decimalFromString(position.quantity)
|
|
||||||
: null;
|
|
||||||
const coupons = events
|
|
||||||
.map((event) => {
|
|
||||||
const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null;
|
|
||||||
const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null;
|
|
||||||
const perBond = money(event.payOneBond);
|
|
||||||
const timing = !paymentDate
|
|
||||||
? ('undated' as const)
|
|
||||||
: paymentDate >= today
|
|
||||||
? ('upcoming' as const)
|
|
||||||
: ('past' as const);
|
|
||||||
return {
|
|
||||||
number: event.couponNumber ?? null,
|
|
||||||
paymentDate,
|
|
||||||
recordDate,
|
|
||||||
perBond,
|
|
||||||
couponType: event.couponType ?? null,
|
|
||||||
periodDays: event.couponPeriod ?? null,
|
|
||||||
timing,
|
|
||||||
estimatedAmount:
|
|
||||||
quantity &&
|
|
||||||
perBond &&
|
|
||||||
timing === 'upcoming' &&
|
|
||||||
recordDate &&
|
|
||||||
recordDate >= today
|
|
||||||
? {
|
|
||||||
currency: perBond.currency,
|
|
||||||
value: formatDecimal(
|
|
||||||
multiply(quantity, decimalFromString(perBond.value)),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) =>
|
|
||||||
(a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
coupons,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
observedAt: new Date().toISOString(),
|
|
||||||
positionAt: quantity ? (position?.observedAt ?? null) : null,
|
|
||||||
estimateUnavailable: unavailable.length > 0,
|
|
||||||
positionIsStale: position?.isStale ?? false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async position(userId: string, uid: string): Promise<BondPositionDto> {
|
|
||||||
return this.catalog.cached(userId, `position:${uid}`, async () =>
|
|
||||||
instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { CandleService } from '../../application/candle.service';
|
||||||
|
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||||
|
import {
|
||||||
|
CatalogEnrichmentService,
|
||||||
|
type CatalogPayload,
|
||||||
|
} from '../../application/catalog-enrichment.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
@@ -7,58 +13,67 @@ import {
|
|||||||
multiply,
|
multiply,
|
||||||
subtract,
|
subtract,
|
||||||
} from '../../../../../common/decimal';
|
} from '../../../../../common/decimal';
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||||
|
import { timestamp } from '../../application/instrument-market';
|
||||||
import {
|
import {
|
||||||
optional,
|
|
||||||
previousClose,
|
|
||||||
timestamp,
|
|
||||||
} from '../../application/instrument-market';
|
|
||||||
import {
|
|
||||||
BondCandlesDto,
|
|
||||||
BondCandlesQueryDto,
|
BondCandlesQueryDto,
|
||||||
BondQuoteDto,
|
BondQuoteDto,
|
||||||
|
BondQuoteResourceDto,
|
||||||
} from '../presentation/bond.dto';
|
} from '../presentation/bond.dto';
|
||||||
|
|
||||||
const dayMs = 86_400_000;
|
|
||||||
|
|
||||||
import { money } from '../infrastructure/bond-money.mapper';
|
import { money } from '../infrastructure/bond-money.mapper';
|
||||||
import { BondCatalogService } from './bond-catalog.service';
|
|
||||||
|
|
||||||
const PREVIOUS_CLOSE_LOOKBACK_DAYS = 20;
|
|
||||||
const HALF_YEAR_MONTHS = 6;
|
|
||||||
const YEAR_MONTHS = 12;
|
|
||||||
const CANDLE_WINDOW_DAYS = 365;
|
|
||||||
const PRICE_DECIMAL_PLACES = 9;
|
const PRICE_DECIMAL_PLACES = 9;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BondMarketService {
|
export class BondMarketService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly catalog: BondCatalogService,
|
private readonly history: CandleService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly sources: CatalogEnrichmentService,
|
||||||
) {}
|
) {}
|
||||||
quote(userId: string, uid: string): Promise<BondQuoteDto> {
|
async quote(
|
||||||
return this.catalog.cached(userId, `quote:${uid}`, async (connection) => {
|
userId: string,
|
||||||
const { token } = connection;
|
uid: string,
|
||||||
const instrument = await this.catalog.bond(userId, connection, uid);
|
observe = false,
|
||||||
const unavailable: string[] = [];
|
): Promise<BondQuoteResourceDto> {
|
||||||
const [last, tradingStatus] = await Promise.all([
|
const result = await this.sources.section(
|
||||||
optional(
|
userId,
|
||||||
() => this.tbank.getLastPrice(token, uid),
|
'bond',
|
||||||
'price',
|
uid,
|
||||||
unavailable,
|
'quote',
|
||||||
),
|
observe,
|
||||||
optional(
|
);
|
||||||
() => this.tbank.getTradingStatus(token, uid),
|
return {
|
||||||
'status',
|
data: result.values.instrument
|
||||||
unavailable,
|
? bondQuote(result.values, result.unavailable, result.observedAt)
|
||||||
),
|
: null,
|
||||||
]);
|
meta: result.meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
candles(
|
||||||
|
userId: string,
|
||||||
|
uid: string,
|
||||||
|
period: BondCandlesQueryDto['period'] = 'month',
|
||||||
|
observe = false,
|
||||||
|
) {
|
||||||
|
return this.history.read(
|
||||||
|
userId,
|
||||||
|
{ uid, instrumentType: 'bond', period },
|
||||||
|
observe,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bondQuote(
|
||||||
|
values: CatalogPayload,
|
||||||
|
unavailable: string[],
|
||||||
|
observedAt: string,
|
||||||
|
): BondQuoteDto {
|
||||||
|
const { price: last, candle: previous, tradingStatus = null } = values;
|
||||||
|
const instrument = values.instrument as TbankBond | undefined;
|
||||||
const priceAt = timestamp(last?.time);
|
const priceAt = timestamp(last?.time);
|
||||||
const pricePercent = last?.price
|
const pricePercent = last?.price ? formatDecimal(decimal(last.price)) : null;
|
||||||
? formatDecimal(decimal(last.price))
|
const nominal = money(instrument?.nominal);
|
||||||
: null;
|
const aci = money(instrument?.aciValue);
|
||||||
const nominal = money(instrument.nominal);
|
|
||||||
const aci = money(instrument.aciValue);
|
|
||||||
const cleanPrice =
|
const cleanPrice =
|
||||||
pricePercent !== null &&
|
pricePercent !== null &&
|
||||||
nominal &&
|
nominal &&
|
||||||
@@ -89,24 +104,6 @@ export class BondMarketService {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const previous = priceAt
|
|
||||||
? await optional(
|
|
||||||
async () =>
|
|
||||||
previousClose(
|
|
||||||
await this.tbank.getCandles(
|
|
||||||
token,
|
|
||||||
uid,
|
|
||||||
new Date(
|
|
||||||
Date.parse(priceAt) - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs,
|
|
||||||
),
|
|
||||||
new Date(priceAt),
|
|
||||||
),
|
|
||||||
priceAt,
|
|
||||||
),
|
|
||||||
'comparison',
|
|
||||||
unavailable,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
pricePercent,
|
pricePercent,
|
||||||
cleanPrice,
|
cleanPrice,
|
||||||
@@ -116,83 +113,10 @@ export class BondMarketService {
|
|||||||
tradingStatus,
|
tradingStatus,
|
||||||
changePercentagePoints:
|
changePercentagePoints:
|
||||||
last?.price && previous?.close
|
last?.price && previous?.close
|
||||||
? formatDecimal(
|
? formatDecimal(subtract(decimal(last.price), decimal(previous.close)))
|
||||||
subtract(decimal(last.price), decimal(previous.close)),
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
|
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
|
||||||
unavailable,
|
unavailable,
|
||||||
observedAt: new Date().toISOString(),
|
observedAt,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
candles(
|
|
||||||
userId: string,
|
|
||||||
uid: string,
|
|
||||||
period: BondCandlesQueryDto['period'] = 'month',
|
|
||||||
): Promise<BondCandlesDto> {
|
|
||||||
return this.catalog.cached(
|
|
||||||
userId,
|
|
||||||
`candles:${uid}:${period}`,
|
|
||||||
async (connection) => {
|
|
||||||
const { token } = connection;
|
|
||||||
const bond = await this.catalog.bond(userId, connection, uid);
|
|
||||||
const historyFrom = timestamp(bond.first_1dayCandleDate);
|
|
||||||
const to = new Date();
|
|
||||||
const from = new Date(to);
|
|
||||||
from.setUTCMonth(
|
|
||||||
from.getUTCMonth() -
|
|
||||||
(period === 'month'
|
|
||||||
? 1
|
|
||||||
: period === 'halfYear'
|
|
||||||
? HALF_YEAR_MONTHS
|
|
||||||
: YEAR_MONTHS),
|
|
||||||
);
|
|
||||||
if (period === 'all') {
|
|
||||||
if (!historyFrom)
|
|
||||||
throw new ServiceUnavailableException({
|
|
||||||
code: 'BOND_HISTORY_START_UNAVAILABLE',
|
|
||||||
});
|
|
||||||
from.setTime(Date.parse(historyFrom));
|
|
||||||
} else if (historyFrom && Date.parse(historyFrom) > from.valueOf())
|
|
||||||
from.setTime(Date.parse(historyFrom));
|
|
||||||
const collected = new Map<string, BondCandlesDto['candles'][number]>();
|
|
||||||
// Conservative one-year windows also work with older deployed RPC limits.
|
|
||||||
for (let start = from.valueOf(); start < to.valueOf();) {
|
|
||||||
const end = Math.min(
|
|
||||||
start + CANDLE_WINDOW_DAYS * dayMs,
|
|
||||||
to.valueOf(),
|
|
||||||
);
|
|
||||||
for (const candle of await this.tbank.getCandles(
|
|
||||||
token,
|
|
||||||
uid,
|
|
||||||
new Date(start),
|
|
||||||
new Date(end),
|
|
||||||
)) {
|
|
||||||
const time = timestamp(candle.time);
|
|
||||||
if (
|
|
||||||
time &&
|
|
||||||
candle.close &&
|
|
||||||
Date.parse(time) >= from.valueOf() - dayMs &&
|
|
||||||
Date.parse(time) <= to.valueOf()
|
|
||||||
)
|
|
||||||
collected.set(time, {
|
|
||||||
time,
|
|
||||||
close: formatDecimal(decimal(candle.close)),
|
|
||||||
isComplete: candle.isComplete === true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
start = end;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
candles: [...collected.values()].sort((a, b) =>
|
|
||||||
a.time.localeCompare(b.time),
|
|
||||||
),
|
|
||||||
currency: null,
|
|
||||||
historyFrom,
|
|
||||||
observedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,12 @@
|
|||||||
import {
|
import { bondCoupons, bondMetrics } from './bond-income.service';
|
||||||
ConflictException,
|
import { bondQuote } from './bond-market.service';
|
||||||
NotFoundException,
|
import { instrumentPosition } from '../../application/instrument-position';
|
||||||
ServiceUnavailableException,
|
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
||||||
UnauthorizedException,
|
jest.mock('../../../../../prisma/prisma.service', () => ({
|
||||||
} from '@nestjs/common';
|
PrismaService: class {},
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { MoexClient } from '../../../../../integrations/moex/moex.client';
|
|
||||||
import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr-ratings.client';
|
|
||||||
import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service';
|
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
|
|
||||||
import { PortfolioService } from '../../../portfolio/application/portfolio.service';
|
|
||||||
import { BondIncomeService } from './bond-income.service';
|
|
||||||
import { BondMarketService } from './bond-market.service';
|
|
||||||
import { BondCatalogService } from './bond-catalog.service';
|
|
||||||
jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({
|
|
||||||
TbankAccessService: class TbankAccessService {},
|
|
||||||
}));
|
}));
|
||||||
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
||||||
const money = (units: string, currency = 'rub') => ({ units, currency });
|
const money = (units: string, currency = 'rub') => ({ units, currency });
|
||||||
const cacheClock = performance;
|
|
||||||
const bond = {
|
const bond = {
|
||||||
uid: 'uid',
|
uid: 'uid',
|
||||||
ticker: 'BOND',
|
ticker: 'BOND',
|
||||||
@@ -30,418 +18,22 @@ const bond = {
|
|||||||
amortizationFlag: true,
|
amortizationFlag: true,
|
||||||
first_1dayCandleDate: time('2024-01-01'),
|
first_1dayCandleDate: time('2024-01-01'),
|
||||||
};
|
};
|
||||||
function setup() {
|
describe('bond financial projections', () => {
|
||||||
let generation = 'a';
|
beforeEach(() =>
|
||||||
const tbank = {
|
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')),
|
||||||
findBonds: jest.fn().mockResolvedValue([bond]),
|
|
||||||
getBond: jest.fn().mockResolvedValue(bond),
|
|
||||||
getLastPrice: jest.fn().mockResolvedValue({
|
|
||||||
instrumentUid: 'uid',
|
|
||||||
price: { units: '98', nano: 125000000 },
|
|
||||||
time: time('2026-09-10T10:00:00Z'),
|
|
||||||
}),
|
|
||||||
getLastPrices: jest.fn().mockResolvedValue([]),
|
|
||||||
getTradingStatus: jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue('SECURITY_TRADING_STATUS_NORMAL_TRADING'),
|
|
||||||
getCandles: jest.fn().mockResolvedValue([
|
|
||||||
{ time: time('2026-09-09'), close: { units: '99' }, isComplete: true },
|
|
||||||
{ time: time('2026-09-10'), close: { units: '100' }, isComplete: true },
|
|
||||||
]),
|
|
||||||
getBondCoupons: jest.fn().mockResolvedValue([]),
|
|
||||||
};
|
|
||||||
const access = {
|
|
||||||
withConnection: jest.fn(
|
|
||||||
(user: string, handler: (context: unknown) => unknown) =>
|
|
||||||
handler({ token: user, generation }),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
const portfolios = {
|
|
||||||
getAll: jest.fn().mockResolvedValue({
|
|
||||||
positions: [],
|
|
||||||
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
|
||||||
isStale: false,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const ratings = { rating: jest.fn().mockResolvedValue(null) };
|
|
||||||
const moex = { bondYield: jest.fn().mockResolvedValue(null) };
|
|
||||||
const service = new BondCatalogService(
|
|
||||||
access as unknown as TbankAccessService,
|
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
);
|
);
|
||||||
const market = new BondMarketService(
|
afterEach(() => jest.useRealTimers());
|
||||||
service,
|
it('estimates only future unfixed payouts from exact UID holdings', () => {
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
);
|
|
||||||
const income = new BondIncomeService(
|
|
||||||
service,
|
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
portfolios as unknown as PortfolioService,
|
|
||||||
market,
|
|
||||||
moex as unknown as MoexClient,
|
|
||||||
ratings as unknown as NcrRatingsClient,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
market,
|
|
||||||
income,
|
|
||||||
service,
|
|
||||||
ratings,
|
|
||||||
moex,
|
|
||||||
tbank,
|
|
||||||
access,
|
|
||||||
portfolios,
|
|
||||||
changeGeneration: () => {
|
|
||||||
generation = 'b';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
describe('bond market use case', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z'));
|
|
||||||
jest.spyOn(cacheClock, 'now').mockImplementation(() => Date.now());
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
it('shows the next coupon and its annualized current yield without extra T-Bank calls', async () => {
|
|
||||||
const { tbank, moex, market, income } = setup();
|
|
||||||
tbank.getBond.mockResolvedValue({ ...bond, classCode: 'TQOB' });
|
|
||||||
tbank.getBondCoupons.mockResolvedValue([
|
|
||||||
{
|
|
||||||
couponDate: time('2026-12-10'),
|
|
||||||
payOneBond: money('40'),
|
|
||||||
couponPeriod: 91,
|
|
||||||
couponType: 'COUPON_TYPE_VARIABLE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
couponDate: time('2026-10-10'),
|
|
||||||
payOneBond: money('30'),
|
|
||||||
couponPeriod: 90,
|
|
||||||
couponType: 'COUPON_TYPE_FIX',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
couponDate: time('2026-09-01'),
|
|
||||||
payOneBond: money('90'),
|
|
||||||
couponPeriod: 90,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const effectiveYield = {
|
|
||||||
value: '15.6262',
|
|
||||||
pricePercent: '53.384',
|
|
||||||
date: '2041-05-15',
|
|
||||||
dateType: 'MATDATE',
|
|
||||||
tradeAt: '2026-09-10T18:49:39Z',
|
|
||||||
};
|
|
||||||
moex.bondYield.mockResolvedValue(effectiveYield);
|
|
||||||
const [metrics] = await Promise.all([
|
|
||||||
income.metrics('user', 'uid'),
|
|
||||||
income.coupons('user', 'uid'),
|
|
||||||
market.quote('user', 'uid'),
|
|
||||||
]);
|
|
||||||
expect(metrics).toMatchObject({
|
|
||||||
coupon: { value: '30', currency: 'RUB' },
|
|
||||||
couponDate: '2026-10-10',
|
|
||||||
couponType: 'COUPON_TYPE_FIX',
|
|
||||||
couponPeriodDays: 90,
|
|
||||||
couponYieldPercent: '24.7983',
|
|
||||||
effectiveYield,
|
|
||||||
});
|
|
||||||
expect(moex.bondYield).toHaveBeenCalledWith('BOND', 'ISIN', 'TQOB');
|
|
||||||
expect(tbank.getBond).toHaveBeenCalledTimes(1);
|
|
||||||
expect(tbank.getBondCoupons).toHaveBeenCalledTimes(1);
|
|
||||||
expect(tbank.getLastPrice).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
it.each(['unknown', 'currency', 'period', 'zero-price'])(
|
|
||||||
'does not invent coupon yield with %s inputs',
|
|
||||||
async (scenario) => {
|
|
||||||
const { tbank, income } = setup();
|
|
||||||
tbank.getBondCoupons.mockResolvedValue([
|
|
||||||
{
|
|
||||||
couponDate: time('2026-10-10'),
|
|
||||||
payOneBond:
|
|
||||||
scenario === 'unknown'
|
|
||||||
? undefined
|
|
||||||
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
|
||||||
couponPeriod: scenario === 'period' ? 0 : 90,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
couponDate: time('2026-12-10'),
|
|
||||||
payOneBond: money('50'),
|
|
||||||
couponPeriod: 90,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
if (scenario === 'zero-price')
|
|
||||||
tbank.getLastPrice.mockResolvedValue({ price: { units: '0' } });
|
|
||||||
expect(await income.metrics('user', 'uid')).toMatchObject({
|
|
||||||
couponDate: '2026-10-10',
|
|
||||||
couponYieldPercent: null,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
it('keeps coupons when MOEX fails and never substitutes a risk level for a credit rating', async () => {
|
|
||||||
const { tbank, moex, income } = setup();
|
|
||||||
tbank.getBond.mockResolvedValue({
|
|
||||||
...bond,
|
|
||||||
classCode: 'TQOB',
|
|
||||||
floatingCouponFlag: true,
|
|
||||||
riskLevel: 'RISK_LEVEL_LOW',
|
|
||||||
});
|
|
||||||
moex.bondYield.mockRejectedValue(new ServiceUnavailableException());
|
|
||||||
expect(await income.metrics('user', 'uid')).toMatchObject({
|
|
||||||
effectiveYield: null,
|
|
||||||
coupon: null,
|
|
||||||
couponType: 'COUPON_TYPE_FLOATING',
|
|
||||||
couponYieldPercent: null,
|
|
||||||
unavailable: ['yield'],
|
|
||||||
});
|
|
||||||
tbank.getBondCoupons.mockRejectedValue(new UnauthorizedException());
|
|
||||||
await expect(income.metrics('other', 'uid')).rejects.toBeInstanceOf(
|
|
||||||
UnauthorizedException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('returns only the rating of the exact issue and isolates a registry outage', async () => {
|
|
||||||
const { ratings, income } = setup();
|
|
||||||
const creditRating = {
|
|
||||||
agency: 'NCR',
|
|
||||||
value: 'AAA.ru',
|
|
||||||
status: 'active',
|
|
||||||
date: '2026-04-17',
|
|
||||||
sourceUrl: 'https://ratings.ru/ratings/issues/123/',
|
|
||||||
observedAt: '2026-09-10T10:00:00Z',
|
|
||||||
};
|
|
||||||
ratings.rating.mockResolvedValue(creditRating);
|
|
||||||
expect(await income.metrics('user', 'uid')).toMatchObject({
|
|
||||||
creditRating,
|
|
||||||
});
|
|
||||||
expect(ratings.rating).toHaveBeenCalledWith('ISIN');
|
|
||||||
ratings.rating.mockRejectedValue(new ServiceUnavailableException());
|
|
||||||
expect(await income.metrics('other', 'uid')).toMatchObject({
|
|
||||||
creditRating: null,
|
|
||||||
unavailable: ['rating'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('opens concurrent sections within one upstream metadata request budget', async () => {
|
|
||||||
const { service, tbank, market, income } = setup();
|
|
||||||
tbank.getBond
|
|
||||||
.mockReset()
|
|
||||||
.mockResolvedValueOnce(bond)
|
|
||||||
.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
Promise.all([
|
|
||||||
service.getByTicker('user', 'BOND'),
|
|
||||||
market.quote('user', 'uid'),
|
|
||||||
market.candles('user', 'uid'),
|
|
||||||
income.coupons('user', 'uid'),
|
|
||||||
income.metrics('user', 'uid'),
|
|
||||||
]),
|
|
||||||
).resolves.toHaveLength(5);
|
|
||||||
expect(tbank.getBond).toHaveBeenCalledTimes(1);
|
|
||||||
expect(tbank.findBonds).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
it('searches thirty issues without a separate ticker lookup for each result', async () => {
|
|
||||||
const { service, tbank, market, income } = setup();
|
|
||||||
const issues = Array.from({ length: 30 }, (_, index) => ({
|
|
||||||
...bond,
|
|
||||||
uid: `uid-${index}`,
|
|
||||||
isin: `ISIN-${index}`,
|
|
||||||
ticker: `BOND-${index}`,
|
|
||||||
}));
|
|
||||||
tbank.findBonds.mockResolvedValue(issues);
|
|
||||||
tbank.getBond.mockImplementation((_token: string, uid: string) =>
|
|
||||||
Promise.resolve(issues.find((item) => item.uid === uid)),
|
|
||||||
);
|
|
||||||
expect(await service.search('user', 'BOND')).toHaveLength(30);
|
|
||||||
expect(tbank.findBonds).toHaveBeenCalledTimes(1);
|
|
||||||
expect(tbank.getBond).toHaveBeenCalledTimes(30);
|
|
||||||
expect(tbank.getLastPrices).toHaveBeenCalledTimes(1);
|
|
||||||
await service.getByTicker('user', 'BOND-0');
|
|
||||||
await Promise.all([
|
|
||||||
market.quote('user', 'uid-0'),
|
|
||||||
market.candles('user', 'uid-0'),
|
|
||||||
income.coupons('user', 'uid-0'),
|
|
||||||
]);
|
|
||||||
expect(tbank.getBond).toHaveBeenCalledTimes(30);
|
|
||||||
expect(tbank.findBonds).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
it('refreshes current nominal and accrued interest after the metadata TTL', async () => {
|
|
||||||
const { service, tbank, market } = setup();
|
|
||||||
await service.get('user', 'uid');
|
|
||||||
await jest.advanceTimersByTimeAsync(30_001);
|
|
||||||
tbank.getBond.mockResolvedValue({
|
|
||||||
...bond,
|
|
||||||
nominal: money('400'),
|
|
||||||
aciValue: money('3'),
|
|
||||||
});
|
|
||||||
expect(await market.quote('user', 'uid')).toMatchObject({
|
|
||||||
cleanPrice: { value: '392.5' },
|
|
||||||
dirtyPrice: { value: '395.5' },
|
|
||||||
});
|
|
||||||
expect(tbank.getBond).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
it('keeps late metadata responses within their original connection generation', async () => {
|
|
||||||
const { tbank, changeGeneration, market } = setup();
|
|
||||||
let resolveOld: ((value: typeof bond) => void) | undefined;
|
|
||||||
tbank.getBond.mockReturnValueOnce(
|
|
||||||
new Promise((resolve) => {
|
|
||||||
resolveOld = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const oldQuote = market.quote('user', 'uid');
|
|
||||||
changeGeneration();
|
|
||||||
tbank.getBond.mockResolvedValue({ ...bond, nominal: money('400') });
|
|
||||||
expect(await market.quote('user', 'uid')).toMatchObject({
|
|
||||||
cleanPrice: { value: '392.5' },
|
|
||||||
});
|
|
||||||
assert.ok(resolveOld);
|
|
||||||
resolveOld(bond);
|
|
||||||
await oldQuote;
|
|
||||||
expect(await market.quote('user', 'uid')).toMatchObject({
|
|
||||||
cleanPrice: { value: '392.5' },
|
|
||||||
});
|
|
||||||
expect(tbank.getBond).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
it('keeps percent quotes separate from money and uses current amortized nominal', async () => {
|
|
||||||
const { market } = setup();
|
|
||||||
expect(await market.quote('user', 'uid')).toMatchObject({
|
|
||||||
pricePercent: '98.125',
|
|
||||||
cleanPrice: { value: '490.625', currency: 'RUB' },
|
|
||||||
aci: { value: '12', currency: 'RUB' },
|
|
||||||
dirtyPrice: { value: '502.625', currency: 'RUB' },
|
|
||||||
changePercentagePoints: '-0.875',
|
|
||||||
previousCloseDate: '2026-09-09',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('does not invent dirty prices or mix currencies when accrued interest is absent or mismatched', async () => {
|
|
||||||
const { tbank, market } = setup();
|
|
||||||
tbank.getBond.mockResolvedValueOnce({ ...bond, aciValue: undefined });
|
|
||||||
expect(await market.quote('user', 'uid')).toMatchObject({
|
|
||||||
aci: null,
|
|
||||||
dirtyPrice: null,
|
|
||||||
});
|
|
||||||
tbank.getBond.mockResolvedValueOnce({
|
|
||||||
...bond,
|
|
||||||
aciValue: money('12', 'usd'),
|
|
||||||
});
|
|
||||||
expect(await market.quote('other', 'uid')).toMatchObject({
|
|
||||||
dirtyPrice: null,
|
|
||||||
});
|
|
||||||
tbank.getBond.mockResolvedValueOnce({ ...bond, nominal: undefined });
|
|
||||||
expect(await market.quote('third', 'uid')).toMatchObject({
|
|
||||||
pricePercent: '98.125',
|
|
||||||
cleanPrice: null,
|
|
||||||
dirtyPrice: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('preserves absent coupon flags and dates without reporting false or zero', async () => {
|
|
||||||
const { service } = setup();
|
|
||||||
expect(await service.get('user', 'uid')).toMatchObject({
|
|
||||||
floatingCoupon: null,
|
|
||||||
perpetual: null,
|
|
||||||
couponQuantityPerYear: null,
|
|
||||||
maturityDate: null,
|
|
||||||
nominal: { value: '500', currency: 'RUB' },
|
|
||||||
amortization: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('keeps distinct issues, deduplicates ISIN listings, and prices the selected exact UID', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
const listings = [
|
|
||||||
{ ...bond, uid: 'off', apiTradeAvailableFlag: false },
|
|
||||||
{ ...bond, uid: 'uid', apiTradeAvailableFlag: true },
|
|
||||||
{ ...bond, uid: 'second', isin: 'OTHER', ticker: 'OTHER' },
|
|
||||||
{ ...bond, uid: 'share', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
|
|
||||||
{ ...bond, uid: undefined },
|
|
||||||
{ ...bond, uid: '' },
|
|
||||||
];
|
|
||||||
tbank.findBonds.mockResolvedValue(listings);
|
|
||||||
tbank.getBond.mockImplementation((_token: string, uid: string) =>
|
|
||||||
Promise.resolve(listings.find((b) => b.uid === uid)),
|
|
||||||
);
|
|
||||||
tbank.getLastPrices.mockResolvedValue([
|
|
||||||
{ instrumentUid: 'uid', price: { units: '99' } },
|
|
||||||
{ instrumentUid: 'off', price: { units: '1' } },
|
|
||||||
]);
|
|
||||||
const results = await service.search('user', 'BOND');
|
|
||||||
expect(results.map((x) => x.instrumentUid)).toEqual(['uid', 'second']);
|
|
||||||
expect(results[0]).toMatchObject({ pricePercent: '99', slug: 'uid' });
|
|
||||||
expect((await service.get('user', 'off')).slug).toBe('off');
|
|
||||||
});
|
|
||||||
it('rejects ambiguous tickers and wrong instrument identities', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
tbank.findBonds.mockResolvedValue([
|
|
||||||
bond,
|
|
||||||
{ ...bond, uid: 'other', isin: 'OTHER' },
|
|
||||||
]);
|
|
||||||
await expect(service.getByTicker('user', 'bond')).rejects.toBeInstanceOf(
|
|
||||||
ConflictException,
|
|
||||||
);
|
|
||||||
tbank.getBond.mockResolvedValue({ ...bond, uid: 'other' });
|
|
||||||
await expect(service.get('user', 'uid')).rejects.toBeInstanceOf(
|
|
||||||
NotFoundException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('preserves catalog results on enrichment outage but never suppresses auth failures', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
tbank.getBond.mockRejectedValue(new ServiceUnavailableException());
|
|
||||||
tbank.getLastPrices.mockRejectedValue(new ServiceUnavailableException());
|
|
||||||
expect(await service.search('user', 'BOND')).toMatchObject([
|
|
||||||
{ instrumentUid: 'uid', pricePercent: null, nominal: { value: '500' } },
|
|
||||||
]);
|
|
||||||
tbank.getBond.mockRejectedValue(new UnauthorizedException());
|
|
||||||
await expect(service.search('other', 'BOND')).rejects.toBeInstanceOf(
|
|
||||||
UnauthorizedException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('keeps daily candles in percent, deduplicates boundaries and requires an all-history start', async () => {
|
|
||||||
const { tbank, market } = setup();
|
|
||||||
const history = await market.candles('user', 'uid', 'all');
|
|
||||||
expect(history.currency).toBeNull();
|
|
||||||
expect(history.candles.map((c) => c.close)).toEqual(['99', '100']);
|
|
||||||
for (const [, , from, to] of tbank.getCandles.mock.calls as [
|
|
||||||
string,
|
|
||||||
string,
|
|
||||||
Date,
|
|
||||||
Date,
|
|
||||||
][])
|
|
||||||
expect(to.valueOf() - from.valueOf()).toBeLessThanOrEqual(365 * 86400000);
|
|
||||||
tbank.getBond.mockResolvedValue({
|
|
||||||
...bond,
|
|
||||||
first_1dayCandleDate: undefined,
|
|
||||||
});
|
|
||||||
await expect(market.candles('other', 'uid', 'all')).rejects.toBeInstanceOf(
|
|
||||||
ServiceUnavailableException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('isolates caches by user and connection generation and retries failures', async () => {
|
|
||||||
const { service, tbank, changeGeneration } = setup();
|
|
||||||
await Promise.all([service.get('a', 'uid'), service.get('a', 'uid')]);
|
|
||||||
expect(tbank.getBond).toHaveBeenCalledTimes(1);
|
|
||||||
await service.get('b', 'uid');
|
|
||||||
changeGeneration();
|
|
||||||
await service.get('a', 'uid');
|
|
||||||
expect(tbank.getBond).toHaveBeenCalledTimes(3);
|
|
||||||
tbank.getBond.mockRejectedValueOnce(new ServiceUnavailableException());
|
|
||||||
await expect(service.get('c', 'uid')).rejects.toBeInstanceOf(
|
|
||||||
ServiceUnavailableException,
|
|
||||||
);
|
|
||||||
await expect(service.get('c', 'uid')).resolves.toMatchObject({
|
|
||||||
instrumentUid: 'uid',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('estimates only future unfixed coupons from positive exact-UID bond holdings', async () => {
|
|
||||||
const { tbank, portfolios, income } = setup();
|
|
||||||
const holding = {
|
const holding = {
|
||||||
instrumentUid: 'uid',
|
instrumentUid: 'uid',
|
||||||
type: 'BOND',
|
type: 'BOND',
|
||||||
quantity: '2',
|
quantity: '2',
|
||||||
averagePurchasePrice: { value: '490', currency: 'RUB' },
|
averagePurchasePrice: null,
|
||||||
currentValue: { value: '981.25', currency: 'RUB' },
|
currentValue: { value: '981.25', currency: 'RUB' },
|
||||||
absoluteReturn: { value: '1.25', currency: 'RUB' },
|
absoluteReturn: null,
|
||||||
};
|
};
|
||||||
portfolios.getAll.mockResolvedValue({
|
const position = instrumentPosition(
|
||||||
|
{
|
||||||
positions: [
|
positions: [
|
||||||
holding,
|
holding,
|
||||||
{ ...holding, instrumentUid: 'other', quantity: '99' },
|
{ ...holding, instrumentUid: 'other', quantity: '99' },
|
||||||
@@ -449,8 +41,12 @@ describe('bond market use case', () => {
|
|||||||
],
|
],
|
||||||
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
||||||
isStale: true,
|
isStale: true,
|
||||||
});
|
} as PortfolioDto,
|
||||||
tbank.getBondCoupons.mockResolvedValue([
|
'uid',
|
||||||
|
'BOND',
|
||||||
|
);
|
||||||
|
const result = bondCoupons(
|
||||||
|
[
|
||||||
{
|
{
|
||||||
couponNumber: '1',
|
couponNumber: '1',
|
||||||
couponDate: time('2026-10-01'),
|
couponDate: time('2026-10-01'),
|
||||||
@@ -473,26 +69,109 @@ describe('bond market use case', () => {
|
|||||||
couponDate: time('2026-10-01'),
|
couponDate: time('2026-10-01'),
|
||||||
fixDate: time('2026-09-29'),
|
fixDate: time('2026-09-29'),
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
couponNumber: '5',
|
position,
|
||||||
couponDate: time('2025-10-01'),
|
{ from: '2021-09-10', to: '2027-09-10', today: '2026-09-10' },
|
||||||
payOneBond: money('10'),
|
'source-time',
|
||||||
},
|
false,
|
||||||
]);
|
);
|
||||||
const result = await income.coupons('user', 'uid');
|
expect(result.coupons[0].estimatedAmount).toEqual({
|
||||||
|
value: '20',
|
||||||
|
currency: 'RUB',
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
result.coupons.find((c) => c.number === '1')?.estimatedAmount,
|
result.coupons.slice(1).every((row) => row.estimatedAmount === null),
|
||||||
).toEqual({ value: '20', currency: 'RUB' });
|
|
||||||
expect(
|
|
||||||
result.coupons
|
|
||||||
.filter((c) => c.number !== '1')
|
|
||||||
.every((c) => c.estimatedAmount === null),
|
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(result.coupons.find((c) => c.number === '4')?.perBond).toBeNull();
|
expect(result.coupons[3].perBond).toBeNull();
|
||||||
expect(result.positionIsStale).toBe(true);
|
expect(result.positionIsStale).toBe(true);
|
||||||
expect(await income.position('user', 'uid')).toMatchObject({
|
expect(position.quantity).toBe('2');
|
||||||
quantity: '2',
|
});
|
||||||
currentValue: { value: '981.25' },
|
it('preserves amortized nominal, ACI, percent quotes and annualized coupon yield', () => {
|
||||||
|
const values = {
|
||||||
|
instrument: bond,
|
||||||
|
price: { price: { units: '98', nano: 125000000 } },
|
||||||
|
candle: { close: { units: '99' } },
|
||||||
|
coupons: [
|
||||||
|
{
|
||||||
|
couponDate: time('2026-10-10'),
|
||||||
|
payOneBond: money('30'),
|
||||||
|
couponPeriod: 90,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(bondQuote(values, [], 'time')).toMatchObject({
|
||||||
|
pricePercent: '98.125',
|
||||||
|
cleanPrice: { currency: 'RUB', value: '490.625' },
|
||||||
|
dirtyPrice: { currency: 'RUB', value: '502.625' },
|
||||||
|
changePercentagePoints: '-0.875',
|
||||||
|
});
|
||||||
|
expect(bondMetrics(values, [], 'time')).toMatchObject({
|
||||||
|
couponDate: '2026-10-10',
|
||||||
|
couponYieldPercent: '24.7983',
|
||||||
|
creditRating: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it.each(['unknown', 'currency', 'period', 'zero-price'])(
|
||||||
|
'does not invent coupon yield with %s inputs',
|
||||||
|
(scenario) => {
|
||||||
|
const result = bondMetrics(
|
||||||
|
{
|
||||||
|
instrument: bond,
|
||||||
|
price: { price: { units: scenario === 'zero-price' ? '0' : '100' } },
|
||||||
|
coupons: [
|
||||||
|
{
|
||||||
|
couponDate: time('2026-10-10'),
|
||||||
|
payOneBond:
|
||||||
|
scenario === 'unknown'
|
||||||
|
? undefined
|
||||||
|
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
||||||
|
couponPeriod: scenario === 'period' ? 0 : 90,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
'time',
|
||||||
|
);
|
||||||
|
expect(result.couponYieldPercent).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it('keeps absent and mismatched ACI unknown and never substitutes risk level for a rating', () => {
|
||||||
|
expect(
|
||||||
|
bondQuote(
|
||||||
|
{
|
||||||
|
instrument: { ...bond, aciValue: money('1', 'usd') },
|
||||||
|
price: { price: { units: '100' } },
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
'time',
|
||||||
|
).dirtyPrice,
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
bondQuote(
|
||||||
|
{
|
||||||
|
instrument: { ...bond, aciValue: undefined },
|
||||||
|
price: { price: { units: '100' } },
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
'time',
|
||||||
|
).dirtyPrice,
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
bondMetrics(
|
||||||
|
{
|
||||||
|
instrument: {
|
||||||
|
...bond,
|
||||||
|
riskLevel: 'RISK_LEVEL_LOW',
|
||||||
|
floatingCouponFlag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
['yield'],
|
||||||
|
'time',
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
creditRating: null,
|
||||||
|
couponType: 'COUPON_TYPE_FLOATING',
|
||||||
|
couponYieldPercent: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -14,15 +15,15 @@ import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
|||||||
import { BondIncomeService } from '../application/bond-income.service';
|
import { BondIncomeService } from '../application/bond-income.service';
|
||||||
import { BondMarketService } from '../application/bond-market.service';
|
import { BondMarketService } from '../application/bond-market.service';
|
||||||
import {
|
import {
|
||||||
BondCandlesDto,
|
BondCandlesResourceDto,
|
||||||
BondCandlesQueryDto,
|
BondCandlesQueryDto,
|
||||||
BondCouponsDto,
|
BondCouponsResourceDto,
|
||||||
BondDto,
|
BondResourceDto,
|
||||||
BondMetricsDto,
|
BondMetricsResourceDto,
|
||||||
BondPositionDto,
|
BondPositionResourceDto,
|
||||||
BondQuoteDto,
|
BondQuoteResourceDto,
|
||||||
BondSearchQueryDto,
|
BondSearchQueryDto,
|
||||||
BondSearchResultDto,
|
BondSearchResourceDto,
|
||||||
BondTickerParamsDto,
|
BondTickerParamsDto,
|
||||||
} from './bond.dto';
|
} from './bond.dto';
|
||||||
import { BondCatalogService } from '../application/bond-catalog.service';
|
import { BondCatalogService } from '../application/bond-catalog.service';
|
||||||
@@ -39,68 +40,91 @@ export class BondController {
|
|||||||
private readonly income: BondIncomeService,
|
private readonly income: BondIncomeService,
|
||||||
) {}
|
) {}
|
||||||
@Get(':uid/metrics')
|
@Get(':uid/metrics')
|
||||||
@ApiOkResponse({ type: BondMetricsDto })
|
@ApiOkResponse({ type: BondMetricsResourceDto })
|
||||||
metrics(
|
metrics(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.metrics(request.user.sub, uid);
|
return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ type: [BondSearchResultDto] })
|
@ApiOkResponse({ type: BondSearchResourceDto })
|
||||||
search(
|
search(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Query() query: BondSearchQueryDto,
|
@Query() query: BondSearchQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.bonds.search(request.user.sub, query.query);
|
return this.bonds.search(
|
||||||
|
request.user.sub,
|
||||||
|
query.query,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get('by-ticker/:ticker')
|
@Get('by-ticker/:ticker')
|
||||||
@ApiOkResponse({ type: BondDto })
|
@ApiOkResponse({ type: BondResourceDto })
|
||||||
getByTicker(
|
getByTicker(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param() params: BondTickerParamsDto,
|
@Param() params: BondTickerParamsDto,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.bonds.getByTicker(request.user.sub, params.ticker);
|
return this.bonds.getByTicker(
|
||||||
|
request.user.sub,
|
||||||
|
params.ticker,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid')
|
@Get(':uid')
|
||||||
@ApiOkResponse({ type: BondDto })
|
@ApiOkResponse({ type: BondResourceDto })
|
||||||
get(
|
get(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.bonds.get(request.user.sub, uid);
|
return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
@Get(':uid/quote')
|
@Get(':uid/quote')
|
||||||
@ApiOkResponse({ type: BondQuoteDto })
|
@ApiOkResponse({ type: BondQuoteResourceDto })
|
||||||
quote(
|
quote(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.market.quote(request.user.sub, uid);
|
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
@Get(':uid/candles')
|
@Get(':uid/candles')
|
||||||
@ApiOkResponse({ type: BondCandlesDto })
|
@ApiOkResponse({ type: BondCandlesResourceDto })
|
||||||
candles(
|
candles(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: BondCandlesQueryDto,
|
@Query() query: BondCandlesQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.market.candles(request.user.sub, uid, query.period);
|
return this.market.candles(
|
||||||
|
request.user.sub,
|
||||||
|
uid,
|
||||||
|
query.period,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid/position')
|
@Get(':uid/position')
|
||||||
@ApiOkResponse({ type: BondPositionDto })
|
@ApiOkResponse({ type: BondPositionResourceDto })
|
||||||
position(
|
position(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.position(request.user.sub, uid);
|
return this.income.readPosition(
|
||||||
|
request.user.sub,
|
||||||
|
uid,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid/coupons')
|
@Get(':uid/coupons')
|
||||||
@ApiOkResponse({ type: BondCouponsDto })
|
@ApiOkResponse({ type: BondCouponsResourceDto })
|
||||||
coupons(
|
coupons(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.coupons(request.user.sub, uid);
|
return this.income.coupons(request.user.sub, uid, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
@@ -175,3 +176,51 @@ export class BondMetricsDto {
|
|||||||
@ApiProperty({ type: [String] }) declare unavailable: string[];
|
@ApiProperty({ type: [String] }) declare unavailable: string[];
|
||||||
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
|
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BondPositionResourceDto {
|
||||||
|
@ApiProperty({ type: BondPositionDto, nullable: true })
|
||||||
|
declare data: BondPositionDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BondSearchResourceDto {
|
||||||
|
@ApiProperty({ type: BondSearchResultDto, isArray: true, nullable: true })
|
||||||
|
declare data: BondSearchResultDto[] | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BondResourceDto {
|
||||||
|
@ApiProperty({ type: BondDto, nullable: true }) declare data: BondDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BondQuoteResourceDto {
|
||||||
|
@ApiProperty({ type: BondQuoteDto, nullable: true })
|
||||||
|
declare data: BondQuoteDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BondMetricsResourceDto {
|
||||||
|
@ApiProperty({ type: BondMetricsDto, nullable: true })
|
||||||
|
declare data: BondMetricsDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BondCandlesResourceDto {
|
||||||
|
@ApiProperty({ type: BondCandlesDto, nullable: true })
|
||||||
|
declare data: BondCandlesDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BondCouponsResourceDto {
|
||||||
|
@ApiProperty({ type: BondCouponsDto, nullable: true })
|
||||||
|
declare data: BondCouponsDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
candleGaps,
|
||||||
|
candleWindows,
|
||||||
|
confirmedCandleCoverage,
|
||||||
|
} from './candle-coverage';
|
||||||
|
const date = (value: string) => new Date(value);
|
||||||
|
describe('source-confirmed daily candle coverage', () => {
|
||||||
|
it('retains closed empty coverage while yesterday incomplete and open empty ranges require rereading after midnight', () => {
|
||||||
|
const range = { from: date('2026-09-20Z'), to: date('2026-09-26Z') };
|
||||||
|
const now = date('2026-09-25T23:59:50Z');
|
||||||
|
const coverage = confirmedCandleCoverage(
|
||||||
|
range,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
time: '2026-09-24T00:00:00Z',
|
||||||
|
close: '1.000000001',
|
||||||
|
isComplete: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
coverage.map((row) => [
|
||||||
|
row.from.toISOString().slice(0, 10),
|
||||||
|
row.to.toISOString().slice(0, 10),
|
||||||
|
row.isComplete,
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
['2026-09-20', '2026-09-24', true],
|
||||||
|
['2026-09-24', '2026-09-25', false],
|
||||||
|
['2026-09-25', '2026-09-26', false],
|
||||||
|
]);
|
||||||
|
expect(candleGaps(range, coverage, date('2026-09-25T23:59:59Z'))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(candleGaps(range, coverage, date('2026-09-26T00:00:01Z'))).toEqual([
|
||||||
|
{ from: date('2026-09-24Z'), to: range.to },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
candleGaps(
|
||||||
|
range,
|
||||||
|
confirmedCandleCoverage(range, [], date('2026-09-27Z')),
|
||||||
|
date('2030-01-01Z'),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
it('expires the mutable empty current day at exactly one minute', () => {
|
||||||
|
const range = { from: date('2026-09-25Z'), to: date('2026-09-26Z') };
|
||||||
|
const covered = confirmedCandleCoverage(
|
||||||
|
range,
|
||||||
|
[],
|
||||||
|
date('2026-09-25T08:00:00Z'),
|
||||||
|
);
|
||||||
|
expect(candleGaps(range, covered, date('2026-09-25T08:00:59Z'))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(candleGaps(range, covered, date('2026-09-25T08:01:00Z'))).toEqual([
|
||||||
|
range,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('splits leap-year history into contiguous windows of at most 365 days', () => {
|
||||||
|
const range = { from: date('2020-01-01Z'), to: date('2026-09-25Z') };
|
||||||
|
const windows = candleWindows(range);
|
||||||
|
expect(windows[0].from).toEqual(range.from);
|
||||||
|
expect(windows.at(-1)?.to).toEqual(range.to);
|
||||||
|
windows.forEach((window, index) => {
|
||||||
|
expect(window.to.valueOf() - window.from.valueOf()).toBeLessThanOrEqual(
|
||||||
|
365 * 86400000,
|
||||||
|
);
|
||||||
|
if (index) expect(window.from).toEqual(windows[index - 1].to);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||||
|
|
||||||
|
export type CandleRange = { from: Date; to: Date };
|
||||||
|
export type CandleCoverage = CandleRange & {
|
||||||
|
isComplete: boolean;
|
||||||
|
fetchedAt: Date;
|
||||||
|
};
|
||||||
|
export type SavedCandle = {
|
||||||
|
time: string;
|
||||||
|
close: string;
|
||||||
|
isComplete: boolean;
|
||||||
|
};
|
||||||
|
const windowDays = 365;
|
||||||
|
const tailFreshForMs = 60_000;
|
||||||
|
export function utcDay(value: Date): Date {
|
||||||
|
return new Date(
|
||||||
|
Math.floor(value.valueOf() / MILLISECONDS_PER_DAY) * MILLISECONDS_PER_DAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A clock transition never turns an unconfirmed range into closed coverage.
|
||||||
|
export function candleGaps(
|
||||||
|
range: CandleRange,
|
||||||
|
coverage: CandleCoverage[],
|
||||||
|
now: Date,
|
||||||
|
): CandleRange[] {
|
||||||
|
let gaps = [range];
|
||||||
|
for (const covered of coverage) {
|
||||||
|
if (
|
||||||
|
!covered.isComplete &&
|
||||||
|
(now.valueOf() - covered.fetchedAt.valueOf() >= tailFreshForMs ||
|
||||||
|
utcDay(now).valueOf() !== utcDay(covered.fetchedAt).valueOf())
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
gaps = gaps.flatMap((gap) => {
|
||||||
|
if (covered.to <= gap.from || covered.from >= gap.to) return [gap];
|
||||||
|
return [
|
||||||
|
...(covered.from > gap.from
|
||||||
|
? [{ from: gap.from, to: covered.from }]
|
||||||
|
: []),
|
||||||
|
...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return gaps.filter((gap) => gap.from < gap.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function candleWindows(range: CandleRange): CandleRange[] {
|
||||||
|
const windows: CandleRange[] = [];
|
||||||
|
for (let start = range.from.valueOf(); start < range.to.valueOf();) {
|
||||||
|
const end = Math.min(
|
||||||
|
start + windowDays * MILLISECONDS_PER_DAY,
|
||||||
|
range.to.valueOf(),
|
||||||
|
);
|
||||||
|
windows.push({ from: new Date(start), to: new Date(end) });
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
return windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmedCandleCoverage(
|
||||||
|
range: CandleRange,
|
||||||
|
candles: SavedCandle[],
|
||||||
|
fetchedAt: Date,
|
||||||
|
): CandleCoverage[] {
|
||||||
|
const boundaries = new Set([range.from.valueOf(), range.to.valueOf()]);
|
||||||
|
const midnight = utcDay(fetchedAt).valueOf();
|
||||||
|
const incompleteDays = new Set(
|
||||||
|
candles
|
||||||
|
.filter((candle) => !candle.isComplete)
|
||||||
|
.map((candle) => utcDay(new Date(candle.time)).valueOf()),
|
||||||
|
);
|
||||||
|
for (const boundary of [
|
||||||
|
midnight,
|
||||||
|
...[...incompleteDays].flatMap((day) => [day, day + MILLISECONDS_PER_DAY]),
|
||||||
|
])
|
||||||
|
if (boundary > range.from.valueOf() && boundary < range.to.valueOf())
|
||||||
|
boundaries.add(boundary);
|
||||||
|
const sorted = [...boundaries].sort((a, b) => a - b);
|
||||||
|
return sorted.slice(1).map((to, index) => {
|
||||||
|
const from = sorted[index];
|
||||||
|
return {
|
||||||
|
from: new Date(from),
|
||||||
|
to: new Date(to),
|
||||||
|
fetchedAt,
|
||||||
|
isComplete:
|
||||||
|
to <= midnight && !incompleteDays.has(utcDay(new Date(from)).valueOf()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import {
|
||||||
|
confirmedCandleCoverage,
|
||||||
|
type CandleRange,
|
||||||
|
type SavedCandle,
|
||||||
|
} from '../domain/candle-coverage';
|
||||||
|
|
||||||
|
const provider = 'tbank';
|
||||||
|
const interval = 'day';
|
||||||
|
type CandleIntent = {
|
||||||
|
instrumentType?: 'share' | 'bond';
|
||||||
|
uid: string;
|
||||||
|
ranges: CandleRange[];
|
||||||
|
period?: string;
|
||||||
|
now?: string;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
@Injectable()
|
||||||
|
export class CandleRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
read(uid: string, range: CandleRange) {
|
||||||
|
const identity = { provider, instrumentUid: uid, interval };
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const candles = await tx.tbankCandle.findMany({
|
||||||
|
where: { ...identity, time: { gte: range.from, lt: range.to } },
|
||||||
|
orderBy: { time: 'asc' },
|
||||||
|
});
|
||||||
|
const coverage = await tx.tbankCandleCoverage.findMany({
|
||||||
|
where: { ...identity, from: { lt: range.to }, to: { gt: range.from } },
|
||||||
|
orderBy: [{ from: 'asc' }, { to: 'asc' }],
|
||||||
|
});
|
||||||
|
const data = candles.map((row) => ({
|
||||||
|
time: row.time.toISOString(),
|
||||||
|
close: row.close,
|
||||||
|
isComplete: row.isComplete,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
coverage,
|
||||||
|
dataRevision: createHash('sha256')
|
||||||
|
.update(JSON.stringify(data))
|
||||||
|
.digest('base64url'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
coverage(uids: string[]) {
|
||||||
|
return this.prisma.tbankCandleCoverage.findMany({
|
||||||
|
where: { provider, instrumentUid: { in: uids }, interval },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reserve(
|
||||||
|
owner: { userId: string; revision: string },
|
||||||
|
scopeKey: string,
|
||||||
|
intent: CandleIntent,
|
||||||
|
runId: string,
|
||||||
|
active: ReadonlySet<string>,
|
||||||
|
priority = 1,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const current = await tx.tbankConnection.updateMany({
|
||||||
|
where: { ...owner, invalidToken: false },
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
if (!current.count) return null;
|
||||||
|
const scope = { ...owner, scopeKey };
|
||||||
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
previous?.state === 'running' &&
|
||||||
|
previous.processRunId === runId &&
|
||||||
|
active.has(previous.attemptId)
|
||||||
|
) {
|
||||||
|
const previousIntent = previous.intent as Prisma.JsonObject;
|
||||||
|
const previousNow =
|
||||||
|
typeof previousIntent.now === 'string' ? previousIntent.now : '';
|
||||||
|
const nextPriority = Math.min(previous.priority, priority);
|
||||||
|
if (
|
||||||
|
nextPriority === previous.priority &&
|
||||||
|
(!intent.force ||
|
||||||
|
(previousIntent.force === true &&
|
||||||
|
(!intent.now || intent.now <= previousNow)))
|
||||||
|
)
|
||||||
|
return { work: previous, launch: false };
|
||||||
|
const work = await tx.tbankSyncWork.update({
|
||||||
|
where: { id: previous.id },
|
||||||
|
data: {
|
||||||
|
priority: nextPriority,
|
||||||
|
intent: {
|
||||||
|
...previousIntent,
|
||||||
|
...(intent.force
|
||||||
|
? {
|
||||||
|
force: true,
|
||||||
|
...(intent.now && intent.now > previousNow
|
||||||
|
? { now: intent.now }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { work, launch: false };
|
||||||
|
}
|
||||||
|
if (previous)
|
||||||
|
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
|
||||||
|
const data = {
|
||||||
|
attemptId: randomUUID(),
|
||||||
|
processRunId: runId,
|
||||||
|
state: 'running',
|
||||||
|
priority,
|
||||||
|
intent: JSON.parse(JSON.stringify(intent)) as Prisma.InputJsonObject,
|
||||||
|
progress: {},
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
const work = await tx.tbankSyncWork.upsert({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
create: { ...scope, ...data, id: randomUUID() },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
return { work, launch: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
work(userId: string, revision: string, scopeKey: string) {
|
||||||
|
return this.prisma.tbankSyncWork.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: { userId, revision, scopeKey } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stage(
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
range: CandleRange,
|
||||||
|
candles: SavedCandle[],
|
||||||
|
fetchedAt: Date,
|
||||||
|
) {
|
||||||
|
// Reject malformed financial input instead of publishing a false empty range.
|
||||||
|
for (const candle of candles)
|
||||||
|
if (
|
||||||
|
!Number.isFinite(Date.parse(candle.time)) ||
|
||||||
|
!/^-?\d+(?:\.\d+)?$/.test(candle.close)
|
||||||
|
)
|
||||||
|
throw new Error('Invalid candle');
|
||||||
|
const key = `${range.from.toISOString()}/${range.to.toISOString()}`;
|
||||||
|
const rows = [
|
||||||
|
...new Map(
|
||||||
|
candles
|
||||||
|
.filter(
|
||||||
|
(candle) =>
|
||||||
|
new Date(candle.time) >= range.from &&
|
||||||
|
new Date(candle.time) < range.to,
|
||||||
|
)
|
||||||
|
.map((candle) => [candle.time, candle]),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const valid = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { progress: { lastWindow: key } },
|
||||||
|
});
|
||||||
|
if (!valid.count) return false;
|
||||||
|
const identity = {
|
||||||
|
workId: attempt.id,
|
||||||
|
attemptId: attempt.attemptId,
|
||||||
|
sourceKey: 'candles',
|
||||||
|
itemKey: key,
|
||||||
|
};
|
||||||
|
const payload = JSON.parse(
|
||||||
|
JSON.stringify({ range, candles: rows, fetchedAt }),
|
||||||
|
) as Prisma.InputJsonObject;
|
||||||
|
await tx.tbankSyncStage.upsert({
|
||||||
|
where: { workId_attemptId_sourceKey_itemKey: identity },
|
||||||
|
create: { ...identity, payload },
|
||||||
|
update: { payload },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
intent: CandleIntent,
|
||||||
|
completeWork = true,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...(completeWork ? { state: 'complete' } : {}),
|
||||||
|
errorCode: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!claimed.count) return false;
|
||||||
|
const staged = await tx.tbankSyncStage.findMany({
|
||||||
|
where: {
|
||||||
|
workId: attempt.id,
|
||||||
|
attemptId: attempt.attemptId,
|
||||||
|
sourceKey: 'candles',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
intent.ranges.some(
|
||||||
|
(range) =>
|
||||||
|
!staged.some(
|
||||||
|
(row) =>
|
||||||
|
row.itemKey ===
|
||||||
|
`${range.from.toISOString()}/${range.to.toISOString()}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('Incomplete candle reconciliation');
|
||||||
|
const identity = { provider, instrumentUid: intent.uid, interval };
|
||||||
|
for (const range of intent.ranges) {
|
||||||
|
const row = staged.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.itemKey ===
|
||||||
|
`${range.from.toISOString()}/${range.to.toISOString()}`,
|
||||||
|
);
|
||||||
|
if (!row) throw new Error('Incomplete candle reconciliation');
|
||||||
|
const payload = row.payload as {
|
||||||
|
candles: SavedCandle[];
|
||||||
|
fetchedAt: string;
|
||||||
|
};
|
||||||
|
const fetchedAt = new Date(payload.fetchedAt);
|
||||||
|
const old = await tx.tbankCandleCoverage.findMany({
|
||||||
|
where: {
|
||||||
|
...identity,
|
||||||
|
from: { lt: range.to },
|
||||||
|
to: { gt: range.from },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.tbankCandleCoverage.deleteMany({
|
||||||
|
where: {
|
||||||
|
...identity,
|
||||||
|
from: { lt: range.to },
|
||||||
|
to: { gt: range.from },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Preserve coverage outside the requested reconciliation boundaries.
|
||||||
|
for (const previous of old) {
|
||||||
|
for (const part of [
|
||||||
|
...(previous.from < range.from
|
||||||
|
? [{ from: previous.from, to: range.from }]
|
||||||
|
: []),
|
||||||
|
...(previous.to > range.to
|
||||||
|
? [{ from: range.to, to: previous.to }]
|
||||||
|
: []),
|
||||||
|
])
|
||||||
|
await tx.tbankCandleCoverage.create({
|
||||||
|
data: { ...previous, ...part },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.tbankCandle.deleteMany({
|
||||||
|
where: { ...identity, time: { gte: range.from, lt: range.to } },
|
||||||
|
});
|
||||||
|
for (const candle of payload.candles)
|
||||||
|
await tx.tbankCandle.create({
|
||||||
|
data: {
|
||||||
|
...identity,
|
||||||
|
time: new Date(candle.time),
|
||||||
|
close: candle.close,
|
||||||
|
isComplete: candle.isComplete,
|
||||||
|
payload: candle,
|
||||||
|
fetchedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const covered of confirmedCandleCoverage(
|
||||||
|
range,
|
||||||
|
payload.candles,
|
||||||
|
fetchedAt,
|
||||||
|
))
|
||||||
|
await tx.tbankCandleCoverage.create({
|
||||||
|
data: { ...identity, ...covered },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.tbankSyncStage.deleteMany({
|
||||||
|
where: { workId: attempt.id, attemptId: attempt.attemptId },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
completeObserver(attempt: TbankSyncAttempt, initiator: TbankSyncAttempt) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const origin = await tx.tbankSyncWork.findFirst({
|
||||||
|
where: {
|
||||||
|
...initiator,
|
||||||
|
state: 'complete',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!origin) return false;
|
||||||
|
const result = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { state: 'complete', errorCode: null },
|
||||||
|
});
|
||||||
|
return result.count > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(attempt: TbankSyncAttempt, errorCode: string) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { state: 'failed', errorCode },
|
||||||
|
});
|
||||||
|
await tx.tbankSyncStage.deleteMany({
|
||||||
|
where: { workId: attempt.id, attemptId: attempt.attemptId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import type {
|
||||||
|
TbankBond,
|
||||||
|
TbankShare,
|
||||||
|
TbankSyncAttempt,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
export type CatalogQuery = {
|
||||||
|
instrumentType: 'share' | 'bond';
|
||||||
|
query: string;
|
||||||
|
exactTicker?: boolean;
|
||||||
|
};
|
||||||
|
export type CatalogSource = {
|
||||||
|
instrumentType: 'share' | 'bond';
|
||||||
|
uid: string;
|
||||||
|
kind:
|
||||||
|
| 'detail'
|
||||||
|
| 'last-price'
|
||||||
|
| 'bond-current'
|
||||||
|
| 'description'
|
||||||
|
| 'fundamentals'
|
||||||
|
| 'trading-status'
|
||||||
|
| 'comparison'
|
||||||
|
| 'coupons'
|
||||||
|
| 'yield'
|
||||||
|
| 'rating'
|
||||||
|
| 'dividends'
|
||||||
|
| 'bond-events';
|
||||||
|
rangeKey?: string;
|
||||||
|
ticker?: string;
|
||||||
|
};
|
||||||
|
export type CatalogWork = CatalogQuery | CatalogSource;
|
||||||
|
export type CatalogInstrument = (TbankShare | TbankBond) & { uid: string };
|
||||||
|
const provider = 'tbank';
|
||||||
|
export function catalogQuery(input: CatalogQuery): CatalogQuery {
|
||||||
|
return {
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
query: input.query.trim().toUpperCase(),
|
||||||
|
...(input.exactTicker ? { exactTicker: true } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function catalogScope(input: CatalogWork) {
|
||||||
|
if ('uid' in input)
|
||||||
|
return `catalog-source:${JSON.stringify([
|
||||||
|
input.kind,
|
||||||
|
input.kind === 'detail' ? input.instrumentType : null,
|
||||||
|
input.rangeKey ?? '',
|
||||||
|
input.uid,
|
||||||
|
])}`;
|
||||||
|
return `${input.exactTicker ? 'ticker' : 'catalog'}:${JSON.stringify([input.instrumentType, input.query])}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CatalogDiscoveryRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
read(input: CatalogQuery) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const alias = input.exactTicker
|
||||||
|
? await tx.tbankInstrumentLookup.findUnique({
|
||||||
|
where: {
|
||||||
|
provider_identifierType_identifier: {
|
||||||
|
provider,
|
||||||
|
identifierType: `${input.instrumentType}-ticker`,
|
||||||
|
identifier: input.query,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const discovery = input.exactTicker
|
||||||
|
? alias
|
||||||
|
? {
|
||||||
|
instrumentUids: alias.matchedUids,
|
||||||
|
fetchedAt: alias.fetchedAt,
|
||||||
|
dataRevision: alias.dataRevision,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
: await tx.tbankCatalogDiscovery.findUnique({
|
||||||
|
where: {
|
||||||
|
provider_instrumentType_query: {
|
||||||
|
provider,
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
query: input.query,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!discovery) return { data: null, fetchedAt: null, dataRevision: '0' };
|
||||||
|
const uids = discovery.instrumentUids as string[];
|
||||||
|
const rows = await tx.tbankInstrumentSource.findMany({
|
||||||
|
where: {
|
||||||
|
provider,
|
||||||
|
instrumentUid: { in: uids },
|
||||||
|
source: provider,
|
||||||
|
kind: 'catalog',
|
||||||
|
rangeKey: input.instrumentType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const byUid = new Map(rows.map((row) => [row.instrumentUid, row]));
|
||||||
|
// A damaged/missing dependency is unknown, never a confirmed empty search.
|
||||||
|
if (uids.some((uid) => !byUid.has(uid)))
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
fetchedAt: null,
|
||||||
|
dataRevision: String(discovery.dataRevision),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
data: uids.map((uid) => byUid.get(uid)?.payload as CatalogInstrument),
|
||||||
|
fetchedAt: discovery.fetchedAt,
|
||||||
|
dataRevision: [
|
||||||
|
discovery.dataRevision,
|
||||||
|
...uids.map((uid) => byUid.get(uid)?.dataRevision),
|
||||||
|
].join('/'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
readSources(inputs: CatalogSource[]) {
|
||||||
|
return this.prisma.tbankInstrumentSource.findMany({
|
||||||
|
where: {
|
||||||
|
provider,
|
||||||
|
OR: inputs.map((input) => ({
|
||||||
|
instrumentUid: input.uid,
|
||||||
|
kind: input.kind,
|
||||||
|
source: sourceProvider(input),
|
||||||
|
rangeKey:
|
||||||
|
input.kind === 'detail'
|
||||||
|
? input.instrumentType
|
||||||
|
: (input.rangeKey ?? ''),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publishSource(
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
input: CatalogSource,
|
||||||
|
value: object,
|
||||||
|
now: Date,
|
||||||
|
initiator: TbankSyncAttempt = attempt,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const origin = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...initiator,
|
||||||
|
state: { in: ['running', 'complete'] },
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { progress: {} },
|
||||||
|
});
|
||||||
|
if (!origin.count) return false;
|
||||||
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { state: 'complete', progress: {} },
|
||||||
|
});
|
||||||
|
if (!claimed.count) return false;
|
||||||
|
const payload = JSON.parse(
|
||||||
|
JSON.stringify(value),
|
||||||
|
) as Prisma.InputJsonObject;
|
||||||
|
const identity = { provider, uid: input.uid };
|
||||||
|
await tx.tbankInstrument.upsert({
|
||||||
|
where: { provider_uid: identity },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
...identity,
|
||||||
|
metadata: {},
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
fetchedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const instrument = (
|
||||||
|
value as { instrument?: TbankShare | TbankBond | null }
|
||||||
|
).instrument;
|
||||||
|
if (
|
||||||
|
(input.kind === 'detail' || input.kind === 'bond-current') &&
|
||||||
|
instrument &&
|
||||||
|
(instrument.name?.trim() || instrument.ticker?.trim())
|
||||||
|
) {
|
||||||
|
const previous = await tx.tbankInstrument.findUniqueOrThrow({
|
||||||
|
where: { provider_uid: identity },
|
||||||
|
});
|
||||||
|
const metadata = JSON.parse(
|
||||||
|
JSON.stringify({
|
||||||
|
...instrument,
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
}),
|
||||||
|
) as Prisma.InputJsonObject;
|
||||||
|
await tx.tbankInstrument.update({
|
||||||
|
where: { provider_uid: identity },
|
||||||
|
data: {
|
||||||
|
metadata,
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
figi: instrument.figi ?? null,
|
||||||
|
ticker: instrument.ticker ?? null,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(previous.metadata, metadata) ? 0 : 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const [identifierType, identifier] of [
|
||||||
|
['uid', input.uid],
|
||||||
|
['figi', instrument.figi],
|
||||||
|
] as const) {
|
||||||
|
if (!identifier) continue;
|
||||||
|
const alias = { provider, identifierType, identifier };
|
||||||
|
const previousAlias = await tx.tbankInstrumentLookup.findUnique({
|
||||||
|
where: { provider_identifierType_identifier: alias },
|
||||||
|
});
|
||||||
|
const matchedUids = [input.uid];
|
||||||
|
await tx.tbankInstrumentLookup.upsert({
|
||||||
|
where: { provider_identifierType_identifier: alias },
|
||||||
|
create: { ...alias, matchedUids, fetchedAt: now },
|
||||||
|
update: {
|
||||||
|
matchedUids,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(
|
||||||
|
previousAlias?.matchedUids,
|
||||||
|
matchedUids,
|
||||||
|
)
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const key = {
|
||||||
|
provider,
|
||||||
|
instrumentUid: input.uid,
|
||||||
|
source: sourceProvider(input),
|
||||||
|
kind: input.kind,
|
||||||
|
rangeKey:
|
||||||
|
input.kind === 'detail'
|
||||||
|
? input.instrumentType
|
||||||
|
: (input.rangeKey ?? ''),
|
||||||
|
};
|
||||||
|
const previous = await tx.tbankInstrumentSource.findUnique({
|
||||||
|
where: { provider_instrumentUid_source_kind_rangeKey: key },
|
||||||
|
});
|
||||||
|
await tx.tbankInstrumentSource.upsert({
|
||||||
|
where: { provider_instrumentUid_source_kind_rangeKey: key },
|
||||||
|
create: { ...key, payload, fetchedAt: now },
|
||||||
|
update: {
|
||||||
|
payload,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reserve(
|
||||||
|
owner: { userId: string; revision: string },
|
||||||
|
input: CatalogWork,
|
||||||
|
runId: string,
|
||||||
|
active: ReadonlySet<string>,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const current = await tx.tbankConnection.updateMany({
|
||||||
|
where: { ...owner, invalidToken: false },
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
if (!current.count) return null;
|
||||||
|
const scope = { ...owner, scopeKey: catalogScope(input) };
|
||||||
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
previous?.state === 'running' &&
|
||||||
|
previous.processRunId === runId &&
|
||||||
|
active.has(previous.attemptId)
|
||||||
|
)
|
||||||
|
return { work: previous, launch: false };
|
||||||
|
const data = {
|
||||||
|
attemptId: randomUUID(),
|
||||||
|
processRunId: runId,
|
||||||
|
state: 'running',
|
||||||
|
priority: 1,
|
||||||
|
intent: { source: 'catalog', ...input },
|
||||||
|
progress: {},
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
const work = await tx.tbankSyncWork.upsert({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
create: { ...scope, ...data, id: randomUUID() },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
return { work, launch: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
workMany(userId: string, revision: string, inputs: CatalogWork[]) {
|
||||||
|
return this.prisma.tbankSyncWork.findMany({
|
||||||
|
where: { userId, revision, scopeKey: { in: inputs.map(catalogScope) } },
|
||||||
|
select: { scopeKey: true, id: true, state: true, errorCode: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
work(userId: string, revision: string, input: CatalogWork) {
|
||||||
|
return this.prisma.tbankSyncWork.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_revision_scopeKey: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
scopeKey: catalogScope(input),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, state: true, errorCode: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
input: CatalogQuery,
|
||||||
|
instruments: CatalogInstrument[],
|
||||||
|
now: Date,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { state: 'complete', progress: {} },
|
||||||
|
});
|
||||||
|
if (!claimed.count) return false;
|
||||||
|
for (const instrument of instruments) {
|
||||||
|
// Discovery can create a minimal identity, but cannot overwrite verified metadata.
|
||||||
|
const identity = { provider, uid: instrument.uid };
|
||||||
|
const payload = JSON.parse(
|
||||||
|
JSON.stringify({
|
||||||
|
uid: instrument.uid,
|
||||||
|
figi: instrument.figi,
|
||||||
|
ticker: instrument.ticker,
|
||||||
|
name: instrument.name,
|
||||||
|
isin: instrument.isin,
|
||||||
|
classCode: instrument.classCode,
|
||||||
|
instrumentKind: instrument.instrumentKind,
|
||||||
|
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
|
||||||
|
currency: instrument.currency,
|
||||||
|
brand: instrument.brand
|
||||||
|
? { logoName: instrument.brand.logoName }
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
) as Prisma.InputJsonObject;
|
||||||
|
await tx.tbankInstrument.upsert({
|
||||||
|
where: { provider_uid: identity },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
...identity,
|
||||||
|
metadata: payload,
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
figi: instrument.figi,
|
||||||
|
ticker: instrument.ticker,
|
||||||
|
fetchedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const key = {
|
||||||
|
provider,
|
||||||
|
instrumentUid: instrument.uid,
|
||||||
|
source: provider,
|
||||||
|
kind: 'catalog',
|
||||||
|
rangeKey: input.instrumentType,
|
||||||
|
};
|
||||||
|
const previous = await tx.tbankInstrumentSource.findUnique({
|
||||||
|
where: { provider_instrumentUid_source_kind_rangeKey: key },
|
||||||
|
});
|
||||||
|
await tx.tbankInstrumentSource.upsert({
|
||||||
|
where: { provider_instrumentUid_source_kind_rangeKey: key },
|
||||||
|
create: { ...key, payload, fetchedAt: now },
|
||||||
|
update: {
|
||||||
|
payload,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const instrumentUids = instruments.map((instrument) => instrument.uid);
|
||||||
|
if (input.exactTicker) {
|
||||||
|
const key = {
|
||||||
|
provider,
|
||||||
|
identifierType: `${input.instrumentType}-ticker`,
|
||||||
|
identifier: input.query,
|
||||||
|
};
|
||||||
|
const previous = await tx.tbankInstrumentLookup.findUnique({
|
||||||
|
where: { provider_identifierType_identifier: key },
|
||||||
|
});
|
||||||
|
await tx.tbankInstrumentLookup.upsert({
|
||||||
|
where: { provider_identifierType_identifier: key },
|
||||||
|
create: { ...key, matchedUids: instrumentUids, fetchedAt: now },
|
||||||
|
update: {
|
||||||
|
matchedUids: instrumentUids,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(
|
||||||
|
previous?.matchedUids,
|
||||||
|
instrumentUids,
|
||||||
|
)
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const key = {
|
||||||
|
provider,
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
query: input.query,
|
||||||
|
};
|
||||||
|
const previous = await tx.tbankCatalogDiscovery.findUnique({
|
||||||
|
where: { provider_instrumentType_query: key },
|
||||||
|
});
|
||||||
|
await tx.tbankCatalogDiscovery.upsert({
|
||||||
|
where: { provider_instrumentType_query: key },
|
||||||
|
create: { ...key, instrumentUids, fetchedAt: now },
|
||||||
|
update: {
|
||||||
|
instrumentUids,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(
|
||||||
|
previous?.instrumentUids,
|
||||||
|
instrumentUids,
|
||||||
|
)
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(attempt: TbankSyncAttempt, success: boolean) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: true },
|
||||||
|
},
|
||||||
|
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||||
|
});
|
||||||
|
await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
state: success ? 'complete' : 'failed',
|
||||||
|
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceProvider(input: CatalogSource) {
|
||||||
|
return input.kind === 'yield' || input.kind === 'bond-events'
|
||||||
|
? 'moex'
|
||||||
|
: input.kind === 'rating'
|
||||||
|
? 'ncr'
|
||||||
|
: 'tbank';
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { StockIncomeService } from './stocks/application/stock-income.service';
|
||||||
|
export { BondIncomeService } from './bonds/application/bond-income.service';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MoexModule } from '../../../integrations/moex/moex.module';
|
||||||
|
import { NcrModule } from '../../../integrations/ncr/ncr.module';
|
||||||
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
|
import { CandleService } from './application/candle.service';
|
||||||
|
import { CandleRepository } from './infrastructure/candle.repository';
|
||||||
|
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
||||||
|
import { CatalogDiscoveryService } from './application/catalog-discovery.service';
|
||||||
|
import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TbankModule, MoexModule, NcrModule],
|
||||||
|
providers: [
|
||||||
|
CandleService,
|
||||||
|
CandleRepository,
|
||||||
|
CatalogEnrichmentService,
|
||||||
|
CatalogDiscoveryService,
|
||||||
|
CatalogDiscoveryRepository,
|
||||||
|
],
|
||||||
|
exports: [CandleService, CatalogEnrichmentService, CatalogDiscoveryService],
|
||||||
|
})
|
||||||
|
export class InstrumentSourcesModule {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { CandleService } from './application/candle.service';
|
||||||
|
export { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
||||||
|
export type { CatalogPayload } from './application/catalog-enrichment.service';
|
||||||
|
|
||||||
|
export { CatalogDiscoveryService } from './application/catalog-discovery.service';
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
|
import { InstrumentSourcesModule } from './instrument-sources.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MoexModule } from '../../../integrations/moex/moex.module';
|
|
||||||
import { NcrModule } from '../../../integrations/ncr/ncr.module';
|
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { 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 { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
@@ -15,8 +14,9 @@ import { StockController } from './stocks/presentation/stock.controller';
|
|||||||
import { StockCatalogService } from './stocks/application/stock-catalog.service';
|
import { StockCatalogService } from './stocks/application/stock-catalog.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TbankModule, MoexModule, NcrModule, PortfolioModule],
|
imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule],
|
||||||
controllers: [StockController, BondController],
|
controllers: [StockController, BondController],
|
||||||
|
exports: [StockIncomeService, BondIncomeService],
|
||||||
providers: [
|
providers: [
|
||||||
BondIncomeService,
|
BondIncomeService,
|
||||||
BondMarketService,
|
BondMarketService,
|
||||||
|
|||||||
+30
-38
@@ -1,50 +1,42 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
|
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
||||||
import { optional } from '../../application/instrument-market';
|
import {
|
||||||
import { StockCompanyDto, StockMetricDto } from '../presentation/stock.dto';
|
StockCompanyResourceDto,
|
||||||
|
StockMetricDto,
|
||||||
import { StockCatalogService } from './stock-catalog.service';
|
} from '../presentation/stock.dto';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GetStockCompanyUseCase {
|
export class GetStockCompanyUseCase {
|
||||||
constructor(
|
constructor(private readonly sources: CatalogEnrichmentService) {}
|
||||||
private readonly catalog: StockCatalogService,
|
async execute(
|
||||||
private readonly tbank: TbankClient,
|
userId: string,
|
||||||
) {}
|
uid: string,
|
||||||
execute(userId: string, uid: string): Promise<StockCompanyDto> {
|
observe = false,
|
||||||
return this.catalog.cached(userId, `company:${uid}`, async (token) => {
|
): Promise<StockCompanyResourceDto> {
|
||||||
const share = await this.catalog.share(token, uid);
|
const result = await this.sources.section(
|
||||||
const unavailable: string[] = [];
|
userId,
|
||||||
const assetUid = share.assetUid;
|
'share',
|
||||||
if (!assetUid)
|
uid,
|
||||||
|
'company',
|
||||||
|
observe,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
description: null,
|
data: result.values.instrument
|
||||||
metrics: [],
|
? {
|
||||||
unavailable,
|
description: result.values.description ?? null,
|
||||||
observedAt: new Date().toISOString(),
|
metrics: metrics(
|
||||||
};
|
result.values.fundamentals ?? null,
|
||||||
const [description, fundamentals] = await Promise.all([
|
result.values.instrument.sector,
|
||||||
optional(
|
|
||||||
() => this.tbank.getAssetDescription(token, assetUid),
|
|
||||||
'description',
|
|
||||||
unavailable,
|
|
||||||
),
|
),
|
||||||
optional(
|
unavailable: result.unavailable,
|
||||||
() => this.tbank.getFundamentals(token, assetUid),
|
observedAt: result.observedAt,
|
||||||
'fundamentals',
|
}
|
||||||
unavailable,
|
: null,
|
||||||
),
|
meta: result.meta,
|
||||||
]);
|
|
||||||
return {
|
|
||||||
description,
|
|
||||||
metrics: metrics(fundamentals, share.sector),
|
|
||||||
unavailable,
|
|
||||||
observedAt: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function metrics(
|
export function metrics(
|
||||||
values: TbankFundamentals | null,
|
values: TbankFundamentals | null,
|
||||||
sector?: string,
|
sector?: string,
|
||||||
): StockMetricDto[] {
|
): StockMetricDto[] {
|
||||||
|
|||||||
@@ -1,70 +1,39 @@
|
|||||||
import {
|
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||||
ConflictException,
|
import { Injectable } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { LRUCache } from 'lru-cache';
|
|
||||||
import { formatDecimal } from '../../../../../common/decimal';
|
import { formatDecimal } from '../../../../../common/decimal';
|
||||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||||
import {
|
|
||||||
TbankAccessService,
|
|
||||||
TbankClient,
|
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||||
import { optional, timestamp } from '../../application/instrument-market';
|
import { timestamp } from '../../application/instrument-market';
|
||||||
import { StockDto, StockSearchResultDto } from '../presentation/stock.dto';
|
import {
|
||||||
|
StockDto,
|
||||||
const SEARCH_RESULTS_LIMIT = 30;
|
StockResourceDto,
|
||||||
|
StockSearchResourceDto,
|
||||||
|
} from '../presentation/stock.dto';
|
||||||
|
|
||||||
type IdentifiedShare = TbankShare & { uid: string };
|
type IdentifiedShare = TbankShare & { uid: string };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StockCatalogService {
|
export class StockCatalogService {
|
||||||
private readonly cache = new LRUCache<string, Promise<object>>({
|
constructor(private readonly discovery: CatalogDiscoveryService) {}
|
||||||
max: 300,
|
async search(
|
||||||
ttl: 30_000,
|
userId: string,
|
||||||
});
|
query: string,
|
||||||
constructor(
|
observe = false,
|
||||||
private readonly access: TbankAccessService,
|
): Promise<StockSearchResourceDto> {
|
||||||
private readonly tbank: TbankClient,
|
const result = await this.discovery.search(
|
||||||
) {}
|
userId,
|
||||||
search(userId: string, query: string): Promise<StockSearchResultDto[]> {
|
{ instrumentType: 'share', query },
|
||||||
return this.cached(userId, `search:${query}`, async (token) => {
|
observe,
|
||||||
const found = await this.tbank.findShares(token, query);
|
|
||||||
const shares = selectShares(found, query).slice(0, SEARCH_RESULTS_LIMIT);
|
|
||||||
if (!shares.length) return [];
|
|
||||||
// One market-data request for the list; failed enrichment must not hide a catalog result.
|
|
||||||
const [details, prices] = await Promise.all([
|
|
||||||
Promise.all(
|
|
||||||
shares.map(
|
|
||||||
async (share) =>
|
|
||||||
(await optional(
|
|
||||||
() =>
|
|
||||||
this.get(userId, share.uid).catch((error: unknown) => {
|
|
||||||
if (error instanceof NotFoundException) return stock(share);
|
|
||||||
throw error;
|
|
||||||
}),
|
|
||||||
'details',
|
|
||||||
[],
|
|
||||||
)) ?? stock(share),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
optional(
|
|
||||||
() =>
|
|
||||||
this.tbank.getLastPrices(
|
|
||||||
token,
|
|
||||||
shares.map((share) => share.uid),
|
|
||||||
),
|
|
||||||
'prices',
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
return details.map((detail, index) => {
|
|
||||||
const short = stock(shares[index]);
|
|
||||||
const last = prices?.find(
|
|
||||||
(price) => price.instrumentUid === detail.instrumentUid,
|
|
||||||
);
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data:
|
||||||
|
result.data?.map((item) => {
|
||||||
|
const short = stock(item);
|
||||||
|
const detail = item.detail
|
||||||
|
? stock({ ...item.detail, uid: item.uid })
|
||||||
|
: short;
|
||||||
|
const last = item.price;
|
||||||
return {
|
return {
|
||||||
...detail,
|
...detail,
|
||||||
classCode: detail.classCode ?? short.classCode,
|
classCode: detail.classCode ?? short.classCode,
|
||||||
@@ -79,107 +48,40 @@ export class StockCatalogService {
|
|||||||
: null,
|
: null,
|
||||||
priceAt: timestamp(last?.time),
|
priceAt: timestamp(last?.time),
|
||||||
};
|
};
|
||||||
});
|
}) ?? null,
|
||||||
});
|
|
||||||
}
|
|
||||||
get(userId: string, uid: string): Promise<StockDto> {
|
|
||||||
return this.cached(userId, `share:${uid}`, async (token) => {
|
|
||||||
const result = stock(await this.share(token, uid));
|
|
||||||
if (
|
|
||||||
!result.ticker ||
|
|
||||||
!/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker)
|
|
||||||
)
|
|
||||||
return result;
|
|
||||||
try {
|
|
||||||
const match = await this.tickerMatch(userId, result.ticker);
|
|
||||||
if (match.uid === uid) result.slug = result.ticker.toUpperCase();
|
|
||||||
} catch (error) {
|
|
||||||
// A readable alias is optional for an exact UID URL, never a reason to substitute another listing.
|
|
||||||
if (
|
|
||||||
!(error instanceof NotFoundException) &&
|
|
||||||
!(error instanceof ConflictException) &&
|
|
||||||
!(error instanceof ServiceUnavailableException)
|
|
||||||
)
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async getByTicker(userId: string, ticker: string): Promise<StockDto> {
|
|
||||||
const match = await this.tickerMatch(userId, ticker);
|
|
||||||
return {
|
|
||||||
...(await this.get(userId, match.uid)),
|
|
||||||
slug: ticker.trim().toUpperCase(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
private tickerMatch(
|
async get(
|
||||||
|
userId: string,
|
||||||
|
uid: string,
|
||||||
|
observe = false,
|
||||||
|
): Promise<StockResourceDto> {
|
||||||
|
const result = await this.discovery.detail(userId, 'share', uid, observe);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data
|
||||||
|
? { ...stock(result.data), slug: result.data.slug }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async getByTicker(
|
||||||
userId: string,
|
userId: string,
|
||||||
ticker: string,
|
ticker: string,
|
||||||
): Promise<IdentifiedShare> {
|
observe = false,
|
||||||
const normalized = ticker.trim().toUpperCase();
|
): Promise<StockResourceDto> {
|
||||||
return this.cached(userId, `ticker:${normalized}`, async (token) => {
|
const result = await this.discovery.byTicker(
|
||||||
const found = await this.tbank.findShares(token, normalized);
|
userId,
|
||||||
const matches = selectShares(
|
'share',
|
||||||
found.filter(
|
ticker,
|
||||||
(item) => item.ticker?.trim().toUpperCase() === normalized,
|
observe,
|
||||||
),
|
|
||||||
normalized,
|
|
||||||
);
|
);
|
||||||
if (!matches.length) throw new NotFoundException();
|
return {
|
||||||
if (matches.length > 1)
|
...result,
|
||||||
throw new ConflictException({ code: 'STOCK_TICKER_AMBIGUOUS' });
|
data: result.data
|
||||||
return matches[0];
|
? { ...stock(result.data), slug: result.data.slug }
|
||||||
});
|
: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
async share(token: string, uid: string): Promise<IdentifiedShare> {
|
|
||||||
const share = await this.tbank.getShare(token, uid);
|
|
||||||
if (!share || share.uid !== uid) throw new NotFoundException();
|
|
||||||
return { ...share, uid };
|
|
||||||
}
|
|
||||||
cached<T extends object>(
|
|
||||||
userId: string,
|
|
||||||
key: string,
|
|
||||||
operation: (token: string) => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
return this.access.withConnection(userId, ({ token, generation }) => {
|
|
||||||
const cacheKey = JSON.stringify([userId, generation, key]);
|
|
||||||
const cached = this.cache.get(cacheKey);
|
|
||||||
if (cached) return cached as Promise<T>;
|
|
||||||
const pending = operation(token);
|
|
||||||
this.cache.set(cacheKey, pending);
|
|
||||||
return pending.catch((error) => {
|
|
||||||
if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey);
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectShares(found: TbankShare[], query: string): IdentifiedShare[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return found
|
|
||||||
.filter(
|
|
||||||
(share): share is IdentifiedShare =>
|
|
||||||
Boolean(share.uid) && share.instrumentKind === 'INSTRUMENT_TYPE_SHARE',
|
|
||||||
)
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
Number(b.apiTradeAvailableFlag === true) -
|
|
||||||
Number(a.apiTradeAvailableFlag === true) ||
|
|
||||||
Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
|
|
||||||
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
|
|
||||||
Number(b.classCode === 'TQBR') - Number(a.classCode === 'TQBR') ||
|
|
||||||
a.uid.localeCompare(b.uid),
|
|
||||||
)
|
|
||||||
.filter((share) => {
|
|
||||||
// A missing ISIN must not collapse unrelated catalog instruments.
|
|
||||||
const key = share.isin?.trim()
|
|
||||||
? `isin:${share.isin.trim().toUpperCase()}`
|
|
||||||
: `uid:${share.uid}`;
|
|
||||||
if (seen.has(key)) return false;
|
|
||||||
seen.add(key);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stock(share: IdentifiedShare): StockDto {
|
function stock(share: IdentifiedShare): StockDto {
|
||||||
|
|||||||
@@ -1,51 +1,111 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
} from '../../../../../common/decimal';
|
} from '../../../../../common/decimal';
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
|
||||||
import { PortfolioService } from '../../../portfolio/portfolio.api';
|
|
||||||
import {
|
import {
|
||||||
moscowDay,
|
TbankAccessService,
|
||||||
optional,
|
type TbankDividend,
|
||||||
timestamp,
|
} from '../../../../../integrations/tbank/tbank.api';
|
||||||
} from '../../application/instrument-market';
|
import {
|
||||||
import { stockPosition } from './stock-position';
|
PortfolioService,
|
||||||
|
ForecastService,
|
||||||
|
} from '../../../portfolio/portfolio.api';
|
||||||
|
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
||||||
|
import { moscowDay, timestamp } from '../../application/instrument-market';
|
||||||
|
import { instrumentPosition } from '../../application/instrument-position';
|
||||||
import {
|
import {
|
||||||
StockDividendDto,
|
|
||||||
StockDividendsDto,
|
StockDividendsDto,
|
||||||
|
StockDividendsResourceDto,
|
||||||
StockPositionDto,
|
StockPositionDto,
|
||||||
|
StockDividendDto,
|
||||||
} from '../presentation/stock.dto';
|
} from '../presentation/stock.dto';
|
||||||
|
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||||
import { StockCatalogService } from './stock-catalog.service';
|
|
||||||
|
|
||||||
const ISO_YEAR_LENGTH = 4;
|
const ISO_YEAR_LENGTH = 4;
|
||||||
const INCOME_HISTORY_YEARS = 5;
|
const INCOME_HISTORY_YEARS = 5;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StockIncomeService {
|
export class StockIncomeService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly catalog: StockCatalogService,
|
|
||||||
private readonly tbank: TbankClient,
|
|
||||||
private readonly portfolios: PortfolioService,
|
private readonly portfolios: PortfolioService,
|
||||||
|
private readonly sources: CatalogEnrichmentService,
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly forecasts: ForecastService,
|
||||||
) {}
|
) {}
|
||||||
async position(userId: string, uid: string): Promise<StockPositionDto> {
|
|
||||||
return this.catalog.cached(userId, `position:${uid}`, async () =>
|
async readPosition(userId: string, uid: string, observe = false) {
|
||||||
stockPosition(await this.portfolios.getAll(userId), uid),
|
const saved = await this.portfolios.read(userId, 'all', observe);
|
||||||
);
|
return {
|
||||||
|
data: saved.data ? instrumentPosition(saved.data, uid, 'STOCK') : null,
|
||||||
|
meta: { ...saved.meta, resourceKey: `stock/position/${uid}` },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
dividends(userId: string, uid: string): Promise<StockDividendsDto> {
|
async dividends(
|
||||||
return this.catalog.cached(userId, `dividends:${uid}`, async (token) => {
|
userId: string,
|
||||||
await this.catalog.share(token, uid);
|
uid: string,
|
||||||
|
observe = false,
|
||||||
|
): Promise<StockDividendsResourceDto> {
|
||||||
|
const connection = await this.access.local(userId);
|
||||||
const today = moscowDay(new Date().toISOString());
|
const today = moscowDay(new Date().toISOString());
|
||||||
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||||
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||||
const unavailable: string[] = [];
|
const portfolio = await this.portfolios.read(userId, 'all', observe);
|
||||||
const [events, position] = await Promise.all([
|
const sources = await this.sources.readMany(
|
||||||
this.tbank.getDividends(token, uid, from, to),
|
userId,
|
||||||
optional(() => this.position(userId, uid), 'position', unavailable),
|
'share',
|
||||||
]);
|
[uid],
|
||||||
|
observe,
|
||||||
|
['detail', 'dividends'],
|
||||||
|
{ from, to },
|
||||||
|
);
|
||||||
|
if (sources[0].meta.availability === 'complete' && !sources[0].instrument)
|
||||||
|
throw new NotFoundException();
|
||||||
|
const events = sources[1].payload?.dividends;
|
||||||
|
const position = portfolio.data
|
||||||
|
? instrumentPosition(portfolio.data, uid, 'STOCK')
|
||||||
|
: null;
|
||||||
|
const projected = events
|
||||||
|
? stockDividends(
|
||||||
|
events,
|
||||||
|
position,
|
||||||
|
{ from, to, today },
|
||||||
|
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||||
|
!portfolio.data,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const result = await this.forecasts.result({
|
||||||
|
userId,
|
||||||
|
revision: connection.revision,
|
||||||
|
membershipRevision: connection.membershipRevision,
|
||||||
|
scopeKey: `stock/dividends/${JSON.stringify([uid, from, to])}`,
|
||||||
|
portfolio: portfolio.meta,
|
||||||
|
sources,
|
||||||
|
candidate:
|
||||||
|
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||||
|
});
|
||||||
|
return result.data || !projected
|
||||||
|
? result
|
||||||
|
: {
|
||||||
|
data: stockDividends(
|
||||||
|
events ?? [],
|
||||||
|
null,
|
||||||
|
{ from, to, today },
|
||||||
|
projected.observedAt,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
meta: { ...result.meta, availability: 'partial' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stockDividends(
|
||||||
|
events: TbankDividend[],
|
||||||
|
position: StockPositionDto | null,
|
||||||
|
window: { from: string; to: string; today: string },
|
||||||
|
observedAt: string,
|
||||||
|
positionUnavailable: boolean,
|
||||||
|
): StockDividendsDto {
|
||||||
|
const { from, to, today } = window;
|
||||||
const quantity =
|
const quantity =
|
||||||
position &&
|
position &&
|
||||||
position.accounts.length &&
|
position.accounts.length &&
|
||||||
@@ -56,10 +116,8 @@ export class StockIncomeService {
|
|||||||
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
|
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
|
||||||
.map((event) => {
|
.map((event) => {
|
||||||
const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null;
|
const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null;
|
||||||
const paymentDate =
|
const paymentDate = timestamp(event.paymentDate)?.slice(0, 10) ?? null;
|
||||||
timestamp(event.paymentDate)?.slice(0, 10) ?? null;
|
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(0, 10) ?? null;
|
||||||
const lastBuyDate =
|
|
||||||
timestamp(event.lastBuyDate)?.slice(0, 10) ?? null;
|
|
||||||
const currency = event.dividendNet?.currency?.toUpperCase();
|
const currency = event.dividendNet?.currency?.toUpperCase();
|
||||||
const perShare =
|
const perShare =
|
||||||
currency && event.dividendNet
|
currency && event.dividendNet
|
||||||
@@ -99,11 +157,9 @@ export class StockIncomeService {
|
|||||||
dividends,
|
dividends,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
observedAt: new Date().toISOString(),
|
observedAt: observedAt,
|
||||||
positionAt: quantity ? (position?.observedAt ?? null) : null,
|
positionAt: quantity ? (position?.observedAt ?? null) : null,
|
||||||
estimateUnavailable: unavailable.length > 0,
|
estimateUnavailable: positionUnavailable,
|
||||||
positionIsStale: position?.isStale ?? false,
|
positionIsStale: position?.isStale ?? false,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { CandleService } from '../../application/candle.service';
|
||||||
|
import {
|
||||||
|
CatalogEnrichmentService,
|
||||||
|
type CatalogPayload,
|
||||||
|
} from '../../application/catalog-enrichment.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
divide,
|
||||||
@@ -6,76 +11,67 @@ import {
|
|||||||
multiply,
|
multiply,
|
||||||
subtract,
|
subtract,
|
||||||
} from '../../../../../common/decimal';
|
} from '../../../../../common/decimal';
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||||
|
import { timestamp } from '../../application/instrument-market';
|
||||||
import {
|
import {
|
||||||
optional,
|
|
||||||
previousClose,
|
|
||||||
timestamp,
|
|
||||||
} from '../../application/instrument-market';
|
|
||||||
import {
|
|
||||||
StockCandlesDto,
|
|
||||||
StockCandlesQueryDto,
|
StockCandlesQueryDto,
|
||||||
StockQuoteDto,
|
StockQuoteDto,
|
||||||
|
StockQuoteResourceDto,
|
||||||
} from '../presentation/stock.dto';
|
} from '../presentation/stock.dto';
|
||||||
|
|
||||||
const dayMs = 86_400_000;
|
|
||||||
|
|
||||||
import { StockCatalogService } from './stock-catalog.service';
|
|
||||||
|
|
||||||
const PREVIOUS_CLOSE_LOOKBACK_DAYS = 20;
|
|
||||||
const HALF_YEAR_MONTHS = 6;
|
|
||||||
const YEAR_MONTHS = 12;
|
|
||||||
const CANDLE_WINDOW_DAYS = 365;
|
|
||||||
const CHANGE_PERCENT_DECIMAL_PLACES = 6;
|
const CHANGE_PERCENT_DECIMAL_PLACES = 6;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StockMarketService {
|
export class StockMarketService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly catalog: StockCatalogService,
|
private readonly history: CandleService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly sources: CatalogEnrichmentService,
|
||||||
) {}
|
) {}
|
||||||
quote(userId: string, uid: string): Promise<StockQuoteDto> {
|
async quote(
|
||||||
return this.catalog.cached(userId, `quote:${uid}`, async (token) => {
|
userId: string,
|
||||||
const share = await this.catalog.share(token, uid);
|
uid: string,
|
||||||
const unavailable: string[] = [];
|
observe = false,
|
||||||
const [last, tradingStatus] = await Promise.all([
|
): Promise<StockQuoteResourceDto> {
|
||||||
optional(
|
const result = await this.sources.section(
|
||||||
() => this.tbank.getLastPrice(token, uid),
|
userId,
|
||||||
'price',
|
'share',
|
||||||
unavailable,
|
uid,
|
||||||
),
|
'quote',
|
||||||
optional(
|
observe,
|
||||||
() => this.tbank.getTradingStatus(token, uid),
|
);
|
||||||
'status',
|
return {
|
||||||
unavailable,
|
data: result.values.instrument
|
||||||
),
|
? stockQuote(result.values, result.unavailable, result.observedAt)
|
||||||
]);
|
: null,
|
||||||
|
meta: result.meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
candles(
|
||||||
|
userId: string,
|
||||||
|
uid: string,
|
||||||
|
period: StockCandlesQueryDto['period'] = 'month',
|
||||||
|
observe = false,
|
||||||
|
) {
|
||||||
|
return this.history.read(
|
||||||
|
userId,
|
||||||
|
{ uid, instrumentType: 'share', period },
|
||||||
|
observe,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stockQuote(
|
||||||
|
values: CatalogPayload,
|
||||||
|
unavailable: string[],
|
||||||
|
observedAt: string,
|
||||||
|
): StockQuoteDto {
|
||||||
|
const { price: last, candle: previous, tradingStatus = null } = values;
|
||||||
|
const share = values.instrument;
|
||||||
const priceAt = timestamp(last?.time);
|
const priceAt = timestamp(last?.time);
|
||||||
const currency = share.currency?.toUpperCase();
|
const currency = share?.currency?.toUpperCase();
|
||||||
const price =
|
const price =
|
||||||
last?.price && currency
|
last?.price && currency
|
||||||
? { currency, value: formatDecimal(decimal(last.price)) }
|
? { currency, value: formatDecimal(decimal(last.price)) }
|
||||||
: null;
|
: null;
|
||||||
const previous = priceAt
|
|
||||||
? await optional(
|
|
||||||
async () => {
|
|
||||||
const end = new Date(priceAt);
|
|
||||||
return previousClose(
|
|
||||||
await this.tbank.getCandles(
|
|
||||||
token,
|
|
||||||
uid,
|
|
||||||
new Date(
|
|
||||||
end.valueOf() - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs,
|
|
||||||
),
|
|
||||||
end,
|
|
||||||
),
|
|
||||||
priceAt,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
'comparison',
|
|
||||||
unavailable,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const previousPrice =
|
const previousPrice =
|
||||||
previous?.close && currency
|
previous?.close && currency
|
||||||
? { currency, value: formatDecimal(decimal(previous.close)) }
|
? { currency, value: formatDecimal(decimal(previous.close)) }
|
||||||
@@ -110,76 +106,6 @@ export class StockMarketService {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
unavailable,
|
unavailable,
|
||||||
observedAt: new Date().toISOString(),
|
observedAt,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
candles(
|
|
||||||
userId: string,
|
|
||||||
uid: string,
|
|
||||||
period: StockCandlesQueryDto['period'] = 'month',
|
|
||||||
): Promise<StockCandlesDto> {
|
|
||||||
return this.catalog.cached(
|
|
||||||
userId,
|
|
||||||
`candles:${uid}:${period}`,
|
|
||||||
async (token) => {
|
|
||||||
const share = await this.catalog.share(token, uid);
|
|
||||||
const historyFrom = timestamp(share.first_1dayCandleDate);
|
|
||||||
const to = new Date();
|
|
||||||
const from = new Date(to);
|
|
||||||
from.setUTCMonth(
|
|
||||||
from.getUTCMonth() -
|
|
||||||
(period === 'month'
|
|
||||||
? 1
|
|
||||||
: period === 'halfYear'
|
|
||||||
? HALF_YEAR_MONTHS
|
|
||||||
: YEAR_MONTHS),
|
|
||||||
);
|
|
||||||
if (period === 'all') {
|
|
||||||
if (!historyFrom)
|
|
||||||
throw new ServiceUnavailableException({
|
|
||||||
code: 'STOCK_HISTORY_START_UNAVAILABLE',
|
|
||||||
});
|
|
||||||
from.setTime(Date.parse(historyFrom));
|
|
||||||
} else if (historyFrom && Date.parse(historyFrom) > from.valueOf())
|
|
||||||
from.setTime(Date.parse(historyFrom));
|
|
||||||
const collected = new Map<string, StockCandlesDto['candles'][number]>();
|
|
||||||
// Conservative one-year windows also work with older deployed RPC limits.
|
|
||||||
for (let start = from.valueOf(); start < to.valueOf();) {
|
|
||||||
const end = Math.min(
|
|
||||||
start + CANDLE_WINDOW_DAYS * dayMs,
|
|
||||||
to.valueOf(),
|
|
||||||
);
|
|
||||||
for (const candle of await this.tbank.getCandles(
|
|
||||||
token,
|
|
||||||
uid,
|
|
||||||
new Date(start),
|
|
||||||
new Date(end),
|
|
||||||
)) {
|
|
||||||
const time = timestamp(candle.time);
|
|
||||||
if (
|
|
||||||
time &&
|
|
||||||
candle.close &&
|
|
||||||
Date.parse(time) >= from.valueOf() - dayMs &&
|
|
||||||
Date.parse(time) <= to.valueOf()
|
|
||||||
)
|
|
||||||
collected.set(time, {
|
|
||||||
time,
|
|
||||||
close: formatDecimal(decimal(candle.close)),
|
|
||||||
isComplete: candle.isComplete === true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
start = end;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
candles: [...collected.values()].sort((a, b) =>
|
|
||||||
a.time.localeCompare(b.time),
|
|
||||||
),
|
|
||||||
currency: share.currency?.toUpperCase() || null,
|
|
||||||
historyFrom,
|
|
||||||
observedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
|
||||||
import { instrumentPosition } from '../../application/instrument-position';
|
|
||||||
export function stockPosition(portfolio: PortfolioDto, uid: string) {
|
|
||||||
return instrumentPosition(portfolio, uid, 'STOCK');
|
|
||||||
}
|
|
||||||
@@ -1,375 +1,70 @@
|
|||||||
import {
|
import { metrics } from './get-stock-company.use-case';
|
||||||
ConflictException,
|
import { stockDividends } from './stock-income.service';
|
||||||
NotFoundException,
|
import { stockQuote } from './stock-market.service';
|
||||||
ServiceUnavailableException,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service';
|
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
|
|
||||||
import { PortfolioService } from '../../../portfolio/application/portfolio.service';
|
|
||||||
import { GetStockCompanyUseCase } from './get-stock-company.use-case';
|
|
||||||
import { StockIncomeService } from './stock-income.service';
|
|
||||||
import { StockMarketService } from './stock-market.service';
|
|
||||||
import { StockCatalogService } from './stock-catalog.service';
|
|
||||||
import { previousClose } from '../../application/instrument-market';
|
import { previousClose } from '../../application/instrument-market';
|
||||||
jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({
|
jest.mock('../../../../../prisma/prisma.service', () => ({
|
||||||
TbankAccessService: class TbankAccessService {},
|
PrismaService: class {},
|
||||||
}));
|
}));
|
||||||
const time = (date: string) => ({ seconds: String(Date.parse(date) / 1000) });
|
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
||||||
const share = {
|
describe('stock financial projections', () => {
|
||||||
uid: 'uid',
|
it('keeps raw public payouts when holdings are unavailable and omits cancelled events', () => {
|
||||||
assetUid: 'asset',
|
const result = stockDividends(
|
||||||
currency: 'rub',
|
[
|
||||||
sector: 'financial',
|
|
||||||
first_1dayCandleDate: time('2025-11-20'),
|
|
||||||
};
|
|
||||||
function setup() {
|
|
||||||
let generation = 'a';
|
|
||||||
const tbank = {
|
|
||||||
findShares: jest.fn().mockResolvedValue([]),
|
|
||||||
getLastPrices: jest.fn().mockResolvedValue([]),
|
|
||||||
getShare: jest.fn().mockResolvedValue(share),
|
|
||||||
getCandles: jest.fn().mockResolvedValue([]),
|
|
||||||
getAssetDescription: jest.fn().mockResolvedValue('Company'),
|
|
||||||
getFundamentals: jest.fn().mockResolvedValue({
|
|
||||||
assetUid: 'asset',
|
|
||||||
currency: 'rub',
|
|
||||||
peRatioTtm: 6,
|
|
||||||
netIncomeTtm: 10,
|
|
||||||
}),
|
|
||||||
getDividends: jest.fn().mockResolvedValue([]),
|
|
||||||
};
|
|
||||||
const access = {
|
|
||||||
withConnection: jest.fn(
|
|
||||||
(user: string, handler: (context: unknown) => unknown) =>
|
|
||||||
handler({ token: user, generation }),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
const portfolios = {
|
|
||||||
getAll: jest.fn().mockResolvedValue({
|
|
||||||
positions: [],
|
|
||||||
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
|
||||||
isStale: false,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const service = new StockCatalogService(
|
|
||||||
access as unknown as TbankAccessService,
|
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
);
|
|
||||||
const market = new StockMarketService(
|
|
||||||
service,
|
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
);
|
|
||||||
const income = new StockIncomeService(
|
|
||||||
service,
|
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
portfolios as unknown as PortfolioService,
|
|
||||||
);
|
|
||||||
const companyInfo = new GetStockCompanyUseCase(
|
|
||||||
service,
|
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
market,
|
|
||||||
income,
|
|
||||||
companyInfo,
|
|
||||||
service,
|
|
||||||
tbank,
|
|
||||||
portfolios,
|
|
||||||
changeGeneration: () => {
|
|
||||||
generation = 'b';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
describe('stock market use case', () => {
|
|
||||||
beforeEach(() =>
|
|
||||||
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')),
|
|
||||||
);
|
|
||||||
afterEach(() => jest.useRealTimers());
|
|
||||||
it('returns unique ISINs, keeps preferred shares separate and matches prices by selected UID', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
const listings = [
|
|
||||||
{
|
|
||||||
uid: 'special',
|
|
||||||
ticker: 'SBER',
|
|
||||||
classCode: 'SPEQ',
|
|
||||||
apiTradeAvailableFlag: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: 'preferred',
|
|
||||||
ticker: 'SBERP',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
apiTradeAvailableFlag: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: 'common',
|
|
||||||
ticker: 'SBER',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
apiTradeAvailableFlag: true,
|
|
||||||
},
|
|
||||||
].map((item) => ({
|
|
||||||
...item,
|
|
||||||
name: 'Сбербанк',
|
|
||||||
isin: item.uid === 'preferred' ? 'ISINP' : 'ISIN',
|
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
|
||||||
}));
|
|
||||||
tbank.findShares.mockResolvedValue([
|
|
||||||
...listings,
|
|
||||||
{ uid: 'bond', instrumentKind: 'INSTRUMENT_TYPE_BOND' },
|
|
||||||
{ instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
|
|
||||||
{ uid: '', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
|
|
||||||
]);
|
|
||||||
tbank.getShare.mockImplementation((_token: string, uid: string) =>
|
|
||||||
Promise.resolve({
|
|
||||||
...listings.find((item) => item.uid === uid),
|
|
||||||
currency: 'rub',
|
|
||||||
brand: { logoName: 'sber.png' },
|
|
||||||
shareType:
|
|
||||||
uid === 'preferred' ? 'SHARE_TYPE_PREFERRED' : 'SHARE_TYPE_COMMON',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
tbank.getLastPrices.mockResolvedValue([
|
|
||||||
{
|
|
||||||
instrumentUid: 'preferred',
|
|
||||||
price: { units: '305' },
|
|
||||||
time: time('2026-09-09'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
instrumentUid: 'common',
|
|
||||||
price: { units: '306', nano: 120000000 },
|
|
||||||
time: time('2026-09-10'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await service.search('user', 'SBER');
|
|
||||||
expect(result.map((item) => item.instrumentUid)).toEqual([
|
|
||||||
'common',
|
|
||||||
'preferred',
|
|
||||||
]);
|
|
||||||
expect(result[0]).toMatchObject({
|
|
||||||
classCode: 'TQBR',
|
|
||||||
isin: 'ISIN',
|
|
||||||
logoName: 'sber.png',
|
|
||||||
price: { currency: 'RUB', value: '306.12' },
|
|
||||||
priceAt: '2026-09-10T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
expect(result[1]).toMatchObject({
|
|
||||||
shareType: 'SHARE_TYPE_PREFERRED',
|
|
||||||
price: { value: '305' },
|
|
||||||
});
|
|
||||||
expect(tbank.getLastPrices).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
it('prefers TQBR among equally available listings, deduplicates before the limit and keeps missing ISINs separate', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
const hit = {
|
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
|
||||||
ticker: 'SBER',
|
|
||||||
apiTradeAvailableFlag: true,
|
|
||||||
};
|
|
||||||
tbank.findShares.mockResolvedValue([
|
|
||||||
...Array.from({ length: 35 }, (_, i) => ({
|
|
||||||
...hit,
|
|
||||||
uid: `alternate-${i}`,
|
|
||||||
isin: ' isin ',
|
|
||||||
classCode: 'SPEQ',
|
|
||||||
})),
|
|
||||||
{ ...hit, uid: 'common', isin: 'ISIN', classCode: 'TQBR' },
|
|
||||||
{
|
|
||||||
...hit,
|
|
||||||
uid: 'preferred',
|
|
||||||
ticker: 'SBERP',
|
|
||||||
isin: 'ISINP',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
},
|
|
||||||
{ ...hit, uid: 'unknown-a', isin: '' },
|
|
||||||
{ ...hit, uid: 'unknown-b' },
|
|
||||||
]);
|
|
||||||
tbank.getShare.mockResolvedValue(null);
|
|
||||||
const result = await service.search('user', 'SBER');
|
|
||||||
expect(result.map((item) => item.instrumentUid)).toEqual([
|
|
||||||
'common',
|
|
||||||
'unknown-a',
|
|
||||||
'unknown-b',
|
|
||||||
'preferred',
|
|
||||||
]);
|
|
||||||
expect(tbank.getShare).toHaveBeenCalledTimes(4);
|
|
||||||
});
|
|
||||||
it('retains catalog identity when enrichment is unavailable, but propagates auth errors', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
tbank.findShares.mockResolvedValue([
|
|
||||||
{
|
|
||||||
uid: 'uid',
|
|
||||||
ticker: 'SBER',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
tbank.getShare.mockRejectedValue(new ServiceUnavailableException());
|
|
||||||
tbank.getLastPrices.mockRejectedValue(new ServiceUnavailableException());
|
|
||||||
expect(await service.search('user', 'SBER')).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
instrumentUid: 'uid',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
price: null,
|
|
||||||
logoName: null,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
tbank.getShare.mockResolvedValue(null);
|
|
||||||
expect(await service.search('user', 'sber')).toEqual([
|
|
||||||
expect.objectContaining({ instrumentUid: 'uid' }),
|
|
||||||
]);
|
|
||||||
tbank.getShare.mockRejectedValue(new UnauthorizedException());
|
|
||||||
await expect(service.search('another-user', 'SBER')).rejects.toBeInstanceOf(
|
|
||||||
UnauthorizedException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('skips enrichment for an empty catalog result', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
expect(await service.search('user', 'none')).toEqual([]);
|
|
||||||
expect(tbank.getShare).not.toHaveBeenCalled();
|
|
||||||
expect(tbank.getLastPrices).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
it('resolves an exact ticker, preserves other listing UIDs and does not confuse SBER with SBERP', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
const listings = [
|
|
||||||
{
|
|
||||||
uid: 'common',
|
|
||||||
ticker: 'SBER',
|
|
||||||
isin: 'COMMON',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
apiTradeAvailableFlag: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: 'special',
|
|
||||||
ticker: 'SBER',
|
|
||||||
isin: 'COMMON',
|
|
||||||
classCode: 'SPEQ',
|
|
||||||
apiTradeAvailableFlag: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: 'preferred',
|
|
||||||
ticker: 'SBERP',
|
|
||||||
isin: 'PREFERRED',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
apiTradeAvailableFlag: true,
|
|
||||||
},
|
|
||||||
].map((item) => ({ ...item, instrumentKind: 'INSTRUMENT_TYPE_SHARE' }));
|
|
||||||
tbank.findShares.mockResolvedValue(listings);
|
|
||||||
tbank.getShare.mockImplementation((_token: string, uid: string) =>
|
|
||||||
Promise.resolve(listings.find((item) => item.uid === uid)),
|
|
||||||
);
|
|
||||||
expect(await service.getByTicker('user', 'sberp')).toMatchObject({
|
|
||||||
instrumentUid: 'preferred',
|
|
||||||
slug: 'SBERP',
|
|
||||||
});
|
|
||||||
expect(await service.get('user', 'common')).toMatchObject({
|
|
||||||
instrumentUid: 'common',
|
|
||||||
slug: 'SBER',
|
|
||||||
});
|
|
||||||
expect(await service.get('user', 'special')).toMatchObject({
|
|
||||||
instrumentUid: 'special',
|
|
||||||
slug: 'special',
|
|
||||||
});
|
|
||||||
await expect(service.getByTicker('user', 'SB')).rejects.toBeInstanceOf(
|
|
||||||
NotFoundException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('refuses ambiguous tickers while keeping exact UID pages accessible', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
const listings = ['one', 'two'].map((uid) => ({
|
|
||||||
uid,
|
|
||||||
ticker: 'SAME',
|
|
||||||
isin: uid,
|
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
|
||||||
}));
|
|
||||||
tbank.findShares.mockResolvedValue(listings);
|
|
||||||
tbank.getShare.mockResolvedValue(listings[0]);
|
|
||||||
await expect(service.getByTicker('user', 'SAME')).rejects.toBeInstanceOf(
|
|
||||||
ConflictException,
|
|
||||||
);
|
|
||||||
expect(await service.get('user', 'one')).toMatchObject({
|
|
||||||
instrumentUid: 'one',
|
|
||||||
slug: 'one',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('keeps exact pages if alias lookup fails and partitions ticker resolution by connection', async () => {
|
|
||||||
const { service, tbank, changeGeneration } = setup();
|
|
||||||
tbank.getShare.mockResolvedValue({ ...share, ticker: 'F' });
|
|
||||||
tbank.findShares.mockRejectedValueOnce(new ServiceUnavailableException());
|
|
||||||
expect(await service.get('user', 'uid')).toMatchObject({ slug: 'uid' });
|
|
||||||
tbank.findShares.mockResolvedValue([
|
|
||||||
{ ...share, ticker: 'F', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
|
|
||||||
]);
|
|
||||||
expect(await service.getByTicker('user', 'F')).toMatchObject({
|
|
||||||
instrumentUid: 'uid',
|
|
||||||
slug: 'F',
|
|
||||||
});
|
|
||||||
changeGeneration();
|
|
||||||
expect(await service.getByTicker('user', 'F')).toMatchObject({ slug: 'F' });
|
|
||||||
expect(tbank.findShares).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
it('uses a complete previous trading day, never today’s main close', () => {
|
|
||||||
const candles = [
|
|
||||||
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: true },
|
|
||||||
{ time: time('2026-09-10'), close: { units: '11' }, isComplete: true },
|
|
||||||
{ time: time('2026-09-08'), close: { units: '9' }, isComplete: false },
|
|
||||||
];
|
|
||||||
expect(previousClose(candles, '2026-09-10T18:00:00Z')).toEqual(candles[0]);
|
|
||||||
expect(previousClose(candles, '2026-09-09T22:00:00Z')).toEqual(candles[0]);
|
|
||||||
});
|
|
||||||
it('starts all-history at listing, keeps incomplete candles and deduplicates boundaries', async () => {
|
|
||||||
const { tbank, market } = setup();
|
|
||||||
tbank.getCandles.mockResolvedValue([
|
|
||||||
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: false },
|
|
||||||
]);
|
|
||||||
const result = await market.candles('user', 'uid', 'all');
|
|
||||||
expect(tbank.getCandles).toHaveBeenCalledWith(
|
|
||||||
'user',
|
|
||||||
'uid',
|
|
||||||
new Date('2025-11-20'),
|
|
||||||
expect.any(Date),
|
|
||||||
);
|
|
||||||
expect(result.candles).toHaveLength(1);
|
|
||||||
expect(result.candles[0].isComplete).toBe(false);
|
|
||||||
});
|
|
||||||
it('does not manufacture zero fundamentals and isolates optional failures', async () => {
|
|
||||||
const { tbank, companyInfo } = setup();
|
|
||||||
tbank.getAssetDescription.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException(),
|
|
||||||
);
|
|
||||||
const result = await companyInfo.execute('user', 'uid');
|
|
||||||
expect(result.description).toBeNull();
|
|
||||||
expect(result.unavailable).toEqual(['description']);
|
|
||||||
expect(result.metrics.map((item) => item.key)).toEqual([
|
|
||||||
'peRatioTtm',
|
|
||||||
'netIncomeTtm',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it('isolates cache by user and connection generation', async () => {
|
|
||||||
const { service, tbank, changeGeneration } = setup();
|
|
||||||
await service.get('a', 'uid');
|
|
||||||
await service.get('a', 'uid');
|
|
||||||
expect(tbank.getShare).toHaveBeenCalledTimes(1);
|
|
||||||
await service.get('b', 'uid');
|
|
||||||
changeGeneration();
|
|
||||||
await service.get('a', 'uid');
|
|
||||||
expect(tbank.getShare).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
it('keeps public dividends when there is no holding or position retrieval fails', async () => {
|
|
||||||
const { tbank, portfolios, income } = setup();
|
|
||||||
portfolios.getAll.mockRejectedValue(new ServiceUnavailableException());
|
|
||||||
tbank.getDividends.mockResolvedValue([
|
|
||||||
{
|
{
|
||||||
dividendNet: { currency: 'rub', units: '32', nano: 880000000 },
|
dividendNet: { currency: 'rub', units: '32', nano: 880000000 },
|
||||||
recordDate: time('2026-10-13'),
|
recordDate: time('2026-10-13'),
|
||||||
paymentDate: time('2026-10-27'),
|
paymentDate: time('2026-10-27'),
|
||||||
},
|
},
|
||||||
]);
|
{ dividendType: 'Cancelled', recordDate: time('2026-10-14') },
|
||||||
const result = await income.dividends('user', 'uid');
|
],
|
||||||
expect(result.estimateUnavailable).toBe(true);
|
null,
|
||||||
expect(result.dividends[0]).toMatchObject({
|
{ from: '2021-09-10', to: '2027-09-10', today: '2026-09-10' },
|
||||||
|
'source-time',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result.dividends).toEqual([
|
||||||
|
{
|
||||||
perShare: { currency: 'RUB', value: '32.88' },
|
perShare: { currency: 'RUB', value: '32.88' },
|
||||||
estimatedAmount: null,
|
recordDate: '2026-10-13',
|
||||||
|
paymentDate: '2026-10-27',
|
||||||
|
lastBuyDate: null,
|
||||||
timing: 'upcoming',
|
timing: 'upcoming',
|
||||||
|
estimatedAmount: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.estimateUnavailable).toBe(true);
|
||||||
|
expect(result.observedAt).toBe('source-time');
|
||||||
});
|
});
|
||||||
|
it('uses the last complete previous trading day', () => {
|
||||||
|
const candles = [
|
||||||
|
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: true },
|
||||||
|
{ time: time('2026-09-10'), close: { units: '11' }, isComplete: false },
|
||||||
|
];
|
||||||
|
expect(previousClose(candles, '2026-09-10T18:00:00Z')).toEqual(candles[0]);
|
||||||
|
});
|
||||||
|
it('retains stock price precision and percent change without inventing unavailable fundamentals', () => {
|
||||||
|
expect(
|
||||||
|
stockQuote(
|
||||||
|
{
|
||||||
|
instrument: { currency: 'rub' },
|
||||||
|
price: { price: { units: '11', nano: 1 } },
|
||||||
|
candle: { close: { units: '10' } },
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
'time',
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
price: { currency: 'RUB', value: '11.000000001' },
|
||||||
|
change: { currency: 'RUB', value: '1.000000001' },
|
||||||
|
changePercent: '10',
|
||||||
|
});
|
||||||
|
expect(metrics(null, 'financial')).toEqual([]);
|
||||||
|
expect(
|
||||||
|
metrics(
|
||||||
|
{ peRatioTtm: 6, netIncomeTtm: 10, revenueTtm: 100, currency: 'rub' },
|
||||||
|
'financial',
|
||||||
|
).map((item) => item.key),
|
||||||
|
).toEqual(['peRatioTtm', 'netIncomeTtm']);
|
||||||
|
expect(metrics({ netIncomeTtm: 10 })).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -15,15 +16,15 @@ import { GetStockCompanyUseCase } from '../application/get-stock-company.use-cas
|
|||||||
import { StockIncomeService } from '../application/stock-income.service';
|
import { StockIncomeService } from '../application/stock-income.service';
|
||||||
import { StockMarketService } from '../application/stock-market.service';
|
import { StockMarketService } from '../application/stock-market.service';
|
||||||
import {
|
import {
|
||||||
StockCandlesDto,
|
StockCandlesResourceDto,
|
||||||
StockCandlesQueryDto,
|
StockCandlesQueryDto,
|
||||||
StockCompanyDto,
|
StockCompanyResourceDto,
|
||||||
StockDividendsDto,
|
StockDividendsResourceDto,
|
||||||
StockDto,
|
StockResourceDto,
|
||||||
StockPositionDto,
|
StockPositionResourceDto,
|
||||||
StockQuoteDto,
|
StockQuoteResourceDto,
|
||||||
StockSearchQueryDto,
|
StockSearchQueryDto,
|
||||||
StockSearchResultDto,
|
StockSearchResourceDto,
|
||||||
StockTickerParamsDto,
|
StockTickerParamsDto,
|
||||||
} from './stock.dto';
|
} from './stock.dto';
|
||||||
import { StockCatalogService } from '../application/stock-catalog.service';
|
import { StockCatalogService } from '../application/stock-catalog.service';
|
||||||
@@ -41,68 +42,99 @@ export class StockController {
|
|||||||
private readonly companyInfo: GetStockCompanyUseCase,
|
private readonly companyInfo: GetStockCompanyUseCase,
|
||||||
) {}
|
) {}
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ type: [StockSearchResultDto] })
|
@ApiOkResponse({ type: StockSearchResourceDto })
|
||||||
search(
|
search(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Query() query: StockSearchQueryDto,
|
@Query() query: StockSearchQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.stocks.search(request.user.sub, query.query);
|
return this.stocks.search(
|
||||||
|
request.user.sub,
|
||||||
|
query.query,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get('by-ticker/:ticker')
|
@Get('by-ticker/:ticker')
|
||||||
@ApiOkResponse({ type: StockDto })
|
@ApiOkResponse({ type: StockResourceDto })
|
||||||
getByTicker(
|
getByTicker(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param() params: StockTickerParamsDto,
|
@Param() params: StockTickerParamsDto,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.stocks.getByTicker(request.user.sub, params.ticker);
|
return this.stocks.getByTicker(
|
||||||
|
request.user.sub,
|
||||||
|
params.ticker,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid')
|
@Get(':uid')
|
||||||
@ApiOkResponse({ type: StockDto })
|
@ApiOkResponse({ type: StockResourceDto })
|
||||||
get(
|
get(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.stocks.get(request.user.sub, uid);
|
return this.stocks.get(request.user.sub, uid, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
@Get(':uid/quote')
|
@Get(':uid/quote')
|
||||||
@ApiOkResponse({ type: StockQuoteDto })
|
@ApiOkResponse({ type: StockQuoteResourceDto })
|
||||||
quote(
|
quote(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.market.quote(request.user.sub, uid);
|
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
@Get(':uid/candles')
|
@Get(':uid/candles')
|
||||||
@ApiOkResponse({ type: StockCandlesDto })
|
@ApiOkResponse({ type: StockCandlesResourceDto })
|
||||||
candles(
|
candles(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: StockCandlesQueryDto,
|
@Query() query: StockCandlesQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.market.candles(request.user.sub, uid, query.period);
|
return this.market.candles(
|
||||||
|
request.user.sub,
|
||||||
|
uid,
|
||||||
|
query.period,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid/position')
|
@Get(':uid/position')
|
||||||
@ApiOkResponse({ type: StockPositionDto })
|
@ApiOkResponse({ type: StockPositionResourceDto })
|
||||||
position(
|
position(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.position(request.user.sub, uid);
|
return this.income.readPosition(
|
||||||
|
request.user.sub,
|
||||||
|
uid,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid/company')
|
@Get(':uid/company')
|
||||||
@ApiOkResponse({ type: StockCompanyDto })
|
@ApiOkResponse({ type: StockCompanyResourceDto })
|
||||||
company(
|
company(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.companyInfo.execute(request.user.sub, uid);
|
return this.companyInfo.execute(
|
||||||
|
request.user.sub,
|
||||||
|
uid,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid/dividends')
|
@Get(':uid/dividends')
|
||||||
@ApiOkResponse({ type: StockDividendsDto })
|
@ApiOkResponse({ type: StockDividendsResourceDto })
|
||||||
dividends(
|
dividends(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.dividends(request.user.sub, uid);
|
return this.income.dividends(
|
||||||
|
request.user.sub,
|
||||||
|
uid,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user