Compare commits
22
Commits
2facd8675c
..
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d50db7903 | ||
|
|
8d5569a3c7 | ||
|
|
90486d4237 | ||
|
|
52fa3a36ba | ||
|
|
b9b76bab91 | ||
|
|
fa57a201fd | ||
|
|
96a442253e | ||
|
|
c5248dbefc | ||
|
|
c7d060023b | ||
|
|
a31e8d2c4d | ||
|
|
2f7c15205f | ||
|
|
d7895f287e | ||
|
|
bcd5cb3dd0 | ||
|
|
0ad84ff4f0 | ||
|
|
7d10ef8adf | ||
|
|
bc631e72de | ||
|
|
947ffefa9b | ||
|
|
db375cbee7 | ||
|
|
a46a4112cb | ||
|
|
966b2a5836 | ||
|
|
323b83b582 | ||
|
|
5d857de7a5 |
@@ -7,11 +7,19 @@
|
||||
checkout. Внешний API задаётся предоставленным публичным контрактом;
|
||||
исходники или инструкции его реализации не являются зависимостью разработки.
|
||||
При нехватке требования сообщи, какое решение не определено, до его реализации.
|
||||
При конфликте с общим шаблоном следуй местным правилам и явному запросу
|
||||
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
|
||||
логики, публичного контракта или утверждённой семантики возвращай владельцу
|
||||
на согласование до зависимой работы.
|
||||
|
||||
Начни с результата и affected areas. До edits проследи flow/invariants, найди
|
||||
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
|
||||
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
|
||||
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
|
||||
Выбери lightweight или SDD по Engineering Workflow из AGENTS; OpenSpec lifecycle
|
||||
начинай после явного решения Change Owner.
|
||||
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
|
||||
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
|
||||
выполни `nvm use --silent` из backend. Если `nvm.sh` недоступен, остановись,
|
||||
не используй системный Node.
|
||||
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
|
||||
|
||||
## Change scope
|
||||
@@ -57,19 +65,28 @@ Peer-конфликт решай совместимыми версиями бе
|
||||
|
||||
## Проверки и завершение
|
||||
|
||||
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
|
||||
risk checks из местной testing convention. Targeted tests ускоряют итерацию,
|
||||
но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
|
||||
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
|
||||
чтения и `git diff --check`; application build ради текста не нужен.
|
||||
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
|
||||
и ограничения. Зелёный lint не заменяет semantic/security review.
|
||||
| Когда | Что проверить |
|
||||
| --- | --- |
|
||||
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
|
||||
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
|
||||
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
|
||||
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
|
||||
|
||||
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
|
||||
проверенные входы, команду, exit code, результат и ограничения. Повторяй
|
||||
проверку при изменении значимых входов (включая config, dependencies и
|
||||
generated artifacts), сомнении в evidence или явном запросе пользователя.
|
||||
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
|
||||
|
||||
## Git и релизы
|
||||
|
||||
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
|
||||
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
|
||||
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
|
||||
Имя ветки и связь PR с реальной Issue проверяются вручную: commitlint
|
||||
проверяет синтаксис commit/PR title, но не существование и соответствие Issue.
|
||||
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
|
||||
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
|
||||
содержит одну связанную задачу. Root другого checkout не управляет этим repo.
|
||||
Commits и PR titles — Conventional Commits с parser-recognized Issue reference; допустим
|
||||
`spec:`. Каждый commit содержит одну задачу. Исключения release и правила
|
||||
ссылки — в Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
|
||||
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
|
||||
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Неверное поведение
|
||||
---
|
||||
## Проблема и шаги воспроизведения
|
||||
|
||||
## Факт / ожидание
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Окружение / ссылки (если применимо)
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Engineering Task
|
||||
about: CI, тесты, документация, процесс и прочее
|
||||
---
|
||||
## Контекст и результат
|
||||
|
||||
## Scope / связанные Issues (если есть)
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Новое наблюдаемое поведение
|
||||
---
|
||||
## Зачем и какой результат
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Зависимости / связанные Issues (если есть)
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Refactoring / Technical Debt
|
||||
about: Улучшение без нового поведения
|
||||
---
|
||||
## Проблема и результат
|
||||
|
||||
## Сохраняемые инварианты / границы
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Зависимости / ссылки (если есть)
|
||||
@@ -0,0 +1,11 @@
|
||||
## Summary
|
||||
|
||||
## Linked Issue
|
||||
Refs
|
||||
|
||||
## Changes
|
||||
|
||||
## Verification
|
||||
- Команда / результат / ограничения:
|
||||
|
||||
## Breaking Changes (если есть)
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [develop, main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
types: [opened, synchronize, reopened]
|
||||
schedule:
|
||||
- cron: '47 5 * * *'
|
||||
|
||||
@@ -34,7 +34,11 @@ jobs:
|
||||
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"
|
||||
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
|
||||
else
|
||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
fi
|
||||
|
||||
security:
|
||||
needs: commits
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
только его файлы и предоставленные публичные контракты. Перед работой прочитай
|
||||
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
|
||||
объединяй triggers задачи и путей; читай активированные темы до решения.
|
||||
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
|
||||
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
|
||||
(main#28; после merge — актуальная версия `develop`).
|
||||
|
||||
| Решение / сигнал | Читать |
|
||||
| --- | --- |
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## [2.1.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.0.0...v2.1.0) (2026-10-12)
|
||||
|
||||
### Features
|
||||
|
||||
* add hourly portfolio sync history ([0057925](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0057925888dde874830149865fc5e13920ac5783))
|
||||
* add stock screener ([7d10ef8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d10ef8adfce76ad2ed6368e1022fbd3f6847a23))
|
||||
* reconcile hourly investment resource states ([323b83b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/323b83b5827d1191fbc14cfdf1d0e62f249fc78d))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* align backend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c7d0600](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c7d060023b612af9e1dd4c2a2fb2d5d1bf173784))
|
||||
* bound hourly step awaits by the per-step wait limit ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([52fa3a3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/52fa3a36baa5ab236a7ebd64ef1b4f447c91e129))
|
||||
* keep candle retries read-only ([947ffef](https://git.personal.ksv741.ru/WealthLedger/backend/commit/947ffefa9b8cad7f19355c187f2bd50c9739a822))
|
||||
* restore terminal hourly lifecycle after persistence failure ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([fa57a20](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa57a201fdd8127774aee9af553da75a75add07c))
|
||||
* unblock backend dependency audit ([85858a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85858a160f00dd428c80b8fa1824c1779245fdfe))
|
||||
|
||||
### Documentation
|
||||
|
||||
* clarify backend verification policy ([d7895f2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7895f287ee802e7b13df7a2a4c6dba0b711e5a9))
|
||||
* pin backend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c5248db](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5248dbefc4b11a9f417bc835041a4294bd61047))
|
||||
|
||||
### CI
|
||||
|
||||
* skip checks on PR metadata edits ([0ad84ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ad84ff4f0b4712abc7e69dc299856f14af6eadd))
|
||||
|
||||
### Tests
|
||||
|
||||
* align all accounts metadata ([db375cb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/db375cbee7d8f1eff48822b8e3c74898f40e8886))
|
||||
* align resource availability metadata ([bc631e7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bc631e72de0d9b0ff7b261b1ea644a73c2943d2a))
|
||||
* align upcoming events with local reads ([966b2a5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966b2a5836cdaf5b761e3c76568ff30a90ba1f4c))
|
||||
* extend ledger sqlite timeout ([4870d49](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4870d4950303ad1de743e19b665b2fbdc05b781a))
|
||||
* increase ledger request timeout ([2facd86](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2facd8675c74a97d92e0e8f44ed4a93aabb47674))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* align backend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([a31e8d2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a31e8d2c4d85a21cdd5efdcc9f5fe503f210804b))
|
||||
* prune obsolete eslint suppressions ([a46a411](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a46a4112cbcc72b1c3ff17cac4ac8bebcf579bca))
|
||||
|
||||
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
@@ -295,9 +295,11 @@ Durability оставляет временные DB, логи и `result.json`
|
||||
## Релизы
|
||||
|
||||
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
||||
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
|
||||
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
|
||||
общие правила — в Engineering Workflow по ссылке из AGENTS.
|
||||
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
|
||||
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
|
||||
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
|
||||
включая `spec:`. Версии вручную не повышай.
|
||||
|
||||
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
|
||||
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: { 'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']] },
|
||||
defaultIgnores: false,
|
||||
ignores: [
|
||||
(message) => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
|
||||
(message) => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
|
||||
],
|
||||
rules: {
|
||||
'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']],
|
||||
'references-empty': [2, 'never'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -32,11 +32,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/income/income-analytics.service.ts": {
|
||||
"sonarjs/no-nested-template-literals": {
|
||||
"count": 1
|
||||
@@ -99,11 +94,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/investment-resource.mapper.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/modules/investments/investment-sync-plan.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 1
|
||||
@@ -146,11 +136,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/portfolio/application/portfolio.service.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/tbank-sync/tbank-screen.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 1
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wealth-ledger-backend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wealth-ledger-backend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "1.14.5",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wealth-ledger-backend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('StructuredLogger', () => {
|
||||
const logger = new StructuredLogger(pino as never);
|
||||
|
||||
logger.info('event', {
|
||||
asOf: '2026-10-10T12:00:00.000Z',
|
||||
userId: 'user',
|
||||
brokerageAccountId: 'account',
|
||||
password: 'secret',
|
||||
@@ -21,6 +22,7 @@ describe('StructuredLogger', () => {
|
||||
|
||||
expect(pino.info).toHaveBeenCalledWith(
|
||||
{
|
||||
asOf: '2026-10-10T12:00:00.000Z',
|
||||
userId: 'user',
|
||||
brokerageAccountId: 'account',
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ type LogLevel = 'debug' | 'error' | 'info' | 'warn';
|
||||
type LogContext = Record<string, boolean | number | string | undefined>;
|
||||
|
||||
const allowedFields = new Set([
|
||||
'asOf',
|
||||
'attempts',
|
||||
'brokerageAccountId',
|
||||
'clientRequestId',
|
||||
|
||||
@@ -133,6 +133,18 @@ export class TbankAccessService {
|
||||
return this.tokens.decrypt(connection.encryptedToken);
|
||||
}
|
||||
|
||||
async current(userId: string): Promise<{ revision: string; token: string }> {
|
||||
const connection = await this.prisma.tbankConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { encryptedToken: true, invalidToken: true, revision: true },
|
||||
});
|
||||
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
|
||||
if (connection.invalidToken) {
|
||||
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||
}
|
||||
return { revision: connection.revision, token: this.tokens.decrypt(connection.encryptedToken) };
|
||||
}
|
||||
|
||||
async publishMembership(
|
||||
attempt: TbankSyncAttempt,
|
||||
accounts: TbankAccount[],
|
||||
|
||||
@@ -8,7 +8,9 @@ import type {
|
||||
export type TbankShare = TbankInstrument & {
|
||||
apiTradeAvailableFlag?: boolean;
|
||||
assetUid?: string;
|
||||
buyAvailableFlag?: boolean;
|
||||
classCode?: string;
|
||||
countryOfRisk?: string;
|
||||
currency?: string;
|
||||
figi?: string;
|
||||
first_1dayCandleDate?: TbankTimestamp;
|
||||
|
||||
@@ -5,6 +5,10 @@ export enum InstrumentIdType {
|
||||
Uid = 'INSTRUMENT_ID_TYPE_UID',
|
||||
}
|
||||
|
||||
export enum TbankInstrumentStatus {
|
||||
All = 'INSTRUMENT_STATUS_ALL',
|
||||
}
|
||||
|
||||
export enum TbankInstrumentType {
|
||||
Bond = 'INSTRUMENT_TYPE_BOND',
|
||||
Share = 'INSTRUMENT_TYPE_SHARE',
|
||||
|
||||
@@ -282,3 +282,141 @@ describe('TbankClient metadata retries', () => {
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TbankClient share catalog', () => {
|
||||
const token = 'token';
|
||||
const catalogSize = 40;
|
||||
let client: TbankClient;
|
||||
let call: jest.Mock;
|
||||
let logger: {
|
||||
event: jest.Mock<void, [string, string, Record<string, number | string>]>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
call = jest.fn();
|
||||
logger = {
|
||||
event: jest.fn<void, [string, string, Record<string, number | string>]>(),
|
||||
};
|
||||
client = Object.assign(
|
||||
Object.create(TbankClient.prototype) as TbankClient,
|
||||
{ call, logger },
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches the full share catalog through Shares with INSTRUMENT_STATUS_ALL', async () => {
|
||||
const instruments = [{ ticker: 'AFLT', uid: 'uid-aflt' }];
|
||||
call.mockResolvedValue({ instruments });
|
||||
|
||||
await expect(client.getAllShares(token)).resolves.toEqual(instruments);
|
||||
expect(call).toHaveBeenCalledWith('token', undefined, 'shares', {
|
||||
instrumentStatus: 'INSTRUMENT_STATUS_ALL',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the whole catalog including a fitting instrument beyond the first 30 text matches', async () => {
|
||||
const catalog = Array.from({ length: catalogSize }, (_, index) => {
|
||||
const fitting = index === catalogSize - 1;
|
||||
return {
|
||||
apiTradeAvailableFlag: fitting,
|
||||
buyAvailableFlag: fitting,
|
||||
ticker: fitting ? 'FIT' : `T${index}`,
|
||||
uid: fitting ? 'uid-fit' : `uid-${index}`,
|
||||
};
|
||||
});
|
||||
call.mockResolvedValue({ instruments: catalog });
|
||||
|
||||
const shares = await client.getAllShares(token);
|
||||
|
||||
expect(shares).toEqual(catalog);
|
||||
expect(shares).toHaveLength(catalogSize);
|
||||
expect(shares).toContainEqual({
|
||||
apiTradeAvailableFlag: true,
|
||||
buyAvailableFlag: true,
|
||||
ticker: 'FIT',
|
||||
uid: 'uid-fit',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when the source refuses instead of returning an empty success', async () => {
|
||||
const error = new ServiceUnavailableException({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
call.mockRejectedValue(error);
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('rejects when the response carries no instrument list', async () => {
|
||||
call.mockResolvedValue({});
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an empty instrument list with the public unavailable code', async () => {
|
||||
call.mockResolvedValue({ instruments: [] });
|
||||
|
||||
const failure = await client
|
||||
.getAllShares(token)
|
||||
.catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(ServiceUnavailableException);
|
||||
expect((failure as ServiceUnavailableException).getResponse()).toEqual({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a non-list instrument payload as uninterpretable', async () => {
|
||||
call.mockResolvedValue({ instruments: 'corrupted' });
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an uninterpretable entry without instrument identity', async () => {
|
||||
call.mockResolvedValue({
|
||||
instruments: [
|
||||
{ ticker: 'FIT', uid: 'uid-fit' },
|
||||
{ ticker: 'BROKEN' },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
it('registers asOf and source instrument count on a successful catalog fetch', async () => {
|
||||
call.mockResolvedValue({
|
||||
instruments: [{ uid: 'uid-a' }, { uid: 'uid-b' }],
|
||||
});
|
||||
|
||||
await client.getAllShares(token);
|
||||
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'tbank.shares.catalog.fetched',
|
||||
expect.objectContaining({ count: 2, operation: 'shares' }),
|
||||
);
|
||||
expect(logger.event.mock.calls[0][2].asOf).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('logs a distinguishable failure without secrets when the catalog is unusable', async () => {
|
||||
call.mockResolvedValue({ instruments: [] });
|
||||
|
||||
await expect(client.getAllShares('secret-token')).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'tbank.shares.catalog.unusable',
|
||||
expect.objectContaining({ operation: 'shares' }),
|
||||
);
|
||||
expect(JSON.stringify(logger.event.mock.calls)).not.toContain(
|
||||
'secret-token',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
InstrumentIdType,
|
||||
TbankAccountStatus,
|
||||
TbankCandleInterval,
|
||||
TbankInstrumentStatus,
|
||||
TbankInstrumentType,
|
||||
TbankOperationState,
|
||||
} from './tbank-protocol';
|
||||
@@ -325,6 +326,35 @@ export class TbankClient {
|
||||
).then((response) => response.instruments ?? []);
|
||||
}
|
||||
|
||||
async getAllShares(token: string): Promise<TbankShare[]> {
|
||||
const response = await this.call<{ instruments?: TbankShare[] }>(
|
||||
token,
|
||||
this.instruments,
|
||||
'shares',
|
||||
{ instrumentStatus: TbankInstrumentStatus.All },
|
||||
);
|
||||
const { instruments } = response;
|
||||
if (
|
||||
!Array.isArray(instruments) ||
|
||||
!instruments.length ||
|
||||
instruments.some((item) => !item?.uid)
|
||||
) {
|
||||
this.logger.event('error', 'tbank.shares.catalog.unusable', {
|
||||
code: TbankErrorCode.Unavailable,
|
||||
operation: 'shares',
|
||||
});
|
||||
throw new ServiceUnavailableException({
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
}
|
||||
this.logger.event('info', 'tbank.shares.catalog.fetched', {
|
||||
asOf: new Date().toISOString(),
|
||||
count: instruments.length,
|
||||
operation: 'shares',
|
||||
});
|
||||
return instruments;
|
||||
}
|
||||
|
||||
findBonds(token: string, query: string): Promise<TbankBond[]> {
|
||||
return this.call<{ instruments?: TbankBond[] }>(
|
||||
token,
|
||||
|
||||
@@ -3,12 +3,12 @@ import { Module } from '@nestjs/common';
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { TbankSyncModule } from '../tbank-sync/tbank-sync.module';
|
||||
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncModule],
|
||||
imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncRepositoryModule],
|
||||
controllers: [BrokerageAccountsController],
|
||||
providers: [BrokerageAccountsService],
|
||||
exports: [BrokerageAccountsService],
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('saved brokerage account projection', () => {
|
||||
|
||||
it('distinguishes unknown membership from a confirmed empty list', async () => {
|
||||
portfolio.saved.mockResolvedValueOnce({
|
||||
membership: { ...meta, availability: 'missing' },
|
||||
membership: { ...meta, completeness: 'incomplete' },
|
||||
accounts: [],
|
||||
});
|
||||
expect((await service.list('owner')).data).toBeNull();
|
||||
@@ -66,6 +66,39 @@ describe('saved brokerage account projection', () => {
|
||||
bondPositionCount: 1,
|
||||
});
|
||||
expect(response.data?.[1].portfolioSummary).toBeNull();
|
||||
expect(response.meta.availability).toBe('partial');
|
||||
expect(response.meta.availability).toBe('available');
|
||||
expect(response.meta.completeness).toBe('complete');
|
||||
});
|
||||
|
||||
it('observes the latest hourly walk without starting another one', async () => {
|
||||
portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] });
|
||||
sync.hourly.mockResolvedValue({
|
||||
startedAt: new Date('2026-09-25T12:00:00Z'),
|
||||
finishedAt: new Date('2026-09-25T12:01:00Z'),
|
||||
state: 'partial',
|
||||
sections: [
|
||||
{
|
||||
section: 'portfolio',
|
||||
accountId: 'a',
|
||||
errorCode: 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await service.list('owner');
|
||||
|
||||
expect(response.sync).toEqual({
|
||||
startedAt: '2026-09-25T12:00:00.000Z',
|
||||
finishedAt: '2026-09-25T12:01:00.000Z',
|
||||
state: 'partial',
|
||||
problems: [
|
||||
{
|
||||
section: 'portfolio',
|
||||
accountId: 'a',
|
||||
errorCode: 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(sync.hourly).toHaveBeenCalledWith('owner');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ export class BrokerageAccountsService {
|
||||
async list(userId: string): Promise<BrokerageAccountsResourceDto> {
|
||||
const saved = await this.portfolio.saved(userId, 'all');
|
||||
const data: BrokerageAccountDto[] | null =
|
||||
saved.membership.availability === 'missing'
|
||||
saved.membership.completeness === 'incomplete'
|
||||
? null
|
||||
: saved.accounts.map((account) => ({
|
||||
id: account.id,
|
||||
@@ -55,16 +55,12 @@ export class BrokerageAccountsService {
|
||||
availability:
|
||||
data === null
|
||||
? 'missing'
|
||||
: saved.accounts.some((account) => !account.snapshot)
|
||||
? 'partial'
|
||||
: 'complete',
|
||||
: 'complete',
|
||||
fetchedAt: saved.membership.lastSuccessfulAt
|
||||
? new Date(saved.membership.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: 3_600_000,
|
||||
work: saved.membership.sync.id
|
||||
? { ...saved.membership.sync, id: saved.membership.sync.id }
|
||||
: null,
|
||||
work: null,
|
||||
dependencies: saved.accounts.map((account) => account.meta),
|
||||
}),
|
||||
sync: hourly
|
||||
@@ -72,7 +68,10 @@ export class BrokerageAccountsService {
|
||||
state: hourly.state,
|
||||
startedAt: hourly.startedAt.toISOString(),
|
||||
finishedAt: hourly.finishedAt?.toISOString() ?? null,
|
||||
sections: hourly.sections,
|
||||
problems: hourly.sections.map((section) => ({
|
||||
...section,
|
||||
accountId: section.accountId ?? null,
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('IncomeAnalyticsService', () => {
|
||||
expect.objectContaining({ role: 'previous' }),
|
||||
expect.objectContaining({
|
||||
role: 'year',
|
||||
availability: 'missing',
|
||||
availability: 'temporarily-unavailable',
|
||||
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -119,7 +119,10 @@ export class IncomeAnalyticsService {
|
||||
...investmentResourceMeta({
|
||||
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
||||
dataRevision: current.meta.dataRevision,
|
||||
availability: current.meta.availability,
|
||||
availability:
|
||||
current.items === null
|
||||
? 'temporarily-unavailable'
|
||||
: 'available',
|
||||
fetchedAt: current.meta.lastSuccessfulAt
|
||||
? new Date(current.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { timestamp } from './instrument-market';
|
||||
|
||||
const REPLACED_WINDOW_COUNT = 1;
|
||||
const NEXT_WINDOW_OFFSET = 1;
|
||||
const NO_CANDLE_GAPS = 0;
|
||||
|
||||
type CandleInput = {
|
||||
instrumentType: 'bond' | 'share';
|
||||
@@ -106,7 +107,7 @@ export class CandleService {
|
||||
true,
|
||||
['detail'],
|
||||
);
|
||||
if (detail.meta.availability === 'complete' && !detail.instrument) {
|
||||
if (detail.meta.completeness === 'complete' && !detail.instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const now = new Date();
|
||||
@@ -114,8 +115,10 @@ export class CandleService {
|
||||
? rangeFor(input, detail.instrument, now)
|
||||
: null;
|
||||
const saved = range ? await this.repository.read(input.uid, range) : null;
|
||||
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
|
||||
if (!observe && (!saved || gaps.length)) {
|
||||
const hasCompleteRange = Boolean(
|
||||
range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
|
||||
);
|
||||
if (!observe && (!saved || !hasCompleteRange)) {
|
||||
await this.request(userId, revision, input, now);
|
||||
}
|
||||
const work = await this.repository.work(userId, revision, scope(input));
|
||||
|
||||
@@ -121,7 +121,7 @@ export class CatalogDiscoveryService {
|
||||
availability:
|
||||
discovery.data === null
|
||||
? 'missing'
|
||||
: sources.every((source) => source.meta.availability === 'complete')
|
||||
: sources.every((source) => source.meta.completeness === 'complete')
|
||||
? 'complete'
|
||||
: 'partial',
|
||||
fetchedAt: discovery.meta.lastSuccessfulAt
|
||||
@@ -173,7 +173,7 @@ export class CatalogDiscoveryService {
|
||||
['detail'],
|
||||
);
|
||||
const source = sources[0];
|
||||
if (source.meta.availability === 'complete' && !source.instrument) {
|
||||
if (source.meta.completeness === 'complete' && !source.instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const instrument = source.instrument;
|
||||
|
||||
@@ -58,7 +58,7 @@ export class CatalogEnrichmentService {
|
||||
private running = false;
|
||||
private readonly completions = new Map<
|
||||
string,
|
||||
{ done: Promise<void>; resolve: () => void; workId: string }
|
||||
{ attempt: TbankSyncAttempt; done: Promise<void>; resolve: () => void; workId: string }
|
||||
>();
|
||||
|
||||
async ensureDetail(
|
||||
@@ -208,7 +208,7 @@ export class CatalogEnrichmentService {
|
||||
const done = new Promise<void>((finish) => {
|
||||
resolve = finish;
|
||||
});
|
||||
this.completions.set(attemptId, { workId: id, done, resolve });
|
||||
this.completions.set(attemptId, { attempt, done, resolve, workId: id });
|
||||
const key = producerKey(input);
|
||||
const producer = this.producers.get(key);
|
||||
if (producer) producer.observers.push({ attempt, input });
|
||||
@@ -288,7 +288,7 @@ export class CatalogEnrichmentService {
|
||||
kinds,
|
||||
);
|
||||
const first = sources[0];
|
||||
if (first.meta.availability === 'complete' && !first.instrument) {
|
||||
if (first.meta.completeness === 'complete' && !first.instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const values = sources.reduce<CatalogPayload>(
|
||||
@@ -297,7 +297,7 @@ export class CatalogEnrichmentService {
|
||||
);
|
||||
const dependencies = sources.map((source) => source.meta);
|
||||
const complete = dependencies.every(
|
||||
(dependency) => dependency.availability === 'complete',
|
||||
(dependency) => dependency.completeness === 'complete',
|
||||
);
|
||||
const meta = investmentResourceMeta({
|
||||
resourceKey: `${instrumentType}/${section}/${uid}`,
|
||||
@@ -322,7 +322,7 @@ export class CatalogEnrichmentService {
|
||||
unavailable: sources
|
||||
.filter(
|
||||
(source) =>
|
||||
source.meta.availability !== 'complete' ||
|
||||
source.meta.completeness !== 'complete' ||
|
||||
source.meta.sync.state === 'failed',
|
||||
)
|
||||
.map((source) => source.input.kind),
|
||||
@@ -580,6 +580,14 @@ export class CatalogEnrichmentService {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Persistence failed mid-batch: finish every still-open observer
|
||||
// attempt durably so no source row is left running. Best effort — a
|
||||
// row that stays open is re-reserved by the next reader.
|
||||
for (const completion of this.completions.values()) {
|
||||
await this.repository
|
||||
.finish(completion.attempt, false)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
for (const completion of this.completions.values()) completion.resolve();
|
||||
this.completions.clear();
|
||||
this.active.clear();
|
||||
|
||||
@@ -97,7 +97,7 @@ export class BondIncomeService {
|
||||
['detail', 'coupons'],
|
||||
{ from, to },
|
||||
);
|
||||
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
|
||||
if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const events = sources[1].payload?.coupons;
|
||||
@@ -137,7 +137,7 @@ export class BondIncomeService {
|
||||
projected.observedAt,
|
||||
true,
|
||||
),
|
||||
meta: { ...result.meta, availability: 'partial' },
|
||||
meta: { ...result.meta, completeness: 'incomplete' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
@@ -12,6 +13,7 @@ import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.u
|
||||
import { StockCatalogService } from './stocks/application/stock-catalog.service';
|
||||
import { StockIncomeService } from './stocks/application/stock-income.service';
|
||||
import { StockMarketService } from './stocks/application/stock-market.service';
|
||||
import { StockScreenerService } from './stocks/application/stock-screener.service';
|
||||
import { StockController } from './stocks/presentation/stock.controller';
|
||||
|
||||
@Module({
|
||||
@@ -19,12 +21,14 @@ import { StockController } from './stocks/presentation/stock.controller';
|
||||
controllers: [StockController, BondController],
|
||||
exports: [StockIncomeService, BondIncomeService],
|
||||
providers: [
|
||||
{ provide: 'TBANK_ACCESS', useExisting: TbankAccessService },
|
||||
BondIncomeService,
|
||||
BondMarketService,
|
||||
GetStockCompanyUseCase,
|
||||
StockIncomeService,
|
||||
StockMarketService,
|
||||
StockCatalogService,
|
||||
StockScreenerService,
|
||||
BondCatalogService,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@ export class StockIncomeService {
|
||||
['detail', 'dividends'],
|
||||
{ from, to },
|
||||
);
|
||||
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
|
||||
if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const events = sources[1].payload?.dividends;
|
||||
@@ -106,7 +106,7 @@ export class StockIncomeService {
|
||||
projected.observedAt,
|
||||
true,
|
||||
),
|
||||
meta: { ...result.meta, availability: 'partial' },
|
||||
meta: { ...result.meta, completeness: 'incomplete' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
|
||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { StockScreenerService } from './stock-screener.service';
|
||||
|
||||
const token = 'secret-token';
|
||||
const revision = 'revision-1';
|
||||
const pageSize = 30;
|
||||
|
||||
function share(index: number, overrides: Partial<TbankShare> = {}): TbankShare {
|
||||
return {
|
||||
apiTradeAvailableFlag: true,
|
||||
buyAvailableFlag: true,
|
||||
currency: 'RUB',
|
||||
lot: 1,
|
||||
name: `Share ${index}`,
|
||||
ticker: `T${String(index).padStart(2, '0')}`,
|
||||
uid: `e6123145-9665-43e0-8413-cd61b8aa${String(index).padStart(4, '0')}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('StockScreenerService', () => {
|
||||
const access = { current: jest.fn(), local: jest.fn() };
|
||||
const client = { getAllShares: jest.fn() };
|
||||
const logger = { event: jest.fn() };
|
||||
let service: StockScreenerService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
access.current.mockResolvedValue({ revision, token });
|
||||
access.local.mockResolvedValue({ revision });
|
||||
client.getAllShares.mockResolvedValue(
|
||||
Array.from({ length: pageSize + 1 }, (_, index) => share(index)),
|
||||
);
|
||||
service = new StockScreenerService(access, client as never, logger as never);
|
||||
});
|
||||
|
||||
it('stores the full selected catalog and returns stable non-overlapping pages', async () => {
|
||||
const first = await service.search('user-1', {});
|
||||
const second = await service.page('user-1', first.searchId, 2);
|
||||
|
||||
expect(first.items).toHaveLength(pageSize);
|
||||
expect(first.hasNext).toBe(true);
|
||||
expect(second).toMatchObject({ asOf: first.asOf, hasNext: false, page: 2, searchId: first.searchId });
|
||||
expect(second.items).toEqual([expect.objectContaining({ ticker: 'T30' })]);
|
||||
expect(client.getAllShares).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates a fresh result for every search and preserves a successful empty result', async () => {
|
||||
client.getAllShares.mockResolvedValueOnce([share(1, { sector: 'energy' })]).mockResolvedValueOnce([share(2, { sector: 'energy' })]);
|
||||
|
||||
const first = await service.search('user-1', { sector: 'finance' });
|
||||
const second = await service.search('user-1', { sector: 'finance' });
|
||||
|
||||
expect(first).toMatchObject({ hasNext: false, items: [], page: 1 });
|
||||
expect(second).toMatchObject({ hasNext: false, items: [], page: 1 });
|
||||
expect(second.searchId).not.toBe(first.searchId);
|
||||
expect(client.getAllShares).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not store a result when the catalog source fails', async () => {
|
||||
client.getAllShares.mockRejectedValueOnce(new ServiceUnavailableException());
|
||||
|
||||
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
});
|
||||
|
||||
it('denies missing, foreign, stale, and out-of-range pages without fetching', async () => {
|
||||
const first = await service.search('user-1', {});
|
||||
access.local.mockResolvedValueOnce({ revision: 'revision-2' });
|
||||
|
||||
await expect(service.page('user-1', first.searchId, 1)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.page('user-2', first.searchId, 1)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.page('user-1', 'missing', 1)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.page('user-1', first.searchId, 3)).rejects.toMatchObject({ status: 400 });
|
||||
expect(client.getAllShares).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('null-normalizes optional source fields and rejects malformed required fields', async () => {
|
||||
client.getAllShares.mockResolvedValueOnce([
|
||||
share(1, {
|
||||
countryOfRisk: undefined,
|
||||
currency: undefined,
|
||||
isin: undefined,
|
||||
realExchange: undefined,
|
||||
sector: undefined,
|
||||
shareType: undefined,
|
||||
ticker: undefined,
|
||||
}),
|
||||
]);
|
||||
const result = await service.search('user-1', {});
|
||||
expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: null, isin: null, logoName: null, ticker: null }));
|
||||
|
||||
client.getAllShares.mockResolvedValueOnce([share(2, { name: ' ' })]);
|
||||
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
});
|
||||
|
||||
it('preserves the catalog logo name in screener results', async () => {
|
||||
client.getAllShares.mockResolvedValueOnce([share(1, { brand: { logoName: 'sber.png' } })]);
|
||||
|
||||
const result = await service.search('user-1', {});
|
||||
|
||||
expect(result.items).toEqual([expect.objectContaining({ logoName: 'sber.png' })]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank-market.types';
|
||||
|
||||
import { StructuredLogger } from '../../../../../common/logging/structured-logger.service';
|
||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
|
||||
import {
|
||||
selectStockScreenerInstruments,
|
||||
type StockScreenerConditions,
|
||||
} from '../domain/stock-screener-selection.rules';
|
||||
|
||||
const MAX_SNAPSHOTS = 100;
|
||||
const PAGE_SIZE = 30;
|
||||
const MIN_PAGE = 1;
|
||||
const MINIMUM_SNAPSHOT_COUNT = 1;
|
||||
const SECONDS_PER_MINUTE = 60;
|
||||
const SNAPSHOT_TTL_MINUTES = 5;
|
||||
const MILLISECONDS_PER_SECOND = 1_000;
|
||||
const SNAPSHOT_TTL_MS = SNAPSHOT_TTL_MINUTES * SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND;
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
type StockScreenerItem = {
|
||||
countryOfRisk: null | string;
|
||||
currency: null | string;
|
||||
instrumentUid: string;
|
||||
isin: null | string;
|
||||
logoName: null | string;
|
||||
lot: null | number;
|
||||
name: string;
|
||||
realExchange: null | string;
|
||||
sector: null | string;
|
||||
shareType: null | string;
|
||||
ticker: null | string;
|
||||
};
|
||||
type StockScreenerPage = {
|
||||
asOf: string;
|
||||
hasNext: boolean;
|
||||
items: StockScreenerItem[];
|
||||
page: number;
|
||||
searchId: string;
|
||||
};
|
||||
type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string };
|
||||
type TbankAccess = {
|
||||
current(userId: string): Promise<{ revision: string; token: string }>;
|
||||
local(userId: string): Promise<{ revision: string }>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StockScreenerService {
|
||||
private readonly snapshots = new Map<string, Snapshot>();
|
||||
|
||||
constructor(
|
||||
@Inject('TBANK_ACCESS') private readonly access: TbankAccess,
|
||||
private readonly client: TbankClient,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
async search(userId: string, conditions: StockScreenerConditions): Promise<StockScreenerPage> {
|
||||
const connection = await this.access.current(userId);
|
||||
const instruments = await this.client.getAllShares(connection.token);
|
||||
const asOf = new Date().toISOString();
|
||||
const selected = selectStockScreenerInstruments({ asOf: new Date(asOf), instruments }, conditions)
|
||||
.instruments.map((instrument) => this.item(instrument));
|
||||
const searchId = randomUUID();
|
||||
this.evict();
|
||||
this.snapshots.set(searchId, {
|
||||
asOf,
|
||||
expiresAt: Date.now() + SNAPSHOT_TTL_MS,
|
||||
items: selected,
|
||||
revision: connection.revision,
|
||||
userId,
|
||||
});
|
||||
this.logger.event('info', 'stock.screener.search.completed', { asOf, count: selected.length, operation: 'stock-screener-search' });
|
||||
return this.result(searchId, this.snapshots.get(searchId) as Snapshot, MIN_PAGE);
|
||||
}
|
||||
|
||||
async page(userId: string, searchId: string, page: number): Promise<StockScreenerPage> {
|
||||
const snapshot = this.snapshots.get(searchId);
|
||||
if (!snapshot || snapshot.expiresAt <= Date.now()) {
|
||||
this.snapshots.delete(searchId);
|
||||
this.logger.event('info', 'stock.screener.result.missing', { operation: 'stock-screener-read' });
|
||||
throw new NotFoundException();
|
||||
}
|
||||
if (snapshot.userId !== userId) {
|
||||
this.logger.event('warn', 'stock.screener.result.rejected', { operation: 'stock-screener-read' });
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const current = await this.access.local(userId);
|
||||
if (snapshot.revision !== current.revision) {
|
||||
this.logger.event('warn', 'stock.screener.result.rejected', { operation: 'stock-screener-read' });
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return this.result(searchId, snapshot, page);
|
||||
}
|
||||
|
||||
private result(searchId: string, snapshot: Snapshot, page: number): StockScreenerPage {
|
||||
const start = (page - MIN_PAGE) * PAGE_SIZE;
|
||||
if (!Number.isInteger(page) || page < MIN_PAGE || (start >= snapshot.items.length && !(page === MIN_PAGE && !snapshot.items.length))) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
const items = snapshot.items.slice(start, start + PAGE_SIZE);
|
||||
return { asOf: snapshot.asOf, hasNext: start + PAGE_SIZE < snapshot.items.length, items, page, searchId };
|
||||
}
|
||||
|
||||
private item(instrument: TbankShare): StockScreenerItem {
|
||||
if (!instrument.uid || !UUID_PATTERN.test(instrument.uid) || !instrument.name?.trim()) {
|
||||
throw new ServiceUnavailableException();
|
||||
}
|
||||
return {
|
||||
countryOfRisk: instrument.countryOfRisk ?? null,
|
||||
currency: instrument.currency ?? null,
|
||||
instrumentUid: instrument.uid,
|
||||
isin: instrument.isin ?? null,
|
||||
lot: instrument.lot ?? null,
|
||||
logoName: instrument.brand?.logoName?.trim() || null,
|
||||
name: instrument.name,
|
||||
realExchange: instrument.realExchange ?? null,
|
||||
sector: instrument.sector ?? null,
|
||||
shareType: instrument.shareType ?? null,
|
||||
ticker: instrument.ticker ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private evict(): void {
|
||||
for (const [searchId, snapshot] of this.snapshots) {
|
||||
if (snapshot.expiresAt <= Date.now()) this.snapshots.delete(searchId);
|
||||
}
|
||||
while (this.snapshots.size >= MAX_SNAPSHOTS) {
|
||||
const searchId = this.snapshots.keys().next().value;
|
||||
if (searchId && this.snapshots.size >= MINIMUM_SNAPSHOT_COUNT) this.snapshots.delete(searchId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { selectStockScreenerInstruments } from './stock-screener-selection.rules';
|
||||
|
||||
const asOf = new Date('2026-10-10T12:00:00.000Z');
|
||||
|
||||
function share(overrides: Partial<TbankShare> = {}): TbankShare {
|
||||
return {
|
||||
apiTradeAvailableFlag: true,
|
||||
buyAvailableFlag: true,
|
||||
currency: 'RUB',
|
||||
lot: 10,
|
||||
name: 'North Star',
|
||||
ticker: 'NSTR',
|
||||
uid: 'uid-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('selectStockScreenerInstruments', () => {
|
||||
it('keeps only instruments matching every selected condition', () => {
|
||||
const result = selectStockScreenerInstruments(
|
||||
{
|
||||
asOf,
|
||||
instruments: [
|
||||
share({ isin: 'A', sector: 'energy' }),
|
||||
share({ currency: 'USD', isin: 'B', sector: 'energy', uid: 'uid-2' }),
|
||||
share({ isin: 'C', name: 'Other', sector: 'energy', uid: 'uid-3' }),
|
||||
share({ isin: 'D', sector: 'finance', uid: 'uid-4' }),
|
||||
],
|
||||
},
|
||||
{ currency: 'RUB', query: 'star', sector: 'energy' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ asOf, instruments: [share({ isin: 'A', sector: 'energy' })] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['sector', { sector: 'energy' }, share({ sector: undefined })],
|
||||
['share type', { shareType: 'common' }, share({ shareType: 'preferred' })],
|
||||
['currency', { currency: 'USD' }, share({ currency: undefined })],
|
||||
['exchange', { realExchange: 'MOEX' }, share({ realExchange: undefined })],
|
||||
['country of risk', { countryOfRisk: 'RU' }, share({ countryOfRisk: 'US' })],
|
||||
['query', { query: 'star' }, share({ name: undefined, ticker: undefined })],
|
||||
] as const)('excludes an unknown or unequal selected %s', (_condition, conditions, instrument) => {
|
||||
const result = selectStockScreenerInstruments(
|
||||
{ asOf, instruments: [instrument] },
|
||||
conditions,
|
||||
);
|
||||
|
||||
expect(result.instruments).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses an inclusive maximum lot and ignores lots when it is not selected', () => {
|
||||
const instruments = [
|
||||
share({ isin: 'equal', lot: 10, uid: 'equal' }),
|
||||
share({ isin: 'large', lot: 11, uid: 'large' }),
|
||||
share({ isin: 'unknown', lot: undefined, uid: 'unknown' }),
|
||||
share({ isin: 'zero', lot: 0, uid: 'zero' }),
|
||||
];
|
||||
|
||||
expect(
|
||||
selectStockScreenerInstruments({ asOf, instruments }, { maxLot: 10 }).instruments.map(
|
||||
({ uid }) => uid,
|
||||
),
|
||||
).toEqual(['equal']);
|
||||
expect(
|
||||
selectStockScreenerInstruments({ asOf, instruments }, {}).instruments.map(({ uid }) => uid),
|
||||
).toEqual(['equal', 'large', 'unknown', 'zero']);
|
||||
});
|
||||
|
||||
it('chooses the smallest matching uid as the complete representative of an ISIN', () => {
|
||||
const chosen = share({ isin: ' same ', lot: 1, name: 'Chosen', uid: 'a' });
|
||||
const result = selectStockScreenerInstruments(
|
||||
{
|
||||
asOf,
|
||||
instruments: [share({ isin: 'SAME', lot: 99, name: 'Sibling', uid: 'z' }), chosen],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.instruments).toEqual([chosen]);
|
||||
expect(result.instruments[0]).toMatchObject({ lot: 1, name: 'Chosen', uid: 'a' });
|
||||
});
|
||||
|
||||
it('chooses the sole matching mode of an issue', () => {
|
||||
const matching = share({ isin: 'same', sector: 'energy', uid: 'matching' });
|
||||
const result = selectStockScreenerInstruments(
|
||||
{
|
||||
asOf,
|
||||
instruments: [share({ isin: 'same', sector: 'finance', uid: 'smaller-uid' }), matching],
|
||||
},
|
||||
{ sector: 'energy' },
|
||||
);
|
||||
|
||||
expect(result.instruments).toEqual([matching]);
|
||||
});
|
||||
|
||||
it('keeps different ISIN issues from one issuer separate', () => {
|
||||
const result = selectStockScreenerInstruments(
|
||||
{
|
||||
asOf,
|
||||
instruments: [
|
||||
share({ isin: 'common', shareType: 'common', uid: 'common' }),
|
||||
share({ isin: 'preferred', shareType: 'preferred', uid: 'preferred' }),
|
||||
],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.instruments.map(({ uid }) => uid)).toEqual(['common', 'preferred']);
|
||||
});
|
||||
|
||||
it('treats instruments without an ISIN as separate issues', () => {
|
||||
const result = selectStockScreenerInstruments(
|
||||
{
|
||||
asOf,
|
||||
instruments: [share({ isin: ' ', uid: 'first' }), share({ isin: undefined, uid: 'second' })],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.instruments.map(({ uid }) => uid)).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('preserves exact whitespace-differing UIDs as separate blank-ISIN issues', () => {
|
||||
const result = selectStockScreenerInstruments(
|
||||
{
|
||||
asOf,
|
||||
instruments: [
|
||||
share({ isin: ' ', uid: 'uid' }),
|
||||
share({ isin: undefined, uid: ' uid ' }),
|
||||
],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.instruments.map(({ uid }) => uid)).toEqual([' uid ', 'uid']);
|
||||
});
|
||||
|
||||
it('returns the exact chosen uid for the later card link', () => {
|
||||
const result = selectStockScreenerInstruments(
|
||||
{
|
||||
asOf,
|
||||
instruments: [share({ isin: 'same', uid: 'sibling' }), share({ isin: 'SAME', uid: 'chosen' })],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.instruments[0]?.uid).toBe('chosen');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, true, 1],
|
||||
[true, false, 0],
|
||||
[false, true, 0],
|
||||
[false, false, 0],
|
||||
[true, undefined, 0],
|
||||
[undefined, true, 0],
|
||||
] as const)(
|
||||
'includes only confirmed buy and API availability: buy=%s api=%s',
|
||||
(buyAvailableFlag, apiTradeAvailableFlag, expectedCount) => {
|
||||
const result = selectStockScreenerInstruments(
|
||||
{ asOf, instruments: [share({ apiTradeAvailableFlag, buyAvailableFlag })] },
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.instruments).toHaveLength(expectedCount);
|
||||
},
|
||||
);
|
||||
|
||||
it('orders representatives by ticker and then uid before paging', () => {
|
||||
const result = selectStockScreenerInstruments(
|
||||
{
|
||||
asOf,
|
||||
instruments: [
|
||||
share({ isin: 'c', ticker: 'B', uid: 'z' }),
|
||||
share({ isin: 'a', ticker: 'A', uid: 'z' }),
|
||||
share({ isin: 'b', ticker: 'A', uid: 'a' }),
|
||||
share({ isin: 'empty', ticker: ' ', uid: 'empty' }),
|
||||
],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.instruments.map(({ uid }) => uid)).toEqual(['empty', 'a', 'z', 'z']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||
|
||||
export type StockScreenerCatalog = {
|
||||
asOf: Date;
|
||||
instruments: TbankShare[];
|
||||
};
|
||||
|
||||
export type StockScreenerConditions = {
|
||||
countryOfRisk?: string;
|
||||
currency?: string;
|
||||
maxLot?: number;
|
||||
query?: string;
|
||||
realExchange?: string;
|
||||
sector?: string;
|
||||
shareType?: string;
|
||||
};
|
||||
|
||||
type IdentifiedShare = TbankShare & { uid: string };
|
||||
const sortBefore = 0;
|
||||
|
||||
export function selectStockScreenerInstruments(
|
||||
catalog: StockScreenerCatalog,
|
||||
conditions: StockScreenerConditions,
|
||||
): StockScreenerCatalog {
|
||||
const representatives = new Map<string, IdentifiedShare>();
|
||||
|
||||
for (const instrument of catalog.instruments) {
|
||||
if (!matches(instrument, conditions)) continue;
|
||||
|
||||
const { uid } = instrument;
|
||||
const isin = instrument.isin?.trim().toUpperCase();
|
||||
const issueKey = isin ? `isin:${isin}` : `uid:${uid}`;
|
||||
const existing = representatives.get(issueKey);
|
||||
if (!existing || uid.localeCompare(existing.uid) < sortBefore) {
|
||||
representatives.set(issueKey, instrument);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
asOf: catalog.asOf,
|
||||
instruments: [...representatives.values()].sort(
|
||||
(left, right) =>
|
||||
(left.ticker?.trim() ?? '').localeCompare(right.ticker?.trim() ?? '') ||
|
||||
left.uid.localeCompare(right.uid),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function matches(
|
||||
instrument: TbankShare,
|
||||
conditions: StockScreenerConditions,
|
||||
): instrument is IdentifiedShare {
|
||||
if (
|
||||
!instrument.uid?.trim() ||
|
||||
instrument.buyAvailableFlag !== true ||
|
||||
instrument.apiTradeAvailableFlag !== true
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
conditions.query !== undefined &&
|
||||
!`${instrument.name ?? ''}\n${instrument.ticker ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(conditions.query.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (conditions.sector !== undefined && instrument.sector !== conditions.sector) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
conditions.shareType !== undefined &&
|
||||
instrument.shareType !== conditions.shareType
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
conditions.currency !== undefined &&
|
||||
instrument.currency !== conditions.currency
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
conditions.realExchange !== undefined &&
|
||||
instrument.realExchange !== conditions.realExchange
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
conditions.countryOfRisk !== undefined &&
|
||||
instrument.countryOfRisk !== conditions.countryOfRisk
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const minimumPositiveLot = 0;
|
||||
return (
|
||||
conditions.maxLot === undefined ||
|
||||
(Number.isInteger(instrument.lot) &&
|
||||
instrument.lot !== undefined &&
|
||||
instrument.lot > minimumPositiveLot &&
|
||||
instrument.lot <= conditions.maxLot)
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
||||
@@ -18,6 +24,7 @@ import { GetStockCompanyUseCase } from '../application/get-stock-company.use-cas
|
||||
import { StockCatalogService } from '../application/stock-catalog.service';
|
||||
import { StockIncomeService } from '../application/stock-income.service';
|
||||
import { StockMarketService } from '../application/stock-market.service';
|
||||
import { StockScreenerService } from '../application/stock-screener.service';
|
||||
import {
|
||||
StockCandlesQueryDto,
|
||||
StockCandlesResourceDto,
|
||||
@@ -26,6 +33,9 @@ import {
|
||||
StockPositionResourceDto,
|
||||
StockQuoteResourceDto,
|
||||
StockResourceDto,
|
||||
StockScreenerCriteriaDto,
|
||||
StockScreenerPageDto,
|
||||
StockScreenerPageQueryDto,
|
||||
StockSearchQueryDto,
|
||||
StockSearchResourceDto,
|
||||
StockTickerParamsDto,
|
||||
@@ -42,6 +52,7 @@ export class StockController {
|
||||
private readonly market: StockMarketService,
|
||||
private readonly income: StockIncomeService,
|
||||
private readonly companyInfo: GetStockCompanyUseCase,
|
||||
private readonly screener: StockScreenerService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -71,6 +82,33 @@ export class StockController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('screener/search')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ operationId: 'createStockScreenerSearch' })
|
||||
@ApiOkResponse({ type: StockScreenerPageDto })
|
||||
searchScreener(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Body(new ValidationPipe({ forbidNonWhitelisted: true, forbidUnknownValues: true, whitelist: true }))
|
||||
criteria: StockScreenerCriteriaDto,
|
||||
) {
|
||||
const allowed = new Set(['countryOfRisk', 'currency', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
|
||||
if (Object.keys(request.body as object).some((key) => !allowed.has(key))) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
return this.screener.search(request.user.sub, criteria);
|
||||
}
|
||||
|
||||
@Get('screener/search/:searchId')
|
||||
@ApiOperation({ operationId: 'readStockScreenerPage' })
|
||||
@ApiOkResponse({ type: StockScreenerPageDto })
|
||||
pageScreener(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('searchId') searchId: string,
|
||||
@Query() query: StockScreenerPageQueryDto,
|
||||
) {
|
||||
return this.screener.page(request.user.sub, searchId, query.page);
|
||||
}
|
||||
|
||||
@Get(':uid')
|
||||
@ApiOkResponse({ type: StockResourceDto })
|
||||
get(
|
||||
|
||||
@@ -2,10 +2,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
@@ -21,6 +23,86 @@ import {
|
||||
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
|
||||
|
||||
const MIN_TICKER_LENGTH = 1;
|
||||
const MAX_SCREENER_QUERY_LENGTH = 80;
|
||||
const MIN_PAGE = 1;
|
||||
|
||||
export class StockScreenerCriteriaDto {
|
||||
@ApiPropertyOptional({ maxLength: MAX_SCREENER_QUERY_LENGTH })
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
value === '' ? undefined : value,
|
||||
)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(MIN_TICKER_LENGTH)
|
||||
@MaxLength(MAX_SCREENER_QUERY_LENGTH)
|
||||
query?: string;
|
||||
|
||||
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(MIN_TICKER_LENGTH)
|
||||
sector?: string;
|
||||
|
||||
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(MIN_TICKER_LENGTH)
|
||||
shareType?: string;
|
||||
|
||||
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(MIN_TICKER_LENGTH)
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(MIN_TICKER_LENGTH)
|
||||
realExchange?: string;
|
||||
|
||||
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(MIN_TICKER_LENGTH)
|
||||
countryOfRisk?: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: MIN_TICKER_LENGTH, type: 'integer' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(MIN_TICKER_LENGTH)
|
||||
maxLot?: number;
|
||||
}
|
||||
|
||||
export class StockScreenerPageQueryDto {
|
||||
@ApiProperty({ minimum: MIN_PAGE, type: 'integer' })
|
||||
@Transform(({ value }: { value: unknown }) => Number(value))
|
||||
@IsInt()
|
||||
@Min(MIN_PAGE)
|
||||
declare page: number;
|
||||
}
|
||||
|
||||
export class StockScreenerItemDto {
|
||||
@ApiProperty({ format: 'uuid' }) declare instrumentUid: string;
|
||||
@ApiProperty() declare name: string;
|
||||
@ApiProperty({ nullable: true, type: String }) declare ticker: null | string;
|
||||
@ApiProperty({ nullable: true, type: String }) declare isin: null | string;
|
||||
@ApiProperty({ nullable: true, type: String }) declare logoName: null | string;
|
||||
@ApiProperty({ nullable: true, type: String }) declare shareType: null | string;
|
||||
@ApiProperty({ nullable: true, type: String }) declare sector: null | string;
|
||||
@ApiProperty({ nullable: true, type: String }) declare currency: null | string;
|
||||
@ApiProperty({ nullable: true, type: String }) declare realExchange: null | string;
|
||||
@ApiProperty({ nullable: true, type: String }) declare countryOfRisk: null | string;
|
||||
@ApiProperty({ nullable: true, type: 'integer' }) declare lot: null | number;
|
||||
}
|
||||
|
||||
export class StockScreenerPageDto {
|
||||
@ApiProperty() declare searchId: string;
|
||||
@ApiProperty({ format: 'date-time' }) declare asOf: string;
|
||||
@ApiProperty({ minimum: MIN_PAGE, type: 'integer' }) declare page: number;
|
||||
@ApiProperty() declare hasNext: boolean;
|
||||
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
|
||||
}
|
||||
|
||||
export class StockSearchQueryDto extends InvestmentReadQueryDto {
|
||||
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
export const availabilityValues = ['missing', 'partial', 'complete'] as const;
|
||||
export const availabilityValues = [
|
||||
'available',
|
||||
'initial-loading',
|
||||
'temporarily-unavailable',
|
||||
'connection-unavailable',
|
||||
] as const;
|
||||
export const completenessValues = ['complete', 'incomplete'] as const;
|
||||
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
|
||||
export const syncStateValues = ['idle', 'running', 'failed'] as const;
|
||||
export const attemptStateValues = [
|
||||
'none',
|
||||
'running',
|
||||
'succeeded',
|
||||
'partial',
|
||||
'failed',
|
||||
] as const;
|
||||
|
||||
export class InvestmentReadQueryDto {
|
||||
@ApiPropertyOptional({ enum: ['observe'] })
|
||||
@@ -12,17 +24,31 @@ export class InvestmentReadQueryDto {
|
||||
declare sync?: 'observe';
|
||||
}
|
||||
|
||||
export class InvestmentSyncDto {
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare id: null | string;
|
||||
export class InvestmentAttemptProblemDto {
|
||||
@ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
|
||||
declare section: 'events' | 'membership' | 'operations' | 'portfolio';
|
||||
|
||||
@ApiProperty({ enum: syncStateValues })
|
||||
declare state: (typeof syncStateValues)[number];
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare accountId: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare errorCode: null | string;
|
||||
}
|
||||
|
||||
export class InvestmentAttemptDto {
|
||||
@ApiProperty({ enum: attemptStateValues })
|
||||
declare state: (typeof attemptStateValues)[number];
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare startedAt: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare finishedAt: null | string;
|
||||
|
||||
@ApiProperty({ type: InvestmentAttemptProblemDto, isArray: true })
|
||||
declare problems: InvestmentAttemptProblemDto[];
|
||||
}
|
||||
|
||||
export class InvestmentRangeDto {
|
||||
@ApiPropertyOptional()
|
||||
accountId?: string;
|
||||
@@ -73,14 +99,21 @@ export class InvestmentDependencyDto {
|
||||
@ApiProperty({ enum: availabilityValues })
|
||||
declare availability: (typeof availabilityValues)[number];
|
||||
|
||||
@ApiProperty({ enum: completenessValues })
|
||||
declare completeness: (typeof completenessValues)[number];
|
||||
|
||||
@ApiProperty({ enum: freshnessValues })
|
||||
declare freshness: (typeof freshnessValues)[number];
|
||||
|
||||
@ApiProperty({ type: String, format: 'date-time', nullable: true })
|
||||
declare lastSuccessfulAt: null | string;
|
||||
|
||||
@ApiProperty({ type: InvestmentSyncDto })
|
||||
declare sync: InvestmentSyncDto;
|
||||
@ApiProperty({ type: InvestmentAttemptDto })
|
||||
declare lastAttempt: InvestmentAttemptDto;
|
||||
|
||||
// Internal resource-source work remains available to existing source owners.
|
||||
// It is deliberately undecorated and therefore not part of the HTTP schema.
|
||||
declare sync: { errorCode: null | string; id: null | string; state: 'failed' | 'idle' | 'running' };
|
||||
}
|
||||
|
||||
export class InvestmentResourceMetaDto extends InvestmentDependencyDto {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
jest.mock('../../prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
|
||||
import { investmentResourceMeta } from './investment-resource.mapper';
|
||||
import { hourlyAttempt } from './tbank-sync/tbank-sync.repository';
|
||||
|
||||
describe('investment resource source freshness', () => {
|
||||
afterEach(() => jest.useRealTimers());
|
||||
@@ -36,6 +41,105 @@ describe('investment resource source freshness', () => {
|
||||
}).freshness,
|
||||
).toBe('unknown');
|
||||
});
|
||||
|
||||
it('keeps confirmed data state independent from a failed hourly attempt', () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-09-25T11:30:00Z'));
|
||||
const meta = investmentResourceMeta({
|
||||
resourceKey: 'portfolio/a',
|
||||
dataRevision: '1',
|
||||
availability: 'complete',
|
||||
fetchedAt: new Date('2026-09-25T11:00:00Z'),
|
||||
freshForMs: 3_600_000,
|
||||
work: null,
|
||||
lastAttempt: {
|
||||
startedAt: '2026-09-25T12:00:00.000Z',
|
||||
finishedAt: '2026-09-25T12:01:00.000Z',
|
||||
state: 'partial',
|
||||
problems: [
|
||||
{
|
||||
section: 'portfolio',
|
||||
accountId: 'a',
|
||||
errorCode: 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(meta).toMatchObject({
|
||||
availability: 'available',
|
||||
completeness: 'complete',
|
||||
freshness: 'fresh',
|
||||
lastSuccessfulAt: '2026-09-25T11:00:00.000Z',
|
||||
lastAttempt: {
|
||||
state: 'partial',
|
||||
startedAt: '2026-09-25T12:00:00.000Z',
|
||||
finishedAt: '2026-09-25T12:01:00.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('projects only the matching hourly section into a resource attempt', () => {
|
||||
const attempt = hourlyAttempt(
|
||||
{
|
||||
startedAt: new Date('2026-09-25T12:00:00Z'),
|
||||
finishedAt: new Date('2026-09-25T12:01:00Z'),
|
||||
state: 'partial',
|
||||
sections: [
|
||||
{ section: 'portfolio', accountId: 'a', errorCode: 'TBANK_UNAVAILABLE' },
|
||||
{ section: 'operations', accountId: 'a', errorCode: null },
|
||||
{ section: 'events', errorCode: 'CALENDAR_UNAVAILABLE' },
|
||||
],
|
||||
},
|
||||
'portfolio',
|
||||
'a',
|
||||
);
|
||||
|
||||
const meta = investmentResourceMeta({
|
||||
resourceKey: 'portfolio/a',
|
||||
dataRevision: '1',
|
||||
availability: 'complete',
|
||||
fetchedAt: new Date('2026-09-25T11:00:00Z'),
|
||||
freshForMs: 3_600_000,
|
||||
work: { id: 'legacy-work', state: 'failed', errorCode: 'LEGACY_FAILURE' },
|
||||
lastAttempt: attempt,
|
||||
});
|
||||
|
||||
expect(meta).toMatchObject({
|
||||
availability: 'available',
|
||||
completeness: 'complete',
|
||||
lastAttempt: {
|
||||
state: 'partial',
|
||||
startedAt: '2026-09-25T12:00:00.000Z',
|
||||
finishedAt: '2026-09-25T12:01:00.000Z',
|
||||
problems: [{ section: 'portfolio', accountId: 'a', errorCode: 'TBANK_UNAVAILABLE' }],
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(meta)).not.toContain('legacy-work');
|
||||
});
|
||||
|
||||
it('projects no confirmed result as incomplete without inventing a successful attempt', () => {
|
||||
expect(
|
||||
investmentResourceMeta({
|
||||
resourceKey: 'accounts',
|
||||
dataRevision: '0',
|
||||
availability: 'missing',
|
||||
fetchedAt: null,
|
||||
freshForMs: 3_600_000,
|
||||
work: { id: 'work', state: 'running', errorCode: null },
|
||||
}),
|
||||
).toMatchObject({
|
||||
availability: 'initial-loading',
|
||||
completeness: 'incomplete',
|
||||
freshness: 'unknown',
|
||||
lastSuccessfulAt: null,
|
||||
lastAttempt: {
|
||||
state: 'running',
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
problems: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('dependent resource lifecycle', () => {
|
||||
@@ -60,8 +164,8 @@ describe('dependent resource lifecycle', () => {
|
||||
...input,
|
||||
dependencies: [failed, running],
|
||||
});
|
||||
expect(parent.sync).toEqual(running.sync);
|
||||
expect(parent.availability).toBe('complete');
|
||||
expect(parent.lastAttempt.state).toBe('none');
|
||||
expect(parent.availability).toBe('available');
|
||||
const terminal = investmentResourceMeta({
|
||||
...input,
|
||||
dependencies: [failed],
|
||||
|
||||
@@ -1,41 +1,34 @@
|
||||
import { InvestmentResourceMetaDto } from './investment-resource.dto';
|
||||
|
||||
type LegacyAvailability = 'available' | 'complete' | 'connection-unavailable' | 'initial-loading' | 'missing' | 'partial' | 'temporarily-unavailable';
|
||||
type Work = null | { errorCode: null | string; id: string; state: string };
|
||||
type Attempt = NonNullable<InvestmentResourceMetaDto['lastAttempt']>;
|
||||
|
||||
export function investmentResourceMeta(input: {
|
||||
availability: InvestmentResourceMetaDto['availability'];
|
||||
availability: LegacyAvailability;
|
||||
coverage?: NonNullable<InvestmentResourceMetaDto['coverage']>;
|
||||
dataRevision: string;
|
||||
dependencies?: InvestmentResourceMetaDto[];
|
||||
fetchedAt: Date | null;
|
||||
freshForMs: number;
|
||||
lastAttempt?: Attempt;
|
||||
resourceKey: string;
|
||||
unavailableReason?: string;
|
||||
work: null | { errorCode: null | string; id: string; state: string };
|
||||
work: Work;
|
||||
}): InvestmentResourceMetaDto {
|
||||
const ownSync = {
|
||||
id: input.work?.id ?? null,
|
||||
state:
|
||||
input.work?.state === 'running'
|
||||
? ('running' as const)
|
||||
: input.work?.state === 'failed'
|
||||
? ('failed' as const)
|
||||
: ('idle' as const),
|
||||
errorCode: input.work?.errorCode ?? null,
|
||||
};
|
||||
const states = [
|
||||
const ownSync = sourceSync(input.work);
|
||||
const syncStates = [
|
||||
ownSync,
|
||||
...(input.dependencies ?? [])
|
||||
.filter((dependency) => !dependency.unavailableReason)
|
||||
.map((dependency) => dependency.sync),
|
||||
.map((dependency) => dependency.sync)
|
||||
.filter((state): state is typeof ownSync => Boolean(state)),
|
||||
];
|
||||
const sync =
|
||||
states.find((state) => state.state === 'running') ??
|
||||
states.find((state) => state.state === 'failed') ??
|
||||
const sourceWork =
|
||||
syncStates.find((state) => state.state === 'running') ??
|
||||
syncStates.find((state) => state.state === 'failed') ??
|
||||
ownSync;
|
||||
const sourceFreshness = input.fetchedAt
|
||||
? Date.now() - input.fetchedAt.valueOf() < input.freshForMs
|
||||
? ('fresh' as const)
|
||||
: ('stale' as const)
|
||||
: ('unknown' as const);
|
||||
const sourceFreshness = freshnessOf(input.fetchedAt, input.freshForMs);
|
||||
const freshness = [
|
||||
sourceFreshness,
|
||||
...(input.dependencies ?? [])
|
||||
@@ -57,16 +50,75 @@ export function investmentResourceMeta(input: {
|
||||
? { unavailableReason: input.unavailableReason }
|
||||
: {}),
|
||||
dataRevision: input.dataRevision,
|
||||
availability: input.availability,
|
||||
freshness: freshness.includes('unknown')
|
||||
? ('unknown' as const)
|
||||
: freshness.includes('stale')
|
||||
? ('stale' as const)
|
||||
: ('fresh' as const),
|
||||
availability: availability(input.availability, input.work),
|
||||
completeness:
|
||||
availability(input.availability, input.work) === 'available'
|
||||
? ('complete' as const)
|
||||
: ('incomplete' as const),
|
||||
freshness: combinedFreshness(freshness),
|
||||
lastSuccessfulAt: timestamps[0] ?? null,
|
||||
sync,
|
||||
lastAttempt: input.lastAttempt ??
|
||||
attempt(input.work),
|
||||
...(input.coverage ? { coverage: input.coverage } : {}),
|
||||
...(input.dependencies ? { dependencies: input.dependencies } : {}),
|
||||
};
|
||||
return meta;
|
||||
return Object.defineProperty(meta, 'sync', {
|
||||
enumerable: false,
|
||||
value: sourceWork,
|
||||
}) as InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
function sourceSync(work: Work) {
|
||||
let state: 'failed' | 'idle' | 'running' = 'idle';
|
||||
if (work?.state === 'running') state = 'running';
|
||||
else if (work?.state === 'failed') state = 'failed';
|
||||
return { id: work?.id ?? null, state, errorCode: work?.errorCode ?? null };
|
||||
}
|
||||
|
||||
function freshnessOf(fetchedAt: Date | null, freshForMs: number) {
|
||||
if (!fetchedAt) return 'unknown' as const;
|
||||
return Date.now() - fetchedAt.valueOf() < freshForMs
|
||||
? ('fresh' as const)
|
||||
: ('stale' as const);
|
||||
}
|
||||
|
||||
function combinedFreshness(values: InvestmentResourceMetaDto['freshness'][]) {
|
||||
if (values.includes('unknown')) return 'unknown' as const;
|
||||
if (values.includes('stale')) return 'stale' as const;
|
||||
return 'fresh' as const;
|
||||
}
|
||||
|
||||
function attempt(work: Work): Attempt {
|
||||
const base = { startedAt: null, finishedAt: null };
|
||||
if (work?.state === 'running') return { ...base, state: 'running', problems: [] };
|
||||
if (work?.state === 'failed') {
|
||||
return {
|
||||
...base,
|
||||
state: 'failed',
|
||||
problems: work.errorCode
|
||||
? [{ section: 'membership', accountId: null, errorCode: work.errorCode }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
return { ...base, state: 'none', problems: [] };
|
||||
}
|
||||
|
||||
function availability(value: LegacyAvailability, work: Work) {
|
||||
if (
|
||||
value === 'available' ||
|
||||
value === 'connection-unavailable' ||
|
||||
value === 'initial-loading' ||
|
||||
value === 'temporarily-unavailable'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (value === 'complete') return 'available' as const;
|
||||
if (value === 'partial') {
|
||||
return work?.state === 'running'
|
||||
? ('initial-loading' as const)
|
||||
: ('temporarily-unavailable' as const);
|
||||
}
|
||||
return work?.state === 'running'
|
||||
? ('initial-loading' as const)
|
||||
: ('temporarily-unavailable' as const);
|
||||
}
|
||||
|
||||
@@ -209,6 +209,12 @@ export class BrokerageOperationsSyncService {
|
||||
'tbank.operations.sync.persistence_failed',
|
||||
{ userId: attempt.userId, code: TbankErrorCode.Unavailable },
|
||||
);
|
||||
// The step could not even record its failure; close the attempt
|
||||
// durably so it does not stay running. Best effort — an open row
|
||||
// is re-reserved by the next request.
|
||||
await this.repository
|
||||
.fail(attempt, false)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
const next = pending ? await this.repository.pending(attempt) : null;
|
||||
if (next) {
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
ledgerRange,
|
||||
planInvestmentScreen,
|
||||
} from '../../investment-sync-plan';
|
||||
import {
|
||||
hourlyAttempt,
|
||||
TbankSyncRepository,
|
||||
} from '../../tbank-sync/tbank-sync.repository';
|
||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||
import {
|
||||
confirmedOperationRanges,
|
||||
@@ -59,6 +63,7 @@ export class BrokerageOperationsService {
|
||||
private readonly sync: BrokerageOperationsSyncService,
|
||||
private readonly membership: TbankMembershipService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly hourlySync: TbankSyncRepository,
|
||||
) {}
|
||||
|
||||
async read(
|
||||
@@ -145,6 +150,7 @@ export class BrokerageOperationsService {
|
||||
});
|
||||
}
|
||||
const saved = await this.repository.readPage(readInput);
|
||||
const hourly = await this.hourlySync.hourly(userId);
|
||||
const accounts = saved?.connection.accounts ?? [];
|
||||
const targets = accounts.flatMap((account) =>
|
||||
from < to
|
||||
@@ -243,6 +249,7 @@ export class BrokerageOperationsService {
|
||||
? STALE_FRESHNESS_MS
|
||||
: Number.POSITIVE_INFINITY,
|
||||
work: work ?? saved?.membershipWork ?? null,
|
||||
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
||||
dependencies: [membershipMeta, ...metadataDependencies],
|
||||
coverage: {
|
||||
requested: targets.map(serialize),
|
||||
@@ -294,7 +301,7 @@ export class BrokerageOperationsService {
|
||||
const hasNext = Boolean(saved && saved.rows.length > limit);
|
||||
return {
|
||||
data:
|
||||
meta.availability === 'missing'
|
||||
meta.completeness === 'incomplete'
|
||||
? null
|
||||
: {
|
||||
accountId,
|
||||
|
||||
@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||
import { BrokerageOperationsService } from './application/brokerage-operations.service';
|
||||
import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository';
|
||||
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TbankModule],
|
||||
imports: [AuthModule, TbankModule, TbankSyncRepositoryModule],
|
||||
controllers: [BrokerageOperationsController],
|
||||
providers: [
|
||||
BrokerageOperationsService,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||
|
||||
import { add, decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
ForecastRepository,
|
||||
@@ -16,6 +17,7 @@ export class ForecastService {
|
||||
|
||||
async result<T extends object>(input: {
|
||||
candidate?: T;
|
||||
lastAttempt?: InvestmentResourceMetaDto['lastAttempt'];
|
||||
membershipRevision: number;
|
||||
portfolio: InvestmentResourceMetaDto;
|
||||
revision: string;
|
||||
@@ -72,22 +74,51 @@ export class ForecastService {
|
||||
.map((dependency) => dependency.sync)
|
||||
.find((sync) => sync.state === 'failed');
|
||||
return {
|
||||
data: stored ? (stored.payload as T) : null,
|
||||
data: stored ? filterEvents(stored.payload as T, input.scopeKey) : null,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: input.scopeKey,
|
||||
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
|
||||
availability: stored
|
||||
? 'complete'
|
||||
: dependencies.some(
|
||||
(dependency) => dependency.availability !== 'missing',
|
||||
(dependency) => dependency.completeness === 'complete',
|
||||
)
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
fetchedAt: first ? new Date(first) : null,
|
||||
freshForMs: Number.POSITIVE_INFINITY,
|
||||
work: work?.id ? { ...work, id: work.id } : null,
|
||||
lastAttempt: input.lastAttempt,
|
||||
dependencies,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function filterEvents<T extends object>(payload: T, scopeKey: string): T {
|
||||
if (!scopeKey.startsWith('events/')) return payload;
|
||||
try {
|
||||
const [, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
|
||||
const value = payload as T & {
|
||||
events?: { date: string; projectedAmount?: { currency: string; value: string }; type: string }[];
|
||||
incomeTotalsByCurrency?: { currency: string; value: string }[];
|
||||
};
|
||||
if (!value.events || !value.incomeTotalsByCurrency) return payload;
|
||||
const events = value.events.filter((event) => event.date >= from && event.date <= to);
|
||||
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
|
||||
for (const event of events) {
|
||||
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) continue;
|
||||
const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0 };
|
||||
totals.set(event.projectedAmount.currency, add(current, decimalFromString(event.projectedAmount.value)));
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
events,
|
||||
incomeTotalsByCurrency: [...totals]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([currency, amount]) => ({ currency, value: formatDecimal(amount) })),
|
||||
};
|
||||
} catch {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('persistent portfolio projection', () => {
|
||||
repository as never,
|
||||
{ request: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ event: jest.fn() } as never,
|
||||
{ hourly: jest.fn().mockResolvedValue(null) } as never,
|
||||
);
|
||||
access.local.mockResolvedValue({
|
||||
revision: 'private',
|
||||
@@ -192,7 +193,8 @@ describe('persistent portfolio projection', () => {
|
||||
});
|
||||
const partial = await service.read('user', 'all');
|
||||
expect(partial.data).toBeNull();
|
||||
expect(partial.meta.availability).toBe('partial');
|
||||
expect(partial.meta.availability).toBe('temporarily-unavailable');
|
||||
expect(partial.meta.completeness).toBe('incomplete');
|
||||
const saved = await service.saved('user', 'all');
|
||||
expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual(
|
||||
snapshot.totalsByCurrency,
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
hourlyAttempt,
|
||||
TbankSyncRepository,
|
||||
} from '../../tbank-sync/tbank-sync.repository';
|
||||
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
||||
import {
|
||||
@@ -28,6 +32,7 @@ import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
|
||||
import { PortfolioDto } from '../presentation/portfolio.dto';
|
||||
|
||||
const MISSING_DATA_REVISION = 0;
|
||||
const NO_ACCOUNTS = 0;
|
||||
|
||||
const membershipFreshForMs = tbankSourceTtlMs.membership;
|
||||
|
||||
@@ -44,12 +49,14 @@ export class PortfolioService {
|
||||
private readonly repository: PortfolioRepository,
|
||||
private readonly membership: TbankMembershipService,
|
||||
private readonly logger: StructuredLogger,
|
||||
private readonly sync: TbankSyncRepository,
|
||||
) {}
|
||||
|
||||
// Local reads never start upstream work; the hourly walk owns refreshes.
|
||||
async saved(userId: string, accountId: string) {
|
||||
const connection = await this.access.local(userId, accountId);
|
||||
const saved = await this.repository.read(userId, connection.revision);
|
||||
const hourly = await this.sync.hourly(userId);
|
||||
if (
|
||||
saved?.membershipAt &&
|
||||
accountId !== 'all' &&
|
||||
@@ -64,8 +71,10 @@ export class PortfolioService {
|
||||
fetchedAt: saved?.membershipAt ?? null,
|
||||
freshForMs: membershipFreshForMs,
|
||||
work: saved?.work.find((work) => work.scopeKey === 'membership') ?? null,
|
||||
lastAttempt: hourlyAttempt(hourly, 'membership'),
|
||||
});
|
||||
return {
|
||||
hourly,
|
||||
membership: membershipMeta,
|
||||
accounts: (saved?.accounts ?? [])
|
||||
.filter(
|
||||
@@ -95,16 +104,21 @@ export class PortfolioService {
|
||||
saved?.work.find(
|
||||
(work) => work.scopeKey === portfolioWorkKey(account.accountId),
|
||||
) ?? null,
|
||||
lastAttempt: hourlyAttempt(hourly, 'portfolio', account.accountId),
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async read(userId: string, accountId: string) {
|
||||
const saved = await this.saved(userId, accountId);
|
||||
const { hourly, ...saved } = await this.saved(userId, accountId);
|
||||
const missing =
|
||||
saved.membership.availability === 'missing' ||
|
||||
saved.accounts.some((account) => !account.snapshot);
|
||||
saved.membership.completeness === 'incomplete' ||
|
||||
(saved.accounts.length > NO_ACCOUNTS && saved.accounts.some((account) => !account.snapshot));
|
||||
const available =
|
||||
!missing &&
|
||||
(saved.membership.availability === 'available' ||
|
||||
saved.accounts.some((account) => account.snapshot));
|
||||
const metadata = await this.metadata.savedMany(
|
||||
saved.accounts.flatMap((account) => account.snapshot?.positions ?? []),
|
||||
);
|
||||
@@ -153,6 +167,11 @@ export class PortfolioService {
|
||||
]
|
||||
: [],
|
||||
);
|
||||
let availability: 'available' | 'complete' | 'missing' | 'partial';
|
||||
if (available) availability = 'available';
|
||||
else if (!missing) availability = 'complete';
|
||||
else if (portfolios.length > NO_ACCOUNTS) availability = 'partial';
|
||||
else availability = 'missing';
|
||||
const lastSuccessfulAt =
|
||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
|
||||
@@ -196,16 +215,13 @@ export class PortfolioService {
|
||||
]),
|
||||
metadataDependencies.map((dependency) => dependency.dataRevision),
|
||||
]),
|
||||
availability: missing
|
||||
? portfolios.length
|
||||
? 'partial'
|
||||
: 'missing'
|
||||
: 'complete',
|
||||
availability,
|
||||
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
|
||||
freshForMs: portfolios.length
|
||||
? portfolioFreshForMs
|
||||
: membershipFreshForMs,
|
||||
work: work.id ? { ...work, id: work.id } : null,
|
||||
lastAttempt: hourlyAttempt(hourly, 'portfolio', accountId === 'all' ? undefined : accountId),
|
||||
dependencies: [
|
||||
saved.membership,
|
||||
...saved.accounts.map((account) => account.meta),
|
||||
|
||||
@@ -51,11 +51,32 @@ export class ForecastRepository {
|
||||
const previous = await tx.tbankForecast.findUnique({
|
||||
where: { userId_revision_scopeKey: key },
|
||||
});
|
||||
const requestedWindow = eventsWindow(input.scopeKey);
|
||||
const saved = previous ?? (requestedWindow
|
||||
? (
|
||||
await tx.tbankForecast.findMany({
|
||||
where: {
|
||||
...owner,
|
||||
scopeKey: { startsWith: 'events/' },
|
||||
membershipRevision: input.membershipRevision,
|
||||
},
|
||||
})
|
||||
).find((forecast) => {
|
||||
const window = eventsWindow(forecast.scopeKey);
|
||||
return (
|
||||
window &&
|
||||
window.accountId === requestedWindow.accountId &&
|
||||
window.from <= requestedWindow.to &&
|
||||
requestedWindow.from <= window.to
|
||||
);
|
||||
})
|
||||
: null);
|
||||
const fallback =
|
||||
previous?.membershipRevision === input.membershipRevision
|
||||
? previous
|
||||
saved?.membershipRevision === input.membershipRevision
|
||||
? saved
|
||||
: null;
|
||||
if (!input.candidate) return fallback;
|
||||
if (!previous && fallback) return fallback;
|
||||
const portfolios = await tx.tbankPortfolio.findMany({
|
||||
where: {
|
||||
...owner,
|
||||
@@ -154,3 +175,13 @@ export class ForecastRepository {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function eventsWindow(scopeKey: string) {
|
||||
if (!scopeKey.startsWith('events/')) return null;
|
||||
try {
|
||||
const [accountId, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
|
||||
return { accountId, from, to };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||
import { ForecastService } from './application/forecast.service';
|
||||
import { PortfolioService } from './application/portfolio.service';
|
||||
import { ForecastRepository } from './infrastructure/forecast.repository';
|
||||
@@ -9,7 +10,7 @@ import { PortfolioRepository } from './infrastructure/portfolio.repository';
|
||||
import { PortfolioController } from './presentation/portfolio.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TbankModule],
|
||||
imports: [AuthModule, TbankModule, TbankSyncRepositoryModule],
|
||||
controllers: [PortfolioController],
|
||||
providers: [
|
||||
PortfolioService,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { StructuredLogger } from '../../../common/logging/structured-logger.service';
|
||||
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
@@ -12,6 +13,9 @@ import { BrokerageOperationsSyncService } from '../operations/operations.api';
|
||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||
import { UpcomingEventsService } from '../upcoming-events/upcoming-events.service';
|
||||
import {
|
||||
HOURLY_CAUSE_INTERRUPTED,
|
||||
type HourlyCause,
|
||||
type HourlyChild,
|
||||
type HourlySection,
|
||||
TbankSyncRepository,
|
||||
} from './tbank-sync.repository';
|
||||
@@ -19,11 +23,19 @@ import {
|
||||
const HOUR_MS = 3_600_000;
|
||||
const TICK_MS = 60_000;
|
||||
const WORK_POLL_MS = 100;
|
||||
const WORK_POLL_BACKOFF_FACTOR = 2;
|
||||
const WORK_POLL_MAX_MS = 5_000;
|
||||
// ponytail: fixed 15-minute step wait bound (900_000 ms), no config knob;
|
||||
// make it configurable if slow steps become regular.
|
||||
const WORK_WAIT_BOUND_MS = 900_000;
|
||||
// An await that outlived WORK_WAIT_BOUND_MS. The underlying call keeps
|
||||
// running; its late durable result is fenced like any other late publication.
|
||||
const BOUND_EXPIRED = Symbol('hourly-bound-expired');
|
||||
const FIRST_DAY_OF_MONTH = 1;
|
||||
const ONE_ATTEMPT = 1;
|
||||
const NO_ATTEMPTS = 0;
|
||||
const NO_MILLISECONDS = 0;
|
||||
|
||||
type SettledWork = { errorCode: null | string; id: string; state: string };
|
||||
type DispatchStep = (child: HourlyChild) => Promise<void>;
|
||||
type Accounts = Awaited<ReturnType<TbankAccessService['local']>>['accounts'];
|
||||
|
||||
// One native timer per process. The timer only proposes work; the durable
|
||||
// reservation in TbankSyncRepository is the fence that keeps a single
|
||||
@@ -32,6 +44,9 @@ type SettledWork = { errorCode: null | string; id: string; state: string };
|
||||
@Injectable()
|
||||
export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
||||
private readonly processRunId = randomUUID();
|
||||
// Attempt ids of walks currently executing in this process; a running row
|
||||
// outside this set lost its settle and is closed by recovery.
|
||||
private readonly active = new Set<string>();
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private ticking = false;
|
||||
|
||||
@@ -43,6 +58,7 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
||||
private readonly portfolio: PortfolioService,
|
||||
private readonly operations: BrokerageOperationsSyncService,
|
||||
private readonly events: UpcomingEventsService,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
@@ -74,6 +90,7 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
||||
// A fresh connection starts its first walk immediately, regardless of the
|
||||
// previous term of the replaced connection.
|
||||
private async launch(userId: string) {
|
||||
await this.recoverAbandoned(userId);
|
||||
const reserved = await this.repository.reserveHourly(
|
||||
userId,
|
||||
this.processRunId,
|
||||
@@ -87,135 +104,307 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
||||
return reserved.work;
|
||||
}
|
||||
|
||||
// One membership pass, then the owners refresh their own datasets. The
|
||||
// walk publishes only safe section results and holds no transaction or
|
||||
// upstream call inside one.
|
||||
private async walk(attempt: TbankSyncAttempt) {
|
||||
const failures: HourlySection[] = [];
|
||||
const attempted = { count: NO_ATTEMPTS };
|
||||
try {
|
||||
const { userId, revision } = attempt;
|
||||
await this.refreshMembership(userId, revision, failures, attempted);
|
||||
if (failures.some((item) => item.errorCode === TbankErrorCode.InvalidToken)) {
|
||||
return void this.settle(attempt, failures, attempted.count);
|
||||
}
|
||||
const current = await this.access.local(userId);
|
||||
if (current.revision !== revision) {
|
||||
return void this.settle(attempt, failures, attempted.count);
|
||||
}
|
||||
await this.refreshAccounts(userId, revision, current.accounts, failures, attempted);
|
||||
await this.settle(attempt, failures, attempted.count);
|
||||
} catch {
|
||||
await this.settle(attempt, failures, attempted.count);
|
||||
// A running hourly row of this process that has no active walk lost its
|
||||
// settle (suppressed persistence error). Close it from its durable step
|
||||
// results; rows of other processes are closed by the reservation restart
|
||||
// branch. Rides the existing tick: no new timers or sanitors.
|
||||
private async recoverAbandoned(userId: string) {
|
||||
const rows = await this.repository.runningHourly(userId);
|
||||
for (const row of rows) {
|
||||
if (row.processRunId !== this.processRunId) continue;
|
||||
if (this.active.has(row.attemptId)) continue;
|
||||
await this.repository
|
||||
.settleHourly(
|
||||
{
|
||||
userId,
|
||||
revision: row.revision,
|
||||
id: row.id,
|
||||
attemptId: row.attemptId,
|
||||
},
|
||||
HOURLY_CAUSE_INTERRUPTED,
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// One membership pass, then the owners refresh their own datasets. The
|
||||
// walk publishes only safe section results and holds no transaction or
|
||||
// upstream call inside one. The attempt outcome is derived from durable
|
||||
// step rows by the repository, never from walk-local state.
|
||||
private async walk(attempt: TbankSyncAttempt) {
|
||||
this.active.add(attempt.attemptId);
|
||||
try {
|
||||
const { userId, revision } = attempt;
|
||||
const children: HourlyChild[] = [];
|
||||
// Required sections whose dispatch failed never get a durable step
|
||||
// row; they are recorded as problems so the attempt cannot succeed.
|
||||
const dispatchProblems: HourlySection[] = [];
|
||||
const dispatch: DispatchStep = async (child) => {
|
||||
children.push(child);
|
||||
await this.repository.recordHourlyChild(attempt, child);
|
||||
};
|
||||
const accounts = await this.refreshMembership(
|
||||
attempt,
|
||||
dispatch,
|
||||
dispatchProblems,
|
||||
);
|
||||
if (!accounts) return;
|
||||
await this.refreshAccounts(
|
||||
userId,
|
||||
revision,
|
||||
accounts,
|
||||
dispatch,
|
||||
dispatchProblems,
|
||||
);
|
||||
await this.waitWorks(userId, children.map((child) => child.id));
|
||||
for (const account of accounts) {
|
||||
const pending = this.events
|
||||
.get(userId, account.accountId)
|
||||
.catch(() => undefined);
|
||||
await this.withBound(pending);
|
||||
}
|
||||
await this.settle(attempt, null, dispatchProblems);
|
||||
} catch {
|
||||
await this.settle(attempt, HOURLY_CAUSE_INTERRUPTED);
|
||||
} finally {
|
||||
this.active.delete(attempt.attemptId);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the current accounts, or null when the attempt is already
|
||||
// settled (invalid token or a replaced connection revision).
|
||||
private async refreshMembership(
|
||||
userId: string,
|
||||
revision: string,
|
||||
failures: HourlySection[],
|
||||
attempted: { count: number },
|
||||
attempt: TbankSyncAttempt,
|
||||
dispatch: DispatchStep,
|
||||
dispatchProblems: HourlySection[],
|
||||
) {
|
||||
const member = await this.membership.request(userId, revision, true);
|
||||
attempted.count += ONE_ATTEMPT;
|
||||
if (!member) return;
|
||||
await member.done;
|
||||
const errorCode = await this.repository.workError(userId, member.id);
|
||||
if (errorCode) failures.push({ section: 'membership', errorCode });
|
||||
const { userId, revision } = attempt;
|
||||
const member = await this.withBound(
|
||||
this.membership.request(userId, revision, true),
|
||||
);
|
||||
if (member && member !== BOUND_EXPIRED) {
|
||||
await dispatch({ id: member.id, section: 'membership' });
|
||||
const done = await this.withBound(member.done);
|
||||
if (
|
||||
done !== BOUND_EXPIRED &&
|
||||
(await this.repository.workError(userId, member.id)) ===
|
||||
TbankErrorCode.InvalidToken
|
||||
) {
|
||||
await this.settle(attempt);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// No durable step id (rejected request or an expired wait): record the
|
||||
// observable problem; a late row stays fenced by its durable attempt id.
|
||||
dispatchProblems.push({
|
||||
section: 'membership',
|
||||
errorCode: TbankErrorCode.Unavailable,
|
||||
});
|
||||
}
|
||||
const current = await this.withBound(this.access.local(userId));
|
||||
if (current === BOUND_EXPIRED || current.revision !== revision) {
|
||||
await this.settle(attempt, HOURLY_CAUSE_INTERRUPTED);
|
||||
return null;
|
||||
}
|
||||
return current.accounts;
|
||||
}
|
||||
|
||||
// Races a step await against the same per-step bound the row wait uses, so
|
||||
// a dispatch that never resolves cannot hold the attempt running forever.
|
||||
// The call itself is not cancelled; a rejection landing after the expiry is
|
||||
// swallowed here because the expiry already accounts the step as a problem.
|
||||
private withBound<T>(step: Promise<T>): Promise<T | typeof BOUND_EXPIRED> {
|
||||
step.catch(() => undefined);
|
||||
return new Promise<T | typeof BOUND_EXPIRED>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => resolve(BOUND_EXPIRED),
|
||||
WORK_WAIT_BOUND_MS,
|
||||
);
|
||||
step.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
clearTimeout(timer);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshAccounts(
|
||||
userId: string,
|
||||
revision: string,
|
||||
accounts: Awaited<ReturnType<TbankAccessService['local']>>['accounts'],
|
||||
failures: HourlySection[],
|
||||
attempted: { count: number },
|
||||
accounts: Accounts,
|
||||
dispatch: DispatchStep,
|
||||
dispatchProblems: HourlySection[],
|
||||
) {
|
||||
for (const account of accounts) {
|
||||
await this.refreshPortfolio(userId, revision, account.accountId, failures, attempted);
|
||||
await this.dispatchPortfolio(
|
||||
userId,
|
||||
revision,
|
||||
account.accountId,
|
||||
dispatch,
|
||||
dispatchProblems,
|
||||
);
|
||||
}
|
||||
for (const account of accounts) {
|
||||
await this.refreshOperations(userId, revision, account.accountId, failures, attempted);
|
||||
await this.dispatchOperations(
|
||||
userId,
|
||||
revision,
|
||||
account.accountId,
|
||||
dispatch,
|
||||
dispatchProblems,
|
||||
);
|
||||
}
|
||||
for (const account of accounts) {
|
||||
await this.refreshEvents(userId, account.accountId, failures, attempted);
|
||||
await this.dispatchEvents(
|
||||
userId,
|
||||
account.accountId,
|
||||
dispatch,
|
||||
dispatchProblems,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshPortfolio(userId: string, revision: string, accountId: string, failures: HourlySection[], attempted: { count: number }) {
|
||||
const work = await this.portfolio.request(userId, revision, accountId, true);
|
||||
attempted.count += ONE_ATTEMPT;
|
||||
if (!work) return;
|
||||
await work.done;
|
||||
const errorCode = await this.repository.workError(userId, work.id);
|
||||
if (errorCode) failures.push({ section: 'portfolio', errorCode, accountId });
|
||||
}
|
||||
|
||||
private async refreshOperations(userId: string, revision: string, accountId: string, failures: HourlySection[], attempted: { count: number }) {
|
||||
attempted.count += ONE_ATTEMPT;
|
||||
private async dispatchPortfolio(
|
||||
userId: string,
|
||||
revision: string,
|
||||
accountId: string,
|
||||
dispatch: DispatchStep,
|
||||
dispatchProblems: HourlySection[],
|
||||
) {
|
||||
try {
|
||||
const id = await this.operations.request(userId, [{ accountId, from: monthStartOf(new Date()), to: new Date() }], true, 'screen', revision);
|
||||
if (!id) return;
|
||||
const errorCode = await this.settledWorkError(userId, [id]);
|
||||
if (errorCode) failures.push({ section: 'operations', errorCode, accountId });
|
||||
const work = await this.withBound(
|
||||
this.portfolio.request(userId, revision, accountId, true),
|
||||
);
|
||||
if (work && work !== BOUND_EXPIRED) {
|
||||
await dispatch({ accountId, id: work.id, section: 'portfolio' });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
failures.push({ section: 'operations', errorCode: TbankErrorCode.Unavailable, accountId });
|
||||
// A failed dispatch has no durable step row; record the problem.
|
||||
}
|
||||
dispatchProblems.push({
|
||||
accountId,
|
||||
errorCode: TbankErrorCode.Unavailable,
|
||||
section: 'portfolio',
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshEvents(userId: string, accountId: string, failures: HourlySection[], attempted: { count: number }) {
|
||||
attempted.count += ONE_ATTEMPT;
|
||||
private async dispatchOperations(
|
||||
userId: string,
|
||||
revision: string,
|
||||
accountId: string,
|
||||
dispatch: DispatchStep,
|
||||
dispatchProblems: HourlySection[],
|
||||
) {
|
||||
try {
|
||||
const ids = await this.events.refresh(userId, accountId);
|
||||
const failed = ids.length ? (await this.settledWorks(userId, ids)).find((row) => row.state === 'failed') : undefined;
|
||||
if (failed?.errorCode) failures.push({ section: 'events', errorCode: failed.errorCode, accountId });
|
||||
else await this.events.get(userId, accountId);
|
||||
const id = await this.withBound(
|
||||
this.operations.request(
|
||||
userId,
|
||||
[
|
||||
{
|
||||
accountId,
|
||||
from: monthStartOf(new Date()),
|
||||
to: new Date(),
|
||||
},
|
||||
],
|
||||
true,
|
||||
'screen',
|
||||
revision,
|
||||
),
|
||||
);
|
||||
if (id && id !== BOUND_EXPIRED) {
|
||||
await dispatch({ accountId, id, section: 'operations' });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
failures.push({ section: 'events', errorCode: TbankErrorCode.Unavailable, accountId });
|
||||
// A failed dispatch has no durable step row; record the problem.
|
||||
}
|
||||
dispatchProblems.push({
|
||||
accountId,
|
||||
errorCode: TbankErrorCode.Unavailable,
|
||||
section: 'operations',
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchEvents(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
dispatch: DispatchStep,
|
||||
dispatchProblems: HourlySection[],
|
||||
) {
|
||||
try {
|
||||
const ids = await this.withBound(this.events.refresh(userId, accountId));
|
||||
if (ids !== BOUND_EXPIRED) {
|
||||
for (const id of ids) {
|
||||
await dispatch({ accountId, id, section: 'events' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// A failed dispatch has no durable step row; record the problem.
|
||||
}
|
||||
dispatchProblems.push({
|
||||
accountId,
|
||||
errorCode: TbankErrorCode.Unavailable,
|
||||
section: 'events',
|
||||
});
|
||||
}
|
||||
|
||||
private async settle(
|
||||
attempt: TbankSyncAttempt,
|
||||
failures: HourlySection[],
|
||||
attempted: number,
|
||||
cause: HourlyCause | null = null,
|
||||
dispatchProblems: HourlySection[] = [],
|
||||
) {
|
||||
let result: 'failed' | 'partial' | 'succeeded' = 'succeeded';
|
||||
if (failures.length) {
|
||||
result = attempted > NO_ATTEMPTS && failures.length >= attempted ? 'failed' : 'partial';
|
||||
}
|
||||
// A suppressed settle error is not lost: the walk leaves the active set
|
||||
// and the next tick closes the attempt from its durable step results.
|
||||
await this.repository
|
||||
.settleHourly(attempt, result, failures)
|
||||
.catch(() => undefined);
|
||||
.settleHourly(attempt, cause, dispatchProblems)
|
||||
.catch(() => {
|
||||
this.logger.event('error', 'tbank.hourly.settle_failed', {
|
||||
userId: attempt.userId,
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private async settledWorkError(userId: string, ids: string[]) {
|
||||
const rows = await this.settledWorks(userId, ids);
|
||||
return rows.find((row) => row.state === 'failed')?.errorCode ?? null;
|
||||
// Drops settled and missing rows from the wait set and expires waits that
|
||||
// passed the per-step bound.
|
||||
private advanceWaiting(
|
||||
waiting: Map<string, number>,
|
||||
rows: { id: string; state: string }[],
|
||||
) {
|
||||
for (const row of rows) {
|
||||
if (row.state !== 'running') waiting.delete(row.id);
|
||||
}
|
||||
for (const id of [...waiting.keys()]) {
|
||||
if (!rows.some((row) => row.id === id)) {
|
||||
waiting.delete(id);
|
||||
continue;
|
||||
}
|
||||
const startedAt = waiting.get(id) ?? NO_MILLISECONDS;
|
||||
if (Date.now() - startedAt >= WORK_WAIT_BOUND_MS) waiting.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Polls the durable work rows until they leave the running state; a row
|
||||
// deleted with its connection counts as interrupted, not failed.
|
||||
private async settledWorks(userId: string, ids: string[]) {
|
||||
const settled = new Map<string, SettledWork>();
|
||||
const pending = new Set(ids);
|
||||
while (pending.size) {
|
||||
const rows = await this.repository.works(userId, [...pending]);
|
||||
for (const row of rows) {
|
||||
if (row.state !== 'running') {
|
||||
settled.set(row.id, row);
|
||||
pending.delete(row.id);
|
||||
}
|
||||
}
|
||||
for (const id of [...pending]) {
|
||||
if (!rows.some((row) => row.id === id)) {
|
||||
settled.set(id, { id, state: 'cancelled', errorCode: null });
|
||||
pending.delete(id);
|
||||
}
|
||||
// Waits for every dispatched step to leave the running state. Each wait is
|
||||
// bounded so a stuck step cannot hold the attempt running forever; the
|
||||
// bound does not cancel the upstream call, and late results stay in the
|
||||
// step row where the attempt fence decides whether they count.
|
||||
private async waitWorks(userId: string, ids: string[]) {
|
||||
const waiting = new Map(ids.map((id) => [id, Date.now()]));
|
||||
let pollMs = WORK_POLL_MS;
|
||||
while (waiting.size) {
|
||||
const rows = await this.repository.works(userId, [...waiting.keys()]);
|
||||
this.advanceWaiting(waiting, rows);
|
||||
if (waiting.size) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
pollMs = Math.min(pollMs * WORK_POLL_BACKOFF_FACTOR, WORK_POLL_MAX_MS);
|
||||
}
|
||||
if (pending.size) await new Promise((resolve) => setTimeout(resolve, WORK_POLL_MS));
|
||||
}
|
||||
return [...settled.values()];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankSyncRepository } from './tbank-sync.repository';
|
||||
|
||||
@Module({
|
||||
providers: [TbankSyncRepository],
|
||||
exports: [TbankSyncRepository],
|
||||
})
|
||||
export class TbankSyncRepositoryModule {}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query';
|
||||
import { InvestmentAttemptProblemDto } from '../investment-resource.dto';
|
||||
import { operationCategories } from '../operations/operations.api';
|
||||
|
||||
const MIN_ACCOUNT_ID_LENGTH = 1;
|
||||
@@ -267,6 +268,6 @@ export class HourlySyncStatusDto {
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare finishedAt: null | string;
|
||||
|
||||
@ApiProperty({ type: HourlySyncSectionDto, isArray: true })
|
||||
declare sections: HourlySyncSectionDto[];
|
||||
@ApiProperty({ type: InvestmentAttemptProblemDto, isArray: true })
|
||||
declare problems: InvestmentAttemptProblemDto[];
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { OperationsModule } from '../operations/operations.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
|
||||
import { TbankHourlyScheduler } from './tbank-hourly.scheduler';
|
||||
import { TbankSyncRepositoryModule } from './tbank-sync-repository.module';
|
||||
import { TbankSyncController } from './tbank-sync.controller';
|
||||
import { TbankSyncRepository } from './tbank-sync.repository';
|
||||
import { TbankSyncService } from './tbank-sync.service';
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -18,9 +18,10 @@ import { TbankSyncService } from './tbank-sync.service';
|
||||
OperationsModule,
|
||||
InstrumentSourcesModule,
|
||||
UpcomingEventsModule,
|
||||
TbankSyncRepositoryModule,
|
||||
],
|
||||
controllers: [TbankSyncController],
|
||||
providers: [TbankSyncService, TbankSyncRepository, TbankHourlyScheduler],
|
||||
exports: [TbankHourlyScheduler, TbankSyncRepository],
|
||||
providers: [TbankSyncService, TbankHourlyScheduler],
|
||||
exports: [TbankHourlyScheduler],
|
||||
})
|
||||
export class TbankSyncModule {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Prisma } from '../../../../generated/prisma/client';
|
||||
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api';
|
||||
import type { InvestmentAttemptDto } from '../investment-resource.dto';
|
||||
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
||||
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
@@ -17,10 +18,56 @@ export type HourlySection = {
|
||||
errorCode: null | string;
|
||||
section: 'events' | 'membership' | 'operations' | 'portfolio';
|
||||
};
|
||||
export type HourlyChild = {
|
||||
accountId?: string;
|
||||
id: string;
|
||||
section: HourlySection['section'];
|
||||
};
|
||||
export const HOURLY_CAUSE_DEADLINE = 'DEADLINE';
|
||||
export const HOURLY_CAUSE_INTERRUPTED = 'INTERRUPTED';
|
||||
export type HourlyCause = typeof HOURLY_CAUSE_INTERRUPTED;
|
||||
type StepOutcome = { problem?: HourlySection; success: boolean };
|
||||
type StepRow = { attemptId: string; errorCode: null | string; state: string };
|
||||
export type HourlyProgress = {
|
||||
attempts?: Record<string, string>;
|
||||
children?: HourlyChild[];
|
||||
result?: 'failed' | 'partial' | 'succeeded';
|
||||
sections: HourlySection[];
|
||||
};
|
||||
type HourlyAttempt = {
|
||||
finishedAt: Date | null;
|
||||
sections: HourlySection[];
|
||||
startedAt: Date;
|
||||
state: 'cancelled' | 'failed' | 'partial' | 'running' | 'succeeded';
|
||||
};
|
||||
|
||||
export function hourlyAttempt(
|
||||
hourly: HourlyAttempt | null,
|
||||
section: HourlySection['section'],
|
||||
accountId?: string,
|
||||
): InvestmentAttemptDto {
|
||||
if (!hourly) {
|
||||
return { state: 'none', startedAt: null, finishedAt: null, problems: [] };
|
||||
}
|
||||
const problems = hourly.sections
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.section === section &&
|
||||
(accountId === undefined || entry.accountId === accountId),
|
||||
)
|
||||
.map((entry) => ({
|
||||
section: entry.section,
|
||||
accountId: entry.accountId ?? null,
|
||||
errorCode: entry.errorCode,
|
||||
}));
|
||||
return {
|
||||
state: hourly.state === 'cancelled' ? 'failed' : hourly.state,
|
||||
startedAt: hourly.startedAt.toISOString(),
|
||||
finishedAt: hourly.finishedAt?.toISOString() ?? null,
|
||||
problems,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TbankSyncRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -156,12 +203,10 @@ export class TbankSyncRepository {
|
||||
const due = now.valueOf() - previous.createdAt.valueOf() >= hourMs;
|
||||
if (previous.state === 'running') {
|
||||
if (previous.processRunId === runId) return null;
|
||||
// A dead process cannot finish its attempt; interrupt it, but its
|
||||
// start stays the basis of the next term.
|
||||
await tx.tbankSyncWork.update({
|
||||
where: { userId_revision_scopeKey: key },
|
||||
data: { state: 'cancelled', errorCode: null },
|
||||
});
|
||||
// A dead process cannot finish its attempt; close it from its
|
||||
// actual step results, but its start stays the basis of the next
|
||||
// term.
|
||||
await this.closeHourly(tx, previous, 'INTERRUPTED', []);
|
||||
if (!due) return null;
|
||||
} else if (!due) return null;
|
||||
}
|
||||
@@ -185,28 +230,146 @@ export class TbankSyncRepository {
|
||||
});
|
||||
}
|
||||
|
||||
settleHourly(
|
||||
attempt: TbankSyncAttempt,
|
||||
result: 'failed' | 'partial' | 'succeeded',
|
||||
private classifyStep(
|
||||
child: HourlyChild,
|
||||
row: StepRow | undefined,
|
||||
recorded: string | undefined,
|
||||
cause: HourlyCause | null,
|
||||
): StepOutcome {
|
||||
if (row && row.attemptId === recorded && row.state === 'complete') {
|
||||
return { success: true };
|
||||
}
|
||||
const stale = !row || (recorded !== undefined && row.attemptId !== recorded);
|
||||
let errorCode: null | string = null;
|
||||
if (!stale && row.state === 'failed') errorCode = row.errorCode;
|
||||
else if (!stale && cause === HOURLY_CAUSE_INTERRUPTED) {
|
||||
errorCode = HOURLY_CAUSE_INTERRUPTED;
|
||||
} else if (!stale) errorCode = HOURLY_CAUSE_DEADLINE;
|
||||
return {
|
||||
problem: { accountId: child.accountId, errorCode, section: child.section },
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
private deriveResult(
|
||||
cause: HourlyCause | null,
|
||||
sections: HourlySection[],
|
||||
successes: number,
|
||||
): 'failed' | 'partial' | 'succeeded' {
|
||||
if (!cause && !sections.length) return 'succeeded';
|
||||
return successes ? 'partial' : 'failed';
|
||||
}
|
||||
|
||||
// The outcome is derived from the durable state of the dispatched steps,
|
||||
// never from an in-memory error list that an exception path can empty.
|
||||
// `cause` marks a close before normal completion and caps the result at
|
||||
// `partial`; `dispatchProblems` records required sections that never got a
|
||||
// durable step row because their dispatch failed.
|
||||
private async closeHourly(
|
||||
tx: Prisma.TransactionClient,
|
||||
work: {
|
||||
attemptId: string;
|
||||
id: string;
|
||||
progress: Prisma.JsonValue;
|
||||
revision: string;
|
||||
userId: string;
|
||||
},
|
||||
cause: HourlyCause | null,
|
||||
dispatchProblems: HourlySection[],
|
||||
) {
|
||||
return this.prisma.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
id: attempt.id,
|
||||
attemptId: attempt.attemptId,
|
||||
userId: attempt.userId,
|
||||
revision: attempt.revision,
|
||||
state: 'running',
|
||||
},
|
||||
const progress = work.progress as HourlyProgress;
|
||||
const children = progress.children ?? [];
|
||||
const rows = children.length
|
||||
? await tx.tbankSyncWork.findMany({
|
||||
where: {
|
||||
userId: work.userId,
|
||||
id: { in: children.map((child) => child.id) },
|
||||
},
|
||||
select: { attemptId: true, errorCode: true, id: true, state: true },
|
||||
})
|
||||
: [];
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
const outcomes = children.map((child) =>
|
||||
this.classifyStep(
|
||||
child,
|
||||
byId.get(child.id),
|
||||
progress.attempts?.[child.id],
|
||||
cause,
|
||||
),
|
||||
);
|
||||
const sections = outcomes
|
||||
.flatMap((outcome) => (outcome.problem ? [outcome.problem] : []))
|
||||
.concat(dispatchProblems);
|
||||
const successes = outcomes.filter((outcome) => outcome.success).length;
|
||||
const result = this.deriveResult(cause, sections, successes);
|
||||
return tx.tbankSyncWork.updateMany({
|
||||
where: { id: work.id, attemptId: work.attemptId, state: 'running' },
|
||||
data: {
|
||||
state: result === 'failed' ? 'failed' : 'complete',
|
||||
errorCode:
|
||||
sections.find((section) => section.errorCode)?.errorCode ?? null,
|
||||
progress: { result, sections },
|
||||
cause ??
|
||||
sections.find((section) => section.errorCode)?.errorCode ??
|
||||
null,
|
||||
progress: {
|
||||
attempts: progress.attempts ?? {},
|
||||
children,
|
||||
result,
|
||||
sections,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async settleHourly(
|
||||
attempt: TbankSyncAttempt,
|
||||
cause: HourlyCause | null = null,
|
||||
dispatchProblems: HourlySection[] = [],
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const work = await tx.tbankSyncWork.findFirst({ where: attempt });
|
||||
if (!work || work.state !== 'running') return { count: 0 };
|
||||
return this.closeHourly(tx, work, cause, dispatchProblems);
|
||||
});
|
||||
}
|
||||
|
||||
// Records a dispatched step on the attempt before it is awaited, so a lost
|
||||
// walk can still derive its outcome from durable rows.
|
||||
async recordHourlyChild(attempt: TbankSyncAttempt, child: HourlyChild) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const work = await tx.tbankSyncWork.findFirst({
|
||||
where: { ...attempt, state: 'running' },
|
||||
select: { progress: true },
|
||||
});
|
||||
if (!work) return false;
|
||||
const progress = work.progress as HourlyProgress;
|
||||
const row = await tx.tbankSyncWork.findFirst({
|
||||
where: { userId: attempt.userId, id: child.id },
|
||||
select: { attemptId: true },
|
||||
});
|
||||
const updated = await tx.tbankSyncWork.updateMany({
|
||||
where: { ...attempt, state: 'running' },
|
||||
data: {
|
||||
progress: {
|
||||
attempts: {
|
||||
...progress.attempts,
|
||||
...(row ? { [child.id]: row.attemptId } : {}),
|
||||
},
|
||||
children: [...(progress.children ?? []), child],
|
||||
sections: progress.sections ?? [],
|
||||
},
|
||||
},
|
||||
});
|
||||
return Boolean(updated.count);
|
||||
});
|
||||
}
|
||||
|
||||
runningHourly(userId: string) {
|
||||
return this.prisma.tbankSyncWork.findMany({
|
||||
where: { userId, scopeKey: 'hourly', state: 'running' },
|
||||
select: { attemptId: true, id: true, processRunId: true, revision: true },
|
||||
});
|
||||
}
|
||||
|
||||
async hourly(userId: string) {
|
||||
const connection = await this.prisma.tbankConnection.findFirst({
|
||||
where: { userId },
|
||||
|
||||
@@ -4,11 +4,12 @@ import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||
import { UpcomingEventsController } from './upcoming-events.controller';
|
||||
import { UpcomingEventsService } from './upcoming-events.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule],
|
||||
imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule, TbankSyncRepositoryModule],
|
||||
controllers: [UpcomingEventsController],
|
||||
providers: [UpcomingEventsService],
|
||||
exports: [UpcomingEventsService],
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
type PortfolioPositionDto,
|
||||
PortfolioService,
|
||||
} from '../portfolio/portfolio.api';
|
||||
import {
|
||||
hourlyAttempt,
|
||||
TbankSyncRepository,
|
||||
} from '../tbank-sync/tbank-sync.repository';
|
||||
import { bondEvent, dividendEvent } from './upcoming-event.mapper';
|
||||
import {
|
||||
UpcomingEventDto,
|
||||
@@ -43,6 +47,7 @@ export class UpcomingEventsService {
|
||||
private readonly sources: CatalogEnrichmentService,
|
||||
private readonly forecasts: ForecastService,
|
||||
private readonly metadata: InstrumentMetadataService,
|
||||
private readonly sync: TbankSyncRepository,
|
||||
) {}
|
||||
|
||||
// Read endpoints stay local; callers without a saved portfolio receive unknown.
|
||||
@@ -54,6 +59,7 @@ export class UpcomingEventsService {
|
||||
const window = dateWindow(query);
|
||||
const connection = await this.access.local(userId, accountId);
|
||||
const portfolio = await this.portfolios.read(userId, accountId);
|
||||
const hourly = await this.sync.hourly(userId);
|
||||
const events: UpcomingEventDto[] = [];
|
||||
let ready = portfolio.data !== null;
|
||||
const positions = portfolio.data?.positions.filter(supported) ?? [];
|
||||
@@ -61,7 +67,7 @@ export class UpcomingEventsService {
|
||||
userId,
|
||||
positions,
|
||||
window,
|
||||
query.sync === 'observe',
|
||||
true,
|
||||
);
|
||||
for (const position of positions) {
|
||||
const generic = metadata.get(metadataKey(position))?.data;
|
||||
@@ -98,7 +104,7 @@ export class UpcomingEventsService {
|
||||
source.input.uid === uid && source.input.kind === 'detail',
|
||||
)?.instrument || generic;
|
||||
const ticker = position.ticker || instrument?.ticker;
|
||||
if (!calendar || calendar.meta.availability !== 'complete' || !ticker) {
|
||||
if (!calendar || calendar.meta.completeness !== 'complete' || !ticker) {
|
||||
ready = false;
|
||||
continue;
|
||||
}
|
||||
@@ -157,6 +163,11 @@ export class UpcomingEventsService {
|
||||
membershipRevision: connection.membershipRevision,
|
||||
scopeKey: `events/${JSON.stringify([accountId, window.from, window.to])}`,
|
||||
portfolio: portfolio.meta,
|
||||
lastAttempt: hourlyAttempt(
|
||||
hourly,
|
||||
'events',
|
||||
accountId === 'all' ? undefined : accountId,
|
||||
),
|
||||
sources,
|
||||
candidate,
|
||||
});
|
||||
@@ -168,8 +179,12 @@ export class UpcomingEventsService {
|
||||
|
||||
// Triggers online refreshes of the account's event sources and returns the
|
||||
// launched work ids for the hourly walk to observe.
|
||||
async refresh(userId: string, accountId: string): Promise<string[]> {
|
||||
const window = dateWindow({});
|
||||
async refresh(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
query: UpcomingEventsQueryDto = {},
|
||||
): Promise<string[]> {
|
||||
const window = dateWindow(query);
|
||||
await this.access.local(userId, accountId);
|
||||
const portfolio = await this.portfolios.read(userId, accountId);
|
||||
const positions = portfolio.data?.positions.filter(supported) ?? [];
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('All accounts HTTP boundary', () => {
|
||||
expect(response.body).not.toHaveProperty('token');
|
||||
expect(response.body).not.toHaveProperty('generation');
|
||||
expect(response.body).toMatchObject({
|
||||
meta: { availability: 'complete' },
|
||||
meta: { availability: 'available', completeness: 'complete' },
|
||||
...(path === 'portfolio' ? { data: { positions: [] } } : {}),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import { configureApp } from '../src/main';
|
||||
import { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service';
|
||||
import { StockIncomeService } from '../src/modules/investments/instruments/stocks/application/stock-income.service';
|
||||
import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service';
|
||||
import { StockScreenerService } from '../src/modules/investments/instruments/stocks/application/stock-screener.service';
|
||||
|
||||
const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
|
||||
describe('Stock HTTP contract', () => {
|
||||
@@ -25,6 +26,7 @@ describe('Stock HTTP contract', () => {
|
||||
search: jest.fn(),
|
||||
candles: jest.fn(),
|
||||
};
|
||||
const screener = { page: jest.fn(), search: jest.fn() };
|
||||
const token = new JwtService().sign(
|
||||
{ sub: 'stock-reader', type: 'access' },
|
||||
{ secret: process.env.JWT_SECRET },
|
||||
@@ -37,6 +39,8 @@ describe('Stock HTTP contract', () => {
|
||||
.useValue(stocks)
|
||||
.overrideProvider(StockIncomeService)
|
||||
.useValue(stocks)
|
||||
.overrideProvider(StockScreenerService)
|
||||
.useValue(screener)
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
@@ -51,12 +55,55 @@ describe('Stock HTTP contract', () => {
|
||||
});
|
||||
stocks.search.mockResolvedValue([]);
|
||||
stocks.getByTicker.mockResolvedValue({ instrumentUid: uid, slug: 'SBERP' });
|
||||
screener.search.mockResolvedValue({ asOf: '2026-10-10T12:00:00.000Z', hasNext: false, items: [], page: 1, searchId: 'search-id' });
|
||||
});
|
||||
afterAll(() => app.close());
|
||||
it('requires authentication before invoking the use case', async () => {
|
||||
await request(app.getHttpServer()).get(`/api/v1/stocks/${uid}`).expect(401);
|
||||
expect(stocks.get).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
[() => request(app.getHttpServer()).post('/api/v1/stocks/screener/search'), 'post'],
|
||||
[() => request(app.getHttpServer()).get('/api/v1/stocks/screener/search/search-id?page=1'), 'get'],
|
||||
])('requires authentication for screener route', async (send) => {
|
||||
await send().expect(401);
|
||||
expect(screener.search).not.toHaveBeenCalled();
|
||||
expect(screener.page).not.toHaveBeenCalled();
|
||||
});
|
||||
it('validates screener input and returns raw snapshot pages', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/stocks/screener/search')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ query: ' ', unknown: 'value' })
|
||||
.expect(400);
|
||||
expect(screener.search).not.toHaveBeenCalled();
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/stocks/screener/search')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ query: '' })
|
||||
.expect(200);
|
||||
expect(response.body).toEqual({ asOf: '2026-10-10T12:00:00.000Z', hasNext: false, items: [], page: 1, searchId: 'search-id' });
|
||||
expect(screener.search).toHaveBeenCalledWith('stock-reader', {});
|
||||
});
|
||||
it.each([
|
||||
'/api/v1/stocks/screener/search/search-id',
|
||||
'/api/v1/stocks/screener/search/search-id?page=0',
|
||||
'/api/v1/stocks/screener/search/search-id?page=one',
|
||||
])('validates required screener page on %s', async (path) => {
|
||||
await request(app.getHttpServer())
|
||||
.get(path)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(400);
|
||||
expect(screener.page).not.toHaveBeenCalled();
|
||||
});
|
||||
it('reads a screener snapshot page with the verified identity', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/stocks/screener/search/search-id?page=2')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
expect(screener.page).toHaveBeenCalledWith('stock-reader', 'search-id', 2);
|
||||
});
|
||||
it.each([
|
||||
'/stocks/not-a-uid',
|
||||
'/stocks/by-ticker/%20',
|
||||
|
||||
@@ -240,7 +240,8 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
data: { fetchedAt: new Date(0) },
|
||||
});
|
||||
const stale = await reopened.read('owner', input, true);
|
||||
expect(stale.meta.availability).toBe('complete');
|
||||
expect(stale.meta.availability).toBe('available');
|
||||
expect(stale.meta.completeness).toBe('complete');
|
||||
expect(stale.meta.freshness).toBe('stale');
|
||||
expect(stale.meta.coverage?.missingRanges).toEqual([]);
|
||||
expect(stale.meta.coverage?.completeRanges.length).toBeGreaterThan(0);
|
||||
@@ -302,7 +303,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
expect(await prisma.tbankCandleCoverage.findMany()).toEqual(before);
|
||||
expect(await prisma.tbankCandle.count()).toBe(0);
|
||||
const read = await service.read('owner', input, true);
|
||||
expect(read.meta.sync.state).toBe('failed');
|
||||
expect(read.meta.lastAttempt.state).toBe('failed');
|
||||
expect(read.data?.candles).toEqual([]);
|
||||
});
|
||||
it.each([
|
||||
|
||||
@@ -85,14 +85,15 @@ describe('shared catalog discovery (SQLite)', () => {
|
||||
it('persists normalized query identity and confirmed empty separately from unknown, across users and reopen', async () => {
|
||||
const cold = await service.read('owner', { ...query, query: ' sber ' });
|
||||
expect(cold.data).toBeNull();
|
||||
expect(cold.meta.availability).toBe('missing');
|
||||
expect(cold.meta.availability).toBe('initial-loading');
|
||||
await settled();
|
||||
await prisma.$disconnect();
|
||||
service = createService();
|
||||
const decrypt = jest.spyOn(tokens, 'decrypt');
|
||||
const warm = await service.read('other', query);
|
||||
expect(warm.data).toEqual([instrument]);
|
||||
expect(warm.meta.availability).toBe('complete');
|
||||
expect(warm.meta.availability).toBe('available');
|
||||
expect(warm.meta.completeness).toBe('complete');
|
||||
expect(warm.meta.sync.id).toBeNull();
|
||||
expect(decrypt).not.toHaveBeenCalled();
|
||||
expect(upstream.findShares).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { setImmediate as immediate } from 'node:timers/promises';
|
||||
|
||||
import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
|
||||
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
|
||||
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
|
||||
import { CatalogDiscoveryRepository } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository';
|
||||
import { BrokerageOperationsSyncService } from '../src/modules/investments/operations/application/brokerage-operations-sync.service';
|
||||
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
import { TbankSyncRepository } from '../src/modules/investments/tbank-sync/tbank-sync.repository';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
type HourlyProgressView = {
|
||||
result?: string;
|
||||
sections: { accountId?: string; errorCode: null | string; section: string }[];
|
||||
};
|
||||
|
||||
// Real SQLite proves reservation, outcome derivation, recovery and fences;
|
||||
// the step owners are controlled so each failure mode can be injected.
|
||||
describe('hourly attempt lifecycle (SQLite)', () => {
|
||||
const prisma = new PrismaService(
|
||||
new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }),
|
||||
);
|
||||
const tokens = new TbankTokenService(
|
||||
new ConfigService({
|
||||
TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY,
|
||||
}) as never,
|
||||
);
|
||||
const repository = new TbankSyncRepository(prisma);
|
||||
const access = { local: jest.fn() };
|
||||
const membership = { request: jest.fn() };
|
||||
const portfolio = { request: jest.fn() };
|
||||
const operations = { request: jest.fn() };
|
||||
const events = { refresh: jest.fn(), get: jest.fn() };
|
||||
const logger = { event: jest.fn() };
|
||||
const createScheduler = () =>
|
||||
new TbankHourlyScheduler(
|
||||
prisma,
|
||||
repository,
|
||||
access as never,
|
||||
membership as never,
|
||||
portfolio as never,
|
||||
operations as never,
|
||||
events as never,
|
||||
logger as never,
|
||||
);
|
||||
const USER = 'hourly-owner';
|
||||
const REVISION = 'hourly-revision';
|
||||
|
||||
function child(
|
||||
state: 'complete' | 'failed' | 'running',
|
||||
errorCode: null | string,
|
||||
scopeKey: string,
|
||||
) {
|
||||
return prisma.tbankSyncWork.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId: USER,
|
||||
revision: REVISION,
|
||||
scopeKey: `test:${scopeKey}:${randomUUID()}`,
|
||||
attemptId: randomUUID(),
|
||||
processRunId: 'child-process',
|
||||
priority: 0,
|
||||
intent: {},
|
||||
progress: {},
|
||||
state,
|
||||
errorCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function hourlyRow() {
|
||||
return prisma.tbankSyncWork.findFirst({
|
||||
where: { userId: USER, scopeKey: 'hourly' },
|
||||
});
|
||||
}
|
||||
|
||||
async function pump(turns = 20) {
|
||||
for (let i = 0; i < turns; i++) {
|
||||
await immediate();
|
||||
await jest.advanceTimersByTimeAsync(50);
|
||||
}
|
||||
}
|
||||
|
||||
async function runWalk(scheduler: TbankHourlyScheduler, advanceMs = 50) {
|
||||
await scheduler.tick();
|
||||
for (let i = 0; i < 400; i++) {
|
||||
await immediate();
|
||||
await jest.advanceTimersByTimeAsync(advanceMs);
|
||||
const row = await hourlyRow();
|
||||
if (row && row.state !== 'running') return row;
|
||||
}
|
||||
throw new Error('hourly attempt did not settle');
|
||||
}
|
||||
|
||||
// Sequential per-step bounds: advance past each bound until the attempt
|
||||
// closes. Covers walks whose awaits hang before any durable step row exists.
|
||||
async function runBoundedWalk(scheduler: TbankHourlyScheduler) {
|
||||
await scheduler.tick();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await pump(5);
|
||||
await jest.advanceTimersByTimeAsync(16 * 60 * 1000);
|
||||
const row = await hourlyRow();
|
||||
if (row && row.state !== 'running') return row;
|
||||
}
|
||||
throw new Error('hourly attempt did not settle');
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await prisma.$connect();
|
||||
await prisma.user.create({
|
||||
data: { id: USER, email: 'hourly@example.invalid', passwordHash: 'x' },
|
||||
});
|
||||
});
|
||||
afterAll(async () => prisma.$disconnect());
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
await prisma.tbankSyncWork.deleteMany();
|
||||
await prisma.tbankConnection.deleteMany();
|
||||
await prisma.tbankConnection.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
revision: REVISION,
|
||||
encryptedToken: tokens.encrypt('synthetic'),
|
||||
connectedAt: new Date(),
|
||||
},
|
||||
});
|
||||
jest.useFakeTimers({
|
||||
doNotFake: ['queueMicrotask', 'setImmediate', 'nextTick'],
|
||||
});
|
||||
access.local.mockResolvedValue({
|
||||
revision: REVISION,
|
||||
accounts: [{ accountId: 'a' }, { accountId: 'b' }],
|
||||
});
|
||||
membership.request.mockImplementation(async () => {
|
||||
const row = await child('complete', null, 'membership');
|
||||
return { id: row.id, done: Promise.resolve() };
|
||||
});
|
||||
portfolio.request.mockImplementation(
|
||||
async (_userId: string, _revision: string, accountId: string) => {
|
||||
const row = await child('complete', null, `portfolio:${accountId}`);
|
||||
return { id: row.id, done: Promise.resolve() };
|
||||
},
|
||||
);
|
||||
operations.request.mockImplementation(async () => {
|
||||
const row = await child('complete', null, 'operations');
|
||||
return row.id;
|
||||
});
|
||||
events.refresh.mockResolvedValue([]);
|
||||
events.get.mockResolvedValue(undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('settles the first walk succeeded only when every step row completed', async () => {
|
||||
const row = await runWalk(createScheduler());
|
||||
expect(row.state).toBe('complete');
|
||||
const progress = row.progress as HourlyProgressView;
|
||||
expect(progress.result).toBe('succeeded');
|
||||
expect(progress.sections).toEqual([]);
|
||||
const read = await repository.hourly(USER);
|
||||
expect(read?.state).toBe('succeeded');
|
||||
});
|
||||
|
||||
it('settles partial and keeps successful steps when one account fails', async () => {
|
||||
portfolio.request.mockImplementation(
|
||||
async (_userId: string, _revision: string, accountId: string) => {
|
||||
const failed = accountId === 'b';
|
||||
const row = await child(
|
||||
failed ? 'failed' : 'complete',
|
||||
failed ? 'TBANK_UNAVAILABLE' : null,
|
||||
`portfolio:${accountId}`,
|
||||
);
|
||||
return { id: row.id, done: Promise.resolve() };
|
||||
},
|
||||
);
|
||||
const row = await runWalk(createScheduler());
|
||||
const progress = row.progress as HourlyProgressView;
|
||||
expect(progress.result).toBe('partial');
|
||||
expect(progress.sections).toHaveLength(1);
|
||||
expect(progress.sections[0].section).toBe('portfolio');
|
||||
expect(progress.sections[0].accountId).toBe('b');
|
||||
expect(progress.sections[0].errorCode).toBe('TBANK_UNAVAILABLE');
|
||||
});
|
||||
|
||||
it('settles failed when the membership step fails before any update', async () => {
|
||||
membership.request.mockImplementation(async () => {
|
||||
const row = await child('failed', 'INVALID_TBANK_TOKEN', 'membership');
|
||||
return { id: row.id, done: Promise.resolve() };
|
||||
});
|
||||
const row = await runWalk(createScheduler());
|
||||
const progress = row.progress as HourlyProgressView;
|
||||
expect(progress.result).toBe('failed');
|
||||
expect(row.errorCode).toBe('INVALID_TBANK_TOKEN');
|
||||
expect(progress.sections[0].section).toBe('membership');
|
||||
});
|
||||
|
||||
it('never succeeds when no required step succeeded', async () => {
|
||||
membership.request.mockImplementation(async () => {
|
||||
const row = await child('failed', 'TBANK_UNAVAILABLE', 'membership');
|
||||
return { id: row.id, done: Promise.resolve() };
|
||||
});
|
||||
portfolio.request.mockImplementation(
|
||||
async (_userId: string, _revision: string, accountId: string) => {
|
||||
const row = await child(
|
||||
'failed',
|
||||
'TBANK_UNAVAILABLE',
|
||||
`portfolio:${accountId}`,
|
||||
);
|
||||
return { id: row.id, done: Promise.resolve() };
|
||||
},
|
||||
);
|
||||
operations.request.mockRejectedValue(new Error('db'));
|
||||
events.refresh.mockRejectedValue(new Error('db'));
|
||||
const row = await runWalk(createScheduler());
|
||||
const progress = row.progress as HourlyProgressView;
|
||||
expect(progress.result).toBe('failed');
|
||||
expect(progress.result).not.toBe('succeeded');
|
||||
assert.ok(progress.sections.length > 0);
|
||||
});
|
||||
|
||||
it('caps the outcome at partial when the coordinator itself breaks', async () => {
|
||||
access.local.mockRejectedValueOnce(new Error('db'));
|
||||
const row = await runWalk(createScheduler());
|
||||
const progress = row.progress as HourlyProgressView;
|
||||
expect(progress.result).toBe('partial');
|
||||
expect(row.errorCode).toBe('INTERRUPTED');
|
||||
});
|
||||
|
||||
it('records a section whose dispatch failed as a problem', async () => {
|
||||
events.refresh.mockRejectedValue(new Error('db'));
|
||||
const row = await runWalk(createScheduler());
|
||||
const progress = row.progress as HourlyProgressView;
|
||||
expect(progress.result).toBe('partial');
|
||||
const eventsSection = progress.sections.find(
|
||||
(entry) => entry.section === 'events',
|
||||
);
|
||||
assert.ok(eventsSection);
|
||||
expect(eventsSection.errorCode).toBe('TBANK_UNAVAILABLE');
|
||||
expect(row.errorCode).toBe('TBANK_UNAVAILABLE');
|
||||
});
|
||||
|
||||
it('closes a stuck step as DEADLINE and keeps its row running', async () => {
|
||||
operations.request.mockImplementation(async () => {
|
||||
const row = await child('running', null, 'operations');
|
||||
return row.id;
|
||||
});
|
||||
const scheduler = createScheduler();
|
||||
await scheduler.tick();
|
||||
await pump();
|
||||
await jest.advanceTimersByTimeAsync(16 * 60 * 1000);
|
||||
await pump(20);
|
||||
const row = await hourlyRow();
|
||||
assert.ok(row);
|
||||
expect(row.state).not.toBe('running');
|
||||
const progress = row.progress as HourlyProgressView;
|
||||
expect(progress.result).toBe('partial');
|
||||
assert.ok(
|
||||
progress.sections.some(
|
||||
(entry) =>
|
||||
entry.section === 'operations' && entry.errorCode === 'DEADLINE',
|
||||
),
|
||||
);
|
||||
const stuck = await prisma.tbankSyncWork.findFirst({
|
||||
where: { userId: USER, state: 'running' },
|
||||
});
|
||||
assert.ok(stuck, 'the wait bound does not cancel the upstream call');
|
||||
});
|
||||
|
||||
it('settles the attempt when a portfolio dispatch never resolves', async () => {
|
||||
portfolio.request.mockImplementation(() => new Promise(() => undefined));
|
||||
const row = await runBoundedWalk(createScheduler());
|
||||
const progress = row.progress as HourlyProgressView;
|
||||
expect(progress.result).toBe('partial');
|
||||
const problems = progress.sections.filter(
|
||||
(entry) => entry.section === 'portfolio',
|
||||
);
|
||||
expect(problems).toHaveLength(2);
|
||||
expect(problems[0].errorCode).toBe('TBANK_UNAVAILABLE');
|
||||
});
|
||||
|
||||
it('settles the attempt when the membership dispatch never resolves', async () => {
|
||||
membership.request.mockImplementation(() => new Promise(() => undefined));
|
||||
const row = await runBoundedWalk(createScheduler());
|
||||
const progress = row.progress as HourlyProgressView;
|
||||
expect(progress.result).toBe('partial');
|
||||
expect(progress.sections).toHaveLength(1);
|
||||
expect(progress.sections[0].section).toBe('membership');
|
||||
expect(progress.sections[0].errorCode).toBe('TBANK_UNAVAILABLE');
|
||||
});
|
||||
|
||||
it('bounds a membership step promise that never resolves', async () => {
|
||||
membership.request.mockImplementation(async () => {
|
||||
const row = await child('running', null, 'membership');
|
||||
return { id: row.id, done: new Promise(() => undefined) };
|
||||
});
|
||||
const row = await runBoundedWalk(createScheduler());
|
||||
const progress = row.progress as HourlyProgressView;
|
||||
expect(progress.result).toBe('partial');
|
||||
const membershipSection = progress.sections.find(
|
||||
(entry) => entry.section === 'membership',
|
||||
);
|
||||
expect(membershipSection?.errorCode).toBe('DEADLINE');
|
||||
});
|
||||
|
||||
it('settles the attempt when a post-wait refresh never resolves', async () => {
|
||||
events.get.mockImplementation(() => new Promise(() => undefined));
|
||||
const row = await runBoundedWalk(createScheduler());
|
||||
const progress = row.progress as HourlyProgressView;
|
||||
expect(progress.result).toBe('succeeded');
|
||||
expect(progress.sections).toEqual([]);
|
||||
});
|
||||
|
||||
it('recovers a lost settle and unblocks the next attempt at its term', async () => {
|
||||
const settleSpy = jest
|
||||
.spyOn(repository, 'settleHourly')
|
||||
.mockRejectedValueOnce(new Error('db'));
|
||||
const scheduler = createScheduler();
|
||||
await scheduler.tick();
|
||||
for (let i = 0; i < 40 && settleSpy.mock.calls.length === 0; i++) {
|
||||
await immediate();
|
||||
await jest.advanceTimersByTimeAsync(50);
|
||||
}
|
||||
expect(settleSpy.mock.calls).toHaveLength(1);
|
||||
const open = await hourlyRow();
|
||||
expect(open?.state).toBe('running');
|
||||
await pump(20);
|
||||
await scheduler.tick();
|
||||
await pump(20);
|
||||
const closed = await hourlyRow();
|
||||
expect(closed?.state).toBe('complete');
|
||||
expect((closed?.progress as HourlyProgressView).result).toBe('partial');
|
||||
expect(closed?.errorCode).toBe('INTERRUPTED');
|
||||
await jest.advanceTimersByTimeAsync(61 * 60 * 1000);
|
||||
await scheduler.tick();
|
||||
await pump(30);
|
||||
const next = await hourlyRow();
|
||||
expect(next?.attemptId).not.toBe(closed?.attemptId);
|
||||
expect(next?.state).not.toBe('running');
|
||||
expect((next?.progress as HourlyProgressView).result).toBe('succeeded');
|
||||
});
|
||||
|
||||
it('closes an abandoned foreign-process attempt and keeps the normal term', async () => {
|
||||
const stuck = await child('complete', null, 'membership');
|
||||
const deadAttemptId = randomUUID();
|
||||
await prisma.tbankSyncWork.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId: USER,
|
||||
revision: REVISION,
|
||||
scopeKey: 'hourly',
|
||||
attemptId: deadAttemptId,
|
||||
processRunId: 'dead-process',
|
||||
priority: 0,
|
||||
intent: {},
|
||||
state: 'running',
|
||||
progress: {
|
||||
children: [{ id: stuck.id, section: 'membership' }],
|
||||
attempts: { [stuck.id]: stuck.attemptId },
|
||||
sections: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const scheduler = createScheduler();
|
||||
await runWalk(scheduler);
|
||||
const closed = await hourlyRow();
|
||||
expect(closed?.attemptId).toBe(deadAttemptId);
|
||||
expect(closed?.state).not.toBe('running');
|
||||
expect((closed?.progress as HourlyProgressView).result).toBe('partial');
|
||||
expect(closed?.errorCode).toBe('INTERRUPTED');
|
||||
const hourlyCount = await prisma.tbankSyncWork.count({
|
||||
where: { userId: USER, scopeKey: 'hourly' },
|
||||
});
|
||||
expect(hourlyCount).toBe(1);
|
||||
await jest.advanceTimersByTimeAsync(61 * 60 * 1000);
|
||||
await scheduler.tick();
|
||||
await pump(30);
|
||||
const next = await hourlyRow();
|
||||
expect(next?.attemptId).not.toBe(deadAttemptId);
|
||||
expect(next?.state).not.toBe('running');
|
||||
});
|
||||
|
||||
it('does not overlap a running attempt with a second walk', async () => {
|
||||
operations.request.mockImplementation(async () => {
|
||||
const row = await child('running', null, 'operations');
|
||||
return row.id;
|
||||
});
|
||||
const scheduler = createScheduler();
|
||||
await scheduler.tick();
|
||||
await pump();
|
||||
expect((await hourlyRow())?.state).toBe('running');
|
||||
await scheduler.tick();
|
||||
await pump();
|
||||
const hourlyCount = await prisma.tbankSyncWork.count({
|
||||
where: { userId: USER, scopeKey: 'hourly' },
|
||||
});
|
||||
expect(hourlyCount).toBe(1);
|
||||
expect((await hourlyRow())?.state).toBe('running');
|
||||
await jest.advanceTimersByTimeAsync(16 * 60 * 1000);
|
||||
await pump(20);
|
||||
expect((await hourlyRow())?.state).not.toBe('running');
|
||||
});
|
||||
|
||||
it('does not start a new attempt before the next hourly term', async () => {
|
||||
const scheduler = createScheduler();
|
||||
const row = await runWalk(scheduler);
|
||||
const firstAttemptId = row.attemptId;
|
||||
expect(row.state).toBe('complete');
|
||||
await scheduler.tick();
|
||||
expect((await hourlyRow())?.attemptId).toBe(firstAttemptId);
|
||||
await jest.advanceTimersByTimeAsync(59 * 60 * 1000);
|
||||
await scheduler.tick();
|
||||
expect((await hourlyRow())?.attemptId).toBe(firstAttemptId);
|
||||
await jest.advanceTimersByTimeAsync(2 * 60 * 1000);
|
||||
await scheduler.tick();
|
||||
await pump(30);
|
||||
expect((await hourlyRow())?.attemptId).not.toBe(firstAttemptId);
|
||||
});
|
||||
|
||||
it('counts a late step result before the attempt outcome is recorded', async () => {
|
||||
const late: { id: string }[] = [];
|
||||
operations.request.mockImplementation(async () => {
|
||||
const row = await child('running', null, 'operations');
|
||||
late.push(row);
|
||||
return row.id;
|
||||
});
|
||||
const scheduler = createScheduler();
|
||||
await scheduler.tick();
|
||||
await pump();
|
||||
assert.ok(late.length > 0);
|
||||
for (const row of late) {
|
||||
await prisma.tbankSyncWork.update({
|
||||
where: { id: row.id },
|
||||
data: { state: 'complete' },
|
||||
});
|
||||
}
|
||||
let settled = false;
|
||||
for (let i = 0; i < 100 && !settled; i++) {
|
||||
await immediate();
|
||||
await jest.advanceTimersByTimeAsync(200);
|
||||
const current = await hourlyRow();
|
||||
settled = Boolean(current && current.state !== 'running');
|
||||
}
|
||||
const row = await hourlyRow();
|
||||
expect(row).not.toBeNull();
|
||||
expect(row?.state).not.toBe('running');
|
||||
expect((row?.progress as HourlyProgressView).result).toBe('succeeded');
|
||||
});
|
||||
|
||||
it('treats a step superseded by a successor as stale and keeps CAS fencing', async () => {
|
||||
const stale = await child('complete', null, 'membership');
|
||||
const attemptId = randomUUID();
|
||||
const id = randomUUID();
|
||||
await prisma.tbankSyncWork.create({
|
||||
data: {
|
||||
id,
|
||||
userId: USER,
|
||||
revision: REVISION,
|
||||
scopeKey: 'hourly',
|
||||
attemptId,
|
||||
processRunId: 'dead-process',
|
||||
priority: 0,
|
||||
intent: {},
|
||||
state: 'running',
|
||||
progress: {
|
||||
children: [{ id: stale.id, section: 'membership' }],
|
||||
attempts: { [stale.id]: randomUUID() },
|
||||
sections: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const foreign = await repository.settleHourly(
|
||||
{ userId: USER, revision: REVISION, id, attemptId: randomUUID() },
|
||||
'INTERRUPTED',
|
||||
);
|
||||
expect(foreign.count).toBe(0);
|
||||
expect((await hourlyRow())?.state).toBe('running');
|
||||
const closed = await repository.settleHourly(
|
||||
{ userId: USER, revision: REVISION, id, attemptId },
|
||||
'INTERRUPTED',
|
||||
);
|
||||
expect(closed.count).toBe(1);
|
||||
const progress = (await hourlyRow())?.progress as HourlyProgressView;
|
||||
expect(progress.result).toBe('failed');
|
||||
expect(progress.sections[0].errorCode).toBeNull();
|
||||
const late = await repository.settleHourly(
|
||||
{ userId: USER, revision: REVISION, id, attemptId },
|
||||
'INTERRUPTED',
|
||||
);
|
||||
expect(late.count).toBe(0);
|
||||
});
|
||||
|
||||
it('creates no reservations after a controlled shutdown', async () => {
|
||||
const scheduler = createScheduler();
|
||||
scheduler.onModuleInit();
|
||||
scheduler.onModuleDestroy();
|
||||
await jest.advanceTimersByTimeAsync(2 * 60 * 60 * 1000);
|
||||
await pump(5);
|
||||
expect(await prisma.tbankSyncWork.count({ where: { userId: USER } })).toBe(0);
|
||||
});
|
||||
|
||||
it('closes the operations step durably when its finalizer cannot record', async () => {
|
||||
await prisma.tbankConnection.update({
|
||||
where: { userId: USER },
|
||||
data: { membershipAt: new Date() },
|
||||
});
|
||||
await prisma.tbankAccount.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
revision: REVISION,
|
||||
accountId: 'a',
|
||||
type: 'BROKER',
|
||||
name: 'Test',
|
||||
upstreamOrder: 0,
|
||||
},
|
||||
});
|
||||
const accessReal = new TbankAccessService(prisma, tokens);
|
||||
const operationsRepository = new BrokerageOperationsRepository(prisma);
|
||||
const sync = new BrokerageOperationsSyncService(
|
||||
operationsRepository,
|
||||
accessReal,
|
||||
{ getOperationsByCursor: jest.fn() } as never,
|
||||
logger as never,
|
||||
);
|
||||
jest
|
||||
.spyOn(operationsRepository, 'pending')
|
||||
.mockRejectedValueOnce(new Error('db'));
|
||||
jest
|
||||
.spyOn(operationsRepository, 'fail')
|
||||
.mockRejectedValueOnce(new Error('db'));
|
||||
const id = await sync.request(
|
||||
USER,
|
||||
[
|
||||
{
|
||||
accountId: 'a',
|
||||
from: new Date(Date.UTC(2026, 0, 1)),
|
||||
to: new Date(),
|
||||
},
|
||||
] as never,
|
||||
true,
|
||||
'screen',
|
||||
REVISION,
|
||||
);
|
||||
assert.ok(id);
|
||||
await pump(40);
|
||||
const row = await prisma.tbankSyncWork.findFirst({
|
||||
where: { userId: USER, id },
|
||||
});
|
||||
expect(row?.state).toBe('failed');
|
||||
});
|
||||
|
||||
it('finishes open catalog steps durably when publication fails mid-batch', async () => {
|
||||
const accessReal = new TbankAccessService(prisma, tokens);
|
||||
const catalogRepository = new CatalogDiscoveryRepository(prisma);
|
||||
const enrichment = new CatalogEnrichmentService(
|
||||
catalogRepository,
|
||||
accessReal,
|
||||
{ getLastPrices: jest.fn().mockResolvedValue([]) } as never,
|
||||
logger as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
jest
|
||||
.spyOn(catalogRepository, 'publishSource')
|
||||
.mockRejectedValue(new Error('db'));
|
||||
await enrichment.requestMany(USER, REVISION, 'share', ['uid'], false, [
|
||||
'last-price',
|
||||
]);
|
||||
await pump(40);
|
||||
const rows = await prisma.tbankSyncWork.findMany({
|
||||
where: { userId: USER, scopeKey: { not: 'hourly' } },
|
||||
});
|
||||
assert.ok(rows.length > 0);
|
||||
for (const row of rows) expect(row.state).toBe('failed');
|
||||
});
|
||||
});
|
||||
@@ -128,7 +128,8 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const result = await read();
|
||||
assert(result.data);
|
||||
expect(result.meta.availability).toBe('complete');
|
||||
expect(result.meta.availability).toBe('available');
|
||||
expect(result.meta.completeness).toBe('complete');
|
||||
expect(result.data.totalsByCurrency.map((row) => row.total)).toEqual([
|
||||
{ currency: 'RUB', value: '15.000000002' },
|
||||
{ currency: 'USD', value: '6' },
|
||||
@@ -146,10 +147,9 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
'previous',
|
||||
'year',
|
||||
]);
|
||||
expect(
|
||||
result.meta.dependencies.find(({ role }) => role === 'previous')
|
||||
?.availability,
|
||||
).toBe('missing');
|
||||
const previous = result.meta.dependencies.find(({ role }) => role === 'previous');
|
||||
expect(previous?.availability).toBe('temporarily-unavailable');
|
||||
expect(previous?.completeness).toBe('incomplete');
|
||||
expect(result.data.calculatedAt).toBe('2025-02-01T00:00:00.000Z');
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
@@ -161,7 +161,9 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
where: { accountId: 'b' },
|
||||
});
|
||||
expect((await read()).data).toBeNull();
|
||||
expect((await read()).meta.availability).toBe('partial');
|
||||
const incomplete = await read();
|
||||
expect(incomplete.meta.availability).toBe('temporarily-unavailable');
|
||||
expect(incomplete.meta.completeness).toBe('incomplete');
|
||||
expect((await read('a')).data).not.toBeNull();
|
||||
expect((await read('a', 'from=2025-03-01&to=2025-03-31')).data).toBeNull();
|
||||
await coverage(
|
||||
@@ -241,7 +243,7 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
await repo.fail(fence, false);
|
||||
const after = await read();
|
||||
expect(after.data).toEqual(before.data);
|
||||
expect(after.meta.sync.state).toBe('failed');
|
||||
expect(after.meta.lastAttempt.state).toBe('failed');
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
it('retains current-month observed completeness and does not renew source timestamps', async () => {
|
||||
@@ -261,7 +263,8 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
from: `${today.slice(0, 7)}-01`,
|
||||
to: today,
|
||||
});
|
||||
expect(result.meta.availability).toBe('complete');
|
||||
expect(result.meta.availability).toBe('available');
|
||||
expect(result.meta.completeness).toBe('complete');
|
||||
expect(result.meta.coverage?.asOf).toBe(asOf.toISOString());
|
||||
expect(result.data?.calculatedAt).toBe('2025-02-01T00:00:00.000Z');
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
|
||||
@@ -118,7 +118,8 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
`limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
|
||||
).expect(200);
|
||||
const result = response.body as BrokerageOperationsResourceDto;
|
||||
expect(result.meta.availability).toBe('complete');
|
||||
expect(result.meta.availability).toBe('available');
|
||||
expect(result.meta.completeness).toBe('complete');
|
||||
assert(result.data);
|
||||
ids.push(
|
||||
...result.data.items.map((item) => `${item.account?.id}/${item.id}`),
|
||||
@@ -251,15 +252,18 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
.set('Authorization', auth());
|
||||
const first = (await page().expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(first.meta.availability).toBe('complete');
|
||||
expect(first.meta.availability).toBe('available');
|
||||
expect(first.meta.completeness).toBe('complete');
|
||||
assert(first.data?.nextCursor);
|
||||
const second = (await page(first.data.nextCursor).expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(second.meta.availability).toBe('complete');
|
||||
expect(second.meta.availability).toBe('available');
|
||||
expect(second.meta.completeness).toBe('complete');
|
||||
expect(second.meta.coverage?.missingRanges).toEqual([]);
|
||||
const observed = (await page(first.data.nextCursor, true).expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(observed.meta.availability).toBe('complete');
|
||||
expect(observed.meta.availability).toBe('available');
|
||||
expect(observed.meta.completeness).toBe('complete');
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
@@ -384,12 +388,14 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
'a',
|
||||
).expect(200)
|
||||
).body as BrokerageOperationsResourceDto;
|
||||
expect(partial.meta.availability).toBe('partial');
|
||||
expect(partial.data?.hasNext).toBe(false);
|
||||
expect(partial.meta.availability).toBe('initial-loading');
|
||||
expect(partial.meta.completeness).toBe('incomplete');
|
||||
expect(partial.data).toBeNull();
|
||||
expect(partial.meta.coverage?.missingRanges.length).toBeGreaterThan(0);
|
||||
const whole = (await get('sync=observe', 'a').expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(whole.meta.availability).toBe('complete');
|
||||
expect(whole.meta.availability).toBe('available');
|
||||
expect(whole.meta.completeness).toBe('complete');
|
||||
expect(whole.data?.items).toEqual([]);
|
||||
expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -419,7 +425,8 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
const cold = (await get('', 'a').expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(cold.data).toBeNull();
|
||||
expect(cold.meta.availability).toBe('missing');
|
||||
expect(cold.meta.availability).toBe('initial-loading');
|
||||
expect(cold.meta.completeness).toBe('incomplete');
|
||||
await get('sync=observe', 'a').expect(200);
|
||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||
// eslint-disable-next-line sonarjs/no-extra-arguments -- The typed callback is assigned the promise resolver before invocation.
|
||||
@@ -434,7 +441,8 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
const result = (await get('sync=observe', 'a').expect(200))
|
||||
.body as BrokerageOperationsResourceDto;
|
||||
expect(result.data?.items).toEqual([]);
|
||||
expect(result.meta.availability).toBe('complete');
|
||||
expect(result.meta.availability).toBe('available');
|
||||
expect(result.meta.completeness).toBe('complete');
|
||||
await get('sync=observe', 'a').expect(200);
|
||||
expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThanOrEqual(count);
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
const cold = await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(200);
|
||||
expect((cold.body as PortfolioResourceDto).data).toBeNull();
|
||||
expect((cold.body as PortfolioResourceDto).meta.availability).toBe(
|
||||
'missing',
|
||||
'temporarily-unavailable',
|
||||
);
|
||||
await get('brokerage-accounts?sync=observe').expect(200);
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
@@ -321,7 +321,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
'brokerage-accounts/account/portfolio?sync=observe',
|
||||
).expect(200);
|
||||
expect((failed.body as PortfolioResourceDto).data).toEqual(previous);
|
||||
expect((failed.body as PortfolioResourceDto).meta.sync.state).toBe('idle');
|
||||
expect((failed.body as PortfolioResourceDto).meta.lastAttempt.state).toBe('none');
|
||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -375,7 +375,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
where: { id: 'abandoned-membership' },
|
||||
data: { state: 'complete' },
|
||||
});
|
||||
expect((await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'missing' } });
|
||||
expect((await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
|
||||
expect(await prisma.tbankPortfolio.count()).toBe(0);
|
||||
expect((await get('brokerage-accounts?sync=observe')).body).toMatchObject({
|
||||
data: [{ id: 'account', portfolioSummary: null }],
|
||||
@@ -423,13 +423,14 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
{ currency: 'RUB', value: '21.1' },
|
||||
]);
|
||||
expect(summaries.data?.[1].portfolioSummary).toBeNull();
|
||||
expect(summaries.meta.availability).toBe('partial');
|
||||
expect(summaries.meta.availability).toBe('available');
|
||||
expect(summaries.meta.completeness).toBe('complete');
|
||||
expect(
|
||||
(await get('brokerage-accounts/all/portfolio?sync=observe')).body,
|
||||
).toMatchObject({ data: null, meta: { availability: 'partial' } });
|
||||
).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
|
||||
expect(
|
||||
(await get(`stocks/${uid}/position?sync=observe`).expect(200)).body,
|
||||
).toMatchObject({ data: null, meta: { availability: 'partial' } });
|
||||
).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } });
|
||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
|
||||
await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } });
|
||||
await prisma.tbankSyncWork.updateMany({
|
||||
@@ -444,7 +445,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
);
|
||||
expect(position.body).toMatchObject({
|
||||
data: { quantity: '2', accounts: [{ account: { id: 'account' } }] },
|
||||
meta: { availability: 'complete' },
|
||||
meta: { availability: 'available', completeness: 'complete' },
|
||||
});
|
||||
await prisma.$disconnect();
|
||||
expect(
|
||||
@@ -495,7 +496,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
const empty = await get('brokerage-accounts?sync=observe').expect(200);
|
||||
expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]);
|
||||
expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe(
|
||||
'complete',
|
||||
'available',
|
||||
);
|
||||
await put().expect(200);
|
||||
await seedMembership();
|
||||
|
||||
@@ -21,6 +21,7 @@ import { configureApp } from '../src/main';
|
||||
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
|
||||
import { ForecastRepository } from '../src/modules/investments/portfolio/infrastructure/forecast.repository';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
import { UpcomingEventsService } from '../src/modules/investments/upcoming-events/upcoming-events.service';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
const shareUid = '00000000-0000-4000-8000-000000000001';
|
||||
@@ -81,6 +82,10 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
`brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`,
|
||||
userId,
|
||||
);
|
||||
const refresh = async (range = window, accountId = 'account') => {
|
||||
await app.get(UpcomingEventsService).refresh('owner', accountId, range);
|
||||
await settled();
|
||||
};
|
||||
async function settled() {
|
||||
for (let i = 0; i < 500; i++) {
|
||||
await delay(5);
|
||||
@@ -235,39 +240,29 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
});
|
||||
|
||||
it('returns unknown immediately, keeps observe passive and shares confirmed empty calendars across users', async () => {
|
||||
let release: (value: unknown[]) => void = () => {};
|
||||
upstream.getDividends.mockReturnValueOnce(
|
||||
new Promise<unknown[]>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
((await events().expect(200)).body as UpcomingEventsResourceDto).data,
|
||||
).toBeNull();
|
||||
expect(upstream.getDividends).not.toHaveBeenCalled();
|
||||
const cold = (await events(false).expect(200))
|
||||
const cold = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(cold.data).toBeNull();
|
||||
for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) {
|
||||
await delay(5);
|
||||
}
|
||||
expect(await prisma.tbankForecast.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps an ordinary GET local and does not start catalog work', async () => {
|
||||
await portfolio([position()]);
|
||||
jest.clearAllMocks();
|
||||
|
||||
await events().expect(200);
|
||||
release([]);
|
||||
await settled();
|
||||
const warm = (await events().expect(200)).body as UpcomingEventsResourceDto;
|
||||
expect(warm.meta.availability).toBe('complete');
|
||||
expect(warm.data?.events).toEqual([]);
|
||||
const other = (await events(false, 'account', 'other').expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(other.data?.events).toEqual([]);
|
||||
expect(upstream.getDividends).toHaveBeenCalledTimes(1);
|
||||
expect(await prisma.tbankForecast.count()).toBe(2);
|
||||
expect(JSON.stringify(warm)).not.toContain('owner-revision');
|
||||
|
||||
expect(upstream.getDividends).not.toHaveBeenCalled();
|
||||
expect(upstream.getInstrumentByUid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves source identity, inclusive dates, exact currencies, cancellation and offers in account/all scopes', async () => {
|
||||
await portfolio([position(), position(bondUid, '3', 'BOND')]);
|
||||
await events(false).expect(200);
|
||||
await app.get(UpcomingEventsService).refresh('owner', 'account', window);
|
||||
await settled();
|
||||
const single = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
@@ -297,7 +292,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
});
|
||||
|
||||
it('keeps the previous full forecast and timestamps after a new holding calendar fails, including reopen', async () => {
|
||||
await events(false).expect(200);
|
||||
await refresh();
|
||||
await settled();
|
||||
const before = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
@@ -307,7 +302,8 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
? Promise.reject(new Error('new calendar unavailable'))
|
||||
: Promise.resolve([]),
|
||||
);
|
||||
const pending = (await events(false).expect(200))
|
||||
await refresh();
|
||||
const pending = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(pending.data).toEqual(before.data);
|
||||
await settled();
|
||||
@@ -316,14 +312,14 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(after.data).toEqual(before.data);
|
||||
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
|
||||
expect(after.meta.sync.state).toBe('failed');
|
||||
expect(after.meta.dependencies?.some((dependency) => dependency.lastAttempt.state === 'failed')).toBe(true);
|
||||
const count = upstream.getDividends.mock.calls.length;
|
||||
await events().expect(200);
|
||||
expect(upstream.getDividends).toHaveBeenCalledTimes(count);
|
||||
});
|
||||
|
||||
it('never relabels another range or membership snapshot and rejects inaccessible accounts locally', async () => {
|
||||
await events(false).expect(200);
|
||||
await refresh();
|
||||
await settled();
|
||||
await events().expect(200);
|
||||
const newRange = (
|
||||
@@ -346,7 +342,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
});
|
||||
|
||||
it('refuses a calculated snapshot if its portfolio changes before atomic publication', async () => {
|
||||
await events(false).expect(200);
|
||||
await refresh();
|
||||
await settled();
|
||||
const before = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
@@ -531,7 +527,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
it('keeps optional metadata failure separate from calendar completeness and uses portfolio ticker', async () => {
|
||||
upstream.getBond.mockRejectedValue(new Error('metadata unavailable'));
|
||||
await portfolio([position(bondUid, '3', 'BOND')]);
|
||||
await events(false).expect(200);
|
||||
await refresh();
|
||||
await settled();
|
||||
const result = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
@@ -566,7 +562,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
new MoexClient().bondEvents(ticker),
|
||||
);
|
||||
try {
|
||||
await events(false).expect(200);
|
||||
await refresh();
|
||||
await settled();
|
||||
const before = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
@@ -590,7 +586,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(after.data).toEqual(before.data);
|
||||
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
|
||||
expect(after.meta.sync.state).toBe('failed');
|
||||
expect(after.meta.dependencies?.some((dependency) => dependency.lastAttempt.state === 'failed')).toBe(true);
|
||||
expect(
|
||||
await prisma.tbankInstrumentSource.findFirstOrThrow({
|
||||
where: { kind: 'bond-events' },
|
||||
@@ -605,9 +601,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
|
||||
it('defaults to a 30-day window', async () => {
|
||||
await portfolio([position(bondUid, '3', 'BOND')]);
|
||||
await events(false).expect(200);
|
||||
await settled();
|
||||
await get('brokerage-accounts/account/events?from=2099-02-01').expect(200);
|
||||
await refresh();
|
||||
await settled();
|
||||
const result = (
|
||||
await get(
|
||||
@@ -622,6 +616,30 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters a saved source window without changing its calculated timestamp', async () => {
|
||||
await portfolio([position(bondUid, '3', 'BOND')]);
|
||||
await app.get(UpcomingEventsService).refresh('owner', 'account', {
|
||||
from: '2099-02-01',
|
||||
to: '2099-02-28',
|
||||
});
|
||||
await settled();
|
||||
const full = (await events().expect(200)).body as UpcomingEventsResourceDto;
|
||||
const calculatedAt = full.data?.calculatedAt;
|
||||
jest.clearAllMocks();
|
||||
|
||||
const narrow = (await events(false, 'account', 'owner', {
|
||||
from: '2099-02-01',
|
||||
to: '2099-02-10',
|
||||
}).expect(200)).body as UpcomingEventsResourceDto;
|
||||
|
||||
expect(narrow.data?.calculatedAt).toBe(calculatedAt);
|
||||
expect(narrow.data?.events.map((event) => event.date)).toEqual([
|
||||
'2099-02-01',
|
||||
]);
|
||||
expect(upstream.getDividends).not.toHaveBeenCalled();
|
||||
expect(moex.bondEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('discards a late calendar from a replaced connection and prevents access to old private forecasts', async () => {
|
||||
let release: (value: unknown[]) => void = () => {};
|
||||
upstream.getDividends.mockReturnValueOnce(
|
||||
@@ -629,7 +647,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
await events(false).expect(200);
|
||||
await app.get(UpcomingEventsService).refresh('owner', 'account', window);
|
||||
for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) {
|
||||
await delay(5);
|
||||
}
|
||||
@@ -664,8 +682,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
|
||||
it('uses UTC today for an end-only range and includes the entire leap day', async () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
await get('brokerage-accounts/account/events?to=2096-02-29').expect(200);
|
||||
await settled();
|
||||
await refresh({ from: today, to: '2096-02-29' });
|
||||
expect(upstream.getDividends).toHaveBeenCalledWith(
|
||||
'synthetic-owner',
|
||||
shareUid,
|
||||
@@ -685,8 +702,8 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
},
|
||||
]);
|
||||
const range = { from: '2096-02-29', to: '2096-02-29' };
|
||||
await events(false, 'account', 'owner', range).expect(200);
|
||||
await settled();
|
||||
await refresh(range);
|
||||
jest.clearAllMocks();
|
||||
const result = (await events(true, 'account', 'owner', range).expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(result.data?.events).toHaveLength(1);
|
||||
@@ -694,5 +711,6 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
date: '2096-02-29',
|
||||
projectedAmount: { currency: 'RUB', value: '14' },
|
||||
});
|
||||
expect(upstream.getDividends).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user