97 Commits
Author SHA1 Message Date
ksv741 4a17f1fffd fix(performance): ignore non-RUB cash flows (WealthLedger/main#55)
Backend verify / commits (pull_request) Failing after 1m39s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
2026-11-11 09:00:25 +03:00
ksv741 b4c83e7462 fix: rebuild RUB-only snapshots on next tick (WealthLedger/main#55) 2026-11-11 08:51:34 +03:00
ksv741 3b55b611b5 refactor!: support RUB only (WealthLedger/main#55)
Filter non-RUB data at ingest, collapse per-currency collections into single RUB Money, drop currency from TbankPortfolioValuePoint, remove NON_RUB_ACTIVITY and screener currency filter.

BREAKING CHANGE: per-currency API fields replaced with single RUB Money; stock screener currency param removed.
2026-11-11 08:22:16 +03:00
codex-bot fee61537b6 feat(performance): investment performance vs Moscow Exchange index (WealthLedger/main#54) (#200)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m7s
Backend verify / verify (push) Failing after 7m19s
2026-11-08 16:45:29 +03:00
ksv741 84433ee207 chore(release): 2.4.1
Backend verify / commits (pull_request) Successful in 1m13s
Backend verify / security (pull_request) Successful in 57s
Backend verify / verify (pull_request) Successful in 5m16s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 59s
Backend verify / verify (push) Successful in 5m11s
Backend image / publish (push) Successful in 8m22s
2026-11-06 07:43:01 +03:00
codex-bot d792baaf59 refactor: retire legacy tbank-sync status and sync=observe (WealthLedger/backend#196)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m7s
Backend verify / verify (push) Failing after 7m39s
2026-11-05 22:33:14 +03:00
codex-bot 83b515a453 refactor: expose upcoming-events public api and guard its boundary (WealthLedger/backend#163) (#195)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m3s
Backend verify / verify (push) Failing after 6m52s
2026-11-04 23:52:27 +03:00
codex-bot 07cfd08df9 refactor: replace deep imports with native node aliases (WealthLedger/backend#165) (#193)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m6s
Backend verify / verify (push) Failing after 6m44s
2026-11-04 23:22:46 +03:00
ksv741 e3ecb8c15e refactor: enforce repository boundary in application (WealthLedger/backend#171)
Backend verify / commits (pull_request) Successful in 1m29s
Backend verify / security (pull_request) Successful in 1m5s
Backend verify / verify (pull_request) Successful in 6m13s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m11s
Backend verify / verify (push) Failing after 7m34s
2026-11-04 22:29:01 +03:00
ksv741 61c55256e7 refactor: move auth persistence into repository (WealthLedger/backend#171) 2026-11-04 22:29:01 +03:00
ksv741 cea77f6d56 refactor: route bank operation queries through repository (WealthLedger/backend#171) 2026-11-04 22:29:01 +03:00
ksv741 b3d4801cfe refactor: move bank card queries into repository (WealthLedger/backend#171) 2026-11-04 22:28:32 +03:00
ksv741 b3ca11d141 refactor: route bank card reads through repository (WealthLedger/backend#171) 2026-11-04 22:28:32 +03:00
ksv741 efac7201e0 refactor: move category persistence into bank repository (WealthLedger/backend#171) 2026-11-04 22:28:32 +03:00
codex-bot 85a71967fa refactor: share prisma json codec and check progress shape (WealthLedger/backend#170) (#191)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m5s
Backend verify / verify (push) Failing after 7m21s
2026-11-04 22:28:11 +03:00
codex-bot cd78b43783 refactor: split brokerage operations repository and cursor codec (WealthLedger/backend#167) (#190)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m11s
Backend verify / verify (push) Failing after 7m17s
2026-11-04 22:01:02 +03:00
codex-bot deda7ffc2c refactor: single bank operation projection and domain rules (WealthLedger/backend#168) (#189)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m10s
Backend verify / verify (push) Failing after 7m50s
2026-11-04 21:37:59 +03:00
codex-bot 5d31b3e6dd refactor: share tbank-sync claim and generation lock (WealthLedger/backend#166) (#188)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m9s
Backend verify / verify (push) Failing after 7m40s
2026-11-04 20:47:50 +03:00
codex-bot c5ccfeadea refactor: extract catalog enrichment source loader (WealthLedger/backend#169) (#186)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m10s
Backend verify / verify (push) Failing after 7m43s
2026-11-04 18:17:09 +03:00
codex-bot 02e0d560c8 refactor(api): describe bank operation create body as oneOf by kind (WealthLedger/backend#172) (#173)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m12s
Backend verify / verify (push) Failing after 7m27s
2026-11-04 17:47:36 +03:00
codex-bot fa0a6e5603 refactor: share sync reservation join rule (WealthLedger/backend#119) (#162)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m10s
Backend verify / verify (push) Failing after 7m39s
2026-11-03 22:53:54 +03:00
codex-bot bade5fae1c chore(deps): set Renovate release age, PR limit and update groups (WealthLedger/main#6) (#160)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m2s
Backend verify / verify (push) Failing after 6m42s
2026-11-03 14:07:40 +03:00
codex-bot 1a5de9bebd ci: drop stale npm audit advisory ignore (WealthLedger/backend#158) (#159)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m7s
Backend verify / verify (push) Failing after 6m59s
2026-11-03 12:04:10 +03:00
codex-bot aeb4647fda fix(events): refresh saved payout calendars once per hourly pass (WealthLedger/backend#53) (#157)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m6s
Backend verify / verify (push) Failing after 6m50s
2026-11-02 23:06:14 +03:00
codex-bot 169bee1da5 fix(portfolio): publish new complete forecast regardless of request window (WealthLedger/backend#55) (#156)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m11s
Backend verify / verify (push) Failing after 7m6s
2026-11-02 22:42:46 +03:00
codex-bot 554410a271 refactor: inject TbankAccessService directly into stock screener (#151) (#155)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m0s
Backend verify / verify (push) Failing after 6m12s
2026-11-02 07:53:11 +03:00
codex-bot a80bbbf1e2 refactor: check persisted json payload shape instead of double casts (#152) (#154)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 59s
Backend verify / verify (push) Failing after 6m3s
2026-11-02 07:31:24 +03:00
ksv741 a18d9dbdc8 chore(release): 2.4.0
Backend verify / commits (pull_request) Successful in 1m28s
Backend verify / security (pull_request) Successful in 1m3s
Backend verify / verify (pull_request) Successful in 5m52s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m7s
Backend verify / verify (push) Successful in 5m30s
Backend image / publish (push) Successful in 8m40s
2026-11-01 19:33:18 +03:00
codex-bot 17432dfee3 feat(portfolio): store and publish instrument sector and issuer (WealthLedger/main#48) (#148)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m4s
Backend verify / verify (push) Failing after 7m15s
2026-11-01 19:13:32 +03:00
codex-bot 5e84e1e5f1 fix: scope income and ledger sync to requested intervals (WealthLedger/backend#57) (#147)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 17s
Backend verify / verify (push) Failing after 5m52s
2026-10-28 21:29:53 +03:00
codex-bot e350146749 test: cover portfolio publish rollback and claim fence (WealthLedger/backend#105) (#146)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 6m11s
2026-10-28 21:10:50 +03:00
codex-bot 99b137ed0b test: replace operations sync delays with explicit barriers (WealthLedger/backend#104) (#145)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 6m12s
2026-10-28 20:41:24 +03:00
codex-bot d37fa2c903 refactor: publish typed problem detail codes (WealthLedger/backend#117) (#144)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m8s
2026-10-28 17:11:41 +03:00
codex-bot 5295945ab2 refactor: expose tbank-sync public api and guard its boundary (WealthLedger/backend#118) (#143)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m28s
2026-10-28 16:40:01 +03:00
codex-bot 22668da3fa refactor: remove tbank-sync operations import cycle (WealthLedger/backend#116) (#142)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m14s
2026-10-28 16:26:49 +03:00
ksv741 2325b63883 fix: reset portfolio value points inflated by double-counted cash (WealthLedger/backend#140)
Backend verify / commits (pull_request) Successful in 27s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 4m33s
2026-10-28 08:00:20 +03:00
ksv741 f2843e30bd fix: count brokerage cash once and skip short-position return percent (WealthLedger/backend#140) 2026-10-28 08:00:20 +03:00
codex-bot 97fc41fa29 feat: extend account list portfolio summary (#138)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 8s
Backend verify / verify (push) Failing after 4m43s
2026-10-27 05:44:07 +03:00
ksv741 3dcd1a69a6 chore: restore main ancestry in develop (WealthLedger/backend#135)
Backend verify / commits (pull_request) Successful in 31s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 5m47s
2026-10-25 10:06:30 +03:00
codex-botandksv741 8503429b46 chore(release): 2.3.1 (#132)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 23s
Backend verify / verify (push) Successful in 6m19s
Backend image / publish (push) Successful in 13m17s
Backend patch #129 for WealthLedger/deploy#12. Verified release SHA `0dff4b43773041a73a725fa0b9d0c665eac0cab2`; fast-forward-only main then tag on exact SHA.

---------

Co-authored-by: Sergey Krylov <ksv741@mail.ru>
Reviewed-on: #132
Co-authored-by: codex-bot <codex-bot@ksv741.ru>
2026-10-24 21:32:57 +03:00
ksv741 0dff4b4377 chore(release): 2.3.1
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / commits (pull_request) Successful in 33s
Backend verify / verify (push) Failing after 6m28s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Successful in 6m13s
2026-10-24 21:00:38 +03:00
ksv741 a250142a86 fix: use available Debian packages in image build (#129)
Backend verify / commits (pull_request) Successful in 38s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 6m18s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m38s
2026-10-24 20:48:55 +03:00
ksv741 0a0e70b273 chore(release): 2.3.0
Backend verify / commits (pull_request) Successful in 35s
Backend verify / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 6m23s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend image / publish (push) Failing after 6m34s
Backend verify / verify (push) Successful in 6m36s
2026-10-24 19:15:04 +03:00
ksv741 449cfcd6c5 chore(deps): update non-major dependencies #11
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 5m31s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 5m51s
2026-10-24 18:20:20 +03:00
codex-bot 966a1f793a feat: log unhandled errors and pretty dev output (WealthLedger/backend#120) (#126)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 5m39s
feat: log unhandled errors and pretty dev output (#120)
2026-10-24 15:06:34 +03:00
codex-bot d1f6969dc7 fix: persist refresh cookie across browser restarts (WealthLedger/backend#123)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 4m58s
2026-10-23 08:34:39 +03:00
ksv741 b20080ac85 chore(release): 2.2.0
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Successful in 13s
Backend verify / verify (pull_request) Successful in 5m38s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend image / publish (push) Successful in 7m58s
Backend verify / verify (push) Successful in 5m40s
2026-10-18 16:25:22 +03:00
codex-bot 32622c1dad test: cover auth token, cookie and expiry boundaries (WealthLedger/backend#61) (#113)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 6m26s
2026-10-18 15:55:05 +03:00
codex-bot 4f485ab0e9 chore: adopt linear git model in backend docs (#111)
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 30s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 4m44s
2026-10-17 14:19:19 +03:00
codex-bot 8a6f60d965 fix: translate permanent invalid-token errors on online entrypoints (WealthLedger/backend#59) (#110)
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 33s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m40s
2026-10-16 23:23:12 +03:00
codex-bot e03fad622b fix: align password policy to 8-72 printable ASCII on register (WealthLedger/backend#60) (#109)
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 32s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m21s
2026-10-16 10:00:47 +03:00
ksv741 774e58ef80 Merge branch 'chore/30-sync-release' into develop
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 28s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 5m38s
2026-10-16 07:38:58 +03:00
codex-bot 702302126b test: await portfolio publication done instead of polling ticks (WealthLedger/backend#63) (#103)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 5m25s
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m39s
2026-10-15 15:40:03 +03:00
codex-bot ec6d9a2639 test: replace metadata sleeps with upstream start barriers (WealthLedger/backend#63) (#102)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 15s
Backend verify / verify (push) Failing after 6m46s
2026-10-15 14:46:09 +03:00
codex-bot 7bc7d8ddec test: replace late portfolio sleep with started and done barriers (WealthLedger/backend#63) (#101)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 6m21s
2026-10-15 12:23:14 +03:00
codex-bot 9e4f9d9cab test: isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend#65) (#100)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m59s
2026-10-15 11:50:47 +03:00
codex-bot a93f2d55e7 chore: make OpenAPI export atomic and add read-only check (WealthLedger/backend#3) (#97)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m4s
2026-10-15 11:24:38 +03:00
codex-bot 96d4de4195 refactor: remove unreachable catalog producer and sync orchestration (WealthLedger/backend#62) (#98)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m47s
2026-10-15 10:46:58 +03:00
codex-bot c789e33c41 chore: drop unused direct source-map-support dependency (WealthLedger/backend#4) (#96)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m22s
2026-10-15 06:15:47 +03:00
codex-bot 2ad3517d87 fix: restore screener OpenAPI constraints (WealthLedger/backend#70) (#95)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m50s
2026-10-15 06:00:39 +03:00
codex-bot 0d7839cf79 fix: separate T-Bank RPC network time from queue wait (WealthLedger/backend#81) (#94)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Successful in 5m20s
2026-10-15 05:41:21 +03:00
codex-bot 6975853656 fix: skip hourly terms overlapped by a long walk (WealthLedger/backend#54)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m56s
2026-10-14 22:43:08 +03:00
codex-bot 14bcb9da5f fix: surface durable hourly attempt in accounts and income meta (WealthLedger/backend#58)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 17s
Backend verify / verify (push) Failing after 7m31s
2026-10-14 18:00:20 +03:00
codex-bot b90cedd580 chore: unify verification preparation (WealthLedger/backend#66)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m44s
2026-10-14 17:01:53 +03:00
codex-bot da2e1d0781 perf: start bond search prices with details (WealthLedger/backend#71) (#89)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 14s
Backend verify / verify (push) Failing after 6m48s
2026-10-14 14:04:07 +03:00
codex-bot bbb5afff84 ci: validate edited PR titles (WealthLedger/backend#74)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m37s
Closes #74
2026-10-14 09:02:53 +03:00
codex-bot 285dd08472 perf: batch bank operation page relations (WealthLedger/backend#68)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m20s
Closes #68
2026-10-14 08:48:37 +03:00
codex-bot 1033d2114e test: slim sqlite runner regression workload (WealthLedger/backend#72)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 5m47s
Closes #72
2026-10-14 08:34:37 +03:00
codex-bot 203cc72cb2 chore: align SQLite driver version (WealthLedger/backend#67)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 4m18s
Closes #67
2026-10-14 08:12:51 +03:00
codex-bot 9b7e5e1215 refactor: extract shared public instrument projection (#69) (#84)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 18s
Backend verify / verify (push) Failing after 6m47s
2026-10-13 21:54:51 +03:00
codex-bot 31b0e15579 feat: unify income payment cards and drop period comparisons (WealthLedger/main#33) (#83)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 7m1s
feat: unify income payment cards and drop period comparisons (WealthLedger/main#33)
2026-10-13 18:16:46 +03:00
ksv741 3d50db7903 ci: validate release PR commits independently (WealthLedger/main#30)
Backend verify / commits (push) Skipped
Backend image / publish (push) Successful in 7m28s
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Successful in 5m3s
Backend verify / commits (pull_request) Successful in 27s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Successful in 5m11s
2026-10-12 23:39:30 +03:00
ksv741 8d5569a3c7 chore(release): 2.1.0
Backend verify / commits (pull_request) Failing after 31s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
2026-10-12 23:19:12 +03:00
ksv741 90486d4237 Merge pull request 'fix: bound hourly step awaits by the per-step wait limit (#56)' (#78) from fix/56-hourly-step-await-bound into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 7m0s
Reviewed-on: #78
2026-10-12 22:25:27 +03:00
ksv741 52fa3a36ba fix: bound hourly step awaits by the per-step wait limit (#56)
Backend verify / commits (pull_request) Successful in 32s
Backend verify / security (pull_request) Successful in 12s
Backend verify / verify (pull_request) Successful in 5m45s
2026-10-12 22:17:53 +03:00
ksv741 b9b76bab91 Merge pull request 'fix: restore terminal hourly lifecycle after persistence failure (#56)' (#77) from fix/56-harden-hourly-sync-lifecycle into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 7m10s
Reviewed-on: #77
Reviewed-by: hermes-agent <hermes@ksv741.ru>
2026-10-12 21:59:27 +03:00
ksv741 fa57a201fd fix: restore terminal hourly lifecycle after persistence failure (#56)
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Successful in 12s
Backend verify / verify (pull_request) Successful in 5m48s
2026-10-12 20:42:19 +03:00
codex-bot 96a442253e Merge pull request 'chore: align backend engineering workflow (WealthLedger/main#27)' (#76) from chore/27-engineering-workflow into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m24s
2026-10-12 11:00:36 +03:00
ksv741 c5248dbefc docs: pin backend workflow reference (WealthLedger/main#27)
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Successful in 5m3s
2026-10-12 10:22:13 +03:00
ksv741 c7d060023b fix: align backend workflow rules (WealthLedger/main#27)
Backend verify / commits (pull_request) Canceled after 0s
Backend verify / security (pull_request) Canceled after 0s
Backend verify / verify (pull_request) Canceled after 0s
2026-10-12 10:20:50 +03:00
ksv741 a31e8d2c4d chore: align backend engineering workflow (WealthLedger/main#27)
Backend verify / commits (pull_request) Successful in 31s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m56s
2026-10-12 09:20:31 +03:00
codex-bot 2f7c15205f Merge pull request 'docs: clarify backend verification policy' (#75) from codex/issue-24-process into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m9s
2026-10-11 23:23:10 +03:00
ksv741 d7895f287e docs: clarify backend verification policy
Backend verify / commits (pull_request) Successful in 34s
Backend verify / security (pull_request) Successful in 16s
Backend verify / verify (pull_request) Successful in 5m15s
2026-10-11 23:06:51 +03:00
codex-bot bcd5cb3dd0 feat: add stock screener
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 5m33s
2026-10-11 11:45:09 +03:00
ksv741 0ad84ff4f0 ci: skip checks on PR metadata edits
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m38s
2026-10-11 11:36:51 +03:00
ksv741 7d10ef8adf feat: add stock screener
Backend verify / commits (pull_request) Successful in 28s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m45s
2026-10-11 11:18:21 +03:00
ksv741 bc631e72de test: align resource availability metadata
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m21s
2026-10-09 23:10:50 +03:00
ksv741 947ffefa9b fix: keep candle retries read-only 2026-10-09 23:10:50 +03:00
ksv741 db375cbee7 test: align all accounts metadata 2026-10-09 23:10:50 +03:00
ksv741 a46a4112cb chore: prune obsolete eslint suppressions 2026-10-09 23:10:50 +03:00
ksv741 966b2a5836 test: align upcoming events with local reads 2026-10-09 23:10:50 +03:00
ksv741 323b83b582 feat: reconcile hourly investment resource states 2026-10-09 23:10:50 +03:00
codex-bot 5d857de7a5 Merge pull request 'feat: add hourly portfolio sync history' (#49) from release/2.0.0 into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m46s
Merge hourly portfolio sync history backend when checks pass
2026-10-09 09:57:20 +03:00
ksv741 2facd8675c test: increase ledger request timeout
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 5m21s
2026-10-09 09:50:59 +03:00
ksv741 4870d49503 test: extend ledger sqlite timeout
Backend verify / commits (pull_request) Successful in 32s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Failing after 4m32s
2026-10-09 09:43:41 +03:00
ksv741 85858a160f fix: unblock backend dependency audit
Backend verify / commits (pull_request) Successful in 36s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Failing after 4m47s
2026-10-09 09:14:28 +03:00
ksv741 0057925888 feat: add hourly portfolio sync history
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Failing after 12s
Backend verify / verify (pull_request) Skipped
2026-10-09 09:04:41 +03:00
230 changed files with 12480 additions and 7648 deletions
+6 -6
View File
@@ -29,9 +29,9 @@ market — котировками, income — выплатами/личными
domain из-за extraction в функцию. Boundary mappers преобразуют внешние records; domain из-за extraction в функцию. Boundary mappers преобразуют внешние records;
чистые расчёты portfolio/banking остаются у domain owner. чистые расчёты portfolio/banking остаются у domain owner.
PrismaService владеет DB lifecycle. Самостоятельные use cases получают данные PrismaService владеет DB lifecycle. Application services/use cases получают
через concrete repositories; существующие связные CRUD/transaction services данные через concrete repositories; запросы и CAS живут в infrastructure
сохраняют прямые queries по [data convention](conventions/data.md). по [data convention](conventions/data.md).
Money — currency + decimal string; расчёты используют точные общие helpers. Money — currency + decimal string; расчёты используют точные общие helpers.
Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и
@@ -201,9 +201,9 @@ Circular dependencies устраняй изменением ownership/compositio
конкретные infrastructure providers, зарегистрированные module. Infrastructure конкретные infrastructure providers, зарегистрированные module. Infrastructure
может использовать application types/input и domain rules; новые interfaces может использовать application types/input и domain rules; новые interfaces
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries infrastructure. Application не строит Prisma queries: use case и связный CRUD
по [Data](conventions/data.md); use case получает данные через repository и сам service обращаются к репозиторию по [Data](conventions/data.md). Entity/interface
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется. на каждую таблицу не требуется.
DTO остаются транспортными runtime classes; существующее использование DTO DTO остаются транспортными runtime classes; существующее использование DTO
на application boundary допустимо по [HTTP API](conventions/http-api.md). на application boundary допустимо по [HTTP API](conventions/http-api.md).
+10 -10
View File
@@ -34,19 +34,19 @@ candidate, не повод дублировать их при новом use cas
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
не становятся domain entities: расчёт получает только нужные ему значения. не становятся domain entities: расчёт получает только нужные ему значения.
Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction Application-код не импортирует PrismaService и не строит Prisma queries.
services (auth, lifecycle подключения, изменения карт/операций/категорий) Репозиторий владеет запросом и CAS-проверкой результата, а application —
сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует правилами и формированием ответа. Типы Prisma допустимы только в type-only
лишь набора pass-through методов. Новая самостоятельная read/write capability импортах для существующей транзакционной границы. Не создавай обёртку
получает repository method; самостоятельный use case не выполняет Prisma queries. на каждый вызов Prisma без текущего потребителя. `BankCardsRepository.write`
`BankCardsRepository.write` сохраняет общую retry/transaction boundary для сохраняет retry/transaction boundary для банковских записей; callback с
существующих banking services. Такой callback с Prisma transaction client — transaction client — локальная техническая граница, не API для domain.
локальная техническая граница этих services, не API для domain или use cases.
Выбирай только нужные данные, сохраняй user/account ownership в запросах и Выбирай только нужные данные, сохраняй user/account ownership в запросах и
application checks. Предварительный lookup не заменяет unique constraint при race. application checks. Предварительный lookup не заменяет unique constraint при race.
Пример: AuthService обрабатывает P2002 и использует conditional `updateMany` Пример: AuthRepository преобразует P2002 в результат регистрации и выполняет
для refresh rotation. Сначала пойми invariant; не копируй его flow в другой use case. conditional `updateMany` для refresh rotation; AuthService сохраняет HTTP
error contract. Сначала пойми invariant; не копируй его flow в другой use case.
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
+35 -13
View File
@@ -7,11 +7,19 @@
checkout. Внешний API задаётся предоставленным публичным контрактом; checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки. исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации. При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу. Выбери lightweight или SDD по Engineering Workflow из AGENTS; OpenSpec lifecycle
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`. начинай после явного решения Change Owner.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из backend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы. Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope ## Change scope
@@ -57,19 +65,33 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение ## Проверки и завершение
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные | Когда | Что проверить |
risk checks из местной testing convention. Targeted tests ускоряют итерацию, | --- | --- |
но не заменяют финальный gate. `lint:fix`/format — edits, не proof. | Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты | Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
чтения и `git diff --check`; application build ради текста не нужен. | Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты | Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
и ограничения. Зелёный lint не заменяет semantic/security review.
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы ## 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. Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit Commits и PR titles — Conventional Commits с parser-recognized Issue reference; допустим
содержит одну связанную задачу. Root другого checkout не управляет этим repo. `spec:`. Каждый commit содержит одну задачу. Исключения release и правила
ссылки — в Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы». обычную работу. PR сливается squash или rebase-merge; merge-коммит не
используется. `main` обновляется только fast-forward из `develop`, поэтому
`origin/main` всегда предок `origin/develop`:
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
Перед релизом прочитай местный README, раздел «Релизы».
+12
View File
@@ -0,0 +1,12 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
+10
View File
@@ -0,0 +1,10 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
+12
View File
@@ -0,0 +1,12 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
+11
View File
@@ -0,0 +1,11 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
+1 -2
View File
@@ -28,9 +28,8 @@ jobs:
docker version docker version
docker buildx version docker buildx version
- run: npm ci - run: npm ci
- run: npx --no-install prisma generate
- run: npm run verify - run: npm run verify
- run: npm run test:e2e - run: node test/sqlite.cjs --all
- name: Login with dedicated Nexus writer - name: Login with dedicated Nexus writer
env: env:
REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }} REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }}
+9 -5
View File
@@ -34,12 +34,17 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: | run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict 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: security:
needs: commits needs: commits
if: >- if: >-
!cancelled() && !cancelled() &&
gitea.event.action != 'edited' &&
(needs.commits.result == 'success' || needs.commits.result == 'skipped') (needs.commits.result == 'success' || needs.commits.result == 'skipped')
timeout-minutes: 15 timeout-minutes: 15
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -67,7 +72,8 @@ jobs:
verify: verify:
needs: [commits, security] needs: [commits, security]
if: >- if: >-
!cancelled() && needs.security.result == 'success' && !cancelled() && gitea.event.action != 'edited' &&
needs.security.result == 'success' &&
gitea.event_name != 'schedule' gitea.event_name != 'schedule'
timeout-minutes: 45 timeout-minutes: 45
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -81,12 +87,10 @@ jobs:
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
- run: npm ci - run: npm ci
- name: Generate Prisma client before typechecks
run: npx --no-install prisma generate
- name: Run all backend gates - name: Run all backend gates
run: npm run verify run: npm run verify
- name: Run isolated SQLite and runtime OpenAPI suites - name: Run isolated SQLite and runtime OpenAPI suites
run: npm run test:e2e run: node test/sqlite.cjs --all
- name: Analyze develop with SonarQube - name: Analyze develop with SonarQube
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop' if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop'
# v2.3.0: same Docker-based scanner used for SonarQube 9.9. # v2.3.0: same Docker-based scanner used for SonarQube 9.9.
+1 -1
View File
@@ -1 +1 @@
24.21.0 24.22.1
+3
View File
@@ -4,6 +4,9 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем [порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения. объединяй 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`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
+165
View File
@@ -1,5 +1,170 @@
# Changelog # Changelog
## [2.4.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.4.0...v2.4.1) (2026-11-06)
### Bug Fixes
* **events:** refresh saved payout calendars once per hourly pass (WealthLedger/backend[#53](https://git.personal.ksv741.ru/WealthLedger/backend/issues/53)) ([#157](https://git.personal.ksv741.ru/WealthLedger/backend/issues/157)) ([aeb4647](https://git.personal.ksv741.ru/WealthLedger/backend/commit/aeb4647fda3806a419ff3578834c34fff97ae3e8))
* **portfolio:** publish new complete forecast regardless of request window (WealthLedger/backend[#55](https://git.personal.ksv741.ru/WealthLedger/backend/issues/55)) ([#156](https://git.personal.ksv741.ru/WealthLedger/backend/issues/156)) ([169bee1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/169bee1da5731a1330e995a4a508da544f791387))
### Refactoring
* **api:** describe bank operation create body as oneOf by kind (WealthLedger/backend[#172](https://git.personal.ksv741.ru/WealthLedger/backend/issues/172)) ([#173](https://git.personal.ksv741.ru/WealthLedger/backend/issues/173)) ([02e0d56](https://git.personal.ksv741.ru/WealthLedger/backend/commit/02e0d560c84606d6372cbac001dc94edc348995e))
* check persisted json payload shape instead of double casts ([#152](https://git.personal.ksv741.ru/WealthLedger/backend/issues/152)) ([#154](https://git.personal.ksv741.ru/WealthLedger/backend/issues/154)) ([a80bbbf](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a80bbbf1e28954812d2d32c6e8f9f67f5a5d42a4))
* enforce repository boundary in application (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([e3ecb8c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e3ecb8c15e61ccd8de9acb4954a38932099eefc3))
* expose upcoming-events public api and guard its boundary (WealthLedger/backend[#163](https://git.personal.ksv741.ru/WealthLedger/backend/issues/163)) ([#195](https://git.personal.ksv741.ru/WealthLedger/backend/issues/195)) ([83b515a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/83b515a4536a6c534bcdec82f9f43f9005a44c35))
* extract catalog enrichment source loader (WealthLedger/backend[#169](https://git.personal.ksv741.ru/WealthLedger/backend/issues/169)) ([#186](https://git.personal.ksv741.ru/WealthLedger/backend/issues/186)) ([c5ccfea](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5ccfeadea7a9235ebdd6171d9b6f7b89ed2e1e7))
* inject TbankAccessService directly into stock screener ([#151](https://git.personal.ksv741.ru/WealthLedger/backend/issues/151)) ([#155](https://git.personal.ksv741.ru/WealthLedger/backend/issues/155)) ([554410a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/554410a2716ce13d7202fd27a621c72734b5e6c8))
* move auth persistence into repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([61c5525](https://git.personal.ksv741.ru/WealthLedger/backend/commit/61c55256e756b9187e1087215d35db3fd30ac148))
* move bank card queries into repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([b3d4801](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b3d4801cfe67ecf301c58c894676b13b8833c4a6))
* move category persistence into bank repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([efac720](https://git.personal.ksv741.ru/WealthLedger/backend/commit/efac7201e06f49b264854566b2e3f73931f3f6cc))
* replace deep imports with native node aliases (WealthLedger/backend[#165](https://git.personal.ksv741.ru/WealthLedger/backend/issues/165)) ([#193](https://git.personal.ksv741.ru/WealthLedger/backend/issues/193)) ([07cfd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/07cfd08df9b13ac3fd13da153b12acf159329cea))
* retire legacy tbank-sync status and sync=observe (WealthLedger/backend[#196](https://git.personal.ksv741.ru/WealthLedger/backend/issues/196)) ([d792baa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d792baaf59a1c204458ec249ee3ce907f3564455))
* route bank card reads through repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([b3ca11d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b3ca11d1410899bb9c6707113ba9fd1f3b30e4bf))
* route bank operation queries through repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([cea77f6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/cea77f6d568809d989462c121f50b4e07e7efca5))
* share prisma json codec and check progress shape (WealthLedger/backend[#170](https://git.personal.ksv741.ru/WealthLedger/backend/issues/170)) ([#191](https://git.personal.ksv741.ru/WealthLedger/backend/issues/191)) ([85a7196](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85a71967fa4e85427a991c973c6528206332cf5e))
* share sync reservation join rule (WealthLedger/backend[#119](https://git.personal.ksv741.ru/WealthLedger/backend/issues/119)) ([#162](https://git.personal.ksv741.ru/WealthLedger/backend/issues/162)) ([fa0a6e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa0a6e56030bb1c0302cd7588d30e7fa91e19c11))
* share tbank-sync claim and generation lock (WealthLedger/backend[#166](https://git.personal.ksv741.ru/WealthLedger/backend/issues/166)) ([#188](https://git.personal.ksv741.ru/WealthLedger/backend/issues/188)) ([5d31b3e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5d31b3e6dd37d1c79b61c43ed820947b5b38aaa1))
* single bank operation projection and domain rules (WealthLedger/backend[#168](https://git.personal.ksv741.ru/WealthLedger/backend/issues/168)) ([#189](https://git.personal.ksv741.ru/WealthLedger/backend/issues/189)) ([deda7ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/deda7ffc2cc8185eba48a18c37272c404a3e9983))
* split brokerage operations repository and cursor codec (WealthLedger/backend[#167](https://git.personal.ksv741.ru/WealthLedger/backend/issues/167)) ([#190](https://git.personal.ksv741.ru/WealthLedger/backend/issues/190)) ([cd78b43](https://git.personal.ksv741.ru/WealthLedger/backend/commit/cd78b43783cb7469e44b9531d165603df258b55e))
### CI
* drop stale npm audit advisory ignore (WealthLedger/backend[#158](https://git.personal.ksv741.ru/WealthLedger/backend/issues/158)) ([#159](https://git.personal.ksv741.ru/WealthLedger/backend/issues/159)) ([1a5de9b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1a5de9bebd060a5ff2ca188ad862c4ec5aa06ffd))
### Maintenance
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/backend/issues/6)) ([#160](https://git.personal.ksv741.ru/WealthLedger/backend/issues/160)) ([bade5fa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bade5fae1c3170b38717b26eda56ca20183f4ec8))
## [2.4.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.1...v2.4.0) (2026-11-01)
### Features
* extend account list portfolio summary ([#138](https://git.personal.ksv741.ru/WealthLedger/backend/issues/138)) ([97fc41f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/97fc41fa295a7a943a27171fa41cdaf286632956))
* **portfolio:** store and publish instrument sector and issuer (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/backend/issues/48)) ([#148](https://git.personal.ksv741.ru/WealthLedger/backend/issues/148)) ([17432df](https://git.personal.ksv741.ru/WealthLedger/backend/commit/17432dfee3d7d06841caea4419a062e2a4bfc82c))
### Bug Fixes
* count brokerage cash once and skip short-position return percent (WealthLedger/backend[#140](https://git.personal.ksv741.ru/WealthLedger/backend/issues/140)) ([f2843e3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/f2843e30bda5442158ec145cc63a5491506bb60f))
* reset portfolio value points inflated by double-counted cash (WealthLedger/backend[#140](https://git.personal.ksv741.ru/WealthLedger/backend/issues/140)) ([2325b63](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2325b638839240bf5d6d4f7e6e1f8eaa82d4d7fd))
* scope income and ledger sync to requested intervals (WealthLedger/backend[#57](https://git.personal.ksv741.ru/WealthLedger/backend/issues/57)) ([#147](https://git.personal.ksv741.ru/WealthLedger/backend/issues/147)) ([5e84e1e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5e84e1e5f1943b5af1992a3a946194b27fc1a1d5))
* use available Debian packages in image build ([#129](https://git.personal.ksv741.ru/WealthLedger/backend/issues/129)) ([a250142](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a250142a86e07ec7047e642afa16492fa4987f9a))
### Refactoring
* expose tbank-sync public api and guard its boundary (WealthLedger/backend[#118](https://git.personal.ksv741.ru/WealthLedger/backend/issues/118)) ([#143](https://git.personal.ksv741.ru/WealthLedger/backend/issues/143)) ([5295945](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5295945ab237a6e94aa64d7bb54bfd04554ca9f9))
* publish typed problem detail codes (WealthLedger/backend[#117](https://git.personal.ksv741.ru/WealthLedger/backend/issues/117)) ([#144](https://git.personal.ksv741.ru/WealthLedger/backend/issues/144)) ([d37fa2c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d37fa2c9034e90e9d22d4022c45fc36c22dd2ab3))
* remove tbank-sync operations import cycle (WealthLedger/backend[#116](https://git.personal.ksv741.ru/WealthLedger/backend/issues/116)) ([#142](https://git.personal.ksv741.ru/WealthLedger/backend/issues/142)) ([22668da](https://git.personal.ksv741.ru/WealthLedger/backend/commit/22668da3fa0666bdd22737913379c99d4cf01993))
### Tests
* cover portfolio publish rollback and claim fence (WealthLedger/backend[#105](https://git.personal.ksv741.ru/WealthLedger/backend/issues/105)) ([#146](https://git.personal.ksv741.ru/WealthLedger/backend/issues/146)) ([e350146](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e350146749293f2eaf8060f7626ff3c1cd8edf2c))
* replace operations sync delays with explicit barriers (WealthLedger/backend[#104](https://git.personal.ksv741.ru/WealthLedger/backend/issues/104)) ([#145](https://git.personal.ksv741.ru/WealthLedger/backend/issues/145)) ([99b137e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/99b137ed0b39ab73b6a28d1a494d2d98bf8fa380))
## [2.3.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.0...v2.3.1) (2026-10-24)
### Bug Fixes
* use available Debian packages in image build ([#129](https://git.personal.ksv741.ru/WealthLedger/backend/issues/129)) ([a250142](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a250142a86e07ec7047e642afa16492fa4987f9a))
## [2.3.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.2.0...v2.3.0) (2026-10-24)
### Features
* log unhandled errors and pretty dev output (WealthLedger/backend[#120](https://git.personal.ksv741.ru/WealthLedger/backend/issues/120)) ([#126](https://git.personal.ksv741.ru/WealthLedger/backend/issues/126)) ([966a1f7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966a1f793aa2087cce3e47750e0017f78315bd64))
### Bug Fixes
* persist refresh cookie across browser restarts (WealthLedger/backend[#123](https://git.personal.ksv741.ru/WealthLedger/backend/issues/123)) ([d1f6969](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d1f6969dc7c22ae3cf7396bccdbf0cea2f61db2e))
### Maintenance
* **deps:** update non-major dependencies [#11](https://git.personal.ksv741.ru/WealthLedger/backend/issues/11) ([449cfcd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/449cfcd6c5a91307012bb3a640c8255ba243cbf5))
## [2.2.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.1.0...v2.2.0) (2026-10-18)
### Features
* unify income payment cards and drop period comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/backend/issues/33)) ([#83](https://git.personal.ksv741.ru/WealthLedger/backend/issues/83)) ([31b0e15](https://git.personal.ksv741.ru/WealthLedger/backend/commit/31b0e1557978019a9293e5fd3efeee319194cddd))
### Bug Fixes
* align password policy to 8-72 printable ASCII on register (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/backend/issues/60)) ([#109](https://git.personal.ksv741.ru/WealthLedger/backend/issues/109)) ([e03fad6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e03fad622bfec0fa7e42d536f65e361d319a1ac9))
* restore screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/backend/issues/70)) ([#95](https://git.personal.ksv741.ru/WealthLedger/backend/issues/95)) ([2ad3517](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2ad3517d87bffa8af9285d72fd6f78b96b3eed9f))
* separate T-Bank RPC network time from queue wait (WealthLedger/backend[#81](https://git.personal.ksv741.ru/WealthLedger/backend/issues/81)) ([#94](https://git.personal.ksv741.ru/WealthLedger/backend/issues/94)) ([0d7839c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0d7839cf79669768bba55f420ed43304545ac8d9))
* skip hourly terms overlapped by a long walk (WealthLedger/backend[#54](https://git.personal.ksv741.ru/WealthLedger/backend/issues/54)) ([6975853](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6975853656698b61781ca056f35c0d68e47ea32a))
* surface durable hourly attempt in accounts and income meta (WealthLedger/backend[#58](https://git.personal.ksv741.ru/WealthLedger/backend/issues/58)) ([14bcb9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14bcb9da5ff081710d672d140175d348a03a2fe5))
* translate permanent invalid-token errors on online entrypoints (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/backend/issues/59)) ([#110](https://git.personal.ksv741.ru/WealthLedger/backend/issues/110)) ([8a6f60d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8a6f60d9651fc7c474dd9b3690314505e18c4fa0))
### Performance
* batch bank operation page relations (WealthLedger/backend[#68](https://git.personal.ksv741.ru/WealthLedger/backend/issues/68)) ([285dd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/285dd08472d9acb66fab8d46d511d9ce607b110f))
* start bond search prices with details (WealthLedger/backend[#71](https://git.personal.ksv741.ru/WealthLedger/backend/issues/71)) ([#89](https://git.personal.ksv741.ru/WealthLedger/backend/issues/89)) ([da2e1d0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/da2e1d0781d23f4ab1c38e7bf3cad2e254d026d5))
### Refactoring
* extract shared public instrument projection ([#69](https://git.personal.ksv741.ru/WealthLedger/backend/issues/69)) ([#84](https://git.personal.ksv741.ru/WealthLedger/backend/issues/84)) ([9b7e5e1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b7e5e1215b7c1c5320effb6ba5b2c2a8b0cd604))
* remove unreachable catalog producer and sync orchestration (WealthLedger/backend[#62](https://git.personal.ksv741.ru/WealthLedger/backend/issues/62)) ([#98](https://git.personal.ksv741.ru/WealthLedger/backend/issues/98)) ([96d4de4](https://git.personal.ksv741.ru/WealthLedger/backend/commit/96d4de4195b105d64ac6ffaf8d3d8aab4142ece4))
### CI
* validate edited PR titles (WealthLedger/backend[#74](https://git.personal.ksv741.ru/WealthLedger/backend/issues/74)) ([bbb5aff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bbb5afff842720374feecfcd74eea83283e0b2ae))
### Tests
* await portfolio publication done instead of polling ticks (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#103](https://git.personal.ksv741.ru/WealthLedger/backend/issues/103)) ([7023021](https://git.personal.ksv741.ru/WealthLedger/backend/commit/702302126b81335959be2e66b4b5ad38e5806b70))
* cover auth token, cookie and expiry boundaries (WealthLedger/backend[#61](https://git.personal.ksv741.ru/WealthLedger/backend/issues/61)) ([#113](https://git.personal.ksv741.ru/WealthLedger/backend/issues/113)) ([32622c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/32622c1dad9649842d8cb78d8e964da33e5df459))
* isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend[#65](https://git.personal.ksv741.ru/WealthLedger/backend/issues/65)) ([#100](https://git.personal.ksv741.ru/WealthLedger/backend/issues/100)) ([9e4f9d9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9e4f9d9cab12aa714449b99280894a521c3514b6))
* replace late portfolio sleep with started and done barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#101](https://git.personal.ksv741.ru/WealthLedger/backend/issues/101)) ([7bc7d8d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7bc7d8ddecc6cb9f387d00be68f61eb99d57d6fc))
* replace metadata sleeps with upstream start barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#102](https://git.personal.ksv741.ru/WealthLedger/backend/issues/102)) ([ec6d9a2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/ec6d9a2639f1845674bf7ce5fc357f2f7b7f5dfb))
* slim sqlite runner regression workload (WealthLedger/backend[#72](https://git.personal.ksv741.ru/WealthLedger/backend/issues/72)) ([1033d21](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1033d2114e99232a90c5d5c321749a9c6e3fbff0))
### Maintenance
* adopt linear git model in backend docs ([#111](https://git.personal.ksv741.ru/WealthLedger/backend/issues/111)) ([4f485ab](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4f485ab0e9f0f9d52ae446e51966525473948b0e))
* align SQLite driver version (WealthLedger/backend[#67](https://git.personal.ksv741.ru/WealthLedger/backend/issues/67)) ([203cc72](https://git.personal.ksv741.ru/WealthLedger/backend/commit/203cc72cb2d1aac34f39c5ba528820b511666f17))
* drop unused direct source-map-support dependency (WealthLedger/backend[#4](https://git.personal.ksv741.ru/WealthLedger/backend/issues/4)) ([#96](https://git.personal.ksv741.ru/WealthLedger/backend/issues/96)) ([c789e33](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c789e33c41281f2fc3de8851f21ae897a81e2b5d))
* make OpenAPI export atomic and add read-only check (WealthLedger/backend[#3](https://git.personal.ksv741.ru/WealthLedger/backend/issues/3)) ([#97](https://git.personal.ksv741.ru/WealthLedger/backend/issues/97)) ([a93f2d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a93f2d55e7a31515ed1cc056cd57101f2b315405))
* unify verification preparation (WealthLedger/backend[#66](https://git.personal.ksv741.ru/WealthLedger/backend/issues/66)) ([b90cedd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b90cedd580321fb560a4ad0d9f9f5ba03303248e))
## [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) ## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES ### ⚠ BREAKING CHANGES
+6 -5
View File
@@ -1,9 +1,9 @@
FROM node:24.21.0-bookworm-slim AS base FROM node:24.22.1-bookworm-slim AS base
WORKDIR /app WORKDIR /app
FROM base AS dependencies FROM base AS dependencies
# Native SQLite/bcrypt builds happen only here; compilers stay out of runtime. # Native SQLite/bcrypt builds happen only here; compilers stay out of runtime.
RUN apt-get update && apt-get install -y --no-install-recommends python3=3.11.2-1+b1 make=4.3-4.1 g++=4:12.2.0-3 ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates openssl && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
@@ -11,7 +11,7 @@ RUN npx --no-install prisma generate
FROM dependencies AS development FROM dependencies AS development
# Nest watch uses ps to stop the previous process tree before restarting. # Nest watch uses ps to stop the previous process tree before restarting.
RUN apt-get update && apt-get install -y --no-install-recommends procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends procps && rm -rf /var/lib/apt/lists/*
RUN mkdir /data && chown node:node /data && chown -R node:node /app RUN mkdir /data && chown node:node /data && chown -R node:node /app
USER node USER node
ENV NODE_ENV=development DATABASE_URL=file:/data/wealthledger.db ENV NODE_ENV=development DATABASE_URL=file:/data/wealthledger.db
@@ -19,10 +19,11 @@ EXPOSE 3000
CMD ["npm", "run", "start:dev"] CMD ["npm", "run", "start:dev"]
FROM dependencies AS build FROM dependencies AS build
RUN npm run build && npm prune --omit=dev --omit=optional # The client is already generated at the shared dependencies stage (same revision).
RUN npx --no-install nest build && npm prune --omit=dev --omit=optional
FROM base AS production FROM base AS production
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/* \ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates openssl && rm -rf /var/lib/apt/lists/* \
&& mkdir /data && chown node:node /data && mkdir /data && chown node:node /data
ARG VERSION ARG VERSION
ARG REVISION ARG REVISION
+58 -24
View File
@@ -14,6 +14,7 @@ cp .env.example .env
nvm use --silent nvm use --silent
npm ci npm ci
npx prisma migrate deploy npx prisma migrate deploy
npx prisma generate
npm run start:dev npm run start:dev
``` ```
@@ -60,7 +61,7 @@ Compose topology принадлежит workspace, эксплуатационн
npm run verify npm run verify
``` ```
Он запускает zero-warning lint, Он запускает генерацию Prisma client, zero-warning lint,
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
@@ -80,11 +81,13 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify` Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
последовательно на PR; на push в `develop`/`main` — `security → verify`. последовательно на PR; на push в `develop`/`main` — `security → verify`.
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска. Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
Изменение заголовка PR тоже повторяет проверки Conventional Commits. Изменение заголовка PR повторяет только проверку Conventional Commits; `security`
и `verify` на metadata-only событии не запускаются.
Новый запуск отменяет устаревший для того же PR/ветки и типа события Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`. (требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
Job `verify` запускает `npm ci`, генерацию Prisma client, Job `verify` запускает `npm ci` и `npm run verify` (генерация Prisma client
`npm run verify` и функциональный `npm run test:e2e`. Используется только checkout внутри gate, один Nest build), затем функциональный `node test/sqlite.cjs --all`
на уже собранных артефактах. Используется только checkout
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог. backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL. Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
@@ -215,7 +218,10 @@ nvm use --silent
npm run openapi:generate npm run openapi:generate
``` ```
Команда собирает backend и записывает `openapi.json` в корень репозитория. Команда собирает backend и записывает `openapi.json` в корень репозитория
атомарно: через временный файл и rename, без частичной записи при сбое.
`npm run openapi:check` только сравнивает runtime schema с файлом и завершается
с ошибкой при расхождении, не перезаписывая его.
Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания
провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты
не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger
@@ -248,10 +254,14 @@ breaking changes относительно предыдущего выпуска.
настраивается отдельно. История migrations применяется штатным `migrate deploy`. настраивается отдельно. История migrations применяется штатным `migrate deploy`.
Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет
три последовательных и два параллельных полных e2e, проверяет уникальность DB, три последовательных и два параллельных запуска двух обычных e2e suites,
сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup. проверяет уникальность DB, сохранность вымышленного внешнего файла, отказ migration
и SIGINT/SIGTERM cleanup. Полный e2e набор остаётся в `npm run test:e2e`.
Логи остаются в каталоге из вывода. Для одной suite: Логи остаются в каталоге из вывода. Для одной suite:
`node test/sqlite.cjs app.e2e-spec.ts`. `node test/sqlite.cjs app.e2e-spec.ts`. Crash-регрессия в
`tbank-operations-sync.e2e-spec.ts` загружает классы из `dist/`, поэтому перед
адресным запуском этой suite выполните `npm run build`, иначе child/dist может
расходиться с parent/src.
| Риск | Проверка реальной DB | | Риск | Проверка реальной DB |
| --- | --- | | --- | --- |
@@ -295,35 +305,59 @@ Durability оставляет временные DB, логи и `result.json`
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`. релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. общие правила — в Engineering Workflow по ссылке из AGENTS.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай. PR сливается squash или rebase-merge; merge-коммит не используется. `main`
обновляется только fast-forward из `develop`, поэтому история линейна:
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что ```text
`develop` содержит последний релиз. Создай `release/<name>` от проверенного <type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
`develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в ```
`release/hotfix-<name>` и проходит тот же процесс.
Инвариант: `origin/main` всегда предок `origin/develop`. Проверка:
```bash
git fetch -q origin
git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN
```
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь
инвариант. Создай `release/X.Y.Z` от проверенного `develop`;
`nvm use --silent`, `npm ci`.
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog, 2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`, затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии. guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку, 3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `main` `verify`, `security`, `commits` выполни fast-forward-only merge. Если
изменился, согласуй историю и повтори проверки кандидата. `develop` изменился, обнови release-ветку и повтори проверки кандидата.
4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией 4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA: manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только `git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`. `git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь, Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег
merge создай отсутствующий тег без нового bump; до merge исправь и проверь без нового bump; до merge исправь и проверь существующий кандидат без
существующий кандидат без повторного повышения версии. повторного повышения версии.
Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают.
Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом;
пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение
требует явного решения: `release/hotfix-<name>` от `main`, PR в `main`, тег,
затем merge `main → develop` для восстановления инварианта.
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch; `feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):` `!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`. исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены. администратором, включена блокировка устаревшей ветки, обязательны `verify`,
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
Workflow сам не включает эти настройки. Отдельное review approval для одного Workflow сам не включает эти настройки. Отдельное review approval для одного
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены. владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
+9 -1
View File
@@ -1,4 +1,12 @@
module.exports = { module.exports = {
extends: ['@commitlint/config-conventional'], 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'],
},
}; };
+3 -51
View File
@@ -32,11 +32,6 @@
"count": 1 "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": { "src/modules/investments/income/income-analytics.service.ts": {
"sonarjs/no-nested-template-literals": { "sonarjs/no-nested-template-literals": {
"count": 1 "count": 1
@@ -58,18 +53,7 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": { "src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 2
},
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 1 "count": 1
} }
@@ -102,25 +86,17 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/investment-resource.mapper.ts": { "src/modules/investments/investment-sync-plan.ts": {
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 3 "count": 3
} }
}, },
"src/modules/investments/investment-sync-plan.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 4
}
},
"src/modules/investments/operations/application/brokerage-operations.service.ts": { "src/modules/investments/operations/application/brokerage-operations.service.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 2 "count": 2
}, },
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 9 "count": 6
} }
}, },
"src/modules/investments/operations/domain/operations-sync-plan.ts": { "src/modules/investments/operations/domain/operations-sync-plan.ts": {
@@ -149,32 +125,11 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/portfolio/application/portfolio.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/tbank-sync/tbank-screen.ts": { "src/modules/investments/tbank-sync/tbank-screen.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 1 "count": 1
} }
}, },
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 7
}
},
"src/modules/investments/upcoming-events/upcoming-events.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"test/bank-cards-durability.cjs": { "test/bank-cards-durability.cjs": {
"sonarjs/no-nested-template-literals": { "sonarjs/no-nested-template-literals": {
"count": 1 "count": 1
@@ -193,9 +148,6 @@
"test/tbank-ledger-local.e2e-spec.ts": { "test/tbank-ledger-local.e2e-spec.ts": {
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 1 "count": 1
},
"sonarjs/no-nested-template-literals": {
"count": 3
} }
} }
} }
+33 -3
View File
@@ -15,6 +15,18 @@ import globals from 'globals';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
const tests = ['**/*.spec.ts', 'test/**/*.ts']; const tests = ['**/*.spec.ts', 'test/**/*.ts'];
const guardedModules = [
{
name: 'tbank-sync',
except: [
'./tbank-sync.api.ts',
'./tbank-sync.dto.ts',
'./tbank-sync.module.ts',
'./tbank-sync-repository.module.ts',
],
},
{ name: 'upcoming-events', except: ['./upcoming-events.api.ts', './upcoming-events.module.ts'] },
];
const prismaImports = [ const prismaImports = [
'@prisma/*', '**/generated/prisma/**', '**/prisma.service', '@prisma/*', '**/generated/prisma/**', '**/prisma.service',
]; ];
@@ -37,7 +49,7 @@ export default defineConfig(
linterOptions: { reportUnusedDisableDirectives: 'error' }, linterOptions: { reportUnusedDisableDirectives: 'error' },
settings: { settings: {
'import-x/resolver-next': [createTypeScriptImportResolver()], 'import-x/resolver-next': [createTypeScriptImportResolver()],
'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.21.0' }, 'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.22.1' },
}, },
rules: { rules: {
'curly': ['error', 'multi-line'], 'curly': ['error', 'multi-line'],
@@ -56,6 +68,7 @@ export default defineConfig(
'after', 'after',
{ overrides: { '?': 'before', ':': 'before' } }, { overrides: { '?': 'before', ':': 'before' } },
], ],
'import-x/no-cycle': 'error',
'import-x/no-duplicates': ['error', { 'prefer-inline': true }], 'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
}, },
}, },
@@ -157,13 +170,14 @@ export default defineConfig(
}, },
}, },
{ {
files: ['src/**/*.use-case.ts'], files: ['src/**/application/**/*.ts'],
ignores: tests, ignores: tests,
rules: { rules: {
'no-restricted-imports': ['error', { 'no-restricted-imports': ['error', {
patterns: [{ patterns: [{
group: prismaImports, group: prismaImports,
message: 'Use cases obtain data through concrete repositories.', allowTypeImports: true,
message: 'Application code obtains data through concrete repositories; type-only imports are allowed.',
}], }],
}], }],
}, },
@@ -211,4 +225,20 @@ export default defineConfig(
'@typescript-eslint/no-unsafe-enum-comparison': 'error', '@typescript-eslint/no-unsafe-enum-comparison': 'error',
}, },
}, },
// Separate rule so it does not replace layer-specific no-restricted-imports configs.
// A module may import its own internals, so each guarded module gets a block without its zone.
...[undefined, ...guardedModules].map((own) => ({
files: own ? [`src/modules/investments/${own.name}/**/*.ts`] : ['src/**/*.ts'],
ignores: own ? tests : [...tests, ...guardedModules.map(({ name }) => `src/modules/investments/${name}/**`)],
rules: {
'import-x/no-restricted-paths': ['error', {
zones: guardedModules.filter((module) => module !== own).map(({ except, name }) => ({
target: './src',
from: `./src/modules/investments/${name}`,
except,
message: `Use ${name}.api for cross-module access.`,
})),
}],
},
})),
); );
+1057 -1328
View File
File diff suppressed because it is too large Load Diff
+69 -46
View File
@@ -1,20 +1,31 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "2.0.0", "version": "2.4.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
"imports": {
"#generated/*": {
"development": "./dist-dev/generated/*",
"default": "./dist/generated/*"
},
"#src/*": {
"development": "./dist-dev/src/*",
"default": "./dist/src/*"
}
},
"license": "UNLICENSED", "license": "UNLICENSED",
"engines": { "engines": {
"node": "v24.21.0" "node": "v24.22.1"
}, },
"scripts": { "scripts": {
"build": "prisma generate && nest build", "build": "prisma generate && nest build",
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs", "openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
"openapi:check": "npm run build && node scripts/generate-openapi.cjs --check",
"format": "npm run lint:fix", "format": "npm run lint:fix",
"start": "nest start", "start": "nest start",
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json", "start:dev": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --watch --path tsconfig.dev.json",
"start:debug": "nest start --debug --watch --path tsconfig.dev.json", "start:debug": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --debug --watch --path tsconfig.dev.json",
"start:prod": "node dist/src/main.js", "start:prod": "node dist/src/main.js",
"lint": "npm run lint:check", "lint": "npm run lint:check",
"lint:check": "eslint . --max-warnings=0", "lint:check": "eslint . --max-warnings=0",
@@ -31,96 +42,109 @@
"test:container": "node test/container-smoke.cjs", "test:container": "node test/container-smoke.cjs",
"test:durability": "node test/bank-cards-durability.cjs", "test:durability": "node test/bank-cards-durability.cjs",
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts", "test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
"verify": "npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build", "verify": "prisma generate && npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && nest build",
"release:preview": "release-it --dry-run --ci", "release:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci", "release:prepare": "release-it --ci",
"commitlint": "commitlint", "commitlint": "commitlint",
"test:lint": "node --test test/eslint-config.cjs" "test:lint": "node --test test/eslint-config.cjs"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "1.14.5", "@grpc/grpc-js": "1.14.6",
"@grpc/proto-loader": "0.8.1", "@grpc/proto-loader": "0.8.1",
"@nestjs/common": "12.0.1", "@nestjs/common": "12.2.0",
"@nestjs/config": "12.0.0", "@nestjs/config": "12.0.1",
"@nestjs/core": "12.0.1", "@nestjs/core": "12.2.0",
"@nestjs/jwt": "12.0.1", "@nestjs/jwt": "12.0.3",
"@nestjs/passport": "12.0.0", "@nestjs/passport": "12.0.0",
"@nestjs/platform-express": "12.0.1", "@nestjs/platform-express": "12.2.0",
"@nestjs/swagger": "12.0.1", "@nestjs/swagger": "12.0.4",
"@prisma/adapter-better-sqlite3": "7.10.0", "@prisma/adapter-better-sqlite3": "7.10.1",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.1",
"bcrypt": "6.0.0", "bcrypt": "6.0.0",
"better-sqlite3": "13.0.3", "better-sqlite3": "12.11.1",
"cheerio": "1.2.0", "cheerio": "1.2.0",
"class-transformer": "0.5.1", "class-transformer": "0.5.1",
"class-validator": "0.15.1", "class-validator": "0.15.1",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"dotenv": "17.4.2", "dotenv": "17.4.2",
"lru-cache": "11.5.2", "lru-cache": "11.5.3",
"nestjs-pino": "5.1.0", "nestjs-pino": "5.3.1",
"passport": "0.7.0", "passport": "0.7.0",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"pino-http": "11.0.0", "pino-http": "11.0.0",
"prisma": "7.10.0", "prisma": "7.10.1",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.2" "rxjs": "7.8.2"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "21.2.2", "@commitlint/cli": "21.2.4",
"@commitlint/config-conventional": "21.2.2", "@commitlint/config-conventional": "21.2.4",
"@darraghor/eslint-plugin-nestjs-typed": "7.5.6", "@darraghor/eslint-plugin-nestjs-typed": "7.5.8",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@nestjs/cli": "12.0.0", "@nestjs/cli": "12.0.10",
"@nestjs/schematics": "12.0.1", "@nestjs/schematics": "12.0.10",
"@nestjs/testing": "12.0.1", "@nestjs/testing": "12.2.0",
"@release-it/conventional-changelog": "12.0.0", "@release-it/conventional-changelog": "12.0.2",
"@stylistic/eslint-plugin": "5.10.0", "@stylistic/eslint-plugin": "5.10.0",
"@types/bcrypt": "6.0.0", "@types/bcrypt": "6.0.0",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
"@types/express": "5.0.6", "@types/express": "5.0.6",
"@types/jest": "30.0.0", "@types/jest": "30.0.0",
"@types/node": "24.13.4", "@types/node": "24.19.2",
"@types/passport-jwt": "4.0.1", "@types/passport-jwt": "4.0.1",
"@types/supertest": "7.2.1", "@types/supertest": "7.2.1",
"conventional-changelog-conventionalcommits": "10.4.0", "conventional-changelog-conventionalcommits": "10.4.1",
"eslint": "10.11.0", "eslint": "10.13.0",
"eslint-import-resolver-typescript": "4.4.5", "eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-import-x": "4.17.1", "eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jest": "29.16.6", "eslint-plugin-jest": "29.17.0",
"eslint-plugin-n": "18.4.0", "eslint-plugin-n": "18.5.0",
"eslint-plugin-perfectionist": "5.12.1", "eslint-plugin-perfectionist": "5.13.0",
"eslint-plugin-regexp": "3.3.1", "eslint-plugin-regexp": "3.3.1",
"eslint-plugin-security": "4.1.0", "eslint-plugin-security": "4.2.0",
"eslint-plugin-sonarjs": "4.2.1", "eslint-plugin-sonarjs": "4.2.2",
"globals": "17.12.0", "globals": "17.13.0",
"jest": "30.5.1", "jest": "30.5.2",
"release-it": "21.0.2", "pino-pretty": "13.2.0",
"source-map-support": "0.5.21", "release-it": "21.1.1",
"supertest": "7.2.2", "supertest": "7.3.1",
"ts-jest": "29.4.12", "ts-jest": "29.4.15",
"ts-loader": "9.6.2", "ts-loader": "9.6.2",
"ts-node": "10.9.2", "ts-node": "10.9.2",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.70.1" "typescript-eslint": "8.72.0"
}, },
"overrides": { "overrides": {
"proxy-addr": "2.0.8", "proxy-addr": "2.0.8",
"express": {
"body-parser": "2.4.0"
},
"@nestjs/platform-express": { "@nestjs/platform-express": {
"multer": "2.4.0" "multer": "2.5.0"
},
"ajv": {
"fast-uri": "3.1.9"
}, },
"@prisma/config@7.10.0": { "@prisma/config@7.10.0": {
"deepmerge-ts": "8.0.2" "deepmerge-ts": "8.0.2"
}, },
"release-it": { "release-it": {
"basic-ftp": "6.2.1", "basic-ftp": "6.2.3",
"undici": "7.29.1" "undici": "7.30.1"
},
"socks": {
"ip-address": "10.7.4"
}, },
"prisma": { "prisma": {
"mysql2": "3.24.4" "mysql2": "3.24.6"
} }
}, },
"jest": { "jest": {
"moduleNameMapper": {
"^#generated/(.*)$": "<rootDir>/../generated/$1",
"^#src/(.*)$": "<rootDir>/$1"
},
"moduleFileExtensions": [ "moduleFileExtensions": [
"js", "js",
"json", "json",
@@ -144,7 +168,6 @@
"better-sqlite3@12.11.1": true, "better-sqlite3@12.11.1": true,
"fsevents@2.3.3": true, "fsevents@2.3.3": true,
"protobufjs@7.6.5": true, "protobufjs@7.6.5": true,
"unrs-resolver@1.12.2": true, "unrs-resolver@1.12.2": true
"better-sqlite3@13.0.3": true
} }
} }
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "TbankPortfolioValueHistory" (
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"deletedAt" DATETIME,
"updatedAt" DATETIME NOT NULL,
PRIMARY KEY ("userId", "accountId"),
CONSTRAINT "TbankPortfolioValueHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TbankPortfolioValuePoint" (
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"attemptId" TEXT NOT NULL,
"currency" TEXT NOT NULL,
"value" TEXT NOT NULL,
"capturedAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId", "accountId", "attemptId", "currency"),
CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- CreateIndex
CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt");
@@ -0,0 +1,4 @@
-- Points written before WealthLedger/backend#140 counted cash twice (cash balance
-- plus the mirroring CURRENCY position). Historical compositions and FX rates are
-- not stored, so the points cannot be recomputed exactly; history restarts here.
DELETE FROM "TbankPortfolioValuePoint";
@@ -0,0 +1,11 @@
CREATE TABLE "TbankInstrumentClassification" (
"provider" TEXT NOT NULL,
"instrumentKey" TEXT NOT NULL,
"sector" TEXT,
"sectorFetchedAt" DATETIME,
"assetUid" TEXT,
"brandUid" TEXT,
"brandName" TEXT,
"brandFetchedAt" DATETIME,
PRIMARY KEY ("provider","instrumentKey")
);
@@ -0,0 +1,15 @@
CREATE TABLE "MarketIndexDailyClose" (
"secid" TEXT NOT NULL,
"tradeDate" TEXT NOT NULL,
"close" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MarketIndexDailyClose_pkey" PRIMARY KEY ("secid","tradeDate")
);
CREATE TABLE "MarketIndexSyncState" (
"secid" TEXT NOT NULL PRIMARY KEY,
"lastAttemptAt" DATETIME,
"lastSuccessAt" DATETIME,
"coveredFrom" TEXT,
"coveredTo" TEXT
);
@@ -0,0 +1,32 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_TbankPortfolioValuePoint" (
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"attemptId" TEXT NOT NULL,
"value" TEXT NOT NULL,
"capturedAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId", "accountId", "attemptId"),
CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- RUB-only: keep the RUB row per key; non-RUB rows are dropped.
INSERT INTO "new_TbankPortfolioValuePoint" ("accountId", "attemptId", "capturedAt", "createdAt", "userId", "value")
SELECT "accountId", "attemptId", "capturedAt", "createdAt", "userId", "value"
FROM "TbankPortfolioValuePoint"
WHERE "currency" = 'RUB';
DROP TABLE "TbankPortfolioValuePoint";
ALTER TABLE "new_TbankPortfolioValuePoint" RENAME TO "TbankPortfolioValuePoint";
CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt");
-- RUB-only: persisted JSON payloads written in the old multi-currency shape
-- are stale. A missing snapshot/forecast is the app's normal "not synced yet"
-- state: reads report availability 'missing' and the hourly walk rebuilds them
-- on the next pass. Delete the stale rows instead of failing reads with 500.
DELETE FROM "TbankPortfolio";
DELETE FROM "TbankForecast";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,3 @@
-- RUB-only: snapshots were dropped by the previous migration; clearing the hourly
-- schedule makes the next scheduler tick rebuild them instead of waiting for the term.
DELETE FROM "TbankSyncWork" WHERE "scopeKey" = 'hourly';
+60
View File
@@ -8,6 +8,24 @@ datasource db {
provider = "sqlite" provider = "sqlite"
} }
// Public MOEX index closes contain no user holdings or credentials.
model MarketIndexDailyClose {
secid String
tradeDate String
close String
createdAt DateTime @default(now())
@@id([secid, tradeDate])
}
model MarketIndexSyncState {
secid String @id
lastAttemptAt DateTime?
lastSuccessAt DateTime?
coveredFrom String?
coveredTo String?
}
model User { model User {
id String @id id String @id
email String @unique email String @unique
@@ -17,6 +35,7 @@ model User {
bankCardOperations BankCardOperation[] bankCardOperations BankCardOperation[]
sessions Session[] sessions Session[]
tbankConnection TbankConnection? tbankConnection TbankConnection?
tbankValueHistory TbankPortfolioValueHistory[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -69,6 +88,33 @@ model TbankPortfolio {
@@id([userId, revision, accountId]) @@id([userId, revision, accountId])
} }
// Account valuation history survives connection replacement and stays hidden
// until the current connection confirms the account again.
model TbankPortfolioValueHistory {
userId String
accountId String
deletedAt DateTime?
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
points TbankPortfolioValuePoint[]
@@id([userId, accountId])
}
// One row per fully valued successful portfolio attempt (RUB only).
model TbankPortfolioValuePoint {
userId String
accountId String
attemptId String
value String
capturedAt DateTime
createdAt DateTime @default(now())
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
@@id([userId, accountId, attemptId])
@@index([userId, accountId, capturedAt])
}
model TbankOperation { model TbankOperation {
userId String userId String
revision String revision String
@@ -157,6 +203,20 @@ model TbankInstrumentLookup {
@@id([provider, identifierType, identifier]) @@id([provider, identifierType, identifier])
} }
// Public per-instrument classification; no holdings or credentials.
model TbankInstrumentClassification {
provider String
instrumentKey String
sector String?
sectorFetchedAt DateTime?
assetUid String?
brandUid String?
brandName String?
brandFetchedAt DateTime?
@@id([provider, instrumentKey])
}
// Source records contain no holdings, personal estimates or credentials errors. // Source records contain no holdings, personal estimates or credentials errors.
model TbankInstrumentSource { model TbankInstrumentSource {
provider String provider String
+30 -1
View File
@@ -2,7 +2,8 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [ "extends": [
"config:recommended", "config:recommended",
":semanticCommitTypeAll(chore)" ":semanticCommitTypeAll(chore)",
"security:minimumReleaseAgeNpm"
], ],
"timezone": "Europe/Moscow", "timezone": "Europe/Moscow",
"labels": [ "labels": [
@@ -10,6 +11,13 @@
], ],
"dependencyDashboard": true, "dependencyDashboard": true,
"rangeStrategy": "pin", "rangeStrategy": "pin",
"prConcurrentLimit": 5,
"lockFileMaintenance": {
"enabled": true,
"schedule": [
"before 4am on the first day of the month"
]
},
"packageRules": [ "packageRules": [
{ {
"matchUpdateTypes": [ "matchUpdateTypes": [
@@ -18,6 +26,27 @@
], ],
"groupName": "non-major dependencies" "groupName": "non-major dependencies"
}, },
{
"matchManagers": [
"github-actions"
],
"matchUpdateTypes": [
"minor",
"patch"
],
"groupName": "non-major github actions"
},
{
"matchManagers": [
"docker-compose",
"dockerfile"
],
"matchUpdateTypes": [
"minor",
"patch"
],
"groupName": "non-major container images"
},
{ {
"matchUpdateTypes": [ "matchUpdateTypes": [
"major" "major"
+40 -10
View File
@@ -1,5 +1,10 @@
const { randomBytes } = require('node:crypto'); const { randomBytes } = require('node:crypto');
const { writeFile } = require('node:fs/promises'); const { readFile, rename, rm, writeFile } = require('node:fs/promises');
const { resolve } = require('node:path');
// Relative to the working directory, as before: the contract test runs the script in a temporary directory.
const target = resolve(process.cwd(), 'openapi.json');
const checkOnly = process.argv.includes('--check');
// Schema generation needs no deployment configuration or real credentials. // Schema generation needs no deployment configuration or real credentials.
Object.assign(process.env, { Object.assign(process.env, {
@@ -19,23 +24,48 @@ const { NestFactory } = require('@nestjs/core');
const { AppModule } = require('../dist/src/app.module.js'); const { AppModule } = require('../dist/src/app.module.js');
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js'); const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
async function generate() { async function render() {
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create(AppModule, {
preview: true, preview: true,
logger: false, logger: false,
}); });
try { try {
app.setGlobalPrefix(API_PREFIX); app.setGlobalPrefix(API_PREFIX);
await writeFile( return JSON.stringify(createOpenApiDocument(app), null, 2) + '\n';
'openapi.json',
JSON.stringify(createOpenApiDocument(app), null, 2) + '\n',
);
} finally { } finally {
await app.close(); await app.close();
} }
} }
generate().catch((error) => { // Read-only: compares the runtime document with the committed artifact and never writes.
console.error(error); async function check(content) {
process.exitCode = 1; const current = await readFile(target, 'utf8').catch(() => null);
}); if (current !== content) {
console.error(
'openapi.json is out of date with the runtime schema. Run `npm run openapi:generate`.',
);
process.exitCode = 1;
return;
}
console.log('openapi.json matches the runtime schema.');
}
// Atomic update: write a sibling temp file, then rename over the target, so a failed run never leaves partial output.
async function update(content) {
const temp = `${target}.${process.pid}.tmp`;
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input.
await writeFile(temp, content);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input.
await rename(temp, target);
} finally {
await rm(temp, { force: true });
}
}
render()
.then((content) => (checkOnly ? check(content) : update(content)))
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
+2
View File
@@ -9,6 +9,7 @@ import { BrokerageAccountsModule } from './modules/investments/brokerage-account
import { IncomeModule } from './modules/investments/income/income.module'; import { IncomeModule } from './modules/investments/income/income.module';
import { InstrumentsModule } from './modules/investments/instruments/instruments.module'; import { InstrumentsModule } from './modules/investments/instruments/instruments.module';
import { OperationsModule } from './modules/investments/operations/operations.module'; import { OperationsModule } from './modules/investments/operations/operations.module';
import { PerformanceModule } from './modules/investments/performance/performance.module';
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module'; import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module'; import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module'; import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
@@ -27,6 +28,7 @@ import { PrismaModule } from './prisma/prisma.module';
BankCardsModule, BankCardsModule,
BrokerageAccountsModule, BrokerageAccountsModule,
PortfolioModule, PortfolioModule,
PerformanceModule,
OperationsModule, OperationsModule,
IncomeModule, IncomeModule,
UpcomingEventsModule, UpcomingEventsModule,
+55 -2
View File
@@ -1,6 +1,6 @@
import { BadRequestException, ValidationPipe } from '@nestjs/common'; import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { CredentialsDto } from '../modules/auth/presentation/auth.dto'; import { LoginDto, RegisterDto } from '../modules/auth/presentation/auth.dto';
import { import {
CreateBankCardDto, CreateBankCardDto,
UpdateBankCardDto, UpdateBankCardDto,
@@ -11,7 +11,7 @@ describe('DTO validation', () => {
forbidUnknownValues: true, transform: true, whitelist: true }); forbidUnknownValues: true, transform: true, whitelist: true });
it('requires credentials and normalizes a valid email', async () => { it('requires credentials and normalizes a valid email', async () => {
const metadata = { type: 'body' as const, metatype: CredentialsDto }; const metadata = { type: 'body' as const, metatype: RegisterDto };
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf( await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
@@ -29,6 +29,59 @@ describe('DTO validation', () => {
}); });
}); });
// Test intent: register accepts 8..72 printable ASCII characters only (bcrypt
// reads 72 bytes, so non-ASCII could be silently truncated); login only caps
// bytes so accounts created under the older policy can still sign in.
describe('password policy', () => {
const register = { type: 'body' as const, metatype: RegisterDto };
const login = { type: 'body' as const, metatype: LoginDto };
const body = (password: string) => ({ email: 'user@example.com', password });
const accepts = (value: unknown, metadata: typeof login | typeof register) =>
pipe.transform(value, metadata).then(
() => true,
(error: unknown) => {
if (!(error instanceof BadRequestException)) throw error;
return false;
},
);
it.each([
['7 chars', 'a'.repeat(7), false],
['8 chars', 'a'.repeat(8), true],
['72 chars', 'a'.repeat(72), true],
['73 chars', 'a'.repeat(73), false],
['all printable symbols and space', 'Aa1 !~"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}', true],
['Cyrillic, 8+ chars', 'пароль-пароль', false],
['emoji', 'passw😀rd', false],
['tab', 'pass\tword1', false],
['newline', 'password1\n', false],
['DEL', 'password\x7f1', false],
['non-string', 12345678, false],
])('register: %s', async (_, password, accepted) => {
await expect(accepts(body(password as string), register)).resolves.toBe(accepted);
});
it('register keeps raw whitespace untouched', async () => {
const password = ' spaced pass ';
await expect(pipe.transform(body(password), register)).resolves.toEqual(
body(password),
);
});
it.each([
['legacy Cyrillic password', 'пароль-пароль', true],
['short legacy password', 'short', true],
['72 bytes', 'a'.repeat(72), true],
['73 bytes', 'a'.repeat(73), false],
['36 Cyrillic letters = 72 bytes', 'я'.repeat(36), true],
['37 Cyrillic letters = 74 bytes', 'я'.repeat(37), false],
['empty', '', false],
['non-string', 12345678, false],
])('login: %s', async (_, password, accepted) => {
await expect(accepts(body(password as string), login)).resolves.toBe(accepted);
});
});
it('requires and validates nested money in a new card', async () => { it('requires and validates nested money in a new card', async () => {
const metadata = { type: 'body' as const, metatype: CreateBankCardDto }; const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
const card = { const card = {
@@ -1,24 +1,29 @@
import { ArgumentsHost } from '@nestjs/common'; import { ArgumentsHost, BadRequestException } from '@nestjs/common';
import { StructuredLogger } from '../logging/structured-logger.service';
import { HttpExceptionFilter } from './http-exception.filter'; import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => { describe('HttpExceptionFilter', () => {
it('does not expose internal error details', () => { const setup = () => {
const status = jest.fn().mockReturnThis(); const status = jest.fn().mockReturnThis();
const type = jest.fn().mockReturnThis(); const type = jest.fn().mockReturnThis();
const json = jest.fn(); const json = jest.fn();
const locals: Record<string, unknown> = {}; const locals: Record<string, unknown> = {};
const filter = new HttpExceptionFilter(); const logger = { event: jest.fn() };
const filter = new HttpExceptionFilter(logger as unknown as StructuredLogger);
const host = { const host = {
switchToHttp: () => ({ switchToHttp: () => ({
getResponse: () => ({ status, type, json, locals }), getResponse: () => ({ status, type, json, locals }),
}), }),
} as unknown as ArgumentsHost; } as unknown as ArgumentsHost;
return { filter, host, status, type, json, locals, logger };
};
filter.catch( it('logs unhandled errors with name and stack frames but never the message', () => {
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'), const { filter, host, status, type, json, locals, logger } = setup();
host, const error = new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET');
);
filter.catch(error, host);
expect(status).toHaveBeenCalledWith(500); expect(status).toHaveBeenCalledWith(500);
expect(type).toHaveBeenCalledWith('application/problem+json'); expect(type).toHaveBeenCalledWith('application/problem+json');
@@ -27,6 +32,29 @@ describe('HttpExceptionFilter', () => {
code: 'INTERNAL_SERVER_ERROR', code: 'INTERNAL_SERVER_ERROR',
}); });
expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR'); expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR');
expect(locals.errorStack).toBeUndefined(); expect(logger.event).toHaveBeenCalledTimes(1);
const [level, event, fields] = logger.event.mock.calls[0] as [string, string, Record<string, unknown>];
expect(level).toBe('error');
expect(event).toBe('http.request.unhandled');
expect(fields).toMatchObject({ code: 'INTERNAL_SERVER_ERROR', errorName: 'Error' });
expect(JSON.stringify(fields)).not.toContain('SYNTHETIC_AUDIT_SECRET');
expect(String(fields['errorStack']).split('\n').every((line) => line.startsWith(' at '))).toBe(true);
});
it('logs field pointers without values for validation errors', () => {
const { filter, host, status, logger, locals } = setup();
const exception = new BadRequestException({
message: [
{ property: 'amount', constraints: { isPositive: 'amount must be positive' }, value: -42 },
{ property: 'amount', constraints: { min: 'amount too small' }, value: -42 },
],
});
filter.catch(exception, host);
expect(status).toHaveBeenCalledWith(400);
expect(locals.invalidFields).toBe('#/amount');
expect(logger.event).not.toHaveBeenCalled();
expect(JSON.stringify(locals)).not.toContain('-42');
}); });
}); });
@@ -10,9 +10,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { HttpErrorCode } from '../error-code'; import { HttpErrorCode } from '../error-code';
import { StructuredLogger } from '../logging/structured-logger.service';
@Catch() @Catch()
export class HttpExceptionFilter implements ExceptionFilter { export class HttpExceptionFilter implements ExceptionFilter {
constructor(private readonly logger: StructuredLogger) {}
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const context = host.switchToHttp(); const context = host.switchToHttp();
const response = context.getResponse<Response>(); const response = context.getResponse<Response>();
@@ -31,6 +34,18 @@ export class HttpExceptionFilter implements ExceptionFilter {
: this.codeFor(status, payload); : this.codeFor(status, payload);
response.locals ??= {}; response.locals ??= {};
response.locals.publicErrorCode = code; response.locals.publicErrorCode = code;
if (isValidation) {
response.locals.invalidFields = [
...new Set(
this.validationErrors(
(payload as { message: ValidationError[] }).message,
).map((error) => error.pointer),
),
].join(',');
}
if (!(exception instanceof HttpException)) {
this.logUnhandled(exception, code);
}
response response
.status(status) .status(status)
.type('application/problem+json') .type('application/problem+json')
@@ -47,6 +62,20 @@ export class HttpExceptionFilter implements ExceptionFilter {
}); });
} }
// ponytail: stack содержит текст message в первой строке; оставляем только кадры, message не пишем.
private logUnhandled(exception: unknown, code: string): void {
const error = exception instanceof Error ? exception : undefined;
const frames = (error?.stack ?? '')
.split('\n')
.filter((line) => line.startsWith(' at '))
.join('\n');
this.logger.event('error', 'http.request.unhandled', {
code,
errorName: error?.name ?? typeof exception,
errorStack: frames,
});
}
private codeFor(status: number, payload: unknown): string { private codeFor(status: number, payload: unknown): string {
if (typeof payload === 'object' && payload !== null && 'code' in payload) { if (typeof payload === 'object' && payload !== null && 'code' in payload) {
const code = (payload as { code?: unknown }).code; const code = (payload as { code?: unknown }).code;
+5
View File
@@ -24,6 +24,7 @@ function completion(request: Request, response: Response, value: { responseTime:
status: response.statusCode, status: response.statusCode,
durationMs: Math.round(value.responseTime), durationMs: Math.round(value.responseTime),
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}), ...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
...(typeof response.locals.invalidFields === 'string' ? { invalidFields: response.locals.invalidFields } : {}),
}; };
} }
@@ -31,6 +32,10 @@ export function loggingOptions(config: ConfigService<Environment, true>): Option
return { return {
level: config.getOrThrow('LOG_LEVEL'), level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message', messageKey: 'message',
// ponytail: development-only pretty output; production never loads pino-pretty.
...(process.env['NODE_ENV'] === 'development'
? { transport: { target: 'pino-pretty', options: { colorize: true, singleLine: true, messageKey: 'message' } } }
: {}),
genReqId: (_request, response) => { genReqId: (_request, response) => {
const id = randomUUID(); const id = randomUUID();
response.setHeader('X-Request-ID', id); response.setHeader('X-Request-ID', id);
@@ -14,6 +14,7 @@ describe('StructuredLogger', () => {
const logger = new StructuredLogger(pino as never); const logger = new StructuredLogger(pino as never);
logger.info('event', { logger.info('event', {
asOf: '2026-10-10T12:00:00.000Z',
userId: 'user', userId: 'user',
brokerageAccountId: 'account', brokerageAccountId: 'account',
password: 'secret', password: 'secret',
@@ -21,6 +22,7 @@ describe('StructuredLogger', () => {
expect(pino.info).toHaveBeenCalledWith( expect(pino.info).toHaveBeenCalledWith(
{ {
asOf: '2026-10-10T12:00:00.000Z',
userId: 'user', userId: 'user',
brokerageAccountId: 'account', brokerageAccountId: 'account',
}, },
@@ -5,21 +5,30 @@ type LogLevel = 'debug' | 'error' | 'info' | 'warn';
type LogContext = Record<string, boolean | number | string | undefined>; type LogContext = Record<string, boolean | number | string | undefined>;
const allowedFields = new Set([ const allowedFields = new Set([
'asOf',
'attempts', 'attempts',
'brokerageAccountId', 'brokerageAccountId',
'channelState',
'clientRequestId', 'clientRequestId',
'code', 'code',
'count', 'count',
'durationMs', 'durationMs',
'environment', 'environment',
'errorName',
'errorStack',
'found', 'found',
'grpcCode', 'grpcCode',
'headersReceived',
'invalidFields',
'method', 'method',
'networkMs',
'operation', 'operation',
'queueWaitMs',
'requestId', 'requestId',
'route', 'route',
'service', 'service',
'status', 'status',
'trackingId',
'userId', 'userId',
]); ]);
+122
View File
@@ -8,6 +8,73 @@ const emptyEvents = {
describe('MoexClient', () => { describe('MoexClient', () => {
afterEach(() => jest.restoreAllMocks()); afterEach(() => jest.restoreAllMocks());
it('loads all pages of index closes without dropping a page', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockImplementation((input) => {
const start = Number(new URL(input as string).searchParams.get('start'));
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({
'history': {
columns: ['TRADEDATE', 'CLOSE'],
data: start === 0 ? [['2026-10-01', 5752.22]] : [['2026-10-02', 5745.61]],
},
'history.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[start, 2, 1]],
},
}),
} as Response);
});
expect(await new MoexClient().indexHistory('MCFTRR', '2026-10-01', '2026-10-02')).toEqual([
{ tradeDate: '2026-10-01', close: '5752.22' },
{ tradeDate: '2026-10-02', close: '5745.61' },
]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('rejects a later malformed page instead of returning partial index history', async () => {
jest.spyOn(global, 'fetch').mockImplementation((input) => {
const start = Number(new URL(input as string).searchParams.get('start'));
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({
'history': {
columns: ['TRADEDATE', 'CLOSE'],
data: [[start ? '2026-10-02' : '2026-10-01', start ? null : 100]],
},
'history.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[start, 2, 1]] },
}),
} as Response);
});
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
.rejects.toThrow();
});
it('rejects an unsuccessful index history response', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({ ok: false, status: 503 } as Response);
await expect(new MoexClient().indexHistory('MCFTRR', '2026-10-01', '2026-10-02'))
.rejects.toThrow();
});
it('rejects a timed-out index history request', async () => {
jest.spyOn(global, 'fetch').mockRejectedValue(new DOMException('Timed out', 'TimeoutError'));
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
.rejects.toThrow();
});
it('rejects duplicate or out-of-order trading dates', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true, status: 200,
json: () => Promise.resolve({
'history': { columns: ['TRADEDATE', 'CLOSE'], data: [['2026-10-02', 100], ['2026-10-01', 101]] },
'history.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[0, 2, 100]] },
}),
} as Response);
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
.rejects.toThrow();
});
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => { it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({ jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true, ok: true,
@@ -273,4 +340,59 @@ describe('MoexClient', () => {
expect(starts).toEqual(['0', '1']); expect(starts).toEqual(['0', '1']);
}, },
); );
it('filters non-RUB coupons and maturity out of bond events', async () => {
const pages: Record<string, unknown> = {
coupons: Response.json({
'coupons': {
columns: ['coupondate', 'value', 'faceunit'],
data: [
['2099-02-01', '2', 'SUR'],
['2099-02-02', '3', 'USD'],
],
},
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, 2, 100]],
},
}),
offers: Response.json({
'offers': {
columns: ['offerdate'],
data: [['2099-02-28']],
},
'offers.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, 1, 100]],
},
}),
description: Response.json({
description: {
columns: ['name', 'value'],
data: [
['MATDATE', '2099-02-28'],
['FACEVALUE', '500'],
['FACEUNIT', 'USD'],
],
},
}),
};
const fetchMock = jest
.spyOn(global, 'fetch')
.mockImplementation((input) => {
const url = new URL(input instanceof Request ? input.url : String(input));
const block = url.searchParams.get('iss.only')?.split(',')[0] ?? 'description';
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names of this test's page map.
const page = pages[block] ?? pages.description;
return Promise.resolve(page as Response);
});
try {
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
{ type: 'offer', date: '2099-02-28' },
]);
} finally {
fetchMock.mockRestore();
}
});
}); });
+68 -7
View File
@@ -1,7 +1,7 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { constants as httpStatus } from 'node:http2'; import { constants as httpStatus } from 'node:http2';
enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' } export enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
const MIN_PRICE_PERCENT_EXCLUSIVE = 0; const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
const FIRST_PAGE_OFFSET = 0; const FIRST_PAGE_OFFSET = 0;
@@ -10,6 +10,13 @@ const EMPTY_PAGE_SIZE = 0;
const MIN_CURSOR_VALUE = 0; const MIN_CURSOR_VALUE = 0;
const FALLBACK_PAGE_SIZE = 100; const FALLBACK_PAGE_SIZE = 100;
const INDEX_COLUMNS = ['TRADEDATE', 'CLOSE'];
const ISO_DATE_LENGTH = 10;
const ZERO_CLOSE = 0;
const ISO_DATE_START = 0;
const LAST_HISTORY_ROW = -1;
const isoDayPattern = /^\d{4}-\d{2}-\d{2}$/;
const baseUrl = 'https://iss.moex.com/iss'; const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000; const timeoutMs = 10_000;
@@ -28,9 +35,64 @@ export type MoexBondYield = {
tradeAt: string; tradeAt: string;
value: string; value: string;
}; };
export type MoexIndexClose = { close: string; tradeDate: string };
@Injectable() @Injectable()
export class MoexClient { export class MoexClient {
async indexHistory(
secid: 'IMOEX' | 'MCFTRR',
from: string,
till: string,
): Promise<MoexIndexClose[]> {
const path = `history/engines/stock/markets/index/securities/${secid}.json`;
const rows: MoexIndexClose[] = [];
let start = FIRST_PAGE_OFFSET;
for (;;) {
const query = new URLSearchParams({
from,
till,
'start': String(start),
'iss.meta': 'off',
'history.columns': 'TRADEDATE,CLOSE',
});
const response = await this.request(`${baseUrl}/${path}?${query}`);
if (!response) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
const block = response.history;
if (
!block || !Array.isArray(block.columns) ||
block.columns.length !== INDEX_COLUMNS.length ||
// eslint-disable-next-line security/detect-object-injection -- Offset comes from a fixed-length validated columns array.
block.columns.some((column, offset) => column !== INDEX_COLUMNS[offset])
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
const current = parseRows(block);
let previousDate = rows.at(LAST_HISTORY_ROW)?.tradeDate;
for (const row of current) {
const tradeDate = text(row.tradedate);
const close = numberText(row.close);
if (
!tradeDate || !isoDayPattern.test(tradeDate) ||
Number.isNaN(Date.parse(`${tradeDate}T00:00:00Z`)) ||
new Date(`${tradeDate}T00:00:00Z`).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== tradeDate ||
!close || Number(close) <= ZERO_CLOSE ||
(previousDate !== undefined && tradeDate <= previousDate)
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
rows.push({ tradeDate, close });
previousDate = tradeDate;
}
const cursor = parseRows(response['history.cursor']);
const index = integer(cursor[0]?.index);
const total = integer(cursor[0]?.total);
const pageSize = integer(cursor[0]?.pagesize);
if (
cursor.length !== EXPECTED_CURSOR_ROWS || index !== start ||
total === null || pageSize === null || pageSize === EMPTY_PAGE_SIZE ||
current.length !== Math.min(pageSize, total - start)
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
if (index + pageSize >= total) return rows;
start = index + pageSize;
}
}
async bondYield( async bondYield(
secid: string, secid: string,
isin: string, isin: string,
@@ -95,9 +157,9 @@ export class MoexClient {
for (const row of coupons) { for (const row of coupons) {
const date = text(row.coupondate); const date = text(row.coupondate);
const value = numberText(row.value); const value = numberText(row.value);
const currency = currencyText(row.faceunit); // RUB-only: coupons in other currencies are filtered out at this ingest boundary.
if (date && value && currency) { if (date && value && currencyText(row.faceunit) === 'RUB') {
events.push({ type: 'coupon', date, value, currency }); events.push({ type: 'coupon', date, value, currency: 'RUB' });
} }
} }
for (const row of offers) { for (const row of offers) {
@@ -109,9 +171,8 @@ export class MoexClient {
); );
const date = values.get('MATDATE'); const date = values.get('MATDATE');
const value = numberText(values.get('FACEVALUE')); const value = numberText(values.get('FACEVALUE'));
const currency = currencyText(values.get('FACEUNIT')); if (date && value && currencyText(values.get('FACEUNIT')) === 'RUB') {
if (date && value && currency) { events.push({ type: 'maturity', date, value, currency: 'RUB' });
events.push({ type: 'maturity', date, value, currency });
} }
return events; return events;
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import { LRUCache } from 'lru-cache';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' } export enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
const EXPECTED_REGISTRY_TABLES = 1; const EXPECTED_REGISTRY_TABLES = 1;
const RATING_COLUMN = 2; const RATING_COLUMN = 2;
+9 -1
View File
@@ -1,4 +1,6 @@
import { Decimal } from '../../common/decimal'; import type { Money } from '../../common/money.types';
import { Decimal, formatDecimal } from '../../common/decimal';
const MISSING_PROTO_COMPONENT = 0; const MISSING_PROTO_COMPONENT = 0;
@@ -6,6 +8,12 @@ const NANO = 1_000_000_000n;
export type ProtoDecimal = { nano?: number | string; units?: number | string }; export type ProtoDecimal = { nano?: number | string; units?: number | string };
export function rubMoney(value?: ProtoDecimal & { currency?: string }): Money | null {
return value?.currency?.trim().toUpperCase() === 'RUB'
? { currency: 'RUB', value: formatDecimal(decimal(value)) }
: null;
}
export function decimal(value: ProtoDecimal): Decimal { export function decimal(value: ProtoDecimal): Decimal {
return { return {
value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT), value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
@@ -5,14 +5,15 @@ import { isDeepStrictEqual } from 'node:util';
import type { import type {
TbankInstrumentLookup as MetadataLookupRow, TbankInstrumentLookup as MetadataLookupRow,
TbankInstrument as MetadataRow, TbankInstrument as MetadataRow,
Prisma, } from '#generated/prisma/client';
} from '../../../generated/prisma/client';
import type { TbankSyncAttempt } from './tbank-access.service';
import type { TbankInstrument } from './tbank.client'; import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { toJson } from '../../prisma/to-json';
import { isJoinableRunningAttempt, type TbankSyncAttempt } from './tbank-access.service';
import { TbankErrorCode } from './tbank-error-code'; import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceTtlMs } from './tbank-source.policy'; import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
const FIRST_BATCH_OFFSET = 0; const FIRST_BATCH_OFFSET = 0;
const UNCHANGED_REVISION_INCREMENT = 0; const UNCHANGED_REVISION_INCREMENT = 0;
@@ -120,11 +121,8 @@ export class InstrumentMetadataRepository {
active: ReadonlySet<string>, active: ReadonlySet<string>,
) { ) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const valid = await tx.tbankConnection.updateMany({ const valid = await lockConnectionGeneration(tx, attemptOwner);
where: { ...attemptOwner, invalidToken: false }, if (!valid) return null;
data: { membershipRevision: { increment: 0 } },
});
if (!valid.count) return null;
const scope = { const scope = {
...attemptOwner, ...attemptOwner,
scopeKey: `metadata:${metadataKey(identity)}`, scopeKey: `metadata:${metadataKey(identity)}`,
@@ -132,11 +130,7 @@ export class InstrumentMetadataRepository {
const previous = await tx.tbankSyncWork.findUnique({ const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope }, where: { userId_revision_scopeKey: scope },
}); });
if ( if (isJoinableRunningAttempt(previous, runId, active)) {
previous?.state === 'running' &&
previous.processRunId === runId &&
active.has(previous.attemptId)
) {
return { work: previous, launch: false }; return { work: previous, launch: false };
} }
const data = { const data = {
@@ -172,15 +166,11 @@ export class InstrumentMetadataRepository {
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) { publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const claimed = await tx.tbankSyncWork.updateMany({ const claimed = await claimSyncWork(tx, attempt, {
where: { state: 'complete',
...attempt, progress: {},
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'complete', progress: {} },
}); });
if (!claimed.count) return false; if (!claimed) return false;
const aliases = new Map( const aliases = new Map(
results.map((result) => [`${result.type}:${result.id}`, result]), results.map((result) => [`${result.type}:${result.id}`, result]),
); );
@@ -217,9 +207,7 @@ export class InstrumentMetadataRepository {
const previous = await tx.tbankInstrument.findUnique({ const previous = await tx.tbankInstrument.findUnique({
where: { provider_uid: key }, where: { provider_uid: key },
}); });
const payload = JSON.parse( const payload = toJson(instrument);
JSON.stringify(instrument),
) as Prisma.InputJsonObject;
const data = { const data = {
metadata: payload, metadata: payload,
figi: figi:
@@ -269,18 +257,17 @@ export class InstrumentMetadataRepository {
} }
async finishObserver(attempt: TbankSyncAttempt, success: boolean) { async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
await this.prisma.tbankSyncWork.updateMany({ await claimSyncWork(
where: { this.prisma,
...attempt, attempt,
state: 'running', {
...(success ? { connection: { invalidToken: false } } : {}),
},
data: {
state: success ? 'complete' : 'failed', state: success ? 'complete' : 'failed',
errorCode: success ? null : TbankErrorCode.Unavailable, errorCode: success ? null : TbankErrorCode.Unavailable,
}, },
}); { withoutConnectionFence: !success },
);
if (success) { if (success) {
// Fails sibling attempts whose connection was invalidated mid-flight.
await this.prisma.tbankSyncWork.updateMany({ await this.prisma.tbankSyncWork.updateMany({
where: { where: {
...attempt, ...attempt,
@@ -320,11 +307,7 @@ function selectMetadata(
return { return {
data: usefulUid ? uid.data : (figi.data ?? uid.data), data: usefulUid ? uid.data : (figi.data ?? uid.data),
complete, complete,
fresh: fresh: complete,
complete &&
// eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard.
fetchedAt !== null &&
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
fetchedAt, fetchedAt,
dataRevision: used dataRevision: used
.map( .map(
+59 -32
View File
@@ -12,6 +12,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { supportedTbankAccounts } from './tbank-account.policy'; import { supportedTbankAccounts } from './tbank-account.policy';
import { TbankErrorCode } from './tbank-error-code'; import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceNeedsRefresh } from './tbank-source.policy'; import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
const EXPECTED_UPDATED_CONNECTIONS = 1; const EXPECTED_UPDATED_CONNECTIONS = 1;
@@ -25,6 +26,22 @@ export type TbankSyncAttempt = {
userId: string; userId: string;
}; };
// Join rule shared by sync reservations: the same process joins its own running attempt
// only while that attempt is still active in memory. Set and Map both satisfy `has`.
export function isJoinableRunningAttempt<
T extends { attemptId: string; processRunId: string; state: string },
>(
previous: null | T,
processRunId: string,
active: { has(attemptId: string): boolean },
): previous is T {
return (
previous?.state === 'running' &&
previous.processRunId === processRunId &&
active.has(previous.attemptId)
);
}
@Injectable() @Injectable()
export class TbankAccessService { export class TbankAccessService {
constructor( constructor(
@@ -40,11 +57,8 @@ export class TbankAccessService {
force: boolean, force: boolean,
) { ) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const current = await tx.tbankConnection.updateMany({ const current = await lockConnectionGeneration(tx, { userId, revision });
where: { userId, revision, invalidToken: false }, if (!current) return null;
data: { membershipRevision: { increment: 0 } },
});
if (!current.count) return null;
const connection = await tx.tbankConnection.findUniqueOrThrow({ const connection = await tx.tbankConnection.findUniqueOrThrow({
where: { userId }, where: { userId },
}); });
@@ -62,11 +76,7 @@ export class TbankAccessService {
const previous = await tx.tbankSyncWork.findUnique({ const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope }, where: { userId_revision_scopeKey: scope },
}); });
if ( if (isJoinableRunningAttempt(previous, processRunId, active)) {
previous?.state === 'running' &&
previous.processRunId === processRunId &&
active.has(previous.attemptId)
) {
return { work: previous, launch: false }; return { work: previous, launch: false };
} }
const data = { const data = {
@@ -98,15 +108,11 @@ export class TbankAccessService {
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) { async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
if (invalidToken) return this.rejectMembershipToken(attempt); if (invalidToken) return this.rejectMembershipToken(attempt);
const result = await this.prisma.tbankSyncWork.updateMany({ const result = await claimSyncWork(this.prisma, attempt, {
where: { state: 'failed',
...attempt, errorCode: TbankErrorCode.Unavailable,
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
}); });
return result.count === EXPECTED_UPDATED_CONNECTIONS; return result === EXPECTED_UPDATED_CONNECTIONS;
} }
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> { async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
@@ -121,6 +127,23 @@ export class TbankAccessService {
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null; return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
} }
// Online reads: a permanent upstream auth refusal becomes 400/INVALID_TBANK_TOKEN here, once, for every caller.
async online<T>(
userId: string,
run: (connection: { revision: string; token: string }) => Promise<T>,
): Promise<T> {
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 });
}
const token = this.tokens.decrypt(connection.encryptedToken);
return this.tokens.translateInvalidToken(() => run({ revision: connection.revision, token }));
}
async publishMembership( async publishMembership(
attempt: TbankSyncAttempt, attempt: TbankSyncAttempt,
accounts: TbankAccount[], accounts: TbankAccount[],
@@ -134,15 +157,8 @@ export class TbankAccessService {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
// Acquire the write fence before reading membership. Replacement and // Acquire the write fence before reading membership. Replacement and
// attempt supersession cannot interleave with the publication below. // attempt supersession cannot interleave with the publication below.
const claimed = await tx.tbankSyncWork.updateMany({ const claimed = await claimSyncWork(tx, attempt, { updatedAt: new Date() });
where: { if (!claimed) return false;
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { updatedAt: new Date() },
});
if (!claimed.count) return false;
const { userId, revision } = attempt; const { userId, revision } = attempt;
const previous = await tx.tbankAccount.findMany({ const previous = await tx.tbankAccount.findMany({
where: { userId, revision }, where: { userId, revision },
@@ -192,6 +208,17 @@ export class TbankAccessService {
membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT }, membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
}, },
}); });
// Value history visibility follows the confirmed membership only; a
// failed fetch never reaches this transaction and changes nothing.
const supportedIds = supported.map(({ id }) => id);
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { in: supportedIds } },
data: { deletedAt: null },
});
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { notIn: supportedIds }, deletedAt: null },
data: { deletedAt: fetchedAt },
});
if (completeWork) { if (completeWork) {
await tx.tbankSyncWork.update({ await tx.tbankSyncWork.update({
where: { id: attempt.id }, where: { id: attempt.id },
@@ -204,11 +231,11 @@ export class TbankAccessService {
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> { async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const claimed = await tx.tbankSyncWork.updateMany({ const claimed = await claimSyncWork(tx, attempt, {
where: { ...attempt, state: 'running' }, state: 'failed',
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken }, errorCode: TbankErrorCode.InvalidToken,
}); }, { withoutConnectionFence: true });
if (!claimed.count) return false; if (!claimed) return false;
await tx.tbankConnection.update({ await tx.tbankConnection.update({
where: { where: {
userId_revision: { userId_revision: {
@@ -8,7 +8,9 @@ import type {
export type TbankShare = TbankInstrument & { export type TbankShare = TbankInstrument & {
apiTradeAvailableFlag?: boolean; apiTradeAvailableFlag?: boolean;
assetUid?: string; assetUid?: string;
buyAvailableFlag?: boolean;
classCode?: string; classCode?: string;
countryOfRisk?: string;
currency?: string; currency?: string;
figi?: string; figi?: string;
first_1dayCandleDate?: TbankTimestamp; first_1dayCandleDate?: TbankTimestamp;
+10
View File
@@ -5,6 +5,10 @@ export enum InstrumentIdType {
Uid = 'INSTRUMENT_ID_TYPE_UID', Uid = 'INSTRUMENT_ID_TYPE_UID',
} }
export enum TbankInstrumentStatus {
All = 'INSTRUMENT_STATUS_ALL',
}
export enum TbankInstrumentType { export enum TbankInstrumentType {
Bond = 'INSTRUMENT_TYPE_BOND', Bond = 'INSTRUMENT_TYPE_BOND',
Share = 'INSTRUMENT_TYPE_SHARE', Share = 'INSTRUMENT_TYPE_SHARE',
@@ -45,11 +49,17 @@ export enum TbankOperationType {
Dividend = 'OPERATION_TYPE_DIVIDEND', Dividend = 'OPERATION_TYPE_DIVIDEND',
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX', DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER', DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
InpMulti = 'OPERATION_TYPE_INP_MULTI',
Input = 'OPERATION_TYPE_INPUT', Input = 'OPERATION_TYPE_INPUT',
InputAcquiring = 'OPERATION_TYPE_INPUT_ACQUIRING',
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES', InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
InputSwift = 'OPERATION_TYPE_INPUT_SWIFT',
MarginFee = 'OPERATION_TYPE_MARGIN_FEE', MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
OutMulti = 'OPERATION_TYPE_OUT_MULTI',
Output = 'OPERATION_TYPE_OUTPUT', Output = 'OPERATION_TYPE_OUTPUT',
OutputAcquiring = 'OPERATION_TYPE_OUTPUT_ACQUIRING',
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES', OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
OutputSwift = 'OPERATION_TYPE_OUTPUT_SWIFT',
Sell = 'OPERATION_TYPE_SELL', Sell = 'OPERATION_TYPE_SELL',
SellCard = 'OPERATION_TYPE_SELL_CARD', SellCard = 'OPERATION_TYPE_SELL_CARD',
SellMargin = 'OPERATION_TYPE_SELL_MARGIN', SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
@@ -3,7 +3,7 @@ import {
tbankSourceTtlMs, tbankSourceTtlMs,
} from './tbank-source.policy'; } from './tbank-source.policy';
describe('request-driven source freshness', () => { describe('mutable source freshness', () => {
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])( it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
'%s expires at its boundary without changing source time', '%s expires at its boundary without changing source time',
(source) => { (source) => {
@@ -1,15 +1,10 @@
// Freshness never establishes completeness. Immutable ranges are planned by // Freshness never establishes completeness. Immutable ranges are planned by
// their coverage owners; these TTLs apply only to mutable source snapshots. // their coverage owners; these TTLs apply only to mutable source snapshots.
export const tbankSourceTtlMs = { export const tbankSourceTtlMs = {
membership: 300_000, membership: 3_600_000,
portfolio: 60_000, portfolio: 3_600_000,
quote: 60_000, quote: 3_600_000,
candleTail: 60_000, candleTail: 3_600_000,
operationsTail: 300_000,
metadata: 86_400_000,
catalog: 86_400_000,
company: 86_400_000,
calendar: 86_400_000,
} as const; } as const;
export function tbankSourceNeedsRefresh( export function tbankSourceNeedsRefresh(
@@ -0,0 +1,54 @@
import type { Prisma } from '#generated/prisma/client';
import type { TbankSyncAttempt } from './tbank-access.service';
const NO_UPDATED_ROWS = 0;
// Shared owners for the tbank-sync DB claim/lock protocol.
// Both helpers are CAS fences: they run inside the caller's transaction and
// keep the exact where/data shapes they replaced. Callers keep deciding
// success from the claim count exactly as before.
// Claims the active TbankSyncWork row: only the still-running attempt of a
// valid connection may transition. `data` is the state transition to apply.
export type ClaimOptions = {
// Overrides the default 'running' fence (e.g. an initiator row also
// accepts 'complete').
state: Prisma.TbankSyncWorkWhereInput['state'] | undefined;
// true drops the `connection: { invalidToken: false }` guard for fences
// that must also fire on an invalidated connection.
withoutConnectionFence: boolean | undefined;
};
export async function claimSyncWork(
tx: Prisma.TransactionClient,
attempt: TbankSyncAttempt,
data: Prisma.TbankSyncWorkUpdateManyMutationInput,
options: Partial<ClaimOptions> = {},
): Promise<number> {
const result = await tx.tbankSyncWork.updateMany({
where: {
...attempt,
state: options.state ?? 'running',
...(options.withoutConnectionFence
? {}
: { connection: { invalidToken: false } }),
},
data,
});
return result.count;
}
// Locks the connection generation before reserving or publishing work: the
// row must still be the current revision with a valid token. `extra`
// (e.g. account membership) is merged into the where clause.
export async function lockConnectionGeneration(
tx: Prisma.TransactionClient,
owner: { revision: string; userId: string },
extra: Prisma.TbankConnectionWhereInput = {},
): Promise<boolean> {
const result = await tx.tbankConnection.updateMany({
where: { ...owner, ...extra, invalidToken: false },
data: { membershipRevision: { increment: 0 } },
});
return result.count > NO_UPDATED_ROWS;
}
+1
View File
@@ -3,6 +3,7 @@ export { InstrumentMetadataService } from './instrument-metadata.service';
export { metadataKey } from './instrument-metadata.service'; export { metadataKey } from './instrument-metadata.service';
export type { MetadataIdentity } from './instrument-metadata.service'; export type { MetadataIdentity } from './instrument-metadata.service';
export { export {
isJoinableRunningAttempt,
TbankAccessService, TbankAccessService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from './tbank-access.service'; } from './tbank-access.service';
+214 -1
View File
@@ -1,5 +1,6 @@
import { status } from '@grpc/grpc-js'; import { Metadata, status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { EventEmitter } from 'node:events';
import { TbankOperationState } from './tbank-protocol'; import { TbankOperationState } from './tbank-protocol';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
@@ -136,6 +137,70 @@ describe('TbankClient operations', () => {
); );
jest.useRealTimers(); jest.useRealTimers();
}); });
it('distinguishes a gRPC deadline from queue delay without logging upstream secrets', async () => {
const logger = { event: jest.fn() };
const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, {
logger,
queue: { size: 0, run: (operation: () => Promise<unknown>) => operation() },
});
const channel = { getConnectivityState: () => 1 };
const service = {
getChannel: () => channel,
getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: unknown) => void) => {
const call = new EventEmitter();
void Promise.resolve().then(() => callback({ code: status.DEADLINE_EXCEEDED, details: 'Bearer secret' }));
return call;
},
};
const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise<unknown> }).call.bind(client);
jest.useFakeTimers();
try {
const failed = invoke('secret', service, 'getAccounts', {});
await Promise.all([
expect(failed).rejects.toBeInstanceOf(ServiceUnavailableException),
jest.advanceTimersByTimeAsync(1000),
]);
expect(logger.event).toHaveBeenLastCalledWith('error', 'tbank.rpc.failed', expect.objectContaining({
operation: 'getAccounts', grpcCode: status.DEADLINE_EXCEEDED,
queueWaitMs: 0, channelState: 'CONNECTING', headersReceived: false,
}));
const [, , fields] = logger.event.mock.calls.at(-1) as [string, string, { networkMs: unknown }];
expect(typeof fields.networkMs).toBe('number');
expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret');
} finally {
jest.useRealTimers();
}
});
it('records response headers and a validated upstream tracking ID', async () => {
const logger = { event: jest.fn() };
const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, {
logger,
queue: { size: 0, run: (operation: () => Promise<unknown>) => operation() },
});
const headers = new Metadata();
headers.set('x-tracking-id', '123e4567-e89b-12d3-a456-426614174000');
headers.set('authorization', 'Bearer secret');
const service = {
getChannel: () => ({ getConnectivityState: () => 2 }),
getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: null, response: object) => void) => {
const call = new EventEmitter();
void Promise.resolve().then(() => {
call.emit('metadata', headers);
callback(null, { accounts: [] });
});
return call;
},
};
const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise<unknown> }).call.bind(client);
await expect(invoke('secret', service, 'getAccounts', {})).resolves.toEqual({ accounts: [] });
expect(logger.event).toHaveBeenLastCalledWith('debug', 'tbank.rpc.completed', expect.objectContaining({
channelState: 'READY', headersReceived: true,
trackingId: '123e4567-e89b-12d3-a456-426614174000',
}));
expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret');
});
}); });
describe('TbankClient metadata retries', () => { describe('TbankClient metadata retries', () => {
@@ -171,6 +236,16 @@ describe('TbankClient metadata retries', () => {
jest.useRealTimers(); jest.useRealTimers();
}); });
it.each([
['bondBy', (client: TbankClient, optIn: boolean) => client.getBond('token', 'uid', undefined, optIn)],
['etfBy', (client: TbankClient, optIn: boolean) => client.getEtf('token', 'uid', undefined, optIn)],
['getAssetBy', (client: TbankClient, optIn: boolean) => optIn ? client.getAssetBrand('token', 'uid') : client.getAssetDescription('token', 'uid')],
])('%s reports NOT_FOUND only for classification lookups', async (_method, invoke) => {
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
await expect(invoke(client, false)).rejects.toBeInstanceOf(ServiceUnavailableException);
await expect(invoke(client, true)).rejects.toBeInstanceOf(NotFoundException);
});
it.each([ it.each([
status.UNAVAILABLE, status.UNAVAILABLE,
status.DEADLINE_EXCEEDED, status.DEADLINE_EXCEEDED,
@@ -282,3 +357,141 @@ describe('TbankClient metadata retries', () => {
expect(jest.getTimerCount()).toBe(0); 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',
);
});
});
+102 -10
View File
@@ -2,6 +2,7 @@ import {
ChannelCredentials, ChannelCredentials,
Client, Client,
ClientUnaryCall, ClientUnaryCall,
connectivityState,
loadPackageDefinition, loadPackageDefinition,
Metadata, Metadata,
ServiceError, ServiceError,
@@ -34,6 +35,7 @@ import {
InstrumentIdType, InstrumentIdType,
TbankAccountStatus, TbankAccountStatus,
TbankCandleInterval, TbankCandleInterval,
TbankInstrumentStatus,
TbankInstrumentType, TbankInstrumentType,
TbankOperationState, TbankOperationState,
} from './tbank-protocol'; } from './tbank-protocol';
@@ -59,6 +61,13 @@ type Unary<Request, Response> = (
callback: (error: null | ServiceError, response: Response) => void, callback: (error: null | ServiceError, response: Response) => void,
) => ClientUnaryCall; ) => ClientUnaryCall;
type TbankClientService = Client & Record<string, Unary<never, never>>; type TbankClientService = Client & Record<string, Unary<never, never>>;
type RpcDiagnostics = {
channelState?: string;
headersReceived?: boolean;
networkMs?: number;
queueWaitMs?: number;
trackingId?: string;
};
export type TbankDecimal = { nano?: number | string; units?: number | string }; export type TbankDecimal = { nano?: number | string; units?: number | string };
export type TbankMoney = TbankDecimal & { currency?: string }; export type TbankMoney = TbankDecimal & { currency?: string };
export type TbankAccount = { export type TbankAccount = {
@@ -325,6 +334,35 @@ export class TbankClient {
).then((response) => response.instruments ?? []); ).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[]> { findBonds(token: string, query: string): Promise<TbankBond[]> {
return this.call<{ instruments?: TbankBond[] }>( return this.call<{ instruments?: TbankBond[] }>(
token, token,
@@ -337,18 +375,29 @@ export class TbankClient {
).then((response) => response.instruments ?? []); ).then((response) => response.instruments ?? []);
} }
getBond(token: string, uid: string): Promise<null | TbankBond> { getBond(token: string, uid: string, idType = InstrumentIdType.Uid, notFound = false): Promise<null | TbankBond> {
return this.call<{ instrument?: TbankBond }>( return this.call<{ instrument?: TbankBond }>(
token, token,
this.sparseInstruments, this.sparseInstruments,
'bondBy', 'bondBy',
{ {
idType: InstrumentIdType.Uid, idType,
id: uid, id: uid,
}, },
notFound,
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
} }
getEtf(
token: string,
id: string,
idType = InstrumentIdType.Uid,
notFound = false,
): Promise<null | Pick<TbankShare, 'assetUid' | 'figi' | 'sector' | 'uid'>> {
return this.call<{ instrument?: TbankShare }>(token, this.sparseInstruments, 'etfBy', { idType, id }, notFound)
.then((response) => response.instrument ?? null);
}
getBondCoupons( getBondCoupons(
token: string, token: string,
uid: string, uid: string,
@@ -367,18 +416,25 @@ export class TbankClient {
).then((response) => response.events ?? []); ).then((response) => response.events ?? []);
} }
getShare(token: string, uid: string): Promise<null | TbankShare> { getShare(token: string, uid: string, idType = InstrumentIdType.Uid, notFound = false): Promise<null | TbankShare> {
return this.call<{ instrument?: TbankShare }>( return this.call<{ instrument?: TbankShare }>(
token, token,
this.instruments, this.instruments,
'shareBy', 'shareBy',
{ {
idType: InstrumentIdType.Uid, idType,
id: uid, id: uid,
}, },
notFound,
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
} }
getAssetBrand(token: string, assetUid: string): Promise<null | { assetUid?: string; brand?: { name?: string; uid?: string } }> {
return this.call<{ asset?: { brand?: { name?: string; uid?: string }; uid?: string } }>(
token, this.instruments, 'getAssetBy', { id: assetUid }, true,
).then((response) => response.asset ? { assetUid: response.asset.uid, brand: response.asset.brand } : null);
}
getAssetDescription(token: string, assetUid: string): Promise<null | string> { getAssetDescription(token: string, assetUid: string): Promise<null | string> {
return this.call<{ asset?: { description?: string } }>( return this.call<{ asset?: { description?: string } }>(
token, token,
@@ -476,20 +532,29 @@ export class TbankClient {
client: TbankClientService, client: TbankClientService,
method: string, method: string,
request: object, request: object,
notFound = false,
): Promise<Response> { ): Promise<Response> {
const metadataCall = method === 'getInstrumentBy'; const metadataCall = method === 'getInstrumentBy';
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) { for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
const startedAt = performance.now(); const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE; const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
const diagnostics: RpcDiagnostics = {};
try { try {
if (queueFull) { if (queueFull) {
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
} }
const response = await this.queue.run(() => const response = await this.queue.run(() => {
this.request<Response>(token, client, method, request), const sentAt = performance.now();
); diagnostics.queueWaitMs = Math.round(sentAt - startedAt);
return this.request<Response>(
token, client, method, request, diagnostics,
).finally(() => {
diagnostics.networkMs = Math.round(performance.now() - sentAt);
});
});
this.logger.event('debug', 'tbank.rpc.completed', { this.logger.event('debug', 'tbank.rpc.completed', {
operation: method, operation: method,
...diagnostics,
attempts, attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
@@ -507,6 +572,7 @@ export class TbankClient {
if (retryable && attempts < RPC_MAX_ATTEMPTS) { if (retryable && attempts < RPC_MAX_ATTEMPTS) {
this.logger.event('warn', 'tbank.rpc.retry', { this.logger.event('warn', 'tbank.rpc.retry', {
operation: method, operation: method,
...diagnostics,
...failureContext, ...failureContext,
attempts, attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
@@ -516,6 +582,7 @@ export class TbankClient {
} }
this.logger.event('error', 'tbank.rpc.failed', { this.logger.event('error', 'tbank.rpc.failed', {
operation: method, operation: method,
...diagnostics,
...failureContext, ...failureContext,
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts, attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
@@ -528,7 +595,7 @@ export class TbankClient {
} }
if ( if (
grpcCode === status.NOT_FOUND && grpcCode === status.NOT_FOUND &&
(method === 'shareBy' || metadataCall) (notFound || method === 'shareBy' || metadataCall)
) { ) {
throw new NotFoundException(); throw new NotFoundException();
} }
@@ -543,17 +610,42 @@ export class TbankClient {
client: TbankClientService, client: TbankClientService,
method: string, method: string,
request: object, request: object,
diagnostics?: RpcDiagnostics,
): Promise<Response> { ): Promise<Response> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const metadata = new Metadata(); const metadata = new Metadata();
metadata.set('Authorization', `Bearer ${token}`); metadata.set('Authorization', `Bearer ${token}`);
// A channel state is a snapshot, not proof that this particular call reached the server.
const channel = diagnostics && client.getChannel();
if (diagnostics) diagnostics.headersReceived = false;
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input. // eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
client[method]( const call = client[method](
request as never, request as never,
metadata, metadata,
{ deadline: new Date(Date.now() + requestDeadlineMs) }, { deadline: new Date(Date.now() + requestDeadlineMs) },
(error, response) => (error ? reject(error) : resolve(response)), (error, response) => {
if (diagnostics && channel) {
diagnostics.channelState = connectivityState[
channel.getConnectivityState(false)
];
}
if (error) reject(error);
else resolve(response);
},
); );
if (diagnostics) {
call.on('metadata', (headers: Metadata) => {
diagnostics.headersReceived = true;
const trackingId = headers.get('x-tracking-id')[0];
// Only a UUID may enter logs; never forward arbitrary upstream metadata.
if (
typeof trackingId === 'string' &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trackingId)
) {
diagnostics.trackingId = trackingId;
}
});
}
}); });
} }
} }
+1 -1
View File
@@ -37,7 +37,7 @@ export function configureApp(app: INestApplication): void {
exceptionFactory: (message) => new BadRequestException({ message }), exceptionFactory: (message) => new BadRequestException({ message }),
}), }),
); );
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter(app.get(StructuredLogger)));
app.enableShutdownHooks(); app.enableShutdownHooks();
} }
@@ -2,12 +2,14 @@ import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { AuthRepository } from '../infrastructure/auth.repository';
jest.mock('bcrypt', () => ({ jest.mock('bcrypt', () => ({
hash: jest.fn(() => Promise.resolve('password-hash')), hash: jest.fn(() => Promise.resolve('password-hash')),
compare: jest.fn(() => Promise.resolve(true)), compare: jest.fn(() => Promise.resolve(true)),
})); }));
jest.mock('../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class PrismaService {}, PrismaService: class PrismaService {},
})); }));
@@ -51,7 +53,7 @@ describe('AuthService', () => {
delete: jest.fn(), delete: jest.fn(),
}, },
}; };
const service = new AuthService(prisma as never, jwt); const service = new AuthService(new AuthRepository(prisma as never), jwt);
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
+24 -68
View File
@@ -7,17 +7,16 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../../../generated/prisma/client'; import type { Session, User } from '#generated/prisma/client';
import { HttpErrorCode } from '../../../common/error-code';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code'; import { HttpErrorCode } from '#src/common/error-code';
import { PrismaService } from '../../../prisma/prisma.service';
import { AuthRepository } from '../infrastructure/auth.repository';
import { AuthErrorCode } from './auth-error-code'; import { AuthErrorCode } from './auth-error-code';
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types'; import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
const EXPECTED_ROTATED_SESSIONS = 1;
const PASSWORD_HASH_ROUNDS = 12; const PASSWORD_HASH_ROUNDS = 12;
const REFRESH_TTL_MS = 604_800_000; export const REFRESH_TTL_MS = 604_800_000;
const ACCESS_TTL = '15m'; const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d'; const REFRESH_TTL = '7d';
@@ -28,7 +27,7 @@ const DUMMY_PASSWORD_HASH =
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly repository: AuthRepository,
private readonly jwt: JwtService, private readonly jwt: JwtService,
) {} ) {}
@@ -36,27 +35,18 @@ export class AuthService {
email: string, email: string,
password: string, password: string,
): Promise<AuthenticationResult> { ): Promise<AuthenticationResult> {
const existing = await this.prisma.user.findUnique({ const existing = await this.repository.findUserByEmail(email);
where: { email },
});
if (existing) { if (existing) {
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
} }
let user: User; const user = await this.repository.createUser({
try { id: randomUUID(),
user = await this.prisma.user.create({ email,
data: { passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
id: randomUUID(), });
email, if (!user) {
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
},
});
} catch (error) {
if (isUniqueConstraintError(error)) {
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
throw error;
} }
return this.createSession(user); return this.createSession(user);
} }
@@ -65,9 +55,7 @@ export class AuthService {
email: string, email: string,
password: string, password: string,
): Promise<AuthenticationResult> { ): Promise<AuthenticationResult> {
const user = await this.prisma.user.findUnique({ const user = await this.repository.findUserByEmail(email);
where: { email },
});
const valid = await bcrypt.compare( const valid = await bcrypt.compare(
password, password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH, user?.passwordHash ?? DUMMY_PASSWORD_HASH,
@@ -92,10 +80,7 @@ export class AuthService {
if (payload.type !== 'refresh') { if (payload.type !== 'refresh') {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
const session = await this.prisma.session.findUnique({ const session = await this.repository.findSessionWithUser(payload.sid);
where: { id: payload.sid },
include: { user: true },
});
if ( if (
!session || !session ||
session.userId !== payload.sub || session.userId !== payload.sub ||
@@ -103,7 +88,7 @@ export class AuthService {
session.expiresAt <= new Date() session.expiresAt <= new Date()
) { ) {
if (session && !session.revokedAt) { if (session && !session.revokedAt) {
await this.prisma.session.delete({ where: { id: session.id } }); await this.repository.deleteSession(session.id);
} }
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
@@ -124,18 +109,13 @@ export class AuthService {
return; return;
} }
if (payload.type !== 'refresh') return; if (payload.type !== 'refresh') return;
const session = await this.prisma.session.findUnique({ const session = await this.repository.findSession(payload.sid);
where: { id: payload.sid },
});
if ( if (
session && session &&
!session.revokedAt && !session.revokedAt &&
session.refreshTokenHash === this.hash(token) session.refreshTokenHash === this.hash(token)
) { ) {
await this.prisma.session.update({ await this.repository.revokeSession(session.id);
where: { id: session.id },
data: { revokedAt: new Date() },
});
} }
} }
@@ -144,7 +124,7 @@ export class AuthService {
} }
async userById(id: string): Promise<User> { async userById(id: string): Promise<User> {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.repository.findUserById(id);
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return user; return user;
} }
@@ -152,14 +132,7 @@ export class AuthService {
private async createSession( private async createSession(
user: User, user: User,
): Promise<AuthenticationResult> { ): Promise<AuthenticationResult> {
const session = await this.prisma.session.create({ const session = await this.repository.createSession(user.id, randomUUID());
data: {
id: randomUUID(),
userId: user.id,
refreshTokenHash: '',
expiresAt: new Date(),
},
});
return this.rotateSession({ ...session, user }); return this.rotateSession({ ...session, user });
} }
@@ -180,16 +153,8 @@ export class AuthService {
refreshTokenHash: this.hash(refreshToken), refreshTokenHash: this.hash(refreshToken),
expiresAt: new Date(Date.now() + REFRESH_TTL_MS), expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
}; };
if (expectedRefreshTokenHash) { if (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) {
const updated = await this.prisma.session.updateMany({ throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
data,
});
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
} else {
await this.prisma.session.update({ where: { id: session.id }, data });
} }
return { return {
user: this.toUser(session.user), user: this.toUser(session.user),
@@ -205,12 +170,3 @@ export class AuthService {
return createHash('sha256').update(token).digest('hex'); return createHash('sha256').update(token).digest('hex');
} }
} }
function isUniqueConstraintError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
);
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
import { AuthService } from './application/auth.service'; import { AuthService } from './application/auth.service';
import { AuthRepository } from './infrastructure/auth.repository';
import { JwtStrategy } from './infrastructure/jwt.strategy'; import { JwtStrategy } from './infrastructure/jwt.strategy';
import { AuthController } from './presentation/auth.controller'; import { AuthController } from './presentation/auth.controller';
import { JwtAuthGuard } from './presentation/jwt-auth.guard'; import { JwtAuthGuard } from './presentation/jwt-auth.guard';
@@ -22,6 +23,6 @@ import { JwtAuthGuard } from './presentation/jwt-auth.guard';
], ],
exports: [JwtAuthGuard], exports: [JwtAuthGuard],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard], providers: [AuthService, AuthRepository, JwtStrategy, JwtAuthGuard],
}) })
export class AuthModule {} export class AuthModule {}
@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
import { PrismaService } from '#src/prisma/prisma.service';
const EXPECTED_ROTATED_SESSIONS = 1;
@Injectable()
export class AuthRepository {
constructor(private readonly prisma: PrismaService) {}
findUserByEmail(email: string) {
return this.prisma.user.findUnique({ where: { email } });
}
async createUser(data: { email: string; id: string; passwordHash: string }) {
try {
return await this.prisma.user.create({ data });
} catch (error) {
if (isUniqueConstraintError(error)) return null;
throw error;
}
}
findUserById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
findSessionWithUser(id: string) {
return this.prisma.session.findUnique({
where: { id },
include: { user: true },
});
}
findSession(id: string) {
return this.prisma.session.findUnique({ where: { id } });
}
deleteSession(id: string) {
return this.prisma.session.delete({ where: { id } });
}
revokeSession(id: string) {
return this.prisma.session.update({
where: { id },
data: { revokedAt: new Date() },
});
}
createSession(userId: string, id: string) {
return this.prisma.session.create({
data: { id, userId, refreshTokenHash: '', expiresAt: new Date() },
});
}
async rotateSession(
id: string,
data: { expiresAt: Date; refreshTokenHash: string },
expectedRefreshTokenHash?: string,
): Promise<boolean> {
if (expectedRefreshTokenHash) {
const updated = await this.prisma.session.updateMany({
where: { id, refreshTokenHash: expectedRefreshTokenHash },
data,
});
return updated.count === EXPECTED_ROTATED_SESSIONS;
}
await this.prisma.session.update({ where: { id }, data });
return true;
}
}
function isUniqueConstraintError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
);
}
@@ -3,8 +3,9 @@ import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { HttpErrorCode } from '../../../common/error-code'; import { HttpErrorCode } from '#src/common/error-code';
import { Environment } from '../../../config/env.validation'; import { Environment } from '#src/config/env.validation';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@Injectable() @Injectable()
@@ -20,11 +20,12 @@ import {
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { HttpErrorCode } from '../../../common/error-code'; import { HttpErrorCode } from '#src/common/error-code';
import { AuthService } from '../application/auth.service';
import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
import { AccessPayload, AuthenticationResult } from '../application/auth.types'; import { AccessPayload, AuthenticationResult } from '../application/auth.types';
import { CredentialsDto } from './auth.dto'; import { LoginDto, RegisterDto } from './auth.dto';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
const REFRESH_COOKIE = 'refresh_token'; const REFRESH_COOKIE = 'refresh_token';
@@ -56,7 +57,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async register( async register(
@Body() credentials: CredentialsDto, @Body() credentials: RegisterDto,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
return this.respond( return this.respond(
@@ -69,7 +70,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async login( async login(
@Body() credentials: CredentialsDto, @Body() credentials: LoginDto,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
return this.respond( return this.respond(
@@ -134,6 +135,7 @@ export class AuthController {
sameSite: 'lax' as const, sameSite: 'lax' as const,
secure: process.env['NODE_ENV'] !== 'development', secure: process.env['NODE_ENV'] !== 'development',
path: COOKIE_PATH, path: COOKIE_PATH,
maxAge: REFRESH_TTL_MS,
}; };
} }
} }
+28 -10
View File
@@ -1,8 +1,9 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsNotEmpty,
IsString, IsString,
MinLength, Matches,
Validate, Validate,
ValidatorConstraint, ValidatorConstraint,
} from 'class-validator'; } from 'class-validator';
@@ -13,8 +14,10 @@ const FIRST_CHARACTER_INDEX = 0;
const MAX_EMAIL_LENGTH = 254; const MAX_EMAIL_LENGTH = 254;
const ASCII_SPACE = 32; const ASCII_SPACE = 32;
const ASCII_DELETE = 127; const ASCII_DELETE = 127;
const MIN_PASSWORD_LENGTH = 12; // bcrypt reads at most 72 bytes; printable ASCII keeps characters == bytes.
const MIN_PASSWORD_LENGTH = 8;
const MAX_PASSWORD_BYTES = 72; const MAX_PASSWORD_BYTES = 72;
const PASSWORD_PATTERN = `^[\\x20-\\x7E]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_BYTES}}$`;
@ValidatorConstraint({ name: 'email' }) @ValidatorConstraint({ name: 'email' })
class EmailConstraint { class EmailConstraint {
@@ -40,8 +43,9 @@ class EmailConstraint {
} }
} }
@ValidatorConstraint({ name: 'passwordBytes' }) // Login stays lenient: accounts created under an older policy must still sign in.
class PasswordBytesConstraint { @ValidatorConstraint({ name: 'passwordMaxBytes' })
class PasswordMaxBytesConstraint {
validate(value: unknown): boolean { validate(value: unknown): boolean {
return ( return (
typeof value === 'string' && typeof value === 'string' &&
@@ -50,11 +54,11 @@ class PasswordBytesConstraint {
} }
defaultMessage(): string { defaultMessage(): string {
return 'must not exceed 72 UTF-8 bytes'; return `must not exceed ${MAX_PASSWORD_BYTES} UTF-8 bytes`;
} }
} }
export class CredentialsDto { class EmailDto {
@ApiProperty({ example: 'user@example.com' }) @ApiProperty({ example: 'user@example.com' })
@IsString() @IsString()
@Transform(({ value }: { value: unknown }) => @Transform(({ value }: { value: unknown }) =>
@@ -62,14 +66,28 @@ export class CredentialsDto {
) )
@Validate(EmailConstraint) @Validate(EmailConstraint)
declare email: string; declare email: string;
}
export class RegisterDto extends EmailDto {
@ApiProperty({ @ApiProperty({
minLength: MIN_PASSWORD_LENGTH, description: `Raw password: ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.`,
maxLength: MAX_PASSWORD_BYTES, pattern: PASSWORD_PATTERN,
example: 'a secure password', example: 'a secure password',
}) })
@IsString() @IsString()
@MinLength(MIN_PASSWORD_LENGTH) @Matches(new RegExp(PASSWORD_PATTERN), {
@Validate(PasswordBytesConstraint) message: `must be ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters`,
})
declare password: string;
}
export class LoginDto extends EmailDto {
@ApiProperty({
description: `Raw password, at most ${MAX_PASSWORD_BYTES} UTF-8 bytes. Not trimmed or normalized.`,
example: 'a secure password',
})
@IsString()
@IsNotEmpty()
@Validate(PasswordMaxBytesConstraint)
declare password: string; declare password: string;
} }
@@ -5,7 +5,8 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { import {
MAX_CARD_NAME_LENGTH, MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH, MAX_OPERATION_DESCRIPTION_LENGTH,
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BankCard, Prisma } from '../../../../../generated/prisma/client'; import type { BankCard, Prisma } from '#generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { bankBalance } from '../domain/bank-card.calculations'; import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money'; import { rubMoney } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
@@ -20,24 +20,13 @@ import {
missing, missing,
} from './bank-card-input'; } from './bank-card-input';
const EXPECTED_UPDATED_CARDS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankCardsService { export class BankCardsService {
constructor( constructor(private readonly repository: BankCardsRepository) {}
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> { private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
const rows = await tx.bankCardOperation.findMany({ const rows = await this.repository.cardOperations(tx, card);
where: {
userId: card.userId,
deletedAt: null,
OR: [{ cardId: card.id }, { targetCardId: card.id }],
},
});
return { return {
id: card.id, id: card.id,
name: card.name, name: card.name,
@@ -53,20 +42,15 @@ export class BankCardsService {
} }
list(userId: string, includeArchived = false) { list(userId: string, includeArchived = false) {
return this.prisma.$transaction(async (tx) => return this.repository.read(async (tx) =>
Promise.all( Promise.all((await this.repository.cards(tx, userId, includeArchived)).map(
( (card) => this.cardView(tx, card),
await tx.bankCard.findMany({ )),
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
).map((card) => this.cardView(tx, card)),
),
); );
} }
get(userId: string, id: string) { get(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => return this.repository.read(async (tx) =>
this.cardView(tx, await this.repository.card(tx, userId, id)), this.cardView(tx, await this.repository.card(tx, userId, id)),
); );
} }
@@ -75,7 +59,7 @@ export class BankCardsService {
const input = cardInput(dto), const input = cardInput(dto),
hash = fingerprint(input); hash = fingerprint(input);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } }); const existing = await this.repository.cardById(tx, dto.id);
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); if (existing.userId !== userId) missing();
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
@@ -88,8 +72,11 @@ export class BankCardsService {
await this.repository.seed(tx, userId); await this.repository.seed(tx, userId);
return this.cardView( return this.cardView(
tx, tx,
await tx.bankCard.create({ await this.repository.createCard(tx, {
data: { ...input, id: dto.id, userId, createFingerprint: hash }, ...input,
id: dto.id,
userId,
createFingerprint: hash,
}), }),
); );
}); });
@@ -122,20 +109,16 @@ export class BankCardsService {
} }
currentDate(input.openingDate); currentDate(input.openingDate);
if ( if (
await tx.bankCardOperation.findFirst({ await this.repository.hasOperationsBefore(tx, userId, id, input.openingDate)
where: {
userId,
deletedAt: null,
date: { lt: input.openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
})
) { ) {
fail(BankErrorCode.OpeningDateConflict); fail(BankErrorCode.OpeningDateConflict);
} }
const changed = await tx.bankCard.updateMany({ const updated = await this.repository.updateCard(
where: { id, userId, version: dto.version }, tx,
data: { userId,
id,
dto.version,
{
...input, ...input,
paymentSystem: input.paymentSystem ?? null, paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 }, version: { increment: 1 },
@@ -147,9 +130,8 @@ export class BankCardsService {
: null, : null,
}), }),
}, },
}); );
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict(); return this.cardView(tx, updated);
return this.cardView(tx, await this.repository.card(tx, userId, id));
}); });
} }
} }
@@ -1,12 +1,16 @@
import { GoneException, Injectable } from '@nestjs/common'; import { GoneException, Injectable } from '@nestjs/common';
import { import type {
BankCardOperation, BankCardOperation,
Prisma, Prisma,
} from '../../../../../generated/prisma/client'; } from '#generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { FIRST_PAGE } from '../domain/bank-card-limits'; import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind'; import { BankOperationKind } from '../domain/bank-operation-kind';
import {
operationFieldConflict,
operationKindChanged,
} from '../domain/bank-operation.rules';
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money'; import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { import {
@@ -28,67 +32,67 @@ import {
periodBounds, periodBounds,
} from './bank-card-input'; } from './bank-card-input';
const EXPECTED_UPDATED_ROWS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankOperationsService { export class BankOperationsService {
constructor( constructor(private readonly repository: BankCardsRepository) {}
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async operationView( private async operationView(
tx: Tx, tx: Tx,
row: BankCardOperation, row: BankCardOperation,
): Promise<BankOperationDto> { ): Promise<BankOperationDto> {
const card = await this.repository.card(tx, row.userId, row.cardId); return (await this.pageViews(tx, [row]))[0];
const target = row.targetCardId }
? await this.repository.card(tx, row.userId, row.targetCardId)
: null; private async pageViews(tx: Tx, rows: BankCardOperation[]) {
const source = row.originalExpenseId const userId = rows[0]?.userId;
? await this.repository.operation(tx, row.userId, row.originalExpenseId) if (!userId) return [];
: null; const { cards, sources, categories, refunds } = await this.repository.operationViewData(tx, userId, rows);
const categoryId = source?.categoryId ?? row.categoryId; const cardsById = new Map(cards.map((card) => [card.id, card]));
const category = categoryId const sourcesById = new Map(sources.map((source) => [source.id, source]));
? await tx.expenseCategory.findFirst({ const categoriesById = new Map(categories.map((category) => [category.id, category]));
where: { id: categoryId, userId: row.userId }, return rows.map((row) => {
}) const card = cardsById.get(row.cardId) as { name: string };
: null; const target = row.targetCardId ? cardsById.get(row.targetCardId) : null;
const refunds = const source = row.originalExpenseId
row.kind === BankOperationKind.Expense || source ? sourcesById.get(row.originalExpenseId)
? await tx.bankCardOperation.findMany({ : null;
where: { const categoryId = source?.categoryId ?? row.categoryId;
userId: row.userId, const relatedRefunds =
originalExpenseId: source?.id ?? row.id,
deletedAt: null,
...(source ? { id: { not: row.id } } : {}),
},
})
: [];
return {
id: row.id,
kind: row.kind as BankOperationDto['kind'],
cardId: row.cardId,
cardName: card.name,
targetCardId: row.targetCardId,
targetCardName: target?.name ?? null,
categoryId,
categoryName: category?.name ?? null,
originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks),
availableRefund:
row.kind === BankOperationKind.Expense || source row.kind === BankOperationKind.Expense || source
? rubMoney( ? refunds.filter(
(source?.amountKopecks ?? row.amountKopecks) - (refund) =>
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS), refund.originalExpenseId === (source?.id ?? row.id) &&
(!source || refund.id !== row.id),
) )
: null, : [];
date: row.date, return {
description: row.description, id: row.id,
version: row.version, kind: row.kind as BankOperationDto['kind'],
createdAt: row.createdAt.toISOString(), cardId: row.cardId,
}; cardName: card.name,
targetCardId: row.targetCardId,
targetCardName: target?.name ?? null,
categoryId,
categoryName: categoriesById.get(categoryId ?? '')?.name ?? null,
originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks),
availableRefund:
row.kind === BankOperationKind.Expense || source
? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) -
relatedRefunds.reduce(
(sum, refund) => sum + refund.amountKopecks,
ZERO_KOPECKS,
),
)
: null,
date: row.date,
description: row.description,
version: row.version,
createdAt: row.createdAt.toISOString(),
};
});
} }
private async validateOperation( private async validateOperation(
@@ -112,49 +116,28 @@ export class BankOperationsService {
fail(BankErrorCode.InvalidDate); fail(BankErrorCode.InvalidDate);
} }
} }
if ( if (old && operationKindChanged(old.kind, input.kind,
old && old.originalExpenseId, input.originalExpenseId)) {
(old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId)
) {
fail(BankErrorCode.ImmutableOperation); fail(BankErrorCode.ImmutableOperation);
} }
if (input.kind === BankOperationKind.Transfer) { if (operationFieldConflict(input)) fail();
if (
!input.targetCardId ||
input.targetCardId === input.cardId ||
input.categoryId ||
input.originalExpenseId
) {
fail();
}
} else if (input.targetCardId) fail();
if (input.kind === BankOperationKind.Expense) { if (input.kind === BankOperationKind.Expense) {
if (input.originalExpenseId) fail();
if (!input.categoryId) { if (!input.categoryId) {
const category = await tx.expenseCategory.findUnique({ const category = await this.repository.defaultCategory(tx, userId);
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
if (!category) missing(); if (!category) missing();
input.categoryId = category.id; input.categoryId = category.id;
} }
if ( if (
!(await tx.expenseCategory.findFirst({ !(await this.repository.category(tx, userId, input.categoryId))
where: { userId, id: input.categoryId },
}))
) { ) {
missing(); missing();
} }
} else if (input.categoryId) fail();
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
fail();
} }
if (input.kind === BankOperationKind.Refund) { if (input.kind === BankOperationKind.Refund) {
if (!input.originalExpenseId) fail();
const source = await this.repository.operation( const source = await this.repository.operation(
tx, tx,
userId, userId,
input.originalExpenseId, input.originalExpenseId as string,
); );
if ( if (
source.deletedAt || source.deletedAt ||
@@ -163,29 +146,11 @@ export class BankOperationsService {
) { ) {
fail(BankErrorCode.InvalidRefund); fail(BankErrorCode.InvalidRefund);
} }
if ( await this.repository.updateOperation(tx,
( { id: source.id, userId, version: source.version, deletedAt: null },
await tx.bankCardOperation.updateMany({ { version: { increment: 1 } },
where: { );
id: source.id, const refunds = await this.repository.refunds(tx, userId, source.id, old?.id);
userId,
version: source.version,
deletedAt: null,
},
data: { version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
const refunds = await tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId: source.id,
deletedAt: null,
...(old ? { id: { not: old.id } } : {}),
},
});
if (input.date < source.date) fail(BankErrorCode.InvalidDate); if (input.date < source.date) fail(BankErrorCode.InvalidDate);
if ( if (
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) + refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
@@ -196,9 +161,7 @@ export class BankOperationsService {
} }
} }
if (old?.kind === BankOperationKind.Expense) { if (old?.kind === BankOperationKind.Expense) {
const refunds = await tx.bankCardOperation.findMany({ const refunds = await this.repository.refunds(tx, userId, old.id);
where: { userId, originalExpenseId: old.id, deletedAt: null },
});
if ( if (
refunds.length && refunds.length &&
(input.cardId !== old.cardId || (input.cardId !== old.cardId ||
@@ -215,9 +178,7 @@ export class BankOperationsService {
const normalized = operationInput(dto), const normalized = operationInput(dto),
hash = fingerprint(normalized); hash = fingerprint(normalized);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const existing = await tx.bankCardOperation.findUnique({ const existing = await this.repository.operationById(tx, dto.id);
where: { id: dto.id },
});
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); if (existing.userId !== userId) missing();
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
@@ -233,15 +194,15 @@ export class BankOperationsService {
await this.validateOperation(tx, userId, input); await this.validateOperation(tx, userId, input);
return this.operationView( return this.operationView(
tx, tx,
await tx.bankCardOperation.create({ await this.repository.createOperation(tx, {
data: { ...input, id: dto.id, userId, createFingerprint: hash }, ...input, id: dto.id, userId, createFingerprint: hash,
}), }),
); );
}); });
} }
getOperation(userId: string, id: string) { getOperation(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => { return this.repository.read(async (tx) => {
const row = await this.repository.operation(tx, userId, id); const row = await this.repository.operation(tx, userId, id);
if (row.deletedAt) missing(); if (row.deletedAt) missing();
return this.operationView(tx, row); return this.operationView(tx, row);
@@ -266,16 +227,10 @@ export class BankOperationsService {
description: dto.description ?? old.description ?? undefined, description: dto.description ?? old.description ?? undefined,
}); });
await this.validateOperation(tx, userId, input, old); await this.validateOperation(tx, userId, input, old);
if ( await this.repository.updateOperation(tx,
( { id, userId, version: dto.version, deletedAt: null },
await tx.bankCardOperation.updateMany({ { ...input, version: { increment: 1 } },
where: { id, userId, version: dto.version, deletedAt: null }, );
data: { ...input, version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
return this.operationView( return this.operationView(
tx, tx,
await this.repository.operation(tx, userId, id), await this.repository.operation(tx, userId, id),
@@ -294,9 +249,7 @@ export class BankOperationsService {
} }
if ( if (
row.kind === BankOperationKind.Expense && row.kind === BankOperationKind.Expense &&
(await tx.bankCardOperation.findFirst({ (await this.repository.dependentRefund(tx, userId, id))
where: { userId, originalExpenseId: id, deletedAt: null },
}))
) { ) {
conflict(BankErrorCode.DependentRefunds); conflict(BankErrorCode.DependentRefunds);
} }
@@ -306,27 +259,15 @@ export class BankOperationsService {
userId, userId,
row.originalExpenseId, row.originalExpenseId,
); );
if ( await this.repository.updateOperation(tx,
( { id: source.id, userId, version: source.version },
await tx.bankCardOperation.updateMany({ { version: { increment: 1 } },
where: { id: source.id, userId, version: source.version }, );
data: { version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
}
if (
(
await tx.bankCardOperation.updateMany({
where: { id, userId, version, deletedAt: null },
data: { deletedAt: new Date(), version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
} }
await this.repository.updateOperation(tx,
{ id, userId, version, deletedAt: null },
{ deletedAt: new Date(), version: { increment: 1 } },
);
}); });
} }
@@ -339,29 +280,15 @@ export class BankOperationsService {
const period = expenses const period = expenses
? undefined ? undefined
: periodBounds(query as BankOperationsQueryDto); : periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => { return this.repository.read(async (tx) => {
await this.repository.card(tx, userId, id); await this.repository.card(tx, userId, id);
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
page = query.page ?? FIRST_PAGE; page = query.page ?? FIRST_PAGE;
const where: Prisma.BankCardOperationWhereInput = { const { total, rows } = await this.repository.operationPage(
userId, tx, userId, id, period, limit, page,
deletedAt: null, );
OR: [{ cardId: id }, { targetCardId: id }],
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: BankOperationKind.Expense }),
};
const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({
where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - FIRST_PAGE) * limit,
take: limit,
});
return { return {
items: await Promise.all( items: await this.pageViews(tx, rows),
rows.map((row) => this.operationView(tx, row)),
),
total, total,
page, page,
hasNext: page * limit < total, hasNext: page * limit < total,
@@ -1,81 +1,24 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../../../prisma/prisma.service';
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { conflict, missing, name } from './bank-card-input'; import { name } from './bank-card-input';
const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 1;
@Injectable() @Injectable()
export class ExpenseCategoriesService { export class ExpenseCategoriesService {
constructor( constructor(private readonly repository: BankCardsRepository) {}
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
async categories(userId: string) { async categories(userId: string) {
return ( return this.repository.categories(userId);
await this.prisma.expenseCategory.findMany({
where: { userId },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
})
).map(({ id, name, seedKey, version }) => ({
id,
name,
isDefault: seedKey === 'other',
version,
}));
} }
createCategory(userId: string, value: string) { createCategory(userId: string, value: string) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => { return this.repository.createCategory(userId, label);
await this.repository.seed(tx, userId);
const row = await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
return {
id: row.id,
name: row.name,
isDefault: false,
version: row.version,
};
});
} }
updateCategory(userId: string, id: string, value: string, version: number) { updateCategory(userId: string, id: string, value: string, version: number) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => { return this.repository.updateCategory(userId, id, label, version);
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
if (!row) missing();
if (
(
await tx.expenseCategory.updateMany({
where: { id, userId, version },
data: {
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
version: { increment: 1 },
},
})
).count !== EXPECTED_UPDATED_CATEGORIES
) {
conflict();
}
return {
id,
name: label,
isDefault: row.seedKey === 'other',
version: version + VERSION_INCREMENT,
};
});
} }
} }
@@ -1,8 +1,9 @@
jest.mock('../../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
jest.mock('../../../../../generated/prisma/client', () => ({ Prisma: {} })); jest.mock('#generated/prisma/client', () => ({ Prisma: {} }));
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '#src/prisma/prisma.service';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { GetBankSummaryUseCase } from './get-bank-summary.use-case'; import { GetBankSummaryUseCase } from './get-bank-summary.use-case';
@@ -1,4 +1,5 @@
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { BankOperationKind } from './bank-operation-kind'; import { BankOperationKind } from './bank-operation-kind';
import { ZERO_KOPECKS } from './rub-money'; import { ZERO_KOPECKS } from './rub-money';
@@ -0,0 +1,141 @@
import { BankOperationKind } from './bank-operation-kind';
import {
operationFieldConflict,
OperationFieldRule,
operationKindChanged,
} from './bank-operation.rules';
const cardId = 'card-1';
function fields(
overrides: Partial<Parameters<typeof operationFieldConflict>[0]> = {},
) {
return { kind: BankOperationKind.Topup, cardId, ...overrides };
}
describe('operationFieldConflict', () => {
it('allows a valid topup', () => {
expect(operationFieldConflict(fields())).toBeNull();
});
it('rejects a topup with a purchase link', () => {
expect(
operationFieldConflict(
fields({ originalExpenseId: 'op-1' }),
),
).toBe(OperationFieldRule.InvalidCombination);
});
it('rejects a non-transfer with a target card', () => {
expect(
operationFieldConflict(fields({ targetCardId: 'card-2' })),
).toBe(OperationFieldRule.InvalidCombination);
});
describe('expense', () => {
it('allows the default category to be resolved by the service', () => {
expect(
operationFieldConflict(
fields({ kind: BankOperationKind.Expense }),
),
).toBeNull();
});
it('rejects a purchase link', () => {
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Expense,
categoryId: 'cat-1',
originalExpenseId: 'op-1',
}),
),
).toBe(OperationFieldRule.InvalidCombination);
});
it('allows a plain categorized expense', () => {
expect(
operationFieldConflict(
fields({ kind: BankOperationKind.Expense, categoryId: 'cat-1' }),
),
).toBeNull();
});
});
describe('transfer', () => {
it('requires a target card', () => {
expect(
operationFieldConflict(fields({ kind: BankOperationKind.Transfer })),
).toBe(OperationFieldRule.MissingTargetCard);
});
it('rejects a transfer to the same card', () => {
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Transfer,
targetCardId: cardId,
}),
),
).toBe(OperationFieldRule.SameTargetCard);
});
it('rejects a transfer with a category or purchase link', () => {
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Transfer,
targetCardId: 'card-2',
categoryId: 'cat-1',
}),
),
).toBe(OperationFieldRule.InvalidCombination);
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Transfer,
targetCardId: 'card-2',
originalExpenseId: 'op-1',
}),
),
).toBe(OperationFieldRule.InvalidCombination);
});
});
describe('refund', () => {
it('requires a purchase link', () => {
expect(
operationFieldConflict(fields({ kind: BankOperationKind.Refund })),
).toBe(OperationFieldRule.MissingOriginalExpense);
});
it('rejects a category', () => {
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Refund,
originalExpenseId: 'op-1',
categoryId: 'cat-1',
}),
),
).toBe(OperationFieldRule.InvalidCombination);
});
it('allows a linked refund', () => {
expect(
operationFieldConflict(
fields({ kind: BankOperationKind.Refund, originalExpenseId: 'op-1' }),
),
).toBeNull();
});
});
});
describe('operationKindChanged', () => {
it('flags kind and purchase link changes', () => {
expect(operationKindChanged('expense', 'expense', 'op-1', 'op-1')).toBe(false);
expect(operationKindChanged('expense', 'refund', null, null)).toBe(true);
expect(operationKindChanged('refund', 'refund', 'op-1', null)).toBe(true);
expect(operationKindChanged('refund', 'refund', null, 'op-1')).toBe(true);
});
});
@@ -0,0 +1,79 @@
import { BankOperationKind } from './bank-operation-kind';
export type OperationFields = {
cardId: string;
categoryId?: null | string;
kind: BankOperationKind;
originalExpenseId?: null | string;
targetCardId?: null | string;
};
export enum OperationFieldRule {
InvalidCombination = 'invalidCombination',
MissingOriginalExpense = 'missingOriginalExpense',
MissingTargetCard = 'missingTargetCard',
SameTargetCard = 'sameTargetCard',
}
function transferConflict(fields: OperationFields): null | OperationFieldRule {
if (!fields.targetCardId) {
return OperationFieldRule.MissingTargetCard;
}
if (fields.targetCardId === fields.cardId) {
return OperationFieldRule.SameTargetCard;
}
if (fields.categoryId || fields.originalExpenseId) {
return OperationFieldRule.InvalidCombination;
}
return null;
}
function expenseConflict(fields: OperationFields): null | OperationFieldRule {
if (fields.originalExpenseId) {
return OperationFieldRule.InvalidCombination;
}
// Expense without a category uses the user's default category in the service.
return null;
}
function refundConflict(fields: OperationFields): null | OperationFieldRule {
return fields.originalExpenseId
? null
: OperationFieldRule.MissingOriginalExpense;
}
/**
* Pure kind/field compatibility rules for bank operations; no Prisma or Nest.
* Returns the first violated rule, or null when the combination is allowed.
*/
export function operationFieldConflict(
fields: OperationFields,
): null | OperationFieldRule {
if (fields.kind === BankOperationKind.Transfer) {
return transferConflict(fields);
}
if (fields.targetCardId) return OperationFieldRule.InvalidCombination;
if (fields.kind === BankOperationKind.Expense) {
return expenseConflict(fields);
}
if (fields.categoryId) return OperationFieldRule.InvalidCombination;
if (fields.kind === BankOperationKind.Refund) {
return refundConflict(fields);
}
// Topup: no target, no category, no purchase link.
return fields.originalExpenseId
? OperationFieldRule.InvalidCombination
: null;
}
export function operationKindChanged(
oldKind: string,
newKind: string,
oldOriginalExpenseId: null | string,
newOriginalExpenseId: null | string,
): boolean {
return (
oldKind !== newKind ||
oldOriginalExpenseId !== newOriginalExpenseId
);
}
@@ -1,6 +1,6 @@
import type { Money } from '../../../../common/money.types'; import type { Money } from '#src/common/money.types';
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal'; import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '#src/common/decimal';
export const ZERO_KOPECKS = 0n; export const ZERO_KOPECKS = 0n;
@@ -1,18 +1,24 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../../../generated/prisma/client'; import { BankCard, BankCardOperation, Prisma } from '#generated/prisma/client';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code'; import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '#src/prisma/prisma.service';
import { import {
BankErrorCode, BankErrorCode,
conflict, conflict,
missing, missing,
} from '../application/bank-card-input'; } from '../application/bank-card-input';
import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind'; import { BankOperationKind } from '../domain/bank-operation-kind';
const INITIAL_RETRY_COUNT = 0; const INITIAL_RETRY_COUNT = 0;
const LAST_SEED_OFFSET = 1; const LAST_SEED_OFFSET = 1;
const EXPECTED_UPDATED_CARDS = 1;
const EXPECTED_UPDATED_OPERATIONS = 1;
const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
const TRANSACTION_MAX_WAIT_MS = 10_000; const TRANSACTION_MAX_WAIT_MS = 10_000;
@@ -32,6 +38,220 @@ const seeds = [
@Injectable() @Injectable()
export class BankCardsRepository { export class BankCardsRepository {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
read<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
return this.prisma.$transaction(run);
}
cards(tx: Tx, userId: string, includeArchived = false) {
return tx.bankCard.findMany({
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
}
cardOperations(tx: Tx, card: BankCard) {
return tx.bankCardOperation.findMany({
where: {
userId: card.userId,
deletedAt: null,
OR: [{ cardId: card.id }, { targetCardId: card.id }],
},
});
}
operationById(tx: Tx, id: string) {
return tx.bankCardOperation.findUnique({ where: { id } });
}
createOperation(tx: Tx, data: Prisma.BankCardOperationUncheckedCreateInput) {
return tx.bankCardOperation.create({ data });
}
async updateOperation(
tx: Tx,
where: Prisma.BankCardOperationWhereInput,
data: Prisma.BankCardOperationUpdateManyMutationInput,
) {
const result = await tx.bankCardOperation.updateMany({ where, data });
if (result.count !== EXPECTED_UPDATED_OPERATIONS) conflict();
}
category(tx: Tx, userId: string, id: string) {
return tx.expenseCategory.findFirst({ where: { id, userId } });
}
defaultCategory(tx: Tx, userId: string) {
return tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
}
refunds(tx: Tx, userId: string, originalExpenseId: string, excludeId?: string) {
return tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId,
deletedAt: null,
...(excludeId ? { id: { not: excludeId } } : {}),
},
});
}
dependentRefund(tx: Tx, userId: string, originalExpenseId: string) {
return tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId, deletedAt: null },
});
}
async operationViewData(tx: Tx, userId: string, rows: BankCardOperation[]) {
const cardIds = new Set(rows.flatMap((row) =>
[row.cardId, row.targetCardId].filter((id): id is string => id !== null),
));
const sourceIds = new Set(rows.flatMap((row) =>
row.originalExpenseId ? [row.originalExpenseId] : [],
));
const [cards, sources] = await Promise.all([
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
tx.bankCardOperation.findMany({ where: { userId, id: { in: [...sourceIds] } } }),
]);
const categoryIds = new Set(rows.map((row) =>
sources.find((source) => source.id === row.originalExpenseId)?.categoryId ?? row.categoryId,
).filter((id): id is string => id !== null));
const expenseIds = new Set(rows.flatMap((row) => {
const source = sources.find((item) => item.id === row.originalExpenseId);
return row.kind === BankOperationKind.Expense || source ? [source?.id ?? row.id] : [];
}));
const [categories, refunds] = await Promise.all([
tx.expenseCategory.findMany({ where: { userId, id: { in: [...categoryIds] } } }),
tx.bankCardOperation.findMany({
where: { userId, originalExpenseId: { in: [...expenseIds] }, deletedAt: null },
}),
]);
return { cards, sources, categories, refunds };
}
async operationPage(
tx: Tx,
userId: string,
id: string,
period: undefined | { from: string; to: string },
limit: number,
page: number,
) {
const where: Prisma.BankCardOperationWhereInput = {
userId,
deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: BankOperationKind.Expense }),
};
const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({
where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - FIRST_PAGE) * limit,
take: limit,
});
return { total, rows };
}
cardById(tx: Tx, id: string) {
return tx.bankCard.findUnique({ where: { id } });
}
createCard(tx: Tx, data: Prisma.BankCardUncheckedCreateInput) {
return tx.bankCard.create({ data });
}
async updateCard(
tx: Tx,
userId: string,
id: string,
version: number,
data: Prisma.BankCardUpdateManyMutationInput,
) {
const changed = await tx.bankCard.updateMany({
where: { id, userId, version },
data,
});
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
return this.card(tx, userId, id);
}
hasOperationsBefore(tx: Tx, userId: string, id: string, openingDate: string) {
return tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
});
}
async categories(userId: string) {
return (
await this.prisma.expenseCategory.findMany({
where: { userId },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
})
).map(({ id, name, seedKey, version }) => ({
id,
name,
isDefault: seedKey === 'other',
version,
}));
}
createCategory(userId: string, label: string) {
return this.write(async (tx) => {
await this.seed(tx, userId);
const row = await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
return {
id: row.id,
name: row.name,
isDefault: false,
version: row.version,
};
});
}
updateCategory(userId: string, id: string, label: string, version: number) {
return this.write(async (tx) => {
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
if (!row) missing();
if (
(
await tx.expenseCategory.updateMany({
where: { id, userId, version },
data: {
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
version: { increment: 1 },
},
})
).count !== EXPECTED_UPDATED_CATEGORIES
) {
conflict();
}
return {
id,
name: label,
isDefault: row.seedKey === 'other',
version: version + VERSION_INCREMENT,
};
});
}
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> { async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) { for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
try { try {
@@ -1,6 +1,7 @@
import { import {
ApiProperty, ApiProperty,
ApiPropertyOptional, ApiPropertyOptional,
getSchemaPath,
IntersectionType, IntersectionType,
OmitType, OmitType,
PartialType, PartialType,
@@ -23,7 +24,8 @@ import {
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '#src/common/money.dto';
import { import {
FIRST_PAGE, FIRST_PAGE,
MAX_CARD_NAME_LENGTH, MAX_CARD_NAME_LENGTH,
@@ -153,6 +155,49 @@ export class CreateBankOperationDto {
@MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH) @MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
description?: string; description?: string;
} }
// OpenAPI-only variants. Runtime validation stays on CreateBankOperationDto so
// invalid combinations keep the existing BANK_INVALID_INPUT response.
class BankOperationCreateBaseDto extends OmitType(CreateBankOperationDto, [
'kind', 'targetCardId', 'categoryId', 'originalExpenseId',
] as const) {}
export class CreateExpenseOperationDto extends BankOperationCreateBaseDto {
@ApiProperty({ enum: [BankOperationKind.Expense] }) declare kind: 'expense';
@ApiPropertyOptional({ format: 'uuid' }) declare categoryId?: string;
}
export class CreateTopupOperationDto extends BankOperationCreateBaseDto {
@ApiProperty({ enum: [BankOperationKind.Topup] }) declare kind: 'topup';
}
export class CreateTransferOperationDto extends BankOperationCreateBaseDto {
@ApiProperty({ enum: [BankOperationKind.Transfer] }) declare kind: 'transfer';
@ApiProperty({ format: 'uuid' }) declare targetCardId: string;
}
export class CreateRefundOperationDto extends BankOperationCreateBaseDto {
@ApiProperty({ enum: [BankOperationKind.Refund] }) declare kind: 'refund';
@ApiProperty({ format: 'uuid' }) declare originalExpenseId: string;
}
export const createBankOperationSchema = {
oneOf: [
CreateExpenseOperationDto,
CreateTopupOperationDto,
CreateTransferOperationDto,
CreateRefundOperationDto,
].map((model) => ({ $ref: getSchemaPath(model) })),
discriminator: {
propertyName: 'kind',
mapping: {
expense: getSchemaPath(CreateExpenseOperationDto),
topup: getSchemaPath(CreateTopupOperationDto),
transfer: getSchemaPath(CreateTransferOperationDto),
refund: getSchemaPath(CreateRefundOperationDto),
},
},
};
export class UpdateBankOperationDto extends PartialType( export class UpdateBankOperationDto extends PartialType(
OmitType(CreateBankOperationDto, ['id'] as const), OmitType(CreateBankOperationDto, ['id'] as const),
{ skipNullProperties: false }, { skipNullProperties: false },
@@ -16,7 +16,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody,
ApiCreatedResponse, ApiCreatedResponse,
ApiExtraModels,
ApiNoContentResponse, ApiNoContentResponse,
ApiOkResponse, ApiOkResponse,
ApiResponse, ApiResponse,
@@ -27,8 +29,9 @@ import {
import { import {
ApiProblemResponses, ApiProblemResponses,
ProblemDetailsDto, ProblemDetailsDto,
} from '../../../../common/decorators/api-safe-error-response.decorator'; } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
import { BankCardsService } from '../application/bank-cards.service'; import { BankCardsService } from '../application/bank-cards.service';
import { BankOperationsService } from '../application/bank-operations.service'; import { BankOperationsService } from '../application/bank-operations.service';
import { ExpenseCategoriesService } from '../application/expense-categories.service'; import { ExpenseCategoriesService } from '../application/expense-categories.service';
@@ -46,6 +49,11 @@ import {
CategoryNameDto, CategoryNameDto,
CreateBankCardDto, CreateBankCardDto,
CreateBankOperationDto, CreateBankOperationDto,
createBankOperationSchema,
CreateExpenseOperationDto,
CreateRefundOperationDto,
CreateTopupOperationDto,
CreateTransferOperationDto,
ExpenseCategoryDto, ExpenseCategoryDto,
UpdateBankCardDto, UpdateBankCardDto,
UpdateBankOperationDto, UpdateBankOperationDto,
@@ -73,6 +81,12 @@ type AuthRequest = Request & { user: AccessPayload };
}, },
}) })
@ApiTags('bank-cards') @ApiTags('bank-cards')
@ApiExtraModels(
CreateExpenseOperationDto,
CreateTopupOperationDto,
CreateTransferOperationDto,
CreateRefundOperationDto,
)
@ApiBearerAuth() @ApiBearerAuth()
@ApiProblemResponses() @ApiProblemResponses()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -131,6 +145,7 @@ export class BankCardsController {
} }
@Post('operations') @Post('operations')
@ApiBody({ schema: createBankOperationSchema })
@ApiCreatedResponse({ type: BankOperationDto }) @ApiCreatedResponse({ type: BankOperationDto })
createOperation( createOperation(
@Req() req: AuthRequest, @Req() req: AuthRequest,
@@ -1,11 +1,37 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../common/money.dto'; import { MoneyDto } from '#src/common/money.dto';
import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { PortfolioStructureDto } from '../portfolio/portfolio.api';
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
export class UnrealizedReturnDto {
@ApiProperty({ example: '12.345678901' })
declare absolute: string;
@ApiProperty({ example: '100.00' })
declare costBasis: string;
@ApiProperty({ type: String, nullable: true, example: '12.34' })
declare percent: null | string;
@ApiProperty()
declare partial: boolean;
}
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto })
declare totalsByCurrency: MoneyDto[]; declare totals: MoneyDto;
@ApiProperty({ type: MoneyDto })
declare cash: MoneyDto;
@ApiProperty({ type: PortfolioStructureDto })
declare structure: PortfolioStructureDto;
@ApiProperty({ type: UnrealizedReturnDto })
declare unrealizedReturn: UnrealizedReturnDto;
@ApiProperty({ @ApiProperty({
type: 'integer', type: 'integer',
@@ -20,6 +46,20 @@ export class PortfolioSummaryDto {
description: 'Number of non-zero bond positions, not the number of units.', description: 'Number of non-zero bond positions, not the number of units.',
}) })
declare bondPositionCount: number; declare bondPositionCount: number;
@ApiProperty({
type: 'integer',
minimum: 0,
description: 'Number of non-zero fund positions, not the number of units.',
})
declare fundPositionCount: number;
@ApiProperty({
type: 'integer',
minimum: 0,
description: 'Number of all non-zero positions, not the number of units.',
})
declare totalPositionCount: number;
} }
export class BrokerageAccountDto { export class BrokerageAccountDto {
@@ -55,4 +95,12 @@ export class BrokerageAccountsResourceDto {
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
@ApiProperty({
type: HourlySyncStatusDto,
nullable: true,
description:
'Read-only status of the last hourly walk for the current connection. Null when no walk attempt exists yet.',
})
declare sync: HourlySyncStatusDto | null;
} }
@@ -1,11 +1,11 @@
import type { Request } from 'express'; import type { Request } from 'express';
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { BrokerageAccountsResourceDto } from './brokerage-account.dto'; import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -19,10 +19,7 @@ export class BrokerageAccountsController {
@Get() @Get()
@ApiOkResponse({ type: BrokerageAccountsResourceDto }) @ApiOkResponse({ type: BrokerageAccountsResourceDto })
list( list(@Req() request: Request & { user: AccessPayload }) {
@Req() request: Request & { user: AccessPayload }, return this.accounts.list(request.user.sub);
@Query() query: InvestmentReadQueryDto,
) {
return this.accounts.list(request.user.sub, query.sync === 'observe');
} }
} }
@@ -1,13 +1,15 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '#src/integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsController } from './brokerage-accounts.controller';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@Module({ @Module({
imports: [AuthModule, TbankModule, PortfolioModule], imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncRepositoryModule],
controllers: [BrokerageAccountsController], controllers: [BrokerageAccountsController],
providers: [BrokerageAccountsService], providers: [BrokerageAccountsService],
exports: [BrokerageAccountsService], exports: [BrokerageAccountsService],
@@ -1,4 +1,4 @@
jest.mock('../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
@@ -19,19 +19,21 @@ const meta = investmentResourceMeta({
describe('saved brokerage account projection', () => { describe('saved brokerage account projection', () => {
const portfolio = { saved: jest.fn() }; const portfolio = { saved: jest.fn() };
const service = new BrokerageAccountsService(portfolio as never); const sync = { hourly: jest.fn().mockResolvedValue(null) };
const service = new BrokerageAccountsService(portfolio as never, sync as never);
it('distinguishes unknown membership from a confirmed empty list', async () => { it('distinguishes unknown membership from a confirmed empty list', async () => {
portfolio.saved.mockResolvedValueOnce({ portfolio.saved.mockResolvedValueOnce({
membership: { ...meta, availability: 'missing' }, membership: { ...meta, completeness: 'incomplete' },
accounts: [], accounts: [],
}); });
expect((await service.list('owner', true)).data).toBeNull(); expect((await service.list('owner')).data).toBeNull();
portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] }); portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] });
expect((await service.list('owner', true)).data).toEqual([]); expect((await service.list('owner')).data).toEqual([]);
}); });
it('keeps exact saved totals and independent missing summaries', async () => { it('keeps exact saved totals and independent missing summaries', async () => {
const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
portfolio.saved.mockResolvedValue({ portfolio.saved.mockResolvedValue({
membership: meta, membership: meta,
accounts: [ accounts: [
@@ -42,10 +44,10 @@ describe('saved brokerage account projection', () => {
openedOn: null, openedOn: null,
meta, meta,
snapshot: { snapshot: {
totalsByCurrency: [ cash: { currency: 'RUB', value: '10.000000001' },
{ currency: 'RUB', value: '100000000.000000001' }, structure,
], totals: { currency: 'RUB', value: '100000000.000000001' },
positions: [{ type: 'STOCK' }, { type: 'BOND' }], positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }],
}, },
}, },
{ {
@@ -58,13 +60,113 @@ describe('saved brokerage account projection', () => {
}, },
], ],
}); });
const response = await service.list('owner', true); const response = await service.list('owner');
expect(response.data?.[0].portfolioSummary).toEqual({ expect(response.data?.[0].portfolioSummary).toEqual({
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }], cash: { currency: 'RUB', value: '10.000000001' },
structure,
totals: { currency: 'RUB', value: '100000000.000000001' },
stockPositionCount: 1, stockPositionCount: 1,
bondPositionCount: 1, bondPositionCount: 1,
fundPositionCount: 0,
totalPositionCount: 2,
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
}); });
expect(response.data?.[1].portfolioSummary).toBeNull(); 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('returns an empty summary from an empty saved portfolio', async () => {
const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
portfolio.saved.mockResolvedValueOnce({
membership: meta,
accounts: [{
id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta,
snapshot: { cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' }, positions: [] },
}],
});
expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({
cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' },
stockPositionCount: 0, bondPositionCount: 0,
fundPositionCount: 0, totalPositionCount: 0,
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
});
});
it('calculates exact returns, partial data, and zero cost basis', async () => {
portfolio.saved.mockResolvedValueOnce({
membership: meta,
accounts: [{
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
snapshot: {
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '100' },
positions: [
{ type: 'STOCK', quantity: '2', averagePurchasePrice: { currency: 'RUB', value: '10.000000001' }, absoluteReturn: { currency: 'RUB', value: '0.000000003' } },
{ type: 'BOND', quantity: '0.5', averagePurchasePrice: { currency: 'RUB', value: '20' }, absoluteReturn: { currency: 'RUB', value: '1.25' } },
{ type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'RUB', value: '2.5' }, absoluteReturn: null },
{ type: 'BOND', quantity: '1', currentValue: { currency: 'RUB', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
],
},
}],
});
expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({
stockPositionCount: 1, bondPositionCount: 2,
fundPositionCount: 1, totalPositionCount: 4,
unrealizedReturn: { absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: true },
});
});
it('omits return percent for short positions', async () => {
portfolio.saved.mockResolvedValueOnce({
membership: meta,
accounts: [{
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
snapshot: {
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '0' },
positions: [
{ type: 'STOCK', quantity: '-2', averagePurchasePrice: { currency: 'RUB', value: '100' }, absoluteReturn: { currency: 'RUB', value: '-20' } },
{ type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'RUB', value: '10' }, absoluteReturn: { currency: 'RUB', value: '1' } },
],
},
}],
});
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturn).toEqual(
{ absolute: '-19', costBasis: '-190', percent: null, partial: false },
);
});
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');
}); });
}); });
@@ -1,18 +1,76 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import {
add,
decimalFromString,
divide,
formatDecimal,
isZero,
multiply,
} from '#src/common/decimal';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { PortfolioService } from '../portfolio/portfolio.api'; import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api';
import { BrokerageAccountDto } from './brokerage-account.dto'; import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.api';
import {
BrokerageAccountDto,
BrokerageAccountsResourceDto,
UnrealizedReturnDto,
} from './brokerage-account.dto';
const PERCENT_DECIMAL_PLACES = 2;
const ZERO_QUANTITY = 0n;
function unrealizedReturn(positions: PortfolioPositionDto[]): UnrealizedReturnDto {
const absoluteReturn = positions.reduce(
(sum, position) => position.absoluteReturn
? add(sum, decimalFromString(position.absoluteReturn.value))
: sum,
decimalFromString('0'),
);
const costBasis = positions
.filter((position) => position.absoluteReturn && position.averagePurchasePrice)
.reduce(
(sum, position) =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Guarded by the filter above.
add(sum, multiply(decimalFromString(position.averagePurchasePrice!.value), decimalFromString(position.quantity))),
decimalFromString('0'),
);
const partial = positions.some(
(position) => !position.absoluteReturn && (position.averagePurchasePrice || position.currentValue),
);
// A short position has negative cost basis, so a percent would flip sign or net out.
const withShorts = positions.some(
(position) =>
(position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue) &&
decimalFromString(position.quantity).value < ZERO_QUANTITY,
);
return {
absolute: formatDecimal(absoluteReturn),
costBasis: formatDecimal(costBasis),
percent: isZero(costBasis) || withShorts
? null
: formatDecimal(divide(
multiply(absoluteReturn, decimalFromString('100')),
costBasis,
PERCENT_DECIMAL_PLACES,
)),
partial,
};
}
@Injectable() @Injectable()
export class BrokerageAccountsService { export class BrokerageAccountsService {
constructor(private readonly portfolio: PortfolioService) {} constructor(
private readonly portfolio: PortfolioService,
private readonly sync: TbankSyncRepository,
) {}
async list(userId: string, observe = false) { async list(userId: string): Promise<BrokerageAccountsResourceDto> {
const saved = await this.portfolio.saved(userId, 'all', observe); const saved = await this.portfolio.saved(userId, 'all');
const data: BrokerageAccountDto[] | null = const data: BrokerageAccountDto[] | null =
saved.membership.availability === 'missing' saved.membership.completeness === 'incomplete'
? null ? null
: saved.accounts.map((account) => ({ : saved.accounts.map((account) => ({
id: account.id, id: account.id,
@@ -21,16 +79,26 @@ export class BrokerageAccountsService {
openedOn: account.openedOn, openedOn: account.openedOn,
portfolioSummary: account.snapshot portfolioSummary: account.snapshot
? { ? {
totalsByCurrency: account.snapshot.totalsByCurrency, totals: account.snapshot.totals,
cash: account.snapshot.cash,
structure: account.snapshot.structure,
unrealizedReturn: unrealizedReturn(account.snapshot.positions),
stockPositionCount: account.snapshot.positions.filter( stockPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'STOCK', (position) => position.type === 'STOCK',
).length, ).length,
bondPositionCount: account.snapshot.positions.filter( bondPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'BOND', (position) => position.type === 'BOND',
).length, ).length,
fundPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'FUND',
).length,
// The snapshot mapper already drops zero-quantity positions.
totalPositionCount: account.snapshot.positions.length,
} }
: null, : null,
})); }));
// The read-only result of the last hourly walk; never starts one.
const hourly = await this.sync.hourly(userId);
return { return {
data, data,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
@@ -49,18 +117,26 @@ export class BrokerageAccountsService {
availability: availability:
data === null data === null
? 'missing' ? 'missing'
: saved.accounts.some((account) => !account.snapshot) : 'complete',
? 'partial'
: 'complete',
fetchedAt: saved.membership.lastSuccessfulAt fetchedAt: saved.membership.lastSuccessfulAt
? new Date(saved.membership.lastSuccessfulAt) ? new Date(saved.membership.lastSuccessfulAt)
: null, : null,
freshForMs: 300_000, freshForMs: 3_600_000,
work: saved.membership.sync.id work: null,
? { ...saved.membership.sync, id: saved.membership.sync.id } lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']),
: null,
dependencies: saved.accounts.map((account) => account.meta), dependencies: saved.accounts.map((account) => account.meta),
}), }),
sync: hourly
? {
state: hourly.state,
startedAt: hourly.startedAt.toISOString(),
finishedAt: hourly.finishedAt?.toISOString() ?? null,
problems: hourly.sections.map((section) => ({
...section,
accountId: section.accountId ?? null,
})),
}
: null,
}; };
} }
} }
@@ -3,9 +3,9 @@ import type { Request } from 'express';
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { import {
IncomeAnalyticsQueryDto, IncomeAnalyticsQueryDto,
IncomeAnalyticsResourceDto, IncomeAnalyticsResourceDto,
@@ -25,12 +25,10 @@ export class IncomeAnalyticsController {
@ApiOkResponse({ type: IncomeSummaryResourceDto }) @ApiOkResponse({ type: IncomeSummaryResourceDto })
allSummary( allSummary(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.summary( return this.income.summary(
request.user.sub, request.user.sub,
'all', 'all',
query.sync === 'observe',
); );
} }
@@ -45,7 +43,6 @@ export class IncomeAnalyticsController {
'all', 'all',
query.from, query.from,
query.to, query.to,
query.sync === 'observe',
); );
} }
@@ -54,12 +51,10 @@ export class IncomeAnalyticsController {
summary( summary(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.summary( return this.income.summary(
request.user.sub, request.user.sub,
accountId, accountId,
query.sync === 'observe',
); );
} }
@@ -75,7 +70,6 @@ export class IncomeAnalyticsController {
accountId, accountId,
query.from, query.from,
query.to, query.to,
query.sync === 'observe',
); );
} }
} }
@@ -1,17 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator'; import { IsOptional, Matches } from 'class-validator';
import { MoneyDto } from '../../../common/money.dto'; import { MoneyDto } from '#src/common/money.dto';
import { import {
InvestmentDependencyDto, InvestmentDependencyDto,
InvestmentReadQueryDto,
InvestmentResourceMetaDto, InvestmentResourceMetaDto,
} from '../investment-resource.dto'; } from '../investment-resource.dto';
import { BrokerageOperationDto } from '../operations/operations.api'; import { BrokerageOperationDto } from '../operations/operations.api';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/; const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto { export class IncomeAnalyticsQueryDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'date', format: 'date',
description: description:
@@ -39,10 +39,7 @@ export class IncomePeriodDto {
declare to: string; declare to: string;
} }
export class IncomeCurrencySummaryDto { export class IncomeTotalDto {
@ApiProperty()
declare currency: string;
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto })
declare coupon: MoneyDto; declare coupon: MoneyDto;
@@ -60,41 +57,19 @@ export class IncomeSummaryDto {
@ApiProperty({ type: IncomePeriodDto }) @ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto; declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true }) @ApiProperty({ type: IncomeTotalDto })
declare totalsByCurrency: IncomeCurrencySummaryDto[]; declare total: IncomeTotalDto;
@ApiProperty({ format: 'date-time' }) @ApiProperty({ format: 'date-time' })
declare calculatedAt: string; declare calculatedAt: string;
} }
export class IncomeComparisonCurrencyDto {
@ApiProperty()
declare currency: string;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare selected: IncomeCurrencySummaryDto;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare compared: IncomeCurrencySummaryDto;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare difference: IncomeCurrencySummaryDto;
}
export class IncomeComparisonDto {
@ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeComparisonCurrencyDto, isArray: true })
declare totalsByCurrency: IncomeComparisonCurrencyDto[];
}
export class IncomeBucketDto { export class IncomeBucketDto {
@ApiProperty({ type: IncomePeriodDto }) @ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto; declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true }) @ApiProperty({ type: IncomeTotalDto })
declare totalsByCurrency: IncomeCurrencySummaryDto[]; declare total: IncomeTotalDto;
} }
export class IncomeInstrumentDto { export class IncomeInstrumentDto {
@@ -135,12 +110,6 @@ export class IncomeAnalyticsEntryDto extends BrokerageOperationDto {
} }
export class IncomeAnalyticsDto extends IncomeSummaryDto { export class IncomeAnalyticsDto extends IncomeSummaryDto {
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
declare previous: IncomeComparisonDto | null;
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
declare yearComparison: IncomeComparisonDto | null;
@ApiProperty({ enum: ['day', 'week', 'month'] }) @ApiProperty({ enum: ['day', 'week', 'month'] })
declare bucketGranularity: 'day' | 'month' | 'week'; declare bucketGranularity: 'day' | 'month' | 'week';
@@ -162,7 +131,7 @@ export class IncomeSummaryResourceDto {
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
} }
const incomeDependencyRoles = ['selected', 'previous', 'year'] as const; const incomeDependencyRoles = ['selected'] as const;
export class IncomeDependencyDto extends InvestmentDependencyDto { export class IncomeDependencyDto extends InvestmentDependencyDto {
@ApiProperty({ enum: incomeDependencyRoles }) @ApiProperty({ enum: incomeDependencyRoles })
@@ -1,4 +1,4 @@
jest.mock('../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
jest.mock('../operations/application/brokerage-operations.service', () => ({ jest.mock('../operations/application/brokerage-operations.service', () => ({
@@ -17,7 +17,7 @@ describe('IncomeAnalyticsService', () => {
beforeEach(() => jest.clearAllMocks()); beforeEach(() => jest.clearAllMocks());
it('labels each comparison dependency independently of its resource key', async () => { it('labels the selected dependency independently of its resource key', async () => {
seed([]); seed([]);
const response = await service.analytics( const response = await service.analytics(
'user', 'user',
@@ -27,32 +27,10 @@ describe('IncomeAnalyticsService', () => {
); );
expect(response.meta.dependencies).toEqual([ expect(response.meta.dependencies).toEqual([
expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }), expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }),
expect.objectContaining({ role: 'previous', resourceKey: 'period/1' }),
expect.objectContaining({ role: 'year', resourceKey: 'period/2' }),
]); ]);
}); expect(
response.meta.dependencies.some((dependency) => dependency.role !== 'selected'),
it('labels an unavailable leap-day comparison as year', async () => { ).toBe(false);
operations.collectRanges.mockImplementation(
(_user: string, _account: string, build: () => unknown[]) =>
build().map((_, index) => ({ items: [], meta: completeMeta(index) })),
);
const response = await service.analytics(
'user',
'account',
'2024-02-29',
'2024-02-29',
);
expect(response.meta.dependencies).toEqual([
expect.objectContaining({ role: 'selected' }),
expect.objectContaining({ role: 'previous' }),
expect.objectContaining({
role: 'year',
availability: 'missing',
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
}),
]);
expect(response.data?.yearComparison).toBeNull();
}); });
function seed(items: BrokerageOperationDto[]) { function seed(items: BrokerageOperationDto[]) {
@@ -63,7 +41,7 @@ describe('IncomeAnalyticsService', () => {
build: (opened?: Date) => unknown[], build: (opened?: Date) => unknown[],
) => { ) => {
build(undefined); build(undefined);
return [items, [], []].map((items, index) => ({ return [items].map((items, index) => ({
items, items,
meta: completeMeta(index), meta: completeMeta(index),
})); }));
@@ -79,22 +57,22 @@ describe('IncomeAnalyticsService', () => {
ticker: 'T', ticker: 'T',
instrumentType: 'bond', instrumentType: 'bond',
}, },
key: 'uid:UID:RUB', key: 'uid:UID',
}, },
{ {
identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' }, identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' },
key: 'figi:FIGI:RUB', key: 'figi:FIGI',
}, },
{ {
identity: { ticker: 'T', instrumentType: 'bond' }, identity: { ticker: 'T', instrumentType: 'bond' },
key: 'ticker:T:bond:RUB', key: 'ticker:T:bond',
}, },
{ identity: { ticker: 'T' }, key: 'ticker:T::RUB' }, { identity: { ticker: 'T' }, key: 'ticker:T:' },
{ {
identity: { ticker: null, instrumentType: 'bond' }, identity: { ticker: null, instrumentType: 'bond' },
key: 'ticker::bond:RUB', key: 'ticker::bond',
}, },
{ identity: { ticker: null }, key: 'operation:receipt:RUB' }, { identity: { ticker: null }, key: 'operation:receipt' },
])( ])(
'uses one membership for entry and aggregate: $key', 'uses one membership for entry and aggregate: $key',
async ({ identity, key }) => { async ({ identity, key }) => {
@@ -124,7 +102,7 @@ describe('IncomeAnalyticsService', () => {
}, },
); );
it('preserves entry order, repeated IDs, currency groups and signed exact totals', async () => { it('preserves entry order, repeated IDs and signed exact totals', async () => {
const selected = [ const selected = [
{ {
...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'), ...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
@@ -134,10 +112,6 @@ describe('IncomeAnalyticsService', () => {
...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'), ...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'),
instrumentUid: 'UID', instrumentUid: 'UID',
}, },
{
...entry('usd', 'OPERATION_TYPE_COUPON', '20', 'USD'),
instrumentUid: 'UID',
},
]; ];
seed(selected); seed(selected);
@@ -153,7 +127,7 @@ describe('IncomeAnalyticsService', () => {
expect(result.entries).toEqual( expect(result.entries).toEqual(
selected.map((item) => ({ selected.map((item) => ({
...item, ...item,
instrumentKey: `uid:UID:${item.payment?.currency}`, instrumentKey: 'uid:UID',
})), })),
); );
expect( expect(
@@ -168,14 +142,7 @@ describe('IncomeAnalyticsService', () => {
), ),
).toEqual([ ).toEqual([
{ {
key: 'uid:UID:USD', key: 'uid:UID',
receiptCount: 1,
coupon: { currency: 'USD', value: '20' },
dividend: { currency: 'USD', value: '0' },
total: { currency: 'USD', value: '20' },
},
{
key: 'uid:UID:RUB',
receiptCount: 2, receiptCount: 2,
coupon: { currency: 'RUB', value: '10.25' }, coupon: { currency: 'RUB', value: '10.25' },
dividend: { currency: 'RUB', value: '-0.25' }, dividend: { currency: 'RUB', value: '-0.25' },
@@ -184,7 +151,7 @@ describe('IncomeAnalyticsService', () => {
]); ]);
expect( expect(
result.entries result.entries
.filter((item) => item.instrumentKey === 'uid:UID:RUB') .filter((item) => item.instrumentKey === 'uid:UID')
.map((item) => item.payment?.value), .map((item) => item.payment?.value),
).toEqual(['10.25', '-0.25']); ).toEqual(['10.25', '-0.25']);
}); });
@@ -213,26 +180,32 @@ describe('IncomeAnalyticsService', () => {
expect(response.data?.buckets).toEqual([ expect(response.data?.buckets).toEqual([
{ {
period: { from: '2026-01-14', to: '2026-01-14' }, period: { from: '2026-01-14', to: '2026-01-14' },
totalsByCurrency: [expect.objectContaining({ total: {
coupon: { currency: 'RUB', value: '0.1' },
dividend: { currency: 'RUB', value: '0' },
total: { currency: 'RUB', value: '0.1' }, total: { currency: 'RUB', value: '0.1' },
})], },
}, },
{ {
period: { from: '2026-01-15', to: '2026-01-15' }, period: { from: '2026-01-15', to: '2026-01-15' },
totalsByCurrency: [expect.objectContaining({ total: {
coupon: { currency: 'RUB', value: '0.2' }, coupon: { currency: 'RUB', value: '0.2' },
dividend: { currency: 'RUB', value: '-0.1' }, dividend: { currency: 'RUB', value: '-0.1' },
total: { currency: 'RUB', value: '0.1' }, total: { currency: 'RUB', value: '0.1' },
})], },
}, },
{ {
period: { from: '2026-01-16', to: '2026-01-16' }, period: { from: '2026-01-16', to: '2026-01-16' },
totalsByCurrency: [], total: {
coupon: { currency: 'RUB', value: '0' },
dividend: { currency: 'RUB', value: '0' },
total: { currency: 'RUB', value: '0' },
},
}, },
]); ]);
}); });
it('aggregates only exact coupon and dividend payments by source currency', async () => { it('aggregates only exact coupon and dividend payments', async () => {
operations.collectRanges.mockImplementation( operations.collectRanges.mockImplementation(
( (
_user: string, _user: string,
@@ -245,10 +218,7 @@ describe('IncomeAnalyticsService', () => {
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'), entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'), entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'), entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
], ],
[],
[],
].map((items, index) => ({ items, meta: completeMeta(index) })); ].map((items, index) => ({ items, meta: completeMeta(index) }));
}, },
); );
@@ -262,22 +232,14 @@ describe('IncomeAnalyticsService', () => {
const result = response.data; const result = response.data;
assert(result); assert(result);
expect(result.totalsByCurrency).toEqual([ expect(result.total).toEqual({
expect.objectContaining({ coupon: { currency: 'RUB', value: '10.25' },
currency: 'RUB', dividend: { currency: 'RUB', value: '-2.5' },
coupon: { currency: 'RUB', value: '10.25' }, total: { currency: 'RUB', value: '7.75' },
dividend: { currency: 'RUB', value: '-2.5' }, });
total: { currency: 'RUB', value: '7.75' },
}),
expect.objectContaining({
currency: 'USD',
total: { currency: 'USD', value: '1' },
}),
]);
expect(result.entries.map((entry) => entry.id)).toEqual([ expect(result.entries.map((entry) => entry.id)).toEqual([
'coupon', 'coupon',
'dividend', 'dividend',
'usd',
]); ]);
expect(result.buckets).toHaveLength(31); expect(result.buckets).toHaveLength(31);
}); });
@@ -6,9 +6,9 @@ import {
compare, compare,
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
subtract, } from '#src/common/decimal';
} from '../../../common/decimal'; import { isTbankProtocolValue, TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
import { DateOnlyRange, moscowDate } from '../investment-calendar'; import { DateOnlyRange, moscowDate } from '../investment-calendar';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { import {
@@ -18,12 +18,11 @@ import {
import { import {
IncomeAnalyticsEntryDto, IncomeAnalyticsEntryDto,
IncomeAnalyticsResourceDto, IncomeAnalyticsResourceDto,
IncomeComparisonDto,
IncomeCurrencySummaryDto,
IncomeDependencyDto, IncomeDependencyDto,
IncomeInstrumentDto, IncomeInstrumentDto,
IncomeSummaryDto, IncomeSummaryDto,
IncomeSummaryResourceDto, IncomeSummaryResourceDto,
IncomeTotalDto,
} from './income-analytics.dto'; } from './income-analytics.dto';
import { import {
IncomeBucketGranularity, IncomeBucketGranularity,
@@ -45,17 +44,12 @@ export class IncomeAnalyticsService {
async summary( async summary(
userId: string, userId: string,
accountId: string, accountId: string,
observe = false,
): Promise<IncomeSummaryResourceDto> { ): Promise<IncomeSummaryResourceDto> {
let plan = planIncomePeriod(undefined, undefined); const plan = planIncomePeriod(undefined, undefined);
const [selected] = await this.operations.collectRanges( const [selected] = await this.operations.collectRanges(
userId, userId,
accountId, accountId,
(openedAt) => { () => [plan.selected],
plan = planIncomePeriod(undefined, undefined, openedAt);
return [plan.selected];
},
observe,
); );
if (selected.items === null) return { data: null, meta: selected.meta }; if (selected.items === null) return { data: null, meta: selected.meta };
assert(selected.meta.lastSuccessfulAt); assert(selected.meta.lastSuccessfulAt);
@@ -75,56 +69,33 @@ export class IncomeAnalyticsService {
accountId: string, accountId: string,
from: string | undefined, from: string | undefined,
to: string | undefined, to: string | undefined,
observe = false,
): Promise<IncomeAnalyticsResourceDto> { ): Promise<IncomeAnalyticsResourceDto> {
let plan = planIncomePeriod(from, to); const plan = planIncomePeriod(from, to);
const periods = await this.operations.collectRanges( const [current] = await this.operations.collectRanges(
userId, userId,
accountId, accountId,
(openedAt) => { () => [plan.selected],
plan = planIncomePeriod(from, to, openedAt);
return [
plan.selected,
plan.previous,
...(plan.yearAgo ? [plan.yearAgo] : []),
];
},
observe,
); );
const [current, prior, year] = periods;
// Preserve the legacy order while consumers migrate to explicit roles.
const dependencies: IncomeDependencyDto[] = [ const dependencies: IncomeDependencyDto[] = [
{ ...current.meta, role: 'selected' }, { ...current.meta, role: 'selected' },
{ ...prior.meta, role: 'previous' },
{
...(plan.yearAgo && year
? year.meta
: investmentResourceMeta({
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
dataRevision: 'unavailable',
availability: 'missing',
fetchedAt: null,
freshForMs: 0,
work: null,
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
})),
role: 'year',
},
]; ];
const states = periods.map((period) => period.meta.sync); const work = current.meta.sync;
const work =
states.find((state) => state.state === 'running') ??
states.find((state) => state.state === 'failed');
const meta = { const meta = {
...investmentResourceMeta({ ...investmentResourceMeta({
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
dataRevision: current.meta.dataRevision, dataRevision: current.meta.dataRevision,
availability: current.meta.availability, availability:
current.items === null
? 'temporarily-unavailable'
: 'available',
fetchedAt: current.meta.lastSuccessfulAt fetchedAt: current.meta.lastSuccessfulAt
? new Date(current.meta.lastSuccessfulAt) ? new Date(current.meta.lastSuccessfulAt)
: null, : null,
freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS, freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
work: work?.id ? { ...work, id: work.id } : null, work: work.id
? { id: work.id, state: work.state, errorCode: work.errorCode }
: null,
lastAttempt: current.meta.lastAttempt,
coverage: current.meta.coverage, coverage: current.meta.coverage,
dependencies, dependencies,
}), }),
@@ -134,7 +105,7 @@ export class IncomeAnalyticsService {
assert(current.meta.lastSuccessfulAt); assert(current.meta.lastSuccessfulAt);
const selected = qualifying(current.items).map((entry) => ({ const selected = qualifying(current.items).map((entry) => ({
...entry, ...entry,
instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`, instrumentKey: instrumentKey(entry),
})); }));
return { return {
data: { data: {
@@ -144,14 +115,6 @@ export class IncomeAnalyticsService {
selected, selected,
current.meta.lastSuccessfulAt, current.meta.lastSuccessfulAt,
), ),
previous:
prior.items === null
? null
: comparison(selected, plan.previous, qualifying(prior.items)),
yearComparison:
plan.yearAgo && year?.items !== null && year
? comparison(selected, plan.yearAgo, qualifying(year.items))
: null,
bucketGranularity: plan.granularity, bucketGranularity: plan.granularity,
buckets: buckets(plan.selected, plan.granularity, selected), buckets: buckets(plan.selected, plan.granularity, selected),
instruments: instruments(selected), instruments: instruments(selected),
@@ -176,55 +139,19 @@ function summaryResponse(
entries: BrokerageOperationDto[], entries: BrokerageOperationDto[],
calculatedAt: string, calculatedAt: string,
): IncomeSummaryDto { ): IncomeSummaryDto {
return { accountId, period, totalsByCurrency: totals(entries), calculatedAt }; return { accountId, period, total: totals(entries), calculatedAt };
} }
function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] { function totals(entries: BrokerageOperationDto[]): IncomeTotalDto {
const values = new Map<string, Total>(); const total = zeroTotal();
for (const entry of entries) { for (const entry of entries) {
if (!entry.payment) continue; if (!entry.payment) continue;
const total = values.get(entry.payment.currency) ?? zeroTotal();
const value = decimalFromString(entry.payment.value); const value = decimalFromString(entry.payment.value);
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) { if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
total.coupon = add(total.coupon, value); total.coupon = add(total.coupon, value);
} else total.dividend = add(total.dividend, value); } else total.dividend = add(total.dividend, value);
values.set(entry.payment.currency, total);
} }
return [...values] return presentTotal(total);
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, total]) => presentTotal(currency, total));
}
function comparison(
selectedEntries: BrokerageOperationDto[],
comparedPeriod: DateOnlyRange,
comparedEntries: BrokerageOperationDto[],
): IncomeComparisonDto {
const selected = new Map(
totals(selectedEntries).map((total) => [total.currency, total]),
);
const compared = new Map(
totals(comparedEntries).map((total) => [total.currency, total]),
);
const currencies = [
...new Set([...selected.keys(), ...compared.keys()]),
// 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.
].sort();
return {
period: comparedPeriod,
totalsByCurrency: currencies.map((currency) => {
const current =
selected.get(currency) ?? presentTotal(currency, zeroTotal());
const prior =
compared.get(currency) ?? presentTotal(currency, zeroTotal());
return {
currency,
selected: current,
compared: prior,
difference: difference(current, prior),
};
}),
};
} }
function buckets( function buckets(
@@ -238,7 +165,7 @@ function buckets(
})); }));
return rangeBuckets(range, granularity).map((period) => ({ return rangeBuckets(range, granularity).map((period) => ({
period, period,
totalsByCurrency: totals( total: totals(
datedEntries datedEntries
.filter(({ date }) => date >= period.from && date <= period.to) .filter(({ date }) => date >= period.from && date <= period.to)
.map(({ entry }) => entry), .map(({ entry }) => entry),
@@ -258,7 +185,7 @@ function instruments(
return [...groups] return [...groups]
.map(([key, group]) => { .map(([key, group]) => {
const first = group[0]; const first = group[0];
const [total] = totals(group); const total = totals(group);
return { return {
key, key,
figi: first.figi, figi: first.figi,
@@ -290,35 +217,14 @@ function instrumentKey(entry: BrokerageOperationDto): string {
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`; return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
} }
function difference(
left: IncomeCurrencySummaryDto,
right: IncomeCurrencySummaryDto,
): IncomeCurrencySummaryDto {
return presentTotal(left.currency, {
coupon: subtract(
decimalFromString(left.coupon.value),
decimalFromString(right.coupon.value),
),
dividend: subtract(
decimalFromString(left.dividend.value),
decimalFromString(right.dividend.value),
),
});
}
function zeroTotal(): Total { function zeroTotal(): Total {
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } }; return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
} }
function presentTotal( function presentTotal(value: Total): IncomeTotalDto {
currency: string,
value: Total,
): IncomeCurrencySummaryDto {
const total = add(value.coupon, value.dividend);
return { return {
currency, coupon: { currency: 'RUB', value: formatDecimal(value.coupon) },
coupon: { currency, value: formatDecimal(value.coupon) }, dividend: { currency: 'RUB', value: formatDecimal(value.dividend) },
dividend: { currency, value: formatDecimal(value.dividend) }, total: { currency: 'RUB', value: formatDecimal(add(value.coupon, value.dividend)) },
total: { currency, value: formatDecimal(total) },
}; };
} }
@@ -23,16 +23,14 @@ describe('income period planning', () => {
} }
}); });
it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => { it('keeps empty buckets and skips removed comparison ranges', () => {
const fullMonth = planIncomePeriod('2026-03-01', '2026-03-31');
expect(fullMonth.previous).toEqual({
from: '2026-02-01',
to: '2026-02-28',
});
expect(planIncomePeriod('2024-02-29', '2024-02-29').yearAgo).toBeNull();
expect( expect(
rangeBuckets({ from: '2026-01-01', to: '2026-02-15' }, 'week'), rangeBuckets({ from: '2026-01-01', to: '2026-02-15' }, 'week'),
).toHaveLength(7); ).toHaveLength(7);
expect(planIncomePeriod('2026-03-01', '2026-03-31')).toEqual({
selected: { from: '2026-03-01', to: '2026-03-31' },
granularity: 'day',
});
}); });
it('accepts multi-year periods and creates monthly buckets for the entire range', () => { it('accepts multi-year periods and creates monthly buckets for the entire range', () => {
@@ -43,7 +41,6 @@ describe('income period planning', () => {
expect(plan.selected).toEqual({ from: '2020-01-01', to: '2025-12-31' }); expect(plan.selected).toEqual({ from: '2020-01-01', to: '2025-12-31' });
expect(plan.granularity).toBe('month'); expect(plan.granularity).toBe('month');
expect(rangeBuckets(plan.selected, plan.granularity)).toHaveLength(72); expect(rangeBuckets(plan.selected, plan.granularity)).toHaveLength(72);
expect(plan.yearAgo).toEqual({ from: '2019-01-01', to: '2024-12-31' });
}); });
it('does not send the unfinished Moscow day as a future instant', () => { it('does not send the unfinished Moscow day as a future instant', () => {
@@ -1,6 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '#src/common/time';
import { import {
DateOnlyRange, DateOnlyRange,
dateParts, dateParts,
@@ -8,51 +9,34 @@ import {
utcDate, utcDate,
} from '../investment-calendar'; } from '../investment-calendar';
enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' } export enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
const MONDAY_WEEKDAY_INDEX = 1; const MONDAY_WEEKDAY_INDEX = 1;
const INCLUSIVE_DAY_OFFSET = 1; const INCLUSIVE_DAY_OFFSET = 1;
const NEXT_DAY_OFFSET = 1; const NEXT_DAY_OFFSET = 1;
const PREVIOUS_MONTH_LAST_DAY = 0; const PREVIOUS_MONTH_LAST_DAY = 0;
const PREVIOUS_DAY_OFFSET = -1;
const PREVIOUS_YEAR_OFFSET = 1;
const DATE_COMPONENT_DIGITS = 2;
const DAILY_BUCKET_MAX_DAYS = 45; const DAILY_BUCKET_MAX_DAYS = 45;
const WEEKLY_BUCKET_MAX_DAYS = 180; const WEEKLY_BUCKET_MAX_DAYS = 180;
const DAYS_PER_WEEK = 7; const DAYS_PER_WEEK = 7;
const ISO_MONTH_LENGTH = 7; const ISO_MONTH_LENGTH = 7;
const ISO_YEAR_LENGTH = 4;
export type IncomeBucketGranularity = 'day' | 'month' | 'week'; export type IncomeBucketGranularity = 'day' | 'month' | 'week';
export function planIncomePeriod( export function planIncomePeriod(
from: string | undefined, from: string | undefined,
to: string | undefined, to: string | undefined,
openedDate?: Date,
): { ): {
granularity: IncomeBucketGranularity; granularity: IncomeBucketGranularity;
previous: DateOnlyRange;
selected: DateOnlyRange; selected: DateOnlyRange;
yearAgo: DateOnlyRange | null;
} { } {
if (Boolean(from) !== Boolean(to)) throw invalidPeriod(); if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
const today = moscowDate(new Date()); const today = moscowDate(new Date());
const selected = const selected =
from && to ? { from, to } : { from: firstOfMonth(today), to: today }; from && to ? { from, to } : { from: firstOfMonth(today), to: today };
assertRange(selected, today); assertRange(selected, today);
const previous = isFullMonth(selected)
? previousMonth(selected.from)
: shiftDays(selected, -rangeDays(selected));
const candidate = yearAgo(selected);
const yearAgoRange =
candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
? candidate
: null;
return { return {
selected, selected,
previous,
yearAgo: yearAgoRange,
granularity: granularity:
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
? 'day' ? 'day'
@@ -136,34 +120,6 @@ function endOfMonth(value: string): string {
return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
} }
function isFullMonth(range: DateOnlyRange): boolean {
return (
range.from === firstOfMonth(range.from) && range.to === endOfMonth(range.to)
);
}
function previousMonth(from: string): DateOnlyRange {
const last = addDays(firstOfMonth(from), PREVIOUS_DAY_OFFSET);
return { from: firstOfMonth(last), to: last };
}
function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange {
return { from: addDays(range.from, days), to: addDays(range.to, days) };
}
function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
const shift = (value: string) => {
const [year, month, day] = dateParts(value);
const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result
? result
: null;
};
const from = shift(range.from);
const to = shift(range.to);
return from && to ? { from, to } : null;
}
function invalidPeriod(): BadRequestException { function invalidPeriod(): BadRequestException {
return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod }); return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod });
} }
@@ -4,7 +4,7 @@ jest.mock('../infrastructure/candle.repository', () => ({
jest.mock('./catalog-enrichment.service', () => ({ jest.mock('./catalog-enrichment.service', () => ({
CatalogEnrichmentService: class {}, CatalogEnrichmentService: class {},
})); }));
jest.mock('../../../../integrations/tbank/tbank.api', () => ({ jest.mock('#src/integrations/tbank/tbank.api', () => ({
TbankAccessService: class {}, TbankAccessService: class {},
TbankClient: class {}, TbankClient: class {},
})); }));
@@ -5,15 +5,16 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { MILLISECONDS_PER_DAY } from '../../../../common/time'; import { MILLISECONDS_PER_DAY } from '#src/common/time';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
TbankClient, TbankClient,
type TbankShare, type TbankShare,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { import {
candleGaps, candleGaps,
@@ -29,6 +30,7 @@ import { timestamp } from './instrument-market';
const REPLACED_WINDOW_COUNT = 1; const REPLACED_WINDOW_COUNT = 1;
const NEXT_WINDOW_OFFSET = 1; const NEXT_WINDOW_OFFSET = 1;
const NO_CANDLE_GAPS = 0;
type CandleInput = { type CandleInput = {
instrumentType: 'bond' | 'share'; instrumentType: 'bond' | 'share';
@@ -97,7 +99,7 @@ export class CandleService {
private readonly logger: StructuredLogger, private readonly logger: StructuredLogger,
) {} ) {}
async read(userId: string, input: CandleInput, observe = false) { async read(userId: string, input: CandleInput) {
const { revision } = await this.access.local(userId); const { revision } = await this.access.local(userId);
const [detail] = await this.sources.readMany( const [detail] = await this.sources.readMany(
userId, userId,
@@ -106,7 +108,7 @@ export class CandleService {
true, true,
['detail'], ['detail'],
); );
if (detail.meta.availability === 'complete' && !detail.instrument) { if (detail.meta.completeness === 'complete' && !detail.instrument) {
throw new NotFoundException(); throw new NotFoundException();
} }
const now = new Date(); const now = new Date();
@@ -114,8 +116,10 @@ export class CandleService {
? rangeFor(input, detail.instrument, now) ? rangeFor(input, detail.instrument, now)
: null; : null;
const saved = range ? await this.repository.read(input.uid, range) : null; const saved = range ? await this.repository.read(input.uid, range) : null;
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : []; const hasCompleteRange = Boolean(
if (!observe && (!saved || gaps.length)) { range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
);
if (!saved || !hasCompleteRange) {
await this.request(userId, revision, input, now); await this.request(userId, revision, input, now);
} }
const work = await this.repository.work(userId, revision, scope(input)); const work = await this.repository.work(userId, revision, scope(input));
@@ -0,0 +1,20 @@
import { CatalogDiscoveryService } from './catalog-discovery.service';
jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class {} }));
describe('catalog discovery currency filtering', () => {
it('uses details to filter InstrumentShort results before resolving ticker ambiguity', async () => {
const short = (uid: string) => ({ uid, ticker: 'SBER', instrumentKind: 'INSTRUMENT_TYPE_SHARE' });
const client = {
findShares: jest.fn().mockResolvedValue([short('a'), short('b')]),
getShare: jest.fn().mockImplementation((_token: string, uid: string) =>
Promise.resolve({ uid, ticker: 'SBER', currency: uid === 'a' ? 'USD' : 'RUB' })),
getLastPrices: jest.fn().mockResolvedValue([]),
};
const access = { online: jest.fn().mockImplementation((_user: string, run: (connection: { token: string }) => unknown) => run({ token: 'fixture' })) };
const service = new CatalogDiscoveryService(access as never, client as never);
const search = await service.search('user', { instrumentType: 'share', query: 'SBER' });
expect(search.data.map((item) => item.uid)).toEqual(['b']);
expect((await service.byTicker('user', 'share', 'SBER')).data?.uid).toBe('b');
});
});
@@ -3,194 +3,63 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
TbankClient, TbankClient,
tbankSourceNeedsRefresh,
tbankSourceTtlMs, tbankSourceTtlMs,
type TbankSyncAttempt, } from '#src/integrations/tbank/tbank.api';
} from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { InstrumentErrorCode } from '../domain/instrument-error-code';
import { import {
CatalogDiscoveryRepository,
catalogQuery, catalogQuery,
type CatalogQuery, type CatalogQuery,
catalogScope, catalogScope,
} from '../infrastructure/catalog-discovery.repository'; } from '../infrastructure/catalog-discovery.repository';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { import {
catalogSearchLimit, catalogSearchLimit,
selectCatalogInstruments, selectCatalogInstruments,
} from './catalog-selection'; } from './catalog-selection';
import { publicInstrument } from './public-instrument';
const UNAMBIGUOUS_MATCH_COUNT = 1; const UNAMBIGUOUS_MATCH_COUNT = 1;
const NO_MATCHES = 0;
const FIRST_RESULT_OFFSET = 0; const FIRST_RESULT_OFFSET = 0;
const tickerAmbiguityLimit = 2; const tickerAmbiguityLimit = 2;
type Producer = {
attempt: TbankSyncAttempt;
force: boolean;
input: CatalogQuery;
observers: TbankSyncAttempt[];
};
@Injectable() @Injectable()
export class CatalogDiscoveryService { export class CatalogDiscoveryService {
private readonly runId = randomUUID();
private readonly active = new Set<string>();
private readonly producers = new Map<string, Producer>();
private readonly queue: Producer[] = [];
private reservations: Promise<unknown> = Promise.resolve();
private running = false;
private readonly completions = new Map<
string,
{ done: Promise<void>; resolve: () => void; workId: string }
>();
constructor( constructor(
private readonly repository: CatalogDiscoveryRepository,
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
private readonly logger: StructuredLogger,
private readonly enrichment: CatalogEnrichmentService,
) {} ) {}
async read(userId: string, input: CatalogQuery, observe = false) { async search(userId: string, input: CatalogQuery) {
input = catalogQuery(input); return this.onlineSearch(userId, catalogQuery(input));
const connection = await this.access.local(userId);
const saved = await this.repository.read(input);
if (!observe) await this.request(userId, connection.revision, input);
return {
data: saved.data,
meta: investmentResourceMeta({
resourceKey: catalogScope(input),
dataRevision: saved.dataRevision,
availability: saved.data === null ? 'missing' : 'complete',
fetchedAt: saved.fetchedAt,
freshForMs: tbankSourceTtlMs.catalog,
work: await this.repository.work(userId, connection.revision, input),
}),
};
}
async search(userId: string, input: CatalogQuery, observe = false) {
const discovery = await this.read(userId, input, observe);
const sources = discovery.data
? await this.enrichment.readMany(
userId,
input.instrumentType,
discovery.data.map((item) => item.uid),
observe,
)
: [];
const dependencies = [
discovery.meta,
...sources.map((source) => source.meta),
];
return {
data:
discovery.data?.map((short) => ({
...short,
detail:
sources.find(
(source) =>
source.input.uid === short.uid &&
source.input.kind === 'detail',
)?.instrument ?? null,
price:
sources.find(
(source) =>
source.input.uid === short.uid &&
source.input.kind === 'last-price',
)?.price ?? null,
})) ?? null,
meta: investmentResourceMeta({
resourceKey: `search:${catalogScope(catalogQuery(input))}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability:
discovery.data === null
? 'missing'
: sources.every((source) => source.meta.availability === 'complete')
? 'complete'
: 'partial',
fetchedAt: discovery.meta.lastSuccessfulAt
? new Date(discovery.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.catalog,
work: discovery.meta.sync.id
? {
id: discovery.meta.sync.id,
state: discovery.meta.sync.state,
errorCode: discovery.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
} }
async detail( async detail(
userId: string, userId: string,
instrumentType: 'bond' | 'share', instrumentType: 'bond' | 'share',
uid: string, uid: string,
observe = false,
) { ) {
const sources = await this.enrichment.readMany( const instrument = await this.access.online(userId, ({ token }) =>
userId, instrumentType === 'share'
instrumentType, ? this.tbank.getShare(token, uid)
[uid], : this.tbank.getBond(token, uid),
observe,
['detail'],
); );
const source = sources[0]; // RUB-only: a non-RUB instrument line (e.g. USD line of an SPB-traded stock) is not available.
if (source.meta.availability === 'complete' && !source.instrument) { if (!instrument || instrument.currency?.toUpperCase() !== 'RUB') throw new NotFoundException();
throw new NotFoundException();
}
const instrument = source.instrument;
let slug = uid;
const dependencies = [source.meta];
if (
instrument?.ticker &&
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
) {
const alias = await this.read(
userId,
{ instrumentType, query: instrument.ticker, exactTicker: true },
observe,
);
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
slug = instrument.ticker.toUpperCase();
}
dependencies.push(alias.meta);
}
return { return {
data: instrument ? { ...instrument, uid, slug } : null, data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: `${instrumentType}/detail/${uid}`, resourceKey: `${instrumentType}/detail/${uid}`,
dataRevision: dependencies dataRevision: `online:${Date.now()}`,
.map((dependency) => dependency.dataRevision) availability: 'complete',
.join('/'), fetchedAt: new Date(),
availability: source.meta.availability, freshForMs: tbankSourceTtlMs.portfolio,
fetchedAt: source.meta.lastSuccessfulAt work: null,
? new Date(source.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.metadata,
work: source.meta.sync.id
? {
id: source.meta.sync.id,
state: source.meta.sync.state,
errorCode: source.meta.sync.errorCode,
}
: null,
dependencies,
}), }),
}; };
} }
@@ -199,15 +68,28 @@ export class CatalogDiscoveryService {
userId: string, userId: string,
instrumentType: 'bond' | 'share', instrumentType: 'bond' | 'share',
ticker: string, ticker: string,
observe = false,
) { ) {
const lookup = await this.read( const query = ticker.trim().toUpperCase();
userId, const found = await this.access.online(userId, ({ token }) =>
{ instrumentType, query: ticker, exactTicker: true }, instrumentType === 'share'
observe, ? this.tbank.findShares(token, query)
: this.tbank.findBonds(token, query),
); );
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException(); const candidates = selectCatalogInstruments(
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) { found.filter((item) => item.ticker?.trim().toUpperCase() === query),
query,
instrumentType,
);
const details = await this.access.online(userId, ({ token }) => Promise.all(
candidates.map((item) => instrumentType === 'share'
? this.tbank.getShare(token, item.uid)
: this.tbank.getBond(token, item.uid)),
));
const matches = candidates.filter((_item, index) =>
details.at(index)?.currency?.trim().toUpperCase() === 'RUB',
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
if (!matches.length) throw new NotFoundException();
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({ throw new ConflictException({
code: code:
instrumentType === 'share' instrumentType === 'share'
@@ -215,192 +97,57 @@ export class CatalogDiscoveryService {
: InstrumentErrorCode.BondTickerAmbiguous, : InstrumentErrorCode.BondTickerAmbiguous,
}); });
} }
const result = lookup.data const result = await this.detail(userId, instrumentType, matches[0].uid);
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe) return { ...result, data: result.data ? { ...result.data, slug: query } : null };
: null; }
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
private async onlineSearch(userId: string, input: CatalogQuery) {
const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => {
const found =
input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query)
: await this.tbank.findBonds(token, input.query);
const instruments = selectCatalogInstruments(
input.exactTicker
? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query)
: found,
input.query,
input.instrumentType,
);
const [details, prices] = await Promise.all([
Promise.all(
instruments.map((item) =>
input.instrumentType === 'share'
? this.tbank.getShare(token, item.uid)
: this.tbank.getBond(token, item.uid),
),
),
instruments.length
? this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
: [],
]);
return { details, instruments, prices };
});
const fetchedAt = new Date();
const data = instruments.flatMap((item, index) => {
const detail = details.at(index);
if (detail?.currency?.trim().toUpperCase() !== 'RUB') return [];
return [{
...item,
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
}];
}).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
return { return {
data: result?.data data,
? { ...result.data, slug: ticker.trim().toUpperCase() }
: null,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`, resourceKey: `search:${catalogScope(input)}`,
dataRevision: dependencies dataRevision: `online:${fetchedAt.toISOString()}`,
.map((dependency) => dependency.dataRevision) availability: 'complete',
.join('/'), fetchedAt,
availability: result?.meta.availability ?? 'missing', freshForMs: tbankSourceTtlMs.portfolio,
fetchedAt: lookup.meta.lastSuccessfulAt work: null,
? new Date(lookup.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.metadata,
work: lookup.meta.sync.id
? {
id: lookup.meta.sync.id,
state: lookup.meta.sync.state,
errorCode: lookup.meta.sync.errorCode,
}
: null,
dependencies,
}), }),
}; };
} }
request(
userId: string,
revision: string,
query: CatalogQuery,
force = false,
) {
const input = catalogQuery(query);
const reserved = this.reservations.then(async () => {
const saved = await this.repository.read(input);
if (
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
) {
return null;
}
const reservation = await this.repository.reserve(
{ userId, revision },
input,
this.runId,
this.active,
);
if (!reservation) return null;
const { id, attemptId } = reservation.work;
if (!reservation.launch) {
const current = this.producers.get(catalogScope(input));
if (current) current.force ||= force;
return id;
}
const attempt = { userId, revision, id, attemptId };
this.active.add(attemptId);
let resolve = () => {};
const done = new Promise<void>((finish) => {
resolve = finish;
});
this.completions.set(attemptId, { workId: id, done, resolve });
const key = catalogScope(input);
const producer = this.producers.get(key);
if (producer) {
producer.observers.push(attempt);
producer.force ||= force;
} else {
const queued = { input, attempt, observers: [], force };
this.producers.set(key, queued);
this.queue.push(queued);
}
if (!this.running) {
this.running = true;
setImmediate(() => {
void this.drain();
});
}
return id;
});
this.reservations = reserved.catch(() => undefined);
return reserved;
}
async refresh(userId: string, revision: string, query: CatalogQuery) {
const id = await this.request(userId, revision, query, true);
if (id) {
await Promise.all(
[...this.completions.values()]
.filter((entry) => entry.workId === id)
.map((entry) => entry.done),
);
}
const saved = await this.repository.read(catalogQuery(query));
const sources = await this.enrichment.readMany(
userId,
query.instrumentType,
saved.data?.map((item) => item.uid) ?? [],
true,
);
return [
...(id ? [id] : []),
...sources.flatMap((source) =>
source.meta.sync.id ? [source.meta.sync.id] : [],
),
];
}
private async drain() {
try {
while (this.queue.length) {
const producer = this.queue.shift();
if (!producer) break;
const { input, attempt } = producer;
let success = false;
try {
const token = await this.access.tokenForAttempt(attempt);
if (token) {
const found =
input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query)
: await this.tbank.findBonds(token, input.query);
success = await this.repository.publish(
attempt,
input,
selectCatalogInstruments(
input.exactTicker
? found.filter(
(item) =>
item.ticker?.trim().toUpperCase() === input.query,
)
: found,
input.query,
input.instrumentType,
).slice(
FIRST_RESULT_OFFSET,
input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit,
),
new Date(),
);
}
} catch (error) {
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(attempt);
} else {
this.logger.event('warn', 'catalog-discovery.failed', {
code: TbankErrorCode.Unavailable,
});
}
}
if (success) {
const saved = await this.repository.read(input);
for (const observer of [attempt, ...producer.observers]) {
if (!input.exactTicker || saved.data?.length === UNAMBIGUOUS_MATCH_COUNT) {
await this.enrichment.requestMany(
observer.userId,
observer.revision,
input.instrumentType,
saved.data?.map((item) => item.uid) ?? [],
producer.force,
input.exactTicker ? ['detail'] : ['last-price', 'detail'],
);
}
}
}
this.producers.delete(catalogScope(input));
for (const observer of [attempt, ...producer.observers]) {
await this.repository.finish(observer, success);
this.active.delete(observer.attemptId);
this.completions.get(observer.attemptId)?.resolve();
this.completions.delete(observer.attemptId);
}
}
} catch {
for (const completion of this.completions.values()) completion.resolve();
this.completions.clear();
this.active.clear();
this.producers.clear();
this.queue.length = 0;
this.logger.event('error', 'catalog-discovery.persistence-failed', {
code: TbankErrorCode.Unavailable,
});
} finally {
this.running = false;
}
}
} }
@@ -1,43 +1,27 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { import { StructuredLogger } from '#src/common/logging/structured-logger.service';
decimalFromString, import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
divide,
} from '../../../../common/decimal';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import {
MILLISECONDS_PER_DAY,
MILLISECONDS_PER_SECOND,
} from '../../../../common/time';
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
type TbankBond,
type TbankCandle,
TbankClient, TbankClient,
type TbankCoupon, TbankLastPrice,
type TbankDividend,
type TbankFundamentals,
type TbankLastPrice,
type TbankShare,
tbankSourceNeedsRefresh, tbankSourceNeedsRefresh,
tbankSourceTtlMs, tbankSourceTtlMs,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { candleGaps, utcDay } from '../domain/candle-coverage';
import { CandleRepository } from '../infrastructure/candle.repository';
import { import {
CatalogDiscoveryRepository, CatalogDiscoveryRepository,
catalogScope, catalogScope,
type CatalogSource, type CatalogSource,
} from '../infrastructure/catalog-discovery.repository'; } from '../infrastructure/catalog-discovery.repository';
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper'; import { type CatalogPayload, CatalogSourceLoader } from './catalog-source.loader';
export type { CatalogPayload } from './catalog-source.loader';
import { catalogSearchLimit } from './catalog-selection'; import { catalogSearchLimit } from './catalog-selection';
import { previousClose, timestamp } from './instrument-market'; import { publicInstrument } from './public-instrument';
const MISSING_DATA_REVISION = 0; const MISSING_DATA_REVISION = 0;
const FIRST_QUEUE_INDEX = 0; const FIRST_QUEUE_INDEX = 0;
@@ -58,7 +42,7 @@ export class CatalogEnrichmentService {
private running = false; private running = false;
private readonly completions = new Map< private readonly completions = new Map<
string, string,
{ done: Promise<void>; resolve: () => void; workId: string } { attempt: TbankSyncAttempt; done: Promise<void>; resolve: () => void; workId: string }
>(); >();
async ensureDetail( async ensureDetail(
@@ -92,9 +76,7 @@ export class CatalogEnrichmentService {
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
private readonly logger: StructuredLogger, private readonly logger: StructuredLogger,
private readonly moex: MoexClient, private readonly loader: CatalogSourceLoader,
private readonly ratings: NcrRatingsClient,
private readonly candles: CandleRepository,
) {} ) {}
async readMany( async readMany(
@@ -109,6 +91,7 @@ export class CatalogEnrichmentService {
tickers?: Record<string, string>; tickers?: Record<string, string>;
to: string; to: string;
}, },
refreshBefore?: Date,
) { ) {
const { revision } = await this.access.local(userId); const { revision } = await this.access.local(userId);
const inputs = sourceInputs(instrumentType, uids, kinds, range); const inputs = sourceInputs(instrumentType, uids, kinds, range);
@@ -122,6 +105,7 @@ export class CatalogEnrichmentService {
false, false,
kinds, kinds,
range, range,
refreshBefore,
); );
} }
const work = inputs.length const work = inputs.length
@@ -154,7 +138,7 @@ export class CatalogEnrichmentService {
dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION), dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
availability: row ? 'complete' : 'missing', availability: row ? 'complete' : 'missing',
fetchedAt: row?.fetchedAt ?? null, fetchedAt: row?.fetchedAt ?? null,
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)], freshForMs: sourcePolicy(input.kind) === 'quote' ? tbankSourceTtlMs.quote : Number.MAX_SAFE_INTEGER,
work: byScope.get(catalogScope(input)) ?? null, work: byScope.get(catalogScope(input)) ?? null,
}), }),
}; };
@@ -174,6 +158,7 @@ export class CatalogEnrichmentService {
tickers?: Record<string, string>; tickers?: Record<string, string>;
to: string; to: string;
}, },
refreshBefore?: Date,
) { ) {
const inputs = sourceInputs(instrumentType, uids, kinds, range); const inputs = sourceInputs(instrumentType, uids, kinds, range);
const reservation = this.reservations.then(async () => { const reservation = this.reservations.then(async () => {
@@ -186,14 +171,12 @@ export class CatalogEnrichmentService {
(entry) => (entry) =>
entry.instrumentUid === input.uid && entry.kind === input.kind, entry.instrumentUid === input.uid && entry.kind === input.kind,
); );
if ( const policy = sourcePolicy(input.kind);
!tbankSourceNeedsRefresh( const needsRefresh = force || !row?.fetchedAt ||
sourcePolicy(input.kind), (policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date())) ||
row?.fetchedAt ?? null, // A walk reads a saved calendar once: later accounts see the new fetchedAt.
new Date(), (policy === 'calendar' && refreshBefore !== undefined && row.fetchedAt < refreshBefore);
force, if (!needsRefresh) {
)
) {
continue; continue;
} }
const reserved = await this.repository.reserve( const reserved = await this.repository.reserve(
@@ -212,7 +195,7 @@ export class CatalogEnrichmentService {
const done = new Promise<void>((finish) => { const done = new Promise<void>((finish) => {
resolve = 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 key = producerKey(input);
const producer = this.producers.get(key); const producer = this.producers.get(key);
if (producer) producer.observers.push({ attempt, input }); if (producer) producer.observers.push({ attempt, input });
@@ -239,31 +222,57 @@ export class CatalogEnrichmentService {
instrumentType: 'bond' | 'share', instrumentType: 'bond' | 'share',
uid: string, uid: string,
section: 'company' | 'metrics' | 'quote', section: 'company' | 'metrics' | 'quote',
observe = false,
) { ) {
const kinds: CatalogSource['kind'][] = if (section === 'company') {
section === 'company' const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => {
? ['detail', 'description', 'fundamentals'] const instrument =
: [ instrumentType === 'share'
...(instrumentType === 'bond' ? await this.tbank.getShare(token, uid)
? ['bond-current' as const] : await this.tbank.getBond(token, uid);
: ['detail' as const]), if (!instrument) throw new NotFoundException();
'last-price', const [description, fundamentals] = instrument.assetUid
'trading-status', ? await Promise.all([
'comparison', this.tbank.getAssetDescription(token, instrument.assetUid),
...(section === 'metrics' this.tbank.getFundamentals(token, instrument.assetUid),
? ['coupons' as const, 'yield' as const, 'rating' as const] ])
: []), : [null, null];
]; return { description, fundamentals, instrument };
});
const meta = investmentResourceMeta({
resourceKey: `${instrumentType}/company/${uid}`,
dataRevision: `online:${Date.now()}`,
availability: 'complete',
fetchedAt: new Date(),
freshForMs: Number.MAX_SAFE_INTEGER,
work: null,
});
return {
values: { instrument: publicInstrument({ ...instrument, uid }), description, fundamentals },
meta,
unavailable: [],
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
};
}
const kinds: CatalogSource['kind'][] = [
...(instrumentType === 'bond'
? ['bond-current' as const]
: ['detail' as const]),
'last-price',
'trading-status',
'comparison',
...(section === 'metrics'
? ['coupons' as const, 'yield' as const, 'rating' as const]
: []),
];
const sources = await this.readMany( const sources = await this.readMany(
userId, userId,
instrumentType, instrumentType,
[uid], [uid],
observe, false,
kinds, kinds,
); );
const first = sources[0]; const first = sources[0];
if (first.meta.availability === 'complete' && !first.instrument) { if (first.meta.completeness === 'complete' && !first.instrument) {
throw new NotFoundException(); throw new NotFoundException();
} }
const values = sources.reduce<CatalogPayload>( const values = sources.reduce<CatalogPayload>(
@@ -272,7 +281,7 @@ export class CatalogEnrichmentService {
); );
const dependencies = sources.map((source) => source.meta); const dependencies = sources.map((source) => source.meta);
const complete = dependencies.every( const complete = dependencies.every(
(dependency) => dependency.availability === 'complete', (dependency) => dependency.completeness === 'complete',
); );
const meta = investmentResourceMeta({ const meta = investmentResourceMeta({
resourceKey: `${instrumentType}/${section}/${uid}`, resourceKey: `${instrumentType}/${section}/${uid}`,
@@ -287,10 +296,7 @@ export class CatalogEnrichmentService {
fetchedAt: first.meta.lastSuccessfulAt fetchedAt: first.meta.lastSuccessfulAt
? new Date(first.meta.lastSuccessfulAt) ? new Date(first.meta.lastSuccessfulAt)
: null, : null,
freshForMs: freshForMs: tbankSourceTtlMs.quote,
section === 'company'
? tbankSourceTtlMs.company
: tbankSourceTtlMs.quote,
work: null, work: null,
dependencies, dependencies,
}); });
@@ -300,7 +306,7 @@ export class CatalogEnrichmentService {
unavailable: sources unavailable: sources
.filter( .filter(
(source) => (source) =>
source.meta.availability !== 'complete' || source.meta.completeness !== 'complete' ||
source.meta.sync.state === 'failed', source.meta.sync.state === 'failed',
) )
.map((source) => source.input.kind), .map((source) => source.input.kind),
@@ -308,168 +314,7 @@ export class CatalogEnrichmentService {
}; };
} }
private async load( // eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion.
input: CatalogSource,
token: string,
attempt: TbankSyncAttempt,
): Promise<CatalogPayload> {
if (input.kind === 'detail' || input.kind === 'bond-current') {
const instrument =
input.instrumentType === 'share'
? await this.tbank.getShare(token, input.uid)
: await this.tbank.getBond(token, input.uid);
if (instrument && instrument.uid !== input.uid) {
throw new Error('Catalog UID mismatch');
}
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
if (instrument === undefined) throw new Error('Missing catalog response');
return { instrument: instrument ? publicInstrument(instrument) : null };
}
if (input.kind === 'trading-status') {
return {
tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
};
}
if (input.kind === 'comparison') {
const prices = await this.repository.readSources([
{ ...input, kind: 'last-price', rangeKey: undefined },
]);
if (!prices.length) throw new Error('Price dependency missing');
const price = (prices[0].payload as CatalogPayload).price;
const priceAt = timestamp(price?.time);
if (!priceAt) return { candle: null };
const now = new Date();
const range = {
from: utcDay(new Date(Date.parse(priceAt) - previousCloseLookbackMs)),
to: new Date(priceAt),
};
const saved = await this.candles.read(input.uid, range);
const gaps = candleGaps(range, saved.coverage, now);
for (const gap of gaps) {
const response = await this.tbank.getCandles(
token,
input.uid,
gap.from,
gap.to,
);
const rows = response.map(toSavedCandle);
if (!(await this.candles.stage(attempt, gap, rows, now))) {
throw new Error('Cancelled candle comparison');
}
}
if (
gaps.length &&
!(await this.candles.publish(
attempt,
{ uid: input.uid, ranges: gaps },
false,
))
) {
throw new Error('Cancelled candle comparison');
}
const values = (await this.candles.read(input.uid, range)).data.map(
(row) => {
const value = divide(
decimalFromString(row.close),
decimalFromString('1'),
quotationScale,
).value;
return {
time: {
seconds: String(Date.parse(row.time) / MILLISECONDS_PER_SECOND),
},
close: {
units: String(value / quotationUnits),
nano: Number(value % quotationUnits),
},
isComplete: row.isComplete,
};
},
);
return { candle: previousClose(values, priceAt) };
}
const details = await this.repository.readSources([
{ ...input, kind: 'detail', rangeKey: undefined },
...(input.instrumentType === 'bond'
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
: []),
]);
const instrument = details
.map((row) => (row.payload as CatalogPayload).instrument)
.find(Boolean);
if (
!instrument &&
!['bond-events', 'coupons', 'dividends'].includes(input.kind)
) {
throw new Error('Instrument dependency missing');
}
if (input.kind === 'description') {
return {
description: instrument?.assetUid
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
: null,
};
}
if (input.kind === 'fundamentals') {
return {
fundamentals: instrument?.assetUid
? await this.tbank.getFundamentals(token, instrument?.assetUid)
: null,
};
}
if (input.kind === 'coupons' || input.kind === 'dividends') {
const year = Number(input.rangeKey);
const [from, to] = input.rangeKey?.startsWith('[')
? (JSON.parse(input.rangeKey) as [string, string])
: [
`${year - payoutHistoryYears}-01-01`,
`${year + payoutFutureYears}-01-01`,
];
return input.kind === 'coupons'
? {
coupons: await this.tbank.getBondCoupons(
token,
input.uid,
from,
to,
),
}
: {
dividends: await this.tbank.getDividends(
token,
input.uid,
from,
to,
),
};
}
if (input.kind === 'bond-events') {
const ticker = instrument?.ticker || input.ticker;
if (!ticker) throw new Error('Bond ticker missing');
return { bondEvents: await this.moex.bondEvents(ticker) };
}
if (input.kind === 'yield') {
return {
effectiveYield:
instrument?.ticker && instrument.isin && instrument.classCode
? await this.moex.bondYield(
instrument.ticker,
instrument.isin,
instrument.classCode,
)
: null,
};
}
if (input.kind === 'rating') {
return {
creditRating: instrument?.isin
? await this.ratings.rating(instrument.isin)
: null,
};
}
throw new Error('Unsupported catalog source');
}
private async drain() { private async drain() {
try { try {
while (this.queue.length) { while (this.queue.length) {
@@ -506,7 +351,7 @@ export class CatalogEnrichmentService {
batch.map((item) => item.input.uid), batch.map((item) => item.input.uid),
); );
} else { } else {
payload = await this.load(producer.input, token, producer.attempt); payload = await this.loader.load(producer.input, token, producer.attempt);
} }
} catch (error) { } catch (error) {
failed = true; failed = true;
@@ -556,6 +401,14 @@ export class CatalogEnrichmentService {
} }
} }
} catch { } 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(); for (const completion of this.completions.values()) completion.resolve();
this.completions.clear(); this.completions.clear();
this.active.clear(); this.active.clear();
@@ -600,57 +453,7 @@ function sourceInputs(
})), })),
); );
} }
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: (value as TbankShare).shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
const previousCloseLookbackDays = 20;
const previousCloseLookbackMs =
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
const payoutHistoryYears = 5;
const payoutFutureYears = 2;
export type CatalogPayload = {
bondEvents?: MoexBondEvent[];
candle?: null | TbankCandle;
coupons?: TbankCoupon[];
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
description?: null | string;
dividends?: TbankDividend[];
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
fundamentals?: null | TbankFundamentals;
instrument?: null | TbankBond | TbankShare;
price?: null | TbankLastPrice;
tradingStatus?: null | string;
};
function sourcePolicy(kind: CatalogSource['kind']) { function sourcePolicy(kind: CatalogSource['kind']) {
if ( if (
[ [
@@ -663,9 +466,8 @@ function sourcePolicy(kind: CatalogSource['kind']) {
) { ) {
return 'quote'; return 'quote';
} }
if (kind === 'description' || kind === 'fundamentals') return 'company'; if (['bond-events', 'dividends'].includes(kind)) return 'calendar';
if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar'; return 'stable';
return 'metadata';
} }
function producerKey(input: CatalogSource) { function producerKey(input: CatalogSource) {
return input.instrumentType === 'bond' && return input.instrumentType === 'bond' &&
@@ -673,6 +475,3 @@ function producerKey(input: CatalogSource) {
? `bond-metadata:${input.uid}` ? `bond-metadata:${input.uid}`
: catalogScope(input); : catalogScope(input);
} }
const quotationScale = 9;
const quotationUnits = 1_000_000_000n;
@@ -1,6 +1,6 @@
import type { TbankShare } from '../../../../integrations/tbank/tbank.api'; import type { TbankShare } from '#src/integrations/tbank/tbank.api';
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol'; import { TbankInstrumentType } from '#src/integrations/tbank/tbank-protocol';
export const catalogSearchLimit = 30; export const catalogSearchLimit = 30;
@@ -0,0 +1,229 @@
import { Injectable } from '@nestjs/common';
import {
decimalFromString,
divide,
} from '#src/common/decimal';
import {
MILLISECONDS_PER_DAY,
MILLISECONDS_PER_SECOND,
} from '#src/common/time';
import { type MoexBondEvent, MoexClient } from '#src/integrations/moex/moex.api';
import { NcrRatingsClient } from '#src/integrations/ncr/ncr.api';
import {
type TbankBond,
type TbankCandle,
TbankClient,
type TbankCoupon,
type TbankDividend,
type TbankFundamentals,
type TbankLastPrice,
type TbankShare,
type TbankSyncAttempt,
} from '#src/integrations/tbank/tbank.api';
import { candleGaps, utcDay } from '../domain/candle-coverage';
import { CandleRepository } from '../infrastructure/candle.repository';
import {
CatalogDiscoveryRepository,
type CatalogSource,
} from '../infrastructure/catalog-discovery.repository';
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
import { previousClose, timestamp } from './instrument-market';
import { publicInstrument } from './public-instrument';
export type CatalogPayload = {
bondEvents?: MoexBondEvent[];
candle?: null | TbankCandle;
coupons?: TbankCoupon[];
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
description?: null | string;
dividends?: TbankDividend[];
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
fundamentals?: null | TbankFundamentals;
instrument?: null | TbankBond | TbankShare;
price?: null | TbankLastPrice;
tradingStatus?: null | string;
};
const previousCloseLookbackDays = 20;
const previousCloseLookbackMs =
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
const payoutHistoryYears = 5;
const payoutFutureYears = 2;
const quotationScale = 9;
const quotationUnits = 1_000_000_000n;
@Injectable()
export class CatalogSourceLoader {
constructor(
private readonly repository: CatalogDiscoveryRepository,
private readonly tbank: TbankClient,
private readonly moex: MoexClient,
private readonly ratings: NcrRatingsClient,
private readonly candles: CandleRepository,
) {}
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
async load(
input: CatalogSource,
token: string,
attempt: TbankSyncAttempt,
): Promise<CatalogPayload> {
if (input.kind === 'detail' || input.kind === 'bond-current') {
const instrument =
input.instrumentType === 'share'
? await this.tbank.getShare(token, input.uid)
: await this.tbank.getBond(token, input.uid);
if (instrument && instrument.uid !== input.uid) {
throw new Error('Catalog UID mismatch');
}
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
if (instrument === undefined) throw new Error('Missing catalog response');
return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : null };
}
if (input.kind === 'trading-status') {
return {
tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
};
}
if (input.kind === 'comparison') {
const prices = await this.repository.readSources([
{ ...input, kind: 'last-price', rangeKey: undefined },
]);
if (!prices.length) throw new Error('Price dependency missing');
const price = (prices[0].payload as CatalogPayload).price;
const priceAt = timestamp(price?.time);
if (!priceAt) return { candle: null };
const now = new Date();
const range = {
from: utcDay(new Date(Date.parse(priceAt) - previousCloseLookbackMs)),
to: new Date(priceAt),
};
const saved = await this.candles.read(input.uid, range);
const gaps = candleGaps(range, saved.coverage, now);
for (const gap of gaps) {
const response = await this.tbank.getCandles(
token,
input.uid,
gap.from,
gap.to,
);
const rows = response.map(toSavedCandle);
if (!(await this.candles.stage(attempt, gap, rows, now))) {
throw new Error('Cancelled candle comparison');
}
}
if (
gaps.length &&
!(await this.candles.publish(
attempt,
{ uid: input.uid, ranges: gaps },
false,
))
) {
throw new Error('Cancelled candle comparison');
}
const values = (await this.candles.read(input.uid, range)).data.map(
(row) => {
const value = divide(
decimalFromString(row.close),
decimalFromString('1'),
quotationScale,
).value;
return {
time: {
seconds: String(Date.parse(row.time) / MILLISECONDS_PER_SECOND),
},
close: {
units: String(value / quotationUnits),
nano: Number(value % quotationUnits),
},
isComplete: row.isComplete,
};
},
);
return { candle: previousClose(values, priceAt) };
}
const details = await this.repository.readSources([
{ ...input, kind: 'detail', rangeKey: undefined },
...(input.instrumentType === 'bond'
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
: []),
]);
const instrument = details
.map((row) => (row.payload as CatalogPayload).instrument)
.find(Boolean);
if (
!instrument &&
!['bond-events', 'coupons', 'dividends'].includes(input.kind)
) {
throw new Error('Instrument dependency missing');
}
if (input.kind === 'description') {
return {
description: instrument?.assetUid
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
: null,
};
}
if (input.kind === 'fundamentals') {
return {
fundamentals: instrument?.assetUid
? await this.tbank.getFundamentals(token, instrument?.assetUid)
: null,
};
}
if (input.kind === 'coupons' || input.kind === 'dividends') {
const year = Number(input.rangeKey);
const [from, to] = input.rangeKey?.startsWith('[')
? (JSON.parse(input.rangeKey) as [string, string])
: [
`${year - payoutHistoryYears}-01-01`,
`${year + payoutFutureYears}-01-01`,
];
return input.kind === 'coupons'
? {
coupons: await this.tbank.getBondCoupons(
token,
input.uid,
from,
to,
),
}
: {
dividends: await this.tbank.getDividends(
token,
input.uid,
from,
to,
),
};
}
if (input.kind === 'bond-events') {
const ticker = instrument?.ticker || input.ticker;
if (!ticker) throw new Error('Bond ticker missing');
return { bondEvents: await this.moex.bondEvents(ticker) };
}
if (input.kind === 'yield') {
return {
effectiveYield:
instrument?.ticker && instrument.isin && instrument.classCode
? await this.moex.bondYield(
instrument.ticker,
instrument.isin,
instrument.classCode,
)
: null,
};
}
if (input.kind === 'rating') {
return {
creditRating: instrument?.isin
? await this.ratings.rating(instrument.isin)
: null,
};
}
throw new Error('Unsupported catalog source');
}
}
@@ -1,9 +1,9 @@
import type { import type {
TbankCandle, TbankCandle,
TbankTimestamp, TbankTimestamp,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '#src/common/time';
const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0; const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
@@ -22,14 +22,16 @@ const row = (
isin: null, isin: null,
ticker: null, ticker: null,
logoName: null, logoName: null,
sector: null,
issuer: null,
}); });
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({ const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
positions, positions,
cashBalances: [], cash: { currency: 'RUB', value: '0' },
totalsByCurrency: [], totals: { currency: 'RUB', value: '0' },
isStale: false, isStale: false,
lastSuccessfulAt: '2026-09-10T00:00:00Z', lastSuccessfulAt: '2026-09-10T00:00:00Z',
structure: { currencies: [], unvaluedCategories: [] }, structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
}); });
describe('stock position', () => { describe('stock position', () => {
@@ -62,10 +64,10 @@ describe('stock position', () => {
expect(result.averagePurchasePrice?.value).toBe('0.1'); expect(result.averagePurchasePrice?.value).toBe('0.1');
}, },
); );
it('does not sum currencies or treat unknown values as zero', () => { it('does not treat unknown values as zero', () => {
const second = { const second = {
...row(), ...row(),
currentValue: money('2', 'USD'), currentValue: null,
averagePurchasePrice: null, averagePurchasePrice: null,
absoluteReturn: null, absoluteReturn: null,
}; };
@@ -4,8 +4,9 @@ import {
divide, divide,
formatDecimal, formatDecimal,
multiply, multiply,
} from '../../../../common/decimal'; } from '#src/common/decimal';
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '#src/common/money.dto';
import { import {
PortfolioDto, PortfolioDto,
PortfolioPositionDto, PortfolioPositionDto,
@@ -45,7 +46,7 @@ export function instrumentPosition(
averagePurchasePrice: averagePurchasePrice:
positive && totalCost positive && totalCost
? { ? {
currency: totalCost.currency, currency: 'RUB',
value: formatDecimal( value: formatDecimal(
divide( divide(
decimalFromString(totalCost.value), decimalFromString(totalCost.value),
@@ -82,17 +83,12 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
: null; : null;
} }
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
const currency = values[0]?.currency; if (!values.length || !values.every((value): value is MoneyDto => value !== null)) return null;
if ( const present = values;
!currency ||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency)
) {
return null;
}
return { return {
currency, currency: 'RUB',
value: formatDecimal( value: formatDecimal(
values.reduce( present.reduce(
(sum, value) => add(sum, decimalFromString(value.value)), (sum, value) => add(sum, decimalFromString(value.value)),
decimalFromString('0'), decimalFromString('0'),
), ),
@@ -106,7 +102,6 @@ function percent(
if ( if (
!base || !base ||
!result || !result ||
base.currency !== result.currency ||
decimalFromString(base.value).value <= ZERO_AMOUNT decimalFromString(base.value).value <= ZERO_AMOUNT
) { ) {
return null; return null;
@@ -0,0 +1,64 @@
import type {
TbankBond,
TbankShare,
} from '#src/integrations/tbank/tbank.api';
import { publicInstrument } from './public-instrument';
const share = {
uid: 'share-uid',
figi: 'FIGI1',
assetUid: 'asset-1',
name: 'Share',
ticker: 'SHAR',
isin: 'US000',
classCode: 'TQBR',
realExchange: 'REAL_EXCHANGE_MOEX',
apiTradeAvailableFlag: true,
currency: 'rub',
shareType: 'SHARE_TYPE_COMMON',
sector: 'Finance',
lot: 1,
brand: { logoName: 'logo.png', title: 'Brand', id: 'brand-1' },
first_1dayCandleDate: '2020-01-01T00:00:00Z',
privateField: 'must-not-leak',
} as unknown as TbankShare;
const bond = {
uid: 'bond-uid',
figi: 'FIGI2',
assetUid: 'asset-2',
name: 'Bond',
ticker: 'BOND',
classCode: 'TQOB',
currency: 'rub',
lot: 1,
nominal: { value: '1000', currency: 'rub' },
initialNominal: { value: '1000', currency: 'rub' },
aciValue: { value: '10', currency: 'rub' },
maturityDate: '2030-01-01T00:00:00Z',
couponQuantityPerYear: 2,
bondType: 'BOND_TYPE_EXPIRING',
brand: null,
privateField: 'must-not-leak',
} as unknown as TbankBond;
describe('publicInstrument projection', () => {
it('keeps approved share fields, trims brand, drops private extras', () => {
const result = publicInstrument(share);
expect(result.uid).toBe('share-uid');
expect(result.shareType).toBe('SHARE_TYPE_COMMON');
expect(result.brand).toEqual({ logoName: 'logo.png' });
expect(result).not.toHaveProperty('privateField');
expect(result).not.toHaveProperty('brand.title');
});
it('projects bonds with missing optional fields and null brand', () => {
const result = publicInstrument(bond);
expect(result.nominal).toEqual({ value: '1000', currency: 'rub' });
expect(result.shareType).toBeUndefined();
expect(result.brand).toBeUndefined();
expect(result.isin).toBeUndefined();
expect(result).not.toHaveProperty('privateField');
});
});
@@ -0,0 +1,38 @@
import type {
TbankBond,
TbankShare,
} from '#src/integrations/tbank/tbank.api';
export function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
const share = value as TbankShare;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: share.shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; import type { TbankBond } from '#src/integrations/tbank/tbank.api';
import { formatDecimal } from '#src/common/decimal';
import { decimal } from '#src/integrations/tbank/decimal';
import { formatDecimal } from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { timestamp } from '../../application/instrument-market'; import { timestamp } from '../../application/instrument-market';
import { import {
@@ -14,7 +15,8 @@ import {
type IdentifiedBond = TbankBond & { uid: string }; type IdentifiedBond = TbankBond & { uid: string };
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { money } from '../infrastructure/bond-money.mapper'; import { money } from '../infrastructure/bond-money.mapper';
const MIN_LOT_EXCLUSIVE = 0; const MIN_LOT_EXCLUSIVE = 0;
@@ -25,12 +27,10 @@ export class BondCatalogService {
async search( async search(
userId: string, userId: string,
query: string, query: string,
observe = false,
): Promise<BondSearchResourceDto> { ): Promise<BondSearchResourceDto> {
const result = await this.discovery.search( const result = await this.discovery.search(
userId, userId,
{ instrumentType: 'bond', query }, { instrumentType: 'bond', query },
observe,
); );
return { return {
...result, ...result,
@@ -58,9 +58,8 @@ export class BondCatalogService {
async get( async get(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<BondResourceDto> { ): Promise<BondResourceDto> {
const result = await this.discovery.detail(userId, 'bond', uid, observe); const result = await this.discovery.detail(userId, 'bond', uid);
return { return {
...result, ...result,
data: result.data data: result.data
@@ -72,13 +71,11 @@ export class BondCatalogService {
async getByTicker( async getByTicker(
userId: string, userId: string,
ticker: string, ticker: string,
observe = false,
): Promise<BondResourceDto> { ): Promise<BondResourceDto> {
const result = await this.discovery.byTicker( const result = await this.discovery.byTicker(
userId, userId,
'bond', 'bond',
ticker, ticker,
observe,
); );
return { return {
...result, ...result,
@@ -5,19 +5,20 @@ import {
divide, divide,
formatDecimal, formatDecimal,
multiply, multiply,
} from '../../../../../common/decimal'; } from '#src/common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol'; import { isTbankProtocolValue, TbankCouponType } from '#src/integrations/tbank/tbank-protocol';
import { import {
TbankAccessService, TbankAccessService,
type TbankBond, type TbankBond,
type TbankCoupon, type TbankCoupon,
tbankSourceNeedsRefresh, tbankSourceNeedsRefresh,
} from '../../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { import {
ForecastService, ForecastService,
PortfolioService, PortfolioService,
} from '../../../portfolio/portfolio.api'; } from '#src/modules/investments/portfolio/portfolio.api';
import { import {
CatalogEnrichmentService, CatalogEnrichmentService,
type CatalogPayload, type CatalogPayload,
@@ -54,14 +55,12 @@ export class BondIncomeService {
async metrics( async metrics(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<BondMetricsResourceDto> { ): Promise<BondMetricsResourceDto> {
const result = await this.sources.section( const result = await this.sources.section(
userId, userId,
'bond', 'bond',
uid, uid,
'metrics', 'metrics',
observe,
); );
return { return {
data: result.values.instrument data: result.values.instrument
@@ -71,8 +70,8 @@ export class BondIncomeService {
}; };
} }
async readPosition(userId: string, uid: string, observe = false) { async readPosition(userId: string, uid: string) {
const saved = await this.portfolios.read(userId, 'all', observe); const saved = await this.portfolios.read(userId, 'all');
return { return {
data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null, data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null,
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` }, meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
@@ -82,22 +81,21 @@ export class BondIncomeService {
async coupons( async coupons(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<BondCouponsResourceDto> { ): Promise<BondCouponsResourceDto> {
const connection = await this.access.local(userId); const connection = await this.access.local(userId);
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const portfolio = await this.portfolios.read(userId, 'all', observe); const portfolio = await this.portfolios.read(userId, 'all');
const sources = await this.sources.readMany( const sources = await this.sources.readMany(
userId, userId,
'bond', 'bond',
[uid], [uid],
observe, false,
['detail', 'coupons'], ['detail', 'coupons'],
{ from, to }, { from, to },
); );
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) {
throw new NotFoundException(); throw new NotFoundException();
} }
const events = sources[1].payload?.coupons; const events = sources[1].payload?.coupons;
@@ -137,7 +135,7 @@ export class BondIncomeService {
projected.observedAt, projected.observedAt,
true, true,
), ),
meta: { ...result.meta, availability: 'partial' }, meta: { ...result.meta, completeness: 'incomplete' },
}; };
} }
} }
@@ -180,7 +178,7 @@ export function bondCoupons(
recordDate && recordDate &&
recordDate >= today recordDate >= today
? { ? {
currency: perBond.currency, currency: 'RUB',
value: formatDecimal( value: formatDecimal(
multiply(quantity, decimalFromString(perBond.value)), multiply(quantity, decimalFromString(perBond.value)),
), ),
@@ -229,7 +227,6 @@ export function bondMetrics(
coupon && coupon &&
couponPeriodDays && couponPeriodDays &&
clean && clean &&
coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= ZERO_AMOUNT && decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
decimalFromString(clean.value).value > ZERO_AMOUNT decimalFromString(clean.value).value > ZERO_AMOUNT
? formatDecimal( ? formatDecimal(
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; import type { TbankBond } from '#src/integrations/tbank/tbank.api';
import { import {
add, add,
@@ -9,9 +9,10 @@ import {
formatDecimal, formatDecimal,
multiply, multiply,
subtract, subtract,
} from '../../../../../common/decimal'; } from '#src/common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { decimal } from '../../../../../integrations/tbank/decimal'; import { decimal } from '#src/integrations/tbank/decimal';
import { CandleService } from '../../application/candle.service'; import { CandleService } from '../../application/candle.service';
import { import {
CatalogEnrichmentService, CatalogEnrichmentService,
@@ -38,14 +39,12 @@ export class BondMarketService {
async quote( async quote(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<BondQuoteResourceDto> { ): Promise<BondQuoteResourceDto> {
const result = await this.sources.section( const result = await this.sources.section(
userId, userId,
'bond', 'bond',
uid, uid,
'quote', 'quote',
observe,
); );
return { return {
data: result.values.instrument data: result.values.instrument
@@ -59,12 +58,10 @@ export class BondMarketService {
userId: string, userId: string,
uid: string, uid: string,
period: BondCandlesQueryDto['period'] = 'month', period: BondCandlesQueryDto['period'] = 'month',
observe = false,
) { ) {
return this.history.read( return this.history.read(
userId, userId,
{ uid, instrumentType: 'bond', period }, { uid, instrumentType: 'bond', period },
observe,
); );
} }
} }
@@ -85,7 +82,7 @@ export function bondQuote(
nominal && nominal &&
decimalFromString(nominal.value).value > ZERO_AMOUNT decimalFromString(nominal.value).value > ZERO_AMOUNT
? { ? {
currency: nominal.currency, currency: 'RUB',
value: formatDecimal( value: formatDecimal(
divide( divide(
multiply( multiply(
@@ -99,9 +96,9 @@ export function bondQuote(
} }
: null; : null;
const dirtyPrice = const dirtyPrice =
cleanPrice && aci && aci.currency === cleanPrice.currency cleanPrice && aci
? { ? {
currency: cleanPrice.currency, currency: 'RUB',
value: formatDecimal( value: formatDecimal(
add( add(
decimalFromString(cleanPrice.value), decimalFromString(cleanPrice.value),
@@ -1,9 +1,9 @@
import type { PortfolioDto } from '../../../portfolio/portfolio.api'; import type { PortfolioDto } from '#src/modules/investments/portfolio/portfolio.api';
import { instrumentPosition } from '../../application/instrument-position'; import { instrumentPosition } from '../../application/instrument-position';
import { bondCoupons, bondMetrics } from './bond-income.service'; import { bondCoupons, bondMetrics } from './bond-income.service';
import { bondQuote } from './bond-market.service'; import { bondQuote } from './bond-market.service';
jest.mock('../../../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) }); const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
@@ -1,10 +1 @@
import { formatDecimal } from '../../../../../common/decimal'; export { rubMoney as money } from '#src/integrations/tbank/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
export function money(value?: TbankMoney) {
const currency = value?.currency?.toUpperCase();
return value && currency
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
@@ -11,9 +11,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
import { BondCatalogService } from '../application/bond-catalog.service'; import { BondCatalogService } from '../application/bond-catalog.service';
import { BondIncomeService } from '../application/bond-income.service'; import { BondIncomeService } from '../application/bond-income.service';
import { BondMarketService } from '../application/bond-market.service'; import { BondMarketService } from '../application/bond-market.service';
@@ -47,9 +47,8 @@ export class BondController {
metrics( metrics(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.metrics(request.user.sub, uid, query.sync === 'observe'); return this.income.metrics(request.user.sub, uid);
} }
@Get() @Get()
@@ -61,7 +60,6 @@ export class BondController {
return this.bonds.search( return this.bonds.search(
request.user.sub, request.user.sub,
query.query, query.query,
query.sync === 'observe',
); );
} }
@@ -70,12 +68,10 @@ export class BondController {
getByTicker( getByTicker(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param() params: BondTickerParamsDto, @Param() params: BondTickerParamsDto,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.bonds.getByTicker( return this.bonds.getByTicker(
request.user.sub, request.user.sub,
params.ticker, params.ticker,
query.sync === 'observe',
); );
} }
@@ -84,9 +80,8 @@ export class BondController {
get( get(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.bonds.get(request.user.sub, uid, query.sync === 'observe'); return this.bonds.get(request.user.sub, uid);
} }
@Get(':uid/quote') @Get(':uid/quote')
@@ -94,9 +89,8 @@ export class BondController {
quote( quote(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.market.quote(request.user.sub, uid, query.sync === 'observe'); return this.market.quote(request.user.sub, uid);
} }
@Get(':uid/candles') @Get(':uid/candles')
@@ -110,7 +104,6 @@ export class BondController {
request.user.sub, request.user.sub,
uid, uid,
query.period, query.period,
query.sync === 'observe',
); );
} }
@@ -119,13 +112,8 @@ export class BondController {
position( position(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.readPosition( return this.income.readPosition(request.user.sub, uid);
request.user.sub,
uid,
query.sync === 'observe',
);
} }
@Get(':uid/coupons') @Get(':uid/coupons')
@@ -133,8 +121,7 @@ export class BondController {
coupons( coupons(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.coupons(request.user.sub, uid, query.sync === 'observe'); return this.income.coupons(request.user.sub, uid);
} }
} }
@@ -1,7 +1,8 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../../../common/money.dto'; import { MoneyDto } from '#src/common/money.dto';
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto'; import { InvestmentResourceMetaDto } from '#src/modules/investments/investment-resource.dto';
import { import {
StockCandlesDto, StockCandlesDto,
StockCandlesQueryDto, StockCandlesQueryDto,

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