Compare commits
14
Commits
3cf883efbc
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
660b250ecd | ||
|
|
f01b82d366 | ||
|
|
d4fc28cbbf | ||
|
|
97e864bc06 | ||
|
|
121af701b7 | ||
|
|
2bc8745495 | ||
|
|
4c8ba2fac9 | ||
|
|
a5156fbeb5 | ||
|
|
197ffe2136 | ||
|
|
ce24a84b7f | ||
|
|
8637ad2f34 | ||
|
|
8ba005346d | ||
|
|
7fde3ece6e | ||
|
|
346a6f0057 |
+6
-5
@@ -6,15 +6,16 @@
|
|||||||
|
|
||||||
| Решение / сигнал | Читать |
|
| Решение / сигнал | Читать |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Новый module/provider/service; бизнес-логика; перенос responsibility | [Architecture](conventions/backend-architecture.md) |
|
| Новый файл/suffix/layer; поиск feature owner; progressive structure; перенос responsibility | [Architecture](conventions/backend-architecture.md) |
|
||||||
|
| TS vocabulary, domain literals, magic values | [Domain literals](conventions/backend-architecture.md#domain-literals-and-magic-values) |
|
||||||
| Controller, DTO, route/status, validation/exception, публичная HTTP-семантика | [HTTP API](conventions/backend-http-api.md) |
|
| 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) |
|
| Repository/data access; Prisma/schema/query, transaction, migration, atomic write | [Data](conventions/backend-data.md) |
|
||||||
| Тесты или behavior с testing risk | [Testing](conventions/backend-testing.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) |
|
| 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) |
|
| External client/integration boundary; T-Bank/gRPC/proto, retry/rate limit/cache | [Integrations](conventions/backend-integrations.md) |
|
||||||
| Logging/request context без изменения secrets/auth | [Logging](conventions/backend-architecture.md#logging) |
|
| Logging/request context без изменения secrets/auth | [Logging](conventions/backend-architecture.md#logging) |
|
||||||
| Новый abstraction/dependency, refactor, неясный scope | [Общий выбор реализации](../../docs/development/engineering-governance.md#выбор-реализации) |
|
| Новый abstraction/dependency, refactor, неясный scope | [Общий выбор реализации](../../AGENTS.md) |
|
||||||
| Изменение request/response/route/status/error shape или Swagger metadata | HTTP API + [общий contract workflow](../../docs/development/runtime-openapi-verification.md) |
|
| Изменение request/response/route/status/error shape или Swagger metadata | HTTP API + [Источник HTTP-контракта](../../docs/architecture/http-api.md#источник-контракта) |
|
||||||
|
|
||||||
Новый endpoint обычно требует HTTP API + Testing; Architecture/Data/Security
|
Новый endpoint обычно требует HTTP API + Testing; Architecture/Data/Security
|
||||||
добавляются по новым ответственности, persistence или access rules.
|
добавляются по новым ответственности, persistence или access rules.
|
||||||
|
|||||||
@@ -1,39 +1,257 @@
|
|||||||
# Backend architecture
|
# Backend architecture
|
||||||
|
|
||||||
## Выбор владельца
|
## Module-first organization
|
||||||
|
|
||||||
Найди существующий use case, его collaborators и tests до создания module/service.
|
Backend — domain-oriented modular monolith с лёгким разделением
|
||||||
Сохрани ответственность в текущем module, если новая возможность относится к нему.
|
по ответственности. `src/modules` группирует код по бизнес-возможности: `auth`, `banking/bank-cards`, области `investments`.
|
||||||
Module — граница связного набора providers, а не папка для каждого endpoint.
|
В инвестициях отдельные Nest modules владеют `brokerage-accounts`, `portfolio`,
|
||||||
|
`operations`, `income`, `upcoming-events`, `instruments`, `tbank-connection`.
|
||||||
|
`instruments/stocks` и `instruments/bonds` — slices внутри одного модуля.
|
||||||
|
|
||||||
| Решение | Owner |
|
### 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 не требуются для этой организации.
|
||||||
|
|
||||||
|
В текущем коде слои используют `auth`, `bank-cards`, `portfolio`, `operations`
|
||||||
|
и slices `instruments/stocks`, `instruments/bonds`. Общие application helpers
|
||||||
|
инструментов находятся в `instruments/application`; `instruments.module.ts`
|
||||||
|
компонует оба slice. `stock-position.ts` и `instrument-position.ts` формируют
|
||||||
|
результат из portfolio DTO; `instrument-market.ts` обрабатывает внешние данные
|
||||||
|
и ошибки Nest. Это application code, а не независимая domain model.
|
||||||
|
`brokerage-accounts`, `income`, `upcoming-events`, `tbank-connection` остаются
|
||||||
|
компактными плоскими modules. Наличие tests само по себе не требует слоёв.
|
||||||
|
|
||||||
|
## Поиск и создание файла
|
||||||
|
|
||||||
|
При работе над feature проходи владельцев в порядке:
|
||||||
|
|
||||||
|
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](backend-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` на каждом уровне.
|
||||||
|
|
||||||
|
Каждый 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` разделяются только
|
||||||
|
инвестиционными областями; это не глобальная библиотека бизнес-правил.
|
||||||
|
|
||||||
|
## Ответственность и направление зависимостей
|
||||||
|
|
||||||
|
| Владелец | Ответственность |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Route, request/response mapping, status/cookie | Controller, [HTTP rules](backend-http-api.md) |
|
| Controller / presentation | Route, verified identity, transport DTO, validation, status/cookie; вызов сценария |
|
||||||
| Business use case, orchestration, domain validation | Feature service |
|
| Use case / application service | Оркестрация, business validation, доступ и согласованность результата |
|
||||||
| Input shape/format | DTO + configured ValidationPipe |
|
| Domain | Чистые правила и расчёты; без NestJS, Prisma, HTTP/Swagger и внешних SDK |
|
||||||
| Атомарность/DB query | Сервис use case через PrismaService, [Data](backend-data.md) |
|
| Persistence / infrastructure | Запросы, транзакции, технические реализации границ |
|
||||||
| Внешний protocol, deadline/retry/limiting | Existing client, [Integrations](backend-integrations.md) |
|
| `src/integrations/{tbank,moex,ncr}` | Внешние протоколы, декодирование, retry/deadline/cache; без зависимостей от HTTP DTO business modules |
|
||||||
| Чистое вычисление/mapper без lifecycle и DI | Обычная функция рядом с domain owner |
|
| `src/prisma` | Общий DB provider и lifecycle; не generic repository |
|
||||||
| Bootstrap/config/global cross-cutting concern | AppModule, config, существующий common owner |
|
|
||||||
|
|
||||||
**Безопасный путь:** расширь текущий owner; выделяй collaborator, когда он скрывает
|
Основной вызов: presentation → application → domain; application использует
|
||||||
самостоятельный инвариант или координацию. Не вводи CQRS, generic repository,
|
конкретные infrastructure providers, зарегистрированные module. Infrastructure
|
||||||
interface с одной реализацией или base controller только ради шаблона Nest.
|
может использовать application types/input и domain rules; новые interfaces
|
||||||
Не превращай common в хранилище бизнес-логики и не переноси frontend модели сюда.
|
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
|
||||||
|
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries
|
||||||
|
по [Data](backend-data.md); use case получает данные через repository и сам
|
||||||
|
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется.
|
||||||
|
DTO остаются транспортными runtime classes; существующее использование DTO
|
||||||
|
на application boundary допустимо по [HTTP API](backend-http-api.md).
|
||||||
|
|
||||||
## Существующие seams
|
Сохраняй существующий error contract: HTTP exceptions остаются на application/
|
||||||
|
transport boundary. Чистому расчёту не нужен новый framework ошибок.
|
||||||
|
Точные ограничения — [HTTP](backend-http-api.md), [Data](backend-data.md),
|
||||||
|
[Security](security-auth.md), [Integrations](backend-integrations.md).
|
||||||
|
|
||||||
`TbankConnectionModule` уже объединяет счета, портфель, операции, доходы и события.
|
## Сценарии и размер сервисов
|
||||||
Новый связанный use case обычно добавляется в него; создание отдельного module
|
|
||||||
требует собственной ответственности, не только нового файла.
|
|
||||||
|
|
||||||
- [BrokerageOperationsController](../../src/tbank/brokerage-operations.controller.ts) передаёт user/account/query в service, без финансовых вычислений в controller.
|
Разделяй независимые операции, а не строки. `BankCardsService` отвечает за карты,
|
||||||
- [TbankAccessService](../../src/tbank/tbank-access.service.ts) владеет connection/account access и generation; новые account use cases используют его вместо копирования guard/decrypt logic.
|
`BankOperationsService` — журнал/переводы/возвраты, `ExpenseCategoriesService` —
|
||||||
- [decimal.ts](../../src/tbank/decimal.ts) — пример pure domain helper; Injectable и adapter для арифметики не нужны.
|
категории, `GetBankSummaryUseCase` — сводку. `BankCardsRepository` сохраняет
|
||||||
|
общий transaction retry и owner lookup; денежные транзакции не разрываются.
|
||||||
|
Чистые расчёты банковских карт находятся в `domain` этого модуля.
|
||||||
|
|
||||||
Наличие guard подтверждает session, но не владение account. При пересечении
|
У инструментов `StockCatalogService` / `BondCatalogService` хранят идентичность
|
||||||
access boundary добавь [Security](security-auth.md). Ошибка upstream не должна
|
листинга и общий кэш slice; market service — котировки/свечи, income service — выплаты/личные позиции.
|
||||||
обходить общий HTTP filter или превращать чужой account в доступный.
|
`GetStockCompanyUseCase` владеет сведениями об эмитенте. Ключи, TTL и pending
|
||||||
|
promises разделяют прежний экземпляр кэша; extract не должен менять generation.
|
||||||
|
Portfolio и operations сохраняют связную координацию снимков/страниц и кэшей.
|
||||||
|
Расчёты портфеля находятся в `portfolio/domain`; преобразование брокерских
|
||||||
|
позиций — в `tbank-portfolio.mapper.ts`. `bond-money.mapper.ts` преобразует
|
||||||
|
ответ клиента и не является value object.
|
||||||
|
|
||||||
|
Выбирай обычную функцию для чистого вычисления; repository contract — только
|
||||||
|
для реальной границы. CQRS/buses/events, BaseRepository/BaseService, DI token
|
||||||
|
для каждого класса, mapper/presenter для каждого DTO и новые dependencies
|
||||||
|
не нужны для структурного переноса.
|
||||||
|
|
||||||
|
## Common
|
||||||
|
|
||||||
|
`common` содержит общий 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
|
||||||
|
|
||||||
|
Finite business vocabulary must be modeled explicitly; incidental implementation
|
||||||
|
literals stay inline. Это относится к error codes, statuses, operation/account/
|
||||||
|
instrument/transaction types, permissions, roles, feature states и бизнес-режимам.
|
||||||
|
|
||||||
|
Перед новым определением найди existing owner/type/value. В branching и comparisons
|
||||||
|
используй символическое значение владельца; не повторяй raw domain literals.
|
||||||
|
Храни словарь у domain/module/entity с соблюдением границ слоёв, без глобальной
|
||||||
|
свалки `constants.ts`. Сохраняй принятый pattern; для нового project-owned словаря
|
||||||
|
без подходящего аналога используй `as const` с derived union:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const BankErrorCode = {
|
||||||
|
InvalidDate: "BANK_INVALID_DATE",
|
||||||
|
ImmutableOperation: "BANK_IMMUTABLE_OPERATION",
|
||||||
|
} as const;
|
||||||
|
type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
|
||||||
|
|
||||||
|
// Вместо code === "BANK_INVALID_DATE":
|
||||||
|
if (code === BankErrorCode.InvalidDate) { /* existing handling */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Значимые numeric policies (timeout, retry, page size, threshold, coefficient)
|
||||||
|
называй по смыслу и единицам: `REQUEST_TIMEOUT_MS`, `CACHE_TTL_SECONDS`,
|
||||||
|
`MAX_RETRY_COUNT`, `PAGE_SIZE`. Оставляй inline UI labels в принятом месте,
|
||||||
|
log messages, индексы, `index + 1`, `Math.max(value, 0)`, `page ?? 1`,
|
||||||
|
`percentage * 100` и очевидные локальные детали без semantic benefit.
|
||||||
|
При неясном owner/смысле оставь значение и укажи ограничение в review.
|
||||||
|
|
||||||
|
Не мигрируй корректные existing enum/union styles массово. Не добавляй ради
|
||||||
|
правила factories, registries, schemas, loaders, abstraction layers, dependencies,
|
||||||
|
custom lint/AST rules, generation/validation scripts или тесты существования
|
||||||
|
констант. Semantic review проверяет ownership, единое написание и неизменность
|
||||||
|
wire values; обычные behavior tests проверяют поведение.
|
||||||
|
|
||||||
|
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
|
||||||
|
что application/domain. Например, `BankOperationKind` принадлежит bank-cards/domain,
|
||||||
|
а DTO получает допустимые значения через `Object.values(BankOperationKind)`.
|
||||||
|
Frontend переиспользует generated OpenAPI types/values по возможностям генератора,
|
||||||
|
а не определяет независимый backend-owned словарь. Внешние/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
|
## Logging
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,33 @@ PrismaService — существующий shared DB provider с lifecycle пр
|
|||||||
|
|
||||||
| Ситуация | Решение |
|
| Ситуация | Решение |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Один query/write конкретного use case | Existing service + PrismaService |
|
| Данные для use case | Concrete repository; use case не импортирует PrismaService/Prisma и не строит queries |
|
||||||
| Несколько записей обязаны выполниться вместе | Database transaction вокруг инварианта |
|
| Несколько записей обязаны выполниться вместе | Database transaction вокруг инварианта |
|
||||||
| Конкурентная смена одного значения | Atomic conditional update/constraint с проверкой результата |
|
| Конкурентная смена одного значения | Atomic conditional update/constraint с проверкой результата |
|
||||||
| Независимые reads | Не добавляй transaction ради внешнего вида |
|
| Независимые reads | Не добавляй transaction ради внешнего вида |
|
||||||
| Общая persistence abstraction | Только при реальной общей ответственности; generic repository для одной Prisma реализации не нужен |
|
| Общая persistence abstraction | Только при реальной общей ответственности; generic repository для одной Prisma реализации не нужен |
|
||||||
|
|
||||||
|
Размещение repository определяется [Architecture](backend-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 приватно.
|
||||||
|
|
||||||
|
`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 в запросах и
|
Выбирай только нужные данные, сохраняй user/account ownership в запросах и
|
||||||
application checks. Предварительный lookup не заменяет unique constraint при race.
|
application checks. Предварительный lookup не заменяет unique constraint при race.
|
||||||
Пример: AuthService обрабатывает P2002 и использует conditional `updateMany`
|
Пример: AuthService обрабатывает P2002 и использует conditional `updateMany`
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
|
|
||||||
Сначала определи, меняется wire contract или только реализация существующего
|
Сначала определи, меняется wire contract или только реализация существующего
|
||||||
use case. Для route/request/response/status/error shape читай
|
use case. Для route/request/response/status/error shape читай
|
||||||
[HTTP semantics](../../../docs/architecture/http-api.md) и
|
[HTTP semantics и источник контракта](../../../docs/architecture/http-api.md).
|
||||||
[общий contract workflow](../../../docs/development/runtime-openapi-verification.md).
|
|
||||||
|
|
||||||
| Проверка | Где выполнять |
|
| Проверка | Где выполнять |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -16,16 +15,23 @@ use case. Для route/request/response/status/error shape читай
|
|||||||
| HTTP envelope/безопасная ошибка | Общий HttpExceptionFilter |
|
| HTTP envelope/безопасная ошибка | Общий HttpExceptionFilter |
|
||||||
|
|
||||||
Controller объявляет contract и делегирует; не обращается напрямую к Prisma/gRPC
|
Controller объявляет contract и делегирует; не обращается напрямую к Prisma/gRPC
|
||||||
ради обхода service. Request identity берётся из verified `request.user`, не из
|
и не выполняет финансовые расчёты или сложную оркестрацию.
|
||||||
body/userId, предоставленного клиентом. Access rules активируют Security.
|
Controller вызывает application use case/service. Identity берётся из verified
|
||||||
|
`request.user`, не из body/userId, предоставленного клиентом. Access rules активируют Security.
|
||||||
|
|
||||||
## DTO и errors
|
## DTO и errors
|
||||||
|
|
||||||
DTO — runtime class: TS interface не предоставляет validation metadata.
|
DTO — runtime class: TS interface не предоставляет validation metadata.
|
||||||
|
`*.controller.ts` и `*.dto.ts` остаются около HTTP boundary, в root компактного
|
||||||
|
module или presentation. DTO можно передать application boundary при совпадении
|
||||||
|
формы; отдельный Input с теми же полями не нужен. Domain не импортирует DTO:
|
||||||
|
например, чистые денежные расчёты используют `Money`, а Swagger — `MoneyDto`.
|
||||||
|
Нормализованная форма input нужна только при смысловом отличии от wire contract;
|
||||||
|
выбор suffix принадлежит [Architecture](backend-architecture.md).
|
||||||
ValidationPipe уже настроен в `configureApp` (`transform`, `whitelist`).
|
ValidationPipe уже настроен в `configureApp` (`transform`, `whitelist`).
|
||||||
Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён.
|
Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён.
|
||||||
Для query numbers/arrays выбирай явное преобразование и валидаторы, как в
|
Для query numbers/arrays выбирай явное преобразование и валидаторы, как в
|
||||||
[operations DTO](../../src/tbank/brokerage-operation.dto.ts). Swagger metadata
|
[operations DTO](../../src/modules/investments/operations/presentation/brokerage-operation.dto.ts). Swagger metadata
|
||||||
описывает те же required/optional/nullable/enum/range значения.
|
описывает те же required/optional/nullable/enum/range значения.
|
||||||
|
|
||||||
Используй типизированные response DTO, `ApiOkResponse`/другой фактический status
|
Используй типизированные response DTO, `ApiOkResponse`/другой фактический status
|
||||||
@@ -40,7 +46,7 @@ raw upstream error, Prisma record с secrets или stack. Текущая фор
|
|||||||
|
|
||||||
## Пример и проверка
|
## Пример и проверка
|
||||||
|
|
||||||
[Operations controller](../../src/tbank/brokerage-operations.controller.ts)
|
[Operations controller](../../src/modules/investments/operations/presentation/brokerage-operations.controller.ts)
|
||||||
показывает guard, user.sub, DTO query и service delegation.
|
показывает guard, user.sub, DTO query и service delegation.
|
||||||
Для нового field обновляй canonical source и generated artifacts в определённом
|
Для нового field обновляй canonical source и generated artifacts в определённом
|
||||||
направлении; body type frontend не исправляется вручную.
|
направлении; body type frontend не исправляется вручную.
|
||||||
|
|||||||
@@ -3,14 +3,34 @@
|
|||||||
## Выбор boundary
|
## Выбор boundary
|
||||||
|
|
||||||
T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient. Сервисы use case
|
T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient. Сервисы use case
|
||||||
получают нормализованный результат через эти clients. Frontend не вызывает
|
используют объявленные клиентом records; SDK clients, metadata и transport errors
|
||||||
|
остаются внутри integration. Frontend не вызывает
|
||||||
T-Bank и не получает credentials/proto client. Reference source —
|
T-Bank и не получает credentials/proto client. Reference source —
|
||||||
[ADR 003](../../../docs/adr/003-tbank-server-side-grpc-boundary.md) и
|
[ADR 003](../../../docs/adr/003-tbank-server-side-grpc-boundary.md) и
|
||||||
[connection behavior](../../../docs/integrations/tbank.md).
|
[connection behavior](../../../docs/integrations/tbank.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](backend-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,
|
Для нового account use case сначала используй TbankAccessService: access,
|
||||||
connection generation и invalid-token translation не дублируются в callers.
|
connection generation и invalid-token translation не дублируются в callers.
|
||||||
Чистый mapper финансового значения остаётся рядом с domain owner.
|
Mapper внешних финансовых значений остаётся внутри business capability,
|
||||||
|
обычно в infrastructure по [Architecture](backend-architecture.md).
|
||||||
|
|
||||||
## Retry, cache и protocol
|
## Retry, cache и protocol
|
||||||
|
|
||||||
@@ -30,7 +50,7 @@ cache/stale policy; semantics конкретного use case читай чер
|
|||||||
raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService
|
raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService
|
||||||
и общем HTTP boundary. Для HTTP error change читай HTTP API/Security по trigger.
|
и общем HTTP boundary. Для HTTP error change читай HTTP API/Security по trigger.
|
||||||
|
|
||||||
Vendor proto хранится в `src/tbank/proto/contracts`; для обновления сверяй
|
Vendor proto хранится в `src/integrations/tbank/proto/contracts`; для обновления сверяй
|
||||||
официальный source и влияние на loader/DTO, не редактируй read-only references.
|
официальный source и влияние на loader/DTO, не редактируй read-only references.
|
||||||
Generated artifacts, если есть, создавай из source, без ручной подгонки вывода.
|
Generated artifacts, если есть, создавай из source, без ручной подгонки вывода.
|
||||||
|
|
||||||
@@ -40,7 +60,7 @@ Generated artifacts, если есть, создавай из source, без р
|
|||||||
ответственного service. Отдельный abstraction/adapter оправдан новой
|
ответственного service. Отдельный abstraction/adapter оправдан новой
|
||||||
ответственностью или несовместимым protocol, не только вторым методом.
|
ответственностью или несовместимым protocol, не только вторым методом.
|
||||||
|
|
||||||
[TbankClient tests](../../src/tbank/tbank.client.spec.ts) — пример controlled
|
[TbankClient tests](../../src/integrations/tbank/tbank.client.spec.ts) — пример controlled
|
||||||
upstream responses. Проверь queue/deadline/retry limit, token translation,
|
upstream responses. Проверь queue/deadline/retry limit, token translation,
|
||||||
cache isolation и late response для затронутых ветвей. Fixtures синтетические;
|
cache isolation и late response для затронутых ветвей. Fixtures синтетические;
|
||||||
реальная сеть, секреты и пользовательские accounts не нужны.
|
реальная сеть, секреты и пользовательские accounts не нужны.
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
## Выбор уровня
|
## Выбор уровня
|
||||||
|
|
||||||
Применяй [общие testing risks](../../../docs/development/engineering-governance.md#risk-based-testing).
|
Проверяй риски доступа, денежных расчётов, целостности данных, конкурентных
|
||||||
|
изменений, публичного контракта и подтверждённых регрессий.
|
||||||
Тесты должны различать правильное и неправильное наблюдаемое поведение.
|
Тесты должны различать правильное и неправильное наблюдаемое поведение.
|
||||||
|
|
||||||
| Что меняется | Проверка |
|
| Что меняется | Проверка |
|
||||||
@@ -21,8 +22,8 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
|
|||||||
|
|
||||||
## Случаи и примеры
|
## Случаи и примеры
|
||||||
|
|
||||||
- [BrokerageAccountsService tests](../../src/tbank/brokerage-accounts.service.spec.ts): отсутствие connection не вызывает upstream, нормализация и точные суммы.
|
- [BrokerageAccountsService tests](../../src/modules/investments/brokerage-accounts/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.
|
- [AuthService tests](../../src/modules/auth/application/auth.service.spec.ts): invalid/expired/superseded token и concurrent rotation; mock race test не является доказательством DB transaction.
|
||||||
- [HTTP filter tests](../../src/common/filters/http-exception.filter.spec.ts): безопасный error envelope.
|
- [HTTP filter tests](../../src/common/filters/http-exception.filter.spec.ts): безопасный error envelope.
|
||||||
- [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): configured app + frontend snapshot, без записи snapshot и без T-Bank.
|
- [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): configured app + frontend snapshot, без записи snapshot и без T-Bank.
|
||||||
|
|
||||||
@@ -36,11 +37,15 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
|
|||||||
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
|
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
|
||||||
`npm run test:openapi-contract` — отдельный required check при wire contract change.
|
`npm run test:openapi-contract` — отдельный required check при wire contract change.
|
||||||
|
|
||||||
`npm run test:e2e` существует, но известный Prisma migration blocker описан в
|
`npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner;
|
||||||
[ограничении сквозной проверки](../../../docs/development/verification.md#известное-ограничение-сквозной-проверки) и WL-P2-05
|
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
|
||||||
[registry](../../../docs/development/audit-remediation.md). Скрипт использует
|
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
|
||||||
фиксированную `/tmp/wealth-ledger-e2e.db`; не запускай его конкурентно как
|
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`,
|
||||||
независимые DB tests. Для isolation выбери отдельную test DB и runner configuration.
|
вызывающего ошибку fresh migration на Prisma 7.9.1. Подробности и регрессия запуска —
|
||||||
Если задаче нужна HTTP/DB proof, установи применимость blocker и выполни доступную
|
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
|
||||||
изолированную проверку. Не объявляй покрытым то, что до tests не запустилось;
|
HTTP suites требуют разрешения открывать локальные порты. Ошибка sandbox
|
||||||
сохрани точный failure и оставшуюся проверку. Unit/Swagger success не заменяет e2e.
|
не считается успешным тестом; повторите проверку в подходящем окружении.
|
||||||
|
|
||||||
|
Для schema/data changes дополнительно `npm run test:durability -- <baseline>`.
|
||||||
|
Матрица рисков и подготовка совместимой предыдущей сборки — в том же README.
|
||||||
|
Mock Prisma не заменяет доказательство реальной DB concurrency.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ TbankTokenService/TbankCrypto. Расширяй существующую boundar
|
|||||||
[ADR 006](../../../docs/adr/006-auth-session-policy.md)/[009](../../../docs/adr/009-concurrent-refresh-rotation-policy.md)
|
[ADR 006](../../../docs/adr/006-auth-session-policy.md)/[009](../../../docs/adr/009-concurrent-refresh-rotation-policy.md)
|
||||||
задают session semantics. Access хранится только в памяти frontend; refresh
|
задают session semantics. Access хранится только в памяти frontend; refresh
|
||||||
передаётся httpOnly cookie. Source code конфигурации cookie —
|
передаётся httpOnly cookie. Source code конфигурации cookie —
|
||||||
[AuthController](../../src/auth/auth.controller.ts); изменение flags/path требует
|
[AuthController](../../src/modules/auth/presentation/auth.controller.ts); изменение flags/path требует
|
||||||
проверки browser/HTTP поведения, не только TypeScript.
|
проверки browser/HTTP поведения, не только TypeScript.
|
||||||
|
|
||||||
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
|
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"frontend": "0244acf429c525b23117f165bbe52ef5403eefd3"
|
||||||
|
}
|
||||||
@@ -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,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,50 @@
|
|||||||
|
name: Backend verify
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: backend
|
||||||
|
- name: Checkout compatible peer revisions
|
||||||
|
env:
|
||||||
|
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||||
|
TARGET_BRANCH: ${{ github.base_ref || github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
for repo in frontend; do
|
||||||
|
ref=develop
|
||||||
|
case "$SOURCE_BRANCH:$TARGET_BRANCH" in
|
||||||
|
release/*:*|*:main)
|
||||||
|
ref=$(jq -er --arg repo "$repo" '.[$repo]' .ci/refs.json)
|
||||||
|
printf '%s\n' "$ref" | grep -Eq '^[0-9a-f]{40}$'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
target="../$repo"
|
||||||
|
if [ "$repo" = main ]; then target=..; fi
|
||||||
|
git init "$target"
|
||||||
|
git -C "$target" remote add origin "https://git.personal.ksv741.ru/WealthLedger/$repo.git"
|
||||||
|
git -C "$target" fetch --depth 1 origin "$ref"
|
||||||
|
git -C "$target" checkout --detach FETCH_HEAD
|
||||||
|
git -C "$target" rev-parse HEAD
|
||||||
|
done
|
||||||
|
git rev-parse HEAD
|
||||||
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: backend/.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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,12 +12,19 @@ zero-warning ESLint, application/test TypeScript, Jest и build.
|
|||||||
`lint:fix`/`format` пишут файлы и запускаются отдельно от доказательства проверки.
|
`lint:fix`/`format` пишут файлы и запускаются отдельно от доказательства проверки.
|
||||||
`test:cov` — диагностика без общего threshold; scripts принадлежат `package.json`.
|
`test:cov` — диагностика без общего threshold; scripts принадлежат `package.json`.
|
||||||
|
|
||||||
HTTP contract changes дополнительно проходят общий
|
HTTP contract changes сохраняют [направление контракта](../docs/architecture/http-api.md#источник-контракта)
|
||||||
[contract workflow](../docs/development/runtime-openapi-verification.md).
|
и проходят contract check. Общий e2e запускается на отдельных временных DB;
|
||||||
Общий e2e имеет известный environment blocker; применимость и маршрут проверки
|
применимость и маршрут проверки
|
||||||
описаны в [backend testing](.ai/conventions/backend-testing.md).
|
описаны в [backend testing](.ai/conventions/backend-testing.md).
|
||||||
Generated Prisma client не редактируется вручную. Credentials, авторизация
|
Generated Prisma client не редактируется вручную. Credentials, авторизация
|
||||||
доступа к счетам и внешние интеграции остаются server-side.
|
доступа к счетам и внешние интеграции остаются server-side.
|
||||||
|
|
||||||
ESLint/TypeScript не доказывают правильность domain boundaries, security или
|
ESLint/TypeScript не доказывают правильность domain boundaries, security или
|
||||||
денежных вычислений: для них нужны semantic review и risk-based tests.
|
денежных вычислений: для них нужны semantic review и risk-based tests.
|
||||||
|
|
||||||
|
## Ветки и релизы
|
||||||
|
|
||||||
|
Работа — в отдельной ветке от `develop`, затем PR в `develop`; в `main` напрямую
|
||||||
|
не пушить. Release/hotfix и синхронизация веток — по [root README](../README.md#релизы-и-ветки).
|
||||||
|
Commits и заголовки PR — Conventional Commits; для спецификаций допустим `spec:`.
|
||||||
|
Перед подготовкой релиза прочитай root README; версии вручную не повышать.
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 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))
|
||||||
@@ -5,13 +5,14 @@ NestJS API WealthLedger. Backend владеет auth, SQLite-данными и s
|
|||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
Требуется Node 22 (`.nvmrc`). Создайте `.env` из примера, замените placeholder
|
Требуется Node 24 (`.nvmrc`). Создайте `.env` из примера, замените placeholder
|
||||||
секреты и установите зависимости строго из lockfile:
|
секреты и установите зависимости строго из lockfile:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
nvm use --silent
|
nvm use --silent
|
||||||
npm ci
|
npm ci
|
||||||
|
npx prisma migrate deploy
|
||||||
npm run start:dev
|
npm run start:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -31,9 +32,49 @@ npm run verify
|
|||||||
Он запускает zero-warning lint,
|
Он запускает zero-warning lint,
|
||||||
application/test typechecks, Jest и build. `npm run lint:fix` — отдельная
|
application/test typechecks, Jest и build. `npm run lint:fix` — отдельная
|
||||||
записывающая команда, `npm run test:cov` — диагностика без общего числового
|
записывающая команда, `npm run test:cov` — диагностика без общего числового
|
||||||
threshold. `npm run test:e2e` пока не является passing gate: Prisma schema
|
threshold. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
|
||||||
engine останавливает migration до запуска Jest. Точный статус и trigger
|
и runtime OpenAPI с соседним frontend checkout. Для полной backend-проверки
|
||||||
пересмотра находятся в [remediation registry](../docs/development/audit-remediation.md).
|
выполните обе команды.
|
||||||
|
|
||||||
|
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. Frontend snapshot для
|
||||||
|
разработки берётся из `develop`; для `main`, PR в `main` и `release/*` — из
|
||||||
|
полного SHA в `.ci/refs.json`. Лог сохраняет оба SHA. Изменение
|
||||||
|
контракта согласуется с этой frontend revision. Успешный 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-валидацию. В обычном коде нужны инициализация или сужение типа
|
||||||
|
после проверки значения.
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
@@ -42,13 +83,10 @@ engine останавливает migration до запуска Jest. Точны
|
|||||||
- [HTTP API](.ai/conventions/backend-http-api.md), [data](.ai/conventions/backend-data.md) и [testing](.ai/conventions/backend-testing.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)
|
- [Security/auth](.ai/conventions/security-auth.md) и [integrations](.ai/conventions/backend-integrations.md)
|
||||||
|
|
||||||
- [Границы проверки API и данных](../docs/development/verification.md#api-и-хранение-данных)
|
|
||||||
- [Engineering governance и risk-based testing](../docs/development/engineering-governance.md)
|
|
||||||
- [Карта проектных фактов](../docs/README.md)
|
- [Карта проектных фактов](../docs/README.md)
|
||||||
- [API contract и runtime OpenAPI](../docs/development/runtime-openapi-verification.md)
|
- [API contract и runtime OpenAPI](../docs/architecture/http-api.md#источник-контракта)
|
||||||
- [Обзор поставленной системы](../docs/overview.md)
|
- [Обзор поставленной системы](../docs/overview.md)
|
||||||
- [Архитектурные решения](../docs/adr/README.md)
|
- [Архитектурные решения](../docs/adr/README.md)
|
||||||
- [Remediation registry](../docs/development/audit-remediation.md)
|
|
||||||
|
|
||||||
Из backend runtime OpenAPI можно read-only сверить с frontend snapshot:
|
Из backend runtime OpenAPI можно read-only сверить с frontend snapshot:
|
||||||
|
|
||||||
@@ -59,3 +97,68 @@ npm run test:openapi-contract
|
|||||||
Разработка идёт напрямую по задаче пользователя. Каждый affected repo проходит
|
Разработка идёт напрямую по задаче пользователя. Каждый affected repo проходит
|
||||||
свой verify; при изменении wire contract дополнительно выполняется contract check.
|
свой verify; при изменении wire contract дополнительно выполняется contract check.
|
||||||
Обязательного OpenSpec/SDD lifecycle нет.
|
Обязательного OpenSpec/SDD lifecycle нет.
|
||||||
|
|
||||||
|
## Реальная 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 пользовательской базы.
|
||||||
|
|
||||||
|
Версии и ветки: [процесс релиза](../README.md#релизы-и-ветки).
|
||||||
|
|||||||
@@ -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: {
|
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-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-floating-promises': 'warn',
|
||||||
'@typescript-eslint/no-unsafe-argument': '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": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true,
|
"deleteOutDir": true,
|
||||||
"assets": [
|
"assets": [
|
||||||
{ "include": "tbank/proto/contracts/**/*.proto", "watchAssets": true }
|
{ "include": "integrations/tbank/proto/contracts/**/*.proto", "watchAssets": true }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+4440
-2054
File diff suppressed because it is too large
Load Diff
+88
-62
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "22.x"
|
"node": "v24.21.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "prisma generate && nest build",
|
"build": "prisma generate && nest build",
|
||||||
@@ -20,72 +20,88 @@
|
|||||||
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"typecheck:app": "tsc --project tsconfig.typecheck.app.json",
|
"typecheck:app": "tsc --project tsconfig.typecheck.app.json",
|
||||||
"typecheck:test": "tsc --project tsconfig.typecheck.test.json",
|
"typecheck:test": "tsc --project tsconfig.typecheck.test.json",
|
||||||
"test": "jest",
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "npm test -- --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "npm test -- --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "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:e2e": "node test/sqlite.cjs --all",
|
||||||
"test:openapi-contract": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json --runInBand openapi-contract.e2e-spec.ts",
|
"test:sqlite": "node test/sqlite.cjs",
|
||||||
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm test -- --runInBand && npm run build"
|
"test:durability": "node test/bank-cards-durability.cjs",
|
||||||
|
"test:openapi-contract": "node test/sqlite.cjs openapi-contract.e2e-spec.ts",
|
||||||
|
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm test -- --runInBand && npm run build",
|
||||||
|
"release:preview": "release-it --dry-run --ci",
|
||||||
|
"release:prepare": "release-it --ci",
|
||||||
|
"commitlint": "commitlint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "^1.14.4",
|
"@grpc/grpc-js": "1.14.4",
|
||||||
"@grpc/proto-loader": "^0.8.1",
|
"@grpc/proto-loader": "0.8.1",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "12.0.1",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "12.0.0",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "12.0.1",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "12.0.1",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "12.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "12.0.1",
|
||||||
"@nestjs/swagger": "11.4.5",
|
"@nestjs/swagger": "12.0.1",
|
||||||
"@prisma/adapter-better-sqlite3": "^7.9.1",
|
"@prisma/adapter-better-sqlite3": "7.10.0",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "7.10.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
"better-sqlite3": "^12.6.0",
|
"better-sqlite3": "13.0.3",
|
||||||
"cheerio": "1.1.2",
|
"cheerio": "1.2.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"lru-cache": "^11.2.4",
|
"lru-cache": "11.5.2",
|
||||||
"nestjs-pino": "^4.6.1",
|
"nestjs-pino": "5.1.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "4.0.1",
|
||||||
"pino-http": "^11.0.0",
|
"pino-http": "11.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "7.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@commitlint/cli": "21.2.2",
|
||||||
"@eslint/js": "^9.18.0",
|
"@commitlint/config-conventional": "21.2.2",
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@eslint/eslintrc": "3.3.7",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@eslint/js": "10.0.1",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/cli": "12.0.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@nestjs/schematics": "12.0.1",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@nestjs/testing": "12.0.1",
|
||||||
"@types/express": "^5.0.0",
|
"@release-it/conventional-changelog": "12.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/bcrypt": "6.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/express": "5.0.6",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/jest": "30.0.0",
|
||||||
"eslint": "^9.18.0",
|
"@types/node": "24.13.4",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"@types/passport-jwt": "4.0.1",
|
||||||
"eslint-plugin-prettier": "^5.2.2",
|
"@types/supertest": "7.2.1",
|
||||||
"globals": "^16.0.0",
|
"conventional-changelog-conventionalcommits": "10.4.0",
|
||||||
"jest": "^30.0.0",
|
"eslint": "10.10.0",
|
||||||
"prettier": "^3.4.2",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"prisma": "^7.9.1",
|
"eslint-plugin-prettier": "5.5.6",
|
||||||
"source-map-support": "^0.5.21",
|
"globals": "17.12.0",
|
||||||
"supertest": "^7.0.0",
|
"jest": "30.5.1",
|
||||||
"ts-jest": "^29.2.5",
|
"prettier": "3.9.6",
|
||||||
"ts-loader": "^9.5.2",
|
"prisma": "7.10.0",
|
||||||
"ts-node": "^10.9.2",
|
"release-it": "21.0.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"source-map-support": "0.5.21",
|
||||||
"typescript": "^5.7.3",
|
"supertest": "7.2.2",
|
||||||
"typescript-eslint": "^8.20.0"
|
"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": {
|
"overrides": {
|
||||||
"@nestjs/swagger@11.4.5": {
|
"@nestjs/platform-express": {
|
||||||
"js-yaml": "5.2.3"
|
"multer": "2.3.0"
|
||||||
|
},
|
||||||
|
"@prisma/config@7.10.0": {
|
||||||
|
"deepmerge-ts": "8.0.2"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"mysql2": "3.24.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
@@ -104,5 +120,15 @@
|
|||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"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 'dotenv/config';
|
||||||
import { defineConfig } from 'prisma/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({
|
export default defineConfig({
|
||||||
schema: 'prisma/schema.prisma',
|
schema: 'prisma/schema.prisma',
|
||||||
datasource: {
|
datasource: {
|
||||||
|
|||||||
@@ -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.");
|
||||||
+16
-4
@@ -1,13 +1,19 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import { BankCardsModule } from './bank-cards/bank-cards.module';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { AuthModule } from './auth/auth.module';
|
|
||||||
import { LoggingModule } from './common/logging/logging.module';
|
import { LoggingModule } from './common/logging/logging.module';
|
||||||
import { Environment, validateEnv } from './config/env.validation';
|
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 { PrismaModule } from './prisma/prisma.module';
|
||||||
import { TbankConnectionModule } from './tbank/tbank-connection.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -51,6 +57,12 @@ import { TbankConnectionModule } from './tbank/tbank-connection.module';
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
BankCardsModule,
|
BankCardsModule,
|
||||||
|
BrokerageAccountsModule,
|
||||||
|
PortfolioModule,
|
||||||
|
OperationsModule,
|
||||||
|
IncomeModule,
|
||||||
|
UpcomingEventsModule,
|
||||||
|
InstrumentsModule,
|
||||||
TbankConnectionModule,
|
TbankConnectionModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { BankCardsController } from './bank-cards.controller';
|
|
||||||
import { BankCardsService } from './bank-cards.service';
|
|
||||||
@Module({ controllers: [BankCardsController], providers: [BankCardsService] })
|
|
||||||
export class BankCardsModule {}
|
|
||||||
@@ -1,708 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
GoneException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
|
||||||
import {
|
|
||||||
Prisma,
|
|
||||||
BankCard,
|
|
||||||
BankCardOperation,
|
|
||||||
} from '../../generated/prisma/client';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { parseRubMoney, rubMoney } from '../common/rub-money';
|
|
||||||
import {
|
|
||||||
bankBalance,
|
|
||||||
bankSummary,
|
|
||||||
isCalendarDate,
|
|
||||||
moscowToday,
|
|
||||||
} from './bank-card.calculations';
|
|
||||||
import {
|
|
||||||
bankPaymentSystems,
|
|
||||||
BankCardDto,
|
|
||||||
BankOperationDto,
|
|
||||||
BankOperationsQueryDto,
|
|
||||||
BankPageQueryDto,
|
|
||||||
BankPeriodQueryDto,
|
|
||||||
BankSummaryQueryDto,
|
|
||||||
CreateBankCardDto,
|
|
||||||
CreateBankOperationDto,
|
|
||||||
UpdateBankCardDto,
|
|
||||||
UpdateBankOperationDto,
|
|
||||||
} from './bank-card.dto';
|
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
|
||||||
const seeds = [
|
|
||||||
'Продукты',
|
|
||||||
'Кафе',
|
|
||||||
'Такси',
|
|
||||||
'Транспорт',
|
|
||||||
'Жильё',
|
|
||||||
'Здоровье',
|
|
||||||
'Покупки',
|
|
||||||
'Развлечения',
|
|
||||||
'Другое',
|
|
||||||
];
|
|
||||||
const fail = (code = 'BANK_INVALID_INPUT'): never => {
|
|
||||||
throw new BadRequestException({ code });
|
|
||||||
};
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
const conflict = (code = 'BANK_VERSION_CONFLICT'): never => {
|
|
||||||
throw new ConflictException({ code });
|
|
||||||
};
|
|
||||||
const missing = (): never => {
|
|
||||||
throw new NotFoundException({ code: 'BANK_NOT_FOUND' });
|
|
||||||
};
|
|
||||||
const fingerprint = (value: unknown) =>
|
|
||||||
createHash('sha256')
|
|
||||||
.update(
|
|
||||||
JSON.stringify(value, (_key, item: unknown) =>
|
|
||||||
typeof item === 'bigint' ? item.toString() : item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.digest('hex');
|
|
||||||
function amount(value: Parameters<typeof parseRubMoney>[0], positive = false) {
|
|
||||||
try {
|
|
||||||
return parseRubMoney(value, positive);
|
|
||||||
} catch {
|
|
||||||
return fail('BANK_INVALID_MONEY');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function calendar(value: string) {
|
|
||||||
if (!isCalendarDate(value)) fail('BANK_INVALID_DATE');
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
function currentDate(value: string) {
|
|
||||||
calendar(value);
|
|
||||||
if (value > moscowToday()) fail('BANK_INVALID_DATE');
|
|
||||||
}
|
|
||||||
function name(value: string, max: number) {
|
|
||||||
if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
|
|
||||||
fail();
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
function cardInput(dto: CreateBankCardDto) {
|
|
||||||
const bankName = dto.bankName?.trim() || null;
|
|
||||||
if (bankName && bankName.length > 100) 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, 100),
|
|
||||||
bankName,
|
|
||||||
lastFour: dto.lastFour || null,
|
|
||||||
openingBalanceKopecks: amount(dto.openingBalance),
|
|
||||||
openingDate: calendar(dto.openingDate),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function operationInput(dto: CreateBankOperationDto) {
|
|
||||||
if (
|
|
||||||
dto.description != null &&
|
|
||||||
(typeof dto.description !== 'string' || dto.description.length > 500)
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BankCardsService {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
private async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
|
||||||
for (let attempt = 0; ; attempt++) {
|
|
||||||
try {
|
|
||||||
return await this.prisma.$transaction(run, {
|
|
||||||
maxWait: 10000,
|
|
||||||
timeout: 15000,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const code =
|
|
||||||
error instanceof Prisma.PrismaClientKnownRequestError
|
|
||||||
? error.code
|
|
||||||
: '';
|
|
||||||
const busy =
|
|
||||||
code === 'P2034' ||
|
|
||||||
code === 'P2028' ||
|
|
||||||
(error instanceof Error &&
|
|
||||||
/SQLITE_BUSY|database is locked/.test(error.message));
|
|
||||||
if ((busy || code === 'P2002') && attempt < 2) continue;
|
|
||||||
if (busy) conflict('BANK_WRITE_CONFLICT');
|
|
||||||
if (code === 'P2002') conflict('BANK_DUPLICATE_CATEGORY');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private async card(tx: Tx, userId: string, id: string, active = false) {
|
|
||||||
const card = await tx.bankCard.findFirst({ where: { id, userId } });
|
|
||||||
if (!card) return missing();
|
|
||||||
if (active && card.archivedAt) conflict('BANK_CARD_ARCHIVED');
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
private async operation(tx: Tx, userId: string, id: string) {
|
|
||||||
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
|
|
||||||
if (!row) return missing();
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
private async seed(tx: Tx, userId: string) {
|
|
||||||
for (const [index, label] of seeds.entries()) {
|
|
||||||
const seedKey = index === 8 ? 'other' : String(index);
|
|
||||||
if (
|
|
||||||
!(await tx.expenseCategory.findUnique({
|
|
||||||
where: { userId_seedKey: { userId, seedKey } },
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
await tx.expenseCategory.create({
|
|
||||||
data: {
|
|
||||||
id: randomUUID(),
|
|
||||||
userId,
|
|
||||||
seedKey,
|
|
||||||
name: label,
|
|
||||||
nameKey: label.toLocaleLowerCase('ru-RU'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
private async operationView(
|
|
||||||
tx: Tx,
|
|
||||||
row: BankCardOperation,
|
|
||||||
): Promise<BankOperationDto> {
|
|
||||||
const card = await this.card(tx, row.userId, row.cardId);
|
|
||||||
const target = row.targetCardId
|
|
||||||
? await this.card(tx, row.userId, row.targetCardId)
|
|
||||||
: null;
|
|
||||||
const source = row.originalExpenseId
|
|
||||||
? await this.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 === '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 === '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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
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.card(tx, userId, id)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
createCard(userId: string, dto: CreateBankCardDto) {
|
|
||||||
const input = cardInput(dto),
|
|
||||||
hash = fingerprint(input);
|
|
||||||
return this.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('BANK_INTENT_CONFLICT');
|
|
||||||
return this.cardView(tx, existing);
|
|
||||||
}
|
|
||||||
currentDate(input.openingDate);
|
|
||||||
await this.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.write(async (tx) => {
|
|
||||||
const previous = await this.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('BANK_CARD_ARCHIVED');
|
|
||||||
currentDate(input.openingDate);
|
|
||||||
if (
|
|
||||||
await tx.bankCardOperation.findFirst({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
deletedAt: null,
|
|
||||||
date: { lt: input.openingDate },
|
|
||||||
OR: [{ cardId: id }, { targetCardId: id }],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
fail('BANK_OPENING_DATE_CONFLICT');
|
|
||||||
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.card(tx, userId, id));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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, 80);
|
|
||||||
return this.write(async (tx) => {
|
|
||||||
await this.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, 80);
|
|
||||||
return this.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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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.card(tx, userId, id, true);
|
|
||||||
if (
|
|
||||||
(id === input.cardId || id === input.targetCardId) &&
|
|
||||||
input.date < card.openingDate
|
|
||||||
)
|
|
||||||
fail('BANK_INVALID_DATE');
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
old &&
|
|
||||||
(old.kind !== input.kind ||
|
|
||||||
old.originalExpenseId !== input.originalExpenseId)
|
|
||||||
)
|
|
||||||
fail('BANK_IMMUTABLE_OPERATION');
|
|
||||||
if (input.kind === 'transfer') {
|
|
||||||
if (
|
|
||||||
!input.targetCardId ||
|
|
||||||
input.targetCardId === input.cardId ||
|
|
||||||
input.categoryId ||
|
|
||||||
input.originalExpenseId
|
|
||||||
)
|
|
||||||
fail();
|
|
||||||
} else if (input.targetCardId) fail();
|
|
||||||
if (input.kind === '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 !== 'refund' && input.originalExpenseId) fail();
|
|
||||||
if (input.kind === 'refund') {
|
|
||||||
if (!input.originalExpenseId) fail();
|
|
||||||
const source = await this.operation(tx, userId, input.originalExpenseId!);
|
|
||||||
if (
|
|
||||||
source.deletedAt ||
|
|
||||||
source.kind !== 'expense' ||
|
|
||||||
source.cardId !== input.cardId
|
|
||||||
)
|
|
||||||
fail('BANK_INVALID_REFUND');
|
|
||||||
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('BANK_INVALID_DATE');
|
|
||||||
if (
|
|
||||||
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
|
|
||||||
input.amountKopecks >
|
|
||||||
source.amountKopecks
|
|
||||||
)
|
|
||||||
conflict('BANK_REFUND_LIMIT');
|
|
||||||
}
|
|
||||||
if (old?.kind === '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('BANK_DEPENDENT_REFUNDS');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createOperation(userId: string, dto: CreateBankOperationDto) {
|
|
||||||
const normalized = operationInput(dto),
|
|
||||||
hash = fingerprint(normalized);
|
|
||||||
return this.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('BANK_INTENT_CONFLICT');
|
|
||||||
if (existing.deletedAt)
|
|
||||||
throw new GoneException({ code: 'BANK_OPERATION_DELETED' });
|
|
||||||
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.operation(tx, userId, id);
|
|
||||||
if (row.deletedAt) missing();
|
|
||||||
return this.operationView(tx, row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
|
|
||||||
return this.write(async (tx) => {
|
|
||||||
const old = await this.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.operation(tx, userId, id));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
deleteOperation(userId: string, id: string, version: number) {
|
|
||||||
return this.write(async (tx) => {
|
|
||||||
const row = await this.operation(tx, userId, id);
|
|
||||||
if (row.deletedAt) return;
|
|
||||||
if (row.version !== version) conflict();
|
|
||||||
await this.card(tx, userId, row.cardId, true);
|
|
||||||
if (row.targetCardId) await this.card(tx, userId, row.targetCardId, true);
|
|
||||||
if (
|
|
||||||
row.kind === 'expense' &&
|
|
||||||
(await tx.bankCardOperation.findFirst({
|
|
||||||
where: { userId, originalExpenseId: id, deletedAt: null },
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
conflict('BANK_DEPENDENT_REFUNDS');
|
|
||||||
if (row.originalExpenseId) {
|
|
||||||
const source = await this.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.card(tx, userId, id);
|
|
||||||
const page = query.page ?? 1,
|
|
||||||
limit = query.limit ?? 50;
|
|
||||||
const where: Prisma.BankCardOperationWhereInput = {
|
|
||||||
userId,
|
|
||||||
deletedAt: null,
|
|
||||||
OR: [{ cardId: id }, { targetCardId: id }],
|
|
||||||
...(expenses
|
|
||||||
? { kind: 'expense' }
|
|
||||||
: { date: { gte: period!.from, lte: period!.to } }),
|
|
||||||
};
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
summary(userId: string, query: BankSummaryQueryDto) {
|
|
||||||
const period = periodBounds(query);
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
|
||||||
if (query.cardId) await this.card(tx, userId, query.cardId);
|
|
||||||
const rows = await tx.bankCardOperation.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
deletedAt: null,
|
|
||||||
date: { gte: period.from, lte: period.to },
|
|
||||||
kind: { in: ['expense', 'refund'] },
|
|
||||||
...(query.cardId ? { cardId: query.cardId } : {}),
|
|
||||||
},
|
|
||||||
include: { originalExpense: { select: { categoryId: true } } },
|
|
||||||
});
|
|
||||||
const totals = bankSummary(
|
|
||||||
period,
|
|
||||||
rows.map((row) => ({
|
|
||||||
...row,
|
|
||||||
categoryId: row.originalExpense?.categoryId ?? row.categoryId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const categories = await tx.expenseCategory.findMany({
|
|
||||||
where: { userId },
|
|
||||||
});
|
|
||||||
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) => ({
|
|
||||||
categoryId: row.categoryId,
|
|
||||||
categoryName: categories.find((c) => c.id === row.categoryId)!.name,
|
|
||||||
expenses: rubMoney(row.expenses),
|
|
||||||
refunds: rubMoney(row.refunds),
|
|
||||||
net: rubMoney(row.net),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { applyDecorators } from '@nestjs/common';
|
import { HttpStatus, applyDecorators } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiExtraModels,
|
ApiExtraModels,
|
||||||
ApiProperty,
|
ApiProperty,
|
||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
|
|
||||||
export class ProblemDetailsDto {
|
export class ProblemDetailsDto {
|
||||||
@ApiProperty({ example: 401 })
|
@ApiProperty({ example: 401 })
|
||||||
status: number;
|
declare status: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 'UNAUTHORIZED' })
|
@ApiProperty({ example: 'UNAUTHORIZED' })
|
||||||
code: string;
|
declare code: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
required: false,
|
||||||
@@ -35,10 +35,13 @@ function problemResponse(status: number, description: string) {
|
|||||||
export function ApiProblemResponses(): MethodDecorator & ClassDecorator {
|
export function ApiProblemResponses(): MethodDecorator & ClassDecorator {
|
||||||
return applyDecorators(
|
return applyDecorators(
|
||||||
ApiExtraModels(ProblemDetailsDto),
|
ApiExtraModels(ProblemDetailsDto),
|
||||||
problemResponse(400, 'Invalid request'),
|
problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
|
||||||
problemResponse(401, 'Authentication required'),
|
problemResponse(HttpStatus.UNAUTHORIZED, 'Authentication required'),
|
||||||
problemResponse(404, 'Resource not found'),
|
problemResponse(HttpStatus.NOT_FOUND, 'Resource not found'),
|
||||||
problemResponse(503, 'Upstream service unavailable'),
|
problemResponse(
|
||||||
problemResponse(500, 'Internal server error'),
|
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 { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
@@ -46,11 +47,11 @@ export class RequestLoggingMiddleware implements NestMiddleware {
|
|||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
const level =
|
const level =
|
||||||
response.statusCode >= 500
|
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||||
? 'error'
|
? 'error'
|
||||||
: response.statusCode === 429 ||
|
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
|
||||||
response.statusCode === 401 ||
|
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
|
||||||
response.statusCode === 403
|
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
|
||||||
? 'warn'
|
? 'warn'
|
||||||
: 'info';
|
: 'info';
|
||||||
this.logger.event(level, 'http.request.completed', context);
|
this.logger.event(level, 'http.request.completed', context);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import type { Money } from './money.types';
|
||||||
|
|
||||||
export class MoneyDto {
|
export class MoneyDto implements Money {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
currency: string;
|
declare currency: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
value: string;
|
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 {
|
export interface Environment {
|
||||||
DATABASE_URL: string;
|
DATABASE_URL: string;
|
||||||
FRONTEND_ORIGIN: string;
|
FRONTEND_ORIGIN: string;
|
||||||
@@ -13,12 +18,14 @@ export interface Environment {
|
|||||||
export function validateEnv(config: Record<string, unknown>): Environment {
|
export function validateEnv(config: Record<string, unknown>): Environment {
|
||||||
const databaseUrl = config['DATABASE_URL'];
|
const databaseUrl = config['DATABASE_URL'];
|
||||||
const frontendOrigin = config['FRONTEND_ORIGIN'];
|
const frontendOrigin = config['FRONTEND_ORIGIN'];
|
||||||
const port = config['PORT'] ?? 3000;
|
const port = config['PORT'] ?? DEFAULT_PORT;
|
||||||
const jwtSecret = config['JWT_SECRET'];
|
const jwtSecret = config['JWT_SECRET'];
|
||||||
const tokenEncryptionKey = config['TOKEN_ENCRYPTION_KEY'];
|
const tokenEncryptionKey = config['TOKEN_ENCRYPTION_KEY'];
|
||||||
const tbankEndpoint =
|
const tbankEndpoint =
|
||||||
config['TBANK_GRPC_ENDPOINT'] ?? 'invest-public-api.tbank.ru:443';
|
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 tbankCaCertPath = config['TBANK_CA_CERT_PATH'];
|
||||||
const logLevel = config['LOG_LEVEL'] ?? 'info';
|
const logLevel = config['LOG_LEVEL'] ?? 'info';
|
||||||
|
|
||||||
@@ -47,18 +54,25 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsedPort = Number(port);
|
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');
|
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');
|
throw new Error('JWT_SECRET must be at least 32 characters long');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof tokenEncryptionKey !== 'string' ||
|
typeof tokenEncryptionKey !== 'string' ||
|
||||||
!/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) ||
|
!/^[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(
|
throw new Error(
|
||||||
'TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key',
|
'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';
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { constants as httpStatus } from 'node:http2';
|
||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
|
||||||
|
const FALLBACK_PAGE_SIZE = 100;
|
||||||
|
|
||||||
const baseUrl = 'https://iss.moex.com/iss';
|
const baseUrl = 'https://iss.moex.com/iss';
|
||||||
const timeoutMs = 10_000;
|
const timeoutMs = 10_000;
|
||||||
|
|
||||||
@@ -121,8 +124,8 @@ export class MoexClient {
|
|||||||
if (!paginated) return rows;
|
if (!paginated) return rows;
|
||||||
const cursorBlock = response[`${block}.cursor`];
|
const cursorBlock = response[`${block}.cursor`];
|
||||||
if (!cursorBlock) {
|
if (!cursorBlock) {
|
||||||
if (current.length < 100) return rows;
|
if (current.length < FALLBACK_PAGE_SIZE) return rows;
|
||||||
start += 100;
|
start += FALLBACK_PAGE_SIZE;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cursor = parseRows(cursorBlock)[0];
|
const cursor = parseRows(cursorBlock)[0];
|
||||||
@@ -145,7 +148,7 @@ export class MoexClient {
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
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}`);
|
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
|
||||||
const body: unknown = await response.json();
|
const body: unknown = await response.json();
|
||||||
if (!body || typeof body !== 'object')
|
if (!body || typeof body !== 'object')
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MoexClient } from './moex.client';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [MoexClient],
|
||||||
|
exports: [MoexClient],
|
||||||
|
})
|
||||||
|
export class MoexModule {}
|
||||||
@@ -2,6 +2,12 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
|||||||
import { load } from 'cheerio';
|
import { load } from 'cheerio';
|
||||||
import { LRUCache } from 'lru-cache';
|
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/';
|
const registryUrl = 'https://ratings.ru/ratings/issues/';
|
||||||
export type NcrRating = {
|
export type NcrRating = {
|
||||||
agency: 'NCR';
|
agency: 'NCR';
|
||||||
@@ -18,7 +24,7 @@ export class NcrRatingsClient {
|
|||||||
private readonly cache = new LRUCache<
|
private readonly cache = new LRUCache<
|
||||||
string,
|
string,
|
||||||
Promise<Map<string, NcrRating>>
|
Promise<Map<string, NcrRating>>
|
||||||
>({ max: 1, ttl: 6 * 60 * 60_000 });
|
>({ max: 1, ttl: REGISTRY_CACHE_TTL_MS });
|
||||||
|
|
||||||
async rating(isin: string): Promise<NcrRating | null> {
|
async rating(isin: string): Promise<NcrRating | null> {
|
||||||
const key = isin.trim().toUpperCase();
|
const key = isin.trim().toUpperCase();
|
||||||
@@ -38,7 +44,7 @@ export class NcrRatingsClient {
|
|||||||
|
|
||||||
private async registry(): Promise<Map<string, NcrRating>> {
|
private async registry(): Promise<Map<string, NcrRating>> {
|
||||||
const response = await fetch(registryUrl, {
|
const response = await fetch(registryUrl, {
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('NCR unavailable');
|
if (!response.ok) throw new Error('NCR unavailable');
|
||||||
const $ = load(await response.text());
|
const $ = load(await response.text());
|
||||||
@@ -49,18 +55,18 @@ export class NcrRatingsClient {
|
|||||||
.get();
|
.get();
|
||||||
if (
|
if (
|
||||||
table.length !== 1 ||
|
table.length !== 1 ||
|
||||||
headings.length !== 6 ||
|
headings.length !== COLUMN_COUNT ||
|
||||||
headings[2] !== 'Рейтинг' ||
|
headings[2] !== 'Рейтинг' ||
|
||||||
headings[3] !== 'ISIN' ||
|
headings[ISIN_COLUMN] !== 'ISIN' ||
|
||||||
headings[5] !== 'Дата'
|
headings[DATE_COLUMN] !== 'Дата'
|
||||||
)
|
)
|
||||||
throw new Error('NCR registry changed');
|
throw new Error('NCR registry changed');
|
||||||
const observedAt = new Date().toISOString();
|
const observedAt = new Date().toISOString();
|
||||||
const result = new Map<string, NcrRating>();
|
const result = new Map<string, NcrRating>();
|
||||||
table.find('tbody tr').each((_, row) => {
|
table.find('tbody tr').each((_, row) => {
|
||||||
const cells = $(row).find('td');
|
const cells = $(row).find('td');
|
||||||
if (cells.length !== 6) throw new Error('NCR row changed');
|
if (cells.length !== COLUMN_COUNT) throw new Error('NCR row changed');
|
||||||
const isin = cells.eq(3).text().trim().toUpperCase();
|
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
|
||||||
// Unassigned issues cannot safely be matched to a held instrument.
|
// Unassigned issues cannot safely be matched to a held instrument.
|
||||||
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
|
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
|
||||||
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim();
|
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim();
|
||||||
@@ -73,9 +79,9 @@ export class NcrRatingsClient {
|
|||||||
)
|
)
|
||||||
throw new Error('NCR rating changed');
|
throw new Error('NCR rating changed');
|
||||||
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
|
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
|
||||||
cells.eq(5).text().trim(),
|
cells.eq(DATE_COLUMN).text().trim(),
|
||||||
);
|
);
|
||||||
const href = cells.eq(3).find('a').attr('href');
|
const href = cells.eq(ISIN_COLUMN).find('a').attr('href');
|
||||||
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href))
|
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href))
|
||||||
throw new Error('NCR identity missing');
|
throw new Error('NCR identity missing');
|
||||||
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
|
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
|
||||||
@@ -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,4 @@
|
|||||||
import { add, formatDecimal, multiply, subtract } from '../common/decimal';
|
import { add, formatDecimal, multiply, subtract } from '../../common/decimal';
|
||||||
import { decimal } from './decimal';
|
import { decimal } from './decimal';
|
||||||
|
|
||||||
describe('decimal', () => {
|
describe('decimal', () => {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Decimal } from '../common/decimal';
|
import { Decimal } from '../../common/decimal';
|
||||||
|
|
||||||
const NANO = 1_000_000_000n;
|
const NANO = 1_000_000_000n;
|
||||||
|
|
||||||
+8
-4
@@ -1,6 +1,7 @@
|
|||||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
|
||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
import type { LRUCache } from 'lru-cache';
|
import type { LRUCache } from 'lru-cache';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
import type { TbankInstrument } from './tbank.client';
|
import type { TbankInstrument } from './tbank.client';
|
||||||
|
|
||||||
describe('InstrumentMetadataService', () => {
|
describe('InstrumentMetadataService', () => {
|
||||||
@@ -178,7 +179,7 @@ describe('InstrumentMetadataService', () => {
|
|||||||
it('isolates in-flight credentials while sharing successful public metadata', async () => {
|
it('isolates in-flight credentials while sharing successful public metadata', async () => {
|
||||||
const error = new Error('INVALID_TBANK_TOKEN');
|
const error = new Error('INVALID_TBANK_TOKEN');
|
||||||
const known = { name: 'Облигация' };
|
const known = { name: 'Облигация' };
|
||||||
let rejectInvalid!: (reason: Error) => void;
|
let rejectInvalid: ((reason: Error) => void) | undefined;
|
||||||
tbank.getInstrumentByUid.mockImplementation((token: string) =>
|
tbank.getInstrumentByUid.mockImplementation((token: string) =>
|
||||||
token === 'invalid-token'
|
token === 'invalid-token'
|
||||||
? new Promise((_resolve, reject) => {
|
? new Promise((_resolve, reject) => {
|
||||||
@@ -191,6 +192,7 @@ describe('InstrumentMetadataService', () => {
|
|||||||
service.resolve('invalid-token', 'uid-1', null),
|
service.resolve('invalid-token', 'uid-1', null),
|
||||||
).rejects.toBe(error);
|
).rejects.toBe(error);
|
||||||
const valid = service.resolve('valid-token', 'uid-1', null);
|
const valid = service.resolve('valid-token', 'uid-1', null);
|
||||||
|
assert.ok(rejectInvalid);
|
||||||
rejectInvalid(error);
|
rejectInvalid(error);
|
||||||
|
|
||||||
await invalid;
|
await invalid;
|
||||||
@@ -237,7 +239,7 @@ describe('InstrumentMetadataService', () => {
|
|||||||
|
|
||||||
it('keeps a shared fetch alive after one caller aborts and caches its result', async () => {
|
it('keeps a shared fetch alive after one caller aborts and caches its result', async () => {
|
||||||
const known = { name: 'Облигация' };
|
const known = { name: 'Облигация' };
|
||||||
let finish!: (value: TbankInstrument) => void;
|
let finish: ((value: TbankInstrument) => void) | undefined;
|
||||||
tbank.getInstrumentByUid.mockReturnValue(
|
tbank.getInstrumentByUid.mockReturnValue(
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
finish = resolve;
|
finish = resolve;
|
||||||
@@ -254,6 +256,7 @@ describe('InstrumentMetadataService', () => {
|
|||||||
await expect(aborted).resolves.toBeNull();
|
await expect(aborted).resolves.toBeNull();
|
||||||
|
|
||||||
const waiting = service.resolve('token', 'uid-1', null);
|
const waiting = service.resolve('token', 'uid-1', null);
|
||||||
|
assert.ok(finish);
|
||||||
finish(known);
|
finish(known);
|
||||||
|
|
||||||
await expect(waiting).resolves.toEqual(known);
|
await expect(waiting).resolves.toEqual(known);
|
||||||
@@ -265,7 +268,7 @@ describe('InstrumentMetadataService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not start FIGI lookup after an aborted UID lookup fails later', async () => {
|
it('does not start FIGI lookup after an aborted UID lookup fails later', async () => {
|
||||||
let fail!: (reason: Error) => void;
|
let fail: ((reason: Error) => void) | undefined;
|
||||||
tbank.getInstrumentByUid.mockReturnValue(
|
tbank.getInstrumentByUid.mockReturnValue(
|
||||||
new Promise((_resolve, reject) => {
|
new Promise((_resolve, reject) => {
|
||||||
fail = reject;
|
fail = reject;
|
||||||
@@ -281,6 +284,7 @@ describe('InstrumentMetadataService', () => {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
await expect(result).resolves.toBeNull();
|
await expect(result).resolves.toBeNull();
|
||||||
|
|
||||||
|
assert.ok(fail);
|
||||||
fail(new Error('INVALID_TBANK_TOKEN'));
|
fail(new Error('INVALID_TBANK_TOKEN'));
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||||
|
|
||||||
const metadataForMs = 86_400_000;
|
const metadataForMs = 86_400_000;
|
||||||
+2
-2
@@ -5,12 +5,12 @@ import {
|
|||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
||||||
jest.mock('../prisma/prisma.service', () => ({
|
jest.mock('../../prisma/prisma.service', () => ({
|
||||||
PrismaService: class PrismaService {},
|
PrismaService: class PrismaService {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
|
||||||
import type { TbankAccountContext } from './tbank-access.service';
|
import type { TbankAccountContext } from './tbank-access.service';
|
||||||
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
|
|
||||||
describe('TbankAccessService', () => {
|
describe('TbankAccessService', () => {
|
||||||
@@ -4,14 +4,14 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
|
||||||
import {
|
import {
|
||||||
|
SupportedTbankAccount,
|
||||||
accessibleTbankAccount,
|
accessibleTbankAccount,
|
||||||
supportedTbankAccounts,
|
supportedTbankAccounts,
|
||||||
} from './tbank-account-policy';
|
} from './tbank-account.policy';
|
||||||
import { TbankClient, TbankAccount } from './tbank.client';
|
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
|
import { TbankAccount, TbankClient } from './tbank.client';
|
||||||
|
|
||||||
export type TbankConnectionContext = {
|
export type TbankConnectionContext = {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -30,7 +30,9 @@ export class TbankAccessService {
|
|||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listSupportedAccounts(userId: string): Promise<BrokerageAccountDto[]> {
|
async listSupportedAccounts(
|
||||||
|
userId: string,
|
||||||
|
): Promise<SupportedTbankAccount[]> {
|
||||||
return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
|
return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ export class TbankAccessService {
|
|||||||
handler: (context: {
|
handler: (context: {
|
||||||
token: string;
|
token: string;
|
||||||
generation: string;
|
generation: string;
|
||||||
accounts: BrokerageAccountDto[];
|
accounts: SupportedTbankAccount[];
|
||||||
}) => Promise<T> | T,
|
}) => Promise<T> | T,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return this.withConnection(userId, async ({ token, generation }) =>
|
return this.withConnection(userId, async ({ token, generation }) =>
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
accessibleTbankAccount,
|
accessibleTbankAccount,
|
||||||
supportedTbankAccounts,
|
supportedTbankAccounts,
|
||||||
} from './tbank-account-policy';
|
} from './tbank-account.policy';
|
||||||
|
|
||||||
describe('tbank-account-policy', () => {
|
describe('tbank-account-policy', () => {
|
||||||
it('normalizes supported account types and fallback names', () => {
|
it('normalizes supported account types and fallback names', () => {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||||
import { TbankAccount } from './tbank.client';
|
import { TbankAccount } from './tbank.client';
|
||||||
|
|
||||||
const accountTypes = {
|
const accountTypes = {
|
||||||
@@ -15,10 +15,16 @@ const accountTypes = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type AccountType = keyof typeof accountTypes;
|
type AccountType = keyof typeof accountTypes;
|
||||||
|
export type SupportedTbankAccount = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: (typeof accountTypes)[AccountType]['type'];
|
||||||
|
openedOn: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export function supportedTbankAccounts(
|
export function supportedTbankAccounts(
|
||||||
accounts: TbankAccount[],
|
accounts: TbankAccount[],
|
||||||
): BrokerageAccountDto[] {
|
): SupportedTbankAccount[] {
|
||||||
return accounts.flatMap((account) => {
|
return accounts.flatMap((account) => {
|
||||||
const details = accountTypes[account.type as AccountType];
|
const details = accountTypes[account.type as AccountType];
|
||||||
if (!details) return [];
|
if (!details) return [];
|
||||||
@@ -46,7 +52,7 @@ export function accessibleTbankAccount(
|
|||||||
|
|
||||||
function openedOn(seconds: string | number | undefined): string | null {
|
function openedOn(seconds: string | number | undefined): string | null {
|
||||||
if (seconds === undefined) return null;
|
if (seconds === undefined) return null;
|
||||||
const date = new Date(Number(seconds) * 1000);
|
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
|
||||||
if (Number.isNaN(date.valueOf())) return null;
|
if (Number.isNaN(date.valueOf())) return null;
|
||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
TbankDecimal,
|
TbankDecimal,
|
||||||
TbankMoney,
|
|
||||||
TbankInstrument,
|
TbankInstrument,
|
||||||
|
TbankMoney,
|
||||||
TbankTimestamp,
|
TbankTimestamp,
|
||||||
} from './tbank.client';
|
} from './tbank.client';
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Environment } from '../config/env.validation';
|
import { Environment } from '../../config/env.validation';
|
||||||
import { TbankCrypto } from './tbank.crypto';
|
import { TbankCrypto } from './tbank.crypto';
|
||||||
|
|
||||||
@Injectable()
|
@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';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
|
||||||
import { status } from '@grpc/grpc-js';
|
import { status } from '@grpc/grpc-js';
|
||||||
|
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
describe('TbankClient operations', () => {
|
describe('TbankClient operations', () => {
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import {
|
import {
|
||||||
ChannelCredentials,
|
ChannelCredentials,
|
||||||
Client,
|
Client,
|
||||||
@@ -14,20 +9,33 @@ import {
|
|||||||
status,
|
status,
|
||||||
} from '@grpc/grpc-js';
|
} from '@grpc/grpc-js';
|
||||||
import { loadSync } from '@grpc/proto-loader';
|
import { loadSync } from '@grpc/proto-loader';
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { Environment } from '../config/env.validation';
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
import { Environment } from '../../config/env.validation';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
TbankShare,
|
|
||||||
TbankBond,
|
TbankBond,
|
||||||
TbankCoupon,
|
|
||||||
TbankCandle,
|
TbankCandle,
|
||||||
TbankLastPrice,
|
TbankCoupon,
|
||||||
TbankFundamentals,
|
TbankFundamentals,
|
||||||
|
TbankLastPrice,
|
||||||
|
TbankShare,
|
||||||
} from './tbank-market.types';
|
} 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;
|
const requestDeadlineMs = 15_000;
|
||||||
|
|
||||||
type Unary<Request, Response> = (
|
type Unary<Request, Response> = (
|
||||||
@@ -134,7 +142,7 @@ class RateLimiter {
|
|||||||
|
|
||||||
private drain(): void {
|
private drain(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.windowStartedAt >= 1000) {
|
if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
|
||||||
this.windowStartedAt = now;
|
this.windowStartedAt = now;
|
||||||
this.starts = 0;
|
this.starts = 0;
|
||||||
}
|
}
|
||||||
@@ -145,7 +153,7 @@ class RateLimiter {
|
|||||||
if (this.waiting.length && this.starts === this.perSecond) {
|
if (this.waiting.length && this.starts === this.perSecond) {
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => this.drain(),
|
() => this.drain(),
|
||||||
Math.max(1, 1000 - (now - this.windowStartedAt)),
|
Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,10 +172,13 @@ export class TbankClient {
|
|||||||
config: ConfigService<Environment, true>,
|
config: ConfigService<Environment, true>,
|
||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
) {
|
) {
|
||||||
const sourceProtoRoot = join(process.cwd(), 'src/tbank/proto/contracts');
|
const sourceProtoRoot = join(
|
||||||
|
process.cwd(),
|
||||||
|
'src/integrations/tbank/proto/contracts',
|
||||||
|
);
|
||||||
const protoRoot = existsSync(sourceProtoRoot)
|
const protoRoot = existsSync(sourceProtoRoot)
|
||||||
? sourceProtoRoot
|
? sourceProtoRoot
|
||||||
: join(process.cwd(), 'dist/tbank/proto/contracts');
|
: join(process.cwd(), 'dist/integrations/tbank/proto/contracts');
|
||||||
const definition = loadSync(
|
const definition = loadSync(
|
||||||
[
|
[
|
||||||
'users.proto',
|
'users.proto',
|
||||||
@@ -233,11 +244,12 @@ export class TbankClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getOpenAccounts(token: string): Promise<TbankAccount[]> {
|
getOpenAccounts(token: string): Promise<TbankAccount[]> {
|
||||||
return this.call(token, this.users, 'getAccounts', {
|
return this.call<{ accounts?: TbankAccount[] }>(
|
||||||
status: 'ACCOUNT_STATUS_OPEN',
|
token,
|
||||||
}).then(
|
this.users,
|
||||||
(response: { accounts?: TbankAccount[] }) => response.accounts ?? [],
|
'getAccounts',
|
||||||
);
|
{ status: 'ACCOUNT_STATUS_OPEN' },
|
||||||
|
).then((response) => response.accounts ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
getPortfolio(token: string, accountId: string): Promise<TbankPortfolio> {
|
getPortfolio(token: string, accountId: string): Promise<TbankPortfolio> {
|
||||||
@@ -275,13 +287,16 @@ export class TbankClient {
|
|||||||
from: string,
|
from: string,
|
||||||
to: string,
|
to: string,
|
||||||
): Promise<TbankDividend[]> {
|
): Promise<TbankDividend[]> {
|
||||||
return this.call(token, this.instruments, 'getDividends', {
|
return this.call<{ dividends?: TbankDividend[] }>(
|
||||||
|
token,
|
||||||
|
this.instruments,
|
||||||
|
'getDividends',
|
||||||
|
{
|
||||||
instrumentId,
|
instrumentId,
|
||||||
from: { seconds: String(utcSeconds(from)) },
|
from: { seconds: String(utcSeconds(from)) },
|
||||||
to: { seconds: String(utcSeconds(to)) },
|
to: { seconds: String(utcSeconds(to)) },
|
||||||
}).then(
|
},
|
||||||
(response: { dividends?: TbankDividend[] }) => response.dividends ?? [],
|
).then((response) => response.dividends ?? []);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
findShares(token: string, query: string): Promise<TbankShare[]> {
|
findShares(token: string, query: string): Promise<TbankShare[]> {
|
||||||
@@ -415,8 +430,12 @@ export class TbankClient {
|
|||||||
'getCandles',
|
'getCandles',
|
||||||
{
|
{
|
||||||
instrumentId: uid,
|
instrumentId: uid,
|
||||||
from: { seconds: String(Math.floor(from.valueOf() / 1000)) },
|
from: {
|
||||||
to: { seconds: String(Math.floor(to.valueOf() / 1000)) },
|
seconds: String(Math.floor(from.valueOf() / MILLISECONDS_PER_SECOND)),
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
|
||||||
|
},
|
||||||
interval: 'CANDLE_INTERVAL_DAY',
|
interval: 'CANDLE_INTERVAL_DAY',
|
||||||
limit: 2400,
|
limit: 2400,
|
||||||
},
|
},
|
||||||
@@ -449,10 +468,10 @@ export class TbankClient {
|
|||||||
request: object,
|
request: object,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const metadataCall = method === 'getInstrumentBy';
|
const metadataCall = method === 'getInstrumentBy';
|
||||||
const maxAttempts = metadataCall ? 3 : 2;
|
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
|
||||||
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
|
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const queueFull = this.queue.size >= 50;
|
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||||
try {
|
try {
|
||||||
if (queueFull)
|
if (queueFull)
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||||
@@ -485,7 +504,12 @@ export class TbankClient {
|
|||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
});
|
});
|
||||||
await new Promise((resolve) =>
|
await new Promise((resolve) =>
|
||||||
setTimeout(resolve, metadataCall ? attempts * 2000 : 1000),
|
setTimeout(
|
||||||
|
resolve,
|
||||||
|
metadataCall
|
||||||
|
? attempts * METADATA_RETRY_DELAY_MS
|
||||||
|
: RPC_RETRY_DELAY_MS,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -498,6 +522,7 @@ export class TbankClient {
|
|||||||
if (
|
if (
|
||||||
[status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode)
|
[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');
|
throw new Error('INVALID_TBANK_TOKEN');
|
||||||
if (
|
if (
|
||||||
grpcCode === status.NOT_FOUND &&
|
grpcCode === status.NOT_FOUND &&
|
||||||
@@ -530,5 +555,5 @@ export class TbankClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function utcSeconds(date: string): number {
|
function utcSeconds(date: string): number {
|
||||||
return Math.floor(Date.parse(`${date}T00:00:00Z`) / 1000);
|
return Math.floor(Date.parse(`${date}T00:00:00Z`) / MILLISECONDS_PER_SECOND);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const KEY_SIZE_BYTES = 32;
|
||||||
|
|
||||||
const VERSION = 1;
|
const VERSION = 1;
|
||||||
const NONCE_SIZE = 12;
|
const NONCE_SIZE = 12;
|
||||||
const TAG_SIZE = 16;
|
const TAG_SIZE = 16;
|
||||||
@@ -9,7 +11,8 @@ export class TbankCrypto {
|
|||||||
|
|
||||||
static fromBase64(key: string): TbankCrypto {
|
static fromBase64(key: string): TbankCrypto {
|
||||||
const decoded = Buffer.from(key, 'base64');
|
const decoded = Buffer.from(key, 'base64');
|
||||||
if (decoded.length !== 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);
|
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 {}
|
||||||
@@ -7,7 +7,7 @@ jest.mock('bcrypt', () => ({
|
|||||||
compare: jest.fn(() => Promise.resolve(true)),
|
compare: jest.fn(() => Promise.resolve(true)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../prisma/prisma.service', () => ({
|
jest.mock('../../../prisma/prisma.service', () => ({
|
||||||
PrismaService: class PrismaService {},
|
PrismaService: class PrismaService {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -6,10 +6,13 @@ import {
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { createHash, randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
import { Session, User } from '../../generated/prisma/client';
|
import { Session, User } from '../../../../generated/prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { AuthUser, RefreshPayload } from './auth.types';
|
import { AuthUser, RefreshPayload } from './auth.types';
|
||||||
|
|
||||||
|
const PASSWORD_HASH_ROUNDS = 12;
|
||||||
|
const REFRESH_TTL_MS = 604_800_000;
|
||||||
|
|
||||||
const ACCESS_TTL = '15m';
|
const ACCESS_TTL = '15m';
|
||||||
const REFRESH_TTL = '7d';
|
const REFRESH_TTL = '7d';
|
||||||
const DUMMY_PASSWORD_HASH =
|
const DUMMY_PASSWORD_HASH =
|
||||||
@@ -39,7 +42,7 @@ export class AuthService {
|
|||||||
data: {
|
data: {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
passwordHash: await bcrypt.hash(password, 12),
|
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -162,7 +165,7 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
const data = {
|
const data = {
|
||||||
refreshTokenHash: this.hash(refreshToken),
|
refreshTokenHash: this.hash(refreshToken),
|
||||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
|
||||||
};
|
};
|
||||||
if (expectedRefreshTokenHash) {
|
if (expectedRefreshTokenHash) {
|
||||||
const updated = await this.prisma.session.updateMany({
|
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 { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { Environment } from '../config/env.validation';
|
import { Environment } from '../../config/env.validation';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './presentation/auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './application/auth.service';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -19,6 +19,7 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
exports: [JwtAuthGuard],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy, JwtAuthGuard],
|
providers: [AuthService, JwtStrategy, JwtAuthGuard],
|
||||||
})
|
})
|
||||||
@@ -2,8 +2,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { Environment } from '../config/env.validation';
|
import { Environment } from '../../../config/env.validation';
|
||||||
import { AccessPayload } from './auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
|
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
|
||||||
|
|
||||||
jest.mock('./auth.service', () => ({
|
jest.mock('../application/auth.service', () => ({
|
||||||
AuthService: class AuthService {},
|
AuthService: class AuthService {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -3,11 +3,12 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
@@ -17,29 +18,29 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { CredentialsDto } from './auth.dto';
|
import { CredentialsDto } from './auth.dto';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from '../application/auth.service';
|
||||||
import { AccessPayload } from './auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
|
|
||||||
|
|
||||||
const REFRESH_COOKIE = 'refresh_token';
|
const REFRESH_COOKIE = 'refresh_token';
|
||||||
const COOKIE_PATH = '/api/v1/auth';
|
const COOKIE_PATH = '/api/v1/auth';
|
||||||
|
|
||||||
class UserDto {
|
class UserDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
id: string;
|
declare id: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
email: string;
|
declare email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthenticationDto {
|
class AuthenticationDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
accessToken: string;
|
declare accessToken: string;
|
||||||
|
|
||||||
@ApiProperty({ type: UserDto })
|
@ApiProperty({ type: UserDto })
|
||||||
user: UserDto;
|
declare user: UserDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@@ -49,7 +50,7 @@ export class AuthController {
|
|||||||
constructor(private readonly auth: AuthService) {}
|
constructor(private readonly auth: AuthService) {}
|
||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@HttpCode(200)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOkResponse({ type: AuthenticationDto })
|
@ApiOkResponse({ type: AuthenticationDto })
|
||||||
async register(
|
async register(
|
||||||
@Body() credentials: CredentialsDto,
|
@Body() credentials: CredentialsDto,
|
||||||
@@ -62,7 +63,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(200)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOkResponse({ type: AuthenticationDto })
|
@ApiOkResponse({ type: AuthenticationDto })
|
||||||
async login(
|
async login(
|
||||||
@Body() credentials: CredentialsDto,
|
@Body() credentials: CredentialsDto,
|
||||||
@@ -75,7 +76,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@HttpCode(200)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOkResponse({ type: AuthenticationDto })
|
@ApiOkResponse({ type: AuthenticationDto })
|
||||||
async refresh(
|
async refresh(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@@ -92,7 +93,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(204)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiNoContentResponse()
|
@ApiNoContentResponse()
|
||||||
async logout(
|
async logout(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@@ -1,26 +1,32 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsString,
|
IsString,
|
||||||
MinLength,
|
MinLength,
|
||||||
Validate,
|
Validate,
|
||||||
ValidatorConstraint,
|
ValidatorConstraint,
|
||||||
} from 'class-validator';
|
} 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' })
|
@ValidatorConstraint({ name: 'email' })
|
||||||
class EmailConstraint {
|
class EmailConstraint {
|
||||||
validate(value: unknown): boolean {
|
validate(value: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
typeof value === 'string' &&
|
typeof value === 'string' &&
|
||||||
value.length <= 254 &&
|
value.length <= MAX_EMAIL_LENGTH &&
|
||||||
value.split('@').length === 2 &&
|
value.split('@').length === 2 &&
|
||||||
!value
|
!value
|
||||||
.split('')
|
.split('')
|
||||||
.some(
|
.some(
|
||||||
(character) =>
|
(character) =>
|
||||||
/\s/.test(character) ||
|
/\s/.test(character) ||
|
||||||
character.charCodeAt(0) < 32 ||
|
character.charCodeAt(0) < ASCII_SPACE ||
|
||||||
character.charCodeAt(0) === 127,
|
character.charCodeAt(0) === ASCII_DELETE,
|
||||||
) &&
|
) &&
|
||||||
value.split('@').every(Boolean)
|
value.split('@').every(Boolean)
|
||||||
);
|
);
|
||||||
@@ -33,7 +39,10 @@ class EmailConstraint {
|
|||||||
@ValidatorConstraint({ name: 'passwordBytes' })
|
@ValidatorConstraint({ name: 'passwordBytes' })
|
||||||
class PasswordBytesConstraint {
|
class PasswordBytesConstraint {
|
||||||
validate(value: unknown): boolean {
|
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 {
|
defaultMessage(): string {
|
||||||
return 'must not exceed 72 UTF-8 bytes';
|
return 'must not exceed 72 UTF-8 bytes';
|
||||||
@@ -47,11 +56,15 @@ export class CredentialsDto {
|
|||||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||||
)
|
)
|
||||||
@Validate(EmailConstraint)
|
@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()
|
@IsString()
|
||||||
@MinLength(12)
|
@MinLength(MIN_PASSWORD_LENGTH)
|
||||||
@Validate(PasswordBytesConstraint)
|
@Validate(PasswordBytesConstraint)
|
||||||
password: string;
|
declare password: string;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { RequestContextService } from '../common/logging/request-context.service';
|
import { RequestContextService } from '../../../common/logging/request-context.service';
|
||||||
import { AccessPayload } from './auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
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 {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const MAX_CARD_NAME_LENGTH = 100;
|
||||||
|
export const MAX_CATEGORY_NAME_LENGTH = 80;
|
||||||
|
export const MAX_OPERATION_DESCRIPTION_LENGTH = 500;
|
||||||
+29
-7
@@ -1,3 +1,6 @@
|
|||||||
|
import { BankOperationKind } from './bank-operation-kind';
|
||||||
|
|
||||||
|
const ISO_MONTH_LENGTH = 7;
|
||||||
export function moscowToday(now = new Date()): string {
|
export function moscowToday(now = new Date()): string {
|
||||||
return new Intl.DateTimeFormat('en-CA', {
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
timeZone: 'Europe/Moscow',
|
timeZone: 'Europe/Moscow',
|
||||||
@@ -30,12 +33,21 @@ export function bankBalance(
|
|||||||
let balance = opening;
|
let balance = opening;
|
||||||
for (const operation of operations) {
|
for (const operation of operations) {
|
||||||
if (operation.cardId === cardId) {
|
if (operation.cardId === cardId) {
|
||||||
if (operation.kind === 'expense' || operation.kind === 'transfer')
|
if (
|
||||||
|
operation.kind === BankOperationKind.Expense ||
|
||||||
|
operation.kind === BankOperationKind.Transfer
|
||||||
|
)
|
||||||
balance -= operation.amountKopecks;
|
balance -= operation.amountKopecks;
|
||||||
else if (operation.kind === 'topup' || operation.kind === 'refund')
|
else if (
|
||||||
|
operation.kind === BankOperationKind.Topup ||
|
||||||
|
operation.kind === BankOperationKind.Refund
|
||||||
|
)
|
||||||
balance += operation.amountKopecks;
|
balance += operation.amountKopecks;
|
||||||
}
|
}
|
||||||
if (operation.kind === 'transfer' && operation.targetCardId === cardId)
|
if (
|
||||||
|
operation.kind === BankOperationKind.Transfer &&
|
||||||
|
operation.targetCardId === cardId
|
||||||
|
)
|
||||||
balance += operation.amountKopecks;
|
balance += operation.amountKopecks;
|
||||||
}
|
}
|
||||||
return balance;
|
return balance;
|
||||||
@@ -60,13 +72,23 @@ export function bankSummary(
|
|||||||
for (const operation of operations) {
|
for (const operation of operations) {
|
||||||
if (
|
if (
|
||||||
typeof period === 'string'
|
typeof period === 'string'
|
||||||
? operation.date.slice(0, 7) !== period
|
? operation.date.slice(0, ISO_MONTH_LENGTH) !== period
|
||||||
: operation.date < period.from || operation.date > period.to
|
: operation.date < period.from || operation.date > period.to
|
||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
if (operation.kind !== 'expense' && operation.kind !== 'refund') continue;
|
if (
|
||||||
const expense = operation.kind === 'expense' ? operation.amountKopecks : 0n;
|
operation.kind !== BankOperationKind.Expense &&
|
||||||
const refund = operation.kind === 'refund' ? operation.amountKopecks : 0n;
|
operation.kind !== BankOperationKind.Refund
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const expense =
|
||||||
|
operation.kind === BankOperationKind.Expense
|
||||||
|
? operation.amountKopecks
|
||||||
|
: 0n;
|
||||||
|
const refund =
|
||||||
|
operation.kind === BankOperationKind.Refund
|
||||||
|
? operation.amountKopecks
|
||||||
|
: 0n;
|
||||||
expenses += expense;
|
expenses += expense;
|
||||||
refunds += refund;
|
refunds += refund;
|
||||||
if (operation.categoryId !== null) {
|
if (operation.categoryId !== null) {
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const BankOperationKind = {
|
||||||
|
Expense: 'expense',
|
||||||
|
Topup: 'topup',
|
||||||
|
Transfer: 'transfer',
|
||||||
|
Refund: 'refund',
|
||||||
|
} as const;
|
||||||
|
export type BankOperationKind =
|
||||||
|
(typeof BankOperationKind)[keyof typeof BankOperationKind];
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { decimalFromString, formatDecimal } from './decimal';
|
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||||
import { MoneyDto } from './money.dto';
|
import type { Money } from '../../../../common/money.types';
|
||||||
|
|
||||||
export function parseRubMoney(money: MoneyDto, positive = false): bigint {
|
const MIN_KOPECKS = -9_223_372_036_854_775_808n;
|
||||||
|
const MAX_KOPECKS = 9_223_372_036_854_775_807n;
|
||||||
|
|
||||||
|
export function parseRubMoney(money: Money, positive = false): bigint {
|
||||||
if (
|
if (
|
||||||
money?.currency !== 'RUB' ||
|
money?.currency !== 'RUB' ||
|
||||||
typeof money.value !== 'string' ||
|
typeof money.value !== 'string' ||
|
||||||
@@ -12,14 +15,14 @@ export function parseRubMoney(money: MoneyDto, positive = false): bigint {
|
|||||||
if (amount.scale > 2)
|
if (amount.scale > 2)
|
||||||
throw new RangeError('RUB money supports at most two decimal places');
|
throw new RangeError('RUB money supports at most two decimal places');
|
||||||
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
|
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
|
||||||
if (kopecks < -(2n ** 63n) || kopecks > 2n ** 63n - 1n)
|
if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS)
|
||||||
throw new RangeError('RUB money is outside the signed 64-bit range');
|
throw new RangeError('RUB money is outside the signed 64-bit range');
|
||||||
if (positive && kopecks <= 0n)
|
if (positive && kopecks <= 0n)
|
||||||
throw new RangeError('Operation amount must be positive');
|
throw new RangeError('Operation amount must be positive');
|
||||||
return kopecks;
|
return kopecks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rubMoney(kopecks: bigint): MoneyDto {
|
export function rubMoney(kopecks: bigint): Money {
|
||||||
return {
|
return {
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
value: formatDecimal({ value: kopecks, scale: 2 }),
|
value: formatDecimal({ value: kopecks, scale: 2 }),
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BankErrorCode,
|
||||||
|
conflict,
|
||||||
|
missing,
|
||||||
|
} from '../application/bank-card-input';
|
||||||
|
type Tx = Prisma.TransactionClient;
|
||||||
|
const TRANSACTION_MAX_WAIT_MS = 10_000;
|
||||||
|
const TRANSACTION_TIMEOUT_MS = 15_000;
|
||||||
|
const MAX_WRITE_RETRY_COUNT = 2;
|
||||||
|
const seeds = [
|
||||||
|
'Продукты',
|
||||||
|
'Кафе',
|
||||||
|
'Такси',
|
||||||
|
'Транспорт',
|
||||||
|
'Жильё',
|
||||||
|
'Здоровье',
|
||||||
|
'Покупки',
|
||||||
|
'Развлечения',
|
||||||
|
'Другое',
|
||||||
|
];
|
||||||
|
@Injectable()
|
||||||
|
export class BankCardsRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction(run, {
|
||||||
|
maxWait: TRANSACTION_MAX_WAIT_MS,
|
||||||
|
timeout: TRANSACTION_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError
|
||||||
|
? error.code
|
||||||
|
: '';
|
||||||
|
const busy =
|
||||||
|
code === 'P2034' ||
|
||||||
|
code === 'P2028' ||
|
||||||
|
(error instanceof Error &&
|
||||||
|
/SQLITE_BUSY|database is locked/.test(error.message));
|
||||||
|
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT)
|
||||||
|
continue;
|
||||||
|
if (busy) conflict(BankErrorCode.WriteConflict);
|
||||||
|
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async card(tx: Tx, userId: string, id: string, active = false) {
|
||||||
|
const card = await tx.bankCard.findFirst({ where: { id, userId } });
|
||||||
|
if (!card) return missing();
|
||||||
|
if (active && card.archivedAt) conflict(BankErrorCode.CardArchived);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
async operation(tx: Tx, userId: string, id: string) {
|
||||||
|
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
|
||||||
|
if (!row) return missing();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
async seed(tx: Tx, userId: string) {
|
||||||
|
for (const [index, label] of seeds.entries()) {
|
||||||
|
const seedKey = index === seeds.length - 1 ? 'other' : String(index);
|
||||||
|
if (
|
||||||
|
!(await tx.expenseCategory.findUnique({
|
||||||
|
where: { userId_seedKey: { userId, seedKey } },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
await tx.expenseCategory.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId,
|
||||||
|
seedKey,
|
||||||
|
name: label,
|
||||||
|
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summaryData(
|
||||||
|
userId: string,
|
||||||
|
period: { from: string; to: string },
|
||||||
|
cardId?: string,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
if (cardId) await this.card(tx, userId, cardId);
|
||||||
|
const rows = await tx.bankCardOperation.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
date: { gte: period.from, lte: period.to },
|
||||||
|
kind: { in: [BankOperationKind.Expense, BankOperationKind.Refund] },
|
||||||
|
...(cardId ? { cardId } : {}),
|
||||||
|
},
|
||||||
|
include: { originalExpense: { select: { categoryId: true } } },
|
||||||
|
});
|
||||||
|
const categories = await tx.expenseCategory.findMany({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
operations: rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
categoryId: row.originalExpense?.categoryId ?? row.categoryId,
|
||||||
|
})),
|
||||||
|
categories,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
-78
@@ -1,8 +1,14 @@
|
|||||||
|
import {
|
||||||
|
MAX_CARD_NAME_LENGTH,
|
||||||
|
MAX_CATEGORY_NAME_LENGTH,
|
||||||
|
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||||
|
} from '../domain/bank-card-limits';
|
||||||
|
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||||
import {
|
import {
|
||||||
ApiProperty,
|
ApiProperty,
|
||||||
ApiPropertyOptional,
|
ApiPropertyOptional,
|
||||||
OmitType,
|
|
||||||
IntersectionType,
|
IntersectionType,
|
||||||
|
OmitType,
|
||||||
PartialType,
|
PartialType,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
@@ -22,38 +28,39 @@ import {
|
|||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { MoneyDto } from '../common/money.dto';
|
import { MoneyDto } from '../../../../common/money.dto';
|
||||||
|
|
||||||
|
export const DEFAULT_BANK_PAGE_SIZE = 50;
|
||||||
|
const MAX_BANK_PAGE_SIZE = 100;
|
||||||
|
|
||||||
const trim = ({ value }: { value: unknown }) =>
|
const trim = ({ value }: { value: unknown }) =>
|
||||||
typeof value === 'string' ? value.trim() : value;
|
typeof value === 'string' ? value.trim() : value;
|
||||||
export const bankPaymentSystems = ['mir', 'visa', 'mastercard'] as const;
|
export const bankPaymentSystems = ['mir', 'visa', 'mastercard'] as const;
|
||||||
export const bankOperationKinds = [
|
const bankOperationKinds = Object.values(BankOperationKind);
|
||||||
'expense',
|
|
||||||
'topup',
|
|
||||||
'transfer',
|
|
||||||
'refund',
|
|
||||||
] as const;
|
|
||||||
export type BankOperationKind = (typeof bankOperationKinds)[number];
|
|
||||||
export class RubMoneyInputDto {
|
export class RubMoneyInputDto {
|
||||||
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) currency: string;
|
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
|
||||||
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
|
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^-?\d+(\.\d{1,2})?$/)
|
@Matches(/^-?\d+(\.\d{1,2})?$/)
|
||||||
value: string;
|
declare value: string;
|
||||||
}
|
}
|
||||||
export class CreateBankCardDto {
|
export class CreateBankCardDto {
|
||||||
@ApiProperty({ format: 'uuid' }) @IsUUID() id: string;
|
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
|
||||||
@ApiProperty({ minLength: 1, maxLength: 100 })
|
@ApiProperty({ minLength: 1, maxLength: MAX_CARD_NAME_LENGTH })
|
||||||
@Transform(trim)
|
@Transform(trim)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@MaxLength(100)
|
@MaxLength(MAX_CARD_NAME_LENGTH)
|
||||||
name: string;
|
declare name: string;
|
||||||
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 100 })
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
maxLength: MAX_CARD_NAME_LENGTH,
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(trim)
|
@Transform(trim)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(MAX_CARD_NAME_LENGTH)
|
||||||
bankName?: string | null;
|
bankName?: string | null;
|
||||||
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
|
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -67,39 +74,39 @@ export class CreateBankCardDto {
|
|||||||
@IsDefined()
|
@IsDefined()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => RubMoneyInputDto)
|
@Type(() => RubMoneyInputDto)
|
||||||
openingBalance: MoneyDto;
|
declare openingBalance: MoneyDto;
|
||||||
@ApiProperty({ format: 'date' })
|
@ApiProperty({ format: 'date' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
openingDate: string;
|
declare openingDate: string;
|
||||||
}
|
}
|
||||||
export class UpdateBankCardDto extends PartialType(
|
export class UpdateBankCardDto extends PartialType(
|
||||||
OmitType(CreateBankCardDto, ['id'] as const),
|
OmitType(CreateBankCardDto, ['id'] as const),
|
||||||
{ skipNullProperties: false },
|
{ skipNullProperties: false },
|
||||||
) {
|
) {
|
||||||
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number;
|
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
}
|
}
|
||||||
export class CategoryNameDto {
|
export class CategoryNameDto {
|
||||||
@ApiProperty({ minLength: 1, maxLength: 80 })
|
@ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH })
|
||||||
@Transform(trim)
|
@Transform(trim)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@MaxLength(80)
|
@MaxLength(MAX_CATEGORY_NAME_LENGTH)
|
||||||
name: string;
|
declare name: string;
|
||||||
}
|
}
|
||||||
export class UpdateExpenseCategoryDto extends CategoryNameDto {
|
export class UpdateExpenseCategoryDto extends CategoryNameDto {
|
||||||
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number;
|
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
|
||||||
}
|
}
|
||||||
export class CreateBankOperationDto {
|
export class CreateBankOperationDto {
|
||||||
@ApiProperty({ format: 'uuid' }) @IsUUID() id: string;
|
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
|
||||||
@ApiProperty({ enum: bankOperationKinds })
|
@ApiProperty({ enum: bankOperationKinds })
|
||||||
@IsIn(bankOperationKinds)
|
@IsIn(bankOperationKinds)
|
||||||
kind: BankOperationKind;
|
declare kind: BankOperationKind;
|
||||||
@ApiProperty({ format: 'uuid' }) @IsUUID() cardId: string;
|
@ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string;
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -116,22 +123,22 @@ export class CreateBankOperationDto {
|
|||||||
@IsDefined()
|
@IsDefined()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => RubMoneyInputDto)
|
@Type(() => RubMoneyInputDto)
|
||||||
amount: MoneyDto;
|
declare amount: MoneyDto;
|
||||||
@ApiProperty({ format: 'date' })
|
@ApiProperty({ format: 'date' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
date: string;
|
declare date: string;
|
||||||
@ApiPropertyOptional({ maxLength: 500 })
|
@ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH })
|
||||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(500)
|
@MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
export class UpdateBankOperationDto extends PartialType(
|
export class UpdateBankOperationDto extends PartialType(
|
||||||
OmitType(CreateBankOperationDto, ['id'] as const),
|
OmitType(CreateBankOperationDto, ['id'] as const),
|
||||||
{ skipNullProperties: false },
|
{ skipNullProperties: false },
|
||||||
) {
|
) {
|
||||||
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number;
|
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
|
||||||
}
|
}
|
||||||
export class BankCardsQueryDto {
|
export class BankCardsQueryDto {
|
||||||
@ApiPropertyOptional({ enum: ['true', 'false'] })
|
@ApiPropertyOptional({ enum: ['true', 'false'] })
|
||||||
@@ -146,12 +153,16 @@ export class BankPageQueryDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
page?: number;
|
page?: number;
|
||||||
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 50 })
|
@ApiPropertyOptional({
|
||||||
|
minimum: 1,
|
||||||
|
maximum: MAX_BANK_PAGE_SIZE,
|
||||||
|
default: DEFAULT_BANK_PAGE_SIZE,
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(100)
|
@Max(MAX_BANK_PAGE_SIZE)
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
export class BankPeriodQueryDto {
|
export class BankPeriodQueryDto {
|
||||||
@@ -192,68 +203,76 @@ export class BankVersionQueryDto {
|
|||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
version: number;
|
declare version: number;
|
||||||
}
|
}
|
||||||
export class ExpenseCategoryDto {
|
export class ExpenseCategoryDto {
|
||||||
@ApiProperty({ type: String }) id: string;
|
@ApiProperty({ type: String }) declare id: string;
|
||||||
@ApiProperty({ type: String }) name: string;
|
@ApiProperty({ type: String }) declare name: string;
|
||||||
@ApiProperty({ type: Boolean }) isDefault: boolean;
|
@ApiProperty({ type: Boolean }) declare isDefault: boolean;
|
||||||
@ApiProperty({ type: Number }) version: number;
|
@ApiProperty({ type: Number }) declare version: number;
|
||||||
}
|
}
|
||||||
export class BankCardDto {
|
export class BankCardDto {
|
||||||
@ApiProperty({ type: String }) id: string;
|
@ApiProperty({ type: String }) declare id: string;
|
||||||
@ApiProperty({ type: String }) name: string;
|
@ApiProperty({ type: String }) declare name: string;
|
||||||
@ApiProperty({ type: String, nullable: true }) bankName: string | null;
|
@ApiProperty({ type: String, nullable: true }) declare bankName:
|
||||||
@ApiProperty({ type: String, nullable: true }) lastFour: string | null;
|
|
||||||
@ApiProperty({ enum: bankPaymentSystems, nullable: true }) paymentSystem:
|
|
||||||
string | null;
|
string | null;
|
||||||
@ApiProperty({ type: MoneyDto }) openingBalance: MoneyDto;
|
@ApiProperty({ type: String, nullable: true }) declare lastFour:
|
||||||
@ApiProperty({ type: String }) openingDate: string;
|
string | null;
|
||||||
@ApiProperty({ type: MoneyDto }) balance: MoneyDto;
|
@ApiProperty({ enum: bankPaymentSystems, nullable: true })
|
||||||
@ApiProperty({ type: String, nullable: true }) archivedAt: string | null;
|
declare paymentSystem: string | null;
|
||||||
@ApiProperty({ type: Number }) version: number;
|
@ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto;
|
||||||
|
@ApiProperty({ type: String }) declare openingDate: string;
|
||||||
|
@ApiProperty({ type: MoneyDto }) declare balance: MoneyDto;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) declare archivedAt:
|
||||||
|
string | null;
|
||||||
|
@ApiProperty({ type: Number }) declare version: number;
|
||||||
}
|
}
|
||||||
export class BankOperationDto {
|
export class BankOperationDto {
|
||||||
@ApiProperty({ type: String }) id: string;
|
@ApiProperty({ type: String }) declare id: string;
|
||||||
@ApiProperty({ enum: bankOperationKinds }) kind: BankOperationKind;
|
@ApiProperty({ enum: bankOperationKinds }) declare kind: BankOperationKind;
|
||||||
@ApiProperty({ type: String }) cardId: string;
|
@ApiProperty({ type: String }) declare cardId: string;
|
||||||
@ApiProperty({ type: String }) cardName: string;
|
@ApiProperty({ type: String }) declare cardName: string;
|
||||||
@ApiProperty({ type: String, nullable: true }) targetCardId: string | null;
|
@ApiProperty({ type: String, nullable: true }) declare targetCardId:
|
||||||
@ApiProperty({ type: String, nullable: true }) targetCardName: string | null;
|
|
||||||
@ApiProperty({ type: String, nullable: true }) categoryId: string | null;
|
|
||||||
@ApiProperty({ type: String, nullable: true }) categoryName: string | null;
|
|
||||||
@ApiProperty({ type: String, nullable: true }) originalExpenseId:
|
|
||||||
string | null;
|
string | null;
|
||||||
@ApiProperty({ type: MoneyDto }) amount: MoneyDto;
|
@ApiProperty({ type: String, nullable: true }) declare targetCardName:
|
||||||
|
string | null;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) declare categoryId:
|
||||||
|
string | null;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) declare categoryName:
|
||||||
|
string | null;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) declare originalExpenseId:
|
||||||
|
string | null;
|
||||||
|
@ApiProperty({ type: MoneyDto }) declare amount: MoneyDto;
|
||||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||||
availableRefund: MoneyDto | null;
|
declare availableRefund: MoneyDto | null;
|
||||||
@ApiProperty({ type: String }) date: string;
|
@ApiProperty({ type: String }) declare date: string;
|
||||||
@ApiProperty({ type: String, nullable: true }) description: string | null;
|
@ApiProperty({ type: String, nullable: true }) declare description:
|
||||||
@ApiProperty({ type: Number }) version: number;
|
string | null;
|
||||||
@ApiProperty({ type: String }) createdAt: string;
|
@ApiProperty({ type: Number }) declare version: number;
|
||||||
|
@ApiProperty({ type: String }) declare createdAt: string;
|
||||||
}
|
}
|
||||||
export class BankOperationsPageDto {
|
export class BankOperationsPageDto {
|
||||||
@ApiProperty({ type: BankOperationDto, isArray: true })
|
@ApiProperty({ type: BankOperationDto, isArray: true })
|
||||||
items: BankOperationDto[];
|
declare items: BankOperationDto[];
|
||||||
@ApiProperty({ type: Number }) page: number;
|
@ApiProperty({ type: Number }) declare page: number;
|
||||||
@ApiProperty({ type: Number }) total: number;
|
@ApiProperty({ type: Number }) declare total: number;
|
||||||
@ApiProperty({ type: Boolean }) hasNext: boolean;
|
@ApiProperty({ type: Boolean }) declare hasNext: boolean;
|
||||||
}
|
}
|
||||||
export class BankSummaryCategoryDto {
|
export class BankSummaryCategoryDto {
|
||||||
@ApiProperty({ type: String }) categoryId: string;
|
@ApiProperty({ type: String }) declare categoryId: string;
|
||||||
@ApiProperty({ type: String }) categoryName: string;
|
@ApiProperty({ type: String }) declare categoryName: string;
|
||||||
@ApiProperty({ type: MoneyDto }) expenses: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
|
||||||
@ApiProperty({ type: MoneyDto }) refunds: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
|
||||||
@ApiProperty({ type: MoneyDto }) net: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
|
||||||
}
|
}
|
||||||
export class BankSummaryDto {
|
export class BankSummaryDto {
|
||||||
@ApiPropertyOptional({ type: String }) month?: string;
|
@ApiPropertyOptional({ type: String }) month?: string;
|
||||||
@ApiPropertyOptional({ format: 'date' }) from?: string;
|
@ApiPropertyOptional({ format: 'date' }) from?: string;
|
||||||
@ApiPropertyOptional({ format: 'date' }) to?: string;
|
@ApiPropertyOptional({ format: 'date' }) to?: string;
|
||||||
@ApiProperty({ type: String, nullable: true }) cardId: string | null;
|
@ApiProperty({ type: String, nullable: true }) declare cardId: string | null;
|
||||||
@ApiProperty({ type: MoneyDto }) expenses: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
|
||||||
@ApiProperty({ type: MoneyDto }) refunds: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
|
||||||
@ApiProperty({ type: MoneyDto }) net: MoneyDto;
|
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
|
||||||
@ApiProperty({ type: BankSummaryCategoryDto, isArray: true })
|
@ApiProperty({ type: BankSummaryCategoryDto, isArray: true })
|
||||||
categories: BankSummaryCategoryDto[];
|
declare categories: BankSummaryCategoryDto[];
|
||||||
}
|
}
|
||||||
+30
-17
@@ -4,6 +4,7 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -16,17 +17,16 @@ import {
|
|||||||
ApiCreatedResponse,
|
ApiCreatedResponse,
|
||||||
ApiNoContentResponse,
|
ApiNoContentResponse,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiTags,
|
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
getSchemaPath,
|
getSchemaPath,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { AccessPayload } from '../auth/auth.types';
|
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
||||||
import {
|
import {
|
||||||
ApiProblemResponses,
|
ApiProblemResponses,
|
||||||
ProblemDetailsDto,
|
ProblemDetailsDto,
|
||||||
} from '../common/decorators/api-safe-error-response.decorator';
|
} from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||||
|
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||||
import {
|
import {
|
||||||
BankCardDto,
|
BankCardDto,
|
||||||
BankCardsQueryDto,
|
BankCardsQueryDto,
|
||||||
@@ -45,7 +45,10 @@ import {
|
|||||||
UpdateBankOperationDto,
|
UpdateBankOperationDto,
|
||||||
UpdateExpenseCategoryDto,
|
UpdateExpenseCategoryDto,
|
||||||
} from './bank-card.dto';
|
} from './bank-card.dto';
|
||||||
import { BankCardsService } from './bank-cards.service';
|
import { BankCardsService } from '../application/bank-cards.service';
|
||||||
|
import { BankOperationsService } from '../application/bank-operations.service';
|
||||||
|
import { ExpenseCategoriesService } from '../application/expense-categories.service';
|
||||||
|
import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case';
|
||||||
type AuthRequest = Request & { user: AccessPayload };
|
type AuthRequest = Request & { user: AccessPayload };
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 409,
|
status: 409,
|
||||||
@@ -73,7 +76,12 @@ type AuthRequest = Request & { user: AccessPayload };
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('bank-cards')
|
@Controller('bank-cards')
|
||||||
export class BankCardsController {
|
export class BankCardsController {
|
||||||
constructor(private readonly cards: BankCardsService) {}
|
constructor(
|
||||||
|
private readonly cards: BankCardsService,
|
||||||
|
private readonly operations: BankOperationsService,
|
||||||
|
private readonly expenseCategories: ExpenseCategoriesService,
|
||||||
|
private readonly bankSummary: GetBankSummaryUseCase,
|
||||||
|
) {}
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ type: BankCardDto, isArray: true })
|
@ApiOkResponse({ type: BankCardDto, isArray: true })
|
||||||
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
|
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
|
||||||
@@ -87,12 +95,12 @@ export class BankCardsController {
|
|||||||
@Get('categories')
|
@Get('categories')
|
||||||
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
|
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
|
||||||
categories(@Req() req: AuthRequest) {
|
categories(@Req() req: AuthRequest) {
|
||||||
return this.cards.categories(req.user.sub);
|
return this.expenseCategories.categories(req.user.sub);
|
||||||
}
|
}
|
||||||
@Post('categories')
|
@Post('categories')
|
||||||
@ApiCreatedResponse({ type: ExpenseCategoryDto })
|
@ApiCreatedResponse({ type: ExpenseCategoryDto })
|
||||||
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
|
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
|
||||||
return this.cards.createCategory(req.user.sub, body.name);
|
return this.expenseCategories.createCategory(req.user.sub, body.name);
|
||||||
}
|
}
|
||||||
@Patch('categories/:id')
|
@Patch('categories/:id')
|
||||||
@ApiOkResponse({ type: ExpenseCategoryDto })
|
@ApiOkResponse({ type: ExpenseCategoryDto })
|
||||||
@@ -101,12 +109,17 @@ export class BankCardsController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() body: UpdateExpenseCategoryDto,
|
@Body() body: UpdateExpenseCategoryDto,
|
||||||
) {
|
) {
|
||||||
return this.cards.updateCategory(req.user.sub, id, body.name, body.version);
|
return this.expenseCategories.updateCategory(
|
||||||
|
req.user.sub,
|
||||||
|
id,
|
||||||
|
body.name,
|
||||||
|
body.version,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
@ApiOkResponse({ type: BankSummaryDto })
|
@ApiOkResponse({ type: BankSummaryDto })
|
||||||
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
|
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
|
||||||
return this.cards.summary(req.user.sub, query);
|
return this.bankSummary.execute(req.user.sub, query);
|
||||||
}
|
}
|
||||||
@Post('operations')
|
@Post('operations')
|
||||||
@ApiCreatedResponse({ type: BankOperationDto })
|
@ApiCreatedResponse({ type: BankOperationDto })
|
||||||
@@ -114,12 +127,12 @@ export class BankCardsController {
|
|||||||
@Req() req: AuthRequest,
|
@Req() req: AuthRequest,
|
||||||
@Body() body: CreateBankOperationDto,
|
@Body() body: CreateBankOperationDto,
|
||||||
) {
|
) {
|
||||||
return this.cards.createOperation(req.user.sub, body);
|
return this.operations.createOperation(req.user.sub, body);
|
||||||
}
|
}
|
||||||
@Get('operations/:id')
|
@Get('operations/:id')
|
||||||
@ApiOkResponse({ type: BankOperationDto })
|
@ApiOkResponse({ type: BankOperationDto })
|
||||||
operation(@Req() req: AuthRequest, @Param('id') id: string) {
|
operation(@Req() req: AuthRequest, @Param('id') id: string) {
|
||||||
return this.cards.getOperation(req.user.sub, id);
|
return this.operations.getOperation(req.user.sub, id);
|
||||||
}
|
}
|
||||||
@Patch('operations/:id')
|
@Patch('operations/:id')
|
||||||
@ApiOkResponse({ type: BankOperationDto })
|
@ApiOkResponse({ type: BankOperationDto })
|
||||||
@@ -128,17 +141,17 @@ export class BankCardsController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() body: UpdateBankOperationDto,
|
@Body() body: UpdateBankOperationDto,
|
||||||
) {
|
) {
|
||||||
return this.cards.updateOperation(req.user.sub, id, body);
|
return this.operations.updateOperation(req.user.sub, id, body);
|
||||||
}
|
}
|
||||||
@Delete('operations/:id')
|
@Delete('operations/:id')
|
||||||
@HttpCode(204)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiNoContentResponse()
|
@ApiNoContentResponse()
|
||||||
deleteOperation(
|
deleteOperation(
|
||||||
@Req() req: AuthRequest,
|
@Req() req: AuthRequest,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query() query: BankVersionQueryDto,
|
@Query() query: BankVersionQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.cards.deleteOperation(req.user.sub, id, query.version);
|
return this.operations.deleteOperation(req.user.sub, id, query.version);
|
||||||
}
|
}
|
||||||
@Get(':id/operations')
|
@Get(':id/operations')
|
||||||
@ApiOkResponse({ type: BankOperationsPageDto })
|
@ApiOkResponse({ type: BankOperationsPageDto })
|
||||||
@@ -147,7 +160,7 @@ export class BankCardsController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query() query: BankOperationsQueryDto,
|
@Query() query: BankOperationsQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.cards.history(req.user.sub, id, query);
|
return this.operations.history(req.user.sub, id, query);
|
||||||
}
|
}
|
||||||
@Get(':id/expenses')
|
@Get(':id/expenses')
|
||||||
@ApiOkResponse({ type: BankOperationsPageDto })
|
@ApiOkResponse({ type: BankOperationsPageDto })
|
||||||
@@ -156,7 +169,7 @@ export class BankCardsController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query() query: BankPageQueryDto,
|
@Query() query: BankPageQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.cards.history(req.user.sub, id, query, true);
|
return this.operations.history(req.user.sub, id, query, true);
|
||||||
}
|
}
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOkResponse({ type: BankCardDto })
|
@ApiOkResponse({ type: BankCardDto })
|
||||||
+2
-2
@@ -3,8 +3,8 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
/** Source account of a row in an all-accounts response. */
|
/** Source account of a row in an all-accounts response. */
|
||||||
export class AccountReferenceDto {
|
export class AccountReferenceDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
id: string;
|
declare id: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
name: string;
|
declare name: string;
|
||||||
}
|
}
|
||||||
@@ -3,14 +3,17 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
jest.mock('./tbank-access.service', () => ({ TbankAccessService: class {} }));
|
jest.mock('../../integrations/tbank/tbank-access.service', () => ({
|
||||||
|
TbankAccessService: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
import { BrokerageOperationsService } from './brokerage-operations.service';
|
import type { TbankConnectionContext } from '../../integrations/tbank/tbank-access.service';
|
||||||
import { IncomeAnalyticsService } from './income-analytics.service';
|
import { IncomeAnalyticsService } from './income/income-analytics.service';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { BrokerageOperationsService } from './operations/application/brokerage-operations.service';
|
||||||
import { UpcomingEventsService } from './upcoming-events.service';
|
import { PortfolioService } from './portfolio/application/portfolio.service';
|
||||||
import type { TbankConnectionContext } from './tbank-access.service';
|
import { UpcomingEventsService } from './upcoming-events/upcoming-events.service';
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const context = {
|
const context = {
|
||||||
@@ -130,6 +133,7 @@ describe('All accounts', () => {
|
|||||||
instrumentUid: 'common',
|
instrumentUid: 'common',
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
|
assert.ok(first.nextCursor);
|
||||||
expect(first.items).toHaveLength(1);
|
expect(first.items).toHaveLength(1);
|
||||||
expect(first.items[0].instrumentUid).toBe('common');
|
expect(first.items[0].instrumentUid).toBe('common');
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
||||||
@@ -143,13 +147,13 @@ describe('All accounts', () => {
|
|||||||
operations.getAll('user', {
|
operations.getAll('user', {
|
||||||
instrumentUid: 'preferred',
|
instrumentUid: 'preferred',
|
||||||
limit: 1,
|
limit: 1,
|
||||||
cursor: first.nextCursor!,
|
cursor: first.nextCursor,
|
||||||
}),
|
}),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
const second = await operations.getAll('user', {
|
const second = await operations.getAll('user', {
|
||||||
instrumentUid: 'common',
|
instrumentUid: 'common',
|
||||||
limit: 1,
|
limit: 1,
|
||||||
cursor: first.nextCursor!,
|
cursor: first.nextCursor,
|
||||||
});
|
});
|
||||||
expect(second.items[0].instrumentUid).toBe('common');
|
expect(second.items[0].instrumentUid).toBe('common');
|
||||||
expect(second.items[0].id).not.toBe(first.items[0].id);
|
expect(second.items[0].id).not.toBe(first.items[0].id);
|
||||||
@@ -246,17 +250,19 @@ describe('All accounts', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const first = await operations.getAll('user', query);
|
const first = await operations.getAll('user', query);
|
||||||
|
assert.ok(first.nextCursor);
|
||||||
const second = await operations.getAll('user', {
|
const second = await operations.getAll('user', {
|
||||||
...query,
|
...query,
|
||||||
cursor: first.nextCursor!,
|
cursor: first.nextCursor,
|
||||||
});
|
});
|
||||||
const repeated = await operations.getAll('user', {
|
const repeated = await operations.getAll('user', {
|
||||||
...query,
|
...query,
|
||||||
cursor: first.nextCursor!,
|
cursor: first.nextCursor,
|
||||||
});
|
});
|
||||||
|
assert.ok(second.nextCursor);
|
||||||
const third = await operations.getAll('user', {
|
const third = await operations.getAll('user', {
|
||||||
...query,
|
...query,
|
||||||
cursor: second.nextCursor!,
|
cursor: second.nextCursor,
|
||||||
});
|
});
|
||||||
expect(first.items.map((item) => [item.id, item.account?.id])).toEqual([
|
expect(first.items.map((item) => [item.id, item.account?.id])).toEqual([
|
||||||
['same', 'a'],
|
['same', 'a'],
|
||||||
@@ -312,7 +318,8 @@ describe('All accounts', () => {
|
|||||||
items: [operation('first', 8), operation('last', 4)],
|
items: [operation('first', 8), operation('last', 4)],
|
||||||
});
|
});
|
||||||
const first = await operations.getAll('user', query);
|
const first = await operations.getAll('user', query);
|
||||||
const next = { ...query, cursor: first.nextCursor! };
|
assert.ok(first.nextCursor);
|
||||||
|
const next = { ...query, cursor: first.nextCursor };
|
||||||
await expect(operations.getAll('other', next)).rejects.toBeInstanceOf(
|
await expect(operations.getAll('other', next)).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
+8
-8
@@ -1,31 +1,31 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { MoneyDto } from '../common/money.dto';
|
import { MoneyDto } from '../../../common/money.dto';
|
||||||
|
|
||||||
export class PortfolioSummaryDto {
|
export class PortfolioSummaryDto {
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||||
totalsByCurrency: MoneyDto[];
|
declare totalsByCurrency: MoneyDto[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
description: 'Number of non-zero stock positions, not the number of units.',
|
description: 'Number of non-zero stock positions, not the number of units.',
|
||||||
})
|
})
|
||||||
stockPositionCount: number;
|
declare stockPositionCount: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
description: 'Number of non-zero bond positions, not the number of units.',
|
description: 'Number of non-zero bond positions, not the number of units.',
|
||||||
})
|
})
|
||||||
bondPositionCount: number;
|
declare bondPositionCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerageAccountDto {
|
export class BrokerageAccountDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
id: string;
|
declare id: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
name: string;
|
declare name: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
enum: [
|
enum: [
|
||||||
@@ -35,11 +35,11 @@ export class BrokerageAccountDto {
|
|||||||
'MONEY_MARKET_FUND',
|
'MONEY_MARKET_FUND',
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
type:
|
declare type:
|
||||||
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
|
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||||
openedOn: string | null;
|
declare openedOn: string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
|
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
|
||||||
portfolioSummary?: PortfolioSummaryDto | null;
|
portfolioSummary?: PortfolioSummaryDto | null;
|
||||||
+2
-3
@@ -1,9 +1,8 @@
|
|||||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { AccessPayload } from '../auth/auth.types';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
|
|
||||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
|
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||||
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, TbankModule],
|
||||||
|
controllers: [BrokerageAccountsController],
|
||||||
|
providers: [BrokerageAccountsService],
|
||||||
|
})
|
||||||
|
export class BrokerageAccountsModule {}
|
||||||
+3
-3
@@ -4,13 +4,13 @@ import {
|
|||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
||||||
jest.mock('../prisma/prisma.service', () => ({
|
jest.mock('../../../prisma/prisma.service', () => ({
|
||||||
PrismaService: class PrismaService {},
|
PrismaService: class PrismaService {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
|
||||||
|
import { TbankTokenService } from '../../../integrations/tbank/tbank-token.service';
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
|
||||||
|
|
||||||
describe('BrokerageAccountsService', () => {
|
describe('BrokerageAccountsService', () => {
|
||||||
const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
||||||
+8
-7
@@ -1,11 +1,12 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
import { isZero } from '../../../common/decimal';
|
||||||
|
import {
|
||||||
|
TbankAccessService,
|
||||||
|
TbankClient,
|
||||||
|
} from '../../../integrations/tbank/tbank.api';
|
||||||
|
import { decimal } from '../../../integrations/tbank/decimal';
|
||||||
|
import { totalsByCurrency } from '../portfolio/portfolio.api';
|
||||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||||
import { isZero } from '../common/decimal';
|
|
||||||
import { decimal } from './decimal';
|
|
||||||
import { totalsByCurrency } from './portfolio-totals';
|
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
|
||||||
import { TbankClient } from './tbank.client';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerageAccountsService {
|
export class BrokerageAccountsService {
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user