Compare commits
38
Commits
33f703d9a3
..
v0.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0862d56c4e | ||
|
|
7c0150c56b | ||
|
|
39f2ce5bc4 | ||
|
|
4c916829ea | ||
|
|
386c91670e | ||
|
|
9437b6a311 | ||
|
|
0ec7ae51af | ||
|
|
1946192be9 | ||
|
|
0557f4ad1f | ||
|
|
d7cb7e3e20 | ||
|
|
1a6be432f2 | ||
|
|
32c12fb10d | ||
|
|
016782657a | ||
|
|
9810d47e42 | ||
|
|
660b250ecd | ||
|
|
f01b82d366 | ||
|
|
d4fc28cbbf | ||
|
|
97e864bc06 | ||
|
|
121af701b7 | ||
|
|
2bc8745495 | ||
|
|
4c8ba2fac9 | ||
|
|
a5156fbeb5 | ||
|
|
197ffe2136 | ||
|
|
ce24a84b7f | ||
|
|
8637ad2f34 | ||
|
|
8ba005346d | ||
|
|
7fde3ece6e | ||
|
|
346a6f0057 | ||
|
|
3cf883efbc | ||
|
|
8c074d85de | ||
|
|
e6f58ef0ee | ||
|
|
8ec172f48e | ||
|
|
b1906c8e3c | ||
|
|
a79f59a468 | ||
|
|
235c179ff8 | ||
|
|
f24fd98fbc | ||
|
|
05747dc375 | ||
|
|
eaf2f67b34 |
@@ -1,24 +0,0 @@
|
||||
# Backend router
|
||||
|
||||
Объединяй triggers из смысла задачи, путей и обнаруженных boundaries.
|
||||
Читай выбранные guides до решения; якорь допускает чтение только нужного раздела.
|
||||
Просто backend-файл не требует всех conventions.
|
||||
|
||||
| Решение / сигнал | Читать |
|
||||
| --- | --- |
|
||||
| Новый module/provider/service; бизнес-логика; перенос responsibility | [Architecture](conventions/backend-architecture.md) |
|
||||
| Controller, DTO, route/status, validation/exception, публичная HTTP-семантика | [HTTP API](conventions/backend-http-api.md) |
|
||||
| Prisma/schema/query/persistence, 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) |
|
||||
| T-Bank/gRPC/proto, upstream retry/rate limit/cache, MoexClient | [Integrations](conventions/backend-integrations.md) |
|
||||
| Logging/request context без изменения secrets/auth | [Logging](conventions/backend-architecture.md#logging) |
|
||||
| Новый abstraction/dependency, refactor, неясный scope | [Общий выбор реализации](../../docs/development/engineering-governance.md#выбор-реализации) |
|
||||
| Изменение request/response/route/status/error shape или Swagger metadata | HTTP API + [общий contract workflow](../../docs/development/runtime-openapi-verification.md) |
|
||||
|
||||
Новый endpoint обычно требует HTTP API + Testing; Architecture/Data/Security
|
||||
добавляются по новым ответственности, persistence или access rules.
|
||||
Изменение одного Prisma query без HTTP/auth границы не требует HTTP API/Security.
|
||||
Auth refresh активирует Security + Data + Testing; HTTP API добавляется, если
|
||||
меняется controller/HTTP contract. Exact docs/spec sources выбирай через SOURCES.
|
||||
Повтори routing при расширении diff, включая новые и unstaged файлы.
|
||||
@@ -1,19 +0,0 @@
|
||||
# Источники backend
|
||||
|
||||
| Что нужно установить | Владелец |
|
||||
| --- | --- |
|
||||
| Implementation decision и безопасный pattern | [CONVENTIONS](CONVENTIONS.md) |
|
||||
| HTTP errors, DTO semantics, public wire shape | [docs: HTTP API](../../docs/AI_INDEX.md#http-api); runtime OpenAPI из Nest — единственный editable source |
|
||||
| Auth/session, account access, token encryption | [docs: Security](../../docs/AI_INDEX.md#security) |
|
||||
| Prisma schema/data ownership и migration context | [docs: Data](../../docs/AI_INDEX.md#data) |
|
||||
| T-Bank/gRPC/cache, logging | [docs: Integrations](../../docs/AI_INDEX.md#integrations) |
|
||||
| Money, account/position/income/cache meanings | [docs: Domain](../../docs/AI_INDEX.md#domain) |
|
||||
| Поведение конкретной feature | [docs: Features](../../docs/AI_INDEX.md#features) |
|
||||
| Существующие 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 не требуется.
|
||||
@@ -0,0 +1,262 @@
|
||||
# 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
|
||||
|
||||
Сначала выбери business owner в `modules/`, затем ответственность внутри него.
|
||||
|
||||
### Progressive structure
|
||||
|
||||
Три уровня организации: business module → responsibility layer → technical grouping
|
||||
при реальной необходимости. Компактный module с понятными controller/DTO/service
|
||||
может оставаться плоским. Ориентир 5–7 файлов не является порогом: учитывай число
|
||||
ответственностей, наличие domain/repository/use cases и удобство просмотра каталога.
|
||||
|
||||
Когда в module смешаны несколько services, transport, расчёты и persistence,
|
||||
группируй файлы по ответственности:
|
||||
|
||||
```text
|
||||
feature/
|
||||
├── feature.module.ts
|
||||
├── feature.api.ts # только при существующих внешних потребителях
|
||||
├── presentation/ # HTTP, DTO, guards
|
||||
├── application/ # сценарии, orchestration, application input
|
||||
├── domain/ # чистые бизнес-правила и расчёты
|
||||
└── infrastructure/ # persistence, framework и boundary mapping
|
||||
```
|
||||
|
||||
Создавай только нужные слои. В корне сложного module оставляй composition и
|
||||
публичную boundary; production code и colocated tests размещай в слое владельца.
|
||||
Внутри business slice действует то же правило, без отдельного Nest module ради папок.
|
||||
`controllers/`, `dto/`, `services/`, `use-cases/`, `persistence/`, `mappers/`
|
||||
допустимы только внутри слоя, когда множество однотипных файлов мешает навигации.
|
||||
Для одного controller или пары DTO дополнительная поддиректория не нужна.
|
||||
Пустые папки, новые barrels, CQRS и interfaces не требуются для этой организации.
|
||||
|
||||
## Поиск и создание файла
|
||||
|
||||
При работе над feature проходи владельцев в порядке:
|
||||
|
||||
1. Найди business module.
|
||||
2. Прочитай его `*.module.ts` (для slice — composition родительского module).
|
||||
3. Проверь `presentation/`: controller и transport contract.
|
||||
4. Проследи вызовы в `application/`: сценарии и services.
|
||||
5. Проверь используемые правила в `domain/`.
|
||||
6. Проверь `infrastructure/`: repository и технические реализации.
|
||||
7. По обнаруженным зависимостям переходи в `integrations/` и `common/`.
|
||||
|
||||
В плоском module классифицируй файлы по suffix и следуй их ответственности.
|
||||
Отсутствующий слой пропускай. Если business owner известен, начинай локально,
|
||||
а не с repository-wide search.
|
||||
|
||||
**Rule of locality:** code that belongs to one business capability stays inside
|
||||
that capability unless it is cross-cutting. Например, расчёты карт принадлежат
|
||||
bank-cards/domain, даже если реализованы обычными функциями.
|
||||
|
||||
Перед созданием файла определи:
|
||||
|
||||
1. Какой business module владеет изменением? Сначала owner, затем common при
|
||||
подтверждённой общей потребности.
|
||||
2. Какова ответственность: HTTP/DTO/guard → presentation; сценарий/orchestration →
|
||||
application; чистое business rule/calculation → domain; persistence или
|
||||
module-specific technical implementation → infrastructure; внешний протокол → integrations.
|
||||
3. Какой approved suffix из таблицы соответствует этой ответственности?
|
||||
4. Какой слой владеет файлом? В компактном плоском module сохрани размещение рядом
|
||||
с owner; в слоистом используй соответствующий слой.
|
||||
5. Достаточно ли colocate файл рядом с существующими? Level 3 grouping создавай
|
||||
только при заметном улучшении навигации по множеству однотипных файлов.
|
||||
|
||||
Если owner или роль не определены, сначала установи ответственность. Generic
|
||||
`utils.ts`, `helpers.ts`, `manager.ts`, `handler.ts` и arbitrary directory её не заменяют.
|
||||
|
||||
## Naming & File Suffix Convention
|
||||
|
||||
Имена файлов — `kebab-case`. File suffix определяет responsibility, directory
|
||||
определяет architectural layer. Размещение в таблице предпочтительно для
|
||||
слоистого module; компактный плоский module сохраняет файлы в корне.
|
||||
Cross-module consumers во всех строках ограничены публичной границей ниже.
|
||||
|
||||
| Suffix | Когда использовать | Где | Кто зависит | Когда не использовать |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `*.controller.ts` | HTTP endpoint / transport | presentation | Nest wiring | DB, расчёты, SDK, сложная оркестрация |
|
||||
| `*.dto.ts` | Request/response, validation, OpenAPI | presentation | Controller; application boundary при совпадении shape | Универсальная domain model |
|
||||
| `*.use-case.ts` | Один самостоятельный application scenario | application | Controller, другой orchestrator | Prisma queries, Request/Response, SDK implementation |
|
||||
| `*.service.ts` | Конкретная cohesive reusable capability | рядом с owner; application/domain/integration по роли | Сценарии и services; domain — только от чистого domain service | Свалка несвязанных сценариев |
|
||||
| `*.repository.ts` | Persistence queries и согласованные DB reads/writes | infrastructure | Application service/use case | Domain calculations, HTTP request, external transport |
|
||||
| `*.client.ts` | Низкоуровневый внешний HTTP/gRPC/SDK | integrations или infrastructure | Application orchestration, boundary mapper | Пользовательский lifecycle и domain rules |
|
||||
| `*.mapper.ts` | Meaningful conversion между моделями | обычно infrastructure у преобразуемой boundary | Код соответствующей boundary | Формальное копирование нескольких одинаковых полей |
|
||||
| `*.policy.ts` | Cohesive decision rule | domain/application/integration по входным данным | Владелец правила; domain — от чистой policy | Persistence, transport, generic strategy |
|
||||
| `*.guard.ts` | Nest guard | presentation | Nest/controller | Основная account authorization и финансовые правила |
|
||||
| `*.strategy.ts` | Framework strategy, например JWT | infrastructure | Framework wiring | Обычный service без strategy contract |
|
||||
| `*.module.ts` | Nest imports/providers/controllers/exports | module root | Composition root, importing module | Бизнес-логика |
|
||||
| `*.types.ts` | Малая группа связанных TS types | рядом с owner | Потребители модели на разрешённых слоях | Глобальные types всего module, runtime validation |
|
||||
| `*.api.ts` | Явный список публичных exports | root конкретного module/integration | Другие modules | DTO/client/service implementation или generic API logic |
|
||||
| `*-input.ts` | Application input: нормализация и проверка формы, отличающейся от wire DTO | application | Application code | Копия DTO под новым именем; вариант `*.input.ts` |
|
||||
| `*.calculations.ts`, `*.rules.ts` или предметное имя | Чистые бизнес-расчёты, value semantics | domain | Application и domain | Nest/Prisma/HTTP/SDK зависимости |
|
||||
| `*.spec.ts` | Существующая unit/component проверка поведения | рядом с owner/slice | Test runner | Архитектурные проверки папок и trivial wiring |
|
||||
|
||||
`bank-card-input.ts` использует выбранный `*-input.ts`: переводит Money в
|
||||
копейки, нормализует значения и проверяет application input. DTO не дублируется
|
||||
отдельным Input type. `rub-money.ts` — domain-правила RUB; `bond-money.mapper.ts`
|
||||
— преобразование ответа клиента. Эти роли не определяются словом money в имени.
|
||||
|
||||
Технические framework suffixes (`*.filter.ts`, `*.middleware.ts`,
|
||||
`*.decorator.ts`, `*.config.ts`, `*.validation.ts`) сохраняют соответствующие
|
||||
существующие роли в common/config. `tbank.crypto.ts` — локальная реализация
|
||||
шифрования токена; имя не является разрешением создавать generic crypto layer.
|
||||
HTTP/e2e naming и расположение — в [Testing](conventions/testing.md).
|
||||
|
||||
Без зафиксированной необходимости не вводи `*.helper.ts`, `*.utils.ts`,
|
||||
`*.manager.ts`, `*.processor.ts`, `*.handler.ts`, `*.provider.ts`, `*.factory.ts`.
|
||||
Новая naming convention сначала получает конкретную роль и обоснование здесь.
|
||||
Для data access выбран только `*.repository.ts`, не persistence/store/dao.
|
||||
|
||||
## Публичные границы
|
||||
|
||||
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
|
||||
модулями providers, функции и типы. Внешний production import идёт через этот
|
||||
API или файл Nest module. Чистый `integrations/tbank/decimal.ts` — отдельный
|
||||
публичный вход для protobuf conversion: импорт арифметики не загружает DI/Prisma.
|
||||
Внутри модуля используй прямые локальные импорты.
|
||||
Tests могут импортировать внутренний owner для проверки поведения и mocks.
|
||||
Новый export требует текущего потребителя; controller и persistence остаются
|
||||
приватными. Не добавляй `index.ts` на каждом уровне.
|
||||
|
||||
Circular dependencies устраняй изменением ownership/composition. `forwardRef()`
|
||||
не является стандартным исправлением boundaries; исключение требует конкретной
|
||||
неизбежной связи и проверки DI initialization.
|
||||
|
||||
Каждый provider регистрируется у одного владельца. Потребители импортируют
|
||||
его module, а не повторяют provider: это сохраняет единственный кэш и лимитер.
|
||||
`AuthModule` экспортирует guard; наличие JWT не заменяет account access check.
|
||||
|
||||
- `income` читает операции через `operations.api.ts`.
|
||||
- `instruments` получает личные позиции через `portfolio.api.ts`.
|
||||
- `tbank-connection` использует T-Bank integration и публичный сброс портфеля.
|
||||
- `portfolio` и другие инвестиционные модули используют `TbankModule`, который
|
||||
не зависит от business modules. Так управление подключением не образует цикл.
|
||||
- `investment-calendar.ts` и `account-reference.dto.ts` разделяются только
|
||||
инвестиционными областями; это не глобальная библиотека бизнес-правил.
|
||||
|
||||
## Ответственность и направление зависимостей
|
||||
|
||||
| Владелец | Ответственность |
|
||||
| --- | --- |
|
||||
| Controller / presentation | Route, verified identity, transport DTO, validation, status/cookie; вызов сценария |
|
||||
| Use case / application service | Оркестрация, business validation, доступ и согласованность результата |
|
||||
| Domain | Чистые правила и расчёты; без NestJS, Prisma, HTTP/Swagger и внешних SDK |
|
||||
| Persistence / infrastructure | Запросы, транзакции, технические реализации границ |
|
||||
| `src/integrations/{tbank,moex,ncr}` | Внешние протоколы, декодирование, retry/deadline/cache; без зависимостей от HTTP DTO business modules |
|
||||
| `src/prisma` | Общий DB provider и lifecycle; не generic repository |
|
||||
|
||||
Основной вызов: presentation → application → domain; application использует
|
||||
конкретные infrastructure providers, зарегистрированные module. Infrastructure
|
||||
может использовать application types/input и domain rules; новые interfaces
|
||||
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
|
||||
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries
|
||||
по [Data](conventions/data.md); use case получает данные через repository и сам
|
||||
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется.
|
||||
DTO остаются транспортными runtime classes; существующее использование DTO
|
||||
на application boundary допустимо по [HTTP API](conventions/http-api.md).
|
||||
|
||||
Сохраняй существующий error contract: HTTP exceptions остаются на application/
|
||||
transport boundary. Чистому расчёту не нужен новый framework ошибок.
|
||||
Mapping и stable codes — по [Errors](conventions/error-handling.md).
|
||||
Точные ограничения — [HTTP](conventions/http-api.md), [Data](conventions/data.md),
|
||||
[Security](conventions/security-auth.md), [Integrations](conventions/integrations.md).
|
||||
|
||||
## Сценарии и размер сервисов
|
||||
|
||||
Разделяй независимые операции, а не строки. Пример: `BankCardsService` владеет
|
||||
картами, `BankOperationsService` — журналом/переводами, `GetBankSummaryUseCase` —
|
||||
сводкой через repository и чистый расчёт. Не разрывай денежную transaction,
|
||||
общий cache/limiter или pending promises при extraction.
|
||||
|
||||
Расширение abstractions — по [change scope](conventions/engineering.md#change-scope).
|
||||
|
||||
## Common
|
||||
|
||||
`common` содержит общий error filter, logging, API decorators, тип `Money`,
|
||||
его HTTP `MoneyDto` и точную decimal-арифметику, используемые несколькими областями. Код одного
|
||||
модуля принадлежит этому модулю: например, RUB/kopecks rules — банковским картам,
|
||||
protobuf units/nano conversion — T-Bank integration. Domain использует
|
||||
`money.types.ts`, а не Swagger DTO; это общая форма денег для карт и портфеля.
|
||||
Давай функциям предметные имена; не создавай глобальные `utils.ts`/`helpers.ts`.
|
||||
|
||||
## Domain literals and magic values
|
||||
|
||||
Общий выбор symbolic values и числовых policies —
|
||||
[Domain values](conventions/engineering.md#domain-values).
|
||||
Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай
|
||||
`unknown` + runtime narrowing; отключённый lint `no-explicit-any` не делает
|
||||
`any` нормой domain/application model. `declare` допустим для заполняемых извне
|
||||
DTO fields, не для сокрытия неинициализированного owned state.
|
||||
|
||||
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
|
||||
что application/domain. Например, `BankOperationKind` принадлежит bank-cards/domain,
|
||||
а DTO получает допустимые значения через `Object.values(BankOperationKind)`.
|
||||
Публичные enum values входят в OpenAPI artifact. Внешние/generated enums
|
||||
сохраняй; generated Prisma/protocol code вручную не меняй.
|
||||
|
||||
`@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule
|
||||
отключено в пользу TS-версии. Исключения для очевидных чисел и их bigint форм,
|
||||
индексов, default values, enum/type declarations и readonly properties заданы
|
||||
в ESLint config. Test fixtures/expected values остаются inline, остальные lint
|
||||
правила продолжают действовать. Numeric policy получает предметное имя и единицы;
|
||||
semantic magic strings проверяются conventions/review без нового инструмента.
|
||||
|
||||
## Logging
|
||||
|
||||
События, levels, correlation и safe fields — по
|
||||
[Observability](conventions/observability.md).
|
||||
|
||||
## Проверка
|
||||
|
||||
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
|
||||
Смысл boundaries проверяется review: backend не имеет отдельного автоматического
|
||||
FSD/DI architecture checker. Existing tests и build не доказывают правильный owner.
|
||||
@@ -1,53 +0,0 @@
|
||||
# Backend architecture
|
||||
|
||||
## Выбор владельца
|
||||
|
||||
Найди существующий use case, его collaborators и tests до создания module/service.
|
||||
Сохрани ответственность в текущем module, если новая возможность относится к нему.
|
||||
Module — граница связного набора providers, а не папка для каждого endpoint.
|
||||
|
||||
| Решение | Owner |
|
||||
| --- | --- |
|
||||
| Route, request/response mapping, status/cookie | Controller, [HTTP rules](backend-http-api.md) |
|
||||
| Business use case, orchestration, domain validation | Feature service |
|
||||
| Input shape/format | DTO + configured ValidationPipe |
|
||||
| Атомарность/DB query | Сервис use case через PrismaService, [Data](backend-data.md) |
|
||||
| Внешний protocol, deadline/retry/limiting | Existing client, [Integrations](backend-integrations.md) |
|
||||
| Чистое вычисление/mapper без lifecycle и DI | Обычная функция рядом с domain owner |
|
||||
| Bootstrap/config/global cross-cutting concern | AppModule, config, существующий common owner |
|
||||
|
||||
**Безопасный путь:** расширь текущий owner; выделяй collaborator, когда он скрывает
|
||||
самостоятельный инвариант или координацию. Не вводи CQRS, generic repository,
|
||||
interface с одной реализацией или base controller только ради шаблона Nest.
|
||||
Не превращай common в хранилище бизнес-логики и не переноси frontend модели сюда.
|
||||
|
||||
## Существующие seams
|
||||
|
||||
`TbankConnectionModule` уже объединяет счета, портфель, операции, доходы и события.
|
||||
Новый связанный use case обычно добавляется в него; создание отдельного module
|
||||
требует собственной ответственности, не только нового файла.
|
||||
|
||||
- [BrokerageOperationsController](../../src/tbank/brokerage-operations.controller.ts) передаёт user/account/query в service, без финансовых вычислений в controller.
|
||||
- [TbankAccessService](../../src/tbank/tbank-access.service.ts) владеет connection/account access и generation; новые account use cases используют его вместо копирования guard/decrypt logic.
|
||||
- [decimal.ts](../../src/tbank/decimal.ts) — пример pure domain helper; Injectable и adapter для арифметики не нужны.
|
||||
|
||||
Наличие guard подтверждает session, но не владение account. При пересечении
|
||||
access boundary добавь [Security](security-auth.md). Ошибка upstream не должна
|
||||
обходить общий HTTP filter или превращать чужой account в доступный.
|
||||
|
||||
## Logging
|
||||
|
||||
Единственный logger — nestjs-pino; application events используют существующий
|
||||
[StructuredLogger](../../src/common/logging/structured-logger.service.ts) и
|
||||
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.
|
||||
|
||||
## Проверка
|
||||
|
||||
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
|
||||
Смысл boundaries проверяется review: backend не имеет отдельного автоматического
|
||||
FSD/DI architecture checker. Existing tests и build не доказывают правильный owner.
|
||||
@@ -1,52 +0,0 @@
|
||||
# Backend HTTP API
|
||||
|
||||
## Выбор boundary
|
||||
|
||||
Сначала определи, меняется wire contract или только реализация существующего
|
||||
use case. Для route/request/response/status/error shape читай
|
||||
[HTTP semantics](../../../docs/architecture/http-api.md) и
|
||||
[общий contract workflow](../../../docs/development/runtime-openapi-verification.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
|
||||
ради обхода service. Request identity берётся из verified `request.user`, не из
|
||||
body/userId, предоставленного клиентом. Access rules активируют Security.
|
||||
|
||||
## DTO и errors
|
||||
|
||||
DTO — runtime class: TS interface не предоставляет validation metadata.
|
||||
ValidationPipe уже настроен в `configureApp` (`transform`, `whitelist`).
|
||||
Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён.
|
||||
Для query numbers/arrays выбирай явное преобразование и валидаторы, как в
|
||||
[operations DTO](../../src/tbank/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/tbank/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 ошибок.
|
||||
@@ -1,47 +0,0 @@
|
||||
# Backend integrations
|
||||
|
||||
## Выбор boundary
|
||||
|
||||
T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient. Сервисы use case
|
||||
получают нормализованный результат через эти clients. Frontend не вызывает
|
||||
T-Bank и не получает credentials/proto client. Reference source —
|
||||
[ADR 003](../../../docs/adr/003-tbank-server-side-grpc-boundary.md) и
|
||||
[connection behavior](../../../docs/features/tbank-connection.md).
|
||||
|
||||
Для нового account use case сначала используй TbankAccessService: access,
|
||||
connection generation и invalid-token translation не дублируются в callers.
|
||||
Чистый mapper финансового значения остаётся рядом с domain owner.
|
||||
|
||||
## Retry, cache и protocol
|
||||
|
||||
Existing client уже владеет bounded FIFO queue, rate limit, deadline и retry
|
||||
классификацией. Новый RPC проходит тот же путь; не добавляй вложенный retry loop
|
||||
в service. Не отключай TLS verification ради окружения: используй документированный
|
||||
CA mechanism. Точные параметры и ограничения принадлежат
|
||||
[connection configuration](../../../docs/features/tbank-connection.md#конфигурация-и-ограничения).
|
||||
|
||||
При query/cache change установи key, user/account/generation ownership,
|
||||
freshness и поведение late response. Старый результат не может обслужить новую
|
||||
connection generation. Client Cached Snapshot frontend отличается от backend
|
||||
cache/stale policy; semantics конкретного use case читай через
|
||||
[feature index](../../../docs/AI_INDEX.md#features).
|
||||
|
||||
Внешний auth failure и временная недоступность имеют разные public codes;
|
||||
raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService
|
||||
и общем HTTP boundary. Для HTTP error change читай HTTP API/Security по trigger.
|
||||
|
||||
Vendor proto хранится в `src/tbank/proto/contracts`; для обновления сверяй
|
||||
официальный source и влияние на loader/DTO, не редактируй read-only references.
|
||||
Generated artifacts, если есть, создавай из source, без ручной подгонки вывода.
|
||||
|
||||
## Безопасный путь и проверка
|
||||
|
||||
Сначала добавь нужный operation в существующий client и используй его из
|
||||
ответственного service. Отдельный abstraction/adapter оправдан новой
|
||||
ответственностью или несовместимым protocol, не только вторым методом.
|
||||
|
||||
[TbankClient tests](../../src/tbank/tbank.client.spec.ts) — пример controlled
|
||||
upstream responses. Проверь queue/deadline/retry limit, token translation,
|
||||
cache isolation и late response для затронутых ветвей. Fixtures синтетические;
|
||||
реальная сеть, секреты и пользовательские accounts не нужны.
|
||||
Logging использует [existing logger](backend-architecture.md#logging).
|
||||
@@ -1,46 +0,0 @@
|
||||
# Backend testing
|
||||
|
||||
## Выбор уровня
|
||||
|
||||
Применяй [общие testing risks](../../../docs/development/engineering-governance.md#risk-based-testing).
|
||||
Тесты должны различать правильное и неправильное наблюдаемое поведение.
|
||||
|
||||
| Что меняется | Проверка |
|
||||
| --- | --- |
|
||||
| Pure mapper/decimal/domain branch | Unit со значимыми edge cases |
|
||||
| Service orchestration/access/retry policy | Jest с mocks внешних boundaries и observable outcome |
|
||||
| Pipe/guard/cookie/status/HTTP error | Configured Nest HTTP boundary; прямого вызова метода controller недостаточно |
|
||||
| OpenAPI metadata/wire schema | `test:openapi-contract` + потребительский contract check |
|
||||
| DB constraint/CAS/transaction/migration | Integration с изолированной реальной DB |
|
||||
| Только DTO declarations/thin wiring | Existing contract/type/build без искусственного unit test |
|
||||
|
||||
Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
|
||||
Мокируй T-Bank, время и нужную DB boundary, а не проверяемую business-функцию.
|
||||
Не зови реальный внешний account/API. Expected error/code, отсутствие запроса
|
||||
при недопустимом доступе и сохранение данных важнее private method call count.
|
||||
|
||||
## Случаи и примеры
|
||||
|
||||
- [BrokerageAccountsService tests](../../src/tbank/brokerage-accounts.service.spec.ts): отсутствие connection не вызывает upstream, нормализация и точные суммы.
|
||||
- [AuthService tests](../../src/auth/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.
|
||||
- [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): configured app + frontend snapshot, без записи snapshot и без T-Bank.
|
||||
|
||||
При bug сначала воспроизведи именно его исход, затем исправь responsible owner.
|
||||
Не создавай tests для каждой декларации, logs строки или поведения Prisma/Nest
|
||||
без собственного инварианта. Coverage не имеет общего threshold.
|
||||
|
||||
## Команды и ограничения
|
||||
|
||||
Итерация: `npm test -- --runInBand <pattern>`, затем нужный typecheck/lint.
|
||||
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
|
||||
`npm run test:openapi-contract` — отдельный required check при wire contract change.
|
||||
|
||||
`npm run test:e2e` существует, но известный Prisma migration blocker описан в
|
||||
[backend workflow](../../../docs/development/backend-workflow.md) и WL-P2-05
|
||||
[registry](../../../docs/development/audit-remediation.md). Скрипт использует
|
||||
фиксированную `/tmp/wealth-ledger-e2e.db`; не запускай его конкурентно как
|
||||
независимые DB tests. Для isolation выбери отдельную test DB и runner configuration.
|
||||
Если задаче нужна HTTP/DB proof, установи применимость blocker и выполни доступную
|
||||
изолированную проверку. Не объявляй покрытым то, что до tests не запустилось;
|
||||
сохрани точный failure и оставшуюся проверку. Unit/Swagger success не заменяет e2e.
|
||||
@@ -9,12 +9,40 @@ PrismaService — существующий shared DB provider с lifecycle пр
|
||||
|
||||
| Ситуация | Решение |
|
||||
| --- | --- |
|
||||
| Один query/write конкретного use case | Existing service + PrismaService |
|
||||
| Данные для use case | Concrete repository; use case не импортирует PrismaService/Prisma и не строит queries |
|
||||
| Несколько записей обязаны выполниться вместе | Database transaction вокруг инварианта |
|
||||
| Конкурентная смена одного значения | Atomic conditional update/constraint с проверкой результата |
|
||||
| Независимые reads | Не добавляй transaction ради внешнего вида |
|
||||
| Общая persistence abstraction | Только при реальной общей ответственности; generic repository для одной Prisma реализации не нужен |
|
||||
|
||||
Размещение repository определяется [Architecture](../architecture.md):
|
||||
в слоистом module это `infrastructure/`.
|
||||
Repository — конкретный класс `*.repository.ts` у business owner, без обязательных
|
||||
interface/port/adapter. Он владеет queries, includes/filtering, DB mapping и
|
||||
согласованным чтением. Суффиксы `*.persistence.ts`, `*.store.ts`, `*.dao.ts`
|
||||
для той же роли не используем. 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` проверяет владельца карты и получает операции
|
||||
с категориями в одной read transaction. `GetBankSummaryUseCase` выбирает период,
|
||||
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
|
||||
не становятся domain entities: расчёт получает только нужные ему значения.
|
||||
|
||||
Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction
|
||||
services (auth, lifecycle подключения, изменения карт/операций/категорий)
|
||||
сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует
|
||||
лишь набора pass-through методов. Новая самостоятельная read/write capability
|
||||
получает repository method; самостоятельный use case не выполняет Prisma queries.
|
||||
`BankCardsRepository.write` сохраняет общую retry/transaction boundary для
|
||||
существующих banking services. Такой callback с Prisma transaction client —
|
||||
локальная техническая граница этих services, не API для domain или use cases.
|
||||
|
||||
Выбирай только нужные данные, сохраняй user/account ownership в запросах и
|
||||
application checks. Предварительный lookup не заменяет unique constraint при race.
|
||||
Пример: AuthService обрабатывает P2002 и использует conditional `updateMany`
|
||||
@@ -23,16 +51,16 @@ application checks. Предварительный lookup не заменяет
|
||||
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
|
||||
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
|
||||
lock во время retry внешней сети. Access/cache generation semantics — у
|
||||
[T-Bank source](../../../docs/features/tbank-connection.md#граница-финансового-кэша).
|
||||
[T-Bank integration](integrations.md#retry-cache-и-protocol).
|
||||
|
||||
## Деньги и точность
|
||||
|
||||
Для денежных значений используй existing decimal helpers и строковый wire value,
|
||||
не JS floating-point арифметику для сумм. Currency — отдельная часть Money;
|
||||
разные валюты не складываются без явно заданного conversion contract.
|
||||
Источник: [Money](../../../docs/architecture/domain-glossary.md#money),
|
||||
пример: [decimal.ts](../../src/tbank/decimal.ts). UI formatting не меняет
|
||||
backend ownership расчётов и доменную семантику.
|
||||
Источник формы — `src/common/money.types.ts`;
|
||||
пример точной арифметики: [decimal.ts](../../src/common/decimal.ts).
|
||||
Форматирование ответа не меняет доменную семантику и точность расчёта.
|
||||
|
||||
## Schema и migration
|
||||
|
||||
@@ -45,12 +73,12 @@ client обновляй из schema. Не переписывай уже прим
|
||||
|
||||
**Безопасный путь:** additive изменение, заполнение/проверка данных и только затем
|
||||
удаление прежней формы, если задача этого требует. Примеры schema/migrations:
|
||||
[Prisma source](../../prisma/schema.prisma); исторический rationale —
|
||||
[ADR 001](../../../docs/adr/001-prisma-sqlite-local-mvp.md), не текущий список таблиц.
|
||||
[Prisma source](../../prisma/schema.prisma). SQLite — embedded storage текущего
|
||||
приложения; файл БД и encryption key требуют согласованного backup/restore.
|
||||
|
||||
## Проверка
|
||||
|
||||
`npx prisma validate`; после schema change `npx prisma generate` и
|
||||
`npm run typecheck:app`. Invariants/atomicity/migration safety требуют tests
|
||||
на соответствующей boundary: mock Prisma не доказывает реальную transaction.
|
||||
Выбор integration/e2e и известные ограничения — в [Testing](backend-testing.md).
|
||||
Выбор integration/e2e и известные ограничения — в [Testing](testing.md).
|
||||
@@ -0,0 +1,74 @@
|
||||
# Работа в репозитории
|
||||
|
||||
## Граница и порядок работы
|
||||
|
||||
Репозиторий сам содержит инструкции, исходники, конфигурацию и входы своих
|
||||
проверок. Сборка, CI и обязательный маршрут чтения работают без соседних
|
||||
checkout. Внешний API задаётся предоставленным публичным контрактом;
|
||||
исходники или инструкции его реализации не являются зависимостью разработки.
|
||||
При нехватке требования сообщи, какое решение не определено, до его реализации.
|
||||
|
||||
Начни с результата и affected areas. До edits проследи flow/invariants, найди
|
||||
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
|
||||
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
|
||||
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
|
||||
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
|
||||
|
||||
## Change scope
|
||||
|
||||
Сначала расширь существующего владельца. Иначе используй standard library,
|
||||
платформу или установленную dependency; небольшая новая реализация допустима
|
||||
при собственной ответственности. Не создавай helper/interface/wrapper ради
|
||||
возможного reuse. Общее имя не означает общую бизнес-ответственность.
|
||||
Сохраняй validation, authorization, atomicity, accessibility и migration safety.
|
||||
|
||||
После diff выполни Boy Scout review: проверь новое дублирование, owner, callers,
|
||||
небольшие связанные legacy issues и соответствие local conventions. Исправляй
|
||||
непосредственно связанные low-risk проблемы; широкий cleanup — отдельная задача.
|
||||
|
||||
## Domain values
|
||||
|
||||
Finite business vocabulary принадлежит одному domain/module/entity owner.
|
||||
Переиспользуй его symbols; для нового словаря без аналога — `as const` + union.
|
||||
Сохраняй wire values; generated/external enums не переписывай вручную.
|
||||
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
|
||||
обычные labels не выноси в constants без semantic benefit.
|
||||
Локальный TypeScript/lint профиль определяет дополнительные ограничения.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Проверь аналоги и direct dependencies в местном manifest до добавления пакета.
|
||||
Новая dependency оправдана текущей потребностью и меньшей общей сложностью;
|
||||
оцени compatibility, runtime/bundle и maintenance/security. Импортируемый пакет
|
||||
объявляется напрямую; транзитивная установка не является контрактом.
|
||||
Сохраняй exact versions и lockfile; воспроизводимая установка — `npm ci`.
|
||||
Peer-конфликт решай совместимыми версиями без `--force`/`--legacy-peer-deps`.
|
||||
Используй существующий analyzer; не добавляй второй для той же проверки.
|
||||
|
||||
## Test Intent
|
||||
|
||||
Перед нетривиальным тестом кратко определи наблюдаемый исход, regression risk,
|
||||
значимые edge/error/concurrency cases и самую дешёвую надёжную boundary.
|
||||
Отдельный artifact не нужен. Проверяй результат, а не private call order,
|
||||
декларации, тривиальные wrappers или поведение framework.
|
||||
При bug сначала воспроизведи исход. Mock race не доказывает DB atomicity.
|
||||
Уровни и runnable commands выбирай по местной testing convention.
|
||||
|
||||
## Проверки и завершение
|
||||
|
||||
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
|
||||
risk checks из местной testing convention. Targeted tests ускоряют итерацию,
|
||||
но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
|
||||
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
|
||||
чтения и `git diff --check`; application build ради текста не нужен.
|
||||
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
|
||||
и ограничения. Зелёный lint не заменяет semantic/security review.
|
||||
|
||||
## Git и релизы
|
||||
|
||||
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
|
||||
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
|
||||
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
|
||||
содержит одну связанную задачу. Root другого checkout не управляет этим repo.
|
||||
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
|
||||
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
|
||||
@@ -0,0 +1,40 @@
|
||||
# Backend errors
|
||||
|
||||
## Поток
|
||||
|
||||
```text
|
||||
application/domain outcome → application mapping → HttpExceptionFilter
|
||||
→ application/problem+json
|
||||
```
|
||||
|
||||
Сохраняй принятый профиль RFC 9457: `status`, стабильный публичный `code`,
|
||||
для validation — `errors: [{ pointer, detail }]`. Точный контракт и известное
|
||||
расхождение validation описаны в [HTTP API](http-api.md#источник-контракта).
|
||||
Не добавляй `type/title/instance` как обязательные поля без contract change.
|
||||
|
||||
Чистый domain не импортирует Nest HTTP exceptions. Существующий application
|
||||
service может бросить подходящий `HttpException` с public code; не нужен новый
|
||||
error framework/registry. Если domain outcome требует HTTP interpretation,
|
||||
маппинг принадлежит application/transport boundary.
|
||||
|
||||
## Codes и безопасность
|
||||
|
||||
- Используй existing code владельца, не сравнивай внутренние сообщения.
|
||||
Новое значение имеет один symbolic owner; metadata/validation и consumers
|
||||
согласуются по [domain values](engineering.md#domain-values).
|
||||
- Общий envelope, default status mapping и unexpected errors принадлежат
|
||||
[HttpExceptionFilter](../../src/common/filters/http-exception.filter.ts).
|
||||
Не дублируй `res.status().json()` в controllers/services.
|
||||
- Неожиданный exception → `500 / INTERNAL_SERVER_ERROR`, без message/stack,
|
||||
SQL, credentials или raw upstream payload в ответе.
|
||||
- External auth failure и временная недоступность имеют разные outcomes;
|
||||
translation выполняется на existing integration/access boundary.
|
||||
- Логи используют [observability](observability.md). Redaction не
|
||||
делает произвольное сообщение ошибки безопасным.
|
||||
|
||||
## Проверка
|
||||
|
||||
Для изменённого кода проверь observable status/code и сохранность данных.
|
||||
Для validation нужен request через `configureApp`: fabricated `ValidationError`
|
||||
в unit test filter не доказывает форму, выходящую из реального ValidationPipe.
|
||||
OpenAPI parity проверяет schema, а не response pipeline; [Testing](testing.md).
|
||||
@@ -0,0 +1,88 @@
|
||||
# Backend HTTP API
|
||||
|
||||
## Выбор boundary
|
||||
|
||||
Сначала определи, меняется wire contract или только реализация существующего
|
||||
use case. Для route/request/response/status/error shape используй
|
||||
[локальный публичный контракт](#источник-контракта) и configured HTTP tests.
|
||||
|
||||
| Проверка | Где выполнять |
|
||||
| --- | --- |
|
||||
| Тип/формат/диапазон входа | Runtime DTO class с class-validator/class-transformer |
|
||||
| Согласованность полей, бизнес-ограничение | Feature service/domain helper |
|
||||
| Session и identity | Existing guard/strategy |
|
||||
| Доступ user к конкретному account | Existing access service |
|
||||
| HTTP envelope/безопасная ошибка | Общий HttpExceptionFilter |
|
||||
|
||||
Controller объявляет contract и делегирует; не обращается напрямую к Prisma/gRPC
|
||||
и не выполняет финансовые расчёты или сложную оркестрацию.
|
||||
Controller вызывает application use case/service. Identity берётся из verified
|
||||
`request.user`, не из body/userId, предоставленного клиентом. Access rules активируют Security.
|
||||
|
||||
## DTO и errors
|
||||
|
||||
DTO — runtime class: TS interface не предоставляет validation metadata.
|
||||
`*.controller.ts` и `*.dto.ts` остаются около HTTP boundary, в root компактного
|
||||
module или presentation. DTO можно передать application boundary при совпадении
|
||||
формы; отдельный Input с теми же полями не нужен. Domain не импортирует DTO:
|
||||
например, чистые денежные расчёты используют `Money`, а Swagger — `MoneyDto`.
|
||||
Нормализованная форма input нужна только при смысловом отличии от wire contract;
|
||||
выбор suffix принадлежит [Architecture](../architecture.md).
|
||||
ValidationPipe уже настроен в `configureApp` (`transform`, `whitelist`).
|
||||
Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён.
|
||||
Для query numbers/arrays выбирай явное преобразование и валидаторы, как в
|
||||
[operations DTO](../../src/modules/investments/operations/presentation/brokerage-operation.dto.ts). Swagger metadata
|
||||
описывает те же required/optional/nullable/enum/range значения.
|
||||
|
||||
Используй типизированные response DTO, `ApiOkResponse`/другой фактический status
|
||||
и существующий `ApiProblemResponses`. Prisma model не является public contract:
|
||||
response выбирает поля явно. Stable codes, RFC 9457 профиль и mapping принадлежат
|
||||
[Errors](error-handling.md).
|
||||
|
||||
**Безопасный путь:** используй exception и общий filter, а не локальный
|
||||
`res.status(...).json(...)`. Исключение — транспортная операция вроде cookie;
|
||||
следуй существующему auth controller с `passthrough` и явно заданным status.
|
||||
|
||||
## Пример и проверка
|
||||
|
||||
Prefix `/api/v1` задаёт `configureApp`. Route/status/request/response/OpenAPI
|
||||
change — изменение публичного backend contract; несовместимое изменение требует
|
||||
согласованного перехода consumers, а не незаметной правки DTO. Новый API version
|
||||
не вводи для внутреннего refactor.
|
||||
|
||||
Pagination сохраняет contract endpoint: operations использует opaque cursor,
|
||||
limit и DTO bounds. Не заменяй cursor offset/page и не добавляй global pagination
|
||||
helper ради первого endpoint. Новая pagination явно определяет порядок,
|
||||
границу страницы и end-of-list; reused cursor не должен менять filters/owner.
|
||||
|
||||
[Operations controller](../../src/modules/investments/operations/presentation/brokerage-operations.controller.ts)
|
||||
показывает guard, user.sub, DTO query и service delegation.
|
||||
Для нового field обновляй Nest DTO/metadata и публикуемый OpenAPI artifact.
|
||||
Потребители получают публичный контракт независимо от внутреннего устройства API.
|
||||
|
||||
Проверь happy path и применимые invalid input/access/not-found/domain/upstream
|
||||
errors на boundary, где они возникают. Unit вызов controller не запускает pipes,
|
||||
guards или serialization. `npm run test:openapi-contract` проверяет schema parity,
|
||||
но не поведение invalid request; выбирай соответствующий test по
|
||||
[Testing](testing.md). Финальный gate не заменяет semantic review ошибок.
|
||||
|
||||
## Источник контракта
|
||||
|
||||
Nest DTO/controllers/Swagger metadata определяют публичный HTTP API.
|
||||
`npm run openapi:generate` собирает backend и записывает корневой `openapi.json`.
|
||||
Nest preview читает metadata без запуска HTTP-порта, провайдеров, БД и T-Bank;
|
||||
команда не требует deployment environment. Artifact исключён из Git.
|
||||
|
||||
При изменении API обнови DTO/metadata и выполни генерацию. Потребители получают
|
||||
artifact отдельно; backend не читает их репозитории. `test:openapi-contract`
|
||||
сравнивает вывод генератора с runtime schema configured app во временном каталоге.
|
||||
Проверка подтверждает правильность генерации, но без versioned baseline не
|
||||
выявляет breaking change относительно предыдущего выпуска. Для такого изменения
|
||||
согласуй переход consumers и проверь relevant HTTP behavior.
|
||||
|
||||
HTTP prefix — `/api/v1`; Swagger доступен согласно `src/config/swagger.config.ts`.
|
||||
Общий error response — `application/problem+json` со `status` и публичным `code`.
|
||||
Validation требует `400 / VALIDATION_ERROR` и `errors: [{ pointer, detail }]`.
|
||||
Известное ограничение: ValidationPipe сейчас формирует строковые сообщения,
|
||||
а filter ожидает структурированные field errors; schema parity не доказывает
|
||||
правильность `errors`. Изменение требует проверки реального HTTP pipeline.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Backend integrations
|
||||
|
||||
## Выбор boundary
|
||||
|
||||
T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient; NCR ratings —
|
||||
NcrRatingsClient. Сервисы use case
|
||||
используют объявленные клиентом records; SDK clients, metadata и transport errors
|
||||
остаются внутри integration. Credentials и proto client не входят в публичный
|
||||
HTTP API. Connection lifecycle проверяется на границе соответствующего module
|
||||
и [Security](security-auth.md).
|
||||
|
||||
`modules/investments/tbank-connection` — пользовательская capability чтения,
|
||||
создания/замены и удаления подключения. `integrations/tbank` — технические
|
||||
RPC, credentials/access и resilience. Эти владельцы не объединяются из-за имени
|
||||
поставщика; низкоуровневый client не зависит от пользовательского module.
|
||||
|
||||
`*.client.ts` содержит transport и его retries; `*.api.ts` только перечисляет
|
||||
публичные exports. Все шесть `auth/operations/portfolio/tbank/moex/ncr.api.ts`
|
||||
имеют одинаковую роль программной границы, определённую в
|
||||
[Architecture](../architecture.md). Логика, DTO definitions и HTTP routes
|
||||
в эти файлы не помещаются. Type exports обозначаются `export type`.
|
||||
|
||||
Преобразование provider records происходит на boundary: например,
|
||||
`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper
|
||||
остаётся отдельным чистым входом integration. Domain получает внутренние
|
||||
значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый
|
||||
client или mapper для простого копирования полей. Даты/units/nano разбираются
|
||||
на границе клиента/его mapping, а не становятся моделью чистых расчётов.
|
||||
|
||||
Для нового account use case сначала используй TbankAccessService: access,
|
||||
connection generation и invalid-token translation не дублируются в callers.
|
||||
Mapper внешних финансовых значений остаётся внутри business capability,
|
||||
обычно в infrastructure по [Architecture](../architecture.md).
|
||||
|
||||
## Retry, cache и protocol
|
||||
|
||||
Existing client уже владеет bounded FIFO queue, rate limit, deadline и retry
|
||||
классификацией. Новый RPC проходит тот же путь; не добавляй вложенный retry loop
|
||||
в service. Не отключай TLS verification ради окружения: используй документированный
|
||||
CA mechanism. Точные параметры и ограничения принадлежат
|
||||
[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,
|
||||
freshness и поведение late response. Старый результат не может обслужить новую
|
||||
connection generation. Успешная замена/deletion подключения меняет generation
|
||||
даже при сохранении прежнего token; ошибка оставляет прежнее подключение.
|
||||
Кэш конкретного use case принадлежит его service и tests. Не переноси
|
||||
сохранённый snapshot между пользователями или поколениями подключения.
|
||||
|
||||
Внешний auth failure и временная недоступность имеют разные public codes;
|
||||
raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService
|
||||
и общем HTTP boundary. Для HTTP error change читай HTTP API/Security по trigger.
|
||||
|
||||
Vendor proto хранится в `src/integrations/tbank/proto/contracts`; для обновления сверяй
|
||||
официальный source и влияние на loader/DTO, не редактируй read-only references.
|
||||
Generated artifacts, если есть, создавай из source, без ручной подгонки вывода.
|
||||
|
||||
## Безопасный путь и проверка
|
||||
|
||||
Сначала добавь нужный operation в существующий client и используй его из
|
||||
ответственного service. Отдельный abstraction/adapter оправдан новой
|
||||
ответственностью или несовместимым protocol, не только вторым методом.
|
||||
|
||||
[TbankClient tests](../../src/integrations/tbank/tbank.client.spec.ts) — пример controlled
|
||||
upstream responses. Проверь queue/deadline/retry limit, token translation,
|
||||
cache isolation и late response для затронутых ветвей. Fixtures синтетические;
|
||||
реальная сеть, секреты и пользовательские accounts не нужны.
|
||||
Logging использует [existing logger](../architecture.md#logging).
|
||||
@@ -0,0 +1,22 @@
|
||||
# Observability
|
||||
|
||||
- Единственный logger — nestjs-pino. Application events используют
|
||||
[StructuredLogger](../../src/common/logging/structured-logger.service.ts),
|
||||
стабильное имя события и разрешённые scalar fields. `console.log` и второй
|
||||
logger в production не используются.
|
||||
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
|
||||
`warn` — восстановимая проблема/retry; `error` — окончательный сбой.
|
||||
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
|
||||
- RequestContextService и request middleware владеют correlation: серверный
|
||||
request ID возвращается как `X-Request-ID`, входящий client ID хранится отдельно.
|
||||
Передавай существующий context, не создавай собственный UUID в каждом service.
|
||||
- Wrapper ограничивает поля, Pino redacts request secrets. Не логируй passwords,
|
||||
tokens, cookie, Authorization, credentials, connection generation, raw body
|
||||
или upstream error. Secret в `message/stack` не становится безопасным из-за
|
||||
allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
|
||||
- Для ошибки используй public code и безопасный context операции/попыток;
|
||||
[error mapping](error-handling.md) не отдаёт внутренние детали клиенту.
|
||||
|
||||
Проверяй новые поля/redaction/correlation existing logging tests; не добавляй
|
||||
тест на каждую строку log. Existing ESLint не запрещает все вызовы `console`
|
||||
отдельным правилом: до настройки встроенного `no-console` это semantic review.
|
||||
@@ -10,20 +10,23 @@ account access/connection generation — TbankAccessService; token crypto —
|
||||
TbankTokenService/TbankCrypto. Расширяй существующую boundary, не добавляй
|
||||
второй JWT middleware, decrypt helper или refresh coordinator.
|
||||
|
||||
[Authentication](../../../docs/features/authentication.md) и
|
||||
[ADR 006](../../../docs/adr/006-auth-session-policy.md)/[009](../../../docs/adr/009-concurrent-refresh-rotation-policy.md)
|
||||
задают session semantics. Access хранится только в памяти frontend; refresh
|
||||
передаётся httpOnly cookie. Source code конфигурации cookie —
|
||||
[AuthController](../../src/auth/auth.controller.ts); изменение flags/path требует
|
||||
Access проверяется JWT strategy; refresh передаётся httpOnly cookie.
|
||||
Сроки token/session принадлежат auth source и configured HTTP tests.
|
||||
Source code конфигурации cookie —
|
||||
[AuthController](../../src/modules/auth/presentation/auth.controller.ts); изменение flags/path требует
|
||||
проверки browser/HTTP поведения, не только TypeScript.
|
||||
|
||||
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
|
||||
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace
|
||||
или иной security contract без текущей задачи. Logout идемпотентен; in-memory
|
||||
frontend session/cache очищается и при network failure.
|
||||
или иной security contract без текущей задачи. Logout идемпотентен, отзывает
|
||||
сессию и очищает refresh cookie на HTTP-границе.
|
||||
|
||||
## 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 и
|
||||
existing account access policy; не доверяй userId/token из клиента как authority.
|
||||
Cross-user, invalid/expired/superseded token и concurrent requests — обязательные
|
||||
@@ -32,13 +35,20 @@ cases для соответствующего изменения.
|
||||
T-Bank credentials остаются server-side и хранятся encrypted. Используй
|
||||
existing AES-256-GCM boundary с validated 32-byte key; не печатай plaintext,
|
||||
шифротекст, cookie, Authorization или connection generation в logs/errors/API.
|
||||
[Connection source](../../../docs/features/tbank-connection.md) определяет
|
||||
cache invalidation при замене/deletion и ограничение key rotation.
|
||||
Успешная замена/deletion меняет connection generation и инвалидирует её кэши;
|
||||
ошибка сохраняет прежнее подключение. Ключ не ротируется автоматически:
|
||||
новый ключ не расшифрует сохранённые credentials. Нужен исходный ключ или
|
||||
повторное сохранение token пользователем; сохраняй ключ отдельно от backup БД.
|
||||
|
||||
Не передавай Prisma User/Session целиком наружу; выбирай public DTO. Пароль
|
||||
обрабатывает существующий bcrypt flow. Не копируй требования о нормализации
|
||||
или длине из 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.
|
||||
@@ -46,6 +56,6 @@ cache invalidation при замене/deletion и ограничение key ro
|
||||
фиктивным success или log. Logger allowlist/redaction не делает произвольный
|
||||
message безопасным.
|
||||
|
||||
Targeted auth/access/crypto/logging tests + [Testing](backend-testing.md).
|
||||
Cookie/guard/HTTP changes требуют configured HTTP proof; frontend Bearer/retry
|
||||
проверяется MSW. Schema parity не доказывает отсутствие утечки secret.
|
||||
Targeted auth/access/crypto/logging tests + [Testing](testing.md).
|
||||
Cookie/guard/HTTP changes требуют configured HTTP proof.
|
||||
Schema parity не доказывает отсутствие утечки secret.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Backend testing
|
||||
|
||||
Перед нетривиальными тестами сформулируй
|
||||
[общий Test Intent](engineering.md#test-intent); отдельный документ
|
||||
не требуется. Trivial low-risk изменения обходятся без него.
|
||||
|
||||
## Выбор уровня
|
||||
|
||||
Проверяй риски доступа, денежных расчётов, целостности данных, конкурентных
|
||||
изменений, публичного контракта и подтверждённых регрессий.
|
||||
Тесты должны различать правильное и неправильное наблюдаемое поведение.
|
||||
|
||||
| Что меняется | Проверка |
|
||||
| --- | --- |
|
||||
| Pure mapper/decimal/domain branch | Unit со значимыми edge cases |
|
||||
| Service orchestration/access/retry policy | Jest с mocks внешних boundaries и observable outcome |
|
||||
| Pipe/guard/cookie/status/HTTP error | Configured Nest HTTP boundary; прямого вызова метода controller недостаточно |
|
||||
| OpenAPI metadata/wire schema | `test:openapi-contract` + configured HTTP tests |
|
||||
| DB constraint/CAS/transaction/migration | Integration с изолированной реальной DB |
|
||||
| Только DTO declarations/thin wiring | Existing contract/type/build без искусственного unit test |
|
||||
|
||||
Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
|
||||
Мокируй T-Bank, время и нужную DB boundary, а не проверяемую business-функцию.
|
||||
Не зови реальный внешний account/API. Expected error/code, отсутствие запроса
|
||||
при недопустимом доступе и сохранение данных важнее private method call count.
|
||||
|
||||
## Случаи и примеры
|
||||
|
||||
- [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.
|
||||
- [HTTP filter tests](../../src/common/filters/http-exception.filter.spec.ts): безопасный error envelope.
|
||||
- [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): генератор без сервера + configured app; временный JSON, без versioned snapshot и без T-Bank.
|
||||
|
||||
При bug сначала воспроизведи именно его исход, затем исправь responsible owner.
|
||||
Не создавай tests для каждой декларации, logs строки или поведения Prisma/Nest
|
||||
без собственного инварианта. Coverage не имеет общего threshold.
|
||||
|
||||
## Команды и ограничения
|
||||
|
||||
Итерация: `npm test -- --runInBand <pattern>`, затем нужный typecheck/lint.
|
||||
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
|
||||
`npm run test:openapi-contract` — отдельный required check при wire contract change.
|
||||
|
||||
`npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner;
|
||||
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
|
||||
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
|
||||
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
|
||||
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
|
||||
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
|
||||
HTTP suites требуют разрешения открывать локальные порты. Ошибка sandbox
|
||||
не считается успешным тестом; повторите проверку в подходящем окружении.
|
||||
|
||||
Для schema/data changes дополнительно `npm run test:durability -- <baseline>`.
|
||||
Матрица рисков и подготовка совместимой предыдущей сборки — в том же README.
|
||||
Mock Prisma не заменяет доказательство реальной DB concurrency.
|
||||
@@ -0,0 +1,13 @@
|
||||
**
|
||||
!package.json
|
||||
!package-lock.json
|
||||
!.nvmrc
|
||||
!nest-cli.json
|
||||
!tsconfig*.json
|
||||
!prisma.config.ts
|
||||
!prisma/
|
||||
!prisma/schema.prisma
|
||||
!prisma/migrations/
|
||||
!prisma/migrations/**
|
||||
!src/
|
||||
!src/**
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Conventional commits
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
jobs:
|
||||
commits:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci --ignore-scripts
|
||||
- name: Check PR title and commits
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Backend security
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '17 5 * * *'
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Record checked revision
|
||||
run: git rev-parse HEAD
|
||||
- name: Scan history with Gitleaks 8.30.1
|
||||
run: |
|
||||
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
|
||||
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
|
||||
mkdir -p /tmp/gitleaks-bin
|
||||
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
|
||||
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Audit runtime and development dependencies
|
||||
run: npm audit --include=dev --audit-level=low
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Backend verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Record checked revision
|
||||
run: git rev-parse HEAD
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci
|
||||
- name: Generate Prisma client before typechecks
|
||||
run: npx --no-install prisma generate
|
||||
- name: Run all backend gates
|
||||
run: npm run verify
|
||||
- name: Run isolated SQLite and runtime OpenAPI suites
|
||||
run: npm run test:e2e
|
||||
- name: Analyze develop with SonarQube
|
||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop'
|
||||
# v2.3.0: same Docker-based scanner used for SonarQube 9.9.
|
||||
uses: https://github.com/SonarSource/sonarqube-scan-action@aecaf43ae57e412bd97d70ef9ce6076e672fe0a9
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
with:
|
||||
args: -Dsonar.qualitygate.wait=true
|
||||
@@ -21,6 +21,7 @@ lerna-debug.log*
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.scannerwork
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
@@ -64,3 +65,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
/dev.db-*
|
||||
/prisma/*.db
|
||||
/prisma/*.db-*
|
||||
|
||||
# Generated public API artifact
|
||||
/openapi.json
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Reviewed historical false positives only; new commits remain scanned.
|
||||
# Test fixtures: base64 of the deterministic string 0123456789abcdef repeated twice.
|
||||
25d7c5780b60343fecb4f1ae9a30392776eb5aec:src/config/env.validation.spec.ts:generic-api-key:30
|
||||
31cbba8654f01adf11c52e12c2b286200d6cad7d:src/config/env.validation.spec.ts:generic-api-key:10
|
||||
31cbba8654f01adf11c52e12c2b286200d6cad7d:src/config/env.validation.spec.ts:generic-api-key:17
|
||||
31cbba8654f01adf11c52e12c2b286200d6cad7d:src/config/env.validation.spec.ts:generic-api-key:42
|
||||
31cbba8654f01adf11c52e12c2b286200d6cad7d:test/e2e-env.cjs:generic-api-key:4
|
||||
326fb67def9b3878d5447ef838ae96d50bf493d6:src/tbank/brokerage-operations.service.spec.ts:generic-api-key:15
|
||||
34c2b79e276c9575996604f3f862bc866cfe44e7:src/tbank/upcoming-events.service.spec.ts:generic-api-key:15
|
||||
c0a4a9388074bdce20138dd5649805ed24a9e9fa:src/tbank/brokerage-accounts.service.spec.ts:generic-api-key:12
|
||||
ce24a84b7fb48091d2b91836eb7b702a2f811c96:test/e2e-env.cjs:generic-api-key:16
|
||||
dfca11c81f7e6dadce3ca6f961d265c5e3f8ff8e:src/tbank/portfolio.service.spec.ts:generic-api-key:16
|
||||
# Historical .env.example JWT_SECRET is an explicit replace-with placeholder.
|
||||
4cbfc2621cfc54df3d09786905130c28a2323a04:.env.example:generic-api-key:4
|
||||
# Historical CircleCI badge copied verbatim from nestjs/typescript-starter README.
|
||||
# Verified against https://github.com/nestjs/typescript-starter/blob/master/README.md
|
||||
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
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"git": {
|
||||
"requireBranch": "release/*",
|
||||
"requireCleanWorkingDir": true,
|
||||
"requireUpstream": false,
|
||||
"requireCommits": true,
|
||||
"commitMessage": "chore(release): ${version}",
|
||||
"tagName": "v${version}",
|
||||
"tag": false,
|
||||
"push": false
|
||||
},
|
||||
"npm": {
|
||||
"publish": false
|
||||
},
|
||||
"github": {
|
||||
"release": false
|
||||
},
|
||||
"gitlab": {
|
||||
"release": false
|
||||
},
|
||||
"hooks": {
|
||||
"after:git:init": "node scripts/check-release.cjs"
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"infile": "CHANGELOG.md",
|
||||
"preset": {
|
||||
"name": "conventionalcommits",
|
||||
"types": [
|
||||
{
|
||||
"type": "chore",
|
||||
"scope": "release",
|
||||
"effect": "hidden"
|
||||
},
|
||||
{
|
||||
"type": "feat",
|
||||
"section": "Features",
|
||||
"effect": "bump"
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"section": "Bug Fixes",
|
||||
"effect": "bump"
|
||||
},
|
||||
{
|
||||
"type": "perf",
|
||||
"section": "Performance",
|
||||
"effect": "bump"
|
||||
},
|
||||
{
|
||||
"type": "docs",
|
||||
"section": "Documentation",
|
||||
"effect": "bump"
|
||||
},
|
||||
{
|
||||
"type": "spec",
|
||||
"section": "Specifications",
|
||||
"effect": "bump"
|
||||
},
|
||||
{
|
||||
"type": "refactor",
|
||||
"section": "Refactoring",
|
||||
"effect": "bump"
|
||||
},
|
||||
{
|
||||
"type": "build",
|
||||
"section": "Build",
|
||||
"effect": "bump"
|
||||
},
|
||||
{
|
||||
"type": "ci",
|
||||
"section": "CI",
|
||||
"effect": "bump"
|
||||
},
|
||||
{
|
||||
"type": "test",
|
||||
"section": "Tests",
|
||||
"effect": "bump"
|
||||
},
|
||||
{
|
||||
"type": "style",
|
||||
"section": "Styles",
|
||||
"effect": "bump"
|
||||
},
|
||||
{
|
||||
"type": "chore",
|
||||
"section": "Maintenance",
|
||||
"effect": "bump"
|
||||
},
|
||||
{
|
||||
"type": "revert",
|
||||
"section": "Reverts",
|
||||
"effect": "bump"
|
||||
}
|
||||
]
|
||||
},
|
||||
"context": {
|
||||
"host": "https://git.personal.ksv741.ru",
|
||||
"owner": "WealthLedger",
|
||||
"repository": "backend",
|
||||
"linkReferences": true,
|
||||
"commit": "commit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +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`.
|
||||
После lockfile install `npm ci` полный gate — `npm run verify`:
|
||||
zero-warning ESLint, application/test TypeScript, Jest и build.
|
||||
`lint:fix`/`format` пишут файлы и запускаются отдельно от доказательства проверки.
|
||||
`test:cov` — диагностика без общего threshold; scripts принадлежат `package.json`.
|
||||
Новый endpoint обычно требует HTTP API + Testing; Data/Security добавляются
|
||||
по persistence/access trigger. Auth refresh активирует Security + Data + Testing.
|
||||
Перед завершением повтори routing по новым и изменённым файлам. Для Git/release
|
||||
прочитай [Git и релизы](.ai/conventions/engineering.md#git-и-релизы).
|
||||
|
||||
HTTP contract changes дополнительно проходят общий
|
||||
[contract workflow](../docs/development/runtime-openapi-verification.md).
|
||||
Общий e2e имеет известный environment blocker; применимость и маршрут проверки
|
||||
описаны в [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 |
|
||||
| T-Bank/cache/logging | [Integrations](.ai/conventions/integrations.md), [Observability](.ai/conventions/observability.md) |
|
||||
| Money и финансовые расчёты | [Data](.ai/conventions/data.md#деньги-и-точность), domain source и tests владельца |
|
||||
| Поведение области | Module source и tests; неясное ожидание уточни до изменения |
|
||||
| Versions/scripts/check scope | Manifest/lockfile, ESLint/TypeScript/Jest/Prisma configs |
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
# Changelog
|
||||
|
||||
## [0.2.3](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.2...v0.2.3) (2026-09-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct Nexus routing and SonarQube host ([7c0150c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7c0150c56b2dab44347b55344e04c821689543e7))
|
||||
|
||||
## [0.2.2](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.1...v0.2.2) (2026-09-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update Nexus registry host ([4c91682](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4c916829eac23326a42c09873e32908cfc9f422c))
|
||||
|
||||
## [0.2.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.0...v0.2.1) (2026-09-18)
|
||||
|
||||
### Documentation
|
||||
|
||||
* use shared Nexus account [#25](https://git.personal.ksv741.ru/WealthLedger/backend/issues/25) ([9437b6a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9437b6a311cd51230b8381c8643c6ea308454d38))
|
||||
|
||||
### CI
|
||||
|
||||
* add backend SonarQube analysis [#23](https://git.personal.ksv741.ru/WealthLedger/backend/issues/23) ([1946192](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1946192be975eeea75432ec39b27d19952742b15))
|
||||
* publish backend images to Nexus [#25](https://git.personal.ksv741.ru/WealthLedger/backend/issues/25) ([0ec7ae5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ec7ae51af080288a31dc6e65a738a4bd6396436))
|
||||
|
||||
## [0.2.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.1.0...v0.2.0) (2026-09-16)
|
||||
|
||||
### Features
|
||||
|
||||
* add backend container delivery ([1a6be43](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1a6be432f24f757728a03415051392069112af1f))
|
||||
* generate OpenAPI without peer repositories ([32c12fb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/32c12fb10d4f739c6af0ce32b8f2ea6e94c42090))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow reviewed container smoke fixture ([d7cb7e3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7cb7e3e206c995ef2fa6f1ee61ef7e93599d88f))
|
||||
|
||||
### Documentation
|
||||
|
||||
* refine backend conventions and routing ([9810d47](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9810d47e42d9275cf2520556f517d411058ddcb3))
|
||||
* simplify conventions layout ([0167826](https://git.personal.ksv741.ru/WealthLedger/backend/commit/016782657acfd35ff3fd755462007938fac35156))
|
||||
|
||||
## 0.1.0 (2026-09-14)
|
||||
|
||||
### Features
|
||||
|
||||
* add authorization ([4cbfc26](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4cbfc2621cfc54df3d09786905130c28a2323a04))
|
||||
* add bond details, yields and credit ratings ([235c179](https://git.personal.ksv741.ru/WealthLedger/backend/commit/235c179ff813dac3312b849928d11734bccba823))
|
||||
* add brocker account list ([c0a4a93](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c0a4a9388074bdce20138dd5649805ed24a9e9fa))
|
||||
* add logging ([25d7c57](https://git.personal.ksv741.ru/WealthLedger/backend/commit/25d7c5780b60343fecb4f1ae9a30392776eb5aec))
|
||||
* add manual bank card ledger ([e6f58ef](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e6f58ef0eef3fbae079270a0d429afd17e4cd3ac))
|
||||
* add operations list ([326fb67](https://git.personal.ksv741.ru/WealthLedger/backend/commit/326fb67def9b3878d5447ef838ae96d50bf493d6))
|
||||
* add portfolio ([dfca11c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/dfca11c81f7e6dadce3ca6f961d265c5e3f8ff8e))
|
||||
* add stock details and ticker resolution ([f24fd98](https://git.personal.ksv741.ru/WealthLedger/backend/commit/f24fd98fbcbbf952b11bb61f607ae0aec4456839))
|
||||
* add tbank tokens ([31cbba8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/31cbba8654f01adf11c52e12c2b286200d6cad7d))
|
||||
* add агегку events ([34c2b79](https://git.personal.ksv741.ru/WealthLedger/backend/commit/34c2b79e276c9575996604f3f862bc866cfe44e7))
|
||||
* aggregate portfolio data across accounts ([eaf2f67](https://git.personal.ksv741.ru/WealthLedger/backend/commit/eaf2f67b34b2f8b45a9f5d53c544b91304b333d3))
|
||||
* **cards:** support inclusive date range queries ([3cf883e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/3cf883efbc6fceddc889c5aadc3c8ffb410c6de5))
|
||||
* enrich ledger events with logo metadata ([84fabd2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/84fabd264fa539dfefb1172e27a038870a30d845))
|
||||
* establish backend foundation ([ab21426](https://git.personal.ksv741.ru/WealthLedger/backend/commit/ab21426478233b1cdfc7571c796f76d347874501))
|
||||
* **events:** support date range filtering ([a79f59a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a79f59a468dd113147b6f1a04ed0efd9828046c6))
|
||||
* expose instrument brand logo name in portfolio positions ([5375e55](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5375e5598a284238b995661dd2c0e9ce5901d190))
|
||||
* expose instrument isin in portfolio positions ([5aa9bd3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5aa9bd395a8e49e423764eb7c4a88afe56332ad4))
|
||||
* expose logos and ISINs for upcoming events ([33f703d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/33f703d9a375e3d88e492fbb338e609d6c499d7f))
|
||||
* **income:** add account income analytics API ([7ba4da0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7ba4da0d1b49afcb0db3d1756254e5d5e04f0c35))
|
||||
* initial commit ([f8dcfb7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/f8dcfb71c1767696a9aa6741ddaf6575416c901e))
|
||||
* **portfolio:** expose asset allocation by currency ([05747dc](https://git.personal.ksv741.ru/WealthLedger/backend/commit/05747dc3757cdfd4c3d840ad0500861063a76b09))
|
||||
* **portfolio:** expose position tickers ([924f60f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/924f60f65357781df967bfbaf1538f3400ad620a))
|
||||
* **tbank:** add brokerage account summaries ([e489d45](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e489d45f889656183e88ad68d964f721b715cbdb))
|
||||
* use money for upcoming event projections ([f1df104](https://git.personal.ksv741.ru/WealthLedger/backend/commit/f1df1044623862770ecb40fd735e1aea08dc2a68))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* align auth success status contract ([9bd3400](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9bd3400264de40a90b45d0cd997c2c752548c0bb))
|
||||
* allow multi-year income analytics periods ([9fcc0ec](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9fcc0ec01068372e6756f60bb49d83643e07466d))
|
||||
* harden auth and cache concurrency ([c77b3cf](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c77b3cf782f24ad3007add43f768d721e8f02386))
|
||||
* harden backend runtime artifact hygiene ([6052565](https://git.personal.ksv741.ru/WealthLedger/backend/commit/60525656b8ecdc4b63956f0d7b77e4286961edc4))
|
||||
* **income:** expose authoritative entry membership ([bdc3847](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bdc384766649bb48513055bb809f0d1b45b91f54))
|
||||
* isolate dev output from production builds ([320e8a0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/320e8a04b0ad8e0d57a89e07768e600bc16bab36))
|
||||
* little fixes ([e77ebc6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e77ebc6e8f78f96a4ee57c2684769ea4ec923017))
|
||||
* make backend local verification safe ([71d57d6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/71d57d60bcf2186d4f8f02169d1bea4ea4d672e3))
|
||||
* make SQLite migrations and e2e reproducible ([ce24a84](https://git.personal.ksv741.ru/WealthLedger/backend/commit/ce24a84b7fb48091d2b91836eb7b702a2f811c96))
|
||||
* recover portfolio instrument metadata on backend ([8ec172f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8ec172f48eefa2237dc0c16919fbb2e726b39a65))
|
||||
* **tbank:** isolate caches by connection generation ([19fe7e7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/19fe7e75d59792e9c6d6b702ab192fa2d5ccc936))
|
||||
* **tbank:** type connectedAt as date-time ([55b100d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/55b100d5f6bed126633432654a3353f862e6f428))
|
||||
* type T-Bank client config ([18d7cfc](https://git.personal.ksv741.ru/WealthLedger/backend/commit/18d7cfcf9fecb89e99f7ba1f5d43602807ae98b2))
|
||||
|
||||
### Documentation
|
||||
|
||||
* **agents:** link audit remediation registry ([595fc04](https://git.personal.ksv741.ru/WealthLedger/backend/commit/595fc0458b3af37ef5eb0c647c401f39f34fe5f0))
|
||||
* **ai:** add scoped backend conventions ([a83e26e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a83e26e5ef5d7cae3179a182fb5359a440538177))
|
||||
* define canonical OpenSpec store ([fee4f8c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fee4f8c8e6e3aa8fc4c41522dc6697825316f8f6))
|
||||
* refresh backend onboarding ([fec542a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fec542a275ed6c8273bb253ca4b58a497f74cd99))
|
||||
* remove useless ([da17893](https://git.personal.ksv741.ru/WealthLedger/backend/commit/da178933509512c05c501773947f35397783f249))
|
||||
* update routes to system documentation ([b1906c8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b1906c8e3ca242a0e30bd63519504c0c070f0354))
|
||||
* update system documentation references ([d4fc28c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d4fc28cbbf0bb76a8c11a68f09bbe41151067d88))
|
||||
|
||||
### Refactoring
|
||||
|
||||
* centralize domain values and enforce numeric lint ([8637ad2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8637ad2f346c7e03e709b3a52d1d67995415a707))
|
||||
* enforce strict backend typing ([346a6f0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/346a6f0057178a2fe157c45ecf93740140c10440))
|
||||
* organize backend by business responsibility ([8ba0053](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8ba005346dbbd515e2c1d71bf99fdacf4b73947f))
|
||||
* refactor future event list ([e005541](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e0055414d6947e46fb58db1abe96f4ec3ea74f8c))
|
||||
* **tbank:** centralize access rules ([130b303](https://git.personal.ksv741.ru/WealthLedger/backend/commit/130b303ec123f144d3833e5c7afa88d40f16f081))
|
||||
|
||||
### CI
|
||||
|
||||
* verify backend and secure dependency updates [#1](https://git.personal.ksv741.ru/WealthLedger/backend/issues/1) [#5](https://git.personal.ksv741.ru/WealthLedger/backend/issues/5) [#6](https://git.personal.ksv741.ru/WealthLedger/backend/issues/6) ([4c8ba2f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4c8ba2fac903c883849359aa354f3ffa749ca085))
|
||||
|
||||
### Tests
|
||||
|
||||
* cover isolated SQLite invariants and durability ([7fde3ec](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7fde3ece6ec7143723140357602861cd2e20b3a5))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* codify backend local verification ([2772d53](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2772d53647132e6119e411c6d18234db4301e541))
|
||||
* configure releases and develop workflow ([f01b82d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/f01b82d36686ac27495263071bc82b34ae2f9b01))
|
||||
* **deps:** pin node.js to v24.21.0 ([97e864b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/97e864bc06a58737cae6669d42255f278d93e887))
|
||||
* **deps:** update and pin backend dependencies ([2bc8745](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2bc87454954d0bd3eeab11634ed16eed869dbaab))
|
||||
* **deps:** upgrade backend to Nest 12 and TypeScript 6 ([121af70](https://git.personal.ksv741.ru/WealthLedger/backend/commit/121af701b722467353a33bf05e0b27a40ed5736e))
|
||||
* save pending changes ([8c074d8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8c074d85de3bc55d0ba750b86bc7b026649fa6bf))
|
||||
* simplify backend verification ([fd4572a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fd4572a64563c588029af38cd7b4cddc3e014cdf))
|
||||
* upgrade Node to 24 LTS ([197ffe2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/197ffe21368b337f27d27c22e473570258ebdc3b))
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
FROM node:24.21.0-bookworm-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS dependencies
|
||||
# Native SQLite/bcrypt builds happen only here; compilers stay out of runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3=3.11.2-1+b1 make=4.3-4.1 g++=4:12.2.0-3 ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npx --no-install prisma generate
|
||||
|
||||
FROM dependencies AS development
|
||||
# Nest watch uses ps to stop the previous process tree before restarting.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir /data && chown node:node /data && chown -R node:node /app
|
||||
USER node
|
||||
ENV NODE_ENV=development DATABASE_URL=file:/data/wealthledger.db
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start:dev"]
|
||||
|
||||
FROM dependencies AS build
|
||||
RUN npm run build && npm prune --omit=dev --omit=optional
|
||||
|
||||
FROM base AS production
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir /data && chown node:node /data
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
LABEL org.opencontainers.image.version=$VERSION \
|
||||
org.opencontainers.image.revision=$REVISION
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
|
||||
COPY --from=build /app/package.json /app/package-lock.json ./
|
||||
ENV NODE_ENV=production DATABASE_URL=file:/data/wealthledger.db
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/src/main.js"]
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=6 \
|
||||
CMD node -e 'fetch("http://127.0.0.1:3000/api/v1/auth/me").then(async r=>{await r.arrayBuffer();process.exit(r.status===401?0:1)}).catch(()=>process.exit(1))'
|
||||
@@ -1,17 +1,19 @@
|
||||
# WealthLedger backend
|
||||
|
||||
NestJS API WealthLedger. Backend владеет auth, SQLite-данными и server-side
|
||||
интеграцией T-Bank; frontend не обращается к T-Bank напрямую.
|
||||
интеграциями. Потребители используют публичный HTTP API и OpenAPI artifact;
|
||||
их репозитории не нужны для разработки, сборки и проверок backend.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
Требуется Node 22 (`.nvmrc`). Создайте `.env` из примера, замените placeholder
|
||||
Требуется Node 24 (`.nvmrc`). Создайте `.env` из примера, замените placeholder
|
||||
секреты и установите зависимости строго из lockfile:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nvm use --silent
|
||||
npm ci
|
||||
npx prisma migrate deploy
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
@@ -20,6 +22,36 @@ npm run start:dev
|
||||
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-файлы:
|
||||
@@ -29,33 +61,213 @@ npm run verify
|
||||
```
|
||||
|
||||
Он запускает zero-warning lint,
|
||||
application/test typechecks, Jest и build. `npm run lint:fix` — отдельная
|
||||
записывающая команда, `npm run test:cov` — диагностика без общего числового
|
||||
threshold. `npm run test:e2e` пока не является passing gate: Prisma schema
|
||||
engine останавливает migration до запуска Jest. Точный статус и trigger
|
||||
пересмотра находятся в [remediation registry](../docs/development/audit-remediation.md).
|
||||
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
|
||||
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
|
||||
threshold нет. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
|
||||
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки
|
||||
выполните обе команды.
|
||||
|
||||
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
|
||||
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
|
||||
использует явные `rootDir` и типы Node/Jest в `tsconfig.json`. Переход на
|
||||
TypeScript 7 требует поддержки со стороны `ts-jest` и `typescript-eslint`.
|
||||
|
||||
### CI и обновления зависимостей
|
||||
|
||||
`.gitea/workflows/verify.yml` запускает `npm ci`, генерацию Prisma client,
|
||||
`npm run verify` и `npm run test:e2e` на push и PR. Используется только checkout
|
||||
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
|
||||
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
||||
|
||||
`security.yml` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
|
||||
значениями и выполняет `npm audit --include=dev --audit-level=low` при push,
|
||||
PR и ежедневно. `.gitleaksignore` перечисляет только проверенные исторические
|
||||
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
|
||||
Новые коммиты этими исключениями не покрываются.
|
||||
|
||||
Renovate группирует minor/patch updates, а major требует согласования в
|
||||
Dependency Dashboard. Автоматический merge не включён. Прямые зависимости
|
||||
фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install,
|
||||
Renovate использует `rangeStrategy: pin`. Обновляйте `package.json` вместе с
|
||||
`package-lock.json`. Overrides в
|
||||
`package.json` закрывают advisories транзитивных `multer`, `deepmerge-ts` и
|
||||
`mysql2`; удаляйте их, когда родительские пакеты примут исправленные
|
||||
версии. Совместимость Prisma override проверяется генерацией и SQLite migrations.
|
||||
Required checks настраиваются отдельно в Gitea: наличие workflow само по себе
|
||||
не блокирует merge.
|
||||
|
||||
Общий `tsconfig.json` включает `strict: true` и `noFallthroughCasesInSwitch`
|
||||
для приложения, тестов и сборок. ESLint запрещает non-null assertions
|
||||
(`value!`) и definite-assignment assertions (`field!: Type`).
|
||||
Поля DTO, заполняемые вне конструктора, описываются через `declare`:
|
||||
входные значения заполняет и проверяет Nest, ответные DTO описывают форму
|
||||
возвращаемых объектов. `declare` не создаёт значение и не заменяет
|
||||
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)
|
||||
- [Backend architecture](.ai/conventions/backend-architecture.md)
|
||||
- [HTTP API](.ai/conventions/backend-http-api.md), [data](.ai/conventions/backend-data.md) и [testing](.ai/conventions/backend-testing.md)
|
||||
- [Security/auth](.ai/conventions/security-auth.md) и [integrations](.ai/conventions/backend-integrations.md)
|
||||
- [AGENTS: правила и источники](AGENTS.md)
|
||||
- [Backend architecture](.ai/architecture.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/integrations.md)
|
||||
|
||||
- [Backend workflow](../docs/development/backend-workflow.md)
|
||||
- [Engineering governance и risk-based testing](../docs/development/engineering-governance.md)
|
||||
- [Карта проектных фактов](../docs/AI_INDEX.md)
|
||||
- [API contract и runtime OpenAPI](../docs/development/runtime-openapi-verification.md)
|
||||
- [Обзор поставленной системы](../docs/architecture/system-overview.md)
|
||||
- [Архитектурные решения](../docs/adr/README.md)
|
||||
- [Remediation registry](../docs/development/audit-remediation.md)
|
||||
- [Генерация OpenAPI](#генерация-openapi)
|
||||
|
||||
Из backend runtime OpenAPI можно read-only сверить с frontend snapshot:
|
||||
Генерация без сервера сверяется с OpenAPI настроенного приложения:
|
||||
|
||||
```bash
|
||||
npm run test:openapi-contract
|
||||
```
|
||||
|
||||
Разработка идёт напрямую по задаче пользователя. Каждый affected repo проходит
|
||||
свой verify; при изменении wire contract дополнительно выполняется contract check.
|
||||
Обязательного OpenSpec/SDD lifecycle нет.
|
||||
## Генерация OpenAPI
|
||||
|
||||
```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 и сохранность данных
|
||||
|
||||
После `nvm use --silent` запускайте `npm run test:sqlite` при изменении auth,
|
||||
карточных операций или DB boundary, `npm run test:e2e` — для всех HTTP suites.
|
||||
Обе команды используют один runner: отдельный каталог `wealthledger-e2e-*`
|
||||
в системном temp на каждую suite, один `DATABASE_URL` для migrations и Jest.
|
||||
Внешний `DATABASE_URL` заменяется временным; `.env` и пользовательские данные
|
||||
не изменяются. Прямой Jest без runner отклоняется до обращения к базе.
|
||||
При ошибке migration/Jest или SIGINT/SIGTERM runner останавливает дочерние
|
||||
процессы, удаляет свой каталог и возвращает ненулевой exit code.
|
||||
Нужен доступ к локальным HTTP-портам; sandbox может его запрещать.
|
||||
|
||||
`prisma.config.ts` удаляет унаследованный `RUST_LOG` только в процессе Prisma CLI:
|
||||
на Prisma 7.9.1 уровни `warn`/`error` вызывают пустой `Schema engine error`
|
||||
при fresh migration, без переменной миграция проходит. Постоянный
|
||||
`RUST_LOG=info` и обновление зависимостей не нужны. Logging приложения
|
||||
настраивается отдельно. История migrations применяется штатным `migrate deploy`.
|
||||
|
||||
Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет
|
||||
три последовательных и два параллельных полных e2e, проверяет уникальность DB,
|
||||
сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup.
|
||||
Логи остаются в каталоге из вывода. Для одной suite:
|
||||
`node test/sqlite.cjs app.e2e-spec.ts`.
|
||||
|
||||
| Риск | Проверка реальной DB |
|
||||
| --- | --- |
|
||||
| Чужой пользователь | `bank-cards.e2e-spec.ts`: owner каждой карты, категории, покупки и стороны перевода |
|
||||
| Повтор и конкурентный update | Те же tests: fingerprint, tombstone, версии, один refund в пределах покупки, archive/write |
|
||||
| Конкурентный refresh | `app.e2e-spec.ts`: оба запроса читают прежний hash до CAS; один 200, один 401, cookie победителя работает после replay |
|
||||
| Rollback и деньги | Карточные tests: откат source lock, атомарный перевод, точные суммы выше 2^53 и границы signed64 |
|
||||
| Migration, restart, backup/restore | `test:durability`: прежнее приложение, новая schema, история, откат приложения и восстановление backup |
|
||||
|
||||
Prisma в этих suites не заменяется mocks. В refresh тесте барьер задерживает
|
||||
настоящую подпись JWT, чтобы оба запроса дошли до реальной конкурентной записи.
|
||||
Unit tests с mocks отдельно проверяют orchestration и не доказывают DB concurrency.
|
||||
|
||||
При изменении schema/migrations или сохранения данных дополнительно выполните
|
||||
`npm run test:durability -- /absolute/path/to/baseline`. Он не входит в lint/verify.
|
||||
Baseline — отдельная сборка до проверяемых миграций с неизменённым префиксом
|
||||
migration history. Для текущей карточной схемы подходит revision
|
||||
`8ec172f48eefa2237dc0c16919fbb2e726b39a65` (до добавления карт):
|
||||
|
||||
```bash
|
||||
# Из backend; экспорт не содержит .env или локальную базу.
|
||||
baseline_dir=$(mktemp -d /tmp/wealthledger-baseline-XXXXXX)
|
||||
git archive 8ec172f48eefa2237dc0c16919fbb2e726b39a65 | tar -x -C "$baseline_dir"
|
||||
(
|
||||
cd "$baseline_dir"
|
||||
nvm use --silent
|
||||
npm ci
|
||||
npm run build
|
||||
)
|
||||
nvm use --silent
|
||||
npm run build
|
||||
npm run test:durability -- "$baseline_dir"
|
||||
```
|
||||
|
||||
Не подменяйте baseline текущей сборкой: runner отвергает тот же путь,
|
||||
отсутствующую сборку и неподходящую migration history. Для следующей schema
|
||||
выберите совместимую предыдущую revision и проверьте её API-сценарии runner.
|
||||
Durability оставляет временные DB, логи и `result.json` по пути из вывода для
|
||||
диагностики; он не откатывает schema пользовательской базы.
|
||||
|
||||
## Релизы
|
||||
|
||||
Репозиторий выпускается независимо. `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 не включены.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
extends: ["@commitlint/config-conventional"],
|
||||
rules: { "type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]] },
|
||||
};
|
||||
+52
-1
@@ -26,10 +26,61 @@ export default tseslint.config(
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'no-magic-numbers': 'off',
|
||||
'@typescript-eslint/no-magic-numbers': [
|
||||
'error',
|
||||
{
|
||||
ignore: [
|
||||
-60,
|
||||
-24,
|
||||
-10,
|
||||
-2,
|
||||
-1,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
10,
|
||||
24,
|
||||
60,
|
||||
'-60n',
|
||||
'-24n',
|
||||
'-10n',
|
||||
'-2n',
|
||||
'-1n',
|
||||
'0n',
|
||||
'1n',
|
||||
'2n',
|
||||
'10n',
|
||||
'24n',
|
||||
'60n',
|
||||
],
|
||||
ignoreArrayIndexes: true,
|
||||
ignoreDefaultValues: true,
|
||||
ignoreEnums: true,
|
||||
ignoreNumericLiteralTypes: true,
|
||||
ignoreReadonlyClassProperties: true,
|
||||
ignoreTypeIndexes: true,
|
||||
enforceConst: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector:
|
||||
'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
|
||||
message:
|
||||
'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
'prettier/prettier': ['error', { endOfLine: 'auto' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts', 'test/**/*.ts'],
|
||||
rules: { '@typescript-eslint/no-magic-numbers': 'off' },
|
||||
},
|
||||
);
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{ "include": "tbank/proto/contracts/**/*.proto", "watchAssets": true }
|
||||
{ "include": "integrations/tbank/proto/contracts/**/*.proto", "watchAssets": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4729
-2167
File diff suppressed because it is too large
Load Diff
+90
-61
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "wealth-ledger-backend",
|
||||
"version": "0.0.1",
|
||||
"version": "0.2.3",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"engines": {
|
||||
"node": "22.x"
|
||||
"node": "v24.21.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "prisma generate && nest build",
|
||||
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
||||
@@ -20,71 +21,89 @@
|
||||
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"typecheck:app": "tsc --project tsconfig.typecheck.app.json",
|
||||
"typecheck:test": "tsc --project tsconfig.typecheck.test.json",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "DATABASE_URL=file:/tmp/wealth-ledger-e2e.db prisma migrate deploy && NODE_ENV=development NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json",
|
||||
"test:openapi-contract": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json --runInBand openapi-contract.e2e-spec.ts",
|
||||
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm test -- --runInBand && npm run build"
|
||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
||||
"test:watch": "npm test -- --watch",
|
||||
"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:e2e": "npm run build && node test/sqlite.cjs --all",
|
||||
"test:sqlite": "node test/sqlite.cjs",
|
||||
"test:container": "node test/container-smoke.cjs",
|
||||
"test:durability": "node test/bank-cards-durability.cjs",
|
||||
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
|
||||
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build",
|
||||
"release:preview": "release-it --dry-run --ci",
|
||||
"release:prepare": "release-it --ci",
|
||||
"commitlint": "commitlint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.14.4",
|
||||
"@grpc/proto-loader": "^0.8.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "11.4.5",
|
||||
"@prisma/adapter-better-sqlite3": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.6.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"lru-cache": "^11.2.4",
|
||||
"nestjs-pino": "^4.6.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pino-http": "^11.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"@grpc/grpc-js": "1.14.4",
|
||||
"@grpc/proto-loader": "0.8.1",
|
||||
"@nestjs/common": "12.0.1",
|
||||
"@nestjs/config": "12.0.0",
|
||||
"@nestjs/core": "12.0.1",
|
||||
"@nestjs/jwt": "12.0.1",
|
||||
"@nestjs/passport": "12.0.0",
|
||||
"@nestjs/platform-express": "12.0.1",
|
||||
"@nestjs/swagger": "12.0.1",
|
||||
"@prisma/adapter-better-sqlite3": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"bcrypt": "6.0.0",
|
||||
"better-sqlite3": "13.0.3",
|
||||
"cheerio": "1.2.0",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.15.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"lru-cache": "11.5.2",
|
||||
"nestjs-pino": "5.1.0",
|
||||
"passport": "0.7.0",
|
||||
"passport-jwt": "4.0.1",
|
||||
"pino-http": "11.0.0",
|
||||
"prisma": "7.10.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^7.9.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
"@commitlint/cli": "21.2.2",
|
||||
"@commitlint/config-conventional": "21.2.2",
|
||||
"@eslint/eslintrc": "3.3.7",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@nestjs/cli": "12.0.0",
|
||||
"@nestjs/schematics": "12.0.1",
|
||||
"@nestjs/testing": "12.0.1",
|
||||
"@release-it/conventional-changelog": "12.0.0",
|
||||
"@types/bcrypt": "6.0.0",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/node": "24.13.4",
|
||||
"@types/passport-jwt": "4.0.1",
|
||||
"@types/supertest": "7.2.1",
|
||||
"conventional-changelog-conventionalcommits": "10.4.0",
|
||||
"eslint": "10.10.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-prettier": "5.5.6",
|
||||
"globals": "17.12.0",
|
||||
"jest": "30.5.1",
|
||||
"prettier": "3.9.6",
|
||||
"release-it": "21.0.2",
|
||||
"source-map-support": "0.5.21",
|
||||
"supertest": "7.2.2",
|
||||
"ts-jest": "29.4.12",
|
||||
"ts-loader": "9.6.2",
|
||||
"ts-node": "10.9.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.70.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@nestjs/swagger@11.4.5": {
|
||||
"js-yaml": "5.2.3"
|
||||
"@nestjs/platform-express": {
|
||||
"multer": "2.3.0"
|
||||
},
|
||||
"@prisma/config@7.10.0": {
|
||||
"deepmerge-ts": "8.0.2"
|
||||
},
|
||||
"prisma": {
|
||||
"mysql2": "3.24.4"
|
||||
}
|
||||
},
|
||||
"jest": {
|
||||
@@ -103,5 +122,15 @@
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@prisma/engines@7.10.0": true,
|
||||
"prisma@7.10.0": true,
|
||||
"bcrypt@6.0.0": true,
|
||||
"better-sqlite3@12.11.1": true,
|
||||
"fsevents@2.3.3": true,
|
||||
"protobufjs@7.6.5": true,
|
||||
"unrs-resolver@1.12.2": true,
|
||||
"better-sqlite3@13.0.3": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'dotenv/config';
|
||||
import { defineConfig } from 'prisma/config';
|
||||
|
||||
// Prisma 7.9.1 fresh SQLite migration fails with inherited RUST_LOG=warn/error.
|
||||
// Use the engine default; application logging is configured separately.
|
||||
delete process.env.RUST_LOG;
|
||||
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
datasource: {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "BankCard" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"bankName" TEXT,
|
||||
"lastFour" TEXT,
|
||||
"openingBalanceKopecks" BIGINT NOT NULL,
|
||||
"openingDate" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"archivedAt" DATETIME,
|
||||
"createFingerprint" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "BankCard_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ExpenseCategory" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameKey" TEXT NOT NULL,
|
||||
"seedKey" TEXT,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
CONSTRAINT "ExpenseCategory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BankCardOperation" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"cardId" TEXT NOT NULL,
|
||||
"targetCardId" TEXT,
|
||||
"categoryId" TEXT,
|
||||
"originalExpenseId" TEXT,
|
||||
"amountKopecks" BIGINT NOT NULL,
|
||||
"date" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"deletedAt" DATETIME,
|
||||
"createFingerprint" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "BankCardOperation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "BankCardOperation_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "BankCard" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "BankCardOperation_targetCardId_fkey" FOREIGN KEY ("targetCardId") REFERENCES "BankCard" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "BankCardOperation_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "ExpenseCategory" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "BankCardOperation_originalExpenseId_fkey" FOREIGN KEY ("originalExpenseId") REFERENCES "BankCardOperation" ("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BankCard_userId_idx" ON "BankCard"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ExpenseCategory_userId_nameKey_key" ON "ExpenseCategory"("userId", "nameKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ExpenseCategory_userId_seedKey_key" ON "ExpenseCategory"("userId", "seedKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BankCardOperation_userId_cardId_date_idx" ON "BankCardOperation"("userId", "cardId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BankCardOperation_userId_targetCardId_date_idx" ON "BankCardOperation"("userId", "targetCardId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BankCardOperation_originalExpenseId_idx" ON "BankCardOperation"("originalExpenseId");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "BankCard" ADD COLUMN "paymentSystem" TEXT CHECK ("paymentSystem" IN ('mir', 'visa', 'mastercard'));
|
||||
@@ -12,6 +12,9 @@ model User {
|
||||
id String @id
|
||||
email String @unique
|
||||
passwordHash String
|
||||
bankCards BankCard[]
|
||||
expenseCategories ExpenseCategory[]
|
||||
bankCardOperations BankCardOperation[]
|
||||
sessions Session[]
|
||||
tbankConnection TbankConnection?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -37,3 +40,61 @@ model Session {
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model BankCard {
|
||||
id String @id
|
||||
userId String
|
||||
name String
|
||||
bankName String?
|
||||
lastFour String?
|
||||
paymentSystem String?
|
||||
openingBalanceKopecks BigInt
|
||||
openingDate String
|
||||
version Int @default(1)
|
||||
archivedAt DateTime?
|
||||
createFingerprint String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
operations BankCardOperation[] @relation("SourceCard")
|
||||
incomingOperations BankCardOperation[] @relation("TargetCard")
|
||||
@@index([userId])
|
||||
}
|
||||
model ExpenseCategory {
|
||||
id String @id
|
||||
userId String
|
||||
name String
|
||||
nameKey String
|
||||
seedKey String?
|
||||
version Int @default(1)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
operations BankCardOperation[]
|
||||
@@unique([userId, nameKey])
|
||||
@@unique([userId, seedKey])
|
||||
}
|
||||
model BankCardOperation {
|
||||
id String @id
|
||||
userId String
|
||||
kind String
|
||||
cardId String
|
||||
targetCardId String?
|
||||
categoryId String?
|
||||
originalExpenseId String?
|
||||
amountKopecks BigInt
|
||||
date String
|
||||
description String?
|
||||
version Int @default(1)
|
||||
deletedAt DateTime?
|
||||
createFingerprint String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
card BankCard @relation("SourceCard", fields: [cardId], references: [id], onDelete: Restrict)
|
||||
targetCard BankCard? @relation("TargetCard", fields: [targetCardId], references: [id], onDelete: Restrict)
|
||||
category ExpenseCategory? @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||
originalExpense BankCardOperation? @relation("RefundExpense", fields: [originalExpenseId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
refunds BankCardOperation[] @relation("RefundExpense")
|
||||
@@index([userId, cardId, date])
|
||||
@@index([userId, targetCardId, date])
|
||||
@@index([originalExpenseId])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":semanticCommitTypeAll(chore)"
|
||||
],
|
||||
"timezone": "Europe/Moscow",
|
||||
"labels": [
|
||||
"dependencies"
|
||||
],
|
||||
"dependencyDashboard": true,
|
||||
"rangeStrategy": "pin",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": [
|
||||
"minor",
|
||||
"patch"
|
||||
],
|
||||
"groupName": "non-major dependencies"
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": [
|
||||
"major"
|
||||
],
|
||||
"dependencyDashboardApproval": true
|
||||
}
|
||||
],
|
||||
"baseBranchPatterns": [
|
||||
"develop"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { version } = require("../package.json");
|
||||
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
|
||||
|
||||
assert.equal(git("status", "--porcelain"), "", "Commit all changes before preparing a release");
|
||||
const tags = git("tag", "--merged", "HEAD", "--list", "v[0-9]*", "--sort=-version:refname")
|
||||
.split("\n").filter(tag => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||
assert.equal(version, tags[0]?.slice(1) ?? "0.0.0",
|
||||
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.");
|
||||
@@ -0,0 +1,40 @@
|
||||
const { randomBytes } = require('node:crypto');
|
||||
const { writeFile } = require('node:fs/promises');
|
||||
|
||||
// Schema generation needs no deployment configuration or real credentials.
|
||||
Object.assign(process.env, {
|
||||
DATABASE_URL: 'file::memory:',
|
||||
FRONTEND_ORIGIN: 'http://localhost',
|
||||
JWT_SECRET: randomBytes(32).toString('hex'),
|
||||
TOKEN_ENCRYPTION_KEY: randomBytes(32).toString('base64'),
|
||||
PORT: '3000',
|
||||
LOG_LEVEL: 'error',
|
||||
TBANK_GRPC_ENDPOINT: 'localhost:443',
|
||||
TBANK_RATE_LIMIT_PER_SECOND: '5',
|
||||
TBANK_CA_CERT_PATH: '',
|
||||
});
|
||||
|
||||
const { NestFactory } = require('@nestjs/core');
|
||||
const { AppModule } = require('../dist/src/app.module.js');
|
||||
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
|
||||
|
||||
async function generate() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
preview: true,
|
||||
logger: false,
|
||||
});
|
||||
try {
|
||||
app.setGlobalPrefix(API_PREFIX);
|
||||
await writeFile(
|
||||
'openapi.json',
|
||||
JSON.stringify(createOpenApiDocument(app), null, 2) + '\n',
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
generate().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
sonar.projectKey=wealthledger-backend
|
||||
sonar.projectName=WealthLedger backend
|
||||
sonar.host.url=https://sonarqube.personal.ksv741.ru
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
sonar.sources=src
|
||||
sonar.tests=src,test
|
||||
sonar.test.inclusions=src/**/*.spec.ts,test/**/*.e2e-spec.ts
|
||||
sonar.exclusions=src/**/*.spec.ts,src/integrations/tbank/proto/contracts/**
|
||||
sonar.javascript.lcov.reportPaths=coverage/lcov.info
|
||||
# SonarQube 9.9 embeds an older TypeScript analyzer than the application compiler.
|
||||
sonar.typescript.tsconfigPaths=tsconfig.sonar.json
|
||||
+17
-3
@@ -1,12 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { LoggingModule } from './common/logging/logging.module';
|
||||
import { Environment, validateEnv } from './config/env.validation';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
|
||||
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
|
||||
import { IncomeModule } from './modules/investments/income/income.module';
|
||||
import { InstrumentsModule } from './modules/investments/instruments/instruments.module';
|
||||
import { OperationsModule } from './modules/investments/operations/operations.module';
|
||||
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
|
||||
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
|
||||
import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { TbankConnectionModule } from './tbank/tbank-connection.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -49,6 +56,13 @@ import { TbankConnectionModule } from './tbank/tbank-connection.module';
|
||||
LoggingModule,
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
BankCardsModule,
|
||||
BrokerageAccountsModule,
|
||||
PortfolioModule,
|
||||
OperationsModule,
|
||||
IncomeModule,
|
||||
UpcomingEventsModule,
|
||||
InstrumentsModule,
|
||||
TbankConnectionModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
const NANO = 1_000_000_000n;
|
||||
|
||||
export type ProtoDecimal = { units?: string | number; nano?: string | number };
|
||||
export type Decimal = { value: bigint; scale: number };
|
||||
|
||||
export function decimalFromString(value: string): Decimal {
|
||||
@@ -14,13 +11,6 @@ export function decimalFromString(value: string): Decimal {
|
||||
};
|
||||
}
|
||||
|
||||
export function decimal(value: ProtoDecimal): Decimal {
|
||||
return {
|
||||
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0),
|
||||
scale: 9,
|
||||
};
|
||||
}
|
||||
|
||||
export function add(left: Decimal, right: Decimal): Decimal {
|
||||
const scale = Math.max(left.scale, right.scale);
|
||||
return {
|
||||
@@ -59,3 +49,14 @@ export function formatDecimal({ value, scale }: Decimal): string {
|
||||
const fraction = digits.slice(-scale).replace(/0+$/, '');
|
||||
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
|
||||
}
|
||||
|
||||
// Round half away from zero; callers choose presentation precision explicitly.
|
||||
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
|
||||
if (right.value === 0n) throw new Error('Division by zero');
|
||||
const numerator = left.value * 10n ** BigInt(right.scale + scale);
|
||||
const denominator = right.value * 10n ** BigInt(left.scale);
|
||||
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n;
|
||||
const n = numerator < 0n ? -numerator : numerator;
|
||||
const d = denominator < 0n ? -denominator : denominator;
|
||||
return { value: sign * ((n + d / 2n) / d), scale };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { HttpStatus, applyDecorators } from '@nestjs/common';
|
||||
import {
|
||||
ApiExtraModels,
|
||||
ApiProperty,
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
|
||||
export class ProblemDetailsDto {
|
||||
@ApiProperty({ example: 401 })
|
||||
status: number;
|
||||
declare status: number;
|
||||
|
||||
@ApiProperty({ example: 'UNAUTHORIZED' })
|
||||
code: string;
|
||||
declare code: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
@@ -35,10 +35,13 @@ function problemResponse(status: number, description: string) {
|
||||
export function ApiProblemResponses(): MethodDecorator & ClassDecorator {
|
||||
return applyDecorators(
|
||||
ApiExtraModels(ProblemDetailsDto),
|
||||
problemResponse(400, 'Invalid request'),
|
||||
problemResponse(401, 'Authentication required'),
|
||||
problemResponse(404, 'Resource not found'),
|
||||
problemResponse(503, 'Upstream service unavailable'),
|
||||
problemResponse(500, 'Internal server error'),
|
||||
problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
|
||||
problemResponse(HttpStatus.UNAUTHORIZED, 'Authentication required'),
|
||||
problemResponse(HttpStatus.NOT_FOUND, 'Resource not found'),
|
||||
problemResponse(
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
'Upstream service unavailable',
|
||||
),
|
||||
problemResponse(HttpStatus.INTERNAL_SERVER_ERROR, 'Internal server error'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
|
||||
import {
|
||||
CreateBankCardDto,
|
||||
UpdateBankCardDto,
|
||||
} from '../modules/banking/bank-cards/presentation/bank-card.dto';
|
||||
|
||||
describe('DTO validation', () => {
|
||||
const pipe = new ValidationPipe({ transform: true, whitelist: true });
|
||||
|
||||
it('requires credentials and normalizes a valid email', async () => {
|
||||
const metadata = { type: 'body' as const, metatype: CredentialsDto };
|
||||
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(
|
||||
pipe.transform({ email: 'user@example.com' }, metadata),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
pipe.transform(
|
||||
{ email: ' User@Example.com ', password: 'long-enough-password' },
|
||||
metadata,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
email: 'user@example.com',
|
||||
password: 'long-enough-password',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires and validates nested money in a new card', async () => {
|
||||
const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
|
||||
const card = {
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
name: 'Everyday',
|
||||
openingDate: '2026-09-13',
|
||||
};
|
||||
for (const openingBalance of [undefined, null, {}, { currency: 'RUB' }]) {
|
||||
await expect(
|
||||
pipe.transform({ ...card, openingBalance }, metadata),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
}
|
||||
const valid = {
|
||||
...card,
|
||||
openingBalance: { currency: 'RUB', value: '100.00' },
|
||||
};
|
||||
await expect(pipe.transform(valid, metadata)).resolves.toEqual(valid);
|
||||
});
|
||||
|
||||
it('keeps update fields optional while requiring version and rejecting null', async () => {
|
||||
const metadata = { type: 'body' as const, metatype: UpdateBankCardDto };
|
||||
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(
|
||||
pipe.transform({ version: 1, name: null }, metadata),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
pipe.transform({ version: 1 }, metadata),
|
||||
).resolves.toMatchObject({ version: 1 });
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { constants as httpStatus } from 'node:http2';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -46,11 +47,11 @@ export class RequestLoggingMiddleware implements NestMiddleware {
|
||||
: {}),
|
||||
};
|
||||
const level =
|
||||
response.statusCode >= 500
|
||||
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||
? 'error'
|
||||
: response.statusCode === 429 ||
|
||||
response.statusCode === 401 ||
|
||||
response.statusCode === 403
|
||||
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
|
||||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
|
||||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
|
||||
? 'warn'
|
||||
: 'info';
|
||||
this.logger.event(level, 'http.request.completed', context);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Money } from './money.types';
|
||||
|
||||
export class MoneyDto implements Money {
|
||||
@ApiProperty()
|
||||
declare currency: string;
|
||||
|
||||
@ApiProperty()
|
||||
declare value: string;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type Money = { currency: string; value: string };
|
||||
@@ -0,0 +1,2 @@
|
||||
export const MILLISECONDS_PER_SECOND = 1_000;
|
||||
export const MILLISECONDS_PER_DAY = 86_400_000;
|
||||
@@ -1,3 +1,8 @@
|
||||
const DEFAULT_PORT = 3000;
|
||||
const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5;
|
||||
const MAX_PORT = 65_535;
|
||||
const MIN_JWT_SECRET_LENGTH = 32;
|
||||
const TOKEN_KEY_BYTES = 32;
|
||||
export interface Environment {
|
||||
DATABASE_URL: string;
|
||||
FRONTEND_ORIGIN: string;
|
||||
@@ -13,12 +18,14 @@ export interface Environment {
|
||||
export function validateEnv(config: Record<string, unknown>): Environment {
|
||||
const databaseUrl = config['DATABASE_URL'];
|
||||
const frontendOrigin = config['FRONTEND_ORIGIN'];
|
||||
const port = config['PORT'] ?? 3000;
|
||||
const port = config['PORT'] ?? DEFAULT_PORT;
|
||||
const jwtSecret = config['JWT_SECRET'];
|
||||
const tokenEncryptionKey = config['TOKEN_ENCRYPTION_KEY'];
|
||||
const tbankEndpoint =
|
||||
config['TBANK_GRPC_ENDPOINT'] ?? 'invest-public-api.tbank.ru:443';
|
||||
const tbankRateLimit = config['TBANK_RATE_LIMIT_PER_SECOND'] ?? 5;
|
||||
const tbankRateLimit =
|
||||
config['TBANK_RATE_LIMIT_PER_SECOND'] ??
|
||||
DEFAULT_TBANK_RATE_LIMIT_PER_SECOND;
|
||||
const tbankCaCertPath = config['TBANK_CA_CERT_PATH'];
|
||||
const logLevel = config['LOG_LEVEL'] ?? 'info';
|
||||
|
||||
@@ -47,18 +54,25 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
||||
}
|
||||
|
||||
const parsedPort = Number(port);
|
||||
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
||||
if (
|
||||
!Number.isInteger(parsedPort) ||
|
||||
parsedPort < 1 ||
|
||||
parsedPort > MAX_PORT
|
||||
) {
|
||||
throw new Error('PORT must be a valid port number');
|
||||
}
|
||||
|
||||
if (typeof jwtSecret !== 'string' || jwtSecret.length < 32) {
|
||||
if (
|
||||
typeof jwtSecret !== 'string' ||
|
||||
jwtSecret.length < MIN_JWT_SECRET_LENGTH
|
||||
) {
|
||||
throw new Error('JWT_SECRET must be at least 32 characters long');
|
||||
}
|
||||
|
||||
if (
|
||||
typeof tokenEncryptionKey !== 'string' ||
|
||||
!/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) ||
|
||||
Buffer.from(tokenEncryptionKey, 'base64').length !== 32
|
||||
Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES
|
||||
) {
|
||||
throw new Error(
|
||||
'TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key',
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { type MoexBondEvent, MoexClient } from './moex.client';
|
||||
@@ -0,0 +1,108 @@
|
||||
import { MoexClient } from './moex.client';
|
||||
|
||||
describe('MoexClient', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
securities: {
|
||||
columns: ['SECID', 'ISIN', 'BOARDID'],
|
||||
data: [['BOND', 'RU000TEST', 'TQOB']],
|
||||
},
|
||||
marketdata_yields: {
|
||||
columns: [
|
||||
'SECID',
|
||||
'BOARDID',
|
||||
'EFFECTIVEYIELD',
|
||||
'PRICE',
|
||||
'YIELDDATE',
|
||||
'YIELDDATETYPE',
|
||||
'TRADEMOMENT',
|
||||
],
|
||||
data: [
|
||||
[
|
||||
'BOND',
|
||||
'OTHER',
|
||||
99,
|
||||
20,
|
||||
'2028-12-01',
|
||||
'OFFERDATE',
|
||||
'2026-09-10 18:49:00',
|
||||
],
|
||||
[
|
||||
'BOND',
|
||||
'TQOB',
|
||||
-0.5,
|
||||
101,
|
||||
'2028-12-01',
|
||||
'OFFERDATE',
|
||||
'2026-09-10 18:49:00',
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
const client = new MoexClient();
|
||||
expect(await client.bondYield('BOND', 'RU000TEST', 'TQOB')).toEqual({
|
||||
value: '-0.5',
|
||||
pricePercent: '101',
|
||||
date: '2028-12-01',
|
||||
dateType: 'OFFERDATE',
|
||||
tradeAt: '2026-09-10T15:49:00.000Z',
|
||||
});
|
||||
expect(await client.bondYield('BOND', 'WRONG', 'TQOB')).toBeNull();
|
||||
expect(await client.bondYield('BOND', 'RU000TEST', 'OTHER')).toBeNull();
|
||||
});
|
||||
it.each([null, 0])(
|
||||
'does not report unknown yield as zero: %s',
|
||||
async (value) => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
securities: {
|
||||
columns: ['SECID', 'ISIN', 'BOARDID'],
|
||||
data: [['BOND', 'ISIN', 'TQOB']],
|
||||
},
|
||||
marketdata_yields: {
|
||||
columns: [
|
||||
'SECID',
|
||||
'BOARDID',
|
||||
'EFFECTIVEYIELD',
|
||||
'PRICE',
|
||||
'YIELDDATE',
|
||||
'TRADEMOMENT',
|
||||
],
|
||||
data: [
|
||||
[
|
||||
'BOND',
|
||||
'TQOB',
|
||||
value,
|
||||
100,
|
||||
'2030-01-01',
|
||||
'2026-09-10 12:00:00',
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
const result = await new MoexClient().bondYield('BOND', 'ISIN', 'TQOB');
|
||||
expect(result?.value ?? null).toBe(value === null ? null : '0');
|
||||
},
|
||||
);
|
||||
it('treats a successful response without the requested block as no events', async () => {
|
||||
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
description: { columns: ['name', 'value'], data: [] },
|
||||
boards: { columns: ['secid'], data: [] },
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]);
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { constants as httpStatus } from 'node:http2';
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
|
||||
const FALLBACK_PAGE_SIZE = 100;
|
||||
|
||||
const baseUrl = 'https://iss.moex.com/iss';
|
||||
const timeoutMs = 10_000;
|
||||
|
||||
@@ -10,9 +13,63 @@ export type MoexBondEvent =
|
||||
|
||||
type IssBlock = { columns?: unknown; data?: unknown };
|
||||
type IssResponse = Record<string, IssBlock | undefined>;
|
||||
export type MoexBondYield = {
|
||||
value: string;
|
||||
pricePercent: string;
|
||||
date: string;
|
||||
dateType: string | null;
|
||||
tradeAt: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MoexClient {
|
||||
async bondYield(
|
||||
secid: string,
|
||||
isin: string,
|
||||
board: string,
|
||||
): Promise<MoexBondYield | null> {
|
||||
const query = new URLSearchParams({
|
||||
'iss.meta': 'off',
|
||||
'iss.only': 'securities,marketdata_yields',
|
||||
});
|
||||
const response = await this.request(
|
||||
`${baseUrl}/engines/stock/markets/bonds/securities/${encodeURIComponent(secid)}.json?${query}`,
|
||||
);
|
||||
if (!response) return null;
|
||||
const listing = parseRows(response.securities).find(
|
||||
(row) =>
|
||||
row.secid === secid && row.isin === isin && row.boardid === board,
|
||||
);
|
||||
if (!listing) return null;
|
||||
const row = parseRows(response.marketdata_yields).find(
|
||||
(row) => row.secid === secid && row.boardid === board,
|
||||
);
|
||||
if (!row) return null;
|
||||
const value = numberText(row.effectiveyield);
|
||||
const pricePercent = numberText(row.price);
|
||||
const date = text(row.yielddate);
|
||||
const tradeAt = text(row.trademoment);
|
||||
if (
|
||||
value === null ||
|
||||
pricePercent === null ||
|
||||
Number(pricePercent) <= 0 ||
|
||||
!date ||
|
||||
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
||||
!Number.isFinite(Date.parse(date)) ||
|
||||
!tradeAt ||
|
||||
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt)
|
||||
)
|
||||
return null;
|
||||
const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`;
|
||||
if (!Number.isFinite(Date.parse(tradeIso))) return null;
|
||||
return {
|
||||
value,
|
||||
pricePercent,
|
||||
date,
|
||||
dateType: text(row.yielddatetype),
|
||||
tradeAt: new Date(tradeIso).toISOString(),
|
||||
};
|
||||
}
|
||||
async bondEvents(secid: string): Promise<MoexBondEvent[]> {
|
||||
const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
|
||||
const [coupons, offers, description] = await Promise.all([
|
||||
@@ -67,8 +124,8 @@ export class MoexClient {
|
||||
if (!paginated) return rows;
|
||||
const cursorBlock = response[`${block}.cursor`];
|
||||
if (!cursorBlock) {
|
||||
if (current.length < 100) return rows;
|
||||
start += 100;
|
||||
if (current.length < FALLBACK_PAGE_SIZE) return rows;
|
||||
start += FALLBACK_PAGE_SIZE;
|
||||
continue;
|
||||
}
|
||||
const cursor = parseRows(cursorBlock)[0];
|
||||
@@ -91,7 +148,7 @@ export class MoexClient {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
|
||||
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
|
||||
const body: unknown = await response.json();
|
||||
if (!body || typeof body !== 'object')
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexClient } from './moex.client';
|
||||
|
||||
@Module({
|
||||
providers: [MoexClient],
|
||||
exports: [MoexClient],
|
||||
})
|
||||
export class MoexModule {}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { NcrRatingsClient } from './ncr-ratings.client';
|
||||
const isin = 'RU000A10EW44';
|
||||
const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') =>
|
||||
`<tr><td>Эмитент</td><td>Облигация</td><td>${value}</td><td><a href="${href}">${code}</a></td><td>REG</td><td>17.04.2026</td></tr>`;
|
||||
const table = (rows: string) =>
|
||||
`<table id="issues-table"><thead><tr><th>Лицо</th><th>Выпуск</th><th>Рейтинг</th><th>ISIN</th><th>Регномер</th><th>Дата</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
const response = (html: string) =>
|
||||
({ ok: true, text: () => Promise.resolve(html) }) as Response;
|
||||
const cacheClock = performance;
|
||||
describe('public NCR issue ratings', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z'));
|
||||
jest.spyOn(cacheClock, 'now').mockImplementation(() => Date.now());
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
it('matches only exact ISIN and shares one public registry download', async () => {
|
||||
const fetchMock = jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockResolvedValue(response(table(row())));
|
||||
const client = new NcrRatingsClient();
|
||||
const [rating, missing] = await Promise.all([
|
||||
client.rating(isin),
|
||||
client.rating('RU000A1065B1'),
|
||||
]);
|
||||
expect(rating).toEqual({
|
||||
agency: 'NCR',
|
||||
value: 'AAA.ru',
|
||||
status: 'active',
|
||||
date: '2026-04-17',
|
||||
sourceUrl: 'https://ratings.ru/ratings/issues/123/',
|
||||
observedAt: '2026-09-10T10:00:00.000Z',
|
||||
});
|
||||
expect(missing).toBeNull();
|
||||
expect(await client.rating('issuer-name')).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(6 * 60 * 60_000 + 1);
|
||||
await client.rating(isin);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('preserves withdrawal and never displays an issuer rating as an issue rating', async () => {
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockResolvedValue(response(table(row('рейтинг отозван'))));
|
||||
expect(await new NcrRatingsClient().rating(isin)).toMatchObject({
|
||||
status: 'withdrawn',
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
'<html>Service unavailable</html>',
|
||||
table(row('AAA.ru', isin, 'https://untrusted.test/')),
|
||||
table(row('unrecognized')),
|
||||
table(row() + row('AA.ru')),
|
||||
table(''),
|
||||
])(
|
||||
'reports changed or ambiguous registry as unavailable, not unrated',
|
||||
async (html) => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue(response(html));
|
||||
await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
},
|
||||
);
|
||||
it('bounds outage retries then recovers without retaining a false missing rating', async () => {
|
||||
const fetchMock = jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValue(response(table(row())));
|
||||
const client = new NcrRatingsClient();
|
||||
await expect(client.rating(isin)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
await expect(client.rating(isin)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(60_001);
|
||||
expect(await client.rating(isin)).toMatchObject({ value: 'AAA.ru' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { load } from 'cheerio';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
const REGISTRY_CACHE_TTL_MS = 21_600_000;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const COLUMN_COUNT = 6;
|
||||
const ISIN_COLUMN = 3;
|
||||
const DATE_COLUMN = 5;
|
||||
|
||||
const registryUrl = 'https://ratings.ru/ratings/issues/';
|
||||
export type NcrRating = {
|
||||
agency: 'NCR';
|
||||
value: string | null;
|
||||
status: 'active' | 'withdrawn';
|
||||
date: string;
|
||||
sourceUrl: string;
|
||||
observedAt: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NcrRatingsClient {
|
||||
// Public registry contains no user data. Share one download across all issues.
|
||||
private readonly cache = new LRUCache<
|
||||
string,
|
||||
Promise<Map<string, NcrRating>>
|
||||
>({ max: 1, ttl: REGISTRY_CACHE_TTL_MS });
|
||||
|
||||
async rating(isin: string): Promise<NcrRating | null> {
|
||||
const key = isin.trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(key)) return null;
|
||||
let pending = this.cache.get('registry');
|
||||
if (!pending) {
|
||||
pending = this.registry().catch(() => {
|
||||
// Bound outage traffic too; never present a failed download as no rating.
|
||||
if (this.cache.get('registry') === pending)
|
||||
this.cache.set('registry', pending, { ttl: 60_000 });
|
||||
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' });
|
||||
});
|
||||
this.cache.set('registry', pending);
|
||||
}
|
||||
return (await pending).get(key) ?? null;
|
||||
}
|
||||
|
||||
private async registry(): Promise<Map<string, NcrRating>> {
|
||||
const response = await fetch(registryUrl, {
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) throw new Error('NCR unavailable');
|
||||
const $ = load(await response.text());
|
||||
const table = $('#issues-table');
|
||||
const headings = table
|
||||
.find('thead th')
|
||||
.map((_, cell) => $(cell).text().trim())
|
||||
.get();
|
||||
if (
|
||||
table.length !== 1 ||
|
||||
headings.length !== COLUMN_COUNT ||
|
||||
headings[2] !== 'Рейтинг' ||
|
||||
headings[ISIN_COLUMN] !== 'ISIN' ||
|
||||
headings[DATE_COLUMN] !== 'Дата'
|
||||
)
|
||||
throw new Error('NCR registry changed');
|
||||
const observedAt = new Date().toISOString();
|
||||
const result = new Map<string, NcrRating>();
|
||||
table.find('tbody tr').each((_, row) => {
|
||||
const cells = $(row).find('td');
|
||||
if (cells.length !== COLUMN_COUNT) throw new Error('NCR row changed');
|
||||
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
|
||||
// Unassigned issues cannot safely be matched to a held instrument.
|
||||
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
|
||||
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim();
|
||||
const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
|
||||
if (
|
||||
!withdrawn &&
|
||||
!/^(AAA|AA[+-]?|A[+-]?|BBB[+-]?|BB[+-]?|B[+-]?|CCC[+-]?|CC|C|D)\.ru$/.test(
|
||||
raw,
|
||||
)
|
||||
)
|
||||
throw new Error('NCR rating changed');
|
||||
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
|
||||
cells.eq(DATE_COLUMN).text().trim(),
|
||||
);
|
||||
const href = cells.eq(ISIN_COLUMN).find('a').attr('href');
|
||||
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href))
|
||||
throw new Error('NCR identity missing');
|
||||
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
|
||||
if (
|
||||
!Number.isFinite(Date.parse(date)) ||
|
||||
new Date(date).toISOString().slice(0, 10) !== date
|
||||
)
|
||||
throw new Error('NCR date invalid');
|
||||
const rating: NcrRating = {
|
||||
agency: 'NCR',
|
||||
value: withdrawn ? null : raw,
|
||||
status: withdrawn ? 'withdrawn' : 'active',
|
||||
date,
|
||||
sourceUrl: `https://ratings.ru${href}`,
|
||||
observedAt,
|
||||
};
|
||||
const previous = result.get(isin);
|
||||
if (
|
||||
previous &&
|
||||
previous.date === date &&
|
||||
(previous.value !== rating.value || previous.status !== rating.status)
|
||||
)
|
||||
throw new Error('NCR rating ambiguous');
|
||||
if (!previous || previous.date < date) result.set(isin, rating);
|
||||
});
|
||||
if (!result.size) throw new Error('NCR registry empty');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { NcrRatingsClient } from './ncr-ratings.client';
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NcrRatingsClient } from './ncr-ratings.client';
|
||||
|
||||
@Module({
|
||||
providers: [NcrRatingsClient],
|
||||
exports: [NcrRatingsClient],
|
||||
})
|
||||
export class NcrModule {}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { add, decimal, formatDecimal, multiply, subtract } from './decimal';
|
||||
import { add, formatDecimal, multiply, subtract } from '../../common/decimal';
|
||||
import { decimal } from './decimal';
|
||||
|
||||
describe('decimal', () => {
|
||||
it('keeps signed nanos exact without floats', () => {
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Decimal } from '../../common/decimal';
|
||||
|
||||
const NANO = 1_000_000_000n;
|
||||
|
||||
export type ProtoDecimal = { units?: string | number; nano?: string | number };
|
||||
|
||||
export function decimal(value: ProtoDecimal): Decimal {
|
||||
return {
|
||||
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0),
|
||||
scale: 9,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||
|
||||
const metadataForMs = 86_400_000;
|
||||
const maxCachedInstruments = 500;
|
||||
const missingInstrument = Symbol('missingInstrument');
|
||||
|
||||
@Injectable()
|
||||
export class InstrumentMetadataService {
|
||||
private readonly metadata = new LRUCache<
|
||||
string,
|
||||
TbankInstrument | typeof missingInstrument
|
||||
>({
|
||||
max: maxCachedInstruments,
|
||||
ttl: metadataForMs,
|
||||
noDeleteOnStaleGet: true,
|
||||
});
|
||||
private readonly pending = new Map<string, Promise<TbankInstrument | null>>();
|
||||
|
||||
constructor(
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
async resolve(
|
||||
token: string,
|
||||
instrumentUid: string | null | undefined,
|
||||
figi: string | null | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TbankInstrument | null> {
|
||||
const uid = instrumentUid?.trim();
|
||||
let byUid: TbankInstrument | null = null;
|
||||
if (uid) {
|
||||
byUid = await this.lookup(
|
||||
token,
|
||||
`uid:${uid}`,
|
||||
() => this.tbank.getInstrumentByUid(token, uid),
|
||||
signal,
|
||||
);
|
||||
if (byUid?.name?.trim() || byUid?.ticker?.trim()) return byUid;
|
||||
}
|
||||
const instrumentFigi = figi?.trim();
|
||||
const byFigi = instrumentFigi
|
||||
? await this.lookup(
|
||||
token,
|
||||
`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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private lastKnown(key: string): TbankInstrument | null {
|
||||
const value = this.metadata.peek(key, { allowStale: true });
|
||||
return value === missingInstrument ? null : (value ?? null);
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
key: string,
|
||||
request: () => Promise<TbankInstrument | null>,
|
||||
previous: TbankInstrument | null,
|
||||
): Promise<TbankInstrument | null> {
|
||||
try {
|
||||
const value = await request();
|
||||
const found = Boolean(value?.name?.trim() || value?.ticker?.trim());
|
||||
if (value === null || found)
|
||||
this.metadata.set(key, value ?? missingInstrument);
|
||||
this.logger.event('debug', 'instrument-metadata.resolved', {
|
||||
found,
|
||||
});
|
||||
return found || value === null ? value : (previous ?? value);
|
||||
} catch (error) {
|
||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||
code:
|
||||
error instanceof Error && 'response' in error
|
||||
? ((error as { response?: { code?: string } }).response?.code ??
|
||||
'UNKNOWN')
|
||||
: 'UNKNOWN',
|
||||
});
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
throw error;
|
||||
return error instanceof ServiceUnavailableException ? previous : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package tinkoff.public.invest.api.contract.v1;
|
||||
|
||||
option go_package = "./;investapi";
|
||||
option java_package = "ru.tinkoff.piapi.contract.v1";
|
||||
option java_multiple_files = true;
|
||||
option csharp_namespace = "Tinkoff.InvestApi.V1";
|
||||
option objc_class_prefix = "TIAPI";
|
||||
option php_namespace = "Tinkoff\\Invest\\V1";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "common.proto";
|
||||
import "google/api/field_behavior.proto";
|
||||
|
||||
service MarketDataService {//Сервис для получения биржевой информации:<br/> 1. Свечи.<br/> 2. Стаканы.<br/> 3. Торговые статусы.<br/> 4. Лента сделок.
|
||||
|
||||
//GetCandles — исторические свечи по инструменту
|
||||
rpc GetCandles(GetCandlesRequest) returns (GetCandlesResponse);
|
||||
|
||||
//GetLastPrices — цены последних сделок по инструментам
|
||||
rpc GetLastPrices(GetLastPricesRequest) returns (GetLastPricesResponse);
|
||||
|
||||
//GetOrderBook — стакан по инструменту
|
||||
rpc GetOrderBook(GetOrderBookRequest) returns (GetOrderBookResponse);
|
||||
|
||||
//GetTradingStatus — статус торгов по инструменту
|
||||
rpc GetTradingStatus(GetTradingStatusRequest) returns (GetTradingStatusResponse);
|
||||
|
||||
//GetTradingStatuses — статус торгов по инструментам
|
||||
rpc GetTradingStatuses(GetTradingStatusesRequest) returns (GetTradingStatusesResponse);
|
||||
|
||||
//GetLastTrades — обезличенные сделки
|
||||
//Обезличенные сделки по инструменту. Метод гарантирует получение информации за последний час.
|
||||
rpc GetLastTrades(GetLastTradesRequest) returns (GetLastTradesResponse);
|
||||
|
||||
//GetClosePrices — цены закрытия торговой сессии по инструментам
|
||||
rpc GetClosePrices(GetClosePricesRequest) returns (GetClosePricesResponse);
|
||||
|
||||
//GetTechAnalysis — технические индикаторы по инструменту
|
||||
rpc GetTechAnalysis(GetTechAnalysisRequest) returns (GetTechAnalysisResponse);
|
||||
|
||||
//GetMarketValues — рыночные данные по инструментам
|
||||
rpc GetMarketValues(GetMarketValuesRequest) returns (GetMarketValuesResponse);
|
||||
}
|
||||
|
||||
service MarketDataStreamService {
|
||||
//MarketDataStream — bidirectional стрим предоставления биржевой информации
|
||||
rpc MarketDataStream(stream MarketDataRequest) returns (stream MarketDataResponse);
|
||||
|
||||
//MarketDataServerSideStream — server-side стрим предоставления биржевой информации
|
||||
rpc MarketDataServerSideStream(MarketDataServerSideStreamRequest) returns (stream MarketDataResponse);
|
||||
}
|
||||
|
||||
//Запрос подписки или отписки на определенные биржевые данные.
|
||||
message MarketDataRequest {
|
||||
oneof payload {
|
||||
SubscribeCandlesRequest subscribe_candles_request = 1; //Запрос подписки на свечи.
|
||||
SubscribeOrderBookRequest subscribe_order_book_request = 2; //Запрос подписки на стаканы.
|
||||
SubscribeTradesRequest subscribe_trades_request = 3; //Запрос подписки на ленту обезличенных сделок.
|
||||
SubscribeInfoRequest subscribe_info_request = 4; //Запрос подписки на торговые статусы инструментов.
|
||||
SubscribeLastPriceRequest subscribe_last_price_request = 5; //Запрос подписки на цены последних сделок.
|
||||
GetMySubscriptions get_my_subscriptions = 6; //Запрос своих подписок.
|
||||
PingRequest ping = 7; //Запрос проверки активности соединения.
|
||||
PingDelaySettings ping_settings = 15; //Запрос настройки пинга.
|
||||
}
|
||||
}
|
||||
|
||||
message MarketDataServerSideStreamRequest {
|
||||
SubscribeCandlesRequest subscribe_candles_request = 1; //Запрос подписки на свечи.
|
||||
SubscribeOrderBookRequest subscribe_order_book_request = 2; //Запрос подписки на стаканы.
|
||||
SubscribeTradesRequest subscribe_trades_request = 3; //Запрос подписки на ленту обезличенных сделок.
|
||||
SubscribeInfoRequest subscribe_info_request = 4; //Запрос подписки на торговые статусы инструментов.
|
||||
SubscribeLastPriceRequest subscribe_last_price_request = 5; //Запрос подписки на цены последних сделок.
|
||||
PingDelaySettings ping_settings = 15; //Запрос настройки пинга.
|
||||
}
|
||||
|
||||
//Пакет биржевой информации по подписке.
|
||||
message MarketDataResponse {
|
||||
oneof payload {
|
||||
SubscribeCandlesResponse subscribe_candles_response = 1; //Результат подписки на свечи.
|
||||
SubscribeOrderBookResponse subscribe_order_book_response = 2; //Результат подписки на стаканы.
|
||||
SubscribeTradesResponse subscribe_trades_response = 3; //Результат подписки на поток обезличенных сделок.
|
||||
SubscribeInfoResponse subscribe_info_response = 4; //Результат подписки на торговые статусы инструментов.
|
||||
Candle candle = 5; //Свеча.
|
||||
Trade trade = 6; //Сделки.
|
||||
OrderBook orderbook = 7; //Стакан.
|
||||
TradingStatus trading_status = 8; //Торговый статус.
|
||||
Ping ping = 9; //Проверка активности стрима.
|
||||
SubscribeLastPriceResponse subscribe_last_price_response = 10; //Результат подписки на цены последние сделок по инструментам.
|
||||
LastPrice last_price = 11; //Цена последней сделки.
|
||||
OpenInterest open_interest = 12; //Открытый интерес.
|
||||
}
|
||||
}
|
||||
|
||||
// subscribeCandles | Изменения статуса подписки на свечи.
|
||||
message SubscribeCandlesRequest {
|
||||
SubscriptionAction subscription_action = 1; //Изменение статуса подписки.
|
||||
repeated CandleInstrument instruments = 2; //Массив инструментов для подписки на свечи.
|
||||
bool waiting_close = 3; //Флаг ожидания закрытия временного интервала для отправки свечи.
|
||||
optional GetCandlesRequest.CandleSource candle_source_type = 9; //Источник свечей.
|
||||
}
|
||||
|
||||
//Тип операции со списком подписок.
|
||||
enum SubscriptionAction {
|
||||
SUBSCRIPTION_ACTION_UNSPECIFIED = 0; //Статус подписки не определен.
|
||||
SUBSCRIPTION_ACTION_SUBSCRIBE = 1; //Подписаться.
|
||||
SUBSCRIPTION_ACTION_UNSUBSCRIBE = 2; //Отписаться.
|
||||
}
|
||||
|
||||
//Интервал свечи.
|
||||
enum SubscriptionInterval {
|
||||
SUBSCRIPTION_INTERVAL_UNSPECIFIED = 0; //Интервал свечи не определен.
|
||||
SUBSCRIPTION_INTERVAL_ONE_MINUTE = 1; //Минутные свечи.
|
||||
SUBSCRIPTION_INTERVAL_FIVE_MINUTES = 2; //Пятиминутные свечи.
|
||||
SUBSCRIPTION_INTERVAL_FIFTEEN_MINUTES = 3; //Пятнадцатиминутные свечи.
|
||||
SUBSCRIPTION_INTERVAL_ONE_HOUR = 4; //Часовые свечи.
|
||||
SUBSCRIPTION_INTERVAL_ONE_DAY = 5; //Дневные свечи.
|
||||
SUBSCRIPTION_INTERVAL_2_MIN = 6; //Двухминутные свечи.
|
||||
SUBSCRIPTION_INTERVAL_3_MIN = 7; //Трехминутные свечи.
|
||||
SUBSCRIPTION_INTERVAL_10_MIN = 8; //Десятиминутные свечи.
|
||||
SUBSCRIPTION_INTERVAL_30_MIN = 9; //Тридцатиминутные свечи.
|
||||
SUBSCRIPTION_INTERVAL_2_HOUR = 10; //Двухчасовые свечи.
|
||||
SUBSCRIPTION_INTERVAL_4_HOUR = 11; //Четырехчасовые свечи.
|
||||
SUBSCRIPTION_INTERVAL_WEEK = 12; //Недельные свечи.
|
||||
SUBSCRIPTION_INTERVAL_MONTH = 13; //Месячные свечи.
|
||||
}
|
||||
|
||||
//Запрос изменения статус подписки на свечи.
|
||||
message CandleInstrument {
|
||||
string figi = 1 [deprecated = true]; // Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
|
||||
SubscriptionInterval interval = 2; //Интервал свечей. Двухчасовые и четырехчасовые свечи в стриме отсчитываются с 0:00 по UTC.
|
||||
string instrument_id = 3; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
|
||||
}
|
||||
|
||||
//Результат изменения статус подписки на свечи.
|
||||
message SubscribeCandlesResponse {
|
||||
string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id).
|
||||
repeated CandleSubscription candles_subscriptions = 2; //Массив статусов подписки на свечи.
|
||||
}
|
||||
|
||||
//Статус подписки на свечи.
|
||||
message CandleSubscription {
|
||||
string figi = 1; //FIGI-идентификатор инструмента.
|
||||
SubscriptionInterval interval = 2; //Интервал свечей.
|
||||
SubscriptionStatus subscription_status = 3; //Статус подписки.
|
||||
string instrument_uid = 4; //UID инструмента.
|
||||
bool waiting_close = 5; //Флаг ожидания закрытия временного интервала для отправки свечи.
|
||||
string stream_id = 6; //Идентификатор открытого соединения.
|
||||
string subscription_id = 7; //Идентификатор подписки в формате `UUID`.
|
||||
SubscriptionAction subscription_action = 8; //Действие подписки.
|
||||
optional GetCandlesRequest.CandleSource candle_source_type = 9; //Источник свечей.
|
||||
string ticker = 10; //Тикер инструмента.
|
||||
string class_code = 11; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Результат подписки.
|
||||
enum SubscriptionStatus {
|
||||
SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Статус подписки не определен.
|
||||
SUBSCRIPTION_STATUS_SUCCESS = 1; //Успешно.
|
||||
SUBSCRIPTION_STATUS_INSTRUMENT_NOT_FOUND = 2; //Инструмент не найден.
|
||||
SUBSCRIPTION_STATUS_SUBSCRIPTION_ACTION_IS_INVALID = 3; //Некорректный статус подписки. [Список возможных значений](./marketdata#subscriptionaction).
|
||||
SUBSCRIPTION_STATUS_DEPTH_IS_INVALID = 4; //Некорректная глубина стакана. Доступные значения — 1, 10, 20, 30, 40, 50.
|
||||
SUBSCRIPTION_STATUS_INTERVAL_IS_INVALID = 5; //Некорректный интервал свечей. [Список возможных значений](./marketdata#subscriptioninterval).
|
||||
SUBSCRIPTION_STATUS_LIMIT_IS_EXCEEDED = 6; //Превышен лимит на общее количество подписок в рамках стрима. [Лимитная политика](./limits/).
|
||||
SUBSCRIPTION_STATUS_INTERNAL_ERROR = 7; //Внутренняя ошибка сервиса.
|
||||
SUBSCRIPTION_STATUS_TOO_MANY_REQUESTS = 8; //Превышен лимит на количество запросов на подписки в течение установленного отрезка времени.
|
||||
SUBSCRIPTION_STATUS_SUBSCRIPTION_NOT_FOUND = 9; // Активная подписка не найдена. Ошибка может возникнуть только при отписке от несуществующей подписки.
|
||||
SUBSCRIPTION_STATUS_SOURCE_IS_INVALID = 10; //Указан некорректный источник.
|
||||
}
|
||||
|
||||
//Запрос на изменение статуса подписки на стаканы.
|
||||
message SubscribeOrderBookRequest {
|
||||
SubscriptionAction subscription_action = 1; //Изменение статуса подписки.
|
||||
repeated OrderBookInstrument instruments = 2; //Массив инструментов для подписки на стаканы.
|
||||
}
|
||||
|
||||
//Запрос подписки на стаканы.
|
||||
message OrderBookInstrument {
|
||||
string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
|
||||
int32 depth = 2; //Глубина стакана.
|
||||
string instrument_id = 3; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
|
||||
OrderBookType order_book_type = 4; //Тип стакана. Значение по умолчанию — `ORDERBOOK_TYPE_ALL`, стакан биржевой и дилера.
|
||||
}
|
||||
|
||||
//Результат изменения статуса подписки на стаканы.
|
||||
message SubscribeOrderBookResponse {
|
||||
string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id).
|
||||
repeated OrderBookSubscription order_book_subscriptions = 2; //Массив статусов подписки на стаканы.
|
||||
}
|
||||
|
||||
//Статус подписки.
|
||||
message OrderBookSubscription {
|
||||
string figi = 1; //FIGI-идентификатор инструмента.
|
||||
int32 depth = 2; //Глубина стакана.
|
||||
SubscriptionStatus subscription_status = 3; //Статус подписки.
|
||||
string instrument_uid = 4; //UID инструмента.
|
||||
string stream_id = 5; //Идентификатор открытого соединения.
|
||||
string subscription_id = 6; //Идентификатор подписки в формате `UUID`.
|
||||
OrderBookType order_book_type = 7; //Тип стакана.
|
||||
SubscriptionAction subscription_action = 8; //Действие подписки.
|
||||
string ticker = 9; //Тикер инструмента.
|
||||
string class_code = 10; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Типы источников сделок.
|
||||
enum TradeSourceType {
|
||||
TRADE_SOURCE_UNSPECIFIED = 0; //Тип источника сделки не определён.
|
||||
TRADE_SOURCE_EXCHANGE = 1; // Биржевые сделки.
|
||||
TRADE_SOURCE_DEALER = 2; // Сделки дилера.
|
||||
TRADE_SOURCE_ALL = 3; // Все сделки.
|
||||
}
|
||||
|
||||
//Изменение статуса подписки на поток обезличенных сделок.
|
||||
message SubscribeTradesRequest {
|
||||
SubscriptionAction subscription_action = 1; //Изменение статуса подписки.
|
||||
repeated TradeInstrument instruments = 2; //Массив инструментов для подписки на поток обезличенных сделок.
|
||||
TradeSourceType trade_source = 3; //Тип источника сделок. Значение по умолчанию — `TRADE_SOURCE_ALL`, все сделки.
|
||||
bool with_open_interest = 4; //Флаг открытого интереса. **true** - в стриме дополнительно передается информация об открытом интересе для фьючерсов
|
||||
}
|
||||
|
||||
//Запрос подписки на поток обезличенных сделок.
|
||||
message TradeInstrument {
|
||||
string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте instrument_id`.
|
||||
string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
|
||||
}
|
||||
|
||||
//Результат изменения статуса подписки на поток обезличенных сделок.
|
||||
message SubscribeTradesResponse {
|
||||
string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id).
|
||||
repeated TradeSubscription trade_subscriptions = 2; //Массив статусов подписки на поток сделок.
|
||||
TradeSourceType trade_source = 3; //Тип источника сделок.
|
||||
}
|
||||
|
||||
//Статус подписки.
|
||||
message TradeSubscription {
|
||||
string figi = 1; //FIGI-идентификатор инструмента.
|
||||
SubscriptionStatus subscription_status = 2; //Статус подписки.
|
||||
string instrument_uid = 3; //UID инструмента.
|
||||
string stream_id = 4; //Идентификатор открытого соединения.
|
||||
string subscription_id = 5; //Идентификатор подписки в формате UUID.
|
||||
bool with_open_interest = 6; //Флаг открытого интереса. **true** - в стриме дополнительно передается информация об открытом интересе для фьючерсов
|
||||
SubscriptionAction subscription_action = 7; //Действие подписки.
|
||||
string ticker = 8; //Тикер инструмента.
|
||||
string class_code = 9; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Изменение статуса подписки на торговый статус инструмента.
|
||||
message SubscribeInfoRequest {
|
||||
SubscriptionAction subscription_action = 1; //Изменение статуса подписки.
|
||||
repeated InfoInstrument instruments = 2; //Массив инструментов для подписки на торговый статус.
|
||||
}
|
||||
|
||||
//Запрос подписки на торговый статус.
|
||||
message InfoInstrument {
|
||||
string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте instrument_id`.
|
||||
string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
|
||||
}
|
||||
|
||||
//Результат изменения статуса подписки на торговый статус.
|
||||
message SubscribeInfoResponse {
|
||||
string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id).
|
||||
repeated InfoSubscription info_subscriptions = 2; //Массив статусов подписки на торговый статус.
|
||||
}
|
||||
|
||||
//Статус подписки.
|
||||
message InfoSubscription {
|
||||
string figi = 1; //FIGI-идентификатор инструмента.
|
||||
SubscriptionStatus subscription_status = 2; //Статус подписки.
|
||||
string instrument_uid = 3; //UID инструмента.
|
||||
string stream_id = 4; //Идентификатор открытого соединения.
|
||||
string subscription_id = 5; //Идентификатор подписки в формате UUID.
|
||||
SubscriptionAction subscription_action = 6; //Действие подписки.
|
||||
string ticker = 7; //Тикер инструмента.
|
||||
string class_code = 8; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Изменение статуса подписки на цену последней сделки по инструменту.
|
||||
message SubscribeLastPriceRequest {
|
||||
SubscriptionAction subscription_action = 1; //Изменение статуса подписки.
|
||||
repeated LastPriceInstrument instruments = 2; //Массив инструментов для подписки на цену последней сделки.
|
||||
}
|
||||
|
||||
//Запрос подписки на последнюю цену.
|
||||
message LastPriceInstrument {
|
||||
string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте instrument_id`.
|
||||
string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
|
||||
}
|
||||
|
||||
//Результат изменения статуса подписки на цену последней сделки.
|
||||
message SubscribeLastPriceResponse {
|
||||
string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id).
|
||||
repeated LastPriceSubscription last_price_subscriptions = 2; //Массив статусов подписки на цену последней сделки.
|
||||
}
|
||||
|
||||
//Статус подписки на цену последней сделки.
|
||||
message LastPriceSubscription {
|
||||
string figi = 1; //FIGI-идентификатор инструмента.
|
||||
SubscriptionStatus subscription_status = 2; //Статус подписки.
|
||||
string instrument_uid = 3; //UID инструмента.
|
||||
string stream_id = 4; //Идентификатор открытого соединения.
|
||||
string subscription_id = 5; //Идентификатор подписки в формате `UUID`.
|
||||
SubscriptionAction subscription_action = 6; //Действие подписки.
|
||||
string ticker = 7; //Тикер инструмента.
|
||||
string class_code = 8; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Пакет свечей в рамках стрима.
|
||||
message Candle {
|
||||
string figi = 1; //FIGI-идентификатор инструмента.
|
||||
SubscriptionInterval interval = 2; //Интервал свечи.
|
||||
Quotation open = 3; //Цена открытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
Quotation high = 4; //Максимальная цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
Quotation low = 5; //Минимальная цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
Quotation close = 6; //Цена закрытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
int64 volume = 7; //Объем сделок в лотах.
|
||||
google.protobuf.Timestamp time = 8; //Время начала интервала свечи по UTC.
|
||||
google.protobuf.Timestamp last_trade_ts = 9; //Время последней сделки, вошедшей в свечу по UTC.
|
||||
string instrument_uid = 10; //UID инструмента.
|
||||
string ticker = 11; //Тикер инструмента.
|
||||
string class_code = 12; //Класс-код (секция торгов).
|
||||
int64 volume_buy = 13; //Объем торгов на покупку.
|
||||
int64 volume_sell = 14; //Объём торгов на продажу.
|
||||
CandleSource candle_source_type = 19; //Источник свечей.
|
||||
}
|
||||
|
||||
//Пакет стаканов в рамках стрима.
|
||||
message OrderBook {
|
||||
string figi = 1; //FIGI-идентификатор инструмента.
|
||||
int32 depth = 2; //Глубина стакана.
|
||||
bool is_consistent = 3; //Флаг консистентности стакана. **false** — не все заявки попали в стакан из-за сетевых задержек или нарушения порядка доставки.
|
||||
repeated Order bids = 4; //Массив предложений.
|
||||
repeated Order asks = 5; //Массив спроса.
|
||||
google.protobuf.Timestamp time = 6; //Время формирования стакана в часовом поясе UTC по времени биржи.
|
||||
Quotation limit_up = 7; //Верхний лимит цены за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
Quotation limit_down = 8; //Нижний лимит цены за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
string instrument_uid = 9; //UID инструмента.
|
||||
OrderBookType order_book_type = 10; //Тип стакана.
|
||||
string ticker = 11; //Тикер инструмента.
|
||||
string class_code = 12; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Массив предложений/спроса.
|
||||
message Order {
|
||||
Quotation price = 1; //Цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
int64 quantity = 2; //Количество в лотах.
|
||||
}
|
||||
|
||||
//Информация о сделке.
|
||||
message Trade {
|
||||
string figi = 1; //FIGI-идентификатор инструмента.
|
||||
TradeDirection direction = 2; //Направление сделки.
|
||||
Quotation price = 3; //Цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
int64 quantity = 4; //Количество лотов.
|
||||
google.protobuf.Timestamp time = 5; //Время сделки в часовом поясе UTC по времени биржи.
|
||||
string instrument_uid = 6; //UID инструмента.
|
||||
TradeSourceType trade_source = 7; //Тип источника сделки.
|
||||
string ticker = 8; //Тикер инструмента.
|
||||
string class_code = 9; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Направление сделки.
|
||||
enum TradeDirection {
|
||||
TRADE_DIRECTION_UNSPECIFIED = 0; //Направление сделки не определено.
|
||||
TRADE_DIRECTION_BUY = 1; //Покупка.
|
||||
TRADE_DIRECTION_SELL = 2; //Продажа.
|
||||
}
|
||||
|
||||
//Пакет изменения торгового статуса.
|
||||
message TradingStatus {
|
||||
string figi = 1; //FIGI-идентификатор инструмента.
|
||||
SecurityTradingStatus trading_status = 2; //Статус торговли инструментом.
|
||||
google.protobuf.Timestamp time = 3; //Время изменения торгового статуса по UTC.
|
||||
bool limit_order_available_flag = 4; //Признак доступности выставления лимитной заявки по инструменту.
|
||||
bool market_order_available_flag = 5; //Признак доступности выставления рыночной заявки по инструменту.
|
||||
string instrument_uid = 6; //UID инструмента.
|
||||
string ticker = 7; //Тикер инструмента.
|
||||
string class_code = 8; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Запрос исторических свечей.
|
||||
message GetCandlesRequest {
|
||||
optional string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
|
||||
google.protobuf.Timestamp from = 2 [(google.api.field_behavior) = REQUIRED]; //Начало запрашиваемого периода по UTC.
|
||||
google.protobuf.Timestamp to = 3 [(google.api.field_behavior) = REQUIRED]; //Окончание запрашиваемого периода по UTC.
|
||||
CandleInterval interval = 4 [(google.api.field_behavior) = REQUIRED]; //Интервал запрошенных свечей.
|
||||
optional string instrument_id = 5; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
|
||||
optional CandleSource candle_source_type = 7; //Тип источника свечи.
|
||||
optional int32 limit = 10; //Максимальное количество свечей в ответе.
|
||||
|
||||
enum CandleSource {
|
||||
CANDLE_SOURCE_UNSPECIFIED = 0; //Все свечи.
|
||||
CANDLE_SOURCE_EXCHANGE = 1; //Биржевые свечи.
|
||||
CANDLE_SOURCE_INCLUDE_WEEKEND = 3; //Все свечи с учетом торговли по выходным.
|
||||
}
|
||||
}
|
||||
|
||||
//Интервал свечей. Максимальное значение интервала приведено ориентировочно, может отличаться в большую сторону в зависимости от параметров запроса.
|
||||
enum CandleInterval {
|
||||
CANDLE_INTERVAL_UNSPECIFIED = 0; //Интервал не определен.
|
||||
CANDLE_INTERVAL_1_MIN = 1; //От 1 минуты до 1 дня. Максимальное значение `limit` — 2400.
|
||||
CANDLE_INTERVAL_5_MIN = 2; //От 5 минут до недели. Максимальное значение `limit` — 2400.
|
||||
CANDLE_INTERVAL_15_MIN = 3; //От 15 минут до 3 недель. Максимальное значение `limit` — 2400.
|
||||
CANDLE_INTERVAL_HOUR = 4; //От 1 часа до 3 месяцев. Максимальное значение `limit` — 2400.
|
||||
CANDLE_INTERVAL_DAY = 5; //От 1 дня до 6 лет. Максимальное значение `limit` — 2400.
|
||||
CANDLE_INTERVAL_2_MIN = 6; //От 2 минут до 1 дня. Максимальное значение `limit` — 1200.
|
||||
CANDLE_INTERVAL_3_MIN = 7; //От 3 минут до 1 дня. Максимальное значение `limit` — 750.
|
||||
CANDLE_INTERVAL_10_MIN = 8; //От 10 минут до недели. Максимальное значение `limit` — 1200.
|
||||
CANDLE_INTERVAL_30_MIN = 9; //От 30 минут до 3 недель. Максимальное значение `limit` — 1200.
|
||||
CANDLE_INTERVAL_2_HOUR = 10; //От 2 часов до 3 месяцев. Максимальное значение `limit` — 2400.
|
||||
CANDLE_INTERVAL_4_HOUR = 11; //От 4 часов до 3 месяцев. Максимальное значение `limit` — 700.
|
||||
CANDLE_INTERVAL_WEEK = 12; //От 1 недели до 5 лет. Максимальное значение `limit` — 300.
|
||||
CANDLE_INTERVAL_MONTH = 13; //От 1 месяца до 10 лет. Максимальное значение `limit` — 120.
|
||||
CANDLE_INTERVAL_5_SEC = 14; //От 5 секунд до 200 минут. Максимальное значение `limit` — 2500.
|
||||
CANDLE_INTERVAL_10_SEC = 15; //От 10 секунд до 200 минут. Максимальное значение `limit` — 1250.
|
||||
CANDLE_INTERVAL_30_SEC = 16; //От 30 секунд до 20 часов. Максимальное значение `limit` — 2500.
|
||||
}
|
||||
|
||||
enum CandleSource {
|
||||
CANDLE_SOURCE_UNSPECIFIED = 0; //Источник свечей не определен.
|
||||
CANDLE_SOURCE_EXCHANGE = 1; //Биржевые свечи.
|
||||
CANDLE_SOURCE_DEALER_WEEKEND = 2; //Свечи дилера в результате торговли по выходным.
|
||||
}
|
||||
|
||||
//Список свечей.
|
||||
message GetCandlesResponse {
|
||||
repeated HistoricCandle candles = 1; //Массив свечей.
|
||||
}
|
||||
|
||||
//Информация о свече.
|
||||
message HistoricCandle {
|
||||
Quotation open = 1; //Цена открытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
Quotation high = 2; //Максимальная цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
Quotation low = 3; //Минимальная цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
Quotation close = 4; //Цена закрытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
int64 volume = 5; //Объем торгов в лотах.
|
||||
google.protobuf.Timestamp time = 6; //Время свечи в часовом поясе UTC.
|
||||
bool is_complete = 7; //Признак завершенности свечи. **false** — свеча за текущие интервал еще сформирована не полностью.
|
||||
CandleSource candle_source = 9; //Тип источника свечи
|
||||
int64 volume_buy = 10; //Объем торгов на покупку.
|
||||
int64 volume_sell = 11; //Объём торгов на продажу.
|
||||
}
|
||||
|
||||
//Запрос получения цен последних сделок.
|
||||
message GetLastPricesRequest {
|
||||
repeated string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
|
||||
repeated string instrument_id = 2; //Массив идентификаторов инструмента. Принимает значения `figi`, `instrument_uid` или `ticker + '_' + class_code`.
|
||||
LastPriceType last_price_type = 3; //Тип запрашиваемой последней цены.
|
||||
optional InstrumentStatus instrument_status = 9; //Статус запрашиваемых инструментов. [Возможные значения](#instrumentstatus).
|
||||
}
|
||||
|
||||
//Список цен последних сделок.
|
||||
message GetLastPricesResponse {
|
||||
repeated LastPrice last_prices = 1; //Массив цен последних сделок.
|
||||
}
|
||||
|
||||
//Информация о цене последней сделки.
|
||||
message LastPrice {
|
||||
string figi = 1; //FIGI инструмента.
|
||||
Quotation price = 2; //Цена последней сделки за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
google.protobuf.Timestamp time = 3; //Время получения последней цены в часовом поясе UTC по времени биржи.
|
||||
string ticker = 9; //Тикер инструмента.
|
||||
string class_code = 10; //Класс-код (секция торгов).
|
||||
string instrument_uid = 11; //UID инструмента.
|
||||
LastPriceType last_price_type = 12; //Тип последней цены.
|
||||
}
|
||||
|
||||
message OpenInterest {
|
||||
string instrument_uid = 1; //UID инструмента.
|
||||
google.protobuf.Timestamp time = 2; //Время получения открытого интереса в часовом поясе UTC по времени биржи.
|
||||
int64 open_interest = 3; //Открытый интерес.
|
||||
string ticker = 4; //Тикер инструмента.
|
||||
string class_code = 5; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Запрос стакана.
|
||||
message GetOrderBookRequest {
|
||||
optional string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
|
||||
int32 depth = 2 [(google.api.field_behavior) = REQUIRED]; //Глубина стакана.
|
||||
optional string instrument_id = 3; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
|
||||
}
|
||||
|
||||
//Информация о стакане.
|
||||
message GetOrderBookResponse {
|
||||
string figi = 1; //FIGI-идентификатор инструмента.
|
||||
int32 depth = 2; //Глубина стакана.
|
||||
repeated Order bids = 3; //Множество пар значений на покупку.
|
||||
repeated Order asks = 4; //Множество пар значений на продажу.
|
||||
Quotation last_price = 5; //Цена последней сделки за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
Quotation close_price = 6; //Цена закрытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
Quotation limit_up = 7; //Верхний лимит цены за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
Quotation limit_down = 8; //Нижний лимит цены за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
|
||||
string instrument_uid = 9; //UID инструмента.
|
||||
string ticker = 10; //Тикер инструмента.
|
||||
string class_code = 11; //Класс-код (секция торгов).
|
||||
google.protobuf.Timestamp last_price_ts = 21; //Время получения цены последней сделки.
|
||||
google.protobuf.Timestamp close_price_ts = 22; //Время получения цены закрытия.
|
||||
google.protobuf.Timestamp orderbook_ts = 23; //Время формирования стакана на бирже.
|
||||
}
|
||||
|
||||
//Запрос получения торгового статуса.
|
||||
message GetTradingStatusRequest {
|
||||
optional string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
|
||||
optional string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`
|
||||
}
|
||||
|
||||
//Запрос получения торгового статуса.
|
||||
message GetTradingStatusesRequest {
|
||||
repeated string instrument_id = 1; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`
|
||||
}
|
||||
|
||||
//Информация о торговом статусе.
|
||||
message GetTradingStatusesResponse {
|
||||
repeated GetTradingStatusResponse trading_statuses = 1; //Массив информации о торговых статусах.
|
||||
}
|
||||
|
||||
//Информация о торговом статусе.
|
||||
message GetTradingStatusResponse {
|
||||
string figi = 1; //FIGI-идентификатор инструмента.
|
||||
SecurityTradingStatus trading_status = 2; //Статус торговли инструментом.
|
||||
bool limit_order_available_flag = 3; //Признак доступности выставления лимитной заявки по инструменту.
|
||||
bool market_order_available_flag = 4; //Признак доступности выставления рыночной заявки по инструменту.
|
||||
bool api_trade_available_flag = 5; //Признак доступности торгов через API.
|
||||
string instrument_uid = 6; //UID инструмента.
|
||||
bool bestprice_order_available_flag = 8; //Признак доступности завяки по лучшей цене.
|
||||
bool only_best_price = 9; //Признак доступности только заявки по лучшей цене.
|
||||
string ticker = 10; //Тикер инструмента.
|
||||
string class_code = 11; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Запрос обезличенных сделок за последний час.
|
||||
message GetLastTradesRequest {
|
||||
optional string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
|
||||
google.protobuf.Timestamp from = 2 [(google.api.field_behavior) = REQUIRED]; //Начало запрашиваемого периода по UTC.
|
||||
google.protobuf.Timestamp to = 3 [(google.api.field_behavior) = REQUIRED]; //Окончание запрашиваемого периода по UTC.
|
||||
optional string instrument_id = 4; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`
|
||||
TradeSourceType trade_source = 5; //Тип источника сделок. Значение по умолчанию — `TRADE_SOURCE_ALL`, все сделки.
|
||||
}
|
||||
|
||||
//Обезличенных сделок за последний час.
|
||||
message GetLastTradesResponse {
|
||||
repeated Trade trades = 1; //Массив сделок.
|
||||
}
|
||||
|
||||
//Запрос активных подписок. Возвращает по одному сообщению на каждый тип активных подписок — `SubscribeLastPriceResponse`, `SubscribeInfoResponse`, `SubscribeTradesResponse`, `SubscribeOrderBookResponse`, `SubscribeCandlesResponse`.
|
||||
message GetMySubscriptions {}
|
||||
|
||||
//Запрос цен закрытия торговой сессии по инструментам.
|
||||
message GetClosePricesRequest {
|
||||
repeated InstrumentClosePriceRequest instruments = 1 [(google.api.field_behavior) = REQUIRED]; //Массив по инструментам.
|
||||
optional InstrumentStatus instrument_status = 9; //Статус запрашиваемых инструментов. [Возможные значения](#instrumentstatus).
|
||||
}
|
||||
|
||||
//Запрос цен закрытия торговой сессии по инструменту.
|
||||
message InstrumentClosePriceRequest {
|
||||
string instrument_id = 1; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`
|
||||
}
|
||||
|
||||
//Цены закрытия торговой сессии по инструментам.
|
||||
message GetClosePricesResponse {
|
||||
repeated InstrumentClosePriceResponse close_prices = 1; //Массив по инструментам.
|
||||
}
|
||||
|
||||
//Цена закрытия торговой сессии по инструменту.
|
||||
message InstrumentClosePriceResponse {
|
||||
string figi = 1; //FIGI инструмента.
|
||||
string instrument_uid = 2; //UID инструмента.
|
||||
string ticker = 3; //Тикер инструмента.
|
||||
string class_code = 4; //Класс-код (секция торгов).
|
||||
Quotation price = 11; //Цена закрытия торговой сессии.
|
||||
Quotation evening_session_price = 12; //Цена последней сделки с вечерней сессии. Цена публикуется биржей по торговым дням и в нерабочие дни не обновляется.
|
||||
google.protobuf.Timestamp time = 21; //Дата совершения торгов.
|
||||
google.protobuf.Timestamp evening_session_price_time = 23; //Дата цены закрытия вечерней сессии.
|
||||
}
|
||||
|
||||
message GetTechAnalysisRequest {
|
||||
IndicatorType indicator_type = 1 [(google.api.field_behavior) = REQUIRED]; //Тип технического индикатора.
|
||||
string instrument_uid = 2 [(google.api.field_behavior) = REQUIRED]; //UID инструмента.
|
||||
google.protobuf.Timestamp from = 3 [(google.api.field_behavior) = REQUIRED]; //Начало запрашиваемого периода по UTC.
|
||||
google.protobuf.Timestamp to = 4 [(google.api.field_behavior) = REQUIRED]; //Окончание запрашиваемого периода по UTC.
|
||||
IndicatorInterval interval = 5 [(google.api.field_behavior) = REQUIRED]; //Интервал, за который рассчитывается индикатор.
|
||||
TypeOfPrice type_of_price = 6 [(google.api.field_behavior) = REQUIRED]; //Тип цены, который используется при расчете индикатора.
|
||||
int32 length = 7; //Торговый период, за который рассчитывается индикатор.
|
||||
Deviation deviation = 8; //Параметры отклонения.
|
||||
Smoothing smoothing = 9; //Параметры сглаживания.
|
||||
|
||||
message Smoothing {
|
||||
int32 fast_length = 1; //Короткий период сглаживания для первой экспоненциальной скользящей средней (EMA).
|
||||
int32 slow_length = 2; //Длинный период сглаживания для второй экспоненциальной скользящей средней (EMA).
|
||||
int32 signal_smoothing = 3; //Период сглаживания для третьей экспоненциальной скользящей средней (EMA)
|
||||
}
|
||||
|
||||
message Deviation {
|
||||
Quotation deviation_multiplier = 1; //Количество стандартных отклонений, на которые отступают верхняя и нижняя границы.
|
||||
}
|
||||
|
||||
//Интервал свечи.
|
||||
enum IndicatorInterval {
|
||||
INDICATOR_INTERVAL_UNSPECIFIED = 0; //Интервал не определен.
|
||||
INDICATOR_INTERVAL_ONE_MINUTE = 1; //1 минута.
|
||||
INDICATOR_INTERVAL_FIVE_MINUTES = 2; //5 минут.
|
||||
INDICATOR_INTERVAL_FIFTEEN_MINUTES = 3; //15 минут.
|
||||
INDICATOR_INTERVAL_ONE_HOUR = 4; //1 час.
|
||||
INDICATOR_INTERVAL_ONE_DAY = 5; //1 день.
|
||||
INDICATOR_INTERVAL_2_MIN = 6; //2 минуты.
|
||||
INDICATOR_INTERVAL_3_MIN = 7; //3 минуты.
|
||||
INDICATOR_INTERVAL_10_MIN = 8; //10 минут.
|
||||
INDICATOR_INTERVAL_30_MIN = 9; //30 минут.
|
||||
INDICATOR_INTERVAL_2_HOUR = 10; //2 часа.
|
||||
INDICATOR_INTERVAL_4_HOUR = 11; //4 часа.
|
||||
INDICATOR_INTERVAL_WEEK = 12; //Неделя.
|
||||
INDICATOR_INTERVAL_MONTH = 13; //Месяц.
|
||||
}
|
||||
|
||||
enum TypeOfPrice {
|
||||
TYPE_OF_PRICE_UNSPECIFIED = 0; //Не указано.
|
||||
TYPE_OF_PRICE_CLOSE = 1; //Цена закрытия.
|
||||
TYPE_OF_PRICE_OPEN = 2; //Цена открытия.
|
||||
TYPE_OF_PRICE_HIGH = 3; //Максимальное значение за выбранный интервал.
|
||||
TYPE_OF_PRICE_LOW = 4; //Минимальное значение за выбранный интервал.
|
||||
TYPE_OF_PRICE_AVG = 5; //Среднее значение по показателям [ (close + open + high + low) / 4 ].
|
||||
}
|
||||
|
||||
enum IndicatorType {
|
||||
INDICATOR_TYPE_UNSPECIFIED = 0; //Не определен.
|
||||
INDICATOR_TYPE_BB = 1; //Bollinger Bands — линия Боллинжера.
|
||||
INDICATOR_TYPE_EMA = 2; //Exponential Moving Average — EMA, экспоненциальная скользящая средняя.
|
||||
INDICATOR_TYPE_RSI = 3; //Relative Strength Index — индекс относительной силы.
|
||||
INDICATOR_TYPE_MACD = 4; //Moving Average Convergence/Divergence — схождение/расхождение скользящих средних.
|
||||
INDICATOR_TYPE_SMA = 5; //Simple Moving Average — простое скользящее среднее.
|
||||
}
|
||||
}
|
||||
|
||||
message GetTechAnalysisResponse {
|
||||
repeated TechAnalysisItem technical_indicators = 1; //Массив значений результатов технического анализа.
|
||||
|
||||
message TechAnalysisItem {
|
||||
google.protobuf.Timestamp timestamp = 1; //Временная метка по UTC, для которой были рассчитаны значения индикатора.
|
||||
optional Quotation middle_band = 2; //Значение простого скользящего среднего (средней линии).
|
||||
optional Quotation upper_band = 3; //Значение верхней линии Боллинджера.
|
||||
optional Quotation lower_band = 4; //Значение нижней линии Боллинджера.
|
||||
optional Quotation signal = 5; //Значение сигнальной линии.
|
||||
optional Quotation macd = 6; //Значение линии MACD.
|
||||
}
|
||||
}
|
||||
|
||||
message GetMarketValuesRequest {
|
||||
repeated string instrument_id = 1; //Массив идентификаторов инструментов. Принимает значения `figi`, `instrument_uid` или `ticker + '_' + class_code`.
|
||||
repeated MarketValueType values = 2; //Массив запрашиваемых параметров.
|
||||
}
|
||||
|
||||
message GetMarketValuesResponse {
|
||||
repeated MarketValueInstrument instruments = 1; //Массив значений параметров.
|
||||
}
|
||||
|
||||
message MarketValueInstrument {
|
||||
string instrument_uid = 1; //Идентификатор инструмента.
|
||||
repeated MarketValue values = 2; //Массив параметров инструмента.
|
||||
string ticker = 3; //Тикер инструмента.
|
||||
string class_code = 4; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
message MarketValue {
|
||||
optional MarketValueType type = 1; //Тип параметра.
|
||||
optional Quotation value = 2; //Значение.
|
||||
optional google.protobuf.Timestamp time = 3; //Дата и время.
|
||||
}
|
||||
|
||||
enum MarketValueType {
|
||||
INSTRUMENT_VALUE_UNSPECIFIED = 0; //Не определен.
|
||||
INSTRUMENT_VALUE_LAST_PRICE = 1; //Последняя биржевая цена.
|
||||
INSTRUMENT_VALUE_LAST_PRICE_DEALER = 2; //Последняя цена дилера.
|
||||
INSTRUMENT_VALUE_CLOSE_PRICE = 3; //Цена закрытия.
|
||||
INSTRUMENT_VALUE_EVENING_SESSION_PRICE = 4; //Цена последней сделки с вечерней сессии.
|
||||
INSTRUMENT_VALUE_OPEN_INTEREST = 5; // Открытый интерес, возвращается только для фьючерсов.
|
||||
INSTRUMENT_VALUE_THEOR_PRICE = 6; //Теоретическая цена, возвращается только для опционов.
|
||||
INSTRUMENT_VALUE_YIELD = 7; //Доходность
|
||||
}
|
||||
|
||||
enum OrderBookType {
|
||||
ORDERBOOK_TYPE_UNSPECIFIED = 0; //Не определен.
|
||||
ORDERBOOK_TYPE_EXCHANGE = 1; //Биржевой стакан.
|
||||
ORDERBOOK_TYPE_DEALER = 2; //Стакан дилера.
|
||||
ORDERBOOK_TYPE_ALL = 3; //Стакан биржевой и дилера.
|
||||
}
|
||||
|
||||
//Тип последней цены
|
||||
enum LastPriceType {
|
||||
LAST_PRICE_UNSPECIFIED = 0; //Не определен.
|
||||
LAST_PRICE_EXCHANGE = 1; // Цена биржи.
|
||||
LAST_PRICE_DEALER = 2; // Цена дилера
|
||||
}
|
||||
+2
-2
@@ -5,12 +5,12 @@ import {
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
jest.mock('../prisma/prisma.service', () => ({
|
||||
jest.mock('../../prisma/prisma.service', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import type { TbankAccountContext } from './tbank-access.service';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
|
||||
describe('TbankAccessService', () => {
|
||||
@@ -4,14 +4,14 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
SupportedTbankAccount,
|
||||
accessibleTbankAccount,
|
||||
supportedTbankAccounts,
|
||||
} from './tbank-account-policy';
|
||||
import { TbankClient, TbankAccount } from './tbank.client';
|
||||
} from './tbank-account.policy';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
import { TbankAccount, TbankClient } from './tbank.client';
|
||||
|
||||
export type TbankConnectionContext = {
|
||||
token: string;
|
||||
@@ -30,7 +30,9 @@ export class TbankAccessService {
|
||||
private readonly tbank: TbankClient,
|
||||
) {}
|
||||
|
||||
async listSupportedAccounts(userId: string): Promise<BrokerageAccountDto[]> {
|
||||
async listSupportedAccounts(
|
||||
userId: string,
|
||||
): Promise<SupportedTbankAccount[]> {
|
||||
return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
|
||||
}
|
||||
|
||||
@@ -39,7 +41,7 @@ export class TbankAccessService {
|
||||
handler: (context: {
|
||||
token: string;
|
||||
generation: string;
|
||||
accounts: BrokerageAccountDto[];
|
||||
accounts: SupportedTbankAccount[];
|
||||
}) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
return this.withConnection(userId, async ({ token, generation }) =>
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
accessibleTbankAccount,
|
||||
supportedTbankAccounts,
|
||||
} from './tbank-account-policy';
|
||||
} from './tbank-account.policy';
|
||||
|
||||
describe('tbank-account-policy', () => {
|
||||
it('normalizes supported account types and fallback names', () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||
import { TbankAccount } from './tbank.client';
|
||||
|
||||
const accountTypes = {
|
||||
@@ -15,10 +15,16 @@ const accountTypes = {
|
||||
} as const;
|
||||
|
||||
type AccountType = keyof typeof accountTypes;
|
||||
export type SupportedTbankAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: (typeof accountTypes)[AccountType]['type'];
|
||||
openedOn: string | null;
|
||||
};
|
||||
|
||||
export function supportedTbankAccounts(
|
||||
accounts: TbankAccount[],
|
||||
): BrokerageAccountDto[] {
|
||||
): SupportedTbankAccount[] {
|
||||
return accounts.flatMap((account) => {
|
||||
const details = accountTypes[account.type as AccountType];
|
||||
if (!details) return [];
|
||||
@@ -46,7 +52,7 @@ export function accessibleTbankAccount(
|
||||
|
||||
function openedOn(seconds: string | number | undefined): string | null {
|
||||
if (seconds === undefined) return null;
|
||||
const date = new Date(Number(seconds) * 1000);
|
||||
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
|
||||
if (Number.isNaN(date.valueOf())) return null;
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
TbankDecimal,
|
||||
TbankInstrument,
|
||||
TbankMoney,
|
||||
TbankTimestamp,
|
||||
} from './tbank.client';
|
||||
|
||||
export type TbankShare = TbankInstrument & {
|
||||
uid?: string;
|
||||
figi?: string;
|
||||
assetUid?: string;
|
||||
classCode?: string;
|
||||
realExchange?: string;
|
||||
apiTradeAvailableFlag?: boolean;
|
||||
currency?: string;
|
||||
shareType?: string;
|
||||
instrumentKind?: string;
|
||||
sector?: string;
|
||||
lot?: number;
|
||||
first_1dayCandleDate?: TbankTimestamp;
|
||||
};
|
||||
export type TbankCandle = {
|
||||
time?: TbankTimestamp;
|
||||
close?: TbankDecimal;
|
||||
isComplete?: boolean;
|
||||
};
|
||||
export type TbankLastPrice = {
|
||||
instrumentUid?: string;
|
||||
price?: TbankDecimal;
|
||||
time?: TbankTimestamp;
|
||||
};
|
||||
export type TbankFundamentals = {
|
||||
assetUid?: string;
|
||||
currency?: string;
|
||||
marketCapitalization?: number;
|
||||
peRatioTtm?: number;
|
||||
priceToBookTtm?: number;
|
||||
revenueTtm?: number;
|
||||
netIncomeTtm?: number;
|
||||
ebitdaTtm?: number;
|
||||
roe?: number;
|
||||
totalDebtMrq?: number;
|
||||
netDebtToEbitda?: number;
|
||||
};
|
||||
|
||||
export type TbankBond = Omit<TbankShare, 'shareType'> & {
|
||||
nominal?: TbankMoney;
|
||||
initialNominal?: TbankMoney;
|
||||
aciValue?: TbankMoney;
|
||||
maturityDate?: TbankTimestamp;
|
||||
callDate?: TbankTimestamp;
|
||||
couponQuantityPerYear?: number;
|
||||
floatingCouponFlag?: boolean;
|
||||
perpetualFlag?: boolean;
|
||||
amortizationFlag?: boolean;
|
||||
subordinatedFlag?: boolean;
|
||||
bondType?: string;
|
||||
riskLevel?: string;
|
||||
};
|
||||
export type TbankCoupon = {
|
||||
couponNumber?: string;
|
||||
couponDate?: TbankTimestamp;
|
||||
fixDate?: TbankTimestamp;
|
||||
payOneBond?: TbankMoney;
|
||||
couponType?: string;
|
||||
couponStartDate?: TbankTimestamp;
|
||||
couponEndDate?: TbankTimestamp;
|
||||
couponPeriod?: number;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Environment } from '../config/env.validation';
|
||||
import { Environment } from '../../config/env.validation';
|
||||
import { TbankCrypto } from './tbank.crypto';
|
||||
|
||||
@Injectable()
|
||||
@@ -0,0 +1,23 @@
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
export {
|
||||
TbankAccessService,
|
||||
type TbankConnectionContext,
|
||||
} from './tbank-access.service';
|
||||
export {
|
||||
type TbankBond,
|
||||
type TbankCandle,
|
||||
type TbankFundamentals,
|
||||
type TbankShare,
|
||||
} from './tbank-market.types';
|
||||
export { TbankTokenService } from './tbank-token.service';
|
||||
export {
|
||||
TbankClient,
|
||||
type TbankDividend,
|
||||
type TbankInstrument,
|
||||
type TbankMoney,
|
||||
type TbankOperationItem,
|
||||
type TbankPortfolio,
|
||||
type TbankPositions,
|
||||
type TbankTimestamp,
|
||||
} from './tbank.client';
|
||||
@@ -0,0 +1,306 @@
|
||||
import { status } from '@grpc/grpc-js';
|
||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
describe('TbankClient operations', () => {
|
||||
const request = {
|
||||
accountId: 'account',
|
||||
from: { seconds: '1' },
|
||||
to: { seconds: '2' },
|
||||
cursor: 'cursor',
|
||||
limit: 10,
|
||||
state: 'OPERATION_STATE_EXECUTED' as const,
|
||||
};
|
||||
|
||||
it('passes a cursor operations request to OperationsService', async () => {
|
||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||
const call = jest.fn().mockResolvedValue({ items: [] });
|
||||
(client as unknown as { call: jest.Mock }).call = call;
|
||||
|
||||
await expect(
|
||||
client.getOperationsByCursor('token', request),
|
||||
).resolves.toEqual({
|
||||
items: [],
|
||||
});
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'token',
|
||||
undefined,
|
||||
'getOperationsByCursor',
|
||||
request,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes a GetDividends request to InstrumentsService', async () => {
|
||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||
const call = jest.fn().mockResolvedValue({
|
||||
dividends: [{ dividendType: 'Regular Cash' }],
|
||||
});
|
||||
(client as unknown as { call: jest.Mock }).call = call;
|
||||
|
||||
await expect(
|
||||
client.getDividends('token', 'uid', '2026-08-19', '2026-09-18'),
|
||||
).resolves.toEqual([{ dividendType: 'Regular Cash' }]);
|
||||
expect(call).toHaveBeenCalledWith('token', undefined, 'getDividends', {
|
||||
instrumentId: 'uid',
|
||||
from: {
|
||||
seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)),
|
||||
},
|
||||
to: {
|
||||
seconds: String(Math.floor(Date.parse('2026-09-18T00:00:00Z') / 1000)),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates the normalized upstream error', async () => {
|
||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||
const error = new ServiceUnavailableException({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
(client as unknown as { call: jest.Mock }).call = jest
|
||||
.fn()
|
||||
.mockRejectedValue(error);
|
||||
|
||||
await expect(client.getOperationsByCursor('token', request)).rejects.toBe(
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
it('logs success, retry, and final failure with the RPC context', async () => {
|
||||
jest.useFakeTimers();
|
||||
const logger = { event: jest.fn(), info: jest.fn() };
|
||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||
(client as unknown as { logger: unknown }).logger = logger;
|
||||
(client as unknown as { queue: unknown }).queue = {
|
||||
size: 0,
|
||||
run: (operation: () => unknown) => operation(),
|
||||
};
|
||||
const invoke = (...args: [string, unknown, string, object]) =>
|
||||
(
|
||||
client as unknown as {
|
||||
call: (
|
||||
token: string,
|
||||
service: unknown,
|
||||
method: string,
|
||||
input: object,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
).call(...args);
|
||||
const requestRpc = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
||||
.mockRejectedValueOnce({ code: status.UNAVAILABLE });
|
||||
(client as unknown as { request: jest.Mock }).request = requestRpc;
|
||||
|
||||
await expect(invoke('token', {}, 'getPortfolio', {})).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
const failed = invoke('token', {}, 'getPortfolio', {});
|
||||
const rejected = expect(failed).rejects.toEqual(
|
||||
expect.any(ServiceUnavailableException),
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
await rejected;
|
||||
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'debug',
|
||||
'tbank.rpc.completed',
|
||||
expect.objectContaining({ operation: 'getPortfolio', attempts: 1 }),
|
||||
);
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'warn',
|
||||
'tbank.rpc.retry',
|
||||
expect.objectContaining({ grpcCode: status.UNAVAILABLE, attempts: 1 }),
|
||||
);
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'tbank.rpc.failed',
|
||||
expect.objectContaining({ grpcCode: status.UNAVAILABLE, attempts: 2 }),
|
||||
);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TbankClient metadata retries', () => {
|
||||
const instrument = { name: 'Облигация', ticker: 'BOND' };
|
||||
let client: TbankClient;
|
||||
let requestRpc: jest.Mock;
|
||||
let queue: { size: number; run: jest.Mock };
|
||||
let logger: {
|
||||
event: jest.Mock<void, [string, string, Record<string, unknown>]>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
requestRpc = jest.fn();
|
||||
queue = {
|
||||
size: 0,
|
||||
run: jest.fn((operation: () => Promise<unknown>) => operation()),
|
||||
};
|
||||
logger = {
|
||||
event: jest.fn<void, [string, string, Record<string, unknown>]>(),
|
||||
};
|
||||
client = Object.assign(
|
||||
Object.create(TbankClient.prototype) as TbankClient,
|
||||
{
|
||||
request: requestRpc,
|
||||
queue,
|
||||
logger,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([
|
||||
status.UNAVAILABLE,
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
])(
|
||||
'recovers metadata after transient gRPC status %s with 2s/4s pauses',
|
||||
async (code) => {
|
||||
requestRpc
|
||||
.mockRejectedValueOnce({ code })
|
||||
.mockRejectedValueOnce({ code })
|
||||
.mockResolvedValueOnce({ instrument });
|
||||
|
||||
const result = client.getInstrumentByUid('token', 'uid');
|
||||
await jest.advanceTimersByTimeAsync(1999);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(3999);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(result).resolves.toEqual(instrument);
|
||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('rechecks a full queue before admitting a metadata retry', async () => {
|
||||
queue.size = 50;
|
||||
requestRpc.mockResolvedValue({ instrument });
|
||||
|
||||
const result = client.getInstrumentByFigi('token', 'figi');
|
||||
await jest.advanceTimersByTimeAsync(1999);
|
||||
expect(queue.run).not.toHaveBeenCalled();
|
||||
queue.size = 49;
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(result).resolves.toEqual(instrument);
|
||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'warn',
|
||||
'tbank.rpc.retry',
|
||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
|
||||
);
|
||||
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
|
||||
});
|
||||
|
||||
it('does not bypass queue admission after an upstream failure', async () => {
|
||||
requestRpc
|
||||
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
||||
.mockResolvedValueOnce({ instrument });
|
||||
|
||||
const result = client.getInstrumentByUid('token', 'uid');
|
||||
queue.size = 50;
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
||||
queue.size = 0;
|
||||
await jest.advanceTimersByTimeAsync(4000);
|
||||
|
||||
await expect(result).resolves.toEqual(instrument);
|
||||
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops retrying a saturated queue after three admission attempts', async () => {
|
||||
queue.size = 50;
|
||||
const result = expect(
|
||||
client.getInstrumentByUid('token', 'uid'),
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(6000);
|
||||
await result;
|
||||
|
||||
expect(queue.run).not.toHaveBeenCalled();
|
||||
expect(logger.event).toHaveBeenCalledTimes(3);
|
||||
expect(logger.event).toHaveBeenLastCalledWith(
|
||||
'error',
|
||||
'tbank.rpc.failed',
|
||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
|
||||
);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('stops retrying an unavailable upstream after three RPCs', async () => {
|
||||
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
||||
const result = expect(
|
||||
client.getInstrumentByUid('token', 'uid'),
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(6000);
|
||||
await result;
|
||||
|
||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([status.UNAUTHENTICATED, status.PERMISSION_DENIED])(
|
||||
'does not retry or mask metadata authentication status %s',
|
||||
async (code) => {
|
||||
requestRpc.mockRejectedValue({ code });
|
||||
|
||||
await expect(client.getInstrumentByUid('token', 'uid')).rejects.toThrow(
|
||||
'INVALID_TBANK_TOKEN',
|
||||
);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['getInstrumentByUid', 'getInstrumentByFigi'] as const)(
|
||||
'returns confirmed absence from %s without retrying',
|
||||
async (method) => {
|
||||
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
|
||||
|
||||
await expect(client[method]('token', 'instrument')).resolves.toBeNull();
|
||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps confirmed absence from ShareBy as a not-found error', async () => {
|
||||
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
|
||||
|
||||
await expect(client.getShare('token', 'MISSING')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not retry invalid metadata arguments', async () => {
|
||||
requestRpc.mockRejectedValue({ code: status.INVALID_ARGUMENT });
|
||||
|
||||
await expect(
|
||||
client.getInstrumentByUid('token', 'uid'),
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps other RPCs failing immediately when the queue is full', async () => {
|
||||
queue.size = 50;
|
||||
|
||||
await expect(
|
||||
client.getPortfolio('token', 'account'),
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
|
||||
expect(queue.run).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,553 @@
|
||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||
import {
|
||||
ChannelCredentials,
|
||||
Client,
|
||||
ClientUnaryCall,
|
||||
loadPackageDefinition,
|
||||
Metadata,
|
||||
ServiceError,
|
||||
status,
|
||||
} from '@grpc/grpc-js';
|
||||
import { loadSync } from '@grpc/proto-loader';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import { Environment } from '../../config/env.validation';
|
||||
|
||||
import type {
|
||||
TbankBond,
|
||||
TbankCandle,
|
||||
TbankCoupon,
|
||||
TbankFundamentals,
|
||||
TbankLastPrice,
|
||||
TbankShare,
|
||||
} from './tbank-market.types';
|
||||
|
||||
const RATE_WINDOW_MS = 1_000;
|
||||
const METADATA_MAX_ATTEMPTS = 3;
|
||||
const RPC_MAX_ATTEMPTS = 2;
|
||||
const MAX_QUEUE_SIZE = 50;
|
||||
const METADATA_RETRY_DELAY_MS = 2_000;
|
||||
const RPC_RETRY_DELAY_MS = 1_000;
|
||||
|
||||
const requestDeadlineMs = 15_000;
|
||||
|
||||
type Unary<Request, Response> = (
|
||||
request: Request,
|
||||
metadata: Metadata,
|
||||
options: { deadline: Date },
|
||||
callback: (error: ServiceError | null, response: Response) => void,
|
||||
) => ClientUnaryCall;
|
||||
type TbankClientService = Client & Record<string, Unary<never, never>>;
|
||||
export type TbankDecimal = { units?: string | number; nano?: string | number };
|
||||
export type TbankMoney = TbankDecimal & { currency?: string };
|
||||
export type TbankAccount = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
openedDate?: { seconds?: string | number };
|
||||
};
|
||||
export type TbankPortfolio = {
|
||||
positions?: {
|
||||
figi?: string;
|
||||
instrumentUid?: string;
|
||||
instrumentType?: string;
|
||||
ticker?: string;
|
||||
quantity?: TbankDecimal;
|
||||
currentPrice?: TbankMoney;
|
||||
averagePositionPrice?: TbankMoney;
|
||||
}[];
|
||||
};
|
||||
export type TbankPositions = { money?: TbankMoney[] };
|
||||
export type TbankInstrument = {
|
||||
ticker?: string;
|
||||
name?: string;
|
||||
isin?: string;
|
||||
brand?: { logoName?: string | null };
|
||||
};
|
||||
export type TbankDividend = {
|
||||
dividendNet?: TbankMoney;
|
||||
recordDate?: TbankTimestamp;
|
||||
paymentDate?: TbankTimestamp;
|
||||
lastBuyDate?: TbankTimestamp;
|
||||
dividendType?: string;
|
||||
};
|
||||
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
|
||||
export type TbankOperationItem = {
|
||||
cursor?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
date?: TbankTimestamp;
|
||||
type?: string;
|
||||
description?: string;
|
||||
figi?: string;
|
||||
instrumentUid?: string;
|
||||
instrumentType?: string;
|
||||
ticker?: string;
|
||||
payment?: TbankMoney;
|
||||
price?: TbankMoney;
|
||||
commission?: TbankMoney;
|
||||
accruedInt?: TbankMoney;
|
||||
quantity?: string | number;
|
||||
quantityDone?: string | number;
|
||||
};
|
||||
export type TbankOperationsByCursorRequest = {
|
||||
accountId: string;
|
||||
instrumentId?: string;
|
||||
from?: TbankTimestamp;
|
||||
to?: TbankTimestamp;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
state: 'OPERATION_STATE_EXECUTED';
|
||||
};
|
||||
export type TbankOperationsByCursorResponse = {
|
||||
hasNext?: boolean;
|
||||
nextCursor?: string;
|
||||
items?: TbankOperationItem[];
|
||||
};
|
||||
type TbankNamespace = {
|
||||
tinkoff: {
|
||||
public: { invest: { api: { contract: { v1: Record<string, unknown> } } } };
|
||||
};
|
||||
};
|
||||
|
||||
class RateLimiter {
|
||||
private readonly waiting: (() => void)[] = [];
|
||||
private starts = 0;
|
||||
private windowStartedAt = Date.now();
|
||||
|
||||
constructor(private readonly perSecond: number) {}
|
||||
|
||||
get size(): number {
|
||||
return this.waiting.length;
|
||||
}
|
||||
|
||||
async run<T>(operation: () => Promise<T>): Promise<T> {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.waiting.push(resolve);
|
||||
this.drain();
|
||||
});
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
this.drain();
|
||||
}
|
||||
}
|
||||
|
||||
private drain(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
|
||||
this.windowStartedAt = now;
|
||||
this.starts = 0;
|
||||
}
|
||||
while (this.starts < this.perSecond && this.waiting.length) {
|
||||
this.starts += 1;
|
||||
this.waiting.shift()?.();
|
||||
}
|
||||
if (this.waiting.length && this.starts === this.perSecond) {
|
||||
setTimeout(
|
||||
() => this.drain(),
|
||||
Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TbankClient {
|
||||
private readonly queue: RateLimiter;
|
||||
private readonly users: TbankClientService;
|
||||
private readonly operations: TbankClientService;
|
||||
private readonly instruments: TbankClientService;
|
||||
private readonly sparseInstruments: TbankClientService;
|
||||
private readonly marketData: TbankClientService;
|
||||
|
||||
constructor(
|
||||
config: ConfigService<Environment, true>,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {
|
||||
const protoRoot = join(__dirname, 'proto/contracts');
|
||||
const definition = loadSync(
|
||||
[
|
||||
'users.proto',
|
||||
'operations.proto',
|
||||
'instruments.proto',
|
||||
'marketdata.proto',
|
||||
].map((file) => join(protoRoot, file)),
|
||||
{
|
||||
includeDirs: [protoRoot],
|
||||
keepCase: false,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
},
|
||||
);
|
||||
const services = (
|
||||
loadPackageDefinition(definition) as unknown as TbankNamespace
|
||||
).tinkoff.public.invest.api.contract.v1;
|
||||
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
|
||||
const credentials = caPath
|
||||
? ChannelCredentials.createSsl(readFileSync(caPath))
|
||||
: ChannelCredentials.createSsl();
|
||||
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
|
||||
const create = (name: string) =>
|
||||
new (
|
||||
services[name] as new (
|
||||
address: string,
|
||||
credentials: ChannelCredentials,
|
||||
) => TbankClientService
|
||||
)(endpoint, credentials);
|
||||
this.users = create('UsersService');
|
||||
this.operations = create('OperationsService');
|
||||
this.instruments = create('InstrumentsService');
|
||||
this.marketData = create('MarketDataService');
|
||||
// Preserve field presence for fundamentals; the established clients retain their decoding contract.
|
||||
const sparse = (
|
||||
loadPackageDefinition(
|
||||
loadSync(join(protoRoot, 'instruments.proto'), {
|
||||
includeDirs: [protoRoot],
|
||||
keepCase: false,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: false,
|
||||
oneofs: true,
|
||||
}),
|
||||
) as unknown as TbankNamespace
|
||||
).tinkoff.public.invest.api.contract.v1;
|
||||
this.sparseInstruments = new (
|
||||
sparse.InstrumentsService as new (
|
||||
address: string,
|
||||
credentials: ChannelCredentials,
|
||||
) => TbankClientService
|
||||
)(endpoint, credentials);
|
||||
this.queue = new RateLimiter(
|
||||
config.getOrThrow<number>('TBANK_RATE_LIMIT_PER_SECOND'),
|
||||
);
|
||||
this.logger.info('tbank.client.configured');
|
||||
}
|
||||
|
||||
async validateToken(token: string): Promise<void> {
|
||||
await this.getOpenAccounts(token);
|
||||
}
|
||||
|
||||
getOpenAccounts(token: string): Promise<TbankAccount[]> {
|
||||
return this.call<{ accounts?: TbankAccount[] }>(
|
||||
token,
|
||||
this.users,
|
||||
'getAccounts',
|
||||
{ status: 'ACCOUNT_STATUS_OPEN' },
|
||||
).then((response) => response.accounts ?? []);
|
||||
}
|
||||
|
||||
getPortfolio(token: string, accountId: string): Promise<TbankPortfolio> {
|
||||
return this.call(token, this.operations, 'getPortfolio', { accountId });
|
||||
}
|
||||
|
||||
getPositions(token: string, accountId: string): Promise<TbankPositions> {
|
||||
return this.call(token, this.operations, 'getPositions', { accountId });
|
||||
}
|
||||
|
||||
getOperationsByCursor(
|
||||
token: string,
|
||||
request: TbankOperationsByCursorRequest,
|
||||
): Promise<TbankOperationsByCursorResponse> {
|
||||
return this.call(token, this.operations, 'getOperationsByCursor', request);
|
||||
}
|
||||
|
||||
getInstrumentByFigi(
|
||||
token: string,
|
||||
figi: string,
|
||||
): Promise<TbankInstrument | null> {
|
||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
|
||||
}
|
||||
|
||||
getInstrumentByUid(
|
||||
token: string,
|
||||
instrumentUid: string,
|
||||
): Promise<TbankInstrument | null> {
|
||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
|
||||
}
|
||||
|
||||
getDividends(
|
||||
token: string,
|
||||
instrumentId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<TbankDividend[]> {
|
||||
return this.call<{ dividends?: TbankDividend[] }>(
|
||||
token,
|
||||
this.instruments,
|
||||
'getDividends',
|
||||
{
|
||||
instrumentId,
|
||||
from: { seconds: String(utcSeconds(from)) },
|
||||
to: { seconds: String(utcSeconds(to)) },
|
||||
},
|
||||
).then((response) => response.dividends ?? []);
|
||||
}
|
||||
|
||||
findShares(token: string, query: string): Promise<TbankShare[]> {
|
||||
return this.call<{ instruments?: TbankShare[] }>(
|
||||
token,
|
||||
this.instruments,
|
||||
'findInstrument',
|
||||
{
|
||||
query,
|
||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
||||
},
|
||||
).then((response) => response.instruments ?? []);
|
||||
}
|
||||
|
||||
findBonds(token: string, query: string): Promise<TbankBond[]> {
|
||||
return this.call<{ instruments?: TbankBond[] }>(
|
||||
token,
|
||||
this.sparseInstruments,
|
||||
'findInstrument',
|
||||
{
|
||||
query,
|
||||
instrumentKind: 'INSTRUMENT_TYPE_BOND',
|
||||
},
|
||||
).then((response) => response.instruments ?? []);
|
||||
}
|
||||
getBond(token: string, uid: string): Promise<TbankBond | null> {
|
||||
return this.call<{ instrument?: TbankBond }>(
|
||||
token,
|
||||
this.sparseInstruments,
|
||||
'bondBy',
|
||||
{
|
||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
||||
id: uid,
|
||||
},
|
||||
).then((response) => response.instrument ?? null);
|
||||
}
|
||||
getBondCoupons(
|
||||
token: string,
|
||||
uid: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<TbankCoupon[]> {
|
||||
return this.call<{ events?: TbankCoupon[] }>(
|
||||
token,
|
||||
this.sparseInstruments,
|
||||
'getBondCoupons',
|
||||
{
|
||||
instrumentId: uid,
|
||||
from: { seconds: String(utcSeconds(from)) },
|
||||
to: { seconds: String(utcSeconds(to)) },
|
||||
},
|
||||
).then((response) => response.events ?? []);
|
||||
}
|
||||
|
||||
getShare(token: string, uid: string): Promise<TbankShare | null> {
|
||||
return this.call<{ instrument?: TbankShare }>(
|
||||
token,
|
||||
this.instruments,
|
||||
'shareBy',
|
||||
{
|
||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
||||
id: uid,
|
||||
},
|
||||
).then((response) => response.instrument ?? null);
|
||||
}
|
||||
|
||||
getAssetDescription(token: string, assetUid: string): Promise<string | null> {
|
||||
return this.call<{ asset?: { description?: string } }>(
|
||||
token,
|
||||
this.instruments,
|
||||
'getAssetBy',
|
||||
{
|
||||
id: assetUid,
|
||||
},
|
||||
).then((response) => response.asset?.description?.trim() || null);
|
||||
}
|
||||
|
||||
getFundamentals(
|
||||
token: string,
|
||||
assetUid: string,
|
||||
): Promise<TbankFundamentals | null> {
|
||||
return this.call<{ fundamentals?: TbankFundamentals[] }>(
|
||||
token,
|
||||
this.sparseInstruments,
|
||||
'getAssetFundamentals',
|
||||
{
|
||||
assets: [assetUid],
|
||||
},
|
||||
).then(
|
||||
(response) =>
|
||||
response.fundamentals?.find((item) => item.assetUid === assetUid) ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
getLastPrices(token: string, uids: string[]): Promise<TbankLastPrice[]> {
|
||||
return this.call<{ lastPrices?: TbankLastPrice[] }>(
|
||||
token,
|
||||
this.marketData,
|
||||
'getLastPrices',
|
||||
{ instrumentId: uids },
|
||||
).then((response) => response.lastPrices ?? []);
|
||||
}
|
||||
|
||||
getLastPrice(token: string, uid: string): Promise<TbankLastPrice | null> {
|
||||
return this.getLastPrices(token, [uid]).then(
|
||||
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
getTradingStatus(token: string, uid: string): Promise<string | null> {
|
||||
return this.call<{ tradingStatus?: string }>(
|
||||
token,
|
||||
this.marketData,
|
||||
'getTradingStatus',
|
||||
{
|
||||
instrumentId: uid,
|
||||
},
|
||||
).then((response) => response.tradingStatus || null);
|
||||
}
|
||||
|
||||
getCandles(
|
||||
token: string,
|
||||
uid: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<TbankCandle[]> {
|
||||
return this.call<{ candles?: TbankCandle[] }>(
|
||||
token,
|
||||
this.marketData,
|
||||
'getCandles',
|
||||
{
|
||||
instrumentId: uid,
|
||||
from: {
|
||||
seconds: String(Math.floor(from.valueOf() / MILLISECONDS_PER_SECOND)),
|
||||
},
|
||||
to: {
|
||||
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
|
||||
},
|
||||
interval: 'CANDLE_INTERVAL_DAY',
|
||||
limit: 2400,
|
||||
},
|
||||
).then((response) => response.candles ?? []);
|
||||
}
|
||||
|
||||
private async getInstrument(
|
||||
token: string,
|
||||
idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI',
|
||||
id: string,
|
||||
): Promise<TbankInstrument | null> {
|
||||
try {
|
||||
const response = await this.call<{ instrument?: TbankInstrument }>(
|
||||
token,
|
||||
this.instruments,
|
||||
'getInstrumentBy',
|
||||
{ idType, id },
|
||||
);
|
||||
return response.instrument ?? null;
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundException) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async call<Response>(
|
||||
token: string,
|
||||
client: TbankClientService,
|
||||
method: string,
|
||||
request: object,
|
||||
): Promise<Response> {
|
||||
const metadataCall = method === 'getInstrumentBy';
|
||||
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
|
||||
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
|
||||
const startedAt = performance.now();
|
||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||
try {
|
||||
if (queueFull)
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
const response = await this.queue.run(() =>
|
||||
this.request<Response>(token, client, method, request),
|
||||
);
|
||||
this.logger.event('debug', 'tbank.rpc.completed', {
|
||||
operation: method,
|
||||
attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
const grpcCode = (error as ServiceError).code;
|
||||
const failureContext = queueFull
|
||||
? { code: 'TBANK_UNAVAILABLE' }
|
||||
: { grpcCode };
|
||||
const retryable = queueFull
|
||||
? metadataCall
|
||||
: [
|
||||
status.UNAVAILABLE,
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
].includes(grpcCode);
|
||||
if (retryable && attempts < maxAttempts) {
|
||||
this.logger.event('warn', 'tbank.rpc.retry', {
|
||||
operation: method,
|
||||
...failureContext,
|
||||
attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(
|
||||
resolve,
|
||||
metadataCall
|
||||
? attempts * METADATA_RETRY_DELAY_MS
|
||||
: RPC_RETRY_DELAY_MS,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
this.logger.event('error', 'tbank.rpc.failed', {
|
||||
operation: method,
|
||||
...failureContext,
|
||||
attempts: queueFull && !metadataCall ? 0 : attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
if (
|
||||
[status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode)
|
||||
)
|
||||
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
|
||||
throw new Error('INVALID_TBANK_TOKEN');
|
||||
if (
|
||||
grpcCode === status.NOT_FOUND &&
|
||||
(method === 'shareBy' || metadataCall)
|
||||
)
|
||||
throw new NotFoundException();
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
}
|
||||
}
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
}
|
||||
|
||||
private request<Response>(
|
||||
token: string,
|
||||
client: TbankClientService,
|
||||
method: string,
|
||||
request: object,
|
||||
): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const metadata = new Metadata();
|
||||
metadata.set('Authorization', `Bearer ${token}`);
|
||||
client[method](
|
||||
request as never,
|
||||
metadata,
|
||||
{ deadline: new Date(Date.now() + requestDeadlineMs) },
|
||||
(error, response) => (error ? reject(error) : resolve(response)),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function utcSeconds(date: string): number {
|
||||
return Math.floor(Date.parse(`${date}T00:00:00Z`) / MILLISECONDS_PER_SECOND);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
const KEY_SIZE_BYTES = 32;
|
||||
|
||||
const VERSION = 1;
|
||||
const NONCE_SIZE = 12;
|
||||
const TAG_SIZE = 16;
|
||||
@@ -9,7 +11,8 @@ export class TbankCrypto {
|
||||
|
||||
static fromBase64(key: string): TbankCrypto {
|
||||
const decoded = Buffer.from(key, 'base64');
|
||||
if (decoded.length !== 32) throw new Error('Invalid token encryption key');
|
||||
if (decoded.length !== KEY_SIZE_BYTES)
|
||||
throw new Error('Invalid token encryption key');
|
||||
return new TbankCrypto(decoded);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
TbankClient,
|
||||
TbankTokenService,
|
||||
TbankAccessService,
|
||||
InstrumentMetadataService,
|
||||
],
|
||||
exports: [
|
||||
TbankClient,
|
||||
TbankTokenService,
|
||||
TbankAccessService,
|
||||
InstrumentMetadataService,
|
||||
],
|
||||
})
|
||||
export class TbankModule {}
|
||||
+4
-9
@@ -2,7 +2,8 @@ import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
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 { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
@@ -21,7 +22,7 @@ export function configureApp(app: INestApplication): void {
|
||||
credentials: true,
|
||||
exposedHeaders: ['X-Request-ID'],
|
||||
});
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.setGlobalPrefix(API_PREFIX);
|
||||
app.use(cookieParser());
|
||||
const requestLogging = app.get(RequestLoggingMiddleware);
|
||||
app.use(requestLogging.use.bind(requestLogging));
|
||||
@@ -36,13 +37,7 @@ async function bootstrap() {
|
||||
const config = app.get<ConfigService<Environment, true>>(ConfigService);
|
||||
|
||||
if (shouldExposeSwagger(process.env['NODE_ENV'])) {
|
||||
const document = SwaggerModule.createDocument(
|
||||
app,
|
||||
new DocumentBuilder()
|
||||
.setTitle('WealthLedger API')
|
||||
.setVersion('v1')
|
||||
.build(),
|
||||
);
|
||||
const document = createOpenApiDocument(app);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ jest.mock('bcrypt', () => ({
|
||||
compare: jest.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
jest.mock('../prisma/prisma.service', () => ({
|
||||
jest.mock('../../../prisma/prisma.service', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { Session, User } from '../../generated/prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Session, User } from '../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { AuthUser, RefreshPayload } from './auth.types';
|
||||
|
||||
const PASSWORD_HASH_ROUNDS = 12;
|
||||
const REFRESH_TTL_MS = 604_800_000;
|
||||
|
||||
const ACCESS_TTL = '15m';
|
||||
const REFRESH_TTL = '7d';
|
||||
const DUMMY_PASSWORD_HASH =
|
||||
@@ -39,7 +42,7 @@ export class AuthService {
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
email: normalizedEmail,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -162,7 +165,7 @@ export class AuthService {
|
||||
);
|
||||
const data = {
|
||||
refreshTokenHash: this.hash(refreshToken),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
|
||||
};
|
||||
if (expectedRefreshTokenHash) {
|
||||
const updated = await this.prisma.session.updateMany({
|
||||
@@ -0,0 +1,3 @@
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { type AccessPayload } from './application/auth.types';
|
||||
export { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||
@@ -2,11 +2,11 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { Environment } from '../config/env.validation';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { Environment } from '../../config/env.validation';
|
||||
import { AuthController } from './presentation/auth.controller';
|
||||
import { AuthService } from './application/auth.service';
|
||||
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -19,6 +19,7 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
}),
|
||||
}),
|
||||
],
|
||||
exports: [JwtAuthGuard],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, JwtAuthGuard],
|
||||
})
|
||||
@@ -2,8 +2,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { Environment } from '../config/env.validation';
|
||||
import { AccessPayload } from './auth.types';
|
||||
import { Environment } from '../../../config/env.validation';
|
||||
import { AccessPayload } from '../application/auth.types';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
|
||||
|
||||
jest.mock('./auth.service', () => ({
|
||||
jest.mock('../application/auth.service', () => ({
|
||||
AuthService: class AuthService {},
|
||||
}));
|
||||
|
||||
@@ -3,11 +3,12 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
@@ -17,29 +18,29 @@ import {
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { CredentialsDto } from './auth.dto';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AccessPayload } from './auth.types';
|
||||
import { AuthService } from '../application/auth.service';
|
||||
import { AccessPayload } from '../application/auth.types';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token';
|
||||
const COOKIE_PATH = '/api/v1/auth';
|
||||
|
||||
class UserDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
declare id: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
declare email: string;
|
||||
}
|
||||
|
||||
class AuthenticationDto {
|
||||
@ApiProperty()
|
||||
accessToken: string;
|
||||
declare accessToken: string;
|
||||
|
||||
@ApiProperty({ type: UserDto })
|
||||
user: UserDto;
|
||||
declare user: UserDto;
|
||||
}
|
||||
|
||||
@ApiTags('auth')
|
||||
@@ -49,7 +50,7 @@ export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@HttpCode(200)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOkResponse({ type: AuthenticationDto })
|
||||
async register(
|
||||
@Body() credentials: CredentialsDto,
|
||||
@@ -62,7 +63,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOkResponse({ type: AuthenticationDto })
|
||||
async login(
|
||||
@Body() credentials: CredentialsDto,
|
||||
@@ -75,7 +76,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(200)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOkResponse({ type: AuthenticationDto })
|
||||
async refresh(
|
||||
@Req() request: Request,
|
||||
@@ -92,7 +93,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(204)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiNoContentResponse()
|
||||
async logout(
|
||||
@Req() request: Request,
|
||||
@@ -1,26 +1,32 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsString,
|
||||
MinLength,
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
const MAX_EMAIL_LENGTH = 254;
|
||||
const ASCII_SPACE = 32;
|
||||
const ASCII_DELETE = 127;
|
||||
const MIN_PASSWORD_LENGTH = 12;
|
||||
const MAX_PASSWORD_BYTES = 72;
|
||||
|
||||
@ValidatorConstraint({ name: 'email' })
|
||||
class EmailConstraint {
|
||||
validate(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length <= 254 &&
|
||||
value.length <= MAX_EMAIL_LENGTH &&
|
||||
value.split('@').length === 2 &&
|
||||
!value
|
||||
.split('')
|
||||
.some(
|
||||
(character) =>
|
||||
/\s/.test(character) ||
|
||||
character.charCodeAt(0) < 32 ||
|
||||
character.charCodeAt(0) === 127,
|
||||
character.charCodeAt(0) < ASCII_SPACE ||
|
||||
character.charCodeAt(0) === ASCII_DELETE,
|
||||
) &&
|
||||
value.split('@').every(Boolean)
|
||||
);
|
||||
@@ -33,7 +39,10 @@ class EmailConstraint {
|
||||
@ValidatorConstraint({ name: 'passwordBytes' })
|
||||
class PasswordBytesConstraint {
|
||||
validate(value: unknown): boolean {
|
||||
return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 72;
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
|
||||
);
|
||||
}
|
||||
defaultMessage(): string {
|
||||
return 'must not exceed 72 UTF-8 bytes';
|
||||
@@ -47,11 +56,15 @@ export class CredentialsDto {
|
||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||
)
|
||||
@Validate(EmailConstraint)
|
||||
email: string;
|
||||
declare email: string;
|
||||
|
||||
@ApiProperty({ minLength: 12, maxLength: 72, example: 'a secure password' })
|
||||
@ApiProperty({
|
||||
minLength: MIN_PASSWORD_LENGTH,
|
||||
maxLength: MAX_PASSWORD_BYTES,
|
||||
example: 'a secure password',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(12)
|
||||
@MinLength(MIN_PASSWORD_LENGTH)
|
||||
@Validate(PasswordBytesConstraint)
|
||||
password: string;
|
||||
declare password: string;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { RequestContextService } from '../common/logging/request-context.service';
|
||||
import { AccessPayload } from './auth.types';
|
||||
import { RequestContextService } from '../../../common/logging/request-context.service';
|
||||
import { AccessPayload } from '../application/auth.types';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
MAX_CARD_NAME_LENGTH,
|
||||
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||
} from '../domain/bank-card-limits';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
bankPaymentSystems,
|
||||
BankPeriodQueryDto,
|
||||
CreateBankCardDto,
|
||||
CreateBankOperationDto,
|
||||
} from '../presentation/bank-card.dto';
|
||||
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
|
||||
import { parseRubMoney } from '../domain/rub-money';
|
||||
|
||||
export const BankErrorCode = {
|
||||
InvalidInput: 'BANK_INVALID_INPUT',
|
||||
VersionConflict: 'BANK_VERSION_CONFLICT',
|
||||
NotFound: 'BANK_NOT_FOUND',
|
||||
InvalidMoney: 'BANK_INVALID_MONEY',
|
||||
InvalidDate: 'BANK_INVALID_DATE',
|
||||
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION',
|
||||
InvalidRefund: 'BANK_INVALID_REFUND',
|
||||
RefundLimit: 'BANK_REFUND_LIMIT',
|
||||
DependentRefunds: 'BANK_DEPENDENT_REFUNDS',
|
||||
IntentConflict: 'BANK_INTENT_CONFLICT',
|
||||
OperationDeleted: 'BANK_OPERATION_DELETED',
|
||||
CardArchived: 'BANK_CARD_ARCHIVED',
|
||||
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT',
|
||||
WriteConflict: 'BANK_WRITE_CONFLICT',
|
||||
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY',
|
||||
} as const;
|
||||
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
|
||||
|
||||
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
|
||||
throw new BadRequestException({ code });
|
||||
}
|
||||
export function periodBounds(query: BankPeriodQueryDto) {
|
||||
if (query.month && !query.from && !query.to) {
|
||||
const from = `${query.month}-01`;
|
||||
if (!isCalendarDate(from)) return fail();
|
||||
const end = new Date(`${from}T00:00:00Z`);
|
||||
end.setUTCMonth(end.getUTCMonth() + 1, 0);
|
||||
return { from, to: end.toISOString().slice(0, 10) };
|
||||
}
|
||||
if (
|
||||
query.month ||
|
||||
!query.from ||
|
||||
!query.to ||
|
||||
!isCalendarDate(query.from) ||
|
||||
!isCalendarDate(query.to) ||
|
||||
query.from > query.to
|
||||
)
|
||||
return fail();
|
||||
return { from: query.from, to: query.to };
|
||||
}
|
||||
export function conflict(
|
||||
code: BankErrorCode = BankErrorCode.VersionConflict,
|
||||
): never {
|
||||
throw new ConflictException({ code });
|
||||
}
|
||||
export function missing(): never {
|
||||
throw new NotFoundException({ code: BankErrorCode.NotFound });
|
||||
}
|
||||
export const fingerprint = (value: unknown) =>
|
||||
createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify(value, (_key, item: unknown) =>
|
||||
typeof item === 'bigint' ? item.toString() : item,
|
||||
),
|
||||
)
|
||||
.digest('hex');
|
||||
export function amount(
|
||||
value: Parameters<typeof parseRubMoney>[0],
|
||||
positive = false,
|
||||
) {
|
||||
try {
|
||||
return parseRubMoney(value, positive);
|
||||
} catch {
|
||||
return fail(BankErrorCode.InvalidMoney);
|
||||
}
|
||||
}
|
||||
export function calendar(value: string) {
|
||||
if (!isCalendarDate(value)) fail(BankErrorCode.InvalidDate);
|
||||
return value;
|
||||
}
|
||||
export function currentDate(value: string) {
|
||||
calendar(value);
|
||||
if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
|
||||
}
|
||||
export function name(value: string, max: number) {
|
||||
if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
|
||||
fail();
|
||||
return value.trim();
|
||||
}
|
||||
export function cardInput(dto: CreateBankCardDto) {
|
||||
const bankName = dto.bankName?.trim() || null;
|
||||
if (bankName && bankName.length > MAX_CARD_NAME_LENGTH) fail();
|
||||
if (dto.lastFour != null && !/^\d{4}$/.test(dto.lastFour)) fail();
|
||||
if (
|
||||
dto.paymentSystem != null &&
|
||||
!bankPaymentSystems.some((system) => system === dto.paymentSystem)
|
||||
)
|
||||
fail();
|
||||
return {
|
||||
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
|
||||
name: name(dto.name, MAX_CARD_NAME_LENGTH),
|
||||
bankName,
|
||||
lastFour: dto.lastFour || null,
|
||||
openingBalanceKopecks: amount(dto.openingBalance),
|
||||
openingDate: calendar(dto.openingDate),
|
||||
};
|
||||
}
|
||||
export function operationInput(dto: CreateBankOperationDto) {
|
||||
if (
|
||||
dto.description != null &&
|
||||
(typeof dto.description !== 'string' ||
|
||||
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
|
||||
)
|
||||
fail();
|
||||
return {
|
||||
kind: dto.kind,
|
||||
cardId: dto.cardId,
|
||||
targetCardId: dto.targetCardId ?? null,
|
||||
categoryId: dto.categoryId ?? null,
|
||||
originalExpenseId: dto.originalExpenseId ?? null,
|
||||
amountKopecks: amount(dto.amount, true),
|
||||
date: calendar(dto.date),
|
||||
description: dto.description || null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import {
|
||||
BankCardDto,
|
||||
CreateBankCardDto,
|
||||
UpdateBankCardDto,
|
||||
} from '../presentation/bank-card.dto';
|
||||
import { bankBalance } from '../domain/bank-card.calculations';
|
||||
import { rubMoney } from '../domain/rub-money';
|
||||
|
||||
import {
|
||||
cardInput,
|
||||
BankErrorCode,
|
||||
conflict,
|
||||
currentDate,
|
||||
fail,
|
||||
fingerprint,
|
||||
missing,
|
||||
} from './bank-card-input';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
type Tx = Prisma.TransactionClient;
|
||||
@Injectable()
|
||||
export class BankCardsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: BankCardsRepository,
|
||||
) {}
|
||||
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
|
||||
const rows = await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId: card.userId,
|
||||
deletedAt: null,
|
||||
OR: [{ cardId: card.id }, { targetCardId: card.id }],
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
bankName: card.bankName,
|
||||
lastFour: card.lastFour,
|
||||
paymentSystem: card.paymentSystem,
|
||||
openingDate: card.openingDate,
|
||||
openingBalance: rubMoney(card.openingBalanceKopecks),
|
||||
balance: rubMoney(bankBalance(card.id, card.openingBalanceKopecks, rows)),
|
||||
version: card.version,
|
||||
archivedAt: card.archivedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
list(userId: string, includeArchived = false) {
|
||||
return this.prisma.$transaction(async (tx) =>
|
||||
Promise.all(
|
||||
(
|
||||
await tx.bankCard.findMany({
|
||||
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
).map((card) => this.cardView(tx, card)),
|
||||
),
|
||||
);
|
||||
}
|
||||
get(userId: string, id: string) {
|
||||
return this.prisma.$transaction(async (tx) =>
|
||||
this.cardView(tx, await this.repository.card(tx, userId, id)),
|
||||
);
|
||||
}
|
||||
createCard(userId: string, dto: CreateBankCardDto) {
|
||||
const input = cardInput(dto),
|
||||
hash = fingerprint(input);
|
||||
return this.repository.write(async (tx) => {
|
||||
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
|
||||
if (existing) {
|
||||
if (existing.userId !== userId) missing();
|
||||
if (existing.createFingerprint !== hash)
|
||||
conflict(BankErrorCode.IntentConflict);
|
||||
return this.cardView(tx, existing);
|
||||
}
|
||||
currentDate(input.openingDate);
|
||||
await this.repository.seed(tx, userId);
|
||||
return this.cardView(
|
||||
tx,
|
||||
await tx.bankCard.create({
|
||||
data: { ...input, id: dto.id, userId, createFingerprint: hash },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
|
||||
return this.repository.write(async (tx) => {
|
||||
const previous = await this.repository.card(tx, userId, id);
|
||||
if (previous.version !== dto.version) conflict();
|
||||
const input = cardInput({
|
||||
id,
|
||||
name: dto.name ?? previous.name,
|
||||
bankName:
|
||||
dto.bankName === undefined
|
||||
? (previous.bankName ?? undefined)
|
||||
: (dto.bankName ?? undefined),
|
||||
lastFour:
|
||||
dto.lastFour === undefined
|
||||
? (previous.lastFour ?? undefined)
|
||||
: (dto.lastFour ?? undefined),
|
||||
paymentSystem:
|
||||
dto.paymentSystem === undefined
|
||||
? previous.paymentSystem
|
||||
: dto.paymentSystem,
|
||||
openingBalance:
|
||||
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
|
||||
openingDate: dto.openingDate ?? previous.openingDate,
|
||||
});
|
||||
const financial =
|
||||
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
|
||||
input.openingDate !== previous.openingDate;
|
||||
if (financial && previous.archivedAt)
|
||||
conflict(BankErrorCode.CardArchived);
|
||||
currentDate(input.openingDate);
|
||||
if (
|
||||
await tx.bankCardOperation.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
date: { lt: input.openingDate },
|
||||
OR: [{ cardId: id }, { targetCardId: id }],
|
||||
},
|
||||
})
|
||||
)
|
||||
fail(BankErrorCode.OpeningDateConflict);
|
||||
const changed = await tx.bankCard.updateMany({
|
||||
where: { id, userId, version: dto.version },
|
||||
data: {
|
||||
...input,
|
||||
paymentSystem: input.paymentSystem ?? null,
|
||||
version: { increment: 1 },
|
||||
...(dto.archived === undefined
|
||||
? {}
|
||||
: {
|
||||
archivedAt: dto.archived
|
||||
? (previous.archivedAt ?? new Date())
|
||||
: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (changed.count !== 1) conflict();
|
||||
return this.cardView(tx, await this.repository.card(tx, userId, id));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||
import { GoneException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BankCardOperation,
|
||||
Prisma,
|
||||
} from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import {
|
||||
BankOperationDto,
|
||||
DEFAULT_BANK_PAGE_SIZE,
|
||||
BankOperationsQueryDto,
|
||||
BankPageQueryDto,
|
||||
CreateBankOperationDto,
|
||||
UpdateBankOperationDto,
|
||||
} from '../presentation/bank-card.dto';
|
||||
import { rubMoney } from '../domain/rub-money';
|
||||
|
||||
import {
|
||||
BankErrorCode,
|
||||
conflict,
|
||||
currentDate,
|
||||
fail,
|
||||
fingerprint,
|
||||
missing,
|
||||
operationInput,
|
||||
periodBounds,
|
||||
} from './bank-card-input';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
type Tx = Prisma.TransactionClient;
|
||||
@Injectable()
|
||||
export class BankOperationsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: BankCardsRepository,
|
||||
) {}
|
||||
private async operationView(
|
||||
tx: Tx,
|
||||
row: BankCardOperation,
|
||||
): Promise<BankOperationDto> {
|
||||
const card = await this.repository.card(tx, row.userId, row.cardId);
|
||||
const target = row.targetCardId
|
||||
? await this.repository.card(tx, row.userId, row.targetCardId)
|
||||
: null;
|
||||
const source = row.originalExpenseId
|
||||
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
|
||||
: null;
|
||||
const categoryId = source?.categoryId ?? row.categoryId;
|
||||
const category = categoryId
|
||||
? await tx.expenseCategory.findFirst({
|
||||
where: { id: categoryId, userId: row.userId },
|
||||
})
|
||||
: null;
|
||||
const refunds =
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId: row.userId,
|
||||
originalExpenseId: source?.id ?? row.id,
|
||||
deletedAt: null,
|
||||
...(source ? { id: { not: row.id } } : {}),
|
||||
},
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as BankOperationDto['kind'],
|
||||
cardId: row.cardId,
|
||||
cardName: card.name,
|
||||
targetCardId: row.targetCardId,
|
||||
targetCardName: target?.name ?? null,
|
||||
categoryId,
|
||||
categoryName: category?.name ?? null,
|
||||
originalExpenseId: row.originalExpenseId,
|
||||
amount: rubMoney(row.amountKopecks),
|
||||
availableRefund:
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? rubMoney(
|
||||
(source?.amountKopecks ?? row.amountKopecks) -
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
|
||||
)
|
||||
: null,
|
||||
date: row.date,
|
||||
description: row.description,
|
||||
version: row.version,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
private async validateOperation(
|
||||
tx: Tx,
|
||||
userId: string,
|
||||
input: ReturnType<typeof operationInput>,
|
||||
old?: BankCardOperation,
|
||||
) {
|
||||
const cards = new Set(
|
||||
[input.cardId, input.targetCardId, old?.cardId, old?.targetCardId].filter(
|
||||
(id): id is string => Boolean(id),
|
||||
),
|
||||
);
|
||||
currentDate(input.date);
|
||||
for (const id of cards) {
|
||||
const card = await this.repository.card(tx, userId, id, true);
|
||||
if (
|
||||
(id === input.cardId || id === input.targetCardId) &&
|
||||
input.date < card.openingDate
|
||||
)
|
||||
fail(BankErrorCode.InvalidDate);
|
||||
}
|
||||
if (
|
||||
old &&
|
||||
(old.kind !== input.kind ||
|
||||
old.originalExpenseId !== input.originalExpenseId)
|
||||
)
|
||||
fail(BankErrorCode.ImmutableOperation);
|
||||
if (input.kind === BankOperationKind.Transfer) {
|
||||
if (
|
||||
!input.targetCardId ||
|
||||
input.targetCardId === input.cardId ||
|
||||
input.categoryId ||
|
||||
input.originalExpenseId
|
||||
)
|
||||
fail();
|
||||
} else if (input.targetCardId) fail();
|
||||
if (input.kind === BankOperationKind.Expense) {
|
||||
if (input.originalExpenseId) fail();
|
||||
if (!input.categoryId) {
|
||||
const category = await tx.expenseCategory.findUnique({
|
||||
where: { userId_seedKey: { userId, seedKey: 'other' } },
|
||||
});
|
||||
if (!category) missing();
|
||||
input.categoryId = category.id;
|
||||
}
|
||||
if (
|
||||
!(await tx.expenseCategory.findFirst({
|
||||
where: { userId, id: input.categoryId },
|
||||
}))
|
||||
)
|
||||
missing();
|
||||
} else if (input.categoryId) fail();
|
||||
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId)
|
||||
fail();
|
||||
if (input.kind === BankOperationKind.Refund) {
|
||||
if (!input.originalExpenseId) fail();
|
||||
const source = await this.repository.operation(
|
||||
tx,
|
||||
userId,
|
||||
input.originalExpenseId,
|
||||
);
|
||||
if (
|
||||
source.deletedAt ||
|
||||
source.kind !== BankOperationKind.Expense ||
|
||||
source.cardId !== input.cardId
|
||||
)
|
||||
fail(BankErrorCode.InvalidRefund);
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: {
|
||||
id: source.id,
|
||||
userId,
|
||||
version: source.version,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
conflict();
|
||||
const refunds = await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
originalExpenseId: source.id,
|
||||
deletedAt: null,
|
||||
...(old ? { id: { not: old.id } } : {}),
|
||||
},
|
||||
});
|
||||
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
||||
if (
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
|
||||
input.amountKopecks >
|
||||
source.amountKopecks
|
||||
)
|
||||
conflict(BankErrorCode.RefundLimit);
|
||||
}
|
||||
if (old?.kind === BankOperationKind.Expense) {
|
||||
const refunds = await tx.bankCardOperation.findMany({
|
||||
where: { userId, originalExpenseId: old.id, deletedAt: null },
|
||||
});
|
||||
if (
|
||||
refunds.length &&
|
||||
(input.cardId !== old.cardId ||
|
||||
refunds.some((row) => row.date < input.date) ||
|
||||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
|
||||
input.amountKopecks)
|
||||
)
|
||||
conflict(BankErrorCode.DependentRefunds);
|
||||
}
|
||||
}
|
||||
createOperation(userId: string, dto: CreateBankOperationDto) {
|
||||
const normalized = operationInput(dto),
|
||||
hash = fingerprint(normalized);
|
||||
return this.repository.write(async (tx) => {
|
||||
const existing = await tx.bankCardOperation.findUnique({
|
||||
where: { id: dto.id },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.userId !== userId) missing();
|
||||
if (existing.createFingerprint !== hash)
|
||||
conflict(BankErrorCode.IntentConflict);
|
||||
if (existing.deletedAt)
|
||||
throw new GoneException({ code: BankErrorCode.OperationDeleted });
|
||||
return this.operationView(tx, existing);
|
||||
}
|
||||
const input = { ...normalized };
|
||||
await this.validateOperation(tx, userId, input);
|
||||
return this.operationView(
|
||||
tx,
|
||||
await tx.bankCardOperation.create({
|
||||
data: { ...input, id: dto.id, userId, createFingerprint: hash },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
getOperation(userId: string, id: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const row = await this.repository.operation(tx, userId, id);
|
||||
if (row.deletedAt) missing();
|
||||
return this.operationView(tx, row);
|
||||
});
|
||||
}
|
||||
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
|
||||
return this.repository.write(async (tx) => {
|
||||
const old = await this.repository.operation(tx, userId, id);
|
||||
if (old.deletedAt) missing();
|
||||
if (old.version !== dto.version) conflict();
|
||||
const input = operationInput({
|
||||
id,
|
||||
kind: dto.kind ?? (old.kind as CreateBankOperationDto['kind']),
|
||||
cardId: dto.cardId ?? old.cardId,
|
||||
targetCardId: dto.targetCardId ?? old.targetCardId ?? undefined,
|
||||
categoryId: dto.categoryId ?? old.categoryId ?? undefined,
|
||||
originalExpenseId:
|
||||
dto.originalExpenseId ?? old.originalExpenseId ?? undefined,
|
||||
amount: dto.amount ?? rubMoney(old.amountKopecks),
|
||||
date: dto.date ?? old.date,
|
||||
description: dto.description ?? old.description ?? undefined,
|
||||
});
|
||||
await this.validateOperation(tx, userId, input, old);
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: { id, userId, version: dto.version, deletedAt: null },
|
||||
data: { ...input, version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
conflict();
|
||||
return this.operationView(
|
||||
tx,
|
||||
await this.repository.operation(tx, userId, id),
|
||||
);
|
||||
});
|
||||
}
|
||||
deleteOperation(userId: string, id: string, version: number) {
|
||||
return this.repository.write(async (tx) => {
|
||||
const row = await this.repository.operation(tx, userId, id);
|
||||
if (row.deletedAt) return;
|
||||
if (row.version !== version) conflict();
|
||||
await this.repository.card(tx, userId, row.cardId, true);
|
||||
if (row.targetCardId)
|
||||
await this.repository.card(tx, userId, row.targetCardId, true);
|
||||
if (
|
||||
row.kind === BankOperationKind.Expense &&
|
||||
(await tx.bankCardOperation.findFirst({
|
||||
where: { userId, originalExpenseId: id, deletedAt: null },
|
||||
}))
|
||||
)
|
||||
conflict(BankErrorCode.DependentRefunds);
|
||||
if (row.originalExpenseId) {
|
||||
const source = await this.repository.operation(
|
||||
tx,
|
||||
userId,
|
||||
row.originalExpenseId,
|
||||
);
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: { id: source.id, userId, version: source.version },
|
||||
data: { version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
conflict();
|
||||
}
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: { id, userId, version, deletedAt: null },
|
||||
data: { deletedAt: new Date(), version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
conflict();
|
||||
});
|
||||
}
|
||||
history(
|
||||
userId: string,
|
||||
id: string,
|
||||
query: BankOperationsQueryDto | BankPageQueryDto,
|
||||
expenses = false,
|
||||
) {
|
||||
const period = expenses
|
||||
? undefined
|
||||
: periodBounds(query as BankOperationsQueryDto);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await this.repository.card(tx, userId, id);
|
||||
const page = query.page ?? 1,
|
||||
limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE;
|
||||
const where: Prisma.BankCardOperationWhereInput = {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
OR: [{ cardId: id }, { targetCardId: id }],
|
||||
...(period
|
||||
? { date: { gte: period.from, lte: period.to } }
|
||||
: { kind: BankOperationKind.Expense }),
|
||||
};
|
||||
const total = await tx.bankCardOperation.count({ where });
|
||||
const rows = await tx.bankCardOperation.findMany({
|
||||
where,
|
||||
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
return {
|
||||
items: await Promise.all(
|
||||
rows.map((row) => this.operationView(tx, row)),
|
||||
),
|
||||
total,
|
||||
page,
|
||||
hasNext: page * limit < total,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
|
||||
import { conflict, missing, name } from './bank-card-input';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
@Injectable()
|
||||
export class ExpenseCategoriesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: BankCardsRepository,
|
||||
) {}
|
||||
async categories(userId: string) {
|
||||
return (
|
||||
await this.prisma.expenseCategory.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
).map(({ id, name, seedKey, version }) => ({
|
||||
id,
|
||||
name,
|
||||
isDefault: seedKey === 'other',
|
||||
version,
|
||||
}));
|
||||
}
|
||||
createCategory(userId: string, value: string) {
|
||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||
return this.repository.write(async (tx) => {
|
||||
await this.repository.seed(tx, userId);
|
||||
const row = await tx.expenseCategory.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
name: label,
|
||||
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
isDefault: false,
|
||||
version: row.version,
|
||||
};
|
||||
});
|
||||
}
|
||||
updateCategory(userId: string, id: string, value: string, version: number) {
|
||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||
return this.repository.write(async (tx) => {
|
||||
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
|
||||
if (!row) missing();
|
||||
if (
|
||||
(
|
||||
await tx.expenseCategory.updateMany({
|
||||
where: { id, userId, version },
|
||||
data: {
|
||||
name: label,
|
||||
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||
version: { increment: 1 },
|
||||
},
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
conflict();
|
||||
return {
|
||||
id,
|
||||
name: label,
|
||||
isDefault: row.seedKey === 'other',
|
||||
version: version + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
jest.mock('../../../../../generated/prisma/client', () => ({ Prisma: {} }));
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
import { GetBankSummaryUseCase } from './get-bank-summary.use-case';
|
||||
|
||||
describe('bank card summary categories', () => {
|
||||
it('resolves category names and rejects an inconsistent category reference', async () => {
|
||||
const categories = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'food', name: 'Продукты' }]);
|
||||
const tx = {
|
||||
bankCardOperation: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
kind: 'expense',
|
||||
categoryId: 'food',
|
||||
amountKopecks: 123n,
|
||||
date: '2026-09-13',
|
||||
originalExpense: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
expenseCategory: { findMany: categories },
|
||||
};
|
||||
const service = new GetBankSummaryUseCase(
|
||||
new BankCardsRepository({
|
||||
$transaction: (run: (client: typeof tx) => unknown) => run(tx),
|
||||
} as unknown as PrismaService),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.execute('user', { month: '2026-09' }),
|
||||
).resolves.toMatchObject({
|
||||
categories: [
|
||||
{
|
||||
categoryId: 'food',
|
||||
categoryName: 'Продукты',
|
||||
net: { value: '1.23' },
|
||||
},
|
||||
],
|
||||
});
|
||||
categories.mockResolvedValue([]);
|
||||
await expect(service.execute('user', { month: '2026-09' })).rejects.toThrow(
|
||||
'Bank operation category is missing',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
|
||||
import { bankSummary } from '../domain/bank-card.calculations';
|
||||
import { rubMoney } from '../domain/rub-money';
|
||||
|
||||
import { periodBounds } from './bank-card-input';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
@Injectable()
|
||||
export class GetBankSummaryUseCase {
|
||||
constructor(private readonly repository: BankCardsRepository) {}
|
||||
|
||||
async execute(userId: string, query: BankSummaryQueryDto) {
|
||||
const period = periodBounds(query);
|
||||
const { operations, categories } = await this.repository.summaryData(
|
||||
userId,
|
||||
period,
|
||||
query.cardId,
|
||||
);
|
||||
const totals = bankSummary(period, operations);
|
||||
return {
|
||||
month: query.month,
|
||||
...period,
|
||||
cardId: query.cardId ?? null,
|
||||
expenses: rubMoney(totals.expenses),
|
||||
refunds: rubMoney(totals.refunds),
|
||||
net: rubMoney(totals.net),
|
||||
categories: totals.categories.map((row) => {
|
||||
const category = categories.find((c) => c.id === row.categoryId);
|
||||
if (!category) throw new Error('Bank operation category is missing');
|
||||
return {
|
||||
categoryId: row.categoryId,
|
||||
categoryName: category.name,
|
||||
expenses: rubMoney(row.expenses),
|
||||
refunds: rubMoney(row.refunds),
|
||||
net: rubMoney(row.net),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { BankCardsController } from './presentation/bank-cards.controller';
|
||||
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
|
||||
import { BankCardsService } from './application/bank-cards.service';
|
||||
import { BankOperationsService } from './application/bank-operations.service';
|
||||
import { ExpenseCategoriesService } from './application/expense-categories.service';
|
||||
import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case';
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [BankCardsController],
|
||||
providers: [
|
||||
GetBankSummaryUseCase,
|
||||
ExpenseCategoriesService,
|
||||
BankOperationsService,
|
||||
BankCardsRepository,
|
||||
BankCardsService,
|
||||
],
|
||||
})
|
||||
export class BankCardsModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user