17 Commits
Author SHA1 Message Date
ksv741 92f216d520 chore(release): 1.0.0
Backend security / security (push) Successful in 8s
Backend image / publish (push) Successful in 12m35s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 9m53s
Backend verify / verify (push) Successful in 9m56s
2026-09-26 22:33:48 +03:00
ksv741 e4232138d0 test: prove reads finish before source synchronization
Conventional commits / commits (pull_request) Successful in 31s
Backend security / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 10m47s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 12m14s
Block synthetic upstream with an explicit barrier instead of comparing
total HTTP batch time with a two-second timer. Keep the 500 ms server
p95 gate and RPC assertions unchanged.
2026-09-26 21:45:50 +03:00
ksv741 d6e6c4ace5 feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite.
Coalesce scoped refreshes, fence connection revisions, preserve complete financial
snapshots and stabilize operation period state.

BREAKING CHANGE: investment reads return data/meta resource envelopes and locally
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
synchronization endpoints.
2026-09-26 21:29:15 +03:00
ksv741 0862d56c4e chore(release): 0.2.3
Backend image / publish (push) Successful in 5m20s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 2m32s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 3m36s
2026-09-19 16:25:12 +03:00
ksv741 7c0150c56b fix: correct Nexus routing and SonarQube host 2026-09-19 16:24:08 +03:00
ksv741 39f2ce5bc4 chore(release): 0.2.2
Backend image / publish (push) Failing after 2m30s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 2m27s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Failing after 2m55s
2026-09-19 15:07:57 +03:00
ksv741 4c916829ea fix: update Nexus registry host
Conventional commits / commits (pull_request) Successful in 26s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 2m29s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Failing after 3m4s
2026-09-19 14:51:27 +03:00
ksv741 386c91670e chore(release): 0.2.1
Conventional commits / commits (pull_request) Successful in 24s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 2m14s
Backend image / publish (push) Failing after 2m28s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Failing after 2m46s
2026-09-18 08:12:28 +03:00
ksv741 9437b6a311 docs: use shared Nexus account #25
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 2m25s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Successful in 9m3s
2026-09-17 20:53:04 +03:00
ksv741 0ec7ae51af ci: publish backend images to Nexus #25
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Successful in 2m27s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 2m22s
Conventional commits / commits (pull_request) Failing after 22s
2026-09-17 20:41:23 +03:00
ksv741 1946192be9 ci: add backend SonarQube analysis #23
Conventional commits / commits (pull_request) Successful in 27s
Backend security / security (pull_request) Successful in 1m14s
Backend verify / verify (pull_request) Successful in 2m39s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 4m26s
2026-09-16 21:15:21 +03:00
ksv741 0557f4ad1f chore(release): 0.2.0
Backend image / publish (push) Successful in 5m29s
Conventional commits / commits (pull_request) Successful in 27s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 2m28s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Successful in 2m29s
2026-09-16 11:35:52 +03:00
ksv741 d7cb7e3e20 fix: allow reviewed container smoke fixture
Conventional commits / commits (pull_request) Successful in 26s
Backend security / security (pull_request) Successful in 6s
Backend verify / verify (pull_request) Successful in 2m19s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Successful in 2m22s
2026-09-16 10:48:27 +03:00
ksv741 1a6be432f2 feat: add backend container delivery
Backend security / security (push) Failing after 5s
Backend verify / verify (push) Successful in 2m19s
Backend security / security (pull_request) Failing after 5s
Backend verify / verify (pull_request) Successful in 2m17s
Conventional commits / commits (pull_request) Successful in 25s
2026-09-16 09:41:56 +03:00
ksv741 32c12fb10d feat: generate OpenAPI without peer repositories
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 2m28s
Conventional commits / commits (pull_request) Successful in 25s
Backend security / security (push) Successful in 6s
Backend verify / verify (push) Successful in 2m16s
2026-09-15 20:45:39 +03:00
ksv741 016782657a docs: simplify conventions layout
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 2m3s
Conventional commits / commits (pull_request) Successful in 24s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Successful in 2m10s
2026-09-15 05:58:33 +03:00
ksv741 9810d47e42 docs: refine backend conventions and routing
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 2m12s
Conventional commits / commits (pull_request) Successful in 25s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 2m3s
2026-09-15 05:42:16 +03:00
147 changed files with 19246 additions and 5888 deletions
-25
View File
@@ -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 файлы.
-19
View File
@@ -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`.
@@ -134,6 +167,10 @@ Tests могут импортировать внутренний owner для п
Новый 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 +199,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 +230,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 +252,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.
## Проверка ## Проверка
-58
View File
@@ -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).
+74
View File
@@ -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, раздел «Релизы».
+40
View File
@@ -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).
+88
View File
@@ -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 сейчас формирует строковые сообщения,
а filter ожидает структурированные field errors; 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).
+22
View File
@@ -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.
+21 -11
View File
@@ -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
не считается успешным тестом; повторите проверку в подходящем окружении. не считается успешным тестом; повторите проверку в подходящем окружении.
-3
View File
@@ -1,3 +0,0 @@
{
"frontend": "0244acf429c525b23117f165bbe52ef5403eefd3"
}
+13
View File
@@ -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/**
+59
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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
+2
View File
@@ -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
+33 -25
View File
@@ -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 |
+55
View File
@@ -1,5 +1,60 @@
# Changelog # Changelog
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.3...v1.0.0) (2026-09-26)
### ⚠ BREAKING CHANGES
* investment reads return data/meta resource envelopes and locally
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
synchronization endpoints.
### Features
* persist T-Bank data and synchronize reads ([d6e6c4a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d6e6c4ace553572ff9e9f635b7a4d21c8afbf97f))
### Tests
* prove reads finish before source synchronization ([e423213](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e4232138d04ad6a2aa8100a7d36fdcb2d7663fa8))
## [0.2.3](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.2...v0.2.3) (2026-09-19)
### 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
View File
@@ -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))'
+132 -23
View File
@@ -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 не включены.
+3 -131
View File
@@ -1,12 +1,12 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "0.1.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "0.1.0", "version": "1.0.0",
"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
View File
@@ -1,6 +1,6 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "0.1.0", "version": "1.0.0",
"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;
+205
View File
@@ -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 {
+40
View File
@@ -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;
});
+12
View File
@@ -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
+2
View File
@@ -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 {}
@@ -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;
} }
} }
} }
+225 -1
View File
@@ -1,9 +1,11 @@
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 { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { import {
SupportedTbankAccount, SupportedTbankAccount,
@@ -22,6 +24,13 @@ export type TbankAccountContext = TbankConnectionContext & {
account: TbankAccount; account: TbankAccount;
}; };
export type TbankSyncAttempt = {
userId: string;
revision: string;
id: string;
attemptId: string;
};
@Injectable() @Injectable()
export class TbankAccessService { export class TbankAccessService {
constructor( constructor(
@@ -30,6 +39,219 @@ export class TbankAccessService {
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
) {} ) {}
async reserveMembership(
userId: string,
revision: string,
processRunId: string,
active: ReadonlySet<string>,
force: boolean,
) {
return this.prisma.$transaction(async (tx) => {
const current = await tx.tbankConnection.updateMany({
where: { userId, revision, invalidToken: false },
data: { membershipRevision: { increment: 0 } },
});
if (!current.count) return null;
const connection = await tx.tbankConnection.findUniqueOrThrow({
where: { userId },
});
if (
!force &&
!tbankSourceNeedsRefresh(
'membership',
connection.membershipAt,
new Date(),
)
)
return null;
const scope = { userId, revision, scopeKey: 'membership' };
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope },
});
if (
previous?.state === 'running' &&
previous.processRunId === processRunId &&
active.has(previous.attemptId)
)
return { work: previous, launch: false };
const data = {
attemptId: randomUUID(),
processRunId,
state: 'running',
intent: { source: 'membership' },
progress: {},
priority: 0,
errorCode: null,
};
const work = await tx.tbankSyncWork.upsert({
where: { userId_revision_scopeKey: scope },
create: { ...scope, ...data, id: randomUUID() },
update: data,
});
return { work, launch: true };
});
}
membershipWork(userId: string, revision: string) {
return this.prisma.tbankSyncWork.findUnique({
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({
where: { userId },
select: {
userId: true,
revision: true,
invalidToken: true,
membershipAt: true,
membershipRevision: true,
accounts: { orderBy: { upstreamOrder: 'asc' } },
},
});
if (!connection)
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
if (connection.invalidToken)
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
if (
connection.membershipAt &&
accountId &&
accountId !== 'all' &&
!connection.accounts.some((account) => account.accountId === accountId)
)
throw new NotFoundException();
return connection;
}
async listSupportedAccounts( async listSupportedAccounts(
userId: string, userId: string,
): Promise<SupportedTbankAccount[]> { ): Promise<SupportedTbankAccount[]> {
@@ -97,6 +319,8 @@ export class TbankAccessService {
}); });
if (!connection) if (!connection)
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
if (connection.invalidToken)
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
return { return {
token: this.tokens.decrypt(connection.encryptedToken), token: this.tokens.decrypt(connection.encryptedToken),
generation: createHash('sha256') generation: createHash('sha256')
@@ -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]
);
}
+13
View File
@@ -7,17 +7,30 @@ export {
export { export {
type TbankBond, type TbankBond,
type TbankCandle, type TbankCandle,
type TbankCoupon,
type TbankFundamentals, type TbankFundamentals,
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';
+8 -10
View File
@@ -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[]> {
+5
View File
@@ -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,
], ],
}) })
+4 -9
View File
@@ -2,7 +2,8 @@ import { 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,7 +22,7 @@ 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));
@@ -36,13 +37,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);
} }
@@ -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,9 +3,14 @@ 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 {
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' })
@IsOptional() @IsOptional()
@Matches(dateOnly) @Matches(dateOnly)
@@ -121,8 +126,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 +144,17 @@ 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;
}
export class IncomeAnalyticsResourceDto {
@ApiProperty({ type: IncomeAnalyticsDto, nullable: true })
declare data: IncomeAnalyticsDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
@@ -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', () => {
@@ -19,7 +24,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 +65,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 +102,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 +158,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 +225,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,11 @@ import {
formatDecimal, formatDecimal,
subtract, subtract,
} from '../../../common/decimal'; } from '../../../common/decimal';
import { investmentResourceMeta } from '../investment-resource.mapper';
import {
IncomeAnalyticsResourceDto,
IncomeSummaryResourceDto,
} from './income-analytics.dto';
import { DateOnlyRange, moscowDate } from '../investment-calendar'; import { DateOnlyRange, moscowDate } from '../investment-calendar';
import { import {
BrokerageOperationDto, BrokerageOperationDto,
@@ -13,7 +19,6 @@ import {
} from '../operations/operations.api'; } from '../operations/operations.api';
import { import {
IncomeAnalyticsDto,
IncomeAnalyticsEntryDto, IncomeAnalyticsEntryDto,
IncomeComparisonDto, IncomeComparisonDto,
IncomeCurrencySummaryDto, IncomeCurrencySummaryDto,
@@ -35,24 +40,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 +73,80 @@ 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( const dependencies = periods.map((period) => period.meta);
collected.pages[0].flatMap((page) => page.items), if (!plan.yearAgo)
).map((entry) => ({ dependencies.push({
...investmentResourceMeta({
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
dataRevision: 'unavailable',
availability: 'missing',
fetchedAt: null,
freshForMs: 0,
work: null,
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
}),
});
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,
});
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,
}; };
} }
} }
@@ -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,481 @@
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,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
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[];
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,
};
if (reserved.launch) {
this.active.add(attempt.attemptId);
const producer = this.producers.get(scope(input));
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, now } = producer;
const instrument = await this.sources.ensureDetail(
attempt.userId,
attempt.revision,
input.instrumentType,
input.uid,
);
if (!instrument) throw new NotFoundException();
const range = rangeFor(input, instrument, now);
if (!producer.ranges) {
const saved = await this.repository.read(input.uid, range);
producer.ranges = (
producer.force ? [range] : candleGaps(range, saved.coverage, now)
)
.flatMap(candleWindows)
.reverse();
producer.nextWindow = 0;
}
const ranges = producer.ranges;
const index = producer.nextWindow ?? 0;
let window = ranges[index];
if (window && !producer.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 (
!producer.force &&
!(await this.repository.publish(
attempt,
{ uid: input.uid, ranges: [window] },
false,
))
)
return false;
producer.nextWindow = index + 1;
if (producer.nextWindow < ranges.length) return null;
}
return this.repository.publish(attempt, {
uid: input.uid,
ranges: producer.force ? ranges : [],
});
}
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;
}
}
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));
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,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
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,665 @@
import type { TbankDividend } from '../../../../integrations/tbank/tbank.api';
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 type {
TbankCandle,
TbankFundamentals,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankCoupon } from '../../../../integrations/tbank/tbank-market.types';
import { previousClose, timestamp } from './instrument-market';
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import {
TbankAccessService,
TbankClient,
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
type TbankBond,
type TbankShare,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankLastPrice } from '../../../../integrations/tbank/tbank-market.types';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
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,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,306 @@
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-access.service';
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)
)
return { work: previous, 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 { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
import type {
TbankBond,
TbankShare,
} 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,
@@ -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,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',
);
} }
} }
@@ -1,3 +1,7 @@
import {
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '../../../investment-resource.dto';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
@@ -13,7 +17,7 @@ import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
const MAX_INSTRUMENT_QUERY_LENGTH = 80; const MAX_INSTRUMENT_QUERY_LENGTH = 80;
export class StockSearchQueryDto { export class StockSearchQueryDto extends InvestmentReadQueryDto {
@ApiProperty({ minLength: 2, maxLength: MAX_INSTRUMENT_QUERY_LENGTH }) @ApiProperty({ minLength: 2, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
@Transform(({ value }: { value: unknown }) => @Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value, typeof value === 'string' ? value.trim() : value,
@@ -35,7 +39,7 @@ export class StockTickerParamsDto {
@Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/) @Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/)
declare ticker: string; declare ticker: string;
} }
export class StockCandlesQueryDto { export class StockCandlesQueryDto extends InvestmentReadQueryDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: ['month', 'halfYear', 'year', 'all'], enum: ['month', 'halfYear', 'year', 'all'],
default: 'month', default: 'month',
@@ -168,3 +172,52 @@ export class StockDividendsDto {
@ApiProperty() declare estimateUnavailable: boolean; @ApiProperty() declare estimateUnavailable: boolean;
@ApiProperty() declare positionIsStale: boolean; @ApiProperty() declare positionIsStale: boolean;
} }
export class StockPositionResourceDto {
@ApiProperty({ type: StockPositionDto, nullable: true })
declare data: StockPositionDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
export class StockSearchResourceDto {
@ApiProperty({ type: StockSearchResultDto, isArray: true, nullable: true })
declare data: StockSearchResultDto[] | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
export class StockResourceDto {
@ApiProperty({ type: StockDto, nullable: true })
declare data: StockDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
export class StockQuoteResourceDto {
@ApiProperty({ type: StockQuoteDto, nullable: true })
declare data: StockQuoteDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
export class StockCompanyResourceDto {
@ApiProperty({ type: StockCompanyDto, nullable: true })
declare data: StockCompanyDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
export class StockCandlesResourceDto {
@ApiProperty({ type: StockCandlesDto, nullable: true })
declare data: StockCandlesDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
export class StockDividendsResourceDto {
@ApiProperty({ type: StockDividendsDto, nullable: true })
declare data: StockDividendsDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
@@ -7,13 +7,14 @@ const MOSCOW_DAY_END_UTC = {
}; };
export type DateOnlyRange = { from: string; to: string }; export type DateOnlyRange = { from: string; to: string };
const moscow = 'Europe/Moscow'; const moscow = 'Europe/Moscow';
export function moscowDate(value: Date): string { const moscowFormatter = new Intl.DateTimeFormat('en-CA', {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: moscow, timeZone: moscow,
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
}).formatToParts(value); });
export function moscowDate(value: Date): string {
const parts = moscowFormatter.formatToParts(value);
const get = (type: string) => parts.find((part) => part.type === type)?.value; const get = (type: string) => parts.find((part) => part.type === type)?.value;
return `${get('year')}-${get('month')}-${get('day')}`; return `${get('year')}-${get('month')}-${get('day')}`;
} }
@@ -0,0 +1,75 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
export const availabilityValues = ['missing', 'partial', 'complete'] as const;
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
export const syncStateValues = ['idle', 'queued', 'running', 'failed'] as const;
export class InvestmentReadQueryDto {
@ApiPropertyOptional({ enum: ['observe'] })
@IsOptional()
@IsIn(['observe'])
declare sync?: 'observe';
}
export class InvestmentSyncDto {
@ApiProperty({ type: String, nullable: true })
declare id: string | null;
@ApiProperty({ enum: syncStateValues })
declare state: (typeof syncStateValues)[number];
@ApiProperty({ type: String, nullable: true })
declare errorCode: string | null;
}
export class InvestmentRangeDto {
@ApiPropertyOptional()
accountId?: string;
@ApiProperty({ format: 'date-time' })
declare from: string;
@ApiProperty({ format: 'date-time' })
declare to: string;
}
export class InvestmentCoverageDto {
@ApiProperty({ type: InvestmentRangeDto, isArray: true })
declare requested: InvestmentRangeDto[];
@ApiProperty({ type: InvestmentRangeDto, isArray: true })
declare completeRanges: InvestmentRangeDto[];
@ApiProperty({ type: InvestmentRangeDto, isArray: true })
declare missingRanges: InvestmentRangeDto[];
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare asOf: string | null;
@ApiProperty({ enum: ['known', 'unknown'] })
declare historyBoundary: 'known' | 'unknown';
@ApiProperty({
enum: ['missing', 'loading', 'partial', 'complete', 'failed'],
})
declare historyState:
'missing' | 'loading' | 'partial' | 'complete' | 'failed';
}
export class InvestmentDependencyDto {
@ApiProperty()
declare dataRevision: string;
@ApiProperty()
declare stateVersion: string;
@ApiPropertyOptional({ type: String })
unavailableReason?: string;
@ApiPropertyOptional({ type: () => InvestmentCoverageDto })
coverage?: InvestmentCoverageDto;
@ApiProperty()
declare resourceKey: string;
@ApiProperty({ enum: availabilityValues })
declare availability: (typeof availabilityValues)[number];
@ApiProperty({ enum: freshnessValues })
declare freshness: (typeof freshnessValues)[number];
@ApiProperty({ type: String, format: 'date-time', nullable: true })
declare lastSuccessfulAt: string | null;
@ApiProperty({ type: InvestmentSyncDto })
declare sync: InvestmentSyncDto;
}
export class InvestmentResourceMetaDto extends InvestmentDependencyDto {
@ApiPropertyOptional({ type: InvestmentDependencyDto, isArray: true })
declare dependencies?: InvestmentDependencyDto[];
}
@@ -0,0 +1,80 @@
import { investmentResourceMeta } from './investment-resource.mapper';
describe('investment resource source freshness', () => {
afterEach(() => jest.useRealTimers());
it('uses each required source TTL and oldest successful timestamp', () => {
jest.useFakeTimers().setSystemTime(new Date('2026-09-25T12:00:00Z'));
const membership = investmentResourceMeta({
resourceKey: 'membership',
dataRevision: '1',
availability: 'complete',
fetchedAt: new Date('2026-09-25T11:58:00Z'),
freshForMs: 300_000,
work: null,
});
const input = {
resourceKey: 'portfolio',
dataRevision: '1',
availability: 'complete' as const,
fetchedAt: new Date('2026-09-25T11:59:30Z'),
freshForMs: 60_000,
work: null,
dependencies: [membership],
};
const fresh = investmentResourceMeta(input);
expect(fresh.freshness).toBe('fresh');
expect(fresh.lastSuccessfulAt).toBe('2026-09-25T11:58:00.000Z');
jest.advanceTimersByTime(30_000);
const stale = investmentResourceMeta(input);
expect(stale.freshness).toBe('stale');
expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt);
expect(stale.stateVersion).not.toBe(fresh.stateVersion);
expect(
investmentResourceMeta({
...input,
dependencies: [{ ...membership, freshness: 'unknown' }],
}).freshness,
).toBe('unknown');
});
});
describe('dependent resource lifecycle', () => {
const input = {
resourceKey: 'source',
dataRevision: '1',
availability: 'complete' as const,
fetchedAt: new Date(),
freshForMs: 60_000,
work: null,
};
it('exposes dependency work without losing saved availability or timestamps', () => {
const failed = investmentResourceMeta({
...input,
work: { id: 'failed', state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
});
const running = investmentResourceMeta({
...input,
work: { id: 'running', state: 'running', errorCode: null },
});
const parent = investmentResourceMeta({
...input,
dependencies: [failed, running],
});
expect(parent.sync).toEqual(running.sync);
expect(parent.availability).toBe('complete');
const terminal = investmentResourceMeta({
...input,
dependencies: [failed],
});
expect(terminal.sync).toEqual(failed.sync);
expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt);
expect(terminal.stateVersion).not.toBe(parent.stateVersion);
expect(
investmentResourceMeta({
...input,
dependencies: [{ ...failed, unavailableReason: 'calendar_boundary' }],
}).sync.state,
).toBe('idle');
});
});
@@ -0,0 +1,87 @@
import { createHash } from 'node:crypto';
import { InvestmentResourceMetaDto } from './investment-resource.dto';
export function investmentResourceMeta(input: {
resourceKey: string;
dataRevision: string;
availability: InvestmentResourceMetaDto['availability'];
fetchedAt: Date | null;
freshForMs: number;
work: { id: string; state: string; errorCode: string | null } | null;
dependencies?: InvestmentResourceMetaDto[];
coverage?: InvestmentResourceMetaDto['coverage'];
unavailableReason?: string;
}): InvestmentResourceMetaDto {
const ownSync = {
id: input.work?.id ?? null,
state:
input.work?.state === 'running'
? ('running' as const)
: input.work?.state === 'failed'
? ('failed' as const)
: ('idle' as const),
errorCode: input.work?.errorCode ?? null,
};
const states = [
ownSync,
...(input.dependencies ?? [])
.filter((dependency) => !dependency.unavailableReason)
.map((dependency) => dependency.sync),
];
const sync =
states.find((state) => state.state === 'running') ??
states.find((state) => state.state === 'queued') ??
states.find((state) => state.state === 'failed') ??
ownSync;
const sourceFreshness = input.fetchedAt
? Date.now() - input.fetchedAt.valueOf() < input.freshForMs
? ('fresh' as const)
: ('stale' as const)
: ('unknown' as const);
const freshness = [
sourceFreshness,
...(input.dependencies ?? [])
.filter((dependency) => !dependency.unavailableReason)
.map((dependency) => dependency.freshness),
];
const timestamps = [
input.fetchedAt?.toISOString(),
...(input.dependencies ?? []).map(
(dependency) => dependency.lastSuccessfulAt,
),
]
.filter((timestamp): timestamp is string => Boolean(timestamp))
.sort();
const meta = {
resourceKey: input.resourceKey,
...(input.unavailableReason
? { unavailableReason: input.unavailableReason }
: {}),
dataRevision: input.dataRevision,
availability: input.availability,
freshness: freshness.includes('unknown')
? ('unknown' as const)
: freshness.includes('stale')
? ('stale' as const)
: ('fresh' as const),
lastSuccessfulAt: timestamps[0] ?? null,
sync,
...(input.coverage ? { coverage: input.coverage } : {}),
...(input.dependencies ? { dependencies: input.dependencies } : {}),
};
return {
...meta,
stateVersion: createHash('sha256')
.update(
JSON.stringify({
...meta,
// The requested window is identified by resourceKey. Its moving "now"
// boundary is not a change to saved coverage between status and GET.
coverage: meta.coverage
? { ...meta.coverage, requested: undefined }
: undefined,
}),
)
.digest('base64url'),
};
}
@@ -0,0 +1,189 @@
jest.mock('../../prisma/prisma.service', () => ({ PrismaService: class {} }));
import {
planInvestmentScreen,
ledgerRange,
accountHistory,
} from './investment-sync-plan';
describe('screen dependency and history planning', () => {
const now = new Date('2026-09-25T12:00:00Z');
const context = {
membershipKnown: true,
accounts: [{ accountId: 'a', openedOn: '2020-01-01' }],
instruments: [
{
uid: 'uid',
type: 'STOCK' as const,
historyFrom: new Date('2020-01-01Z'),
},
],
};
it('prioritizes discovery, selected/comparisons, then history; Moscow dates are converted once', () => {
const plan = planInvestmentScreen(
{
screen: 'income',
accountId: 'a',
from: '2024-02-01',
to: '2024-02-29',
},
context,
now,
);
expect(plan.sources[0].kind).toBe('membership');
const selected = plan.sources.filter(
(source) => source.kind === 'operations' && source.priority === 'screen',
);
expect(
selected.some(
(source) => source.from?.toISOString() === '2024-01-31T21:00:00.000Z',
),
).toBe(true);
expect(
selected.some(
(source) => source.to?.toISOString() === '2024-02-29T21:00:00.000Z',
),
).toBe(true);
expect(plan.unavailable).toEqual([
{ resourceKey: 'income/yearAgo', reason: 'calendar_or_account_boundary' }, // gitleaks:allow public resource identifier
]);
const firstHistory = plan.sources.findIndex(
(source) => source.priority === 'history',
);
expect(
plan.sources
.slice(firstHistory)
.every((source) => source.priority === 'history'),
).toBe(true);
});
it('force contains only shown dependencies and retains instrument reconciliation predicates', () => {
const plan = planInvestmentScreen(
{
screen: 'stock',
instrumentUid: 'uid',
from: '2026-01-01',
to: '2026-02-01',
candlePeriod: 'year',
},
context,
now,
true,
);
expect(plan.sources.map((source) => source.kind)).toEqual(
expect.arrayContaining([
'membership',
'portfolio',
'operations',
'metadata',
'quote',
'company',
'calendar',
'candles',
]),
);
expect(plan.sources.every((source) => source.priority !== 'history')).toBe(
true,
);
expect(
plan.sources
.filter((source) => source.kind === 'operations')
.every((source) => source.instrumentUid === 'uid'),
).toBe(true);
const accounts = planInvestmentScreen(
{ screen: 'accounts' },
context,
now,
true,
);
expect(
accounts.sources.some((source) => source.kind === 'operations'),
).toBe(false);
});
it('never invents an account or candle history boundary; discovery gates unknown membership', () => {
const unknown = {
...context,
accounts: [{ accountId: 'a', openedOn: null }],
instruments: [],
};
expect(accountHistory(unknown.accounts, now)[0].from.toISOString()).toBe(
'1970-01-01T00:00:00.000Z',
);
expect(
ledgerRange(
undefined,
undefined,
'uid',
unknown.accounts,
now,
).from.toISOString(),
).toBe('1970-01-01T00:00:00.000Z');
const plan = planInvestmentScreen(
{ screen: 'bond', instrumentUid: 'uid', candlePeriod: 'all' },
unknown,
now,
);
expect(plan.sources.some((source) => source.kind === 'candles')).toBe(
false,
);
expect(plan.unavailable[0].reason).toBe('history_start_unavailable');
expect(
planInvestmentScreen(
{ screen: 'account' },
{ ...context, membershipKnown: false },
now,
).sources,
).toEqual([{ kind: 'membership', priority: 'discovery' }]);
});
it('keeps UTC event days separate from Moscow instrument calendar days at year rollover', () => {
const rollover = new Date('2026-12-31T22:00:00Z');
const events = planInvestmentScreen(
{ screen: 'events' },
context,
rollover,
true,
);
expect(
events.sources.find((source) => source.kind === 'calendar')?.from,
).toEqual(new Date('2026-12-31Z'));
const stock = planInvestmentScreen(
{ screen: 'stock', instrumentUid: 'uid' },
context,
rollover,
true,
);
expect(
stock.sources.find((source) => source.kind === 'calendar'),
).toMatchObject({
from: new Date('2022-01-01Z'),
to: new Date('2028-01-01Z'),
});
expect(rollover.toISOString()).toBe('2026-12-31T22:00:00.000Z');
});
it('keeps search queries separate and events scoped to their inclusive period', () => {
const search = planInvestmentScreen(
{ screen: 'stocks', query: ' sber ' },
context,
now,
true,
);
expect(
search.sources.find((source) => source.kind === 'catalog')?.query,
).toBe('SBER');
const events = planInvestmentScreen(
{ screen: 'events', from: '2026-10-01', to: '2026-10-31' },
context,
now,
true,
);
expect(
events.sources.find((source) => source.kind === 'calendar'),
).toMatchObject({
from: new Date('2026-10-01Z'),
to: new Date('2026-10-31Z'),
});
expect(events.sources.some((source) => source.kind === 'operations')).toBe(
false,
);
});
});
@@ -0,0 +1,330 @@
import { planIncomePeriod } from './income/income.api';
import { dateOnlyBounds, moscowDate } from './investment-calendar';
import {
operationIntervals,
type OperationInterval,
} from './operations/operations.api';
export type InvestmentScreen = {
screen:
| 'accounts'
| 'account'
| 'operations'
| 'income'
| 'events'
| 'stocks'
| 'bonds'
| 'stock'
| 'bond';
accountId?: string;
from?: string;
to?: string;
instrumentUid?: string;
query?: string;
candlePeriod?: 'month' | 'halfYear' | 'year' | 'all';
candleRange?: { from: Date; to: Date };
};
const defaultEventDays = 30;
const incomeHistoryYears = 5;
const candleMonths = { month: 1, halfYear: 6, year: 12 };
type Account = { accountId: string; openedOn: string | null };
type Instrument = {
uid: string;
type: 'STOCK' | 'BOND';
historyFrom: Date | null;
};
type Source = {
kind:
| 'membership'
| 'portfolio'
| 'operations'
| 'metadata'
| 'catalog'
| 'quote'
| 'company'
| 'metrics'
| 'calendar'
| 'candles';
accountId?: string;
instrumentUid?: string;
instrumentType?: 'STOCK' | 'BOND';
from?: Date;
to?: Date;
query?: string;
priority: 'discovery' | 'screen' | 'history';
};
// Expands screen scope, never credentials or arbitrary caller-supplied jobs.
// Owners execute their own sources and expand instruments after discovery.
export function planInvestmentScreen(
screen: InvestmentScreen,
context: {
accounts: Account[];
instruments: Instrument[];
membershipKnown: boolean;
},
now: Date,
force = false,
) {
const sources: Source[] = [{ kind: 'membership', priority: 'discovery' }];
const accounts = context.accounts.filter(
(account) =>
!screen.accountId ||
screen.accountId === 'all' ||
account.accountId === screen.accountId,
);
const unavailable: { resourceKey: string; reason: string }[] = [];
const operations = (from: Date, to: Date, instrumentUid?: string) => {
const targets = accounts.flatMap((account) =>
from < to
? [
{
accountId: account.accountId,
from,
to,
...(instrumentUid ? { instrumentUid } : {}),
},
]
: [],
);
sources.push(
...operationIntervals(targets).map((range) => ({
...range,
kind: 'operations' as const,
priority: 'screen' as const,
})),
);
};
const portfolio = () =>
sources.push(
...accounts.map(({ accountId }) => ({
kind: 'portfolio' as const,
accountId,
priority: 'screen' as const,
})),
);
const knownOpening =
accounts.length && accounts.every((account) => account.openedOn)
? new Date(
`${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`,
)
: undefined;
if (!context.membershipKnown) return { sources, unavailable, now };
if (
['accounts', 'account', 'events', 'stock', 'bond'].includes(screen.screen)
)
portfolio();
if (
screen.screen === 'operations' ||
screen.screen === 'stock' ||
screen.screen === 'bond'
) {
const range = ledgerRange(
screen.from,
screen.to,
screen.instrumentUid,
accounts,
now,
);
operations(range.from, range.to, force ? screen.instrumentUid : undefined);
}
if (screen.screen === 'account' || screen.screen === 'income') {
const period = planIncomePeriod(
screen.screen === 'income' ? screen.from : undefined,
screen.screen === 'income' ? screen.to : undefined,
knownOpening,
);
for (const range of [
period.selected,
...(screen.screen === 'income'
? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
: []),
]) {
const bounds = dateOnlyBounds(range);
operations(
bounds.from,
new Date(Math.min(bounds.to.valueOf() + 1, now.valueOf())),
);
}
if (screen.screen === 'income' && !period.yearAgo)
unavailable.push({
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
reason: 'calendar_or_account_boundary',
});
if (screen.screen === 'account') {
const range = ledgerRange(
screen.from,
screen.to,
undefined,
accounts,
now,
);
operations(range.from, range.to);
}
}
if (screen.screen === 'stocks' || screen.screen === 'bonds')
sources.push({
kind: 'catalog',
instrumentType: screen.screen === 'stocks' ? 'STOCK' : 'BOND',
query: screen.query?.trim().toUpperCase() ?? '',
priority: 'screen',
});
const instruments = context.instruments.filter(
(instrument) =>
!screen.instrumentUid || instrument.uid === screen.instrumentUid,
);
if (
screen.instrumentUid &&
!instruments.length &&
(screen.screen === 'stock' || screen.screen === 'bond')
)
instruments.push({
uid: screen.instrumentUid,
type: screen.screen === 'stock' ? 'STOCK' : 'BOND',
historyFrom: null,
});
for (const instrument of instruments) {
const identity = {
instrumentUid: instrument.uid,
instrumentType: instrument.type,
};
sources.push({ ...identity, kind: 'metadata', priority: 'screen' });
if (['stocks', 'bonds', 'stock', 'bond'].includes(screen.screen))
sources.push({ ...identity, kind: 'quote', priority: 'screen' });
if (screen.screen === 'stock' || screen.screen === 'bond') {
sources.push({
...identity,
kind: screen.screen === 'stock' ? 'company' : 'metrics',
priority: 'screen',
});
const from = new Date(now);
const period = screen.candlePeriod ?? 'month';
if (period !== 'all')
from.setUTCMonth(from.getUTCMonth() - candleMonths[period]);
const range =
screen.candleRange ??
(period === 'all'
? instrument.historyFrom
? { from: instrument.historyFrom, to: now }
: null
: { from, to: now });
if (range)
sources.push({
...identity,
kind: 'candles',
...range,
priority: 'screen',
});
else
unavailable.push({
resourceKey: `candles/${instrument.uid}/all`,
reason: 'history_start_unavailable',
});
}
if (['account', 'events', 'stock', 'bond'].includes(screen.screen)) {
const day =
screen.screen === 'stock' || screen.screen === 'bond'
? moscowDate(now)
: now.toISOString().slice(0, 10);
const from =
screen.screen === 'events' && screen.from
? new Date(`${screen.from}T00:00:00Z`)
: new Date(`${day}T00:00:00Z`);
const to =
screen.screen === 'events' && screen.to
? new Date(`${screen.to}T00:00:00Z`)
: new Date(from);
if (
!(screen.screen === 'events' && screen.to) &&
screen.screen !== 'stock' &&
screen.screen !== 'bond'
)
to.setUTCDate(to.getUTCDate() + defaultEventDays);
if (screen.screen === 'stock' || screen.screen === 'bond') {
from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears);
to.setUTCFullYear(to.getUTCFullYear() + 1);
}
sources.push({
...identity,
kind: 'calendar',
from,
to,
priority: 'screen',
});
}
if (
!force &&
['account', 'operations', 'income', 'events'].includes(screen.screen)
) {
if (instrument.historyFrom)
sources.push({
...identity,
kind: 'candles',
from: instrument.historyFrom,
to: now,
priority: 'history',
});
else
unavailable.push({
resourceKey: `candles/${instrument.uid}/all`,
reason: 'history_start_unavailable',
});
}
}
if (!force && !['stocks', 'bonds'].includes(screen.screen))
sources.push(
...accountHistory(accounts, now).map((range) => ({
...range,
kind: 'operations' as const,
priority: 'history' as const,
})),
);
const priorities = { discovery: 0, screen: 1, history: 2 };
sources.sort(
(left, right) => priorities[left.priority] - priorities[right.priority],
);
return { sources, unavailable, now };
}
export function accountHistory(
accounts: Account[],
now: Date,
): OperationInterval[] {
return accounts.flatMap((account) => {
const from = new Date(
account.openedOn
? `${account.openedOn}T00:00:00Z`
: '1970-01-01T00:00:00Z',
);
return from < now ? [{ accountId: account.accountId, from, to: now }] : [];
});
}
export function ledgerRange(
fromValue: string | undefined,
toValue: string | undefined,
instrumentUid: string | undefined,
accounts: Account[],
now: Date,
) {
const opening = accounts.every((account) => account.openedOn)
? accounts.map((account) => account.openedOn ?? '').sort()[0]
: undefined;
const from = fromValue
? new Date(fromValue)
: instrumentUid
? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z')
: new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
const end = toValue
? new Date(
/^\d{4}-\d{2}-\d{2}$/.test(toValue)
? `${toValue}T23:59:59.999Z`
: toValue,
)
: now;
return {
from,
to: new Date(Math.min(end.valueOf() + (toValue ? 1 : 0), now.valueOf())),
};
}
@@ -0,0 +1,294 @@
import { accountHistory } from '../../investment-sync-plan';
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import {
TbankAccessService,
TbankClient,
} from '../../../../integrations/tbank/tbank.api';
import {
BrokerageOperationsRepository,
type OperationAttempt,
} from '../infrastructure/brokerage-operations.repository';
import {
operationIntervals,
planOperationRead,
type OperationInterval,
uncoveredOperationIntervals,
} from '../domain/operations-sync-plan';
const operationsPageSize = 1000;
const priorities = { screen: 1, history: 2 } as const;
@Injectable()
export class BrokerageOperationsSyncService {
private readonly processRunId = randomUUID();
private readonly runningAttempts = new Set<string>();
private readonly ready = new Map<
string,
{ attempt: OperationAttempt; priority: number }
>();
private draining = false;
// Only short DB reservations/publications are serialized, never RPCs.
private reservations: Promise<unknown> = Promise.resolve();
constructor(
private readonly repository: BrokerageOperationsRepository,
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly logger: StructuredLogger,
) {}
async readPlan(
userId: string,
intervals: OperationInterval[],
options: {
movingEnd?: boolean;
force?: boolean;
observe?: boolean;
history?: boolean;
revision?: string;
} = {},
) {
const connection = await this.access.local(userId);
if (
!connection.membershipAt ||
(options.revision && options.revision !== connection.revision)
)
return { plan: null, handle: null };
if (
intervals.some(
(interval) =>
!connection.accounts.some(
(account) => account.accountId === interval.accountId,
),
)
)
throw new NotFoundException();
const coverage = await this.repository.coverage(
userId,
connection.revision,
intervals.map((interval) => interval.accountId),
);
const now = new Date();
const movingTo = Math.max(
...intervals.map((interval) => interval.to.valueOf()),
);
const targets = intervals.map((interval) =>
options.movingEnd && interval.to.valueOf() === movingTo
? { ...interval, to: now }
: interval,
);
const plan = planOperationRead(targets, coverage, now, options);
const complete = plan.intervals.every(
(interval) => interval.availability === 'complete',
);
const handle =
!options.observe && plan.load.length
? await this.request(
userId,
plan.load,
complete || Boolean(options.force),
'screen',
connection.revision,
)
: null;
if (!options.observe && !options.force && options.history) {
const accountIds = new Set(
intervals.map((interval) => interval.accountId),
);
const history = accountHistory(
connection.accounts.filter((account) =>
accountIds.has(account.accountId),
),
now,
);
const missing = planOperationRead(history, coverage, now, {
movingEnd: true,
}).load;
const remaining = uncoveredOperationIntervals(missing, targets);
if (remaining.length)
await this.request(
userId,
remaining,
false,
'history',
connection.revision,
);
}
return { plan, handle };
}
async request(
userId: string,
intervals: OperationInterval[],
atomic: boolean,
priority: keyof typeof priorities = 'screen',
revision?: string,
): Promise<string | null> {
const connection = await this.access.local(userId);
if (
connection.membershipAt &&
intervals.some(
(interval) =>
!connection.accounts.some(
(account) => account.accountId === interval.accountId,
),
)
)
throw new NotFoundException();
if (revision && revision !== connection.revision) return null;
const cutoff = new Date();
const bounded = operationIntervals(intervals)
.map((interval) => ({
...interval,
to: new Date(Math.min(interval.to.valueOf(), cutoff.valueOf())),
}))
.filter((interval) => interval.from < interval.to);
if (!bounded.length) return null;
const reserve = this.reservations.then(async () => {
const requested = await this.repository.request(
userId,
connection.revision,
operationIntervals(bounded),
this.processRunId,
atomic,
this.runningAttempts,
priorities[priority],
);
if (!requested) return null;
const { id, attemptId, revision } = requested.work;
this.runningAttempts.add(attemptId);
this.ready.set(attemptId, {
attempt: { id, attemptId, userId, revision },
priority: requested.work.priority,
});
if (!this.draining) {
this.draining = true;
// Startup and observation never enter this path.
setImmediate(() => {
void this.drain();
});
}
return id;
});
this.reservations = reserve.catch(() => undefined);
return reserve;
}
async observe(userId: string, id: string) {
const connection = await this.access.local(userId);
const work = await this.repository.observe(userId, connection.revision, id);
if (!work) throw new NotFoundException();
return work;
}
private async drain(): Promise<void> {
try {
while (this.ready.size) {
const selected = [...this.ready.values()].sort(
(a, b) => a.priority - b.priority,
)[0];
const { attempt } = selected;
this.ready.delete(attempt.attemptId);
let pending = false;
try {
pending = await this.step(attempt);
} catch {
this.logger.event(
'error',
'tbank.operations.sync.persistence_failed',
{ userId: attempt.userId, code: 'TBANK_UNAVAILABLE' },
);
}
const next = pending ? await this.repository.pending(attempt) : null;
if (next) {
const joined = this.ready.get(attempt.attemptId);
this.ready.set(attempt.attemptId, {
attempt,
priority: Math.min(
next.priority,
joined?.priority ?? next.priority,
),
});
} else {
this.ready.delete(attempt.attemptId);
this.runningAttempts.delete(attempt.attemptId);
}
}
} finally {
this.draining = false;
}
}
private async step(attempt: OperationAttempt): Promise<boolean> {
try {
const plan = await this.repository.pending(attempt);
if (!plan) return false;
if (!plan.next) {
const publish = this.reservations.then(async () => {
const current = await this.repository.pending(attempt);
if (!current) return false;
if (current.next) return true;
const published = await this.repository.publish(
attempt,
current.intervals,
new Date(),
true,
);
return published && Boolean(await this.repository.pending(attempt));
});
this.reservations = publish.catch(() => undefined);
return await publish;
}
const { interval, cursor } = plan.next;
const token = await this.access.tokenForAttempt(attempt);
if (!token) return false;
const response = await this.tbank.getOperationsByCursor(token, {
accountId: interval.accountId,
// Upstream inclusivity is not assumed; storage selects exact [from,to).
from: {
seconds: String(
Math.floor(interval.from.valueOf() / MILLISECONDS_PER_SECOND) - 1,
),
},
to: {
seconds: String(
Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + 1,
),
},
...(interval.instrumentUid
? { instrumentId: interval.instrumentUid }
: {}),
cursor,
limit: operationsPageSize,
state: 'OPERATION_STATE_EXECUTED',
});
if (
!(await this.repository.stagePage(attempt, interval, cursor, response))
)
return false;
if (!plan.atomic && !response.hasNext) {
if (
!(await this.repository.publish(
attempt,
[interval],
new Date(),
true,
))
)
return false;
}
// One page per turn: newly requested screen work can overtake backfill.
// TbankClient alone owns the bounded RPC queue and retries.
return true;
} catch (error) {
await this.repository.fail(
attempt,
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
);
return false;
}
}
}
@@ -1,373 +0,0 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
import type { TbankAccountContext } from '../../../../integrations/tbank/tbank-access.service';
import { BrokerageOperationsService } from './brokerage-operations.service';
describe('BrokerageOperationsService', () => {
const access = { withAccount: jest.fn() };
const tbank = {
getOperationsByCursor: jest.fn(),
};
const requestContext = { set: jest.fn() };
const metadata = { resolve: jest.fn() };
const service = new BrokerageOperationsService(
access as never,
tbank as never,
requestContext as never,
metadata as never,
);
let operationsRequest: unknown;
let generation: string;
beforeEach(() => {
jest.clearAllMocks();
access.withAccount.mockReset();
tbank.getOperationsByCursor.mockReset();
metadata.resolve.mockReset();
generation = 'generation-1';
access.withAccount.mockImplementation(
(
_userId: string,
_accountId: string,
handler: (context: TbankAccountContext) => Promise<unknown>,
) =>
handler({
token: 'token',
generation,
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getOperationsByCursor.mockImplementation(
(_token: string, request: unknown) => {
operationsRequest = request;
return Promise.resolve({ items: [] });
},
);
(service as unknown as { pages: Map<string, unknown> }).pages.clear();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('uses the current UTC year by default and asks only for executed operations', async () => {
await service.get('user', 'account', {});
const request = operationsRequest as {
state: string;
from: { seconds: string };
};
expect(request.state).toBe('OPERATION_STATE_EXECUTED');
expect(request.from.seconds).toBe(
String(Math.floor(Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000)),
);
});
it('uses inclusive date boundaries, filters categories, and preserves cursors', async () => {
tbank.getOperationsByCursor.mockResolvedValue({
hasNext: true,
nextCursor: 'next',
items: [
{ id: 'income', date: { seconds: '1' }, type: 'OPERATION_TYPE_COUPON' },
{
id: 'fee',
date: { seconds: '2' },
type: 'OPERATION_TYPE_BROKER_FEE',
},
],
});
await expect(
service.get('user', 'account', {
from: '2025-01-01',
to: '2025-01-31',
categories: ['income'],
cursor: 'previous',
limit: 10,
}),
).resolves.toMatchObject({
items: [{ id: 'income' }],
nextCursor: 'next',
hasNext: true,
});
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
'token',
expect.objectContaining({
from: { seconds: '1735689600' },
to: { seconds: '1738367999', nanos: 999_999_999 },
cursor: 'previous',
}),
);
});
it('includes resolved instrument logo metadata in a ledger entry', async () => {
tbank.getOperationsByCursor.mockResolvedValue({
items: [
{
id: 'coupon',
date: { seconds: '1' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
},
],
});
metadata.resolve.mockResolvedValue({
brand: { logoName: 'instrument-logo.png' },
});
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
items: [{ id: 'coupon', logoName: 'instrument-logo.png' }],
});
});
it('resolves repeated instrument metadata only once per ledger page', async () => {
tbank.getOperationsByCursor.mockResolvedValue({
items: [
{
id: 'first',
date: { seconds: '1' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
},
{
id: 'second',
date: { seconds: '2' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
},
],
});
metadata.resolve.mockResolvedValue({
brand: { logoName: 'instrument-logo.png' },
});
await service.get('user', 'account', {});
expect(metadata.resolve).toHaveBeenCalledTimes(1);
});
it('does not let a UID-only lookup suppress a later FIGI fallback on the same page', async () => {
tbank.getOperationsByCursor.mockResolvedValue({
items: [
{
id: 'first',
date: { seconds: '1' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
},
{
id: 'second',
date: { seconds: '2' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
figi: 'BBG000000001',
},
],
});
metadata.resolve.mockResolvedValueOnce(null).mockResolvedValueOnce({
brand: { logoName: 'fallback-logo.png' },
});
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
items: [
{ id: 'first', logoName: null },
{ id: 'second', logoName: 'fallback-logo.png' },
],
});
expect(metadata.resolve).toHaveBeenNthCalledWith(
1,
'token',
'instrument-uid',
null,
);
expect(metadata.resolve).toHaveBeenNthCalledWith(
2,
'token',
'instrument-uid',
'BBG000000001',
);
});
it('returns an empty page without calling T-Bank when the period starts in the future', async () => {
await expect(
service.get('user', 'account', { from: '2999-01-01T00:00:00.000Z' }),
).resolves.toMatchObject({
accountId: 'account',
items: [],
nextCursor: null,
hasNext: false,
});
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
});
it('rejects inaccessible accounts before reading operations', async () => {
access.withAccount.mockRejectedValueOnce(new NotFoundException());
await expect(service.get('user', 'account', {})).rejects.toEqual(
expect.any(NotFoundException),
);
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
});
it('retains existing T-Bank error semantics', async () => {
access.withAccount.mockRejectedValueOnce(
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
);
await expect(service.get('user', 'account', {})).rejects.toEqual(
expect.any(BadRequestException),
);
tbank.getOperationsByCursor.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account', {})).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
});
it('reuses one fresh page for equivalent categories within the default end-time bucket', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-23T12:00:00.100Z'));
await service.get('user', 'account', {
categories: ['tax', 'income', 'income'],
});
jest.advanceTimersByTime(500);
await service.get('user', 'account', { categories: ['income', 'tax'] });
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
});
it('does not let a cached page bypass account ownership checks', async () => {
const query = { from: '2025-01-01', to: '2025-01-31' };
await service.get('user', 'account', query);
access.withAccount.mockRejectedValueOnce(new NotFoundException());
await expect(service.get('user', 'account', query)).rejects.toEqual(
expect.any(NotFoundException),
);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
});
it('does not reuse a cached ledger page across connection generations', async () => {
const query = { from: '2025-01-01', to: '2025-01-31' };
await service.get('user', 'account', query);
await service.get('user', 'account', query);
generation = 'generation-2';
await service.get('user', 'account', query);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
});
it('does not reuse a cached ledger page after the connection is deleted', async () => {
const query = { from: '2025-01-01', to: '2025-01-31' };
await service.get('user', 'account', query);
access.withAccount.mockRejectedValueOnce(
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
);
await expect(service.get('user', 'account', query)).rejects.toEqual(
expect.any(ConflictException),
);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
});
it('keeps a late previous-generation page unavailable to ledger and income collection', async () => {
let finishPrevious: ((value: unknown) => void) | undefined;
const previous = new Promise((resolve) => {
finishPrevious = resolve;
});
tbank.getOperationsByCursor
.mockReturnValueOnce(previous)
.mockResolvedValueOnce({
items: [operation('current')],
});
const range = { from: '2025-01-01', to: '2025-01-31' };
const previousRequest = service.collectRanges('user', 'account', [range]);
generation = 'generation-2';
const current = await service.collectRanges('user', 'account', [range]);
finishPrevious?.({ items: [operation('previous')] });
await previousRequest;
const reused = await service.collectRanges('user', 'account', [range]);
expect(current.pages[0][0].items[0].id).toBe('current');
expect(reused.pages[0][0].items[0].id).toBe('current');
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
});
it('collects every cursor page and rejects a malformed continuation cursor', async () => {
tbank.getOperationsByCursor
.mockResolvedValueOnce({ hasNext: true, nextCursor: 'next', items: [] })
.mockResolvedValueOnce({ hasNext: false, items: [] });
await expect(
service.collectRanges('user', 'account', [
{ from: '2026-01-01', to: '2026-01-31' },
]),
).resolves.toMatchObject({
pages: [[{ hasNext: true }, { hasNext: false }]],
});
tbank.getOperationsByCursor.mockReset();
tbank.getOperationsByCursor.mockResolvedValue({
hasNext: true,
nextCursor: 'same',
items: [],
});
await expect(
service.collectRanges('user', 'account', [
{ from: '2026-02-01', to: '2026-02-28' },
]),
).rejects.toMatchObject({ response: { code: 'TBANK_UNAVAILABLE' } });
});
it('collects an in-progress Moscow day only through the current instant', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-27T21:44:18.510Z'));
await service.collectRanges('user', 'account', [
{ from: '2026-08-01', to: '2026-08-28' },
]);
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
'token',
expect.objectContaining({
from: { seconds: '1785531600' },
to: { seconds: '1787867058' },
limit: 1000,
}),
);
});
it('evicts the least recently used ledger page after one hundred entries', async () => {
for (let index = 0; index < 100; index += 1)
await service.get('user', 'account', { cursor: String(index) });
await service.get('user', 'account', { cursor: '0' });
await service.get('user', 'account', { cursor: '100' });
await service.get('user', 'account', { cursor: '1' });
const pages = (service as unknown as { pages: { size: number } }).pages;
expect(pages.size).toBe(100);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(102);
});
});
function operation(id: string) {
return {
id,
date: { seconds: '1' },
type: 'OPERATION_TYPE_COUPON',
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,141 @@
import { operationIntervals, planOperationRead } from './operations-sync-plan';
describe('operations calendar windows', () => {
it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => {
const result = operationIntervals([
{
accountId: 'a',
from: new Date('2024-02-01T00:00:00+03:00'),
to: new Date('2024-03-01T00:00:00+03:00'),
},
{
accountId: 'a',
from: new Date('2024-02-10Z'),
to: new Date('2024-04-01Z'),
},
]);
expect(
result.map(({ from, to }) => [from.toISOString(), to.toISOString()]),
).toEqual([
['2024-01-31T21:00:00.000Z', '2024-02-01T00:00:00.000Z'],
['2024-02-01T00:00:00.000Z', '2024-03-01T00:00:00.000Z'],
['2024-03-01T00:00:00.000Z', '2024-04-01T00:00:00.000Z'],
]);
});
it('does not combine account or instrument predicates and rejects invalid ranges', () => {
const range = {
from: new Date('2026-12-15Z'),
to: new Date('2027-01-15Z'),
};
const result = operationIntervals([
{ accountId: 'a', ...range },
{ accountId: 'b', ...range },
{ accountId: 'a', instrumentUid: 'uid', ...range },
]);
expect(result).toHaveLength(6);
expect(() =>
operationIntervals([{ accountId: 'a', from: range.to, to: range.from }]),
).toThrow();
});
});
describe('operation completeness and freshness', () => {
const from = new Date('2026-09-01Z');
const asOf = new Date('2026-09-25T12:00:00Z');
const covered = { accountId: 'a', from, to: asOf, asOf, fetchedAt: asOf };
it.each([1, 299, 300])(
'preserves the observed complete end after %s seconds, separately from TTL',
(seconds) => {
const now = new Date(asOf.valueOf() + seconds * 1000);
const plan = planOperationRead(
[{ accountId: 'a', from, to: now }],
[covered],
now,
{ movingEnd: true },
);
expect(plan.intervals[0]).toMatchObject({
availability: 'complete',
asOf,
to: asOf,
stale: seconds >= 300,
});
expect(plan.load).toEqual(
seconds >= 300 ? [{ accountId: 'a', from, to: now }] : [],
);
},
);
it('preserves a cursor observed end after the clock and calendar advance', () => {
const cursorEnd = new Date(asOf.valueOf() + 1000);
const plan = planOperationRead(
[{ accountId: 'a', from, to: cursorEnd }],
[covered],
new Date('2026-10-01Z'),
{ movingEnd: true },
);
expect(plan.intervals[0]).toMatchObject({
availability: 'complete',
to: asOf,
asOf,
});
expect(plan.load).toEqual([]);
});
it('does not hide earlier holes or reuse an instrument subset as whole-account completeness', () => {
const now = new Date(asOf.valueOf() + 1000);
const partial = { ...covered, from: new Date('2026-09-10Z') };
const plan = planOperationRead(
[{ accountId: 'a', from, to: now }],
[partial],
now,
{ movingEnd: true },
);
expect(plan.intervals[0]).toMatchObject({
availability: 'partial',
stale: false,
});
expect(plan.load).toEqual([{ accountId: 'a', from, to: partial.from }]);
expect(
planOperationRead(
[{ accountId: 'a', from, to: now }],
[{ ...covered, instrumentUid: 'uid' }],
now,
).intervals[0].availability,
).toBe('missing');
});
it('loads a newly explicit tail and closes the previous month after rollover', () => {
const now = new Date(asOf.valueOf() + 1000);
expect(
planOperationRead([{ accountId: 'a', from, to: now }], [covered], now)
.load,
).toEqual([{ accountId: 'a', from: asOf, to: now }]);
const monthEnd = new Date('2026-10-01Z');
const rollover = new Date('2026-10-01T00:00:01Z');
const plan = planOperationRead(
[{ accountId: 'a', from, to: rollover }],
[covered],
rollover,
{ movingEnd: true },
);
expect(plan.load).toEqual([
{ accountId: 'a', from: asOf, to: monthEnd },
{ accountId: 'a', from: monthEnd, to: rollover },
]);
expect(
planOperationRead(
[{ accountId: 'a', from, to: monthEnd }],
[{ ...covered, to: monthEnd, asOf: monthEnd }],
rollover,
).load,
).toEqual([]);
});
it('force refresh reloads only the shown target even when coverage is fresh', () => {
const target = { accountId: 'a', from: new Date('2026-09-20Z'), to: asOf };
expect(
planOperationRead([target], [covered], asOf, { force: true }).load,
).toEqual([target]);
});
});
@@ -0,0 +1,183 @@
export type OperationInterval = {
accountId: string;
from: Date;
to: Date;
instrumentUid?: string;
};
// Normalize duplicate/overlapping targets before creating a durable identity.
// Calendar windows keep each provider walk bounded without changing [from,to).
export function operationIntervals(
requested: OperationInterval[],
): OperationInterval[] {
const groups = new Map<string, OperationInterval[]>();
for (const interval of requested) {
if (!(interval.from < interval.to))
throw new Error('Invalid operations interval');
const key = JSON.stringify([
interval.accountId,
interval.instrumentUid ?? '',
]);
const group = groups.get(key) ?? [];
group.push(interval);
groups.set(key, group);
}
const result: OperationInterval[] = [];
for (const key of [...groups.keys()].sort()) {
const merged: OperationInterval[] = [];
for (const interval of (groups.get(key) ?? []).sort(
(left, right) => left.from.valueOf() - right.from.valueOf(),
)) {
const previous = merged.at(-1);
if (previous && interval.from <= previous.to)
previous.to = new Date(
Math.max(previous.to.valueOf(), interval.to.valueOf()),
);
else merged.push({ ...interval });
}
for (const interval of merged) {
let from = interval.from;
while (from < interval.to) {
const nextMonth = new Date(
Date.UTC(from.getUTCFullYear(), from.getUTCMonth() + 1, 1),
);
const to = new Date(
Math.min(nextMonth.valueOf(), interval.to.valueOf()),
);
result.push({ ...interval, from, to });
from = to;
}
}
}
return result;
}
export function operationIntervalsOverlap(
left: OperationInterval,
right: OperationInterval,
): boolean {
return (
left.accountId === right.accountId &&
(!left.instrumentUid ||
!right.instrumentUid ||
left.instrumentUid === right.instrumentUid) &&
left.from < right.to &&
right.from < left.to
);
}
export function uncoveredOperationIntervals(
requested: OperationInterval[],
existing: OperationInterval[],
): OperationInterval[] {
return requested.flatMap((interval) => {
let fragments = [interval];
for (const covered of existing) {
fragments = fragments.flatMap((fragment) => {
if (
!operationIntervalsOverlap(fragment, covered) ||
(covered.instrumentUid &&
covered.instrumentUid !== fragment.instrumentUid)
)
return [fragment];
return [
...(fragment.from < covered.from
? [{ ...fragment, to: covered.from }]
: []),
...(covered.to < fragment.to
? [{ ...fragment, from: covered.to }]
: []),
];
});
}
return fragments;
});
}
export type OperationCoverage = OperationInterval & {
fetchedAt: Date;
asOf: Date;
};
const operationsFreshForMs = 300_000;
export function planOperationRead(
requested: OperationInterval[],
coverage: OperationCoverage[],
now: Date,
options: { movingEnd?: boolean; force?: boolean } = {},
) {
const monthStart = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1),
);
const observedEnd = Math.max(
...requested.map((interval) => interval.to.valueOf()),
);
const load: OperationInterval[] = [];
const intervals = operationIntervals(requested).map((target) => {
const sources = coverage.filter(
(row) =>
row.accountId === target.accountId &&
(!row.instrumentUid || row.instrumentUid === target.instrumentUid) &&
row.from < target.to &&
target.from < row.to,
);
const ranges = sources.map((row) => ({
...row,
instrumentUid: target.instrumentUid,
}));
const current = target.from >= monthStart && target.to <= now;
const fresh = sources.filter(
(row) => now.valueOf() - row.fetchedAt.valueOf() < operationsFreshForMs,
);
let effective = target;
if (options.movingEnd && target.to.valueOf() === observedEnd) {
const latest = sources
.filter(
(row) =>
row.asOf.valueOf() === row.to.valueOf() && row.to > target.from,
)
.sort((left, right) => right.to.valueOf() - left.to.valueOf())[0];
if (latest && latest.to < target.to)
effective = { ...target, to: latest.to };
}
const missing = uncoveredOperationIntervals([effective], ranges);
const complete = missing.length === 0;
const stale =
current &&
sources.length > 0 &&
uncoveredOperationIntervals(
ranges
.map((row) => ({
...target,
from: new Date(
Math.max(effective.from.valueOf(), row.from.valueOf()),
),
to: new Date(Math.min(effective.to.valueOf(), row.to.valueOf())),
}))
.filter((row) => row.from < row.to),
fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })),
).length > 0;
if (options.force) load.push(target);
else if (stale) load.push({ ...target, from: monthStart, to: now });
else load.push(...missing);
// Completeness is bounded by the observed end, never by the new wall clock.
return {
...effective,
availability: complete
? ('complete' as const)
: sources.length
? ('partial' as const)
: ('missing' as const),
stale,
asOf: sources.length
? new Date(
Math.min(
effective.to.valueOf(),
Math.max(...sources.map((row) => row.asOf.valueOf())),
),
)
: null,
};
});
return { intervals, load: operationIntervals(load) };
}
@@ -0,0 +1,922 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { PrismaService } from '../../../../prisma/prisma.service';
import type { Prisma } from '../../../../../generated/prisma/client';
import type {
TbankOperationItem,
TbankOperationsByCursorResponse,
} from '../../../../integrations/tbank/tbank.api';
import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar';
import { operationCategory } from './brokerage-operation.mapper';
import {
type OperationInterval,
operationIntervals,
operationIntervalsOverlap,
uncoveredOperationIntervals,
} from '../domain/operations-sync-plan';
export type { OperationInterval } from '../domain/operations-sync-plan';
export type OperationAttempt = {
id: string;
attemptId: string;
userId: string;
revision: string;
};
type OperationsIntent = {
intervals: string[];
atomic: boolean;
atomicIntervals?: string[];
screenIntervals?: string[];
};
type PageProgress = {
complete: boolean;
cursor: string;
seen: string[];
pages: number;
};
const progressItem = '';
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
@Injectable()
export class BrokerageOperationsRepository {
constructor(private readonly prisma: PrismaService) {}
async readPage(input: {
userId: string;
revision: string;
accountId: string;
from: Date;
to: Date;
instrumentUid?: string;
categories: string[];
limit?: number;
periods?: (openedAt: Date | undefined) => DateOnlyRange[];
filterKey: string;
cursor?: { binding: string; at: string; accountId: string; id: string };
}) {
return this.prisma.$transaction(async (tx) => {
const connection = await tx.tbankConnection.findUnique({
where: {
userId_revision: { userId: input.userId, revision: input.revision },
invalidToken: false,
},
select: {
membershipAt: true,
membershipRevision: true,
accounts: {
where:
input.accountId === 'all' ? {} : { accountId: input.accountId },
orderBy: { accountId: 'asc' },
select: {
accountId: true,
name: true,
openedOn: true,
operationsRevision: true,
},
},
},
});
if (!connection) return null;
if (
connection.membershipAt &&
input.accountId !== 'all' &&
!connection.accounts.length
)
throw new NotFoundException();
const accounts = connection.accounts;
const oldest = accounts.map((account) => account.openedOn).sort()[0];
const openedAt =
oldest && accounts.every((account) => account.openedOn)
? new Date(`${oldest}T00:00:00+03:00`)
: undefined;
const periods = input.periods?.(openedAt).map((period) => {
const bounds = dateOnlyBounds(period);
// Date-only end is inclusive; persisted coverage is half-open.
const end = new Date(`${period.to}T21:00:00Z`);
return {
...bounds,
key: `${period.from}/${period.to}`,
movingEnd: end.valueOf() > Date.now(),
to: new Date(Math.min(end.valueOf(), Date.now())),
};
});
const binding = createHash('sha256')
.update(
JSON.stringify([
input.userId,
input.revision,
input.accountId === 'all' ? connection.membershipRevision : null,
accounts.map((account) => [
account.accountId,
account.operationsRevision,
]),
input.filterKey,
]),
)
.digest('base64url');
if (input.cursor && input.cursor.binding !== binding)
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
const scope = {
userId: input.userId,
revision: input.revision,
accountId: { in: accounts.map((account) => account.accountId) },
};
const coverage = await tx.tbankOperationCoverage.findMany({
where: scope,
});
const work = await tx.tbankSyncWork.findMany({
where: {
userId: input.userId,
revision: input.revision,
scopeKey: { startsWith: 'operations:' },
state: { in: ['running', 'failed'] },
},
select: {
id: true,
state: true,
errorCode: true,
intent: true,
priority: true,
},
});
const membershipWork = await tx.tbankSyncWork.findUnique({
where: {
userId_revision_scopeKey: {
userId: input.userId,
revision: input.revision,
scopeKey: 'membership',
},
},
select: { id: true, state: true, errorCode: true },
});
const after = input.cursor;
const rows = await tx.tbankOperation.findMany({
where: {
...scope,
...(periods
? {
OR: periods.map((period) => ({
occurredAt: { gte: period.from, lt: period.to },
})),
}
: { occurredAt: { gte: input.from, lt: input.to } }),
...(input.instrumentUid
? { instrumentUid: input.instrumentUid }
: {}),
...(input.categories.length
? { category: { in: input.categories } }
: {}),
...(after
? {
OR: [
{ occurredAt: { lt: new Date(after.at) } },
{
occurredAt: new Date(after.at),
accountId: { gt: after.accountId },
},
{
occurredAt: new Date(after.at),
accountId: after.accountId,
brokerOperationId: { gt: after.id },
},
],
}
: {}),
},
orderBy: [
{ occurredAt: 'desc' },
{ accountId: 'asc' },
{ brokerOperationId: 'asc' },
],
...(input.limit === undefined ? {} : { take: input.limit + 1 }),
});
return {
connection,
periods,
membershipWork,
binding,
rows,
coverage: coverage.map(({ instrumentPredicate, ...row }) => ({
...row,
instrumentUid: instrumentPredicate || undefined,
})),
work: work.map(({ intent, ...row }) => ({
...row,
intervals: (intent as { intervals: string[] }).intervals.map(
decodeInterval,
),
})),
};
});
}
async coverage(userId: string, revision: string, accountIds: string[]) {
const rows = await this.prisma.tbankOperationCoverage.findMany({
where: {
userId,
revision,
accountId: { in: accountIds },
account: { connection: { invalidToken: false } },
},
});
return rows.map(({ instrumentPredicate, ...row }) => ({
...row,
instrumentUid: instrumentPredicate || undefined,
}));
}
async request(
userId: string,
revision: string,
intervals: OperationInterval[],
processRunId: string,
atomic: boolean,
runningAttempts: ReadonlySet<string>,
priority: number,
) {
if (!intervals.length) throw new Error('Empty operations request');
const scopeKey =
'operations:' +
createHash('sha256')
.update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort()))
.digest('hex');
return this.prisma.$transaction(async (tx) => {
const locked = await tx.tbankConnection.updateMany({
where: {
userId,
revision,
invalidToken: false,
membershipAt: { not: null },
},
data: { membershipRevision: { increment: 0 } },
});
if (!locked.count) return null;
const accounts = await tx.tbankAccount.findMany({
where: { userId, revision },
select: { accountId: true, openedOn: true },
});
if (
intervals.some(
(interval) =>
!accounts.some(
(account) => account.accountId === interval.accountId,
),
)
)
throw new NotFoundException();
if (priority > 1) {
for (const account of accounts.filter((account) =>
intervals.some(
(interval) => interval.accountId === account.accountId,
),
))
await tx.tbankAccount.update({
where: {
userId_revision_accountId: {
userId,
revision,
accountId: account.accountId,
},
},
data: {
historyFrom: new Date(
account.openedOn
? `${account.openedOn}T00:00:00Z`
: '1970-01-01T00:00:00Z',
),
historyBoundary: account.openedOn ? 'known' : 'unknown',
},
});
}
const coverage = await tx.tbankOperationCoverage.findMany({
where: {
userId,
revision,
accountId: { in: intervals.map((interval) => interval.accountId) },
},
});
const publicationAtomic =
atomic ||
intervals.every(
(interval) =>
uncoveredOperationIntervals(
[interval],
coverage
.filter(
(row) =>
row.accountId === interval.accountId &&
(!row.instrumentPredicate ||
row.instrumentPredicate === interval.instrumentUid),
)
.map((row) => ({
accountId: row.accountId,
from: row.from,
to: row.to,
instrumentUid: interval.instrumentUid,
})),
).length === 0,
);
const active = await tx.tbankSyncWork.findMany({
where: {
userId,
revision,
state: 'running',
scopeKey: { startsWith: 'operations:' },
},
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
const touching = active.filter((work) => {
const pending = (work.intent as { intervals: string[] }).intervals.map(
decodeInterval,
);
return pending.some((existing) =>
intervals.some((incoming) =>
operationIntervalsOverlap(existing, incoming),
),
);
});
const previous =
touching[0] ??
(await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: { userId, revision, scopeKey } },
}));
const identity = {
userId,
revision,
scopeKey: previous?.scopeKey ?? scopeKey,
};
const previousIntent = previous?.intent as OperationsIntent | undefined;
const upgradeGroup =
publicationAtomic && previousIntent && !previousIntent.atomic;
if (
touching.length <= 1 &&
previous?.state === 'running' &&
previous.processRunId === processRunId &&
runningAttempts.has(previous.attemptId) &&
!upgradeGroup
) {
const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
const additions =
previous.scopeKey === scopeKey
? []
: uncoveredOperationIntervals(intervals, pending);
const work = await tx.tbankSyncWork.update({
where: { id: previous.id },
data: {
intent: {
atomic: previousIntent?.atomic ?? publicationAtomic,
atomicIntervals: previousIntent?.atomic
? [
...new Set([
...(previousIntent.atomicIntervals ??
previousIntent.intervals),
...additions.map(intervalKey),
]),
]
: [],
screenIntervals: [
...new Set([
...(previousIntent?.screenIntervals ??
(previous.priority === 1
? (previousIntent?.intervals ?? [])
: [])),
...(priority === 1
? [...pending, ...additions]
.filter((item) =>
intervals.some((target) =>
operationIntervalsOverlap(item, target),
),
)
.map(intervalKey)
: []),
]),
],
intervals: (priority < previous.priority
? [
...pending.filter((item) =>
intervals.some((target) =>
operationIntervalsOverlap(item, target),
),
),
...additions,
...pending.filter(
(item) =>
!intervals.some((target) =>
operationIntervalsOverlap(item, target),
),
),
]
: [...pending, ...additions]
).map(intervalKey),
},
priority: Math.min(priority, previous.priority),
},
});
return { work, launch: false };
}
const recovering =
previous &&
previous.state !== 'complete' &&
previous.state !== 'superseded';
const retained = recovering
? (previousIntent?.intervals.map(decodeInterval) ?? [])
: [];
// A bridge between two active groups must fence both producers before
// either can publish a subset of the newly combined result.
for (const joined of touching) {
if (joined.id === previous?.id) continue;
retained.push(
...(joined.intent as { intervals: string[] }).intervals.map(
decodeInterval,
),
);
await tx.tbankSyncWork.update({
where: { id: joined.id },
data: {
state: 'superseded',
progress: { successorId: previous?.id ?? '' },
},
});
await tx.tbankSyncStage.deleteMany({ where: { workId: joined.id } });
}
if (previous)
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
const targets =
recovering && !upgradeGroup && touching.length <= 1
? previous?.scopeKey === scopeKey
? retained
: [...retained, ...uncoveredOperationIntervals(intervals, retained)]
: operationIntervals([...retained, ...intervals]);
const atomicTargets = [
...(publicationAtomic ? intervals : []),
...(recovering && previousIntent?.atomic
? (previousIntent.atomicIntervals ?? previousIntent.intervals).map(
decodeInterval,
)
: []),
...touching.flatMap((work) => {
const joined = work.intent as OperationsIntent;
return joined.atomic
? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval)
: [];
}),
];
const intent = {
intervals: targets.map(intervalKey),
atomicIntervals: targets
.filter((target) =>
atomicTargets.some((atomic) =>
operationIntervalsOverlap(target, atomic),
),
)
.map(intervalKey),
screenIntervals: targets
.filter(
(target) =>
(priority === 1 &&
intervals.some((incoming) =>
operationIntervalsOverlap(target, incoming),
)) ||
(
previousIntent?.screenIntervals ??
(previous?.priority === 1
? (previousIntent?.intervals ?? [])
: [])
).some((key) =>
operationIntervalsOverlap(target, decodeInterval(key)),
),
)
.map(intervalKey),
atomic: atomicTargets.length > 0,
};
const data = {
intent,
state: 'running',
attemptId: randomUUID(),
processRunId,
priority: Math.min(
priority,
previous?.priority ?? priority,
...touching.map((work) => work.priority),
),
progress: {},
errorCode: null,
};
const work = await tx.tbankSyncWork.upsert({
where: { userId_revision_scopeKey: identity },
create: { ...identity, ...data, id: randomUUID() },
update: data,
});
return { work, launch: true };
});
}
async pending(attempt: OperationAttempt) {
const work = await this.prisma.tbankSyncWork.findFirst({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
select: { intent: true, priority: true },
});
if (!work) return null;
const intent = work.intent as OperationsIntent;
const markers = await this.prisma.tbankSyncStage.findMany({
where: {
workId: attempt.id,
attemptId: attempt.attemptId,
itemKey: progressItem,
},
select: { sourceKey: true, payload: true },
});
const progress = new Map(
markers.map((marker) => [
marker.sourceKey,
marker.payload as PageProgress,
]),
);
const group = intent.atomic
? (intent.atomicIntervals ?? intent.intervals)
: intent.intervals;
const nextKey = group.find((key) => !progress.get(key)?.complete);
return {
priority: work.priority,
atomic: intent.atomic,
intervals: group.map(decodeInterval),
next: nextKey
? {
interval: decodeInterval(nextKey),
cursor: progress.get(nextKey)?.cursor || undefined,
}
: null,
};
}
async observe(userId: string, revision: string, id: string) {
let target = id;
const seen = new Set<string>();
while (!seen.has(target)) {
seen.add(target);
const work = await this.prisma.tbankSyncWork.findFirst({
where: {
id: target,
userId,
revision,
scopeKey: { startsWith: 'operations:' },
},
select: {
id: true,
state: true,
progress: true,
errorCode: true,
updatedAt: true,
},
});
if (!work || work.state !== 'superseded')
return work ? { ...work, id } : null;
target = (work.progress as { successorId: string }).successorId;
}
throw new Error('Cyclic operations work successor');
}
async fail(attempt: OperationAttempt, invalidToken: boolean) {
return this.prisma.$transaction(async (tx) => {
if (!(await claim(tx, attempt))) return false;
await tx.tbankSyncWork.update({
where: { id: attempt.id },
data: {
state: 'failed',
errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE',
},
});
if (invalidToken)
await tx.tbankConnection.update({
where: {
userId_revision: {
userId: attempt.userId,
revision: attempt.revision,
},
},
data: { invalidToken: true },
});
return true;
});
}
async stagePage(
attempt: OperationAttempt,
interval: OperationInterval,
cursor: string | undefined,
response: TbankOperationsByCursorResponse,
): Promise<boolean> {
const sourceKey = intervalKey(interval);
return this.prisma.$transaction(async (tx) => {
if (!(await claim(tx, attempt))) return false;
const stage = {
workId: attempt.id,
attemptId: attempt.attemptId,
sourceKey,
};
const marker = await tx.tbankSyncStage.findUnique({
where: {
workId_attemptId_sourceKey_itemKey: {
...stage,
itemKey: progressItem,
},
},
});
const progress = marker?.payload as PageProgress | undefined;
if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? ''))
throw new Error('Unexpected operations page');
if (response.hasNext === undefined)
throw new Error('Missing operations pagination state');
const nextCursor = response.nextCursor ?? '';
if (
response.hasNext &&
(!nextCursor ||
nextCursor === cursor ||
progress?.seen.includes(nextCursor))
)
throw new Error('Invalid operations continuation');
for (const operation of response.items ?? []) {
if (!operation.id?.trim())
throw new Error('Missing broker operation identity');
const occurredAt = new Date(
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
Math.floor(
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
),
);
if (!Number.isFinite(occurredAt.valueOf()))
throw new Error('Invalid operation timestamp');
if (
occurredAt < interval.from ||
occurredAt >= interval.to ||
(interval.instrumentUid &&
operation.instrumentUid !== interval.instrumentUid)
)
continue;
const payload = JSON.parse(
JSON.stringify({ ...operation, cursor: undefined }),
) as Prisma.InputJsonObject;
// Provider pagination position is not part of a financial row.
const identity = { ...stage, itemKey: operation.id };
const previous = await tx.tbankSyncStage.findUnique({
where: { workId_attemptId_sourceKey_itemKey: identity },
});
if (previous && !isDeepStrictEqual(previous.payload, payload))
throw new Error('Conflicting broker operation identity');
if (!previous)
await tx.tbankSyncStage.create({ data: { ...identity, payload } });
}
const payload: PageProgress = {
complete: !response.hasNext,
cursor: nextCursor,
seen: [
...(progress?.seen ?? []),
...(response.hasNext ? [nextCursor] : []),
],
pages: (progress?.pages ?? 0) + 1,
};
await tx.tbankSyncStage.upsert({
where: {
workId_attemptId_sourceKey_itemKey: {
...stage,
itemKey: progressItem,
},
},
create: { ...stage, itemKey: progressItem, payload },
update: { payload },
});
await tx.tbankSyncWork.update({
where: { id: attempt.id },
data: { progress: { sourceKey, ...payload } },
});
return true;
});
}
// A complete shown scope passes every account/month/comparison here together.
// Initial missing history may pass one completed interval at a time.
async publish(
attempt: OperationAttempt,
intervals: OperationInterval[],
fetchedAt: Date,
advanceWork = false,
): Promise<boolean> {
if (!intervals.length)
throw new Error('Empty operations publication group');
return this.prisma.$transaction(
async (tx) => {
if (!(await claim(tx, attempt))) return false;
const changedAccounts = new Set<string>();
const groupRows = new Map<string, Prisma.JsonValue>();
for (const interval of intervals) {
const sourceKey = intervalKey(interval);
const stage = {
workId: attempt.id,
attemptId: attempt.attemptId,
sourceKey,
};
const marker = await tx.tbankSyncStage.findUnique({
where: {
workId_attemptId_sourceKey_itemKey: {
...stage,
itemKey: progressItem,
},
},
});
if (!(marker?.payload as PageProgress | undefined)?.complete)
throw new Error('Incomplete operations publication group');
const staged = await tx.tbankSyncStage.findMany({
where: { ...stage, itemKey: { not: progressItem } },
});
for (const row of staged) {
const identity = JSON.stringify([interval.accountId, row.itemKey]);
const previous = groupRows.get(identity);
if (
previous !== undefined &&
!isDeepStrictEqual(previous, row.payload)
)
throw new Error(
'Conflicting broker operation identity across intervals',
);
groupRows.set(identity, row.payload);
}
const rows = staged.filter((row) => {
const state = (row.payload as TbankOperationItem).state;
return !state || state === 'OPERATION_STATE_EXECUTED';
});
const scope = {
userId: attempt.userId,
revision: attempt.revision,
accountId: interval.accountId,
};
const existing = await tx.tbankOperation.findMany({
where: {
...scope,
occurredAt: { gte: interval.from, lt: interval.to },
...(interval.instrumentUid
? { instrumentUid: interval.instrumentUid }
: {}),
},
});
const incoming = new Set(rows.map((row) => row.itemKey));
const removed = existing.filter(
(row) => !incoming.has(row.brokerOperationId),
);
for (const row of removed)
await tx.tbankOperation.delete({
where: {
userId_revision_accountId_brokerOperationId: {
...scope,
brokerOperationId: row.brokerOperationId,
},
},
});
if (removed.length) changedAccounts.add(interval.accountId);
for (const row of rows) {
const operation = row.payload as TbankOperationItem;
const identity = { ...scope, brokerOperationId: row.itemKey };
const previous = await tx.tbankOperation.findUnique({
where: { userId_revision_accountId_brokerOperationId: identity },
});
if (previous && isDeepStrictEqual(previous.payload, operation))
continue;
const data = {
occurredAt: new Date(
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
Math.floor(
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
),
),
instrumentUid: operation.instrumentUid ?? null,
category: operationCategory(operation.type),
payload: row.payload as Prisma.InputJsonObject,
};
await tx.tbankOperation.upsert({
where: { userId_revision_accountId_brokerOperationId: identity },
create: { ...identity, ...data },
update: data,
});
changedAccounts.add(interval.accountId);
}
const coverage = {
...scope,
instrumentPredicate: interval.instrumentUid ?? '',
from: interval.from,
to: interval.to,
};
await tx.tbankOperationCoverage.upsert({
where: {
userId_revision_accountId_instrumentPredicate_from_to: coverage,
},
create: { ...coverage, fetchedAt, asOf: interval.to },
update: { fetchedAt, asOf: interval.to },
});
}
for (const accountId of changedAccounts)
await tx.tbankAccount.update({
where: {
userId_revision_accountId: {
userId: attempt.userId,
revision: attempt.revision,
accountId,
},
},
data: { operationsRevision: { increment: 1 } },
});
for (const interval of intervals)
await tx.tbankSyncStage.deleteMany({
where: {
workId: attempt.id,
attemptId: attempt.attemptId,
sourceKey: intervalKey(interval),
},
});
if (advanceWork) {
const work = await tx.tbankSyncWork.findUniqueOrThrow({
where: { id: attempt.id },
});
const intent = work.intent as OperationsIntent;
const published = new Set(intervals.map(intervalKey));
const remaining = intent.intervals.filter(
(key) => !published.has(key),
);
await tx.tbankSyncWork.update({
where: { id: attempt.id },
data: {
intent: {
...intent,
intervals: remaining,
atomic:
intent.atomic &&
(intent.atomicIntervals ?? intent.intervals).some(
(key) => !published.has(key),
),
atomicIntervals: (
intent.atomicIntervals ?? intent.intervals
).filter((key) => !published.has(key)),
},
state: remaining.length ? 'running' : 'complete',
priority: remaining.some((key) =>
(
intent.screenIntervals ??
(work.priority === 1 ? intent.intervals : [])
).includes(key),
)
? 1
: 2,
progress: { remainingIntervals: remaining.length },
},
});
}
return true;
},
{ timeout: 30_000 },
);
}
}
function intervalKey(interval: OperationInterval): string {
if (!(interval.from < interval.to))
throw new Error('Invalid operations interval');
return JSON.stringify([
interval.accountId,
interval.from.toISOString(),
interval.to.toISOString(),
interval.instrumentUid ?? '',
]);
}
async function claim(
tx: Prisma.TransactionClient,
attempt: OperationAttempt,
): Promise<boolean> {
const result = await tx.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { updatedAt: new Date() },
});
return result.count === 1;
}
function decodeInterval(key: string): OperationInterval {
const [accountId, from, to, instrumentUid] = JSON.parse(key) as string[];
return {
accountId,
from: new Date(from),
to: new Date(to),
...(instrumentUid ? { instrumentUid } : {}),
};
}
@@ -1,3 +1,11 @@
// Public cross-module surface. Nest providers are exported by the matching module. // Public cross-module surface. Nest providers are exported by the matching module.
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto'; export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
export { BrokerageOperationsService } from './application/brokerage-operations.service'; export { BrokerageOperationsService } from './application/brokerage-operations.service';
export {
operationIntervals,
type OperationInterval,
} from './domain/operations-sync-plan';
export { operationCategories } from './presentation/brokerage-operation.dto';
export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
@@ -3,11 +3,17 @@ import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller'; import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
import { BrokerageOperationsService } from './application/brokerage-operations.service'; import { BrokerageOperationsService } from './application/brokerage-operations.service';
import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository';
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
@Module({ @Module({
imports: [AuthModule, TbankModule], imports: [AuthModule, TbankModule],
controllers: [BrokerageOperationsController], controllers: [BrokerageOperationsController],
providers: [BrokerageOperationsService], providers: [
exports: [BrokerageOperationsService], BrokerageOperationsService,
BrokerageOperationsRepository,
BrokerageOperationsSyncService,
],
exports: [BrokerageOperationsService, BrokerageOperationsSyncService],
}) })
export class OperationsModule {} export class OperationsModule {}
@@ -1,3 +1,7 @@
import {
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '../../investment-resource.dto';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { import {
@@ -30,7 +34,7 @@ export const operationCategories = [
] as const; ] as const;
export type OperationCategory = (typeof operationCategories)[number]; export type OperationCategory = (typeof operationCategories)[number];
export class BrokerageOperationsQueryDto { export class BrokerageOperationsQueryDto extends InvestmentReadQueryDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'uuid', format: 'uuid',
description: description:
@@ -144,3 +148,10 @@ export class BrokerageOperationsPageDto {
@ApiProperty({ format: 'date-time' }) @ApiProperty({ format: 'date-time' })
declare observedAt: string; declare observedAt: string;
} }
export class BrokerageOperationsResourceDto {
@ApiProperty({ type: BrokerageOperationsPageDto, nullable: true })
declare data: BrokerageOperationsPageDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
@@ -6,13 +6,13 @@ import { BrokerageOperationsController } from './brokerage-operations.controller
describe('BrokerageOperationsController', () => { describe('BrokerageOperationsController', () => {
it('passes the authenticated user, account, and validated query to the service', async () => { it('passes the authenticated user, account, and validated query to the service', async () => {
const operations = { get: jest.fn() }; const operations = { read: jest.fn() };
const controller = new BrokerageOperationsController(operations as never); const controller = new BrokerageOperationsController(operations as never);
const query = { limit: 10 }; const query = { limit: 10 };
const request = { user: { sub: 'user' } }; const request = { user: { sub: 'user' } };
await controller.get(request as never, 'account', query); await controller.get(request as never, 'account', query);
expect(operations.get).toHaveBeenCalledWith('user', 'account', query); expect(operations.read).toHaveBeenCalledWith('user', 'account', query);
}); });
}); });
@@ -4,7 +4,7 @@ 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 {
BrokerageOperationsPageDto, BrokerageOperationsResourceDto,
BrokerageOperationsQueryDto, BrokerageOperationsQueryDto,
} from './brokerage-operation.dto'; } from './brokerage-operation.dto';
import { BrokerageOperationsService } from '../application/brokerage-operations.service'; import { BrokerageOperationsService } from '../application/brokerage-operations.service';
@@ -18,21 +18,21 @@ export class BrokerageOperationsController {
constructor(private readonly operations: BrokerageOperationsService) {} constructor(private readonly operations: BrokerageOperationsService) {}
@Get('all/operations') @Get('all/operations')
@ApiOkResponse({ type: BrokerageOperationsPageDto }) @ApiOkResponse({ type: BrokerageOperationsResourceDto })
all( all(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Query() query: BrokerageOperationsQueryDto, @Query() query: BrokerageOperationsQueryDto,
) { ) {
return this.operations.getAll(request.user.sub, query); return this.operations.read(request.user.sub, 'all', query);
} }
@Get(':accountId/operations') @Get(':accountId/operations')
@ApiOkResponse({ type: BrokerageOperationsPageDto }) @ApiOkResponse({ type: BrokerageOperationsResourceDto })
get( get(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: BrokerageOperationsQueryDto, @Query() query: BrokerageOperationsQueryDto,
) { ) {
return this.operations.get(request.user.sub, accountId, query); return this.operations.read(request.user.sub, accountId, query);
} }
} }
@@ -0,0 +1,89 @@
import { Injectable } from '@nestjs/common';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
import {
ForecastRepository,
type ForecastSourceVersion,
} from '../infrastructure/forecast.repository';
@Injectable()
export class ForecastService {
constructor(private readonly repository: ForecastRepository) {}
async result<T extends object>(input: {
userId: string;
revision: string;
membershipRevision: number;
scopeKey: string;
portfolio: InvestmentResourceMetaDto;
sources: {
version: ForecastSourceVersion | null;
meta: InvestmentResourceMetaDto;
}[];
candidate?: T;
}) {
const dependencies = [
input.portfolio,
...input.sources.map((source) => source.meta),
];
const stored = await this.repository.resolve({
...input,
portfolioProjection: input.portfolio.dataRevision,
portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) =>
dependency.resourceKey.startsWith('portfolio/')
? [
{
accountId: decodeURIComponent(
dependency.resourceKey.slice('portfolio/'.length),
),
dataRevision: Number(
(dependency as InvestmentResourceMetaDto).dataRevision,
),
},
]
: [],
),
sources: input.sources.flatMap((source) =>
source.version ? [source.version] : [],
),
timestamps: dependencies.flatMap((dependency) =>
dependency.lastSuccessfulAt
? [
{
resourceKey: dependency.resourceKey,
at: dependency.lastSuccessfulAt,
},
]
: [],
),
});
const timestamps = stored?.sourceTimestamps as
{ resourceKey: string; at: string }[] | undefined;
const first = timestamps?.map((entry) => entry.at).sort()[0];
const work =
dependencies
.map((dependency) => dependency.sync)
.find((sync) => sync.state === 'running') ??
dependencies
.map((dependency) => dependency.sync)
.find((sync) => sync.state === 'failed');
return {
data: stored ? (stored.payload as T) : null,
meta: investmentResourceMeta({
resourceKey: input.scopeKey,
dataRevision: String(stored?.dataRevision ?? 0),
availability: stored
? 'complete'
: dependencies.some(
(dependency) => dependency.availability !== 'missing',
)
? 'partial'
: 'missing',
fetchedAt: first ? new Date(first) : null,
freshForMs: Number.POSITIVE_INFINITY,
work: work?.id ? { ...work, id: work.id } : null,
dependencies,
}),
};
}
}
@@ -1,373 +1,197 @@
import { import { setImmediate as immediate } from 'node:timers/promises';
BadRequestException, import { ServiceUnavailableException } from '@nestjs/common';
ConflictException, jest.mock('../../../../prisma/prisma.service', () => ({
ServiceUnavailableException, PrismaService: class {},
} from '@nestjs/common';
import assert from 'node:assert/strict';
jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
})); }));
import { InstrumentMetadataService } from '../../../../integrations/tbank/instrument-metadata.service';
import type {
TbankAccountContext,
TbankConnectionContext,
} from '../../../../integrations/tbank/tbank-access.service';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { PortfolioDto } from '../presentation/portfolio.dto';
describe('PortfolioService', () => { describe('persistent portfolio projection', () => {
const access = { const access = { local: jest.fn(), tokenForAttempt: jest.fn() };
withConnection: jest.fn(), const tbank = { getPortfolio: jest.fn(), getPositions: jest.fn() };
withAccountForConnection: jest.fn(), const metadata = { savedMany: jest.fn(), request: jest.fn() };
const repository = {
read: jest.fn(),
reserve: jest.fn(),
publish: jest.fn(),
fail: jest.fn(),
}; };
const tbank = { let service: PortfolioService;
getPortfolio: jest.fn(), const membershipAt = new Date();
getPositions: jest.fn(), beforeEach(() => {
getInstrumentByFigi: jest.fn(), jest.resetAllMocks();
getInstrumentByUid: jest.fn(), service = new PortfolioService(
};
const requestContext = { set: jest.fn() };
const logger = { event: jest.fn() };
const metadata = new InstrumentMetadataService(
tbank as never,
logger as never,
);
const service = new PortfolioService(
access as never, access as never,
tbank as never, tbank as never,
requestContext as never, { set: jest.fn() } as never,
metadata, metadata as never,
repository as never,
{ request: jest.fn().mockResolvedValue(null) } as never,
{ event: jest.fn() } as never,
{ prefetch: jest.fn().mockResolvedValue(undefined) } as never,
); );
let generation: string; access.local.mockResolvedValue({
revision: 'private',
beforeEach(() => { membershipAt,
jest.clearAllMocks(); accounts: [{ accountId: 'a' }, { accountId: 'b' }],
service.invalidate('user');
generation = 'generation-1';
access.withConnection.mockImplementation(
(
_userId: string,
handler: (context: TbankConnectionContext) => Promise<unknown>,
) => handler({ token: 'token', generation }),
);
access.withAccountForConnection.mockImplementation(
(
connection: TbankConnectionContext,
_accountId: string,
handler: (context: TbankAccountContext) => Promise<unknown>,
) =>
handler({
...connection,
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getPortfolio.mockResolvedValue({ positions: [] });
tbank.getPositions.mockResolvedValue({ money: [] });
}); });
access.tokenForAttempt.mockResolvedValue('synthetic');
it('resolves account-scoped access before loading upstream portfolio', async () => { repository.reserve.mockResolvedValue(null);
await service.get('user', 'account'); repository.read.mockResolvedValue({
await service.get('user', 'account'); membershipAt,
membershipRevision: 1,
expect(access.withConnection).toHaveBeenCalledTimes(2); accounts: [],
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1); work: [],
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1); });
expect(tbank.getPositions).toHaveBeenCalledTimes(1); metadata.savedMany.mockResolvedValue(new Map());
metadata.request.mockResolvedValue(null);
});
it('normalizes exact signed money, order, UID metadata and unknown types without metadata RPC', async () => {
repository.reserve.mockResolvedValueOnce({
launch: true,
work: { id: 'work', attemptId: 'attempt' },
}); });
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
tbank.getPortfolio.mockResolvedValue({ tbank.getPortfolio.mockResolvedValue({
positions: [ positions: [
{ {
figi: 'ONE', figi: 'A',
instrumentUid: 'uid',
instrumentType: 'share', instrumentType: 'share',
quantity: { units: 2 }, quantity: { units: '2' },
currentPrice: { currency: 'rub', units: 3 }, currentPrice: { currency: 'rub', units: '3', nano: 1 },
averagePositionPrice: { currency: 'RUB', units: 2 }, averagePositionPrice: { currency: 'RUB', units: '2' },
}, },
{ {
figi: 'TWO', figi: 'B',
instrumentType: 'new-kind', instrumentType: 'new-kind',
quantity: { nano: -500000000 }, quantity: { nano: -500000000 },
currentPrice: { currency: 'USD', units: 4 }, currentPrice: { currency: 'USD', units: '4' },
}, },
{ figi: 'ZERO', quantity: {} }, { figi: 'zero', instrumentType: 'bond', quantity: { units: '0' } },
], ],
}); });
tbank.getPositions.mockResolvedValue({ tbank.getPositions.mockResolvedValue({
money: [ money: [
{ currency: 'RUB', units: 1 }, { currency: 'rub', units: '1' },
{ currency: 'rub', nano: 500000000 }, { currency: 'EUR', units: '0' },
{ currency: 'USD' },
], ],
}); });
tbank.getInstrumentByFigi metadata.savedMany.mockResolvedValue(
.mockResolvedValueOnce({ new Map([
ticker: 'ONE', [
name: 'Первая позиция', JSON.stringify(['uid', 'A']),
isin: 'RU000A0JX0J2', { data: { name: 'Known', ticker: 'A' } },
brand: { logoName: 'RU000A0JX0J2x160.png' }, ],
}) ]),
.mockRejectedValueOnce(new Error('unavailable')); );
repository.read.mockResolvedValue({
await expect(service.get('user', 'account')).resolves.toMatchObject({ membershipAt,
positions: [ membershipRevision: 1,
accounts: [
{ {
figi: 'ONE', accountId: 'a',
displayName: 'Первая позиция', name: 'A',
type: 'STOCK', type: 'BROKERAGE',
quantity: '2', openedOn: null,
currentValue: { currency: 'RUB', value: '6' }, portfolio: null,
absoluteReturn: { currency: 'RUB', value: '2' },
isin: 'RU000A0JX0J2',
ticker: 'ONE',
logoName: 'RU000A0JX0J2x160.png',
},
{
figi: 'TWO',
displayName: 'TWO',
type: 'OTHER',
quantity: '-0.5',
currentValue: { currency: 'USD', value: '-2' },
isin: null,
ticker: null,
logoName: null,
}, },
], ],
cashBalances: [{ currency: 'RUB', value: '1.5' }], work: [],
totalsByCurrency: [
{ currency: 'RUB', value: '7.5' },
{ currency: 'USD', value: '-2' },
],
}); });
}); await service.read('user', 'a');
for (
it('excludes zero and currency-less values while keeping an empty portfolio available', async () => { let index = 0;
tbank.getPortfolio.mockResolvedValue({ index < 10 && !repository.publish.mock.calls.length;
positions: [ index += 1
{ )
quantity: { units: 1 }, await immediate();
currentPrice: { currency: 'RUB' }, const snapshot = (
}, repository.publish.mock.calls as [unknown, string, PortfolioDto][]
{ )[0][2];
quantity: { units: 1 }, expect(
currentPrice: { units: 10 }, snapshot.positions.map((position) => [
}, position.displayName,
], position.type,
}); position.quantity,
tbank.getPositions.mockResolvedValue({ ]),
money: [{ currency: 'USD' }, { units: 2 }], ).toEqual([
}); ['Known', 'STOCK', '2'],
['B', 'OTHER', '-0.5'],
await expect(service.get('user', 'account')).resolves.toMatchObject({
totalsByCurrency: [],
cashBalances: [],
});
});
it('uses instrument UID before FIGI when resolving a name', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'FIGI',
instrumentUid: 'UID',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getInstrumentByUid.mockResolvedValue({ name: 'Имя по UID' });
await expect(service.get('user', 'account')).resolves.toMatchObject({
positions: [{ displayName: 'Имя по UID', logoName: null }],
});
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('loads a large mixed portfolio without overflowing metadata requests or losing names', async () => {
const positions = Array.from({ length: 60 }, (_, index) => ({
figi: `BATCH-${index}`,
instrumentType: index % 2 ? 'bond' : 'share',
quantity: { units: 2 },
currentPrice: { currency: 'RUB', units: 10 },
}));
tbank.getPortfolio.mockResolvedValue({ positions });
let active = 0;
tbank.getInstrumentByFigi.mockImplementation(
async (_token: string, figi: string) => {
active += 1;
try {
if (active > 50)
throw new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
await Promise.resolve();
return {
name: `Название ${figi}`,
ticker: figi,
isin: `ISIN-${figi}`,
brand: { logoName: `${figi}.png` },
};
} finally {
active -= 1;
}
},
);
const result = await service.get('user', 'account');
expect(result.positions).toMatchObject(
positions.map((position, index) => ({
displayName: `Название BATCH-${index}`,
type: position.instrumentType === 'share' ? 'STOCK' : 'BOND',
ticker: `BATCH-${index}`,
isin: `ISIN-BATCH-${index}`,
logoName: `BATCH-${index}.png`,
quantity: '2',
currentValue: { currency: 'RUB', value: '20' },
})),
);
});
it('bounds total metadata waiting and timestamps the financial response before enrichment', async () => {
jest.useFakeTimers({ now: new Date('2026-09-12T12:00:00Z') });
try {
tbank.getPortfolio.mockResolvedValue({
positions: Array.from({ length: 60 }, (_, index) => ({
figi: `BUDGET-${index}`,
instrumentUid: `budget-uid-${index}`,
instrumentType: index % 2 ? 'bond' : 'share',
quantity: { units: 2 },
currentPrice: { currency: 'RUB', units: 10 },
})),
});
tbank.getInstrumentByUid.mockImplementation(() => new Promise(() => {}));
const pending = service.get('user', 'account');
await jest.advanceTimersByTimeAsync(0);
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
await jest.advanceTimersByTimeAsync(30_000);
const result = await pending;
expect(result.lastSuccessfulAt).toBe('2026-09-12T12:00:00Z');
expect(result.positions).toHaveLength(60);
expect(result.positions[59]).toMatchObject({
displayName: 'BUDGET-59',
currentValue: { currency: 'RUB', value: '20' },
});
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
await service.get('user', 'account');
await service.get('user', 'account');
expect(access.withConnection).toHaveBeenCalledTimes(2);
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
service.invalidate('user');
await service.get('user', 'account');
expect(tbank.getPortfolio).toHaveBeenCalledTimes(2);
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
const snapshot = snapshots.get('user:account');
assert.ok(snapshot);
snapshot.storedAt = Date.now() - 30_001;
tbank.getPortfolio.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).resolves.toMatchObject({
isStale: true,
});
});
it('does not mask an invalid token with a snapshot', async () => {
await service.get('user', 'account');
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
const snapshot = snapshots.get('user:account');
assert.ok(snapshot);
snapshot.storedAt = Date.now() - 30_001;
access.withAccountForConnection.mockRejectedValueOnce(
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(BadRequestException),
);
});
it('does not reuse a snapshot or fallback across connection generations', async () => {
await service.get('user', 'account');
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
const snapshot = snapshots.get('user:account');
assert.ok(snapshot);
snapshot.storedAt = Date.now() - 30_001;
generation = 'generation-2';
tbank.getPortfolio.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
});
it('keeps current-generation fallback when account resolution is temporarily unavailable', async () => {
await service.get('user', 'account');
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
const snapshot = snapshots.get('user:account');
assert.ok(snapshot);
snapshot.storedAt = Date.now() - 30_001;
access.withAccountForConnection.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).resolves.toMatchObject({
isStale: true,
});
});
it('does not return a snapshot when the current connection is absent', async () => {
await service.get('user', 'account');
access.withConnection.mockRejectedValueOnce(
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(ConflictException),
);
});
it('does not expose a late previous-generation portfolio snapshot', async () => {
let finishPrevious: ((value: { positions: never[] }) => void) | undefined;
const previous = new Promise<{ positions: never[] }>((resolve) => {
finishPrevious = resolve;
});
tbank.getPortfolio
.mockReturnValueOnce(previous)
.mockResolvedValueOnce({ positions: [] })
.mockResolvedValueOnce({ positions: [] });
tbank.getPositions
.mockResolvedValueOnce({ money: [{ currency: 'RUB', units: 1 }] })
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 2 }] })
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 3 }] });
const previousRequest = service.get('user', 'account');
generation = 'generation-2';
const current = await service.get('user', 'account');
finishPrevious?.({ positions: [] });
await previousRequest;
const afterLateCompletion = await service.get('user', 'account');
expect(current.cashBalances).toEqual([{ currency: 'USD', value: '2' }]);
expect(afterLateCompletion.cashBalances).toEqual([
{ currency: 'USD', value: '3' },
]); ]);
expect(snapshot.positions[0].absoluteReturn).toEqual({
currency: 'RUB',
value: '2.000000002',
});
expect(snapshot.totalsByCurrency).toEqual([
{ currency: 'RUB', value: '7.000000002' },
{ currency: 'USD', value: '-2' },
]);
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
});
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
const snapshot: PortfolioDto = {
lastSuccessfulAt: membershipAt.toISOString(),
isStale: false,
positions: [],
cashBalances: [],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: 'RUB', value: '1.000000001' }],
};
const account = {
accountId: 'a',
name: 'A',
type: 'BROKERAGE',
openedOn: null,
portfolio: {
payload: snapshot,
fetchedAt: membershipAt,
dataRevision: 1,
},
};
repository.read.mockResolvedValue({
membershipAt,
membershipRevision: 1,
accounts: [account, { ...account, accountId: 'b', portfolio: null }],
work: [],
});
const partial = await service.read('user', 'all', true);
expect(partial.data).toBeNull();
expect(partial.meta.availability).toBe('partial');
const saved = await service.saved('user', 'all', true);
expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual(
snapshot.totalsByCurrency,
);
expect(saved.accounts[1].snapshot).toBeNull();
repository.read.mockResolvedValue({
membershipAt,
membershipRevision: 1,
accounts: [
account,
{
...account,
accountId: 'b',
portfolio: { ...account.portfolio, fetchedAt: new Date(0) },
},
],
work: [],
});
const complete = await service.read('user', 'all', true);
expect(complete.data?.totalsByCurrency).toEqual([
{ currency: 'RUB', value: '2.000000002' },
]);
expect(complete.data?.lastSuccessfulAt).toBe(new Date(0).toISOString());
expect(complete.data?.isStale).toBe(true);
expect(tbank.getPortfolio).not.toHaveBeenCalled();
});
it('keeps pending data unavailable to internal financial consumers', async () => {
repository.read.mockResolvedValue({
membershipAt: null,
membershipRevision: 0,
accounts: [],
work: [],
});
await expect(service.getAll('user')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
}); });
}); });
@@ -1,4 +1,17 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { CandleService } from '../../instruments/instruments.api';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
PortfolioRepository,
portfolioFreshForMs,
portfolioWorkKey,
} from '../infrastructure/portfolio.repository';
import {
Injectable,
ServiceUnavailableException,
NotFoundException,
} from '@nestjs/common';
import { import {
add, add,
formatDecimal, formatDecimal,
@@ -10,10 +23,14 @@ import { RequestContextService } from '../../../../common/logging/request-contex
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '../../../../common/money.dto';
import { import {
InstrumentMetadataService, InstrumentMetadataService,
metadataKey,
type TbankInstrument,
type MetadataIdentity,
TbankAccessService, TbankAccessService,
TbankClient, TbankClient,
TbankConnectionContext,
TbankMoney, TbankMoney,
TbankMembershipService,
tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../integrations/tbank/decimal'; import { decimal } from '../../../../integrations/tbank/decimal';
import { portfolioStructure } from '../domain/portfolio-structure'; import { portfolioStructure } from '../domain/portfolio-structure';
@@ -24,10 +41,7 @@ import {
PortfolioPositionDto, PortfolioPositionDto,
} from '../presentation/portfolio.dto'; } from '../presentation/portfolio.dto';
const freshForMs = 30_000; const membershipFreshForMs = tbankSourceTtlMs.membership;
const fallbackForMs = 300_000;
const positionsBatchSize = 5;
const metadataTimeoutMs = 30_000;
const types: Record<string, string> = { const types: Record<string, string> = {
share: 'STOCK', share: 'STOCK',
bond: 'BOND', bond: 'BOND',
@@ -39,48 +53,215 @@ const types: Record<string, string> = {
dfa: 'DIGITAL_FINANCIAL_ASSET', dfa: 'DIGITAL_FINANCIAL_ASSET',
}; };
type CachedSnapshot = {
generation: string;
snapshot: PortfolioDto;
storedAt: number;
};
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
private readonly snapshots = new Map<string, CachedSnapshot>(); private readonly processRunId = randomUUID();
private readonly active = new Set<string>();
private readonly pending = new Map<string, Promise<void>>();
private reservations: Promise<unknown> = Promise.resolve();
constructor( constructor(
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService, private readonly requestContext: RequestContextService,
private readonly metadata: InstrumentMetadataService, private readonly metadata: InstrumentMetadataService,
private readonly repository: PortfolioRepository,
private readonly membership: TbankMembershipService,
private readonly logger: StructuredLogger,
private readonly candles: CandleService,
) {} ) {}
async get(userId: string, accountId: string): Promise<PortfolioDto> { async saved(userId: string, accountId: string, observe = false) {
this.requestContext.set({ userId, brokerageAccountId: accountId }); this.requestContext.set({ userId, brokerageAccountId: accountId });
return this.access.withConnection(userId, (connection) => const connection = await this.access.local(userId, accountId);
this.snapshot(userId, accountId, connection), if (!observe) {
const discovery = await this.membership.request(
userId,
connection.revision,
);
if (discovery)
void discovery.done
.then(async () => {
const current = await this.access.local(userId, accountId);
if (current.revision !== connection.revision) return;
for (const account of current.accounts.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
))
await this.queueSnapshot(
userId,
current.revision,
account.accountId,
);
})
.catch(() =>
this.logger.event(
'warn',
'tbank.portfolio.discovery_not_available',
{ userId },
),
);
for (const account of (discovery ? [] : connection.accounts).filter(
(account) => accountId === 'all' || account.accountId === accountId,
))
await this.queueSnapshot(
userId,
connection.revision,
account.accountId,
); );
} }
const saved = await this.repository.read(userId, connection.revision);
async getAll(userId: string): Promise<PortfolioDto> { if (
return this.access.withSupportedAccounts(userId, async (context) => { saved?.membershipAt &&
const portfolios: PortfolioDto[] = []; accountId !== 'all' &&
for (const account of context.accounts) { !saved.accounts.some((account) => account.accountId === accountId)
const portfolio = await this.snapshot(userId, account.id, context); )
portfolios.push({ throw new NotFoundException();
...portfolio, if (!observe) {
positions: portfolio.positions.map((position) => ({ const identities = new Map<string, MetadataIdentity>();
...position, for (const account of (saved?.accounts ?? []).filter(
account: { id: account.id, name: account.name }, (account) => accountId === 'all' || account.accountId === accountId,
})), )) {
}); const snapshot = account.portfolio?.payload as unknown as
PortfolioDto | undefined;
for (const position of snapshot?.positions ?? [])
identities.set(metadataKey(position), position);
} }
const savedMetadata = await this.metadata.savedMany([
...identities.values(),
]);
for (const identity of identities.values())
if (!savedMetadata.get(metadataKey(identity))?.fresh)
await this.metadata.request(userId, connection.revision, identity);
}
if (!observe)
void this.candles
.prefetch(
userId,
connection.revision,
(saved?.accounts ?? [])
.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
)
.flatMap(
(account) =>
(
account.portfolio?.payload as unknown as
PortfolioDto | undefined
)?.positions ?? [],
),
)
.catch(() =>
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
);
const membershipMeta = investmentResourceMeta({
resourceKey: 'accounts/membership',
dataRevision: String(saved?.membershipRevision ?? 0),
availability: saved?.membershipAt ? 'complete' : 'missing',
fetchedAt: saved?.membershipAt ?? null,
freshForMs: membershipFreshForMs,
work: saved?.work.find((work) => work.scopeKey === 'membership') ?? null,
});
return { return {
lastSuccessfulAt: membership: membershipMeta,
accounts: (saved?.accounts ?? [])
.filter(
(account) => accountId === 'all' || account.accountId === accountId,
)
.map((account) => ({
id: account.accountId,
name: account.name,
type: account.type,
openedOn: account.openedOn,
snapshot: account.portfolio
? {
...(account.portfolio.payload as unknown as PortfolioDto),
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
isStale:
Date.now() - account.portfolio.fetchedAt.valueOf() >=
portfolioFreshForMs,
}
: null,
meta: investmentResourceMeta({
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
dataRevision: String(account.portfolio?.dataRevision ?? 0),
availability: account.portfolio ? 'complete' : 'missing',
fetchedAt: account.portfolio?.fetchedAt ?? null,
freshForMs: portfolioFreshForMs,
work:
saved?.work.find(
(work) => work.scopeKey === portfolioWorkKey(account.accountId),
) ?? null,
}),
})),
};
}
async read(userId: string, accountId: string, observe = false) {
const saved = await this.saved(userId, accountId, observe);
const missing =
saved.membership.availability === 'missing' ||
saved.accounts.some((account) => !account.snapshot);
const metadata = await this.metadata.savedMany(
saved.accounts.flatMap((account) => account.snapshot?.positions ?? []),
);
const metadataDependencies = [...metadata.entries()].flatMap(
([key, source]) =>
source.complete && source.data
? [
investmentResourceMeta({
resourceKey: `instrument/metadata/${key}`,
dataRevision: source.dataRevision,
availability: 'complete',
fetchedAt: source.fetchedAt,
freshForMs: tbankSourceTtlMs.metadata,
work: null,
}),
]
: [],
);
const portfolios = saved.accounts.flatMap((account) =>
account.snapshot
? [
{
...account.snapshot,
positions: account.snapshot.positions.map((position) => {
const shared = metadata.get(metadataKey(position))?.data;
return {
...position,
...(shared
? {
displayName:
shared.name?.trim() ||
shared.ticker?.trim() ||
position.displayName,
ticker: shared.ticker?.trim() || position.ticker,
isin: shared.isin?.trim() || position.isin,
logoName:
shared.brand?.logoName?.trim() || position.logoName,
}
: {}),
...(accountId === 'all'
? { account: { id: account.id, name: account.name } }
: {}),
};
}),
},
]
: [],
);
const lastSuccessfulAt =
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ?? portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
new Date().toISOString(), saved.membership.lastSuccessfulAt;
isStale: portfolios.some((portfolio) => portfolio.isStale), const data =
missing || !lastSuccessfulAt
? null
: {
lastSuccessfulAt,
isStale: portfolios.length
? portfolios.some((portfolio) => portfolio.isStale)
: saved.membership.freshness !== 'fresh',
totalsByCurrency: sumMoneyByCurrency( totalsByCurrency: sumMoneyByCurrency(
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency), portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
), ),
@@ -93,62 +274,137 @@ export class PortfolioService {
portfolios.flatMap((portfolio) => portfolio.cashBalances), portfolios.flatMap((portfolio) => portfolio.cashBalances),
), ),
}; };
}); const work =
saved.accounts
.map((account) => account.meta.sync)
.find((work) => work.state === 'running') ??
saved.accounts
.map((account) => account.meta.sync)
.find((work) => work.state === 'failed') ??
saved.membership.sync;
return {
data,
meta: investmentResourceMeta({
resourceKey: `portfolio/${encodeURIComponent(accountId)}`,
dataRevision: JSON.stringify([
saved.accounts.map((account) => [
account.id,
account.meta.dataRevision,
]),
metadataDependencies.map((dependency) => dependency.dataRevision),
]),
availability: missing
? portfolios.length
? 'partial'
: 'missing'
: 'complete',
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
freshForMs: portfolios.length
? portfolioFreshForMs
: membershipFreshForMs,
work: work.id ? { ...work, id: work.id } : null,
dependencies: [
saved.membership,
...saved.accounts.map((account) => account.meta),
...metadataDependencies,
],
}),
};
} }
private async snapshot( request(userId: string, revision: string, accountId: string, force = false) {
return this.queueSnapshot(userId, revision, accountId, force);
}
private queueSnapshot(
userId: string, userId: string,
revision: string,
accountId: string, accountId: string,
{ token, generation }: TbankConnectionContext, force = false,
): Promise<PortfolioDto> { ) {
const key = `${userId}:${accountId}`; const reserved = this.reservations.then(async () => {
const cached = this.snapshots.get(key); const requested = await this.repository.reserve(
if ( userId,
cached?.generation === generation && revision,
Date.now() - cached.storedAt <= freshForMs
)
return cached.snapshot;
try {
const snapshot = await this.access.withAccountForConnection(
{ token, generation },
accountId, accountId,
async () => { this.processRunId,
this.active,
force,
);
if (!requested) return null;
if (!requested.launch)
return {
id: requested.work.id,
done: this.pending.get(requested.work.attemptId) ?? Promise.resolve(),
};
const { id, attemptId } = requested.work;
this.active.add(attemptId);
const done = new Promise<void>((resolve) => setImmediate(resolve))
.then(async () => {
const attempt = { id, attemptId, userId, revision };
try {
const token = await this.access.tokenForAttempt(attempt);
if (!token) return;
const [portfolio, positions] = await Promise.all([ const [portfolio, positions] = await Promise.all([
this.tbank.getPortfolio(token, accountId), this.tbank.getPortfolio(token, accountId),
this.tbank.getPositions(token, accountId), this.tbank.getPositions(token, accountId),
]); ]);
return this.normalize( const snapshot = await this.normalize(
token,
portfolio.positions ?? [], portfolio.positions ?? [],
positions.money ?? [], positions.money ?? [],
); );
}, const published = await this.repository.publish(
); attempt,
this.snapshots.set(key, { accountId,
generation,
snapshot, snapshot,
storedAt: Date.parse(snapshot.lastSuccessfulAt), );
}); if (published) {
return snapshot; for (const position of snapshot.positions)
await this.metadata.request(userId, revision, position);
if (!force)
await this.candles.prefetch(
userId,
revision,
snapshot.positions,
);
}
} catch (error) { } catch (error) {
if ( if (
error instanceof ServiceUnavailableException && error instanceof Error &&
cached?.generation === generation && error.message === 'INVALID_TBANK_TOKEN'
Date.now() - cached.storedAt <= fallbackForMs )
) { await this.access.rejectMembershipToken(attempt);
return { ...cached.snapshot, isStale: true }; else await this.repository.fail(attempt);
}
throw error;
} }
})
.catch(() =>
this.logger.event('error', 'tbank.portfolio.persistence_failed', {
userId,
}),
)
.finally(() => {
this.active.delete(attemptId);
this.pending.delete(attemptId);
});
this.pending.set(attemptId, done);
return { id, done };
});
this.reservations = reserved.catch(() => undefined);
return reserved;
} }
invalidate(userId: string): void { async get(userId: string, accountId: string): Promise<PortfolioDto> {
for (const key of this.snapshots.keys()) const result = await this.read(userId, accountId);
if (key.startsWith(`${userId}:`)) this.snapshots.delete(key); if (!result.data)
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
return result.data;
}
getAll(userId: string): Promise<PortfolioDto> {
return this.get(userId, 'all');
} }
private async normalize( private async normalize(
token: string,
upstream: { upstream: {
figi?: string; figi?: string;
instrumentUid?: string; instrumentUid?: string;
@@ -160,28 +416,13 @@ export class PortfolioService {
money: TbankMoney[], money: TbankMoney[],
): Promise<PortfolioDto> { ): Promise<PortfolioDto> {
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const controller = new AbortController(); const savedMetadata = await this.metadata.savedMany(upstream);
const timeout = setTimeout(() => controller.abort(), metadataTimeoutMs); const positions = upstream.map((position) =>
const positions: (PortfolioPositionDto | null)[] = []; this.position(
try { position,
for ( savedMetadata.get(metadataKey(position))?.data ?? null,
let offset = 0;
offset < upstream.length;
offset += positionsBatchSize
) {
positions.push(
...(await Promise.all(
upstream
.slice(offset, offset + positionsBatchSize)
.map((position) =>
this.position(token, position, controller.signal),
), ),
)),
); );
}
} finally {
clearTimeout(timeout);
}
const cash = new Map<string, ReturnType<typeof decimal>>(); const cash = new Map<string, ReturnType<typeof decimal>>();
for (const value of money) { for (const value of money) {
const currency = value.currency?.trim().toUpperCase(); const currency = value.currency?.trim().toUpperCase();
@@ -206,11 +447,10 @@ export class PortfolioService {
}; };
} }
private async position( private position(
token: string, position: Parameters<PortfolioService['normalize']>[0][number],
position: Parameters<PortfolioService['normalize']>[1][number], metadata: TbankInstrument | null,
signal: AbortSignal, ): PortfolioPositionDto | null {
): Promise<PortfolioPositionDto | null> {
const quantity = decimal(position.quantity ?? {}); const quantity = decimal(position.quantity ?? {});
if (isZero(quantity)) return null; if (isZero(quantity)) return null;
const figi = position.figi?.trim() || null; const figi = position.figi?.trim() || null;
@@ -235,12 +475,6 @@ export class PortfolioService {
), ),
} }
: null; : null;
const metadata = await this.metadata.resolve(
token,
instrumentUid,
figi,
signal,
);
return { return {
figi, figi,
instrumentUid, instrumentUid,
@@ -0,0 +1,154 @@
import { Injectable } from '@nestjs/common';
import { isDeepStrictEqual } from 'node:util';
import type { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
export type ForecastSourceVersion = {
instrumentUid: string;
source: string;
kind: string;
rangeKey: string;
dataRevision: number;
fetchedAt: Date;
};
const forecastSourceReadBatchSize = 100;
@Injectable()
export class ForecastRepository {
constructor(private readonly prisma: PrismaService) {}
resolve(input: {
userId: string;
revision: string;
membershipRevision: number;
scopeKey: string;
candidate?: object;
portfolioProjection: string;
portfolios: { accountId: string; dataRevision: number }[];
sources: ForecastSourceVersion[];
timestamps: { resourceKey: string; at: string }[];
}) {
return this.prisma.$transaction(async (tx) => {
const owner = { userId: input.userId, revision: input.revision };
if (input.candidate) {
const current = await tx.tbankConnection.updateMany({
where: {
...owner,
membershipRevision: input.membershipRevision,
invalidToken: false,
},
data: { membershipRevision: { increment: 0 } },
});
if (!current.count) return null;
}
const connection = await tx.tbankConnection.findFirst({
where: {
...owner,
membershipRevision: input.membershipRevision,
invalidToken: false,
},
});
if (!connection) return null;
const key = { ...owner, scopeKey: input.scopeKey };
const previous = await tx.tbankForecast.findUnique({
where: { userId_revision_scopeKey: key },
});
const fallback =
previous?.membershipRevision === input.membershipRevision
? previous
: null;
if (!input.candidate) return fallback;
const portfolios = await tx.tbankPortfolio.findMany({
where: {
...owner,
accountId: { in: input.portfolios.map((row) => row.accountId) },
},
select: { accountId: true, dataRevision: true },
});
if (
input.portfolios.some(
(expected) =>
!portfolios.some(
(row) =>
row.accountId === expected.accountId &&
row.dataRevision === expected.dataRevision,
),
)
)
return fallback;
// Four identity parameters per source: stay below SQLite's parameter limit.
const sources: ForecastSourceVersion[] = [];
for (
let offset = 0;
offset < input.sources.length;
offset += forecastSourceReadBatchSize
)
sources.push(
...(await tx.tbankInstrumentSource.findMany({
where: {
provider: 'tbank',
OR: input.sources
.slice(offset, offset + forecastSourceReadBatchSize)
.map(({ instrumentUid, source, kind, rangeKey }) => ({
instrumentUid,
source,
kind,
rangeKey,
})),
},
select: {
instrumentUid: true,
source: true,
kind: true,
rangeKey: true,
dataRevision: true,
fetchedAt: true,
},
})),
);
if (
input.sources.some(
(expected) =>
!sources.some(
(row) =>
row.instrumentUid === expected.instrumentUid &&
row.source === expected.source &&
row.kind === expected.kind &&
row.rangeKey === expected.rangeKey &&
row.dataRevision === expected.dataRevision &&
row.fetchedAt.valueOf() === expected.fetchedAt.valueOf(),
),
)
)
return fallback;
const revisions = JSON.parse(
JSON.stringify({
portfolioProjection: input.portfolioProjection,
portfolios: input.portfolios,
sources: input.sources,
}),
) as Prisma.InputJsonObject;
const timestamps = input.timestamps as Prisma.InputJsonArray;
if (
fallback &&
isDeepStrictEqual(fallback.dependencyRevisions, revisions) &&
isDeepStrictEqual(fallback.sourceTimestamps, timestamps)
)
return fallback;
const data = {
membershipRevision: input.membershipRevision,
payload: JSON.parse(
JSON.stringify(input.candidate),
) as Prisma.InputJsonObject,
dependencyRevisions: revisions,
sourceTimestamps: timestamps,
calculatedAt: new Date(),
};
return tx.tbankForecast.upsert({
where: { userId_revision_scopeKey: key },
create: { ...key, ...data },
update: { ...data, dataRevision: { increment: 1 } },
});
});
}
}

Some files were not shown because too many files have changed in this diff Show More