From 10b7d7fbf9e951027182c76179e9ade407be5ff1 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:10:18 +0200 Subject: [PATCH 01/46] Add the projection test harness and convert four read paths The endpoint inventory records that every read path is to select the fields it returns, and that a converted endpoint counts only at 4/4 on the four levels in docs/read-path-projections.md. Neither the harness for those levels nor a single conversion existed yet; this adds both. The harness needs a real database, because a mocked repository cannot observe which columns a query asked for. It builds the schema from the entity metadata via synchronize (112 entities, 99 tables, 1,736 columns) into a schema per spec file, and generates fixtures from the same metadata so that every column carries a distinct value - an empty field in a response then proves the query failed to load something. It runs as its own Jest configuration rather than inside the main suite. That is forced: the main suite compiles transpile-only, which emits design:type as Object for imported types, and building a data source from the entity sources fails on the first enum column. jest.projection.config.js compiles with full type information, the same reason the Frick and coverage gates have their own configuration. Converted, each at 4/4: GET /user/profile 253 columns -> 41 GET /buy/:id/history 497 -> 12 GET /swap/:id/history 509 -> 12 GET /sell/:id/history 470 -> 14 The two history mappers move out of their services into their own files so the specs can drive the same mapping the endpoints use; a copy in the spec could be wrong in the same way the projection is wrong. Two notes on the levels themselves. Level 3 now establishes a baseline before it drops anything: with an already incomplete response every reduced run fails too, and "every field is required" would be reported for a projection nothing was proven about. Level 4 uses the unprojected load as the second source - it fetches every column, so what it produces is by construction what the endpoint answered before, and it catches a projection loading the wrong field rather than merely too few. --- .github/workflows/api-pr.yaml | 43 +++ docs/endpoints.md | 152 +++++----- docs/read-path-projections.md | 109 +++++-- jest.projection.config.js | 25 ++ package.json | 4 + src/shared/models/read-projection.ts | 47 +++ src/shared/utils/projection-test.util.ts | 284 ++++++++++++++++++ .../buy-crypto-history.projection.spec.ts | 181 +++++++++++ .../process/dto/buy-crypto-history.mapper.ts | 33 ++ .../repositories/buy-crypto.repository.ts | 85 ++++++ .../__tests__/buy-crypto.service.spec.ts | 5 +- .../process/services/buy-crypto.service.ts | 37 +-- .../buy-fiat-history.projection.spec.ts | 140 +++++++++ .../__tests__/buy-fiat.service.spec.ts | 3 +- .../process/buy-fiat.repository.ts | 52 ++++ .../process/dto/buy-fiat-history.mapper.ts | 31 ++ .../process/services/buy-fiat.service.ts | 27 +- .../models/user-data/user-data.repository.ts | 91 ++++++ .../__tests__/user-profile.projection.spec.ts | 175 +++++++++++ .../generic/user/models/user/user.service.ts | 5 +- 20 files changed, 1356 insertions(+), 173 deletions(-) create mode 100644 jest.projection.config.js create mode 100644 src/shared/models/read-projection.ts create mode 100644 src/shared/utils/projection-test.util.ts create mode 100644 src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts create mode 100644 src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts create mode 100644 src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts create mode 100644 src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts create mode 100644 src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts diff --git a/.github/workflows/api-pr.yaml b/.github/workflows/api-pr.yaml index fb25bc8180..52cddb70a6 100644 --- a/.github/workflows/api-pr.yaml +++ b/.github/workflows/api-pr.yaml @@ -228,3 +228,46 @@ jobs: run: npm test -- --shard=${{ matrix.shard }}/3 env: MIGRATION_TEST_PG: postgres://postgres:postgres@localhost:5432/postgres + + projection: + name: Read-path projections + runs-on: ubuntu-latest + services: + # These specs build the schema from the entity metadata and assert against real rows: a mocked + # repository cannot observe which columns a query asked for, so none of the four levels in + # docs/read-path-projections.md can be tested without a database. + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install packages + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_on: error + command: npm ci + + # Separate from the sharded run because it compiles with full type information; see + # jest.projection.config.js for why the main suite cannot host these. + - name: Run projection tests + run: npm run test:projection + env: + MIGRATION_TEST_PG: postgres://postgres:postgres@localhost:5432/postgres diff --git a/docs/endpoints.md b/docs/endpoints.md index fd9626ff63..f5fec3f3e3 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -1,6 +1,6 @@ # HTTP endpoints -Every HTTP endpoint this service exposes: **537 decorated route entries** across 94 controller files, of which **536 are registered at runtime** — one handler carries two `@Post` decorators and only one of them takes effect, see *Known discrepancy*. 299 are marked `@ApiExcludeEndpoint` and do not appear in the public Swagger schema. +Every HTTP endpoint this service exposes: **534 decorated route entries** across 94 controller files, of which **533 are registered at runtime** — one handler carries two `@Post` decorators and only one of them takes effect, see *Known discrepancy*. 296 are marked `@ApiExcludeEndpoint` and do not appear in the public Swagger schema. ## Columns @@ -23,25 +23,20 @@ Two rules follow from that, and both are binding: 1. **An endpoint counts as converted only when its tests reach `4/4`** against the four levels in [read-path-projections.md](read-path-projections.md#test-definition). A projection without them is worse than no projection: a forgotten field does not crash, it returns a wrong value with a 200, and in a service moving money that can run for weeks unnoticed. Anything short of `4/4` is unfinished work, not a partial success. 2. **The state of every endpoint is recorded here**, in the `Tests` column, and kept in sync with the code in the same pull request that changes it. An undocumented conversion is indistinguishable from one that was never tested. -Nine endpoints moved into the `whole rows` group without their handlers changing: `GET /health` and its -five siblings, `GET /monitoring/data`, `GET /statistic` and `GET /dashboard/financial/latest`. Their state -used to live in an in-process subject and now comes from the database, because the process that writes it -is no longer the process that serves the request. - -Today 2 endpoints read only what they need and 444 do not, so the column reads `not yet` almost everywhere. Two further endpoints project only when the caller supplies a field list and load the whole table otherwise, which is why they are counted separately rather than as converted. That is the point of recording it: the number is the distance to the target. +Today 8 endpoints read only what they return and 428 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. ## What the numbers say | Data access | Endpoints | Share | | ----------- | --------: | ----: | -| `whole rows` | 444 | 83 % | -| `none` | 89 | 17 % | -| `projected` | 2 | 0 % | +| `whole rows` | 428 | 80 % | +| `none` | 98 | 18 % | +| `projected` | 6 | 1 % | | `caller-defined` | 2 | 0 % | -Two endpoints are classified as reading only what they need, on the strength of reads resolved in the source rather than an exhaustive proof: `PUT /log/financial/validity`, whose query names `log.id` and `log.valid`, and `POST /gs/debug`, which assembles its select list from the request. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. How far the test suite actually covers those reads is recorded per site in [read-path-projections.md](read-path-projections.md#which-endpoints-these-apply-to); the short answer is that the projection behind `PUT /log/financial/validity` is never executed in a test. +Six endpoints read only what they return. Two were already that way: `PUT /log/financial/validity`, whose query names `log.id` and `log.valid`, and `POST /gs/debug`, which assembles its select list from the request. Four were converted, each with an explicit field list and tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 to 12), `GET /swap/:id/history` (509 to 12) and `GET /sell/:id/history` (470 to 14). `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. How far the test suite actually covers those reads is recorded per site in [read-path-projections.md](read-path-projections.md#which-endpoints-these-apply-to); the short answer is that the projection behind `PUT /log/financial/validity` is never executed in a test. -Among the 444 that fetch whole rows, the widest query they can trigger is **308 columns** at the median of the recorded maxima; at least 320 exceed 100, 89 exceed 500 and 21 exceed 1000. Postgres refuses a statement with more than 1664 columns; the widest an endpoint can trigger is 1367, so about three hundred columns separate it from a statement the database rejects. The load-site table records a wider one still, at 1453 — that site is reached by a scheduled job rather than by a request. +Among the 428 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 316 exceed 100, 89 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. ### How to read this column, and how not to @@ -55,10 +50,10 @@ Among the 444 that fetch whole rows, the widest query they can trigger is **308 Stated exactly, so the numbers can be checked rather than believed: -- **448 of the 537 route entries rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 444 is a lower bound in that direction. In the other direction 440 of them are backed by at least one measured query; the remaining four are the entries discussed below. -- All 89 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. +- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 428 is a lower bound. +- All 98 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. - The 2 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. -- 4 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`, `PUT /userData/:id/volumes` and `PUT /buyCrypto/:id/amlCheck/reviewReset`. Those four are also the ones most exposed to the upper bound described in [load-sites.md](load-sites.md#measurements): with no measured query behind them, nothing here shows that they reach a whole-row read at all. +- 3 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`, `PUT /userData/:id/volumes`. Those three are also the ones most exposed to the upper bound described in [load-sites.md](load-sites.md#measurements): with no measured query behind them, nothing here shows that they reach a whole-row read at all. ### Two controller classes may share a name @@ -102,7 +97,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea ## How the values are produced -- **Endpoints** — from the routing decorators in `src/**/*.controller.ts`, each attributed to the `@Controller` scope preceding it. Decorators between the route and the method are skipped by counting parentheses, so a multi-line `@UseGuards(` cannot be mistaken for the handler. Cross-checked in both directions against the routes the framework registers at startup: all 530 distinct method/path pairs match, with no entry left over on either side. The 536 registered rows exceed that by the six pairs served under two versions. +- **Endpoints** — from the routing decorators in `src/**/*.controller.ts`, each attributed to the `@Controller` scope preceding it. Decorators between the route and the method are skipped by counting parentheses, so a multi-line `@UseGuards(` cannot be mistaken for the handler. Cross-checked in both directions against the routes the framework registers at startup: all 527 distinct method/path pairs match, with no entry left over on either side. The 533 registered rows exceed that by the six pairs served under two versions. - **Ver** — from `@Version` on the handler, otherwise from the `@Controller` scope, otherwise the configured default. Note that the version follows the class, not the folder: the controllers under `generic/kyc/` are not uniformly v2 — `KycAdminController` carries no version decorator and is therefore served under the default. - **Data access** — the union over the call graph, following injected fields, locally constructed repositories and multi-line call chains. `find*` pulls in eager relations, `createQueryBuilder` does not, a bare identifier passed to `.select(...)` is the root alias and loads every column, while anything else — an array, a qualified column such as `.select('userData.id', 'id')`, or an expression such as `COUNT(*)` — narrows it, and `.update()/.delete()/.insert()` are writes that load nothing. - **Max cols** — the query is built from the real entity metadata and its SELECT list counted, so the number is measured rather than estimated. It is still a lower bound wherever the load site takes its `relations` tree as a parameter, or the call graph did not resolve: both can only add sites and widen queries, never the reverse. @@ -157,12 +152,12 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/bankAccount/iban` | hidden | whole rows | 26 | not yet | | `BankAccountController.addBankAccountIban` | `subdomains/supporting/bank/bank-account/bank-account.controller.ts` | | PUT | 1 | | `/bankData/:id` | hidden | whole rows | 31 | not yet | | `BankDataController.updateBankData` | `subdomains/generic/user/models/bank-data/bank-data.controller.ts` | | PUT | 1 | | `/bankData/:id/nameCheck` | hidden | whole rows | 276 | not yet | | `BankDataController.doNameCheck` | `subdomains/generic/user/models/bank-data/bank-data.controller.ts` | -| POST | 1 | | `/bankTx` | hidden | whole rows | 62 | not yet | | `BankTxController.uploadSepaFiles` | `subdomains/supporting/bank-tx/bank-tx/bank-tx.controller.ts` | -| PUT | 1 | | `/bankTx/:id` | hidden | whole rows | 1053 | not yet | | `BankTxController.update` | `subdomains/supporting/bank-tx/bank-tx/bank-tx.controller.ts` | -| DELETE | 1 | | `/bankTx/:id/buyCrypto` | hidden | whole rows | 249 | not yet | | `BankTxController.reset` | `subdomains/supporting/bank-tx/bank-tx/bank-tx.controller.ts` | +| POST | 1 | | `/bankTx` | hidden | whole rows | 61 | not yet | | `BankTxController.uploadSepaFiles` | `subdomains/supporting/bank-tx/bank-tx/bank-tx.controller.ts` | +| PUT | 1 | | `/bankTx/:id` | hidden | whole rows | 1051 | not yet | | `BankTxController.update` | `subdomains/supporting/bank-tx/bank-tx/bank-tx.controller.ts` | +| DELETE | 1 | | `/bankTx/:id/buyCrypto` | hidden | whole rows | 247 | not yet | | `BankTxController.reset` | `subdomains/supporting/bank-tx/bank-tx/bank-tx.controller.ts` | | PUT | 1 | | `/bankTxRepeat/:id` | hidden | whole rows | 308 | not yet | | `BankTxRepeatController.update` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.controller.ts` | -| PUT | 1 | | `/bankTxReturn/:id` | hidden | whole rows | 439 | not yet | | `BankTxReturnController.update` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.controller.ts` | -| POST | 1 | | `/bankTxReturn/:id/refund` | hidden | whole rows | 728 | not yet | | `BankTxReturnController.refundBuyCrypto` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.controller.ts` | +| PUT | 1 | | `/bankTxReturn/:id` | hidden | whole rows | 438 | not yet | | `BankTxReturnController.update` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.controller.ts` | +| POST | 1 | | `/bankTxReturn/:id/refund` | hidden | whole rows | 727 | not yet | | `BankTxReturnController.refundBuyCrypto` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.controller.ts` | | POST | 1 | | `/blockchain/balances` | public | whole rows | 33 | not yet | | `BlockchainApiController.getBalances` | `integration/blockchain/api/controllers/blockchain-api.controller.ts` | | POST | 1 | | `/blockchain/broadcast` | public | none | — | n/a | | `BlockchainApiController.broadcastTransaction` | `integration/blockchain/api/controllers/blockchain-api.controller.ts` | | POST | 1 | | `/blockchain/transaction` | public | whole rows | 33 | not yet | | `BlockchainApiController.createTransaction` | `integration/blockchain/api/controllers/blockchain-api.controller.ts` | @@ -170,27 +165,27 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/buy` | hidden | whole rows | 364 | not yet | | `BuyController.createBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | GET | 1 | | `/buy/:id` | public | whole rows | 308 | not yet | | `BuyController.getBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/:id` | hidden | whole rows | 308 | not yet | | `BuyController.updateBuyRoute` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| GET | 1 | | `/buy/:id/history` | hidden | whole rows | 498 | not yet | | `BuyController.getBuyRouteHistory` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| GET | 1 | | `/buy/:id/history` | hidden | projected | 12 | 4/4 | | `BuyController.getBuyRouteHistory` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/paymentInfos` | public | whole rows | 364 | not yet | | `BuyController.createBuyWithPaymentInfo` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/paymentInfos/:id/confirm` | public | whole rows | 504 | not yet | | `BuyController.confirmBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/paymentInfos/:id/invoice` | public | whole rows | 504 | not yet | yes | `BuyController.generateInvoicePDF` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | GET | 1 | | `/buy/personalIban` | public | whole rows | 331 | not yet | | `BuyController.getAllPersonalIbans` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | POST | 1 | | `/buy/personalIban` | public | whole rows | 253 | not yet | | `BuyController.createPersonalIban` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/quote` | public | whole rows | 143 | not yet | | `BuyController.getBuyQuote` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| PUT | 1 | | `/buyCrypto/:id` | hidden | whole rows | 1092 | not yet | | `BuyCryptoController.update` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| PUT | 1 | | `/buyCrypto/:id/amlCheck` | hidden | whole rows | 1092 | not yet | | `BuyCryptoController.manualPassAmlCheck` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| PUT | 1 | | `/buyCrypto/:id/amlCheck/reviewReset` | hidden | whole rows | — | not yet | | `BuyCryptoController.resetAmlCheckForReview` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| POST | 1 | | `/buyCrypto/:id/refund` | hidden | whole rows | 1053 | not yet | | `BuyCryptoController.refundBuyCrypto` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| POST | 1 | | `/buyCrypto/:id/scorechain` | hidden | whole rows | 718 | not yet | | `BuyCryptoController.retriggerScorechain` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| POST | 1 | | `/buyCrypto/:id/webhook` | hidden | whole rows | 846 | not yet | | `BuyCryptoController.triggerWebhook` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyCrypto/:id` | hidden | whole rows | 1090 | not yet | | `BuyCryptoController.update` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| DELETE | 1 | | `/buyCrypto/:id/amlCheck` | hidden | whole rows | 422 | not yet | | `BuyCryptoController.resetAmlCheck` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyCrypto/:id/amlCheck` | hidden | whole rows | 1090 | not yet | | `BuyCryptoController.manualPassAmlCheck` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| POST | 1 | | `/buyCrypto/:id/refund` | hidden | whole rows | 1051 | not yet | | `BuyCryptoController.refundBuyCrypto` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| POST | 1 | | `/buyCrypto/:id/scorechain` | hidden | whole rows | 717 | not yet | | `BuyCryptoController.retriggerScorechain` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| POST | 1 | | `/buyCrypto/:id/webhook` | hidden | whole rows | 844 | not yet | | `BuyCryptoController.triggerWebhook` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | PUT | 1 | | `/buyCrypto/refVolumes` | hidden | whole rows | 77 | not yet | | `BuyCryptoController.updateRefVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| PUT | 1 | | `/buyCrypto/volumes` | hidden | whole rows | 488 | not yet | | `BuyCryptoController.updateBuyVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| PUT | 1 | | `/buyFiat/:id` | hidden | whole rows | 1034 | not yet | | `BuyFiatController.update` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | +| PUT | 1 | | `/buyCrypto/volumes` | hidden | whole rows | 487 | not yet | | `BuyCryptoController.updateBuyVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyFiat/:id` | hidden | whole rows | 1033 | not yet | | `BuyFiatController.update` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | DELETE | 1 | | `/buyFiat/:id/amlCheck` | hidden | whole rows | 490 | not yet | | `BuyFiatController.resetAmlCheck` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | -| PUT | 1 | | `/buyFiat/:id/amlCheck` | hidden | whole rows | 1034 | not yet | | `BuyFiatController.manualPassAmlCheck` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | +| PUT | 1 | | `/buyFiat/:id/amlCheck` | hidden | whole rows | 1033 | not yet | | `BuyFiatController.manualPassAmlCheck` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | POST | 1 | | `/buyFiat/:id/refund` | hidden | whole rows | 803 | not yet | | `BuyFiatController.refundBuyFiat` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | POST | 1 | | `/buyFiat/:id/scorechain` | hidden | whole rows | 517 | not yet | | `BuyFiatController.retriggerScorechain` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | -| POST | 1 | | `/buyFiat/:id/webhook` | hidden | whole rows | 645 | not yet | | `BuyFiatController.triggerWebhook` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | +| POST | 1 | | `/buyFiat/:id/webhook` | hidden | whole rows | 644 | not yet | | `BuyFiatController.triggerWebhook` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | PUT | 1 | | `/buyFiat/refVolumes` | hidden | whole rows | 77 | not yet | | `BuyFiatController.updateRefVolumes` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | PUT | 1 | | `/buyFiat/volumes` | hidden | whole rows | 308 | not yet | | `BuyFiatController.updateVolumes` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | GET | 1 | | `/country` | public | whole rows | 23 | not yet | | `CountryController.getAllCountry` | `shared/models/country/country.controller.ts` | @@ -229,11 +224,11 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/dashboard/accounting/ledger/suspense` | hidden | whole rows | 11 | not yet | yes | `LedgerController.getSuspense` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/financial/changes` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getFinancialChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/changes/latest` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getLatestChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | -| GET | 1 | | `/dashboard/financial/latest` | hidden | whole rows | 33 | not yet | | `DashboardFinancialController.getLatestBalance` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | +| GET | 1 | | `/dashboard/financial/latest` | hidden | none | — | n/a | | `DashboardFinancialController.getLatestBalance` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/log` | hidden | whole rows | 33 | not yet | yes | `DashboardFinancialController.getFinancialLog` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/reconciliation` | hidden | whole rows | 229 | not yet | | `DashboardReconciliationController.getReconciliation` | `subdomains/supporting/dashboard/dashboard-reconciliation.controller.ts` | | GET | 1 | | `/dashboard/financial/reconciliation/overview` | hidden | whole rows | 229 | not yet | | `DashboardReconciliationController.getOverview` | `subdomains/supporting/dashboard/dashboard-reconciliation.controller.ts` | -| GET | 1 | | `/dashboard/financial/ref-recipients` | hidden | whole rows | 2 | not yet | | `DashboardFinancialController.getRefRewardRecipients` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | +| GET | 1 | | `/dashboard/financial/ref-recipients` | hidden | whole rows | 25 | not yet | | `DashboardFinancialController.getRefRewardRecipients` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | POST | 1 | | `/deposit` | hidden | whole rows | 6 | not yet | | `DepositController.createDeposits` | `subdomains/supporting/address-pool/deposit/deposit.controller.ts` | | PUT | 1 | | `/deposit/lightningWebhook` | hidden | none | — | n/a | | `DepositController.updateLightningDepositWebhook` | `subdomains/supporting/address-pool/deposit/deposit.controller.ts` | | GET | 1 | | `/deuro/info` | public | whole rows | 11 | not yet | | `DEuroController.getInfo` | `integration/blockchain/deuro/controllers/deuro.controller.ts` | @@ -268,17 +263,17 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/gs/evm/contractTransaction` | hidden | none | — | n/a | | `GsEvmController.sendContractTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | | POST | 1 | | `/gs/evm/rawTransaction` | hidden | whole rows | 6 | not yet | | `GsEvmController.sendRawTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | | POST | 1 | | `/gs/evm/tokenTransaction` | hidden | whole rows | 33 | not yet | | `GsEvmController.sendTokenTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | -| GET | 1 | | `/gs/support` | hidden | whole rows | 910 | not yet | yes | `GsController.getSupportData` | `subdomains/generic/gs/gs.controller.ts` | -| GET | neutral | | `/health` | public | whole rows | 4 | not yet | | `HealthController.getHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/banking` | public | whole rows | 4 | not yet | | `HealthController.getBankingHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/external` | public | whole rows | 4 | not yet | | `HealthController.getExternalHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/liquidity` | public | whole rows | 4 | not yet | | `HealthController.getLiquidityHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/nodes` | public | whole rows | 4 | not yet | | `HealthController.getNodeHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/payment` | public | whole rows | 4 | not yet | | `HealthController.getPaymentHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | 1 | | `/history` | hidden | whole rows | 1367 | not yet | | `HistoryController.getHistory` | `subdomains/core/history/controllers/history.controller.ts` | -| GET | 1 | | `/history/:exportType` | hidden | whole rows | 1367 | not yet | | `HistoryController.getApiHistory` | `subdomains/core/history/controllers/history.controller.ts` | +| GET | 1 | | `/gs/support` | hidden | whole rows | 907 | not yet | yes | `GsController.getSupportData` | `subdomains/generic/gs/gs.controller.ts` | +| GET | neutral | | `/health` | public | none | — | n/a | | `HealthController.getHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/banking` | public | none | — | n/a | | `HealthController.getBankingHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/external` | public | none | — | n/a | | `HealthController.getExternalHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/liquidity` | public | none | — | n/a | | `HealthController.getLiquidityHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/nodes` | public | none | — | n/a | | `HealthController.getNodeHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/payment` | public | none | — | n/a | | `HealthController.getPaymentHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | 1 | | `/history` | hidden | whole rows | 1363 | not yet | | `HistoryController.getHistory` | `subdomains/core/history/controllers/history.controller.ts` | +| GET | 1 | | `/history/:exportType` | hidden | whole rows | 1363 | not yet | | `HistoryController.getApiHistory` | `subdomains/core/history/controllers/history.controller.ts` | | GET | 1 | | `/history/csv` | hidden | none | — | n/a | | `HistoryController.getCsv` | `subdomains/core/history/controllers/history.controller.ts` | -| POST | 1 | | `/history/csv` | hidden | whole rows | 1367 | not yet | | `HistoryController.createCsv` | `subdomains/core/history/controllers/history.controller.ts` | +| POST | 1 | | `/history/csv` | hidden | whole rows | 1363 | not yet | | `HistoryController.createCsv` | `subdomains/core/history/controllers/history.controller.ts` | | GET | 1 | | `/ikna/bfs/:id` | hidden | none | — | n/a | | `IknaController.getBfsResult` | `integration/ikna/controllers/ikna.controller.ts` | | POST | 1 | | `/ikna/bfs/address` | hidden | none | — | n/a | | `IknaController.createBfsAddressRequest` | `integration/ikna/controllers/ikna.controller.ts` | | GET | 1 | | `/ikna/tag` | hidden | none | — | n/a | | `IknaController.getIknaAddressTag` | `integration/ikna/controllers/ikna.controller.ts` | @@ -304,11 +299,11 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/kyc/admin/nameCheck/:id` | hidden | whole rows | 245 | not yet | | `KycAdminController.updateNameCheckLog` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | PUT | 1 | | `/kyc/admin/step/:id` | hidden | whole rows | 385 | not yet | | `KycAdminController.updateKycStep` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | POST | 1 | | `/kyc/admin/webhook` | hidden | whole rows | 364 | not yet | | `KycAdminController.triggerWebhook` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | -| GET | 2 | | `/kyc/client/payments` | public | whole rows | 1095 | not yet | | `KycClientController.getAllPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | +| GET | 2 | | `/kyc/client/payments` | public | whole rows | 1092 | not yet | | `KycClientController.getAllPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 2 | | `/kyc/client/users` | public | whole rows | 20 | not yet | | `KycClientController.getAllKycData` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 2 | | `/kyc/client/users/:id/documents` | public | whole rows | 78 | not yet | | `KycClientController.getKycFiles` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 2 | | `/kyc/client/users/:id/documents/:type` | public | whole rows | 78 | not yet | | `KycClientController.getKycFile` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | -| GET | 2 | | `/kyc/client/users/:id/payments` | public | whole rows | 1095 | not yet | | `KycClientController.getUserPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | +| GET | 2 | | `/kyc/client/users/:id/payments` | public | whole rows | 1092 | not yet | | `KycClientController.getUserPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 1 | yes | `/kyc/countries` | public | whole rows | 351 | not yet | | `KycController.getKycCountriesV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 2 | yes | `/kyc/countries` | public | whole rows | 351 | not yet | | `KycController.getKycCountries` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | DELETE | 2 | | `/kyc/data/:type/:id` | public | whole rows | 351 | not yet | | `KycController.cancelStep` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | @@ -341,7 +336,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | yes | `/kyc/transfer` | public | whole rows | 364 | not yet | | `KycController.transferKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | yes | `/kyc/users` | public | whole rows | 328 | not yet | | `KycClientController.getAllKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | | `/language` | public | whole rows | 7 | not yet | | `LanguageController.getAllLanguage` | `shared/models/language/language.controller.ts` | -| PUT | 1 | | `/limitRequest/:id` | hidden | whole rows | 364 | not yet | | `LimitRequestController.updateUserData` | `subdomains/supporting/support-issue/limit-request.controller.ts` | +| PUT | 1 | | `/limitRequest/:id` | hidden | whole rows | 434 | not yet | | `LimitRequestController.updateUserData` | `subdomains/supporting/support-issue/limit-request.controller.ts` | | GET | 1 | | `/liquidityManagement/balance` | hidden | whole rows | 40 | not yet | | `LiquidityBalanceController.getBalances` | `subdomains/core/liquidity-management/controllers/balance.controller.ts` | | PUT | 1 | | `/liquidityManagement/order/:id/resolveUncertain` | hidden | whole rows | 139 | not yet | | `LiquidityManagementOrderController.resolveUncertainOrder` | `subdomains/core/liquidity-management/controllers/order.controller.ts` | | GET | 1 | | `/liquidityManagement/order/in-progress` | hidden | whole rows | 139 | not yet | | `LiquidityManagementOrderController.getProcessingOrders` | `subdomains/core/liquidity-management/controllers/order.controller.ts` | @@ -365,7 +360,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/lnurlp/:id` | public | whole rows | 545 | not yet | | `LnUrlPForwardController.activatePublicPayment` | `subdomains/generic/forwarding/controllers/lnurlp-forward.controller.ts` | | DELETE | 1 | | `/lnurlp/cancel/:id` | public | whole rows | 545 | not yet | | `LnUrlPForwardController.cancelPayment` | `subdomains/generic/forwarding/controllers/lnurlp-forward.controller.ts` | | GET | 1 | | `/lnurlp/cb/:id` | public | whole rows | 545 | not yet | yes | `LnUrlPForwardController.lnUrlPCallbackForward` | `subdomains/generic/forwarding/controllers/lnurlp-forward.controller.ts` | -| GET | 1 | | `/lnurlp/tx/:id` | public | whole rows | 558 | not yet | | `LnUrlPForwardController.txHexForward` | `subdomains/generic/forwarding/controllers/lnurlp-forward.controller.ts` | +| GET | 1 | | `/lnurlp/tx/:id` | public | whole rows | 545 | not yet | | `LnUrlPForwardController.txHexForward` | `subdomains/generic/forwarding/controllers/lnurlp-forward.controller.ts` | | GET | 1 | | `/lnurlp/wait/:id` | public | whole rows | 545 | not yet | | `LnUrlPForwardController.waitForPayment` | `subdomains/generic/forwarding/controllers/lnurlp-forward.controller.ts` | | GET | 1 | | `/lnurlw/:id` | public | none | — | n/a | yes | `LnUrlWForwardController.lnUrlWForward` | `subdomains/generic/forwarding/controllers/lnurlw-forward.controller.ts` | | GET | 1 | | `/lnurlw/cb/:id` | public | none | — | n/a | yes | `LnUrlWForwardController.lnUrlWCallbackForward` | `subdomains/generic/forwarding/controllers/lnurlw-forward.controller.ts` | @@ -373,7 +368,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/log/:id` | hidden | whole rows | 11 | not yet | | `LogController.update` | `subdomains/supporting/log/log.controller.ts` | | POST | 1 | | `/log/clientError` | public | none | — | n/a | yes | `ClientErrorController.logError` | `subdomains/supporting/log/client-error.controller.ts` | | PUT | 1 | | `/log/financial/validity` | hidden | projected | 2 | 0/4 | | `LogController.setFinancialLogValidity` | `subdomains/supporting/log/log.controller.ts` | -| GET | 1 | | `/monitoring/data` | hidden | whole rows | 4 | not yet | | `MonitoringController.getSystemState` | `subdomains/core/monitoring/monitoring.controller.ts` | +| GET | 1 | | `/monitoring/data` | hidden | none | — | n/a | | `MonitoringController.getSystemState` | `subdomains/core/monitoring/monitoring.controller.ts` | | POST | 1 | | `/monitoring/data` | hidden | none | — | n/a | | `MonitoringController.onWebhook` | `subdomains/core/monitoring/monitoring.controller.ts` | | GET | 1 | | `/mros` | hidden | whole rows | 243 | not yet | | `MrosController.getAll` | `subdomains/supporting/mros/mros.controller.ts` | | POST | 1 | | `/mros` | hidden | whole rows | 253 | not yet | | `MrosController.createMros` | `subdomains/supporting/mros/mros.controller.ts` | @@ -434,7 +429,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/realunit/account/:address` | public | whole rows | 40 | not yet | | `RealUnitController.getAccountSummary` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/account/:address/history` | public | none | — | n/a | | `RealUnitController.getAccountHistory` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/admin/quotes` | hidden | whole rows | 112 | not yet | | `RealUnitController.getAdminQuotes` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/admin/quotes/:id/confirm-payment` | hidden | whole rows | 62 | not yet | | `RealUnitController.confirmPaymentReceived` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/admin/quotes/:id/confirm-payment` | hidden | whole rows | 1051 | not yet | | `RealUnitController.confirmPaymentReceived` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/admin/registration/:id/forward` | hidden | whole rows | 493 | not yet | yes | `RealUnitController.forwardRegistration` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/admin/transactions` | hidden | whole rows | 362 | not yet | | `RealUnitController.getAdminTransactions` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | POST | 1 | | `/realunit/balance/pdf` | public | whole rows | 308 | not yet | yes | `RealUnitController.getBalancePdf` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | @@ -447,7 +442,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/realunit/buy` | public | whole rows | 364 | not yet | yes | `RealUnitController.getPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/buy/:id/confirm` | public | whole rows | 504 | not yet | | `RealUnitController.confirmBuy` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/compliance/customers` | hidden | whole rows | 308 | not yet | | `RealUnitComplianceController.searchCustomers` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | -| GET | 1 | | `/realunit/compliance/customers/:id` | hidden | whole rows | 1039 | not yet | | `RealUnitComplianceController.getCustomer` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | +| GET | 1 | | `/realunit/compliance/customers/:id` | hidden | whole rows | 826 | not yet | | `RealUnitComplianceController.getCustomer` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | | GET | 1 | | `/realunit/compliance/customers/:id/dossier` | hidden | whole rows | 264 | not yet | | `RealUnitComplianceController.downloadCustomerDossier` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | | GET | 1 | | `/realunit/compliance/customers/:id/files` | hidden | whole rows | 264 | not yet | | `RealUnitComplianceController.getCustomerFiles` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | | GET | 1 | | `/realunit/compliance/customers/:id/files/:uid` | hidden | whole rows | 264 | not yet | | `RealUnitComplianceController.downloadCustomerFile` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | @@ -456,7 +451,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/realunit/legal` | public | whole rows | 308 | not yet | yes | `RealUnitLegalController.getLegal` | `subdomains/supporting/realunit/controllers/realunit-legal.controller.ts` | | PUT | 1 | | `/realunit/legal` | public | whole rows | 308 | not yet | yes | `RealUnitLegalController.acceptLegal` | `subdomains/supporting/realunit/controllers/realunit-legal.controller.ts` | | GET | 1 | | `/realunit/pay/:id/status` | public | whole rows | 32 | not yet | yes | `RealUnitController.getOcpPayStatus` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/pay/submit` | public | whole rows | 558 | not yet | yes | `RealUnitController.submitOcpPay` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/pay/submit` | public | whole rows | 545 | not yet | yes | `RealUnitController.submitOcpPay` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/pay/unsigned-transaction` | public | whole rows | 545 | not yet | yes | `RealUnitController.getOcpPayUnsignedTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/price` | public | whole rows | 33 | not yet | | `RealUnitController.getRealUnitPrice` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/price/history` | public | whole rows | 40 | not yet | | `RealUnitController.getHistoricalPrice` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | @@ -477,7 +472,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/realunit/sell/:id/confirm` | public | whole rows | 504 | not yet | | `RealUnitController.confirmSell` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/sell/:id/unsigned-transactions` | public | whole rows | 504 | not yet | | `RealUnitController.getSellUnsignedTransactions` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/support/:id` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.updateSupportIssue` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/:id/data` | hidden | whole rows | 952 | not yet | | `RealUnitSupportController.getIssueData` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/:id/data` | hidden | whole rows | 951 | not yet | | `RealUnitSupportController.getIssueData` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | POST | 1 | | `/realunit/support/:id/message` | hidden | whole rows | 441 | not yet | | `RealUnitSupportController.createSupportMessage` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/:id/message/:messageId/file` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.getFile` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/:id/messages` | hidden | whole rows | 428 | not yet | | `RealUnitSupportController.getIssueMessages` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | @@ -495,9 +490,9 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/realunit/transfer` | public | whole rows | 308 | not yet | yes | `RealUnitController.prepareTransfer` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/transfer/:id/confirm` | public | whole rows | 87 | not yet | yes | `RealUnitController.confirmTransfer` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | yes | `/realunit/wallet/status` | public | whole rows | 308 | not yet | yes | `RealUnitController.getWalletStatus` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| GET | 1 | | `/recall` | hidden | whole rows | 175 | not yet | | `RecallController.getAll` | `subdomains/supporting/recall/recall.controller.ts` | +| GET | 1 | | `/recall` | hidden | whole rows | 174 | not yet | | `RecallController.getAll` | `subdomains/supporting/recall/recall.controller.ts` | | POST | 1 | | `/recall` | hidden | whole rows | 308 | not yet | | `RecallController.createRecall` | `subdomains/supporting/recall/recall.controller.ts` | -| GET | 1 | | `/recall/:id` | hidden | whole rows | 175 | not yet | | `RecallController.getById` | `subdomains/supporting/recall/recall.controller.ts` | +| GET | 1 | | `/recall/:id` | hidden | whole rows | 174 | not yet | | `RecallController.getById` | `subdomains/supporting/recall/recall.controller.ts` | | PUT | 1 | | `/recall/:id` | hidden | whole rows | 308 | not yet | | `RecallController.updateRecall` | `subdomains/supporting/recall/recall.controller.ts` | | GET | 1 | | `/recommendation` | hidden | whole rows | 474 | not yet | | `RecommendationController.getAllRecommendation` | `subdomains/generic/user/models/recommendation/recommendation.controller.ts` | | POST | 1 | | `/recommendation` | hidden | whole rows | 364 | not yet | | `RecommendationController.createRecommendation` | `subdomains/generic/user/models/recommendation/recommendation.controller.ts` | @@ -515,7 +510,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/sell` | hidden | whole rows | 308 | not yet | | `SellController.createSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | GET | 1 | | `/sell/:id` | public | whole rows | 377 | not yet | | `SellController.getSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/:id` | hidden | whole rows | 308 | not yet | | `SellController.updateSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | -| GET | 1 | | `/sell/:id/history` | hidden | whole rows | 470 | not yet | | `SellController.getSellRouteHistory` | `subdomains/core/sell-crypto/route/sell.controller.ts` | +| GET | 1 | | `/sell/:id/history` | hidden | projected | 14 | 4/4 | | `SellController.getSellRouteHistory` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/paymentInfos` | public | whole rows | 308 | not yet | | `SellController.createSellWithPaymentInfo` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/paymentInfos/:id/confirm` | public | whole rows | 545 | not yet | | `SellController.confirmSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | GET | 1 | | `/sell/paymentInfos/:id/tx` | public | whole rows | 504 | not yet | | `SellController.depositTx` | `subdomains/core/sell-crypto/route/sell.controller.ts` | @@ -526,25 +521,24 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/setting/disabledProcesses` | hidden | none | — | n/a | | `SettingController.updateProcess` | `shared/models/setting/setting.controller.ts` | | GET | 1 | | `/setting/infoBanner` | public | none | — | n/a | | `SettingController.getInfoBanner` | `shared/models/setting/setting.controller.ts` | | POST | 1 | | `/specialExternalAccount` | hidden | whole rows | 7 | not yet | | `SpecialExternalAccountController.createSpecialExternalAccount` | `subdomains/supporting/payment/controllers/special-external-account.controller.ts` | -| GET | 1 | | `/statistic` | public | whole rows | 5 | not yet | | `StatisticController.getAll` | `subdomains/core/statistic/statistic.controller.ts` | +| GET | 1 | | `/statistic` | public | none | — | n/a | | `StatisticController.getAll` | `subdomains/core/statistic/statistic.controller.ts` | | GET | 1 | | `/statistic/status` | public | whole rows | 5 | not yet | | `StatisticController.getStatus` | `subdomains/core/statistic/statistic.controller.ts` | -| GET | 1 | | `/statistic/transactions` | public | whole rows | 420 | not yet | | `StatisticController.getTransactions` | `subdomains/core/statistic/statistic.controller.ts` | -| GET | 1 | | `/support` | hidden | whole rows | 594 | not yet | | `SupportController.searchUserByKey` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/:id` | hidden | whole rows | 1039 | not yet | | `SupportController.getUserData` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/statistic/transactions` | public | whole rows | 419 | not yet | | `StatisticController.getTransactions` | `subdomains/core/statistic/statistic.controller.ts` | +| GET | 1 | | `/support` | hidden | whole rows | 593 | not yet | | `SupportController.searchUserByKey` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/:id` | hidden | whole rows | 826 | not yet | | `SupportController.getUserData` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/:id/ip-log-pdf` | hidden | whole rows | 12 | not yet | | `SupportController.getIpLogPdf` | `subdomains/generic/support/support.controller.ts` | -| POST | 1 | | `/support/:id/limit-request-pdf` | hidden | whole rows | 253 | not yet | | `SupportController.generateLimitRequestPdf` | `subdomains/generic/support/support.controller.ts` | | POST | 1 | | `/support/:id/onboarding-pdf` | hidden | whole rows | 264 | not yet | | `SupportController.generateOnboardingPdf` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/:id/scorechain` | hidden | whole rows | 14 | not yet | | `SupportController.getScorechainScreenings` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/:id/transaction-pdf` | hidden | whole rows | 1039 | not yet | | `SupportController.getTransactionPdf` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/:id/transaction-pdf` | hidden | whole rows | 826 | not yet | | `SupportController.getTransactionPdf` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues` | hidden | none | — | n/a | | `SupportController.getCallQueues` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/call-queues/:queue/items` | hidden | whole rows | 673 | not yet | | `SupportController.getCallQueueItems` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/call-queues/:queue/items` | hidden | whole rows | 672 | not yet | | `SupportController.getCallQueueItems` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues/clerks` | hidden | none | — | n/a | | `SupportController.getCallQueueClerks` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/issue` | public | whole rows | 450 | not yet | | `SupportIssueController.getIssues` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue` | public | whole rows | 493 | not yet | | `SupportIssueController.createIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/:id` | public | whole rows | 450 | not yet | | `SupportIssueController.getIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | PUT | 1 | | `/support/issue/:id` | hidden | whole rows | 421 | not yet | | `SupportIssueController.updateSupportIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | PUT | 1 | | `/support/issue/:id/close` | public | whole rows | 450 | not yet | | `SupportIssueController.closeIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/:id/data` | hidden | whole rows | 952 | not yet | | `SupportIssueController.getIssueData` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/:id/data` | hidden | whole rows | 951 | not yet | | `SupportIssueController.getIssueData` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/:id/message` | public | whole rows | 441 | not yet | yes | `SupportIssueController.createSupportMessage` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/:id/message/:messageId/file` | public | whole rows | — | not yet | | `SupportIssueController.getFile` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/activity` | hidden | whole rows | 7 | not yet | | `SupportIssueController.getSupportIssueActivity` | `subdomains/supporting/support-issue/support-issue.controller.ts` | @@ -566,7 +560,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/support/note/users` | hidden | whole rows | 9 | not yet | | `SupportController.listNoteUsers` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/pending-reviews` | hidden | whole rows | 15 | not yet | | `SupportController.getPendingReviews` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/pending-reviews/items` | hidden | whole rows | 261 | not yet | | `SupportController.getPendingReviewItems` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/pending-transactions` | hidden | whole rows | 673 | not yet | | `SupportController.getPendingTransactions` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/pending-transactions` | hidden | whole rows | 672 | not yet | | `SupportController.getPendingTransactions` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/recommendation-graph/:id/neighbors` | hidden | whole rows | 474 | not yet | yes | `SupportController.getRecommendationGraphNeighbors` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/template` | hidden | whole rows | 8 | not yet | | `SupportController.getTemplates` | `subdomains/generic/support/support.controller.ts` | | POST | 1 | | `/support/template` | hidden | whole rows | 253 | not yet | | `SupportController.createTemplate` | `subdomains/generic/support/support.controller.ts` | @@ -579,34 +573,33 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/swap` | hidden | whole rows | 308 | not yet | | `SwapController.createSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | GET | 1 | | `/swap/:id` | public | whole rows | 308 | not yet | | `SwapController.getSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/:id` | hidden | whole rows | 396 | not yet | | `SwapController.updateSwapRoute` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | -| GET | 1 | | `/swap/:id/history` | hidden | whole rows | 510 | not yet | | `SwapController.getSwapRouteHistory` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | +| GET | 1 | | `/swap/:id/history` | hidden | projected | 12 | 4/4 | | `SwapController.getSwapRouteHistory` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/paymentInfos` | public | whole rows | 308 | not yet | | `SwapController.createSwapWithPaymentInfo` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/paymentInfos/:id/confirm` | public | whole rows | 545 | not yet | | `SwapController.confirmSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | GET | 1 | | `/swap/paymentInfos/:id/tx` | public | whole rows | 504 | not yet | | `SwapController.depositTx` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/quote` | public | whole rows | 143 | not yet | | `SwapController.getSwapQuote` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | POST | 1 | | `/tatum/addressWebhook` | hidden | none | — | n/a | | `TatumController.addressWebhook` | `integration/tatum/controllers/tatum.controller.ts` | | PUT | 1 | | `/trading/rule/:id` | hidden | whole rows | 87 | not yet | | `TradingRuleController.update` | `subdomains/core/trading/controllers/trading-rule.controller.ts` | -| GET | 1 | | `/transaction` | public | whole rows | 1367 | not yet | yes | `TransactionController.getTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction` | public | whole rows | 1363 | not yet | yes | `TransactionController.getTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | | PUT | 1 | | `/transaction/:id/invoice` | public | whole rows | 331 | not yet | yes | `TransactionController.generateInvoiceFromTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | | PUT | 1 | | `/transaction/:id/receipt` | public | whole rows | 331 | not yet | yes | `TransactionController.generateReceiptFromTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | | GET | 1 | | `/transaction/:id/refund` | public | whole rows | 331 | not yet | yes | `TransactionController.getTransactionRefund` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/:id/refund` | public | whole rows | 488 | not yet | | `TransactionController.setTransactionRefundTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/:id/target` | hidden | whole rows | 1053 | not yet | | `TransactionController.setTransactionTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/ChainReport` | hidden | whole rows | 1367 | not yet | yes | `TransactionController.getCsvChainReport` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/CoinTracking` | hidden | whole rows | 1367 | not yet | yes | `TransactionController.getCsvCT` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/:id/refund` | public | whole rows | 487 | not yet | | `TransactionController.setTransactionRefundTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/:id/target` | hidden | whole rows | 1051 | not yet | | `TransactionController.setTransactionTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/ChainReport` | hidden | whole rows | 1363 | not yet | yes | `TransactionController.getCsvChainReport` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/CoinTracking` | hidden | whole rows | 1363 | not yet | yes | `TransactionController.getCsvCT` | `subdomains/core/history/controllers/transaction.controller.ts` | | PUT | 1 | | `/transaction/admin/:id` | hidden | whole rows | 276 | not yet | | `TransactionAdminController.updateTransaction` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | -| POST | 1 | | `/transaction/admin/:id/resume` | hidden | whole rows | 98 | not yet | | `TransactionAdminController.resumeTransaction` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | POST | 1 | | `/transaction/admin/:id/stop` | hidden | whole rows | 98 | not yet | | `TransactionAdminController.stopTransaction` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | POST | 1 | | `/transaction/admin/:txId/riskAssessment` | hidden | none | — | n/a | | `TransactionAdminController.createRiskAssessment` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | PUT | 1 | | `/transaction/admin/:txId/riskAssessment/:id` | hidden | whole rows | 13 | not yet | | `TransactionAdminController.updateRiskAssessment` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | GET | 1 | | `/transaction/csv` | public | none | — | n/a | | `TransactionController.getCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/csv` | public | whole rows | 1367 | not yet | yes | `TransactionController.createCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/detail` | public | whole rows | 1367 | not yet | | `TransactionController.getTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/detail/csv` | public | whole rows | 1367 | not yet | | `TransactionController.createDetailCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/detail/single` | public | whole rows | 488 | not yet | yes | `TransactionController.getSingleTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/single` | public | whole rows | 488 | not yet | yes | `TransactionController.getSingleTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/csv` | public | whole rows | 1363 | not yet | yes | `TransactionController.createCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/detail` | public | whole rows | 1363 | not yet | | `TransactionController.getTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/detail/csv` | public | whole rows | 1363 | not yet | | `TransactionController.createDetailCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/detail/single` | public | whole rows | 487 | not yet | yes | `TransactionController.getSingleTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/single` | public | whole rows | 487 | not yet | yes | `TransactionController.getSingleTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | | GET | 1 | | `/transaction/target` | hidden | whole rows | 134 | not yet | | `TransactionController.getTransactionTargets` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/unassigned` | hidden | whole rows | 357 | not yet | | `TransactionController.getUnassignedTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/unassigned` | hidden | whole rows | 356 | not yet | | `TransactionController.getUnassignedTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | | DELETE | 1 | yes | `/user` | public | whole rows | 344 | not yet | | `UserController.deleteUser` | `subdomains/generic/user/models/user/user.controller.ts` | | DELETE | 2 | | `/user` | public | whole rows | 344 | not yet | | `UserV2Controller.deleteAccount` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | yes | `/user` | public | whole rows | 328 | not yet | | `UserController.getUserV1` | `subdomains/generic/user/models/user/user.controller.ts` | @@ -627,7 +620,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 2 | | `/user/mail` | public | whole rows | 364 | not yet | | `UserV2Controller.updateUserMail` | `subdomains/generic/user/models/user/user.controller.ts` | | POST | 2 | | `/user/mail/verify` | public | whole rows | 364 | not yet | | `UserV2Controller.verifyMail` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/name` | hidden | whole rows | 386 | not yet | | `UserController.updateUserName` | `subdomains/generic/user/models/user/user.controller.ts` | -| GET | 2 | | `/user/profile` | public | whole rows | 253 | not yet | | `UserV2Controller.getProfile` | `subdomains/generic/user/models/user/user.controller.ts` | +| GET | 2 | | `/user/profile` | public | projected | 41 | 4/4 | | `UserV2Controller.getProfile` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | | `/user/ref` | hidden | whole rows | 45 | not yet | | `UserController.getRefInfo` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 2 | | `/user/ref` | public | whole rows | 98 | not yet | | `UserV2Controller.getRef` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 2 | | `/user/ref` | public | whole rows | 98 | not yet | | `UserV2Controller.updateRefAsset` | `subdomains/generic/user/models/user/user.controller.ts` | @@ -641,7 +634,6 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | DELETE | 1 | | `/userData/:id/fee` | hidden | whole rows | 253 | not yet | | `UserDataController.removeFee` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | PUT | 1 | | `/userData/:id/fee` | hidden | whole rows | 253 | not yet | | `UserDataController.addFee` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | POST | 1 | | `/userData/:id/kycFile` | hidden | whole rows | 253 | not yet | | `UserDataController.uploadKycFile` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | -| PUT | 1 | | `/userData/:id/kycStatus/check` | hidden | whole rows | 364 | not yet | | `UserDataController.setKycStatusCheck` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | PUT | 1 | | `/userData/:id/merge` | hidden | whole rows | 364 | not yet | | `UserDataController.mergeUserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | PUT | 1 | | `/userData/:id/volumes` | hidden | whole rows | — | not yet | | `UserDataController.updateVolumes` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | PUT | 1 | | `/userData/auditPeriodNumbers` | hidden | whole rows | 40 | not yet | | `UserDataController.calculateAuditPeriodNumbers` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 5371a5d7a4..677512d22c 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -25,16 +25,14 @@ is treated as the latter. This service loads far more data than it returns. Measured against the real entity metadata: -- The whole database schema has **1,742 columns across 100 tables**. +- The whole database schema has **1,736 columns across 99 tables**. - `PUT /v1/transaction/:id/invoice` selected **1,664 of them** — 96% of the entire schema — to render a PDF containing a handful of values. That query sat exactly on Postgres' limit of 1,664 - columns per statement, so a single column added elsewhere (`settlementEventId` on - `transaction_request`) was enough to push it over. -- Of the 537 route entries, **444 reach at least one load site that fetches whole rows**; 89 read - nothing at all, 2 read only the fields they need, and 2 more project only when the caller - supplies a field list. The widest query a fetching endpoint can trigger is 308 columns at the - median of the recorded maxima, and at least 21 of them exceed 1,000 — the call graph does not - fully resolve, and an unresolved edge can only widen a query. + columns per statement, which is why a single new column added elsewhere (`settlementEventId` on + `transaction_request`) broke every invoice and receipt in production until it was fixed. +- Of the 534 endpoints, **428 reach at least one load site that fetches whole rows**; 98 read + nothing at all, and **6 read only the fields they return**. The widest query a fetching endpoint + can trigger is 308 columns at the median, and 19 of them exceed 1,000. The column limit was the symptom, not the cause. Loading a thousand columns to return one is equally wasteful under a limit of 4,096 — it simply would not have failed yet. @@ -49,9 +47,9 @@ and one on `LimitRequest` **434 across 15** — before any `relations` option is decision what to load therefore lives in the entity definition, not at the call site, and no call site can see what it triggers. -**No read model.** Of the load sites in this repository — at most 1,158, see [load-sites.md](load-sites.md#measurements) — **eight** name the columns they need: -one query builder and the seven raw statements. Practically all the rest request whole rows — 1,021 through the -`find` family, and of the 130 query builders, 105 pass the root alias to `.select(...)`, which reads +**No read model.** Of the load sites in this repository — at most 1,105, see [load-sites.md](load-sites.md#measurements) — **six** name the columns they need: +one query builder and the five raw statements. Practically all the rest request whole rows — 971 through the +`find` family, and of the 129 query builders, 105 pass the root alias to `.select(...)`, which reads like a projection but is not, while 23 pass no select at all. The same entities serve persistence, business logic and pure output paths such as invoices, receipts, history and exports — which need fields, not objects. @@ -134,7 +132,7 @@ Leave `surname` out of the projection and `isInvoiceDataComplete` returns `false load returns `true`. The invoice is refused with "user data is not complete" although the data is complete. No error, no log entry. -This service carries **238 such getters across 50 of its 113 entities**. In an application moving +This service carries **234 such getters across 50 of its 112 entities**. In an application moving money, a silent wrong value is worse than a crash: a statement that exceeds the column limit fails loudly and is found at once, while a wrong value can run for weeks. @@ -148,7 +146,9 @@ per endpoint as `0/4` through `4/4`; only `4/4` is done. To any load site that carries an explicit field list — that is where a forgotten field silently yields an empty value. -Today that is **eight sites**, and this is what the suite covers of them: +Six sites carried a field list before any conversion. The table below is what the suite covers of +them — unchanged, because none of the six was converted. Sites a conversion adds are recorded per +endpoint in [endpoints.md](endpoints.md), where only `4/4` counts as done. | Site | Form | Runs in a test | Column list asserted | Real database | | ---- | ---- | -------------- | -------------------- | ------------- | @@ -156,14 +156,12 @@ Today that is **eight sites**, and this is what the suite covers of them: | `log.repository.ts:341` — `getFinancialLogAssetPrices` | raw SQL, columns listed | **no** | no | no | | `log.repository.ts:511` — `getFinancialLogSummariesFull` | raw SQL, columns listed | yes | yes | no | | `log.repository.ts:664` — `getFinancialLogSummariesChartOnly` | raw SQL, columns listed | yes | yes | no | -| `virtual-iban.service.ts:891` — `hasOrderedOwnershipPath` | raw SQL, columns listed | **no** | no | no | +| `virtual-iban.service.ts:769` — `hasOrderedOwnershipPath` | raw SQL, columns listed | **no** | no | no | | `gs.service.ts:337` — `executeDebugQuery` | raw SQL, list supplied by the caller | yes | yes | no | -| `cron-lease.service.ts:186` — `onModuleInit` | raw SQL, `SELECT 1` — a reachability probe, no column to name | yes | n/a | no | -| `bank-tx.service.ts:597` — `recoverRollingInternalTransfers` | raw SQL, columns listed | **no** | no | no | Read that column by column, because the three answers mean different things. -**Runs in a test.** Four of the eight are never executed. `getFinancialLogValidityChangeSet` is +**Runs in a test.** Three of the six are never executed. `getFinancialLogValidityChangeSet` is replaced by `jest.spyOn(logRepo, …).mockResolvedValue([…])` at all nine of its appearances, so the projection line itself never runs. `getFinancialLogAssetPrices` is stood in for by a hand-written fake that reimplements the filtering in TypeScript. `hasOrderedOwnershipPath` appears in no spec at @@ -178,7 +176,7 @@ without being level 3: that level requires level 1 to fail, and level 1 is satis these sites. For the three that never run, removing a column changes nothing at all — the mock supplies the value regardless. -**Real database.** None of the eight. Every spec stubs the boundary — `createQueryBuilder` as a +**Real database.** None of the six. Every spec stubs the boundary — `createQueryBuilder` as a chainable mock in the repository spec, `createMock()` in the service spec. A mock cannot observe which columns were requested, so **level 1, the completeness test, is satisfied nowhere today**, not even for the three sites whose SQL is asserted. Asserting that a column appears in a @@ -216,15 +214,49 @@ in **all three test shards** — Jest distributes the suites across shards, so e own instance. Without the variable the blocks are skipped, which is why the suite still passes on a machine with no database. -The projection tests should use the same mechanism rather than introduce a second one. - -Two things are missing, and only two: - -- **The schema.** The migration specs each create their own Postgres schema (`const SCHEMA = '…'`) - so that parallel specs cannot collide. The projection tests need the same isolation, but their - schema comes from the entity metadata via `synchronize` rather than from replayed migrations — - the reference for a projection is the entity definition, not the migration history. -- **The fixtures**, generated from the same metadata. +The projection tests use that same gate. What they add lives in +`src/shared/utils/projection-test.util.ts`: + +- **The schema.** Each spec file creates its own Postgres schema (`const SCHEMA = '…'`) so parallel + specs cannot collide, and fills it from the entity metadata via `synchronize` rather than from + replayed migrations — the reference for a projection is the entity definition, not the migration + history. Measured: 112 entities, 99 tables, 1,736 columns, about half a minute per spec file. +- **The fixtures**, generated from the same metadata. Every scalar column gets a distinct value and + required relations are created recursively, so an empty field in a response proves the query + failed to load something. Three kinds of value have to be pinned by hand, and all three are the + fixture's business rather than the projection's: enum columns stored as text (the metadata reports + them as `varchar`, so a generated value is not a member of the enum and a mapper looking it up + answers `undefined`), values a check constraint relates to each other, and relations the schema + allows to be null but a mapper reads without a guard. + +They do **not** run in the main suite, and that is not a preference. The main suite compiles +transpile-only (`tsconfig.json` sets `isolatedModules: true`), which makes TypeScript emit +`design:type` as `Object` for any imported type; building a data source from the entity sources then +fails outright with `Data type "Object" in "Fiat.amlRuleFrom" is not supported`, and no projection +spec could run at all. `jest.projection.config.js` compiles with full type information — the same +reason the Frick and coverage gates have their own configuration — and `npm run test:projection` is +a separate CI job with its own Postgres service. + +### What a converted endpoint looks like + +The field list is a value, not a chain of calls at the query site: `ReadProjection` in +`src/shared/models/read-projection.ts`. That is what lets level 3 re-run the *production* query with +one field removed. A query rebuilt inside the spec could be wrong in exactly the way the projection +is wrong, and would prove nothing. + +A projection separates two kinds of field: + +- **Response fields** feed the answer. These are what level 3 drops one at a time. +- **Guards** are loaded but never shown: the primary keys that make the ORM materialise a joined + row, and values a check reads before the mapper runs — `UserData.status`, which + `GET /user/profile` refuses merged accounts on. Dropping a guard changes no response field, so + level 3 would report it as removable although the endpoint breaks without it. Each guard needs an + assertion of its own instead. + +Where a getter branches on a field, the response fields split per branch and each variant asserts +over its own set. `UserData.address` reads the organization's address for a business account and the +account's own for a personal one; a personal-account fixture can say nothing about the organization +fields, and claiming otherwise would be the vacuous kind of green this document is against. ### 1. Completeness @@ -262,15 +294,28 @@ at exactly that point — and a real defect would have slipped through there. Without this level you never know whether a green test verified something or is merely green. +**It needs a baseline, or it is itself merely green.** If the response is already incomplete with +the *full* field list, every reduced run fails too, and "every field is required" comes out true +without a single field having been shown to matter. That happened while the first conversions were +written — a fixture had left an enum at a value the mapper did not know — and the level reported +success. `expectEveryFieldRequired` therefore runs the query unreduced first and refuses to continue +unless that response is complete. + ### 4. Consistency against a second source **Where the same value exists twice, the two must agree.** -Applies wherever a value was materialised into its own column while the original source is still -present. In the financial log, `totalBalanceChf` exists both as a column and inside -`message->balancesTotal->totalBalanceChf`; every row must carry the same value in both. Where such -an invariant exists it is the strongest available test, because it needs no second implementation -that could itself be written wrong. +For a conversion the second source is always available and always exact: **the unprojected load.** +Run the endpoint's mapper over a full `find` of the same fixture, and the two responses must be +identical, per variant. The full load fetches every column, so what it produces is by construction +what the endpoint answered before the conversion — no second implementation is involved that could +be wrong in the same way, which is what makes this the strongest of the four. It is also the only +level that catches a projection loading the *wrong* field rather than too few: level 1 sees a field +that went empty, level 4 sees any field that changed. + +It applies separately wherever a value was materialised into its own column while the original +source is still present. In the financial log, `totalBalanceChf` exists both as a column and inside +`message->balancesTotal->totalBalanceChf`; every row must carry the same value in both. ### Deliberately not part of this: a column budget diff --git a/jest.projection.config.js b/jest.projection.config.js new file mode 100644 index 0000000000..1e7e5b40f2 --- /dev/null +++ b/jest.projection.config.js @@ -0,0 +1,25 @@ +// Read-path projection tests (docs/read-path-projections.md). +// +// Kept out of the main suite for one reason, and it is a hard one: these specs build a TypeORM data +// source from the entity sources, and that needs the decorator metadata to carry real types. The +// main suite runs ts-jest transpile-only (tsconfig.json sets isolatedModules: true), which emits +// `design:type` as `Object` for any imported type — an enum column then fails metadata validation +// with `Data type "Object" ... is not supported`, and no projection spec could run at all. The same +// reasoning is why the Frick and coverage gates compile with tsconfig.coverage.json. +// +// The database gate is shared with the migration specs on purpose: without MIGRATION_TEST_PG these +// suites skip, so a machine with no database still runs a green suite. +const base = require('./package.json').jest; + +module.exports = { + ...base, + transform: { '^.+\\.(t|j)s$': ['ts-jest', { tsconfig: 'tsconfig.coverage.json' }] }, + testRegex: '.*\\.projection\\.spec\\.ts$', + // The base config excludes these specs so the main suite does not pick them up. Spreading it in + // would exclude them here too — this run is the one that must find them. + testPathIgnorePatterns: ['/node_modules/'], + // Each spec file creates and drops its own Postgres schema, which is what allows them to run in + // parallel — but the schema is built by `synchronize` over 112 entities and costs about half a + // minute, so the wall clock is dominated by how many files there are, not by how many assertions. + testTimeout: 300000, +}; diff --git a/package.json b/package.json index 7f1253f473..1f27dfac5c 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:frick:cov": "jest --config jest.frick.config.js integration/bank/services/__tests__/frick.service.spec.ts integration/bank/services/__tests__/iso20022.service.spec.ts config/__tests__/frick.config.spec.ts config/__tests__/bank-frick-config.spec.ts subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-frick.service.spec.ts subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-outgoing-match.service.spec.ts subdomains/supporting/fiat-output/__tests__/fiat-output-frick.service.spec.ts subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts subdomains/supporting/bank/virtual-iban/providers/__tests__/frick-viban.provider.spec.ts --coverage --runInBand --collectCoverageFrom=integration/bank/dto/frick.dto.ts --collectCoverageFrom=integration/bank/services/frick.service.ts --collectCoverageFrom=integration/bank/services/iso20022.service.ts --collectCoverageFrom=config/frick.config.ts --collectCoverageFrom=subdomains/supporting/bank-tx/bank-tx/services/bank-tx-frick.service.ts --collectCoverageFrom=subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service.ts --collectCoverageFrom=subdomains/supporting/fiat-output/fiat-output-frick.service.ts --collectCoverageFrom=subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts --collectCoverageFrom=subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts --collectCoverageFrom=subdomains/supporting/bank/virtual-iban/providers/frick-viban.provider.ts", "test:gate:cov": "jest --config jest.coverage-gate.config.js --coverage --silent", "test:staff-gate:cov": "jest --config jest.staff-gate.config.js shared/auth/__tests__/role.guard.spec.ts shared/auth/__tests__/staff-kyc-clearance.spec.ts subdomains/generic/user/models/user/__tests__/staff-kyc-clearance.service.spec.ts --coverage --runInBand --collectCoverageFrom=shared/auth/role.guard.ts --collectCoverageFrom=shared/auth/staff-kyc-clearance.ts --collectCoverageFrom=shared/auth/exceptions/staff-kyc-required.exception.ts --collectCoverageFrom=subdomains/generic/user/models/user/staff-kyc-clearance.service.ts", + "test:projection": "jest --config jest.projection.config.js --maxWorkers=2", "type-check": "tsc --noEmit", "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", "check": "npm run lint && npm run test", @@ -193,6 +194,9 @@ "^@arkade-os/sdk$": "/integration/blockchain/arkade/__mocks__/arkade-sdk.mock.ts" }, "testRegex": ".*\\.spec\\.ts$", + "testPathIgnorePatterns": [ + "\\.projection\\.spec\\.ts$" + ], "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/src/shared/models/read-projection.ts b/src/shared/models/read-projection.ts new file mode 100644 index 0000000000..0c2f7fa597 --- /dev/null +++ b/src/shared/models/read-projection.ts @@ -0,0 +1,47 @@ +import { SelectQueryBuilder } from 'typeorm'; + +/** + * An explicit field list for a read path, together with the joins it needs. + * + * Two reasons this is a value rather than a chain of `.leftJoin().select()` calls at the call site: + * + * 1. `select` inside `find` options narrows the root entity but still pulls in the eager relations, + * so a projection has to go through a query builder. Wrapping that keeps the call sites short. + * 2. The mutation test (level 3 in `docs/read-path-projections.md`) has to run the *same* query with + * one field removed. With the field list as data, the test drives the production code path + * instead of rebuilding the query — a second implementation could be wrong in the same way and + * would prove nothing. + * + * Field names are the ones the query builder expects: `alias.property`, where `alias` is either the + * root alias or one declared in `joins`. + */ +export class ReadProjection { + constructor( + readonly alias: string, + /** `[relation path, alias]`, applied as left joins in order. A later join may build on an earlier alias. */ + readonly joins: ReadonlyArray, + /** Fields that feed the response. These are what the mutation test drops one by one. */ + readonly fields: ReadonlyArray, + /** + * Fields the query needs but the response never shows: primary keys that make the ORM + * materialise a joined relation, and values a guard decides on before the mapper runs. + * + * They are kept apart because the mutation test asserts through the response. Dropping a guard + * changes no response field, so it would be reported as removable although the endpoint breaks + * without it. Each one needs its own assertion instead — see the specs. + */ + readonly guards: ReadonlyArray = [], + ) {} + + /** + * Applies the joins and the field list to a query builder. + * + * `fields` defaults to the declared list; the mutation test passes a reduced one. Guards and joins + * are applied either way — dropping a field must change the selected columns and nothing else, or + * the test would be measuring the join instead of the projection. + */ + apply(query: SelectQueryBuilder, fields: ReadonlyArray = this.fields): SelectQueryBuilder { + for (const [path, alias] of this.joins) query.leftJoin(path, alias); + return query.select([...fields, ...this.guards]); + } +} diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts new file mode 100644 index 0000000000..09bae03464 --- /dev/null +++ b/src/shared/utils/projection-test.util.ts @@ -0,0 +1,284 @@ +import { DataSource, EntityMetadata, EntityTarget, ObjectLiteral } from 'typeorm'; +import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; + +/** + * Test support for the read-path projections described in `docs/read-path-projections.md`. + * + * All of it needs a real database. A mocked repository returns whatever the mock defines and cannot + * observe which columns were requested, so it can test none of the four levels — which is why the + * suite skips when `MIGRATION_TEST_PG` is unset, the same gate the migration specs use. + * + * The schema comes from the entity metadata via `synchronize`, not from replayed migrations: the + * reference a projection has to be complete against is the entity definition. + */ + +export const PROJECTION_TEST_PG = process.env.MIGRATION_TEST_PG; + +/** `describe` when a database is configured, `describe.skip` otherwise. */ +export const describeProjection = PROJECTION_TEST_PG ? describe : describe.skip; + +/** + * A data source over its own Postgres schema, with the schema created from the entity metadata. + * + * Every spec file passes its own schema name — Jest distributes spec files across workers, and two + * of them writing the same tables would make the results depend on the scheduling. + */ +export async function createProjectionDataSource(schema: string): Promise { + const bootstrap = new DataSource({ type: 'postgres', url: PROJECTION_TEST_PG, logging: false }); + await bootstrap.initialize(); + // Recreate rather than reuse: a schema left behind by an earlier run may predate the entities. + await bootstrap.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); + await bootstrap.query(`CREATE SCHEMA "${schema}"`); + await bootstrap.destroy(); + + const dataSource = new DataSource({ + type: 'postgres', + url: PROJECTION_TEST_PG, + schema, + // Loaded as sources, so the spec sees the entities of the working tree rather than a stale build. + entities: [__dirname + '/../../**/*.entity.ts'], + synchronize: false, + logging: false, + }); + await dataSource.initialize(); + await dataSource.synchronize(); + return dataSource; +} + +/** Drops the schema and closes the connection. Safe to call with `undefined`. */ +export async function destroyProjectionDataSource(dataSource: DataSource | undefined, schema: string): Promise { + if (!dataSource?.isInitialized) return; + await dataSource.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); + await dataSource.destroy(); +} + +/** Which optional relations to populate, and with what. `true` means "populate with defaults". */ +export interface SeedSpec { + /** + * Fixed column values. Anything not listed gets a generated one. + * + * Needed for every column that holds a TypeScript enum in a plain text column — most of them in + * this schema. The metadata reports those as `varchar`, so the generator produces a distinct + * string that is not a member of the enum, and a mapper looking the value up (`PaymentStatusMapper[…]`, + * `txExplorerUrl(blockchain, …)`) answers `undefined`. That reads exactly like a missing column, + * which is why the completeness assertion catches it — set the value here instead, and cover the + * other members as level-2 variants. + */ + values?: ObjectLiteral; + /** Optional relations to populate. Required ones are always populated. */ + relations?: Record; +} + +// One counter for the whole process. Every generated value is distinct, which is what makes an +// empty field in a response proof that the query failed to load something — and it keeps unique +// constraints satisfied when a spec seeds the same entity twice. +let counter = 0; +const nextSeed = (): number => ++counter; + +const BASE_DATE = new Date('2020-01-01T00:00:00.000Z').getTime(); + +function generatedValue(column: ColumnMetadata, seed: number): unknown { + if (column.enum?.length) return column.enum[seed % column.enum.length]; + + const type = typeof column.type === 'function' ? column.type.name.toLowerCase() : String(column.type).toLowerCase(); + + if (['boolean', 'bool'].includes(type)) return true; + if (['date', 'datetime', 'datetime2', 'timestamp', 'timestamptz', 'timestamp with time zone'].includes(type)) + return new Date(BASE_DATE + seed * 1000); + if ( + [ + 'number', + 'int', + 'int2', + 'int4', + 'int8', + 'integer', + 'smallint', + 'bigint', + 'float', + 'float4', + 'float8', + 'double precision', + 'real', + 'numeric', + 'decimal', + ].includes(type) + ) + return seed; + if (['json', 'jsonb'].includes(type)) return { seed }; + + // Everything else is treated as text. Two constraints pull in opposite directions here: the + // declared length rejects anything longer (`country.symbol3` is varchar(3)), while uniqueness + // requires the counter to survive the cut — truncating `symbol3-12` to three characters yields + // "sym" for every row and the second insert fails on the unique index. So the counter is what + // gets kept, base-36 encoded to fit more values into a short column, and the name fills whatever + // room is left. + const token = seed.toString(36); + const text = `${column.propertyName}-${token}`; + const length = Number(column.length); + if (!(length > 0) || length >= text.length) return text; + if (length <= token.length) return token.slice(-length); + return column.propertyName.slice(0, length - token.length) + token; +} + +function isPopulatable(column: ColumnMetadata): boolean { + return ( + !column.isGenerated && !column.isCreateDate && !column.isUpdateDate && !column.isVersion && !column.relationMetadata + ); +} + +/** + * Assigns along a dotted path, creating the intermediate objects. + * + * Needed for embedded columns: their `propertyPath` is `address.address`, and writing that as one + * flat key leaves a string where the ORM expects an object — it then fails with "cannot create + * property 'address' on string" while flushing. + */ +function setPath(target: ObjectLiteral, path: string, value: unknown): void { + const parts = path.split('.'); + let current = target; + for (const part of parts.slice(0, -1)) { + if (typeof current[part] !== 'object' || current[part] === null) current[part] = {}; + current = current[part] as ObjectLiteral; + } + current[parts[parts.length - 1]] = value; +} + +/** Whether the caller pinned this column, by property name or by the full embedded path. */ +function isPinned(spec: SeedSpec, column: ColumnMetadata): boolean { + if (!spec.values) return false; + if (column.propertyPath in spec.values || column.propertyName in spec.values) return true; + const [root] = column.propertyPath.split('.'); + return root in spec.values; +} + +/** + * Inserts one row with a distinct value in every column, creating required relations recursively. + * + * Generated from the metadata on purpose: a hand-written fixture that leaves a field empty makes + * the completeness test green and the defect invisible. + */ +export async function seedEntity( + dataSource: DataSource, + target: EntityTarget, + spec: SeedSpec = {}, +): Promise { + const metadata = dataSource.getMetadata(target); + const entity: ObjectLiteral = {}; + + for (const relation of metadata.relations) { + const requested = spec.relations?.[relation.propertyName]; + // Only owning to-one sides carry a foreign key on this row; the inverse sides are populated + // from the other end and would recurse without end. + if (!relation.isManyToOne && !(relation.isOneToOne && relation.isOwning)) continue; + if (spec.values && relation.propertyName in spec.values) continue; + const required = !relation.isNullable; + if (!requested && !required) continue; + entity[relation.propertyName] = await seedEntity( + dataSource, + relation.inverseEntityMetadata.target, + requested === true || requested === undefined ? {} : requested, + ); + } + + for (const column of metadata.columns) { + if (!isPopulatable(column)) continue; + if (isPinned(spec, column)) continue; + setPath(entity, column.propertyPath, generatedValue(column, nextSeed())); + } + + Object.assign(entity, spec.values ?? {}); + return dataSource.getRepository(target).save(entity as E); +} + +/** + * Level 1 — with a fully populated fixture, no field of the response may be empty. + * + * Walks nested objects and arrays. `undefined`, `null` and `''` count as empty; `0` and `false` do + * not, because they are legitimate values a projection can load correctly. + * + * `optional` lists paths that are allowed to be empty for the fixture at hand — a field the DTO + * only fills for one branch. Every entry is a statement that the *other* branch covers it, which is + * what level 2 is for, so keep the list short and cover the counterpart. + */ +export function expectNoEmptyFields(value: unknown, optional: string[] = [], path = ''): void { + const empty = value === undefined || value === null || value === ''; + if (empty) { + if (optional.includes(path)) return; + throw new Error(`field '${path || ''}' is empty — the query did not load it`); + } + if (Array.isArray(value)) { + if (!value.length && !optional.includes(path)) throw new Error(`array '${path}' is empty`); + value.forEach((item, index) => expectNoEmptyFields(item, optional, `${path}[${index}]`)); + return; + } + if (value instanceof Date || typeof value !== 'object') return; + for (const [key, item] of Object.entries(value as ObjectLiteral)) + expectNoEmptyFields(item, optional, path ? `${path}.${key}` : key); +} + +/** + * Passed to a mutation run to keep the projection whole — it matches no field name, so + * `projectionFieldsWithout` returns the list unchanged. Used for the baseline run. + */ +export const NOTHING_OMITTED = ''; + +/** A projection's field list with one field removed. */ +export function projectionFieldsWithout(fields: ReadonlyArray, omitted: string): string[] { + return fields.filter((field) => field !== omitted); +} + +/** + * Level 3 — removing any single field of the projection must break level 1. + * + * `run` receives the name of the field to leave out, runs the same production query without it and + * returns the response. The caller does the reducing, because which fields feed the response can + * depend on the fixture: `UserData.address` reads the organization's address for a business account + * and the account's own for a personal one, so each variant asserts over its own set of candidates + * while the rest of the projection stays in the query. + * + * A field whose removal changes nothing is either unnecessary or a gap in the fixture; both need + * looking at, so this reports the field names rather than just failing. + */ +export async function expectEveryFieldRequired( + candidates: ReadonlyArray, + run: (omitted: string) => Promise, + optional: string[] = [], +): Promise { + // Establish the baseline first. Without it this level passes vacuously: if the response is already + // incomplete with the full field list — a fixture that leaves an enum at a value no mapper knows, + // say — then every reduced run fails too, and "every field is required" would be reported for a + // projection nothing was ever proven about. `NOTHING_OMITTED` matches no field name, so the caller + // reduces by nothing and runs the query as production does. + try { + expectNoEmptyFields(await run(NOTHING_OMITTED), optional); + } catch (error) { + throw new Error( + `the response is already incomplete with the full projection, so dropping fields proves ` + + `nothing — fix the fixture or the projection first. Cause: ${error.message ?? error}`, + ); + } + + const survived: string[] = []; + for (const field of candidates) { + let stillComplete = false; + try { + expectNoEmptyFields(await run(field), optional); + stillComplete = true; + } catch { + // Either the response lost a value or the query itself refused to run without the field. + // Both mean the field carries weight, which is what this level asserts. + } + if (stillComplete) survived.push(field); + } + if (survived.length) + throw new Error( + `these fields can be dropped without any response field going empty: ${survived.join(', ')} — ` + + `either they are not needed, or the fixture has a gap at exactly that point`, + ); +} + +/** Metadata helper: every column an entity would load without a projection. */ +export function allColumnNames(metadata: EntityMetadata): string[] { + return metadata.columns.map((column) => column.propertyName); +} diff --git a/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts b/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts new file mode 100644 index 0000000000..9e8da07c89 --- /dev/null +++ b/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts @@ -0,0 +1,181 @@ +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { BuyCryptoHistoryMapper } from 'src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper'; +import { + BUY_CRYPTO_BUY_HISTORY_PROJECTION, + BUY_CRYPTO_HISTORY_RESPONSE_FIELDS, + BUY_CRYPTO_ROUTE_HISTORY_PROJECTION, + BuyCryptoRepository, +} from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; +import { BuyCrypto, BuyCryptoStatus } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { Buy } from 'src/subdomains/core/buy-crypto/routes/buy/buy.entity'; +import { Swap } from 'src/subdomains/core/buy-crypto/routes/swap/swap.entity'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'buy_crypto_history_projection_spec'; + +/** + * `GET /buy/:id/history` and `GET /swap/:id/history` — the four levels from + * `docs/read-path-projections.md`. + * + * Both answer a `HistoryDtoDeprecated[]` built by the same mapper, and both reached it through a + * `find` that loads whole `BuyCrypto` rows: 497 and 509 columns respectively, for the ten values the + * mapper reads. They differ only in the route they filter by — `buy` for one, `cryptoRoute` for the + * other — which is why there are two projections over one field list. + */ +describeProjection('buy-crypto history — read-path projection', () => { + let dataSource: DataSource; + let repository: BuyCryptoRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + repository = new BuyCryptoRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * A completed transaction on a buy route, with every column of every entity populated. + * + * `blockchain` and `status` are set explicitly: both are TypeScript enums stored in text columns, + * so a generated value would be a string no mapper knows, and the response would look incomplete + * for a reason that has nothing to do with the projection. + */ + async function seedBuyCrypto( + status = BuyCryptoStatus.COMPLETE, + ): Promise<{ buyCrypto: BuyCrypto; user: User; buy: Buy }> { + const user = await seedEntity(dataSource, User); + const buy = await seedEntity(dataSource, Buy, { values: { user } }); + const outputAsset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + const buyCrypto = await seedEntity(dataSource, BuyCrypto, { values: { buy, outputAsset, status } }); + return { buyCrypto, user, buy }; + } + + /** The same, on a swap route. */ + async function seedSwapCrypto(): Promise<{ buyCrypto: BuyCrypto; user: User; route: Swap }> { + const user = await seedEntity(dataSource, User); + const route = await seedEntity(dataSource, Swap, { values: { user } }); + const outputAsset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + const buyCrypto = await seedEntity(dataSource, BuyCrypto, { + values: { cryptoRoute: route, outputAsset, status: BuyCryptoStatus.COMPLETE }, + }); + return { buyCrypto, user, route }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — the buy history answers with no empty field', async () => { + const { user, buy } = await seedBuyCrypto(); + + const history = (await repository.findBuyHistory(user.id, buy.id)).map(BuyCryptoHistoryMapper.toDto); + + expect(history).toHaveLength(1); + expectNoEmptyFields(history); + }, 120000); + + it('level 1 — the swap history answers with no empty field', async () => { + const { user, route } = await seedSwapCrypto(); + + const history = (await repository.findSwapHistory(user.id, route.id)).map(BuyCryptoHistoryMapper.toDto); + + expect(history).toHaveLength(1); + expectNoEmptyFields(history); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — a transaction without an output asset still answers', async () => { + const user = await seedEntity(dataSource, User); + const buy = await seedEntity(dataSource, Buy, { values: { user } }); + await seedEntity(dataSource, BuyCrypto, { + values: { buy, outputAsset: null, status: BuyCryptoStatus.WAITING_FOR_LOWER_FEE }, + }); + + const history = (await repository.findBuyHistory(user.id, buy.id)).map(BuyCryptoHistoryMapper.toDto); + + // The mapper guards on `outputAsset` for both the asset name and the explorer link, so a + // pending transaction has neither. Everything the transaction itself carries must still be there + // — a projection that only ever ran against a completed row would not show that. + expect(history).toHaveLength(1); + expect(history[0].outputAsset).toBeUndefined(); + expect(history[0].txUrl).toBeUndefined(); + expectNoEmptyFields(history, ['[0].outputAsset', '[0].txUrl']); + }, 120000); + + it('level 2 — the route filter selects only the caller’s own transactions', async () => { + const mine = await seedBuyCrypto(); + const other = await seedBuyCrypto(); + + const history = (await repository.findBuyHistory(mine.user.id, mine.buy.id)).map(BuyCryptoHistoryMapper.toDto); + + expect(history).toHaveLength(1); + expect(history[0].txId).toEqual(mine.buyCrypto.txId); + expect(history[0].txId).not.toEqual(other.buyCrypto.txId); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — every field feeding the buy history is required', async () => { + const { user, buy } = await seedBuyCrypto(); + + await expectEveryFieldRequired(BUY_CRYPTO_HISTORY_RESPONSE_FIELDS, (omitted) => + repository + .findBuyHistory(user.id, buy.id, projectionFieldsWithout(BUY_CRYPTO_BUY_HISTORY_PROJECTION.fields, omitted)) + .then((rows) => rows.map(BuyCryptoHistoryMapper.toDto)), + ); + }, 300000); + + it('level 3 — every field feeding the swap history is required', async () => { + const { user, route } = await seedSwapCrypto(); + + await expectEveryFieldRequired(BUY_CRYPTO_HISTORY_RESPONSE_FIELDS, (omitted) => + repository + .findSwapHistory( + user.id, + route.id, + projectionFieldsWithout(BUY_CRYPTO_ROUTE_HISTORY_PROJECTION.fields, omitted), + ) + .then((rows) => rows.map(BuyCryptoHistoryMapper.toDto)), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it('level 4 — the projected buy history equals the one from a full load', async () => { + const { user, buy } = await seedBuyCrypto(); + + const projected = (await repository.findBuyHistory(user.id, buy.id)).map(BuyCryptoHistoryMapper.toDto); + // The unprojected load is the second source: it fetches every column, so what it produces is by + // construction what the endpoint answered before the conversion. + const full = await dataSource.getRepository(BuyCrypto).find({ + where: { buy: { id: buy.id, user: { id: user.id } } }, + relations: { buy: { user: true } }, + }); + + expect(projected).toEqual(full.map(BuyCryptoHistoryMapper.toDto)); + }, 120000); + + it('level 4 — the projected swap history equals the one from a full load', async () => { + const { user, route } = await seedSwapCrypto(); + + const projected = (await repository.findSwapHistory(user.id, route.id)).map(BuyCryptoHistoryMapper.toDto); + const full = await dataSource.getRepository(BuyCrypto).find({ + where: { cryptoRoute: { id: route.id, user: { id: user.id } } }, + relations: { cryptoRoute: { user: true } }, + }); + + expect(projected).toEqual(full.map(BuyCryptoHistoryMapper.toDto)); + }, 120000); +}); diff --git a/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts b/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts new file mode 100644 index 0000000000..77d173e4cb --- /dev/null +++ b/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts @@ -0,0 +1,33 @@ +import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; +import { HistoryDtoDeprecated, PaymentStatusMapper } from 'src/subdomains/core/history/dto/history.dto'; +import { BuyCrypto } from '../entities/buy-crypto.entity'; + +/** + * The history entry `GET /buy/:id/history` and `GET /swap/:id/history` answer with. + * + * Moved out of `BuyCryptoService` so that the projection spec can drive the same mapping the + * endpoints use. A copy in the spec could be wrong in exactly the way the projection is wrong and + * would prove nothing. + * + * The fields it reads are what `BUY_CRYPTO_HISTORY_RESPONSE_FIELDS` selects — the two are meant to + * be changed together, and the mutation test fails if they drift apart. + */ +export class BuyCryptoHistoryMapper { + static toDto(buyCrypto: BuyCrypto): HistoryDtoDeprecated { + return { + inputAmount: buyCrypto.inputAmount, + inputAsset: buyCrypto.inputAsset, + amlCheck: buyCrypto.amlCheck, + outputAmount: buyCrypto.outputAmount, + outputAsset: buyCrypto.outputAsset?.dexName, + txId: buyCrypto.txId, + txUrl: + buyCrypto.outputAsset && buyCrypto.txId + ? txExplorerUrl(buyCrypto.outputAsset.blockchain, buyCrypto.txId) + : undefined, + isComplete: buyCrypto.isComplete, + date: buyCrypto.outputDate, + status: PaymentStatusMapper[buyCrypto.status], + }; + } +} diff --git a/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts b/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts index eb049badc4..6513c99923 100644 --- a/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts +++ b/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts @@ -1,15 +1,100 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager, UpdateResult } from 'typeorm'; import { BuyCryptoFee } from '../entities/buy-crypto-fees.entity'; import { BuyCrypto } from '../entities/buy-crypto.entity'; +/** + * The ten values `BuyCryptoHistoryMapper.toDto` reads. + * + * Both history endpoints share them; only the route they filter by differs, which is what the two + * projections below encode. + */ +export const BUY_CRYPTO_HISTORY_RESPONSE_FIELDS = [ + 'buyCrypto.inputAmount', + 'buyCrypto.inputAsset', + 'buyCrypto.amlCheck', + 'buyCrypto.outputAmount', + 'buyCrypto.txId', + 'buyCrypto.isComplete', + 'buyCrypto.outputDate', + 'buyCrypto.status', + 'outputAsset.dexName', + 'outputAsset.blockchain', +]; + +// Never part of the response: the primary keys that make the ORM materialise the joined rows. +const HISTORY_GUARDS = ['buyCrypto.id', 'outputAsset.id']; + +/** + * `GET /buy/:id/history` — filtered by the buy route and its user. + * + * Without it the query loads whole `BuyCrypto` rows: 497 columns for these ten values. + */ +export const BUY_CRYPTO_BUY_HISTORY_PROJECTION = new ReadProjection( + 'buyCrypto', + [ + ['buyCrypto.outputAsset', 'outputAsset'], + ['buyCrypto.buy', 'buy'], + ['buy.user', 'buyUser'], + ], + BUY_CRYPTO_HISTORY_RESPONSE_FIELDS, + HISTORY_GUARDS, +); + +/** `GET /swap/:id/history` — the same response, filtered by the swap route instead. 509 columns before. */ +export const BUY_CRYPTO_ROUTE_HISTORY_PROJECTION = new ReadProjection( + 'buyCrypto', + [ + ['buyCrypto.outputAsset', 'outputAsset'], + ['buyCrypto.cryptoRoute', 'cryptoRoute'], + ['cryptoRoute.user', 'routeUser'], + ], + BUY_CRYPTO_HISTORY_RESPONSE_FIELDS, + HISTORY_GUARDS, +); + @Injectable() export class BuyCryptoRepository extends BaseRepository { constructor(manager: EntityManager) { super(BuyCrypto, manager); } + /** + * Transactions on a user's buy route, loaded with the history fields only. + * + * `routeId` narrows to a single route; without it the caller gets every buy route they own, which + * is what `GET /history` relies on. `fields` exists for the mutation test; nothing in production + * passes it. + */ + async findBuyHistory( + userId: number, + routeId?: number, + fields: ReadonlyArray = BUY_CRYPTO_BUY_HISTORY_PROJECTION.fields, + ): Promise { + const query = BUY_CRYPTO_BUY_HISTORY_PROJECTION.apply(this.createQueryBuilder('buyCrypto'), fields).where( + 'buyUser.id = :userId', + { userId }, + ); + if (routeId != null) query.andWhere('buy.id = :routeId', { routeId }); + return query.getMany(); + } + + /** The same for a swap route. */ + async findSwapHistory( + userId: number, + routeId?: number, + fields: ReadonlyArray = BUY_CRYPTO_ROUTE_HISTORY_PROJECTION.fields, + ): Promise { + const query = BUY_CRYPTO_ROUTE_HISTORY_PROJECTION.apply(this.createQueryBuilder('buyCrypto'), fields).where( + 'routeUser.id = :userId', + { userId }, + ); + if (routeId != null) query.andWhere('cryptoRoute.id = :routeId', { routeId }); + return query.getMany(); + } + async updateFee(id: number, update: Partial): Promise { return this.manager.update(BuyCryptoFee, id, update); } diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index e97e4d35d6..875343e2e3 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -218,7 +218,10 @@ describe('BuyCryptoService', () => { break; } - jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue(wantedData); + // The history now goes through the projected queries; both are stubbed because one `setup` + // serves the buy-route and the swap-route cases. + jest.spyOn(buyCryptoRepo, 'findBuyHistory').mockResolvedValue(wantedData); + jest.spyOn(buyCryptoRepo, 'findSwapHistory').mockResolvedValue(wantedData); } } diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index 4d115fad96..57ec0d4376 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -28,7 +28,8 @@ import { Swap } from 'src/subdomains/core/buy-crypto/routes/swap/swap.entity'; import { SwapService } from 'src/subdomains/core/buy-crypto/routes/swap/swap.service'; import { CustodyOrderType } from 'src/subdomains/core/custody/enums/custody'; import { CustodyOrderService } from 'src/subdomains/core/custody/services/custody-order.service'; -import { HistoryDtoDeprecated, PaymentStatusMapper } from 'src/subdomains/core/history/dto/history.dto'; +import { HistoryDtoDeprecated } from 'src/subdomains/core/history/dto/history.dto'; +import { BuyCryptoHistoryMapper } from '../dto/buy-crypto-history.mapper'; import { BankTxRefund, CheckoutTxRefund, @@ -1316,23 +1317,15 @@ export class BuyCryptoService implements OnModuleInit { } async getBuyHistory(userId: number, buyId?: number): Promise { - const where = Util.removeNullFields({ user: { id: userId }, id: buyId }); return this.buyCryptoRepo - .find({ - where: { buy: where }, - relations: { buy: { user: true } }, - }) - .then((buyCryptos) => buyCryptos.map(this.toHistoryDto)); + .findBuyHistory(userId, buyId) + .then((buyCryptos) => buyCryptos.map(BuyCryptoHistoryMapper.toDto)); } async getCryptoHistory(userId: number, routeId?: number): Promise { - const where = Util.removeNullFields({ user: { id: userId }, id: routeId }); return this.buyCryptoRepo - .find({ - where: { cryptoRoute: where }, - relations: { cryptoRoute: { user: true } }, - }) - .then((history) => history.map(this.toHistoryDto)); + .findSwapHistory(userId, routeId) + .then((history) => history.map(BuyCryptoHistoryMapper.toDto)); } async getPendingTransactions(): Promise { @@ -1411,24 +1404,6 @@ export class BuyCryptoService implements OnModuleInit { return request; } - private toHistoryDto(buyCrypto: BuyCrypto): HistoryDtoDeprecated { - return { - inputAmount: buyCrypto.inputAmount, - inputAsset: buyCrypto.inputAsset, - amlCheck: buyCrypto.amlCheck, - outputAmount: buyCrypto.outputAmount, - outputAsset: buyCrypto.outputAsset?.dexName, - txId: buyCrypto.txId, - txUrl: - buyCrypto.outputAsset && buyCrypto.txId - ? txExplorerUrl(buyCrypto.outputAsset.blockchain, buyCrypto.txId) - : undefined, - isComplete: buyCrypto.isComplete, - date: buyCrypto.outputDate, - status: PaymentStatusMapper[buyCrypto.status], - }; - } - private async getBuy(buyId: number): Promise { // buy const buy = await this.buyRepo.findOne({ diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts new file mode 100644 index 0000000000..8e5145fea7 --- /dev/null +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts @@ -0,0 +1,140 @@ +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { Fiat } from 'src/shared/models/fiat/fiat.entity'; +import { + BUY_FIAT_HISTORY_PROJECTION, + BUY_FIAT_HISTORY_RESPONSE_FIELDS, + BuyFiatRepository, +} from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { BuyFiatHistoryMapper } from 'src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper'; +import { BankData } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; +import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { FiatOutput } from 'src/subdomains/supporting/fiat-output/fiat-output.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'buy_fiat_history_projection_spec'; + +/** + * `GET /sell/:id/history` — the four levels from `docs/read-path-projections.md`. + * + * The endpoint answers a `SellHistoryDto[]`. Reading it without a projection loads whole `BuyFiat` + * rows: 470 columns for the nine values the mapper reads. + */ +describeProjection('GET /sell/:id/history — read-path projection', () => { + let dataSource: DataSource; + let repository: BuyFiatRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + repository = new BuyFiatRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * A paid-out transaction on a sell route, with every column populated. + * + * `blockchain` is set explicitly: it is a TypeScript enum in a text column, and the explorer link + * the mapper builds resolves to `undefined` for anything that is not a member. + */ + async function seedBuyFiat(withFiatOutput = true): Promise<{ buyFiat: BuyFiat; user: User; sell: Sell }> { + const user = await seedEntity(dataSource, User); + // An active sell route must carry a bankData — a check constraint on `deposit_route` enforces + // it, and a route with transactions on it is active by definition. + const bankData = await seedEntity(dataSource, BankData); + const sell = await seedEntity(dataSource, Sell, { values: { user, bankData } }); + const asset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + const cryptoInput = await seedEntity(dataSource, CryptoInput, { values: { asset } }); + const fiatOutput = withFiatOutput ? await seedEntity(dataSource, FiatOutput) : null; + // `outputAsset` is nullable in the schema but read without a guard by the mapper, so a payout + // transaction always carries one. That is the behaviour as it stands; the projection does not + // change it. + const outputAsset = await seedEntity(dataSource, Fiat); + const buyFiat = await seedEntity(dataSource, BuyFiat, { + values: { sell, cryptoInput, fiatOutput, outputAsset }, + }); + return { buyFiat, user, sell }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — the sell history answers with no empty field', async () => { + const { user, sell } = await seedBuyFiat(); + + const history = (await repository.findSellHistory(user.id, sell.id)).map(BuyFiatHistoryMapper.toDto); + + expect(history).toHaveLength(1); + expectNoEmptyFields(history); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — a transaction not yet paid out answers without a date', async () => { + const { user, sell } = await seedBuyFiat(false); + + const history = (await repository.findSellHistory(user.id, sell.id)).map(BuyFiatHistoryMapper.toDto); + + // `fiatOutput` is the nullable one of the four joins. The date is the only field it feeds, so + // everything else must still be complete — otherwise a left join was written as an inner one. + expect(history).toHaveLength(1); + expect(history[0].date).toBeUndefined(); + expectNoEmptyFields(history, ['[0].date']); + }, 120000); + + it('level 2 — the route filter selects only the caller’s own transactions', async () => { + const mine = await seedBuyFiat(); + const other = await seedBuyFiat(); + + const history = (await repository.findSellHistory(mine.user.id, mine.sell.id)).map(BuyFiatHistoryMapper.toDto); + + expect(history).toHaveLength(1); + expect(history[0].inputAmount).toEqual(mine.buyFiat.inputAmount); + expect(history[0].inputAmount).not.toEqual(other.buyFiat.inputAmount); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — every field feeding the response is required', async () => { + const { user, sell } = await seedBuyFiat(); + + await expectEveryFieldRequired(BUY_FIAT_HISTORY_RESPONSE_FIELDS, (omitted) => + repository + .findSellHistory(user.id, sell.id, projectionFieldsWithout(BUY_FIAT_HISTORY_PROJECTION.fields, omitted)) + .then((rows) => rows.map(BuyFiatHistoryMapper.toDto)), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([true, false])( + 'level 4 — with fiatOutput=%s the projected response equals the one from a full load', + async (withFiatOutput) => { + const { user, sell } = await seedBuyFiat(withFiatOutput); + + const projected = (await repository.findSellHistory(user.id, sell.id)).map(BuyFiatHistoryMapper.toDto); + // The unprojected load is the second source: it fetches every column, so what it produces is + // by construction what the endpoint answered before the conversion. + const full = await dataSource.getRepository(BuyFiat).find({ + where: { sell: { id: sell.id, user: { id: user.id } } }, + relations: { sell: { user: true }, cryptoInput: true, fiatOutput: true }, + }); + + expect(projected).toEqual(full.map(BuyFiatHistoryMapper.toDto)); + }, + 120000, + ); +}); diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts index b34ec1b0c4..458381396c 100644 --- a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts @@ -181,7 +181,8 @@ describe('BuyFiatService', () => { ]; } - jest.spyOn(buyFiatRepo, 'find').mockResolvedValue(wantedData); + // The history now goes through the projected query. + jest.spyOn(buyFiatRepo, 'findSellHistory').mockResolvedValue(wantedData); } } diff --git a/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts b/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts index 61f9bcdce2..51cddbd0d6 100644 --- a/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts +++ b/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts @@ -1,11 +1,63 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; import { BuyFiat } from './buy-fiat.entity'; +/** The nine values `BuyFiatHistoryMapper.toDto` reads. */ +export const BUY_FIAT_HISTORY_RESPONSE_FIELDS = [ + 'buyFiat.inputAmount', + 'buyFiat.inputAsset', + 'buyFiat.outputAmount', + 'buyFiat.amlCheck', + 'buyFiat.isComplete', + 'outputAsset.name', + 'cryptoInput.inTxId', + 'cryptoInputAsset.blockchain', + 'fiatOutput.outputDate', +]; + +/** + * `GET /sell/:id/history` — filtered by the sell route and its user. + * + * Without it the query loads whole `BuyFiat` rows: 470 columns for these nine values. + */ +export const BUY_FIAT_HISTORY_PROJECTION = new ReadProjection( + 'buyFiat', + [ + ['buyFiat.outputAsset', 'outputAsset'], + ['buyFiat.cryptoInput', 'cryptoInput'], + ['cryptoInput.asset', 'cryptoInputAsset'], + ['buyFiat.fiatOutput', 'fiatOutput'], + ['buyFiat.sell', 'sell'], + ['sell.user', 'sellUser'], + ], + BUY_FIAT_HISTORY_RESPONSE_FIELDS, + // Never part of the response: the primary keys that make the ORM materialise the joined rows. + ['buyFiat.id', 'outputAsset.id', 'cryptoInput.id', 'cryptoInputAsset.id', 'fiatOutput.id'], +); + @Injectable() export class BuyFiatRepository extends BaseRepository { constructor(manager: EntityManager) { super(BuyFiat, manager); } + + /** + * Transactions on a user's sell route, loaded with the history fields only. + * + * `fields` exists for the mutation test; nothing in production passes it. + */ + async findSellHistory( + userId: number, + routeId?: number, + fields: ReadonlyArray = BUY_FIAT_HISTORY_PROJECTION.fields, + ): Promise { + const query = BUY_FIAT_HISTORY_PROJECTION.apply(this.createQueryBuilder('buyFiat'), fields).where( + 'sellUser.id = :userId', + { userId }, + ); + if (routeId != null) query.andWhere('sell.id = :routeId', { routeId }); + return query.getMany(); + } } diff --git a/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts b/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts new file mode 100644 index 0000000000..a6fae1b17c --- /dev/null +++ b/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts @@ -0,0 +1,31 @@ +import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; +import { PaymentStatus } from 'src/subdomains/core/history/dto/history.dto'; +import { SellHistoryDto } from 'src/subdomains/core/sell-crypto/route/dto/sell-history.dto'; +import { BuyFiat } from '../buy-fiat.entity'; + +/** + * The history entry `GET /sell/:id/history` answers with. + * + * Moved out of `BuyFiatService` so that the projection spec can drive the same mapping the endpoint + * uses — see `BuyCryptoHistoryMapper` for the reasoning. + * + * Note that `cryptoInput` and `outputAsset` are read without a guard here. That is the behaviour as + * it stands, and the projection does not change it: `cryptoInput` is a non-nullable relation, and a + * row whose `outputAsset` is still unset would have thrown before the conversion just the same. + */ +export class BuyFiatHistoryMapper { + static toDto(buyFiat: BuyFiat): SellHistoryDto { + return { + inputAmount: buyFiat.inputAmount, + inputAsset: buyFiat.inputAsset, + outputAmount: buyFiat.outputAmount, + outputAsset: buyFiat.outputAsset.name, + txId: buyFiat.cryptoInput.inTxId, + txUrl: txExplorerUrl(buyFiat.cryptoInput.asset.blockchain, buyFiat.cryptoInput.inTxId), + date: buyFiat.fiatOutput?.outputDate, + amlCheck: buyFiat.amlCheck, + isComplete: buyFiat.isComplete, + status: buyFiat.isComplete ? PaymentStatus.COMPLETE : PaymentStatus.PENDING, + }; + } +} diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts index ca453e3f0f..48c3e49527 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException, OnModuleInit } from '@nestjs/common'; -import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; import { toScorechainBlockchain } from 'src/integration/scorechain/dto/scorechain.dto'; import { ScorechainScreening } from 'src/integration/scorechain/entities/scorechain-screening.entity'; import { ScorechainScreeningService } from 'src/integration/scorechain/services/scorechain-screening.service'; @@ -46,7 +45,6 @@ import { AmlReason, PhoneAmlReasons } from '../../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; import { TransactionAmlCheckService } from '../../../aml/services/transaction-aml-check.service'; import { BuyCryptoService } from '../../../buy-crypto/process/services/buy-crypto.service'; -import { PaymentStatus } from '../../../history/dto/history.dto'; import { CryptoInputRefund, RefundInternalDto } from '../../../history/dto/refund-internal.dto'; import { TransactionDetailsDto } from '../../../statistic/dto/statistic.dto'; import { SellHistoryDto } from '../../route/dto/sell-history.dto'; @@ -55,6 +53,7 @@ import { SellRepository } from '../../route/sell.repository'; import { SellService } from '../../route/sell.service'; import { BuyFiat, BuyFiatEditableAmlCheck } from '../buy-fiat.entity'; import { BuyFiatRepository } from '../buy-fiat.repository'; +import { BuyFiatHistoryMapper } from '../dto/buy-fiat-history.mapper'; import { UpdateBuyFiatDto } from '../dto/update-buy-fiat.dto'; import { BuyFiatNotificationService } from './buy-fiat-notification.service'; @@ -631,14 +630,9 @@ export class BuyFiatService implements OnModuleInit { } async getSellHistory(userId: number, sellId?: number): Promise { - const where = Util.removeNullFields({ user: { id: userId }, id: sellId }); - return this.buyFiatRepo - .find({ - where: { sell: where }, - relations: { sell: { user: true }, cryptoInput: true, fiatOutput: true }, - }) - .then((buyFiats) => buyFiats.map(this.toHistoryDto)); + .findSellHistory(userId, sellId) + .then((buyFiats) => buyFiats.map(BuyFiatHistoryMapper.toDto)); } async getPendingTransactions(): Promise { @@ -665,21 +659,6 @@ export class BuyFiatService implements OnModuleInit { return request; } - private toHistoryDto(buyFiat: BuyFiat): SellHistoryDto { - return { - inputAmount: buyFiat.inputAmount, - inputAsset: buyFiat.inputAsset, - outputAmount: buyFiat.outputAmount, - outputAsset: buyFiat.outputAsset.name, - txId: buyFiat.cryptoInput.inTxId, - txUrl: txExplorerUrl(buyFiat.cryptoInput.asset.blockchain, buyFiat.cryptoInput.inTxId), - date: buyFiat.fiatOutput?.outputDate, - amlCheck: buyFiat.amlCheck, - isComplete: buyFiat.isComplete, - status: buyFiat.isComplete ? PaymentStatus.COMPLETE : PaymentStatus.PENDING, - }; - } - private async getSell(sellId: number): Promise { // sell const sell = await this.sellRepo.findOne({ where: { id: sellId }, relations: { user: { wallet: true } } }); diff --git a/src/subdomains/generic/user/models/user-data/user-data.repository.ts b/src/subdomains/generic/user/models/user-data/user-data.repository.ts index 943be3b7cc..b66cc0f59b 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.repository.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.repository.ts @@ -1,15 +1,106 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { CachedRepository } from 'src/shared/repositories/cached.repository'; import { EntityManager } from 'typeorm'; import { UserData } from './user-data.entity'; import { UserDataStatus } from './user-data.enum'; +/** The fields `CountryDtoMapper.entityToDto` reads, for a given join alias. */ +const countryFields = (alias: string): string[] => + [ + 'id', + 'symbol', + 'name', + 'foreignName', + 'ipEnable', + 'fatfEnable', + 'dfxEnable', + 'dfxOrganizationEnable', + 'nationalityStepEnable', + 'bankEnable', + 'checkoutEnable', + 'cryptoEnable', + ].map((field) => `${alias}.${field}`); + +/** + * Fields the profile response shows regardless of account type. + * + * `organization.name` belongs here rather than with the organization address: the mapper reads it + * whenever an organization is linked, while only the *address* branches on `accountType`. + */ +export const USER_PROFILE_ACCOUNT_FIELDS = [ + 'userData.accountType', + 'userData.firstname', + 'userData.surname', + 'userData.mail', + 'userData.phone', + 'organization.name', +]; + +/** + * The address `UserData.address` returns for a personal account. + * + * The getter branches on `accountType`; a business account never reaches these, which is why the + * mutation test asserts over one branch at a time. + */ +export const USER_PROFILE_PERSONAL_ADDRESS_FIELDS = [ + 'userData.street', + 'userData.houseNumber', + 'userData.location', + 'userData.zip', + ...countryFields('country'), +]; + +/** The address `UserData.address` returns for an organization or sole-proprietorship account. */ +export const USER_PROFILE_ORGANIZATION_ADDRESS_FIELDS = [ + 'organization.street', + 'organization.houseNumber', + 'organization.location', + 'organization.zip', + ...countryFields('organizationCountry'), +]; + +/** + * `GET /user/profile` — the seven values `UserDtoMapper.mapProfile` returns. + * + * Without it a `findOne` on `UserData` selects 253 columns across 8 eager joins, `organization` + * among them. Covered by `user-profile.projection.spec.ts` on all four levels. + */ +export const USER_PROFILE_PROJECTION = new ReadProjection( + 'userData', + [ + ['userData.country', 'country'], + ['userData.organization', 'organization'], + ['organization.country', 'organizationCountry'], + ], + [ + ...USER_PROFILE_ACCOUNT_FIELDS, + ...USER_PROFILE_PERSONAL_ADDRESS_FIELDS, + ...USER_PROFILE_ORGANIZATION_ADDRESS_FIELDS, + ], + // Never part of the response: the primary keys that make the ORM materialise the joined rows, and + // the status the endpoint refuses merged accounts on before it maps anything. + ['userData.id', 'userData.status', 'organization.id'], +); + @Injectable() export class UserDataRepository extends CachedRepository { constructor(manager: EntityManager) { super(UserData, manager); } + /** + * Loads exactly what the profile response needs. + * + * `fields` exists for the mutation test, which re-runs this query with one field left out; nothing + * in production passes it. + */ + async getProfile(id: number, fields: ReadonlyArray = USER_PROFILE_PROJECTION.fields): Promise { + return USER_PROFILE_PROJECTION.apply(this.createQueryBuilder('userData'), fields) + .where('userData.id = :id', { id }) + .getOne(); + } + async setNewUpdateTime(userDataId: number): Promise { await this.update(userDataId, { updated: new Date() }); } diff --git a/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts b/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts new file mode 100644 index 0000000000..e8e623fc6a --- /dev/null +++ b/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts @@ -0,0 +1,175 @@ +import { + USER_PROFILE_ACCOUNT_FIELDS, + USER_PROFILE_ORGANIZATION_ADDRESS_FIELDS, + USER_PROFILE_PERSONAL_ADDRESS_FIELDS, + USER_PROFILE_PROJECTION, + UserDataRepository, +} from 'src/subdomains/generic/user/models/user-data/user-data.repository'; +import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { UserDtoMapper } from 'src/subdomains/generic/user/models/user/dto/user-dto.mapper'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'user_profile_projection_spec'; + +/** + * `GET /user/profile` — the four levels from `docs/read-path-projections.md`. + * + * The endpoint answers a `UserProfileDto` built by `UserDtoMapper.mapProfile`. Reading it without a + * projection loads 253 columns across 8 eager joins for the seven values it returns. + * + * The branch that makes this worth testing is `UserData.address`: for an organization account it + * reads the address off `organization`, for a personal one off `userData` itself. A projection + * covering only one of the two answers 200 with an empty address for the other. + */ +describeProjection('GET /user/profile — read-path projection', () => { + let dataSource: DataSource; + let repository: UserDataRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + repository = new UserDataRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** Fully populated fixture: every column of every participating entity carries a distinct value. */ + async function seedAccount(accountType: AccountType): Promise { + return seedEntity(dataSource, UserData, { + values: { accountType }, + relations: { country: true, organization: { relations: { country: true } } }, + }); + } + + /** A personal account with no organization linked — the ordinary case in production. */ + async function seedAccountWithoutOrganization(): Promise { + return seedEntity(dataSource, UserData, { + values: { accountType: AccountType.PERSONAL }, + relations: { country: true }, + }); + } + + /** The response the endpoint produces, through the projected query. */ + async function profileOf(id: number, fields = USER_PROFILE_PROJECTION.fields) { + const userData = await repository.getProfile(id, fields); + return UserDtoMapper.mapProfile(userData); + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a personal account answers with no empty field', async () => { + const userData = await seedAccount(AccountType.PERSONAL); + + expectNoEmptyFields(await profileOf(userData.id)); + }, 120000); + + // --- LEVEL 2: variants --- // + + it.each([AccountType.ORGANIZATION, AccountType.SOLE_PROPRIETORSHIP])( + 'level 2 — %s reads the address off the organization and answers with no empty field', + async (accountType) => { + const userData = await seedAccount(accountType); + + const profile = await profileOf(userData.id); + + expectNoEmptyFields(profile); + }, + 120000, + ); + + it('level 2 — the address comes from a different source per account type', async () => { + const personal = await seedAccount(AccountType.PERSONAL); + const organization = await seedAccount(AccountType.ORGANIZATION); + + const personalProfile = await profileOf(personal.id); + const organizationProfile = await profileOf(organization.id); + + // Both fixtures carry an organization, so the difference isolates the branch: the personal + // account must answer with its own street, the organization one with the organization's. Had the + // projection loaded only one of the two sources, one of these would be empty rather than merely + // different — and the assertion holds because every seeded value is distinct. + expect(personalProfile.address.street).toEqual(personal.street); + expect(organizationProfile.address.street).toEqual(organization.organization.street); + expect(personalProfile.address.street).not.toEqual(organizationProfile.address.street); + }, 120000); + + it('level 2 — a personal account without an organization answers without one', async () => { + const userData = await seedAccountWithoutOrganization(); + + const profile = await profileOf(userData.id); + + // The ordinary case: no organization row, so the mapper has no name to report. Everything else + // must still be complete — a projection that only ever ran against the fixture above would not + // show whether the left joins tolerate the missing row. + expect(profile.organizationName).toBeUndefined(); + expectNoEmptyFields(profile, ['organizationName']); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it.each([ + [AccountType.PERSONAL, [...USER_PROFILE_ACCOUNT_FIELDS, ...USER_PROFILE_PERSONAL_ADDRESS_FIELDS]], + [AccountType.ORGANIZATION, [...USER_PROFILE_ACCOUNT_FIELDS, ...USER_PROFILE_ORGANIZATION_ADDRESS_FIELDS]], + ])( + 'level 3 — for %s every field feeding the response is required', + async (accountType, candidates) => { + const userData = await seedAccount(accountType); + + // Drops each candidate in turn and re-runs the same production query with the rest of the + // projection intact. Only the fields this account type actually reads are candidates: a personal + // account never touches the organization address, so dropping it would prove nothing here — the + // organization row above is what covers those. + await expectEveryFieldRequired(candidates, (omitted) => + profileOf(userData.id, projectionFieldsWithout(USER_PROFILE_PROJECTION.fields, omitted)), + ); + }, + 300000, + ); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([ + ['Personal', () => seedAccount(AccountType.PERSONAL)], + ['Organization', () => seedAccount(AccountType.ORGANIZATION)], + ['SoleProprietorship', () => seedAccount(AccountType.SOLE_PROPRIETORSHIP)], + ['Personal without an organization', seedAccountWithoutOrganization], + ])( + 'level 4 — for %s the projected response equals the one from a full load', + async (_name, seed) => { + const userData = await seed(); + + const projected = await profileOf(userData.id); + // The unprojected load is the second source: it fetches every column, so whatever it produces + // is by construction what the endpoint answered before the conversion. No second + // implementation is involved that could be wrong in the same way. + const full = await dataSource.getRepository(UserData).findOne({ + where: { id: userData.id }, + relations: { organization: true }, + }); + + expect(projected).toEqual(UserDtoMapper.mapProfile(full)); + }, + 120000, + ); + + // --- the projection must not lose the guard the endpoint depends on --- // + + it('loads the status the endpoint refuses merged accounts on', async () => { + const userData = await seedAccount(AccountType.PERSONAL); + + const loaded = await repository.getProfile(userData.id); + + expect(loaded.status).toEqual(userData.status); + }, 120000); +}); diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index d6bba889a4..c22f38f097 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -317,10 +317,7 @@ export class UserService { } async getUserProfile(userDataId: number): Promise { - const userData = await this.userDataRepo.findOne({ - where: { id: userDataId }, - relations: { organization: true }, - }); + const userData = await this.userDataRepo.getProfile(userDataId); if (!userData) throw new NotFoundException('User not found'); if (userData.status === UserDataStatus.MERGED) throw new UnauthorizedException('User is merged'); From c5b88097ebed7ae9155e2ea232893abbbc96ad6f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:13:29 +0200 Subject: [PATCH 02/46] Correct the query-builder classification and record what it changes The inventory counted every string argument to `.select(...)` as the bare root alias - "reads like a projection but is not". That is true for `.select('user')` and false for `.select('userData.id', 'id')`, which names a column and does narrow the query. The classification only recognised the array form, so 84 sites that project one column at a time were counted as full loads. Corrected in the collection step, together with two cases it also missed: a projection whose chain carries a `leftJoinAndSelect` loads that relation whole after all, and a query built through `ReadProjection.apply` carries its field list in the constant rather than in the chain. What moves: whole rows 428 -> 417 endpoints projected 6 -> 17 query builders naming columns 1 -> 89 of 133 What does not move is the finding. The 88 sites this uncovers are counts, maxima and id lookups - one column at the median - not response payloads. History, profile, invoices and exports are still served by `find`, and the endpoints that matter are still the ones this document lists as `not yet`. Their `Tests` column reads `0/4` rather than `n/a`: a projection without the four levels is the state this repository warns about regardless of when it was written. Also adds a spec that reads the column counts out of docs/endpoints.md and compares them against the projections themselves, so the two cannot drift. --- docs/endpoints.md | 52 +- docs/load-sites.md | 1237 ++++++++--------- docs/read-path-projections.md | 28 +- .../models/__tests__/read-projection.spec.ts | 77 + 4 files changed, 716 insertions(+), 678 deletions(-) create mode 100644 src/shared/models/__tests__/read-projection.spec.ts diff --git a/docs/endpoints.md b/docs/endpoints.md index f5fec3f3e3..b2ba386351 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -23,20 +23,20 @@ Two rules follow from that, and both are binding: 1. **An endpoint counts as converted only when its tests reach `4/4`** against the four levels in [read-path-projections.md](read-path-projections.md#test-definition). A projection without them is worse than no projection: a forgotten field does not crash, it returns a wrong value with a 200, and in a service moving money that can run for weeks unnoticed. Anything short of `4/4` is unfinished work, not a partial success. 2. **The state of every endpoint is recorded here**, in the `Tests` column, and kept in sync with the code in the same pull request that changes it. An undocumented conversion is indistinguishable from one that was never tested. -Today 8 endpoints read only what they return and 428 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. +Today 19 endpoints read only what they return and 417 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. ## What the numbers say | Data access | Endpoints | Share | | ----------- | --------: | ----: | -| `whole rows` | 428 | 80 % | +| `whole rows` | 417 | 78 % | | `none` | 98 | 18 % | -| `projected` | 6 | 1 % | +| `projected` | 17 | 3 % | | `caller-defined` | 2 | 0 % | -Six endpoints read only what they return. Two were already that way: `PUT /log/financial/validity`, whose query names `log.id` and `log.valid`, and `POST /gs/debug`, which assembles its select list from the request. Four were converted, each with an explicit field list and tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 to 12), `GET /swap/:id/history` (509 to 12) and `GET /sell/:id/history` (470 to 14). `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. How far the test suite actually covers those reads is recorded per site in [read-path-projections.md](read-path-projections.md#which-endpoints-these-apply-to); the short answer is that the projection behind `PUT /log/financial/validity` is never executed in a test. +Of the 17 that read only what they return, 4 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14). The other 13 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. -Among the 428 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 316 exceed 100, 89 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. +Among the 417 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 313 exceed 100, 87 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. ### How to read this column, and how not to @@ -50,10 +50,10 @@ Among the 428 that fetch whole rows, the widest query they can trigger is **308 Stated exactly, so the numbers can be checked rather than believed: -- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 428 is a lower bound. +- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 417 is a lower bound. - All 98 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. -- The 2 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. -- 3 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`, `PUT /userData/:id/volumes`. Those three are also the ones most exposed to the upper bound described in [load-sites.md](load-sites.md#measurements): with no measured query behind them, nothing here shows that they reach a whole-row read at all. +- The 17 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. +- 5 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue`, `GET /support/issue/:id`, `GET /support/issue/:id/data`, `GET /support/issue/:id/message/:messageId/file`. The classification holds; only the width is unknown. ### Two controller classes may share a name @@ -178,7 +178,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/buyCrypto/:id/refund` | hidden | whole rows | 1051 | not yet | | `BuyCryptoController.refundBuyCrypto` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | POST | 1 | | `/buyCrypto/:id/scorechain` | hidden | whole rows | 717 | not yet | | `BuyCryptoController.retriggerScorechain` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | POST | 1 | | `/buyCrypto/:id/webhook` | hidden | whole rows | 844 | not yet | | `BuyCryptoController.triggerWebhook` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| PUT | 1 | | `/buyCrypto/refVolumes` | hidden | whole rows | 77 | not yet | | `BuyCryptoController.updateRefVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyCrypto/refVolumes` | hidden | projected | 2 | 0/4 | | `BuyCryptoController.updateRefVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | PUT | 1 | | `/buyCrypto/volumes` | hidden | whole rows | 487 | not yet | | `BuyCryptoController.updateBuyVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | PUT | 1 | | `/buyFiat/:id` | hidden | whole rows | 1033 | not yet | | `BuyFiatController.update` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | DELETE | 1 | | `/buyFiat/:id/amlCheck` | hidden | whole rows | 490 | not yet | | `BuyFiatController.resetAmlCheck` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | @@ -186,7 +186,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/buyFiat/:id/refund` | hidden | whole rows | 803 | not yet | | `BuyFiatController.refundBuyFiat` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | POST | 1 | | `/buyFiat/:id/scorechain` | hidden | whole rows | 517 | not yet | | `BuyFiatController.retriggerScorechain` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | POST | 1 | | `/buyFiat/:id/webhook` | hidden | whole rows | 644 | not yet | | `BuyFiatController.triggerWebhook` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | -| PUT | 1 | | `/buyFiat/refVolumes` | hidden | whole rows | 77 | not yet | | `BuyFiatController.updateRefVolumes` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | +| PUT | 1 | | `/buyFiat/refVolumes` | hidden | projected | 2 | 0/4 | | `BuyFiatController.updateRefVolumes` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | PUT | 1 | | `/buyFiat/volumes` | hidden | whole rows | 308 | not yet | | `BuyFiatController.updateVolumes` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | GET | 1 | | `/country` | public | whole rows | 23 | not yet | | `CountryController.getAllCountry` | `shared/models/country/country.controller.ts` | | GET | 1 | | `/cryptoRoute` | hidden | none | — | n/a | | `CryptoRouteController.getAllCrypto` | `subdomains/core/buy-crypto/routes/swap/crypto-route.controller.ts` | @@ -228,7 +228,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/dashboard/financial/log` | hidden | whole rows | 33 | not yet | yes | `DashboardFinancialController.getFinancialLog` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/reconciliation` | hidden | whole rows | 229 | not yet | | `DashboardReconciliationController.getReconciliation` | `subdomains/supporting/dashboard/dashboard-reconciliation.controller.ts` | | GET | 1 | | `/dashboard/financial/reconciliation/overview` | hidden | whole rows | 229 | not yet | | `DashboardReconciliationController.getOverview` | `subdomains/supporting/dashboard/dashboard-reconciliation.controller.ts` | -| GET | 1 | | `/dashboard/financial/ref-recipients` | hidden | whole rows | 25 | not yet | | `DashboardFinancialController.getRefRewardRecipients` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | +| GET | 1 | | `/dashboard/financial/ref-recipients` | hidden | projected | 3 | 0/4 | | `DashboardFinancialController.getRefRewardRecipients` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | POST | 1 | | `/deposit` | hidden | whole rows | 6 | not yet | | `DepositController.createDeposits` | `subdomains/supporting/address-pool/deposit/deposit.controller.ts` | | PUT | 1 | | `/deposit/lightningWebhook` | hidden | none | — | n/a | | `DepositController.updateLightningDepositWebhook` | `subdomains/supporting/address-pool/deposit/deposit.controller.ts` | | GET | 1 | | `/deuro/info` | public | whole rows | 11 | not yet | | `DEuroController.getInfo` | `integration/blockchain/deuro/controllers/deuro.controller.ts` | @@ -292,7 +292,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | yes | `/kyc/:id/documents/:type` | public | whole rows | 328 | not yet | | `KycClientController.getKycFileV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 2 | | `/kyc/:step` | hidden | whole rows | 364 | not yet | | `KycController.initiateStep` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | DELETE | 1 | | `/kyc/admin/blacklist/ip` | hidden | none | — | n/a | | `KycAdminController.deleteIpToBlacklist` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | -| PUT | 1 | | `/kyc/admin/blacklist/ip` | hidden | whole rows | 12 | not yet | | `KycAdminController.addIpToBlacklist` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | +| PUT | 1 | | `/kyc/admin/blacklist/ip` | hidden | projected | 1 | 0/4 | | `KycAdminController.addIpToBlacklist` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | POST | 1 | | `/kyc/admin/ident/file/sync` | hidden | whole rows | 243 | not yet | | `KycAdminController.syncIdentFiles` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | POST | 1 | | `/kyc/admin/log` | hidden | whole rows | 253 | not yet | | `KycAdminController.createLog` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | PUT | 1 | | `/kyc/admin/log/:id` | hidden | whole rows | 17 | not yet | | `KycAdminController.updateLog` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | @@ -472,15 +472,15 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/realunit/sell/:id/confirm` | public | whole rows | 504 | not yet | | `RealUnitController.confirmSell` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/sell/:id/unsigned-transactions` | public | whole rows | 504 | not yet | | `RealUnitController.getSellUnsignedTransactions` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/support/:id` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.updateSupportIssue` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/:id/data` | hidden | whole rows | 951 | not yet | | `RealUnitSupportController.getIssueData` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/:id/data` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.getIssueData` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | POST | 1 | | `/realunit/support/:id/message` | hidden | whole rows | 441 | not yet | | `RealUnitSupportController.createSupportMessage` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/:id/message/:messageId/file` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.getFile` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/:id/messages` | hidden | whole rows | 428 | not yet | | `RealUnitSupportController.getIssueMessages` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/activity` | hidden | whole rows | 99 | not yet | | `RealUnitSupportController.getSupportIssueActivity` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/activity` | hidden | whole rows | 7 | not yet | | `RealUnitSupportController.getSupportIssueActivity` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/clerks` | hidden | none | — | n/a | | `RealUnitSupportController.getRealUnitSupportClerks` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/counts` | hidden | whole rows | 99 | not yet | | `RealUnitSupportController.getSupportIssueCounts` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/list` | hidden | whole rows | 99 | not yet | | `RealUnitSupportController.getSupportIssueList` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/statistics` | hidden | whole rows | 99 | not yet | | `RealUnitSupportController.getSupportIssueStatistics` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/counts` | hidden | projected | 2 | 0/4 | | `RealUnitSupportController.getSupportIssueCounts` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/list` | hidden | whole rows | 16 | not yet | | `RealUnitSupportController.getSupportIssueList` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/statistics` | hidden | whole rows | 16 | not yet | | `RealUnitSupportController.getSupportIssueStatistics` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | PUT | 1 | | `/realunit/swap` | public | whole rows | 308 | not yet | yes | `RealUnitController.getSwapPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/swap/:id/broadcast` | public | whole rows | 504 | not yet | yes | `RealUnitController.broadcastSwapTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/swap/:id/unsigned-transaction` | public | whole rows | 504 | not yet | yes | `RealUnitController.getSwapUnsignedTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | @@ -533,18 +533,18 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/support/call-queues` | hidden | none | — | n/a | | `SupportController.getCallQueues` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues/:queue/items` | hidden | whole rows | 672 | not yet | | `SupportController.getCallQueueItems` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues/clerks` | hidden | none | — | n/a | | `SupportController.getCallQueueClerks` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/issue` | public | whole rows | 450 | not yet | | `SupportIssueController.getIssues` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue` | public | whole rows | — | not yet | | `SupportIssueController.getIssues` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue` | public | whole rows | 493 | not yet | | `SupportIssueController.createIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/:id` | public | whole rows | 450 | not yet | | `SupportIssueController.getIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/:id` | public | whole rows | — | not yet | | `SupportIssueController.getIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | PUT | 1 | | `/support/issue/:id` | hidden | whole rows | 421 | not yet | | `SupportIssueController.updateSupportIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | PUT | 1 | | `/support/issue/:id/close` | public | whole rows | 450 | not yet | | `SupportIssueController.closeIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/:id/data` | hidden | whole rows | 951 | not yet | | `SupportIssueController.getIssueData` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/:id/data` | hidden | whole rows | — | not yet | | `SupportIssueController.getIssueData` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/:id/message` | public | whole rows | 441 | not yet | yes | `SupportIssueController.createSupportMessage` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/:id/message/:messageId/file` | public | whole rows | — | not yet | | `SupportIssueController.getFile` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/activity` | hidden | whole rows | 7 | not yet | | `SupportIssueController.getSupportIssueActivity` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/clerk` | hidden | none | — | n/a | | `SupportIssueController.getSupportIssueClerk` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/clerks` | hidden | none | — | n/a | | `SupportIssueController.getSupportIssueClerks` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/counts` | hidden | whole rows | 16 | not yet | | `SupportIssueController.getSupportIssueCounts` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/counts` | hidden | projected | 2 | 0/4 | | `SupportIssueController.getSupportIssueCounts` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/escalation/telegram-bind` | hidden | whole rows | 5 | not yet | | `SupportIssueController.bindEscalationChat` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/escalation/telegram-chats` | hidden | none | — | n/a | | `SupportIssueController.getEscalationChats` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/escalation/telegram-test` | hidden | whole rows | 5 | not yet | | `SupportIssueController.testEscalationChat` | `subdomains/supporting/support-issue/support-issue.controller.ts` | @@ -557,8 +557,8 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/support/note` | hidden | whole rows | 253 | not yet | | `SupportController.createNote` | `subdomains/generic/support/support.controller.ts` | | DELETE | 1 | | `/support/note/:id` | hidden | whole rows | 9 | not yet | | `SupportController.deleteNote` | `subdomains/generic/support/support.controller.ts` | | PUT | 1 | | `/support/note/:id` | hidden | whole rows | 239 | not yet | | `SupportController.updateNote` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/note/users` | hidden | whole rows | 9 | not yet | | `SupportController.listNoteUsers` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/pending-reviews` | hidden | whole rows | 15 | not yet | | `SupportController.getPendingReviews` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/note/users` | hidden | projected | 5 | 0/4 | | `SupportController.listNoteUsers` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/pending-reviews` | hidden | projected | 3 | 0/4 | | `SupportController.getPendingReviews` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/pending-reviews/items` | hidden | whole rows | 261 | not yet | | `SupportController.getPendingReviewItems` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/pending-transactions` | hidden | whole rows | 672 | not yet | | `SupportController.getPendingTransactions` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/recommendation-graph/:id/neighbors` | hidden | whole rows | 474 | not yet | yes | `SupportController.getRecommendationGraphNeighbors` | `subdomains/generic/support/support.controller.ts` | @@ -621,11 +621,11 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 2 | | `/user/mail/verify` | public | whole rows | 364 | not yet | | `UserV2Controller.verifyMail` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/name` | hidden | whole rows | 386 | not yet | | `UserController.updateUserName` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 2 | | `/user/profile` | public | projected | 41 | 4/4 | | `UserV2Controller.getProfile` | `subdomains/generic/user/models/user/user.controller.ts` | -| GET | 1 | | `/user/ref` | hidden | whole rows | 45 | not yet | | `UserController.getRefInfo` | `subdomains/generic/user/models/user/user.controller.ts` | +| GET | 1 | | `/user/ref` | hidden | projected | 1 | 0/4 | | `UserController.getRefInfo` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 2 | | `/user/ref` | public | whole rows | 98 | not yet | | `UserV2Controller.getRef` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 2 | | `/user/ref` | public | whole rows | 98 | not yet | | `UserV2Controller.updateRefAsset` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/specialCodes` | public | whole rows | 308 | not yet | | `UserController.addSpecialCode` | `subdomains/generic/user/models/user/user.controller.ts` | -| GET | 1 | | `/user/volumes` | hidden | whole rows | 45 | not yet | | `UserController.getVolumes` | `subdomains/generic/user/models/user/user.controller.ts` | +| GET | 1 | | `/user/volumes` | hidden | projected | 1 | 0/4 | | `UserController.getVolumes` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | | `/userData` | hidden | whole rows | 253 | not yet | | `UserDataController.getAllUserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | POST | 1 | | `/userData` | hidden | whole rows | 253 | not yet | | `UserDataController.createEmptyUserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | GET | 1 | | `/userData/:id` | hidden | whole rows | 253 | not yet | | `UserDataController.getUserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | @@ -635,7 +635,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/userData/:id/fee` | hidden | whole rows | 253 | not yet | | `UserDataController.addFee` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | POST | 1 | | `/userData/:id/kycFile` | hidden | whole rows | 253 | not yet | | `UserDataController.uploadKycFile` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | PUT | 1 | | `/userData/:id/merge` | hidden | whole rows | 364 | not yet | | `UserDataController.mergeUserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | -| PUT | 1 | | `/userData/:id/volumes` | hidden | whole rows | — | not yet | | `UserDataController.updateVolumes` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | +| PUT | 1 | | `/userData/:id/volumes` | hidden | projected | 9 | 0/4 | | `UserDataController.updateVolumes` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | PUT | 1 | | `/userData/auditPeriodNumbers` | hidden | whole rows | 40 | not yet | | `UserDataController.calculateAuditPeriodNumbers` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | POST | 1 | | `/userData/download` | hidden | whole rows | 253 | not yet | | `UserDataController.downloadUserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | POST | 1 | | `/userDataRelation` | public | whole rows | 253 | not yet | | `UserDataRelationController.create` | `subdomains/generic/user/models/user-data-relation/user-data-relation.controller.ts` | diff --git a/docs/load-sites.md b/docs/load-sites.md index 50fef32804..7cb5b6c44b 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -1,6 +1,6 @@ # Database load sites -Every place in the code that reads from the database: **at most 1158 load sites** across 243 files — an upper bound, for the reason given under *Measurements*. +Every place in the code that reads from the database: **1105 load sites** across 244 files. This is the level at which the statement is unambiguous. An endpoint reaches several load sites — a permission check, a lookup, the actual query — so asking whether *an endpoint* loads efficiently has no single answer. Asking it of a load site does. [endpoints.md](endpoints.md) carries the per-endpoint summary derived from these sites. @@ -8,37 +8,39 @@ This is the level at which the statement is unambiguous. An endpoint reaches sev | Mechanism | Sites | Eager relations | Columns selected | | --------- | ----: | --------------- | ---------------- | -| `find` family | 1021 | **applied** — expanded recursively | all root columns by default; a `select` in the find options narrows the root, but the eager relations stay | -| `createQueryBuilder` | 130 | not applied | all columns of the root entity, unless `.select([...])` narrows it | -| raw SQL | 7 | not applied | whatever the statement lists | +| `find` family | 967 | **applied** — expanded recursively | all columns of the entity plus every eager relation | +| `createQueryBuilder` | 133 | not applied | all columns of the root entity, unless `.select([...])` narrows it | +| raw SQL | 5 | not applied | whatever the statement lists | -Statements that load nothing are excluded from the count: 2 `createQueryBuilder` calls carrying `.update()`, 6 advisory locks (`SELECT pg_advisory_xact_lock(...)`, which return no rows) and 4 raw `INSERT`. Each of the 7 raw reads that remain names its columns — together with the one query builder below, raw SQL is the only place in this repository where a read states which columns it wants. +Statements that load nothing are excluded from the count: 2 `createQueryBuilder` calls carrying `.update()`, 6 advisory locks (`SELECT pg_advisory_xact_lock(...)`, which return no rows) and 1 raw `INSERT`. Each of the 5 raw reads that remain names its columns. Among the query builders, the field list is what decides whether anything is actually saved: | | Sites | | --- | ---: | -| `.select([...])` — an explicit field list | **1** | -| `.select('alias')` — selects the root alias, **loads every column** | 105 | -| no `select` at all — loads every column | 24 | +| `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **5** | +| `.select('alias.column')` — names columns one by one | **84** | +| `.select('alias')` — selects the root alias, **loads every column** | 20 | +| no `select` at all — loads every column | 23 | +| projects, but a `leftJoinAndSelect` loads a relation whole | 1 | -`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. +`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The distinction is the presence of a dot in the argument, and it matters — the sites that name columns this way select 1 column at the median, against 967 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. ## Measurements -Columns were measured against the real entity metadata by building the query and counting its SELECT list — 788 of 1158 sites. +Columns were measured against the real entity metadata by building the query and counting its SELECT list — 786 of 1105 sites. -- **351 are exact**: the `relations` tree is written at the call site. -- **437 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. -- 370 could not be measured: no resolvable target entity, or raw SQL. +- **341 are exact**: the `relations` tree is written at the call site. +- **445 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. +- 319 could not be measured: no resolvable target entity, or raw SQL. -**That last group is also why the total is an upper bound.** The collection matches `find` by name, and `find` on a repository is indistinguishable by name from `find` on an array. Where the target entity resolved, the distinction is settled; where it did not, the group holds both. A sample of 30 of those rows, read in the source, came out at 21 array operations to 9 genuine repository reads. That group holds 343 rows, so on the order of 240 of them are not database reads at all, and the true count is nearer 900. +Median across measured sites: **112 columns**. 14 sites exceed 1000, 75 exceed 500, 402 exceed 100. -What that does and does not affect: the median and the counts above are computed only over the 788 rows that carry a measured width, and an array operation never has one, so those figures stand. Nor does it move the conclusion this table exists for: the sites that name their columns are a small fraction either way, and the verdict reads the same against 900 as against 1158. It does reach the per-endpoint summary in [endpoints.md](endpoints.md): an endpoint could be listed as fetching whole rows on the strength of such a row alone. The three with no measured width at all are the exposed cases and are named there. For the rest a measured query stands behind the entry, which limits the effect without excluding it — establishing that would need the collection to separate the two kinds of `find`, which is the fix this note stands in for. +What that does and does not affect: the median and the counts above are computed only over the 782 rows that carry a measured width, and an array operation never has one, so those figures stand. Nor does it move the conclusion this table exists for: the sites that name their columns are a small fraction either way, and the verdict reads the same against 900 as against 1105. It does reach the per-endpoint summary in [endpoints.md](endpoints.md): an endpoint could be listed as fetching whole rows on the strength of such a row alone. The three with no measured width at all are the exposed cases and are named there. For the rest a measured query stands behind the entry, which limits the effect without excluding it — establishing that would need the collection to separate the two kinds of `find`, which is the fix this note stands in for. -Median across measured sites: **118 columns**. At least 14 sites exceed 1000, 78 exceed 500 and 412 exceed 100 — "at least", because 437 of these measurements are lower bounds, and a resolved relation tree can only widen a query, never narrow it. +Median across measured sites: **120.5 columns**. At least 14 sites exceed 1000, 77 exceed 500 and 409 exceed 100 — "at least", because 434 of these measurements are lower bounds, and a resolved relation tree can only widen a query, never narrow it. -Postgres refuses a statement with more than 1664 columns. The widest measured site here sits at 1453, so a little over two hundred columns separate it from a rejected statement — and 437 of these measurements are lower bounds, so the real margin can be smaller. +Postgres refuses a statement with more than 1664 columns. The widest measured site here sits at 1453, so a little over two hundred columns separate it from a rejected statement — and 434 of these measurements are lower bounds, so the real margin can be smaller. ## Load sites @@ -47,69 +49,67 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | Columns | Joins | Mechanism | Entity | Location | Method | | ------: | ----: | --------- | ------ | -------- | ------ | | 1453 | 49 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:229` | `FiatOutputJobService.setReadyDate` | -| 1363 | 47 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:368` | `TransactionService.getTransactionsForAccount` | -| 1282 | 50 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:565` | `BuyCryptoPreparationService.fillPaymentLinkPayments` | +| 1363 | 47 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:267` | `TransactionService.getTransactionsForAccount` | +| 1282 | 50 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:426` | `BuyCryptoPreparationService.fillPaymentLinkPayments` | | 1231 | 38 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:174` | `FiatOutputJobService.assignBankAccount` | -| 1162 | 44 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:68` | `BuyCryptoBatchService.batchAndOptimizeTransactions` | -| 1139 | 41 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:422` | `BuyCryptoPreparationService.In` | -| 1092 | 41 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:400` | `TransactionService.getTransactionsForUsers` | -| 1090 | 40 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:309` | `BuyCryptoService.update` | -| 1063 | 42 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:120` | `BuyCryptoPreparationService.doAmlCheck` | -| 1051 | 36 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:624` | `BuyCryptoService.refundBuyCrypto` | -| 1051 | 32 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:402` | `BankTxService.update` | -| 1051 | 32 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:385` | `BankTxService.create` | -| 1033 | 40 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:181` | `BuyFiatService.update` | +| 1162 | 44 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:65` | `BuyCryptoBatchService.batchAndOptimizeTransactions` | +| 1139 | 41 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:317` | `BuyCryptoPreparationService.In` | +| 1092 | 41 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:299` | `TransactionService.getTransactionsForUsers` | +| 1090 | 40 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:274` | `BuyCryptoService.update` | +| 1063 | 42 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:119` | `BuyCryptoPreparationService.doAmlCheck` | +| 1051 | 36 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:513` | `BuyCryptoService.refundBuyCrypto` | +| 1051 | 32 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:328` | `BankTxService.create` | +| 1051 | 32 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:341` | `BankTxService.update` | +| 1033 | 40 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:180` | `BuyFiatService.update` | | 1003 | 36 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:534` | `BuyFiatPreparationService.addFiatOutputs` | -| 951 | 33 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:685` | `SupportIssueService.getIssueData` | -| 907 | 31 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1575` | `BuyCryptoService.getAllUserTransactions` | +| 907 | 31 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1165` | `BuyCryptoService.getAllUserTransactions` | | 891 | 37 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:340` | `BuyFiatPreparationService.fillPaymentLinkPayments` | | 885 | 33 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-out.service.ts:133` | `BuyCryptoOutService.fetchBatchesForPayout` | -| 880 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:811` | `BuyCryptoPreparationService.chargebackFillUp` | +| 880 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:626` | `BuyCryptoPreparationService.chargebackFillUp` | | 844 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-webhook.service.ts:17` | `BuyCryptoWebhookService.triggerWebhookManual` | -| 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:282` | `TransactionService.getTransactionsWithoutUid` | -| 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:291` | `TransactionService.getTransactionsByUserDataId` | -| 815 | 28 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1306` | `BuyCryptoService.getRefTransactions` | -| 815 | 28 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1557` | `BuyCryptoService.getAllRefTransactions` | +| 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:189` | `TransactionService.getTransactionsWithoutUid` | +| 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:198` | `TransactionService.getTransactionsByUserDataId` | +| 815 | 28 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:922` | `BuyCryptoService.getRefTransactions` | +| 815 | 28 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1147` | `BuyCryptoService.getAllRefTransactions` | | 813 | 29 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:255` | `BuyFiatPreparationService.refreshFee` | -| 803 | 29 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:401` | `BuyFiatService.refundBuyFiat` | +| 803 | 29 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:400` | `BuyFiatService.refundBuyFiat` | | 794 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction-notification.service.ts:37` | `TransactionNotificationService.txAssigned` | -| 785 | 24 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:269` | `BuyCryptoNotificationService.chargebackInitiated` | -| 765 | 23 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:760` | `BuyCryptoPreparationService.chargebackTx` | +| 785 | 24 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:267` | `BuyCryptoNotificationService.chargebackInitiated` | +| 765 | 23 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:588` | `BuyCryptoPreparationService.chargebackTx` | | 737 | 30 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:107` | `BuyFiatPreparationService.doAmlCheck` | | 727 | 23 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:176` | `BankTxReturnService.refundBankTxReturn` | -| 717 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1149` | `BuyCryptoService.retriggerScorechain` | +| 717 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:788` | `BuyCryptoService.retriggerScorechain` | | 703 | 24 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:491` | `BuyFiatPreparationService.complete` | -| 672 | 23 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1613` | `BuyCryptoService.getByAmlReason` | -| 644 | 22 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:375` | `BuyFiatService.getBuyFiat` | -| 644 | 22 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:379` | `BuyFiatService.triggerWebhookManual` | +| 672 | 23 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1203` | `BuyCryptoService.getByAmlReason` | +| 644 | 22 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:374` | `BuyFiatService.getBuyFiat` | +| 644 | 22 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:378` | `BuyFiatService.triggerWebhookManual` | | 643 | 21 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:196` | `RecommendationService.confirmRecommendation` | | 643 | 21 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:255` | `RecommendationService.getAndCheckRecommendationByCode` | | 643 | 21 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:294` | `RecommendationService.checkAndConfirmRecommendInvitation` | | 639 | 24 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:108` | `FiatOutputJobService.generateReports` | | 630 | 20 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:284` | `RecommendationService.getUserDataRecommendation` | -| 623 | 20 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:620` | `BuyFiatService.getAllUserTransactions` | +| 623 | 20 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:619` | `BuyFiatService.getAllUserTransactions` | | 613 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:82` | `BuyCryptoNotificationService.paymentCompleted` | -| 613 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:177` | `BuyCryptoNotificationService.pendingBuyCrypto` | -| 613 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:352` | `BuyCryptoNotificationService.chargebackUnconfirmed` | +| 613 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:175` | `BuyCryptoNotificationService.pendingBuyCrypto` | +| 613 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:350` | `BuyCryptoNotificationService.chargebackUnconfirmed` | | 597 | 21 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:432` | `BuyFiatPreparationService.setOutput` | -| 593 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:951` | `BuyCryptoService.getBuyCryptosByChargebackIban` | +| 593 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:738` | `BuyCryptoService.getBuyCryptosByChargebackIban` | | 583 | 21 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:209` | `BuyFiatNotificationService.chargebackInitiated` | | 583 | 21 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:35` | `BuyFiatRegistrationService.syncReturnTxId` | | 567 | 17 | find | `BuyCrypto` | `subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts:114` | `BuyCryptoConsumer.processForward` | -| 558 | 24 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:159` | `PaymentQuoteService.getEarliestQuoteClaimingTx` | -| 558 | 24 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:170` | `PaymentQuoteService.getConfirmingQuotes` | +| 558 | 24 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:150` | `PaymentQuoteService.getConfirmingQuotes` | | 545 | 23 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:41` | `PaymentLinkRepository.getHistoryByStatus` | -| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:246` | `PaymentLinkPaymentService.updatePayment` | -| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:253` | `PaymentLinkPaymentService.getPendingPaymentByUniqueId` | -| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:730` | `PaymentLinkPaymentService.handleBlockchainConfirmed` | -| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:840` | `PaymentLinkPaymentService.sendWebhook` | -| 540 | 17 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:784` | `BuyFiatService.getByAmlReason` | +| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:97` | `PaymentLinkPaymentService.updatePayment` | +| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:104` | `PaymentLinkPaymentService.getPendingPaymentByUniqueId` | +| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:372` | `PaymentLinkPaymentService.handleBlockchainConfirmed` | +| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:449` | `PaymentLinkPaymentService.sendWebhook` | +| 540 | 17 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:763` | `BuyFiatService.getByAmlReason` | | 538 | 21 | find | `CryptoInput` | `subdomains/core/accounting/services/consumers/crypto-input.consumer.ts:89` | `CryptoInputConsumer.processForward` | -| 535 | 15 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1339` | `BuyCryptoService.getPendingTransactions` | -| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:300` | `CustodyOrderService.confirmOrder` | -| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:316` | `CustodyOrderService.getOrdersForSupport` | +| 535 | 15 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:947` | `BuyCryptoService.getPendingTransactions` | +| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:294` | `CustodyOrderService.confirmOrder` | +| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:310` | `CustodyOrderService.getOrdersForSupport` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:615` | `BuyFiatPreparationService.chargebackTx` | -| 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:487` | `BuyFiatService.retriggerScorechain` | +| 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:486` | `BuyFiatService.retriggerScorechain` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:14` | `PaymentLinkRepository.getAllPaymentLinks` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:21` | `PaymentLinkRepository.getAllPaymentLinksByExternalLinkId` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:28` | `PaymentLinkRepository.getAllPaymentLinksByExternalPaymentId` | @@ -121,21 +121,19 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:504` | `PaymentLinkService.assignPaymentLink` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:520` | `PaymentLinkService.getLocations` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:702` | `PaymentLinkService.createPosLinkAdmin` | -| 509 | 17 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1331` | `BuyCryptoService.getCryptoHistory` | -| 507 | 18 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:360` | `KycService.reviewRecommendationStep` | -| 504 | 16 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:224` | `TransactionRequestService.getOrThrow` | +| 507 | 18 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:358` | `KycService.reviewRecommendationStep` | +| 504 | 16 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:218` | `TransactionRequestService.getOrThrow` | | 499 | 14 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:169` | `BankTxReturnService.getPendingTx` | -| 497 | 16 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1321` | `BuyCryptoService.getBuyHistory` | -| 497 | 15 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:86` | `BankTxReturnService.setFiatAmounts` | | 497 | 18 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:128` | `BuyFiatNotificationService.pendingBuyFiat` | +| 497 | 15 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:86` | `BankTxReturnService.setFiatAmounts` | | 493 | 19 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:366` | `SupportIssueService.createIssueInternal` | | 490 | 20 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:587` | `PaymentLinkService.getPublicPaymentLinkByUniqueId` | -| 490 | 15 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:474` | `BuyFiatService.resetAmlCheck` | -| 487 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:958` | `BuyCryptoService.getBuyCryptoByTransactionId` | -| 487 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:962` | `BuyCryptoService.getBuyCrypto` | -| 487 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:966` | `BuyCryptoService.updateVolumes` | +| 490 | 15 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:473` | `BuyFiatService.resetAmlCheck` | +| 487 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:745` | `BuyCryptoService.getBuyCryptoByTransactionId` | +| 487 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:749` | `BuyCryptoService.getBuyCrypto` | +| 487 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:753` | `BuyCryptoService.updateVolumes` | | 484 | 15 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward-notification.service.ts:27` | `RefRewardNotificationService.refRewardPayouts` | -| 474 | 14 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:200` | `KycService.reviewIdentSteps` | +| 474 | 14 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:198` | `KycService.reviewIdentSteps` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:274` | `RecommendationService.getAllRecommendationForUserData` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:318` | `RecommendationService.getRecommendationsByKycStepIdsOrUserDataId` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:326` | `RecommendationService.getRecommendationsByKycStepIdsOrUserDataId` | @@ -147,15 +145,12 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 472 | 19 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:25` | `DepositRouteService.getById` | | 472 | 19 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:29` | `DepositRouteService.getLatest` | | 472 | 19 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:75` | `DepositRouteService.getPaymentRoutesForPublicName` | -| 470 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:637` | `BuyFiatService.getSellHistory` | | 470 | 16 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:119` | `AccountMergeService.executeMerge` | -| 458 | 14 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return-notification.service.ts:31` | `BankTxReturnNotificationService.chargebackInitiated` | -| 454 | 16 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request-notification.service.ts:35` | `LimitRequestNotificationService.limitRequestAcceptedManual` | +| 458 | 14 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return-notification.service.ts:27` | `BankTxReturnNotificationService.chargebackInitiated` | +| 454 | 16 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request-notification.service.ts:31` | `LimitRequestNotificationService.limitRequestAcceptedManual` | | 451 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:42` | `BuyFiatNotificationService.paymentCompleted` | | 451 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:290` | `BuyFiatNotificationService.chargebackUnconfirmed` | | 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:457` | `SupportIssueService.closeIssue` | -| 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:661` | `SupportIssueService.getIssues` | -| 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:670` | `SupportIssueService.getIssue` | | 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:737` | `SupportIssueService.getUserIssues` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/accounting/services/ledger-cutover.service.ts:475` | `LedgerCutoverService.openBuyCryptoReceived` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/accounting/services/ledger-cutover.service.ts:536` | `LedgerCutoverService.openBuyCryptoOwed` | @@ -168,157 +163,156 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 438 | 13 | find | `BankTxReturn` | `subdomains/core/accounting/services/ledger-cutover.service.ts:610` | `LedgerCutoverService.openBankTxReturn` | | 438 | 13 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:54` | `BankTxReturnService.chargebackTx` | | 438 | 13 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:137` | `BankTxReturnService.update` | -| 434 | 15 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request.service.ts:120` | `LimitRequestService.getUserLimitRequests` | +| 434 | 15 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request.service.ts:61` | `LimitRequestService.updateLimitRequest` | +| 434 | 15 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request.service.ts:82` | `LimitRequestService.getUserLimitRequests` | | 433 | 12 | find | `BankTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:106` | `BankTxConsumer.processForward` | | 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:478` | `SupportIssueService.closeIssue` | | 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:723` | `SupportIssueService.getIssueMessages` | -| 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:73` | `TransactionRequestService.txRequestWaitingExpiryCheck` | -| 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:84` | `TransactionRequestService.deleteOldTxRequests` | +| 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:67` | `TransactionRequestService.txRequestWaitingExpiryCheck` | +| 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:78` | `TransactionRequestService.deleteOldTxRequests` | +| 422 | 12 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:778` | `BuyCryptoService.resetAmlCheck` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:214` | `SupportEscalationService.checkEscalations` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:484` | `SupportIssueService.updateIssue` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:713` | `SupportIssueService.getIssueMessages` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:750` | `SupportIssueService.getIssueUserDataId` | -| 419 | 14 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1596` | `BuyCryptoService.getTransactions` | +| 419 | 14 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1186` | `BuyCryptoService.getTransactions` | | 418 | 18 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:80` | `CustodyJobService.executeStep` | | 418 | 11 | find | `User` | `subdomains/generic/user/models/user/user-job.service.ts:19` | `UserJobService.approveUser` | -| 415 | 16 | find | `Buy` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1434` | `BuyCryptoService.getBuy` | -| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:400` | `UserService.updateUserV1` | -| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:454` | `UserService.updateUserData` | +| 415 | 16 | find | `Buy` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1024` | `BuyCryptoService.getBuy` | +| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:396` | `UserService.updateUserV1` | +| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:450` | `UserService.updateUserData` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:69` | `BuyCryptoRegistrationService.filterBuyCryptoPayIns` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:260` | `SwapService.updateSwap` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:268` | `SwapService.confirmSwap` | -| 392 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:645` | `BuyFiatService.getPendingTransactions` | -| 386 | 11 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:446` | `UserService.updateUserName` | +| 392 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:639` | `BuyFiatService.getPendingTransactions` | +| 386 | 11 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:442` | `UserService.updateUserName` | | 385 | 15 | find | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:139` | `BuyFiatRegistrationService.createBuyFiatsAndAckPayIns` | | 385 | 14 | find | `KycStep` | `subdomains/generic/kyc/services/kyc-admin.service.ts:45` | `KycAdminService.updateKycStep` | -| 384 | 14 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:226` | `BuyService.getByBankUsage` | -| 384 | 13 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:353` | `UserDataService.updateUserData` | +| 384 | 14 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:223` | `BuyService.getByBankUsage` | +| 384 | 13 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:344` | `UserDataService.updateUserData` | | 377 | 14 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:80` | `SellService.get` | | 377 | 12 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:118` | `BankTxReturnService.create` | | 377 | 12 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:148` | `BankTxReturnService.updateInternal` | | 377 | 12 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:158` | `BankTxReturnService.getBankTxReturn` | | 377 | 12 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:162` | `BankTxReturnService.getBankTxReturnsByIban` | | 374 | 14 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:287` | `SellService.confirmSell` | -| 370 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:325` | `BankTxService.fillBankTx` | +| 370 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:268` | `BankTxService.fillBankTx` | | 370 | 9 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:554` | `FiatOutputJobService.searchOutgoingBankTx` | -| 364 | 13 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:165` | `BuyService.createBuy` | +| 364 | 13 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:162` | `BuyService.createBuy` | | 364 | 12 | find | `Webhook` | `subdomains/generic/user/services/webhook/webhook-notification.service.ts:32` | `WebhookNotificationService.sendOpenWebhooks` | | 364 | 12 | find | `Webhook` | `subdomains/generic/user/services/webhook/webhook.service.ts:146` | `WebhookService.createAndSendWebhook` | | 363 | 10 | find | `BuyCrypto` | `subdomains/core/accounting/services/consumers/payout-order.consumer.ts:297` | `PayoutOrderConsumer.owedCompletionChf` | -| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:726` | `BuyCryptoPreparationService.checkAggregatingTransactions` | -| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:167` | `BuyCryptoService.checkAmlResetTx` | -| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:183` | `BuyCryptoService.createFromBankTx` | -| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:238` | `BuyCryptoService.createFromCheckoutTx` | -| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1217` | `BuyCryptoService.manualPassAmlCheck` | -| 363 | 10 | find | `BuyCrypto` | `subdomains/supporting/fiat-output/fiat-output.service.ts:109` | `FiatOutputService.create` | +| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:554` | `BuyCryptoPreparationService.checkAggregatingTransactions` | +| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:134` | `BuyCryptoService.checkAmlResetTx` | +| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:148` | `BuyCryptoService.createFromBankTx` | +| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:203` | `BuyCryptoService.createFromCheckoutTx` | +| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:833` | `BuyCryptoService.manualPassAmlCheck` | | 363 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data-job.service.ts:25` | `UserDataJobService.bankTxVerification` | -| 362 | 11 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:476` | `TransactionService.getByAssetId` | +| 363 | 10 | find | `BuyCrypto` | `subdomains/supporting/fiat-output/fiat-output.service.ts:108` | `FiatOutputService.create` | +| 362 | 11 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:375` | `TransactionService.getByAssetId` | | 358 | 16 | find | `CryptoStaking` | `subdomains/core/staking/services/staking.service.ts:48` | `StakingService.getUserInvests` | -| 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:761` | `BankTxService.getUnassignedBankTx` | -| 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:771` | `BankTxService.getBankTxsByVirtualIban` | +| 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:624` | `BankTxService.getUnassignedBankTx` | +| 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:634` | `BankTxService.getBankTxsByVirtualIban` | | 354 | 13 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:117` | `SellService.getSellsByIban` | | 351 | 11 | find | `UserData` | `subdomains/generic/user/models/kyc/kyc.service.ts:127` | `KycService.getUserByKycCode` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:267` | `UserService.getUserDtoV2` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:413` | `UserService.updateUser` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:425` | `UserService.updateUserMail` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:435` | `UserService.verifyMail` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:509` | `UserService.updateAddress` | -| 344 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:524` | `UserService.deactivateUser` | -| 343 | 10 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts:92` | `FiatPayInSyncService.createCheckoutTx` | -| 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1112` | `UserDataService.updateApiFilter` | -| 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1122` | `UserDataService.checkApiKey` | -| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:176` | `VirtualIbanService.getByIdForUser` | -| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1384` | `VirtualIbanService.getActiveForBuyAndCurrency` | -| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1396` | `VirtualIbanService.getByIban` | -| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1438` | `VirtualIbanService.getVirtualIbansForAccount` | -| 329 | 9 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:284` | `TransactionRequestService.findAndComplete` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:266` | `UserService.getUserDtoV2` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:409` | `UserService.updateUser` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:421` | `UserService.updateUserMail` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:431` | `UserService.verifyMail` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:487` | `UserService.updateAddress` | +| 344 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:502` | `UserService.deactivateUser` | +| 343 | 10 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts:87` | `FiatPayInSyncService.createCheckoutTx` | +| 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1074` | `UserDataService.updateApiFilter` | +| 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1084` | `UserDataService.checkApiKey` | +| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:169` | `VirtualIbanService.getByIdForUser` | +| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1262` | `VirtualIbanService.getActiveForBuyAndCurrency` | +| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1274` | `VirtualIbanService.getByIban` | +| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1316` | `VirtualIbanService.getVirtualIbansForAccount` | +| 329 | 9 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:278` | `TransactionRequestService.findAndComplete` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/auth/auth.controller.ts:157` | `AuthController.createAccessTokenAfterMerge` | | 328 | 10 | find | `Wallet` | `subdomains/generic/user/models/kyc/kyc.service.ts:138` | `KycService.getAllKycData` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:147` | `KycService.getKycFiles` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:162` | `KycService.getKycFile` | -| 328 | 10 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:130` | `UserService.getUserDto` | -| 323 | 10 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1247` | `RealUnitService.forwardRegistrationToAktionariat` | -| 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:363` | `BuyFiatService.getBuyFiatByTransactionId` | -| 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:368` | `BuyFiatService.getBuyFiatsByTransactionIds` | +| 328 | 10 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:129` | `UserService.getUserDto` | +| 323 | 10 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1246` | `RealUnitService.forwardRegistrationToAktionariat` | +| 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:362` | `BuyFiatService.getBuyFiatByTransactionId` | +| 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:367` | `BuyFiatService.getBuyFiatsByTransactionIds` | | 319 | 10 | find | `BuyFiat` | `subdomains/core/accounting/services/ledger-cutover.service.ts:308` | `LedgerCutoverService.openBuyFiatReceived` | | 319 | 10 | find | `BuyFiat` | `subdomains/core/accounting/services/ledger-cutover.service.ts:357` | `LedgerCutoverService.openBuyFiatOwed` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:61` | `KycService.transferKycData` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:77` | `KycService.transferKycData` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/staff-kyc-clearance.service.ts:61` | `StaffKycClearanceService.syncStaffKycClearance` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:81` | `UserService.getAllUser` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:85` | `UserService.getUser` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:89` | `UserService.getAllUserDataUsers` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:94` | `UserService.getUsersByUserDataIds` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:123` | `UserService.getUsersByIp` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:205` | `UserService.getRefUser` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:210` | `UserService.getRefUsersByRefs` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:216` | `UserService.getUsersByUsedRefs` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:468` | `UserService.updateUserAdmin` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:488` | `UserService.updateUserInternal` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:591` | `UserService.updateUserDataVolume` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:758` | `UserService.checkApiKey` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:767` | `UserService.updateApiFilter` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:80` | `UserService.getAllUser` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:84` | `UserService.getUser` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:88` | `UserService.getAllUserDataUsers` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:93` | `UserService.getUsersByUserDataIds` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:122` | `UserService.getUsersByIp` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:204` | `UserService.getRefUser` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:209` | `UserService.getRefUsersByRefs` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:215` | `UserService.getUsersByUsedRefs` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:464` | `UserService.updateUserAdmin` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:569` | `UserService.updateUserDataVolume` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:736` | `UserService.checkApiKey` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:745` | `UserService.updateApiFilter` | | 301 | 6 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:89` | `BankTxRepeatService.getAllUserRepeats` | | 299 | 12 | find | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:65` | `TradingOrderService.startNewOrders` | | 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:113` | `CustodyJobService.onStepComplete` | -| 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:290` | `CustodyOrderService.getCustodyOrderByTx` | -| 287 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:767` | `BuyFiatService.getTransactions` | -| 284 | 10 | find | `UserData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:177` | `BankDataService.addBankData` | +| 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:284` | `CustodyOrderService.getCustodyOrderByTx` | +| 287 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:746` | `BuyFiatService.getTransactions` | +| 284 | 10 | find | `UserData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:173` | `BankDataService.addBankData` | | 276 | 10 | find | `NameCheckLog` | `subdomains/generic/kyc/services/name-check.service.ts:205` | `NameCheckService.createNameCheckLog` | -| 274 | 10 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:63` | `BankDataService.checkUnverifiedBankDatas` | +| 274 | 10 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:59` | `BankDataService.checkUnverifiedBankDatas` | | 266 | 9 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data-job.service.ts:39` | `UserDataJobService.setAccountOpener` | | 264 | 9 | find | `KycFile` | `subdomains/generic/kyc/services/kyc-file.service.ts:27` | `KycFileService.getKycFile` | | 264 | 9 | find | `KycFile` | `subdomains/generic/kyc/services/kyc-file.service.ts:34` | `KycFileService.getUserDataKycFiles` | | 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc-notification.service.ts:35` | `KycNotificationService.autoKycStepReminder` | -| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:139` | `KycService.checkIdentSteps` | -| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:176` | `KycService.reviewNationalityStep` | -| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:310` | `KycService.reviewFinancialData` | -| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1858` | `KycService.getUserByTransactionOrThrow` | +| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:137` | `KycService.checkIdentSteps` | +| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:174` | `KycService.reviewNationalityStep` | +| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:308` | `KycService.reviewFinancialData` | +| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1850` | `KycService.getUserByTransactionOrThrow` | | 261 | 8 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-dex.service.ts:30` | `BuyCryptoDexService.secureLiquidity` | | 261 | 8 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-dex.service.ts:35` | `BuyCryptoDexService.secureLiquidity` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:96` | `BankDataService.verifyBankData` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:283` | `BankDataService.getBankData` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:303` | `BankDataService.getBankDatasByIban` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:312` | `BankDataService.getBankDatasByUserData` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:321` | `BankDataService.getApprovedAlternatives` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:355` | `BankDataService.getValidBankDatasForUser` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:373` | `BankDataService.getIdentBankDataForUser` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:380` | `BankDataService.updateUserBankData` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:403` | `BankDataService.updateUserBankData` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:441` | `BankDataService.createIbanForUserInternal` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:511` | `BankDataService.getPendingReviewList` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:417` | `BankDataService.createIbanForUser` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:92` | `BankDataService.verifyBankData` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:279` | `BankDataService.getBankData` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:299` | `BankDataService.getBankDatasByIban` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:308` | `BankDataService.getBankDatasByUserData` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:317` | `BankDataService.getApprovedAlternatives` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:351` | `BankDataService.getValidBankDatasForUser` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:369` | `BankDataService.getIdentBankDataForUser` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:376` | `BankDataService.updateUserBankData` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:399` | `BankDataService.updateUserBankData` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:437` | `BankDataService.createIbanForUserInternal` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:507` | `BankDataService.getPendingReviewList` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:413` | `BankDataService.createIbanForUser` | | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/organization/organization.service.ts:26` | `OrganizationService.syncOrganization` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/jwt-revocation-sync.service.ts:37` | `JwtRevocationSyncService.syncDeniedJwtAccounts` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts:56` | `UserDataController.getAllUserData` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts:106` | `UserDataController.getUserData` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:165` | `UserDataService.getUserData` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:166` | `UserDataService.getUserData` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:178` | `UserDataService.getUserDataByIds` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:197` | `UserDataService.getByKycHashOrThrow` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:223` | `UserDataService.getDifferentUserWithSameIdentDoc` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:236` | `UserDataService.getUsersByMail` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:248` | `UserDataService.getUserDataByBirthday` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:288` | `UserDataService.getUsersByName` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:292` | `UserDataService.getUsersByPhone` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:296` | `UserDataService.getUserDatasWithKycFile` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:644` | `UserDataService.assignNextKycFileId` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1093` | `UserDataService.createApiKey` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1166` | `UserDataService.loadRelationsAndVerify` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1177` | `UserDataService.loadRelationsAndVerify` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1184` | `UserDataService.loadRelationsAndVerify` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1821` | `UserDataService.getByPhoneCallStatuses` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:320` | `UserService.getUserProfile` | -| 247 | 6 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:451` | `BankTxService.reset` | -| 247 | 9 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:576` | `BuyFiatService.updateVolumes` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/jwt-revocation-sync.service.ts:30` | `JwtRevocationSyncService.syncDeniedJwtAccounts` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts:54` | `UserDataController.getAllUserData` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts:91` | `UserDataController.getUserData` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:158` | `UserDataService.getUserData` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:159` | `UserDataService.getUserData` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:171` | `UserDataService.getUserDataByIds` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:190` | `UserDataService.getByKycHashOrThrow` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:216` | `UserDataService.getDifferentUserWithSameIdentDoc` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:229` | `UserDataService.getUsersByMail` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:241` | `UserDataService.getUserDataByBirthday` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:281` | `UserDataService.getUsersByName` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:285` | `UserDataService.getUsersByPhone` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:289` | `UserDataService.getUserDatasWithKycFile` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:606` | `UserDataService.assignNextKycFileId` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1055` | `UserDataService.createApiKey` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1128` | `UserDataService.loadRelationsAndVerify` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1139` | `UserDataService.loadRelationsAndVerify` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1146` | `UserDataService.loadRelationsAndVerify` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1783` | `UserDataService.getByPhoneCallStatuses` | +| 247 | 9 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:575` | `BuyFiatService.updateVolumes` | +| 247 | 6 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:384` | `BankTxService.reset` | | 246 | 9 | find | `CustodyAccountAccess` | `subdomains/core/custody/services/custody-account.service.ts:61` | `CustodyAccountService.getCustodyAccountsForUser` | | 245 | 8 | find | `NameCheckLog` | `subdomains/generic/kyc/services/name-check.service.ts:40` | `NameCheckService.updateLog` | | 245 | 8 | find | `NameCheckLog` | `subdomains/generic/kyc/services/name-check.service.ts:169` | `NameCheckService.closeAndRefreshRiskStatus` | | 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc-admin.service.ts:144` | `KycAdminService.triggerWebhook` | -| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1501` | `KycService.getKycStepById` | -| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1904` | `KycService.syncIdentFiles` | -| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1975` | `KycService.getDfxApprovalSteps` | -| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:2003` | `KycService.getPendingReviewSteps` | +| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1493` | `KycService.getKycStepById` | +| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1896` | `KycService.syncIdentFiles` | +| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1967` | `KycService.getDfxApprovalSteps` | +| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1995` | `KycService.getPendingReviewSteps` | | 243 | 8 | find | `Mros` | `subdomains/supporting/mros/mros.service.ts:50` | `MrosService.getAll` | | 243 | 8 | find | `Mros` | `subdomains/supporting/mros/mros.service.ts:54` | `MrosService.getById` | | 240 | 5 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:23` | `BankTxRepeatService.create` | @@ -337,25 +331,25 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 229 | 11 | find | `CryptoInput` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:527` | `DashboardReconciliationService.getCryptoInputs` | | 226 | 7 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:94` | `CustodyJobService.checkStep` | | 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:52` | `CustodyJobService.resetExpiredConfirmedOrders` | -| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:325` | `CustodyOrderService.approveOrder` | +| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:319` | `CustodyOrderService.approveOrder` | | 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:257` | `CustodyService.getUserCustodyHistory` | | 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:470` | `CustodyService.calculateAccruedInterest` | | 201 | 5 | find | `BuyFiat` | `subdomains/core/accounting/services/consumers/payout-order.consumer.ts:293` | `PayoutOrderConsumer.owedCompletionChf` | | 201 | 8 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward-dex.service.ts:36` | `RefRewardDexService.secureLiquidity` | -| 201 | 5 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:109` | `BuyFiatService.checkAmlResetTx` | -| 201 | 5 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:549` | `BuyFiatService.manualPassAmlCheck` | -| 201 | 5 | find | `BuyFiat` | `subdomains/supporting/fiat-output/fiat-output.service.ts:99` | `FiatOutputService.create` | +| 201 | 5 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:108` | `BuyFiatService.checkAmlResetTx` | +| 201 | 5 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:548` | `BuyFiatService.manualPassAmlCheck` | +| 201 | 5 | find | `BuyFiat` | `subdomains/supporting/fiat-output/fiat-output.service.ts:98` | `FiatOutputService.create` | | 195 | 8 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:612` | `PaymentLinkService.deletePaymentLink` | -| 182 | 5 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:232` | `FiatOutputService.delete` | +| 182 | 5 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:222` | `FiatOutputService.delete` | | 179 | 4 | find | `BankTxRepeat` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:553` | `BankTxConsumer.openingBankTxId` | | 179 | 4 | find | `BankTxRepeat` | `subdomains/core/accounting/services/ledger-cutover.service.ts:641` | `LedgerCutoverService.openBankTxRepeat` | | 176 | 5 | find | `TradingOrder` | `subdomains/core/accounting/services/consumers/trading-order.consumer.ts:84` | `TradingOrderConsumer.processForward` | | 176 | 5 | find | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:164` | `TradingOrderService.checkRunningOrders` | | 176 | 5 | find | `TradingOrder` | `subdomains/core/trading/services/trading-rule.service.ts:42` | `TradingRuleService.getCurrentTradingOrders` | +| 174 | 13 | find | `Route` | `subdomains/core/route/route.service.ts:19` | `RouteService.updateRoute` | | 174 | 4 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:51` | `RecallService.update` | | 174 | 4 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:63` | `RecallService.getAll` | | 174 | 4 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:75` | `RecallService.getById` | -| 174 | 13 | find | `Route` | `subdomains/core/route/route.service.ts:19` | `RouteService.updateRoute` | | 164 | 9 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:41` | `DepositRouteService.getByLabel` | | 159 | 3 | find | `Transaction` | `subdomains/supporting/payment/services/transaction-notification.service.ts:114` | `TransactionNotificationService.txUnassigned` | | 156 | 4 | find | `LiquidityOrder` | `subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts:104` | `LiquidityOrderDexConsumer.processForward` | @@ -375,16 +369,16 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex-spark.service.ts:28` | `DexSparkService.getPendingAmount` | | 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex-tron.service.ts:62` | `DexTronService.getPendingAmount` | | 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex-zano.service.ts:58` | `DexZanoService.getPendingAmount` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:163` | `DexService.fetchLiquidityTransactionResult` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:183` | `DexService.checkOrderReady` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:200` | `DexService.checkOrderCompletion` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:212` | `DexService.completeOrders` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:223` | `DexService.cancelOrders` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:235` | `DexService.hasOrder` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:240` | `DexService.getPendingOrders` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:334` | `DexService.finalizePurchaseOrders` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:346` | `DexService.alertStrandedPurchaseOrders` | -| 150 | 10 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:60` | `LiquidityManagementService.getPipelineWithOrders` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:162` | `DexService.fetchLiquidityTransactionResult` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:182` | `DexService.checkOrderReady` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:199` | `DexService.checkOrderCompletion` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:211` | `DexService.completeOrders` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:222` | `DexService.cancelOrders` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:234` | `DexService.hasOrder` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:239` | `DexService.getPendingOrders` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:329` | `DexService.finalizePurchaseOrders` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:341` | `DexService.alertStrandedPurchaseOrders` | +| 150 | 10 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:56` | `LiquidityManagementService.getPipelineWithOrders` | | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:106` | `SwapService.updateVolume` | | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:140` | `SwapService.getSwapWithoutRoute` | | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:145` | `SwapService.get` | @@ -392,33 +386,33 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:182` | `SwapService.getSwapsByUserDataId` | | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:214` | `SwapService.createSwap` | | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:253` | `SwapService.getUserSwaps` | -| 144 | 6 | find | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:685` | `BuyFiatService.getSell` | -| 143 | 4 | find | `Fee` | `subdomains/supporting/payment/services/fee.service.ts:346` | `FeeService.getAllFees` | +| 144 | 6 | find | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:664` | `BuyFiatService.getSell` | +| 143 | 4 | find | `Fee` | `subdomains/supporting/payment/services/fee.service.ts:342` | `FeeService.getAllFees` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts:89` | `LiquidityMgmtConsumer.processForward` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/adapters/actions/liquidity-pipeline.adapter.ts:62` | `LiquidityPipelineAdapter.buy` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/adapters/actions/liquidity-pipeline.adapter.ts:98` | `LiquidityPipelineAdapter.checkBuyCompletion` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:127` | `LiquidityManagementPipelineService.getProcessingOrders` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:138` | `LiquidityManagementPipelineService.getPendingTx` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:186` | `LiquidityManagementPipelineService.checkRunningPipelines` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:248` | `LiquidityManagementPipelineService.startNewOrders` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:357` | `LiquidityManagementPipelineService.resolveUncertainOrders` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:669` | `LiquidityManagementPipelineService.blockConfirmedOrder` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:837` | `LiquidityManagementPipelineService.resolveUncertainOrderManually` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:890` | `LiquidityManagementPipelineService.checkRunningOrders` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:122` | `LiquidityManagementPipelineService.getProcessingOrders` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:133` | `LiquidityManagementPipelineService.getPendingTx` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:181` | `LiquidityManagementPipelineService.checkRunningPipelines` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:243` | `LiquidityManagementPipelineService.startNewOrders` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:334` | `LiquidityManagementPipelineService.resolveUncertainOrders` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:506` | `LiquidityManagementPipelineService.blockConfirmedOrder` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:649` | `LiquidityManagementPipelineService.resolveUncertainOrderManually` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:702` | `LiquidityManagementPipelineService.checkRunningOrders` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:510` | `DashboardReconciliationService.getLmOrders` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:87` | `BuyService.updateVolume` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:123` | `BuyService.getAllBankUsages` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:134` | `BuyService.get` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:207` | `BuyService.getBuyWithoutRoute` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:211` | `BuyService.getUserBuys` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:215` | `BuyService.getUserDataBuys` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:253` | `BuyService.getAllUserBuys` | +| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:84` | `BuyService.updateVolume` | +| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:120` | `BuyService.getAllBankUsages` | +| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:131` | `BuyService.get` | +| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:204` | `BuyService.getBuyWithoutRoute` | +| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:208` | `BuyService.getUserBuys` | +| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:212` | `BuyService.getUserDataBuys` | +| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:250` | `BuyService.getAllUserBuys` | | 131 | 3 | find | `StakingRefReward` | `subdomains/core/staking/services/staking.service.ts:37` | `StakingService.getUserStakingRefRewards` | -| 130 | 9 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:178` | `LiquidityManagementPipelineService.checkRunningPipelines` | -| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:229` | `BankTxService.assignTransactions` | -| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:484` | `BankTxService.getBankTxByTransactionId` | -| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:489` | `BankTxService.getBankTxsByTransactionIds` | -| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:815` | `BankTxService.getBankTxsByName` | +| 130 | 9 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:173` | `LiquidityManagementPipelineService.checkRunningPipelines` | +| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:223` | `BankTxService.assignTransactions` | +| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:417` | `BankTxService.getBankTxByTransactionId` | +| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:422` | `BankTxService.getBankTxsByTransactionIds` | +| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:678` | `BankTxService.getBankTxsByName` | | 124 | 5 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:123` | `SellService.getUserSells` | | 124 | 5 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:136` | `SellService.getSellsByUserDataId` | | 124 | 5 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:167` | `SellService.createSell` | @@ -432,13 +426,12 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:91` | `PayoutService.getRecentPayoutSentCorrelationIds` | | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:117` | `PayoutService.speedupTransaction` | | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:140` | `PayoutService.retryUncertainPayout` | -| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:197` | `PayoutService.logUncertainOrdersSnapshot` | -| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:210` | `PayoutService.getLatestOrderDate` | -| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:223` | `PayoutService.checkPreparationCompletion` | -| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:243` | `PayoutService.checkPayoutCompletion` | -| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:266` | `PayoutService.prepareNewOrders` | -| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:283` | `PayoutService.payoutOrders` | -| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:300` | `PayoutService.processFailedOrders` | +| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:193` | `PayoutService.getLatestOrderDate` | +| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:206` | `PayoutService.checkPreparationCompletion` | +| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:226` | `PayoutService.checkPayoutCompletion` | +| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:249` | `PayoutService.prepareNewOrders` | +| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:266` | `PayoutService.payoutOrders` | +| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:283` | `PayoutService.processFailedOrders` | | 118 | 3 | find | `CustodyBalance` | `subdomains/core/custody/services/custody-pdf.service.ts:47` | `CustodyPdfService.getBalancesWithHistoricalPrices` | | 118 | 3 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:111` | `CustodyService.getUserCustodyBalance` | | 118 | 3 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:238` | `CustodyService.updateCustodyBalance` | @@ -446,91 +439,73 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:62` | `BankTxRepeatService.update` | | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:104` | `BankTxRepeatService.getBankTxRepeat` | | 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/adapters/actions/liquidity-pipeline.adapter.ts:86` | `LiquidityPipelineAdapter.checkBuyCompletion` | -| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:115` | `LiquidityManagementPipelineService.getProcessingPipelines` | -| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:121` | `LiquidityManagementPipelineService.getStoppedPipelines` | -| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:150` | `LiquidityManagementPipelineService.getPipelineStatus` | -| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:160` | `LiquidityManagementPipelineService.startNewPipelines` | -| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:200` | `LiquidityManagementService.findRunningPipeline` | -| 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:263` | `TransactionRequestService.getTransactionRequestByUid` | -| 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:267` | `TransactionRequestService.getOpenBuyQuotes` | -| 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:430` | `TransactionRequestService.getByAssetId` | -| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/bank/bank.service.ts:216` | `BankService.getReceiveIbanStatus` | -| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:137` | `VirtualIbanService.getActiveReceivingForUserAndCurrency` | -| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:162` | `VirtualIbanService.getActiveSendingCandidatesForUserAndCurrency` | -| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:710` | `VirtualIbanService.isIbanProtectedFromReconciliationDeactivation` | -| 99 | 0 | query-builder (alias only) | `UserData` | `subdomains/generic/user/models/user-data/user-data-notification.service.ts:173` | `UserDataNotificationService.blackSquadInvitation` | -| 99 | 0 | query-builder (no select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:148` | `UserDataService.getUserDataByUser` | -| 99 | 0 | query-builder (alias only) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:320` | `UserDataService.getUserDataByKey` | -| 99 | 0 | query-builder (alias only) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:186` | `UserDataService.getUserDataIdsByServiceProvider` | -| 99 | 0 | query-builder (alias only) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1812` | `UserDataService.getMaxKycFileIdByDateRange` | -| 99 | 0 | query-builder (no select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1804` | `UserDataService.countByDateRange` | -| 98 | 2 | find | `User` | `subdomains/generic/user/models/user-data/user-data.service.ts:1071` | `UserDataService.customIdentMethod` | -| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:278` | `UserService.getRefDtoV2` | -| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:286` | `UserService.updateRef` | +| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:110` | `LiquidityManagementPipelineService.getProcessingPipelines` | +| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:116` | `LiquidityManagementPipelineService.getStoppedPipelines` | +| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:145` | `LiquidityManagementPipelineService.getPipelineStatus` | +| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:155` | `LiquidityManagementPipelineService.startNewPipelines` | +| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:196` | `LiquidityManagementService.findRunningPipeline` | +| 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:257` | `TransactionRequestService.getTransactionRequestByUid` | +| 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:261` | `TransactionRequestService.getOpenBuyQuotes` | +| 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:424` | `TransactionRequestService.getByAssetId` | +| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/bank/bank.service.ts:181` | `BankService.getReceiveIbanStatus` | +| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:130` | `VirtualIbanService.getActiveReceivingForUserAndCurrency` | +| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:155` | `VirtualIbanService.getActiveSendingCandidatesForUserAndCurrency` | +| 99 | 0 | query-builder (nur-alias) | `UserData` | `subdomains/generic/user/models/user-data/user-data-notification.service.ts:173` | `UserDataNotificationService.blackSquadInvitation` | +| 99 | 0 | query-builder (ohne-select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:141` | `UserDataService.getUserDataByUser` | +| 99 | 0 | query-builder (nur-alias) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:313` | `UserDataService.getUserDataByKey` | +| 99 | 0 | query-builder (ohne-select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1766` | `UserDataService.countByDateRange` | +| 98 | 2 | find | `User` | `subdomains/generic/user/models/user-data/user-data.service.ts:1033` | `UserDataService.customIdentMethod` | +| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:277` | `UserService.getRefDtoV2` | +| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:285` | `UserService.updateRef` | | 98 | 2 | find | `User` | `subdomains/generic/user/services/webhook/webhook.service.ts:107` | `WebhookService.sendWebhooks` | | 98 | 2 | find | `User` | `subdomains/generic/user/services/webhook/webhook.service.ts:176` | `WebhookService.getUsers` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:239` | `TransactionService.getTransactionById` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:244` | `TransactionService.getTransactionsByIds` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:252` | `TransactionService.getTransactionByUid` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:259` | `TransactionService.getTransactionByRequestId` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:266` | `TransactionService.getTransactionByRequestUid` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:274` | `TransactionService.getTransactionByExternalId` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:278` | `TransactionService.getTransactionByCkoId` | -| 93 | 2 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1282` | `RealUnitService.findRegistration` | -| 93 | 2 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1297` | `RealUnitService.findRegistration` | -| 91 | 4 | find | `PaymentActivation` | `subdomains/core/payment-link/services/payment-activation.service.ts:67` | `PaymentActivationService.getActivationByTxId` | -| 91 | 4 | find | `PaymentActivation` | `subdomains/core/payment-link/services/payment-activation.service.ts:134` | `PaymentActivationService.getExistingActivations` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:146` | `TransactionService.getTransactionById` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:151` | `TransactionService.getTransactionsByIds` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:159` | `TransactionService.getTransactionByUid` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:166` | `TransactionService.getTransactionByRequestId` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:173` | `TransactionService.getTransactionByRequestUid` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:181` | `TransactionService.getTransactionByExternalId` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:185` | `TransactionService.getTransactionByCkoId` | +| 93 | 2 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1281` | `RealUnitService.findRegistration` | +| 93 | 2 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1296` | `RealUnitService.findRegistration` | +| 91 | 4 | find | `PaymentActivation` | `subdomains/core/payment-link/services/payment-activation.service.ts:64` | `PaymentActivationService.getActivationByTxId` | +| 91 | 4 | find | `PaymentActivation` | `subdomains/core/payment-link/services/payment-activation.service.ts:131` | `PaymentActivationService.getExistingActivations` | | 87 | 2 | find | `TradingRule` | `subdomains/core/trading/services/trading-rule.service.ts:46` | `TradingRuleService.updateTradingRule` | | 87 | 2 | find | `TradingRule` | `subdomains/core/trading/services/trading-rule.service.ts:53` | `TradingRuleService.processRules` | | 87 | 2 | find | `TradingRule` | `subdomains/core/trading/services/trading-rule.service.ts:63` | `TradingRuleService.reactivateRules` | -| 87 | 2 | find | `RealUnitTransferRequest` | `subdomains/supporting/realunit/realunit.service.ts:3104` | `RealUnitService.confirmTransfer` | -| 87 | 2 | find | `RealUnitTransferRequest` | `subdomains/supporting/realunit/realunit.service.ts:3210` | `RealUnitService.reconcilePendingTransfers` | -| 86 | 2 | find | `Asset` | `shared/models/asset/asset.service.ts:90` | `AssetService.getAssetsByPriceRules` | +| 87 | 2 | find | `RealUnitTransferRequest` | `subdomains/supporting/realunit/realunit.service.ts:3103` | `RealUnitService.confirmTransfer` | +| 87 | 2 | find | `RealUnitTransferRequest` | `subdomains/supporting/realunit/realunit.service.ts:3209` | `RealUnitService.reconcilePendingTransfers` | +| 86 | 2 | find | `Asset` | `shared/models/asset/asset.service.ts:78` | `AssetService.getAssetsByPriceRules` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:56` | `LiquidityManagementRuleService.updateRule` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:64` | `LiquidityManagementRuleService.getRule` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:72` | `LiquidityManagementRuleService.deactivateRule` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:82` | `LiquidityManagementRuleService.reactivateRule` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:95` | `LiquidityManagementRuleService.updateRuleSettings` | -| 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:114` | `LiquidityManagementRuleService.reactivateRules` | -| 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:151` | `LiquidityManagementRuleService.findExistingRuleOnCreation` | -| 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:113` | `LiquidityManagementService.findRuleByAssetOrThrow` | -| 78 | 1 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:98` | `UserService.getUserByAddress` | +| 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:110` | `LiquidityManagementRuleService.reactivateRules` | +| 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:147` | `LiquidityManagementRuleService.findExistingRuleOnCreation` | +| 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:109` | `LiquidityManagementService.findRuleByAssetOrThrow` | +| 78 | 1 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:97` | `UserService.getUserByAddress` | | 78 | 3 | find | `Mros` | `subdomains/supporting/mros/mros.service.ts:32` | `MrosService.update` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:925` | `BuyCryptoService.getBuyCryptoByKeys` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1461` | `BuyCryptoService.updateBuyVolume` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1489` | `BuyCryptoService.updateCryptoRouteVolume` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1532` | `BuyCryptoService.getRefVolume` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1544` | `BuyCryptoService.getPartnerFeeRefVolume` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:980` | `BuyCryptoService.updateRefVolumes` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1267` | `BuyCryptoService.getUserVolumeForType` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1347` | `BuyCryptoService.getPendingLiquidityDemandChf` | -| 75 | 2 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:284` | `BuyCryptoBatchService.filterOutExistingBatches` | -| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:341` | `BuyFiatService.getBuyFiatByKey` | +| 77 | 0 | query-builder (nur-alias) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:712` | `BuyCryptoService.getBuyCryptoByKeys` | +| 75 | 2 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:238` | `BuyCryptoBatchService.filterOutExistingBatches` | +| 71 | 0 | query-builder (nur-alias) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:340` | `BuyFiatService.getBuyFiatByKey` | | 71 | 1 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:68` | `RecallService.getByBankTxIds` | -| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:699` | `BuyFiatService.updateSellVolume` | -| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:742` | `BuyFiatService.getRefVolume` | -| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:754` | `BuyFiatService.getPartnerFeeRefVolume` | -| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:587` | `BuyFiatService.updateRefVolumes` | -| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:604` | `BuyFiatService.getUserVolume` | | 68 | 4 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:204` | `SwapService.getById` | -| 65 | 2 | find | `Fee` | `subdomains/supporting/payment/services/fee.service.ts:119` | `FeeService.createFee` | -| 62 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:593` | `BankTxService.getTrackedInternalTransfers` | +| 65 | 2 | find | `Fee` | `subdomains/supporting/payment/services/fee.service.ts:115` | `FeeService.createFee` | | 61 | 0 | find | `BankTx` | `subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts:386` | `ExchangeTxConsumer.hasBankRouteMatch` | | 61 | 0 | find | `BankTx` | `subdomains/core/accounting/services/ledger-cutover.service.ts:710` | `LedgerCutoverService.openUnattributed` | | 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:46` | `BankTxRepeatService.update` | | 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:59` | `BankTxRepeatService.update` | | 61 | 0 | query-builder (alias only) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service.ts:36` | `BankTxOutgoingMatchService.getUniqueOutgoingBankTx` | -| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:159` | `BankTxService.enrichYapealTransactions` | -| 61 | 0 | query-builder (alias only) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:462` | `BankTxService.getBankTxByKey` | -| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:496` | `BankTxService.getBankTxById` | -| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:500` | `BankTxService.getPendingTx` | -| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:659` | `BankTxService.getRecentExchangeTx` | -| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:679` | `BankTxService.storeSepaFile` | +| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:153` | `BankTxService.enrichYapealTransactions` | +| 61 | 0 | query-builder (alias only) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:395` | `BankTxService.getBankTxByKey` | +| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:429` | `BankTxService.getBankTxById` | +| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:433` | `BankTxService.getPendingTx` | +| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:525` | `BankTxService.getRecentBankToBankTx` | +| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:532` | `BankTxService.getRecentExchangeTx` | +| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:552` | `BankTxService.storeSepaFile` | | 61 | 0 | find | `BankTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:464` | `DashboardReconciliationService.getBankFlows` | -| 61 | 0 | query-builder (alias only) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:528` | `BankTxService.getBankTxFee` | -| 61 | 0 | query-builder (alias only) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:572` | `BankTxService.getBankTxFee` | -| 61 | 0 | query-builder (alias only) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:516` | `BankTxService.getBankTxFee` | -| 59 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:97` | `PaymentQuoteService.getActualQuoteByUniqueId` | +| 59 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:94` | `PaymentQuoteService.getActualQuoteByUniqueId` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-frick.service.ts:37` | `FiatOutputFrickService.checkFrickOrderStatus` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-frick.service.ts:101` | `FiatOutputFrickService.transmitPayments` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:85` | `FiatOutputJobService.checkOlkypayOrderStatus` | @@ -538,48 +513,39 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:407` | `FiatOutputJobService.checkTransmission` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:432` | `FiatOutputJobService.transmitYapealPayments` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:495` | `FiatOutputJobService.transmitOlkypayPayments` | -| 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:610` | `FiatOutputJobService.getLastBatchId` | -| 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:645` | `FiatOutputJobService.notifyScryptDeposits` | -| 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:213` | `FiatOutputService.update` | -| 56 | 3 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:143` | `BuyService.getById` | -| 56 | 3 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:261` | `BuyService.updateBuy` | +| 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:603` | `FiatOutputJobService.getLastBatchId` | +| 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:638` | `FiatOutputJobService.notifyScryptDeposits` | +| 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:203` | `FiatOutputService.update` | +| 56 | 3 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:140` | `BuyService.getById` | +| 56 | 3 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:258` | `BuyService.updateBuy` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:89` | `LedgerQueryService.getAccounts` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:157` | `LedgerQueryService.getReconStatus` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:619` | `LedgerQueryService.unverifiedAccountIds` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:146` | `LedgerReconciliationService.reconcileAssets` | | 53 | 1 | find | `PriceRule` | `subdomains/supporting/pricing/services/pricing.service.ts:163` | `PricingService.updatePrices` | -| 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:155` | `PaymentLinkPaymentService.processExpiredPayments` | -| 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:387` | `PaymentLinkPaymentService.deliverToWaitingCallers` | -| 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:615` | `PaymentLinkPaymentService.expirePaymentIfPending` | +| 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:58` | `PaymentLinkPaymentService.processExpiredPayments` | +| 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:264` | `PaymentLinkPaymentService.expirePaymentIfPending` | | 50 | 2 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:172` | `PaymentLinkService.createInvoice` | -| 46 | 0 | query-builder (alias only) | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:241` | `FiatOutputService.getFiatOutputByKey` | | 46 | 1 | find | `Bank` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:839` | `BankTxConsumer.bankContext` | | 46 | 1 | find | `Bank` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:870` | `BankTxConsumer.currencyMarkAssetId` | | 46 | 1 | find | `Bank` | `subdomains/core/accounting/services/ledger-cutover.service.ts:750` | `LedgerCutoverService.bankMaps` | | 46 | 3 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:90` | `SellService.getById` | | 46 | 3 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:143` | `SellService.getSellWithoutRoute` | -| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:41` | `BankService.getAllBanks` | -| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:45` | `BankService.getBanksWithAsset` | -| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:49` | `BankService.getBanksByName` | -| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:257` | `BankService.loadIbanCache` | +| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:40` | `BankService.getAllBanks` | +| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:44` | `BankService.getBanksWithAsset` | +| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:48` | `BankService.getBanksByName` | +| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:222` | `BankService.loadIbanCache` | | 46 | 1 | find | `Asset` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:74` | `DashboardReconciliationService.getReconciliation` | | 46 | 1 | find | `Asset` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:146` | `DashboardReconciliationService.getOverview` | -| 46 | 3 | find | `Sell` | `subdomains/supporting/fiat-output/fiat-output.service.ts:158` | `FiatOutputService.createInternal` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:103` | `UserService.getUserByKey` | -| 45 | 0 | query-builder (no select) | `User` | `subdomains/generic/user/models/user/user.service.ts:178` | `UserService.getOpenRefCreditUser` | -| 45 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:127` | `PaymentQuoteService.getQuoteByAsset` | -| 45 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:146` | `PaymentQuoteService.getQuoteByTxId` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:227` | `UserService.countRefChildrenByUserDataIds` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:249` | `UserService.countRefReferrersByUserDataIds` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:141` | `UserService.getAllLinkedUsers` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:196` | `UserService.getOpenRefCreditEur` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:601` | `UserService.getUserVolumes` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:611` | `UserService.getUserVolumes` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:673` | `UserService.getRefInfo` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:686` | `UserService.getRefInfo` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:745` | `UserService.getTotalRefRewards` | +| 46 | 3 | find | `Sell` | `subdomains/supporting/fiat-output/fiat-output.service.ts:149` | `FiatOutputService.createInternal` | +| 46 | 0 | query-builder (alias only) | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:231` | `FiatOutputService.getFiatOutputByKey` | +| 45 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:124` | `PaymentQuoteService.getQuoteByAsset` | +| 45 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:143` | `PaymentQuoteService.getQuoteByTxId` | +| 45 | 0 | query-builder (nur-alias) | `User` | `subdomains/generic/user/models/user/user.service.ts:102` | `UserService.getUserByKey` | +| 45 | 0 | query-builder (ohne-select) | `User` | `subdomains/generic/user/models/user/user.service.ts:177` | `UserService.getOpenRefCreditUser` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:41` | `FaucetRequestService.checkFaucetRequests` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:93` | `FaucetRequestService.resetFaucet` | +| 41 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:99` | `UserDataRepository.getProfile` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:38` | `LiquidityManagementBalanceService.getAllLiqBalancesForAssets` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:68` | `LiquidityManagementBalanceService.refreshBankBalance` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:82` | `LiquidityManagementBalanceService.getBalances` | @@ -587,47 +553,41 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 40 | 1 | find | `AssetPrice` | `subdomains/supporting/pricing/services/asset-prices.service.ts:12` | `AssetPricesService.getAssetPrices` | | 40 | 1 | find | `AssetPrice` | `subdomains/supporting/pricing/services/asset-prices.service.ts:38` | `AssetPricesService.getAssetPriceEntitiesForDate` | | 39 | 1 | find | `Organization` | `subdomains/generic/user/models/organization/organization.service.ts:86` | `OrganizationService.getOrganizationByName` | -| 38 | 1 | find | `BlockchainFee` | `subdomains/supporting/payment/services/fee.service.ts:97` | `FeeService.updateBlockchainFees` | -| 38 | 1 | find | `BlockchainFee` | `subdomains/supporting/payment/services/fee.service.ts:301` | `FeeService.getBlockchainFeeInChf` | -| 38 | 1 | find | `BlockchainFee` | `subdomains/supporting/payment/services/fee.service.ts:554` | `FeeService.getBlockchainMaxFee` | -| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:102` | `TransactionRequestService.syncStatus` | -| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:239` | `TransactionRequestService.getTransactionRequest` | -| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:250` | `TransactionRequestService.getWaitingTransactionRequest` | -| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:347` | `TransactionRequestService.getConsumedSettlementEventIds` | -| 34 | 0 | query-builder (alias only) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:358` | `TransactionRequestService.getLegacySettlementTxIds` | -| 34 | 0 | query-builder (alias only) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:412` | `TransactionRequestService.getActiveDepositAddresses` | +| 38 | 1 | find | `BlockchainFee` | `subdomains/supporting/payment/services/fee.service.ts:93` | `FeeService.updateBlockchainFees` | +| 38 | 1 | find | `BlockchainFee` | `subdomains/supporting/payment/services/fee.service.ts:297` | `FeeService.getBlockchainFeeInChf` | +| 38 | 1 | find | `BlockchainFee` | `subdomains/supporting/payment/services/fee.service.ts:550` | `FeeService.getBlockchainMaxFee` | +| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:96` | `TransactionRequestService.syncStatus` | +| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:233` | `TransactionRequestService.getTransactionRequest` | +| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:244` | `TransactionRequestService.getWaitingTransactionRequest` | +| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:341` | `TransactionRequestService.getConsumedSettlementEventIds` | | 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:21` | `AssetService.updateAsset` | | 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:30` | `AssetService.getAssetsWith` | | 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:42` | `AssetService.getAllBlockchainAssets` | | 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:46` | `AssetService.getPricedAssets` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:61` | `AssetService.getPayInAssets` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:65` | `AssetService.getPaymentAssets` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:69` | `AssetService.getAssetById` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:73` | `AssetService.getAssetsById` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:99` | `AssetService.getAssetsByIdWith` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:103` | `AssetService.getAssetByChainId` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:107` | `AssetService.getAssetByUniqueName` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:111` | `AssetService.getAssetByQuery` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:115` | `AssetService.getAssetsByName` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:119` | `AssetService.getNativeAsset` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:133` | `AssetService.getTokens` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:138` | `AssetService.getSellableBlockchains` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:169` | `AssetService.getEvmAssetsWithoutDecimals` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:53` | `AssetService.getPaymentAssets` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:57` | `AssetService.getAssetById` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:61` | `AssetService.getAssetsById` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:87` | `AssetService.getAssetsByIdWith` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:91` | `AssetService.getAssetByChainId` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:95` | `AssetService.getAssetByUniqueName` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:99` | `AssetService.getAssetByQuery` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:103` | `AssetService.getAssetsByName` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:107` | `AssetService.getNativeAsset` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:121` | `AssetService.getTokens` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:126` | `AssetService.getSellableBlockchains` | | 33 | 0 | find | `Asset` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:106` | `DashboardReconciliationService.getReconciliation` | | 33 | 0 | find | `Asset` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:377` | `DashboardReconciliationService.getExchangeFlows` | -| 33 | 0 | query-builder (alias only) | `Asset` | `shared/models/asset/asset.service.ts:179` | `AssetService.getAssetsUsedOn` | -| 32 | 1 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:274` | `PaymentLinkPaymentService.getPaymentByExternalId` | -| 32 | 1 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:280` | `PaymentLinkPaymentService.getMostRecentPayment` | -| 32 | 1 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:415` | `PaymentLinkPaymentService.deliverToConnectedDevices` | -| 31 | 1 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:221` | `BankDataService.createBankDataInternal` | -| 31 | 1 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:241` | `BankDataService.updateBankData` | -| 31 | 1 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:336` | `BankDataService.getVerifiedBankDataWithIban` | +| 32 | 1 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:125` | `PaymentLinkPaymentService.getPaymentByExternalId` | +| 32 | 1 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:131` | `PaymentLinkPaymentService.getMostRecentPayment` | +| 31 | 1 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:217` | `BankDataService.createBankDataInternal` | +| 31 | 1 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:237` | `BankDataService.updateBankData` | +| 31 | 1 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:332` | `BankDataService.getVerifiedBankDataWithIban` | | 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:76` | `ExchangeTxService.upsertScryptTx` | -| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:118` | `ExchangeTxService.syncExchanges` | -| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:215` | `ExchangeTxService.getExchangeTx` | -| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:219` | `ExchangeTxService.getLastExchangeTx` | -| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:223` | `ExchangeTxService.getRecentExchangeTx` | -| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:273` | `ExchangeTxService.getSyncSinceDate` | +| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:114` | `ExchangeTxService.syncExchanges` | +| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:211` | `ExchangeTxService.getExchangeTx` | +| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:215` | `ExchangeTxService.getLastExchangeTx` | +| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:219` | `ExchangeTxService.getRecentExchangeTx` | +| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:269` | `ExchangeTxService.getSyncSinceDate` | | 30 | 2 | find | `LedgerTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:335` | `BankTxConsumer.cutoverOwedOpeningChf` | | 30 | 2 | find | `LedgerTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:563` | `BankTxConsumer.openingLiabilityLegChf` | | 30 | 2 | find | `LedgerTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:582` | `BankTxConsumer.cutoverOpeningLiabilityChf` | @@ -643,7 +603,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:286` | `DashboardReconciliationService.getBlockchainFlows` | | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:387` | `DashboardReconciliationService.getExchangeFlows` | | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:544` | `DashboardReconciliationService.getExchangeWithdrawalsForBlockchain` | -| 27 | 2 | find | `LiquidityManagementAction` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:261` | `LiquidityManagementRuleService.findExistingAction` | +| 27 | 2 | find | `LiquidityManagementAction` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:257` | `LiquidityManagementRuleService.findExistingAction` | | 26 | 0 | query-builder (alias only) | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:22` | `BankAccountService.getBankAccountByKey` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:40` | `BankAccountService.checkFailedBankAccounts` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:48` | `BankAccountService.reloadErrorBankAccounts` | @@ -652,10 +612,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:73` | `BankAccountService.getOrCreateBicBankAccountInternal` | | 25 | 0 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/checkout-tx.service.ts:53` | `CheckoutTxService.getCheckoutTx` | | 25 | 0 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/checkout-tx.service.ts:60` | `CheckoutTxService.getPendingRefundedList` | -| 25 | 0 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/checkout-tx.service.ts:74` | `CheckoutTxService.getSyncDate` | -| 25 | 0 | query-builder (alias only) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:278` | `RefRewardService.getRewardRecipients` | -| 25 | 0 | query-builder (alias only) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:216` | `RefRewardService.getRefRewardVolume` | -| 25 | 0 | query-builder (alias only) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:249` | `RefRewardService.updatePaidRefCredit` | +| 25 | 0 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/checkout-tx.service.ts:71` | `CheckoutTxService.getSyncDate` | | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:12` | `CountryService.getAllCountry` | | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:16` | `CountryService.getCountry` | | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:21` | `CountryService.getCountryWithSymbol` | @@ -663,129 +620,89 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:28` | `CountryService.getCountriesByKycType` | | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:31` | `CountryService.getCountriesByKycType` | | 23 | 0 | find | `BankTxBatch` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx-batch.service.ts:10` | `BankTxBatchService.getBankTxBatchByIban` | -| 23 | 0 | query-builder (alias only) | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:53` | `TradingOrderService.getTradingOrderYield` | -| 23 | 0 | query-builder (alias only) | `TradingOrder` | `subdomains/core/trading/services/trading-rule.service.ts:35` | `TradingRuleService.getCurrentTradingOrders` | -| 21 | 0 | query-builder (no select) | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:87` | `DepositRouteService.getPaymentRouteForKey` | -| 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:322` | `TransactionService.getTransactionList` | -| 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:490` | `TransactionService.getTransactionByKey` | -| 20 | 0 | query-builder (no select) | `PriceRule` | `subdomains/supporting/pricing/services/pricing.service.ts:272` | `PricingService.getRuleFor` | -| 20 | 0 | query-builder (alias only) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:95` | `SellService.getSellByKey` | +| 21 | 0 | query-builder (ohne-select) | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:87` | `DepositRouteService.getPaymentRouteForKey` | +| 20 | 0 | query-builder (nur-alias) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:95` | `SellService.getSellByKey` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:13` | `WalletRepository.getByAddress` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:19` | `WalletService.updateWallet` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:28` | `WalletService.getByAddress` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:36` | `WalletService.getByIdOrName` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:40` | `WalletService.getKycClients` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:44` | `WalletService.getDefault` | -| 20 | 0 | query-builder (alias only) | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:97` | `BuyFiatRegistrationService.filterSellPayIns` | -| 20 | 0 | query-builder (alias only) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:261` | `SellService.getUserVolume` | -| 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:425` | `TransactionService.getManualRefVolume` | -| 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:454` | `TransactionService.getAuditPeriodVolumes` | -| 20 | 0 | query-builder (alias only) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:271` | `SellService.getTotalVolume` | -| 19 | 0 | query-builder (no select) | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:237` | `CustodyOrderService.getOrdersByUserData` | -| 19 | 0 | query-builder (no select) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:79` | `SwapService.getSwapByAddress` | -| 19 | 0 | query-builder (alias only) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:152` | `SwapService.getSwapByKey` | -| 19 | 0 | query-builder (alias only) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:123` | `SwapService.getUserVolume` | -| 19 | 0 | query-builder (alias only) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:677` | `CustodyService.getHistoricalBalances` | -| 19 | 0 | query-builder (alias only) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:689` | `CustodyService.getHistoricalBalances` | -| 19 | 0 | query-builder (alias only) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:133` | `SwapService.getTotalVolume` | -| 19 | 0 | query-builder (alias only) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:221` | `CustodyService.updateCustodyBalance` | -| 19 | 0 | query-builder (alias only) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:229` | `CustodyService.updateCustodyBalance` | +| 20 | 0 | query-builder (nur-alias) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:221` | `TransactionService.getTransactionList` | +| 20 | 0 | query-builder (nur-alias) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:389` | `TransactionService.getTransactionByKey` | +| 20 | 0 | query-builder (ohne-select) | `PriceRule` | `subdomains/supporting/pricing/services/pricing.service.ts:272` | `PricingService.getRuleFor` | +| 19 | 0 | query-builder (ohne-select) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:79` | `SwapService.getSwapByAddress` | +| 19 | 0 | query-builder (nur-alias) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:152` | `SwapService.getSwapByKey` | +| 19 | 0 | query-builder (ohne-select) | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:231` | `CustodyOrderService.getOrdersByUserData` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:83` | `KycLogService.updateLog` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:90` | `KycLogService.updateLogPdfUrl` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:109` | `KycLogService.getLogsByUserDataId` | -| 16 | 0 | query-builder (no select) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:549` | `SupportIssueService.getSupportIssueList` | -| 16 | 0 | query-builder (alias only) | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1416` | `VirtualIbanService.getVirtualIbanByKey` | -| 16 | 0 | query-builder (alias only) | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:297` | `PaymentLinkPaymentService.getMostRecentPayments` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:12` | `FiatService.getAllFiat` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:16` | `FiatService.getActiveFiat` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:27` | `FiatService.getFiat` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:33` | `FiatService.getFiatByName` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:43` | `FiatService.getFiatByCountry` | -| 16 | 0 | query-builder (alias only) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:245` | `SupportIssueService.getSupportIssueStatistics` | -| 16 | 0 | query-builder (alias only) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:109` | `SupportIssueService.getSupportIssueCounts` | -| 16 | 0 | query-builder (alias only) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:198` | `SupportIssueService.getSupportIssueStatistics` | -| 16 | 0 | query-builder (alias only) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:177` | `SupportIssueService.getSupportIssueStatistics` | -| 15 | 0 | query-builder (alias only) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:288` | `BankDataService.getBankDataByKey` | +| 16 | 0 | query-builder (projektion-mit-vollem-join) | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:148` | `PaymentLinkPaymentService.getMostRecentPayments` | +| 16 | 0 | query-builder (nur-alias) | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1294` | `VirtualIbanService.getVirtualIbanByKey` | +| 16 | 0 | query-builder (nur-alias) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:177` | `SupportIssueService.getSupportIssueStatistics` | +| 16 | 0 | query-builder (ohne-select) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:549` | `SupportIssueService.getSupportIssueList` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:20` | `WalletAppService.getAllBlockchainWalletApps` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:24` | `WalletAppService.getRecommendedWalletApps` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:28` | `WalletAppService.getWalletAppById` | -| 15 | 0 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:2831` | `RealUnitService.getRegisteredWalletAddresses` | -| 15 | 0 | query-builder (alias only) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:497` | `BankDataService.getPendingReviewSummary` | +| 15 | 0 | query-builder (nur-alias) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:284` | `BankDataService.getBankDataByKey` | +| 15 | 0 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:2830` | `RealUnitService.getRegisteredWalletAddresses` | | 14 | 0 | find | `ScorechainScreening` | `integration/scorechain/repositories/scorechain-screening.repository.ts:18` | `ScorechainScreeningRepository.getByObjectIds` | | 14 | 0 | find | `ScorechainScreening` | `integration/scorechain/services/scorechain-screening.service.ts:233` | `ScorechainScreeningService.getCached` | -| 14 | 0 | query-builder (alias only) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:354` | `RecommendationService.countByRecommenderIds` | -| 14 | 0 | query-builder (alias only) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:369` | `RecommendationService.countByRecommendedIds` | -| 13 | 0 | query-builder (alias only) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:231` | `BuyService.getBuyByKey` | -| 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:79` | `PaymentQuoteService.processExpiredQuotes` | -| 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:112` | `PaymentQuoteService.getActualQuoteByPaymentId` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:149` | `KycService.checkIdentSteps` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:186` | `KycService.reviewNationalityStep` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:211` | `KycService.reviewIdentSteps` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:321` | `KycService.reviewFinancialData` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:373` | `KycService.reviewRecommendationStep` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:443` | `KycService.checkDfxApproval` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1505` | `KycService.getStepsByUserData` | +| 14 | 0 | query-builder (feldliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/buy-fiat.repository.ts:56` | `BuyFiatRepository.findSellHistory` | +| 13 | 0 | query-builder (nur-alias) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:228` | `BuyService.getBuyByKey` | +| 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:76` | `PaymentQuoteService.processExpiredQuotes` | +| 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:109` | `PaymentQuoteService.getActualQuoteByPaymentId` | +| 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:157` | `PaymentQuoteService.cancelAllForPayment` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:147` | `KycService.checkIdentSteps` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:184` | `KycService.reviewNationalityStep` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:209` | `KycService.reviewIdentSteps` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:319` | `KycService.reviewFinancialData` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:371` | `KycService.reviewRecommendationStep` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:441` | `KycService.checkDfxApproval` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1497` | `KycService.getStepsByUserData` | | 13 | 0 | find | `TfaLog` | `subdomains/generic/kyc/services/tfa.service.ts:195` | `TfaService.checkVerification` | -| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:64` | `BankService.getBankInternal` | -| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:69` | `BankService.getBankById` | -| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:73` | `BankService.getBankByIdUncached` | -| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:77` | `BankService.getBankByIban` | -| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:91` | `BankService.getReceiveBanks` | -| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:95` | `BankService.getSenderBanks` | +| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:63` | `BankService.getBankInternal` | +| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:68` | `BankService.getBankById` | +| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:72` | `BankService.getBankByIdUncached` | +| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:76` | `BankService.getBankByIban` | +| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:80` | `BankService.getReceiveBanks` | +| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:84` | `BankService.getSenderBanks` | | 13 | 0 | find | `Notification` | `subdomains/supporting/notification/services/notification-job.service.ts:38` | `NotificationJobService.resendUncompletedMails` | | 13 | 0 | find | `Notification` | `subdomains/supporting/notification/services/notification.service.ts:49` | `NotificationService.getMails` | | 13 | 0 | find | `Notification` | `subdomains/supporting/notification/services/notification.service.ts:107` | `NotificationService.isSuppressed` | | 13 | 0 | find | `TransactionRiskAssessment` | `subdomains/supporting/payment/services/transaction-risk-assessment.service.ts:20` | `TransactionRiskAssessmentService.update` | -| 13 | 0 | query-builder (alias only) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:104` | `BuyService.getUserVolume` | -| 13 | 0 | query-builder (alias only) | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1989` | `KycService.getPendingReviewSummary` | -| 13 | 0 | query-builder (alias only) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:114` | `BuyService.getTotalVolume` | | 12 | 0 | find | `IpLog` | `shared/models/ip-log/ip-log.service.ts:66` | `IpLogService.getByUserDataId` | | 12 | 0 | find | `IpLog` | `shared/models/ip-log/ip-log.service.ts:75` | `IpLogService.getLoginCountries` | | 12 | 0 | find | `IpLog` | `shared/models/ip-log/ip-log.service.ts:119` | `IpLogService.updateUserIpLogs` | -| 12 | 0 | query-builder (alias only) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:79` | `IpLogService.getLoginCountries` | -| 12 | 0 | query-builder (alias only) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:92` | `IpLogService.getUserDataIdsWith` | -| 12 | 0 | query-builder (alias only) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:103` | `IpLogService.getUserDataIdsWith` | +| 12 | 0 | query-builder (feldliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:76` | `BuyCryptoRepository.findBuyHistory` | +| 12 | 0 | query-builder (feldliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:90` | `BuyCryptoRepository.findSwapHistory` | | 11 | 0 | find | `OlkyRecipient` | `integration/bank/services/olkypay.service.ts:104` | `OlkypayService.getOrCreateRecipient` | -| 11 | 0 | query-builder (no select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:125` | `LedgerQueryService.getAccountDetail` | -| 11 | 0 | query-builder (no select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:184` | `LedgerQueryService.getSuspense` | +| 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:212` | `LedgerMarkToMarketService.alreadyBooked` | +| 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:125` | `LedgerQueryService.getAccountDetail` | +| 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:184` | `LedgerQueryService.getSuspense` | +| 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:466` | `LedgerQueryService.marginBuckets` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:119` | `LogRepository.getFinancialLogAt` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:132` | `LogRepository.getLatestFinancialLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:142` | `LogRepository.getLatestValidFinancialLogs` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:150` | `LogRepository.getLatestFinancialChangesLog` | -| 11 | 0 | query-builder (alias only) | `Log` | `subdomains/supporting/log/log.repository.ts:165` | `LogRepository.getFinancialChangesLogs` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:187` | `LogRepository.getFinancialChangesLogs` | -| 11 | 0 | query-builder (alias only) | `Log` | `subdomains/supporting/log/log.repository.ts:214` | `LogRepository.getFinancialLogs` | -| 11 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:244` | `LogRepository.getFinancialLogs` | +| 11 | 0 | query-builder (ohne-select) | `Log` | `subdomains/supporting/log/log.repository.ts:688` | `LogRepository.assertEmptyResultIsEndOfData` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:51` | `LogService.update` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:131` | `LogService.getLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:135` | `LogService.maxEntity` | | 11 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.service.ts:191` | `LogService.getBankLog` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:173` | `LedgerMarkToMarketService.accountBalance` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:517` | `LedgerReconciliationService.nativeBalanceByAccount` | -| 11 | 0 | query-builder (no select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:466` | `LedgerQueryService.marginBuckets` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:540` | `LedgerQueryService.cumulativeEquityByDay` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:284` | `LedgerQueryService.balancesByAccount` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:271` | `LedgerReconciliationService.checkTransitAge` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:347` | `LedgerReconciliationService.openResidualSince` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:326` | `LedgerQueryService.nativeBalanceByAccount` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:400` | `LedgerReconciliationService.checkSuspense` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:106` | `LedgerMarkToMarketService.selectCandidates` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:298` | `LedgerQueryService.nativeBalanceBefore` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:310` | `LedgerQueryService.nativeBalanceInPeriod` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:487` | `LedgerReconciliationService.journalEquity` | -| 11 | 0 | query-builder (alias only) | `Log` | `subdomains/supporting/log/log.repository.ts:97` | `LogRepository.cleanup` | -| 11 | 0 | query-builder (alias only) | `Log` | `subdomains/supporting/log/log.repository.ts:104` | `LogRepository.cleanup` | -| 11 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:158` | `LogRepository.getFinancialChangesLogs` | -| 11 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:206` | `LogRepository.getFinancialLogs` | -| 11 | 0 | query-builder (no select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:212` | `LedgerMarkToMarketService.alreadyBooked` | -| 11 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:688` | `LogRepository.assertEmptyResultIsEndOfData` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:61` | `AccountMergeService.sendMergeRequest` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:162` | `AccountMergeService.pendingMergeRequest` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:57` | `SupportNoteService.search` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:76` | `SupportNoteService.search` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:152` | `SupportNoteService.delete` | +| 9 | 0 | query-builder (spaltenliste) | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1214` | `UserDataService.updateVolumes` | | 9 | 0 | find | `TransactionSpecification` | `subdomains/supporting/payment/services/transaction-helper.ts:92` | `TransactionHelper.updateCache` | -| 9 | 0 | query-builder (alias only) | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:84` | `SupportNoteService.listUsers` | | 8 | 0 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-account.service.ts:12` | `LedgerAccountService.findByName` | | 8 | 0 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-account.service.ts:16` | `LedgerAccountService.findByAssetId` | | 8 | 0 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:122` | `LedgerMarkToMarketService.selectCandidates` | @@ -807,13 +724,13 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:58` | `SpecialExternalAccountService.getBlacklist` | | 7 | 0 | find | `AssetPrice` | `subdomains/supporting/pricing/services/asset-prices-job.service.ts:81` | `AssetPricesJobService.saveAssetPrices` | | 7 | 0 | find | `RealUnitLegalAcceptance` | `subdomains/supporting/realunit/realunit-legal.service.ts:57` | `RealUnitLegalService.getLatestAcceptance` | -| 7 | 0 | query-builder (alias only) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:614` | `SupportIssueService.getMessageStats` | -| 7 | 0 | query-builder (alias only) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:310` | `SupportEscalationService.getLastMessages` | -| 7 | 0 | query-builder (alias only) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:133` | `SupportIssueService.getSupportIssueActivity` | -| 7 | 0 | query-builder (alias only) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:186` | `SupportIssueService.getSupportIssueStatistics` | +| 7 | 0 | query-builder (nur-alias) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:133` | `SupportIssueService.getSupportIssueActivity` | +| 7 | 0 | query-builder (nur-alias) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:186` | `SupportIssueService.getSupportIssueStatistics` | +| 6 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:173` | `LedgerMarkToMarketService.accountBalance` | +| 6 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:517` | `LedgerReconciliationService.nativeBalanceByAccount` | | 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.repository.ts:13` | `RefRepository.getAndRemove` | -| 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.service.ts:23` | `RefService.checkRefs` | -| 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.service.ts:29` | `RefService.addOrUpdate` | +| 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.service.ts:22` | `RefService.checkRefs` | +| 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.service.ts:28` | `RefService.addOrUpdate` | | 6 | 0 | find | `CustodyProvider` | `subdomains/generic/user/models/custody-provider/custody-provider.service.ts:17` | `CustodyProviderService.updateCustodyProvider` | | 6 | 0 | find | `CustodyProvider` | `subdomains/generic/user/models/custody-provider/custody-provider.service.ts:26` | `CustodyProviderService.getWithMasterKey` | | 6 | 0 | find | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:63` | `DepositService.getDeposit` | @@ -822,31 +739,102 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 6 | 0 | find | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:75` | `DepositService.getDepositsByBlockchain` | | 6 | 0 | find | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:79` | `DepositService.getDepositByBlockchainAndIndex` | | 6 | 0 | find | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:83` | `DepositService.getUsedDepositsByBlockchain` | -| 6 | 0 | query-builder (no select) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:89` | `DepositService.getNextDeposit` | -| 6 | 0 | query-builder (alias only) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:189` | `DepositService.getNextDepositIndex` | +| 6 | 0 | query-builder (ohne-select) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:89` | `DepositService.getNextDeposit` | | 5 | 0 | find | `Setting` | `shared/models/setting/setting.repository.ts:36` | `SettingRepository.getStatusSettings` | -| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:22` | `SettingService.getAll` | -| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:32` | `SettingService.get` | -| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:43` | `SettingService.set` | -| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:222` | `SettingService.getObj` | -| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:226` | `SettingService.getObjCached` | -| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:230` | `SettingService.setObj` | +| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:16` | `SettingService.getAll` | +| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:26` | `SettingService.get` | +| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:32` | `SettingService.set` | +| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:202` | `SettingService.getObj` | +| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:206` | `SettingService.getObjCached` | +| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:210` | `SettingService.setObj` | | 5 | 0 | find | `Sanction` | `subdomains/core/aml/services/sanction.service.ts:54` | `SanctionService.syncList` | -| 4 | 0 | find | `SystemStateSnapshot` | `subdomains/core/monitoring/monitoring.service.ts:237` | `MonitoringService.readState` | -| 2 | 0 | query-builder (field list) | `Log` | `subdomains/supporting/log/log.repository.ts:699` | `LogRepository.getFinancialLogValidityChangeSet` | -| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:341` | `LogRepository.getFinancialLogAssetPrices` | -| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:511` | `LogRepository.getFinancialLogSummariesFull` | -| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:664` | `LogRepository.getFinancialLogSummariesChartOnly` | -| — | — | query-builder (alias only) | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1252` | `UserDataService.updateVolumes` | -| — | — | query-builder (alias only) | `—` | `subdomains/core/monitoring/observers/payment.observer.ts:72` | `PaymentObserver.getPayment` | -| — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:868` | `GsService.getExtendedBankTxData` | -| — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:887` | `GsService.getExtendedBankTxData` | -| — | — | query-builder (alias only) | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:335` | `LedgerBookingService.nextSeqFrom` | -| — | — | query-builder (alias only) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:958` | `LedgerCutoverService.maxSettledId` | -| — | — | query-builder (alias only) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:1002` | `LedgerCutoverService.idsUpToBoundary` | -| — | — | query-builder (alias only) | `—` | `subdomains/core/monitoring/observers/bank.observer.ts:117` | `BankObserver.getDbBalance` | -| — | — | query-builder (alias only) | `—` | `subdomains/supporting/payin/services/payin.service.ts:246` | `PayInService.getPayInFee` | -| — | — | find | `—` | `config/config.ts:1365` | `Configuration.isDomesticIban` | +| 5 | 0 | query-builder (spaltenliste) | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:84` | `SupportNoteService.listUsers` | +| 4 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:540` | `LedgerQueryService.cumulativeEquityByDay` | +| 4 | 0 | find | `SystemStateSnapshot` | `subdomains/core/monitoring/monitoring.service.ts:47` | `MonitoringService.loadState` | +| 4 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:614` | `SupportIssueService.getMessageStats` | +| 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:284` | `LedgerQueryService.balancesByAccount` | +| 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:271` | `LedgerReconciliationService.checkTransitAge` | +| 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:347` | `LedgerReconciliationService.openResidualSince` | +| 3 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1051` | `BuyCryptoService.updateBuyVolume` | +| 3 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1079` | `BuyCryptoService.updateCryptoRouteVolume` | +| 3 | 0 | query-builder (spaltenliste) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:101` | `BuyService.getUserVolume` | +| 3 | 0 | query-builder (spaltenliste) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:123` | `SwapService.getUserVolume` | +| 3 | 0 | query-builder (spaltenliste) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:677` | `CustodyService.getHistoricalBalances` | +| 3 | 0 | query-builder (spaltenliste) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:689` | `CustodyService.getHistoricalBalances` | +| 3 | 0 | query-builder (spaltenliste) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:278` | `RefRewardService.getRewardRecipients` | +| 3 | 0 | query-builder (spaltenliste) | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:97` | `BuyFiatRegistrationService.filterSellPayIns` | +| 3 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:678` | `BuyFiatService.updateSellVolume` | +| 3 | 0 | query-builder (spaltenliste) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:261` | `SellService.getUserVolume` | +| 3 | 0 | query-builder (spaltenliste) | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1981` | `KycService.getPendingReviewSummary` | +| 3 | 0 | query-builder (spaltenliste) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:461` | `BankTxService.getBankTxFee` | +| 3 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:310` | `SupportEscalationService.getLastMessages` | +| 3 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:245` | `SupportIssueService.getSupportIssueStatistics` | +| 2 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:326` | `LedgerQueryService.nativeBalanceByAccount` | +| 2 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:400` | `LedgerReconciliationService.checkSuspense` | +| 2 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1122` | `BuyCryptoService.getRefVolume` | +| 2 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1134` | `BuyCryptoService.getPartnerFeeRefVolume` | +| 2 | 0 | query-builder (spaltenliste) | `—` | `subdomains/core/monitoring/observers/payment.observer.ts:72` | `PaymentObserver.getPayment` | +| 2 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:721` | `BuyFiatService.getRefVolume` | +| 2 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:733` | `BuyFiatService.getPartnerFeeRefVolume` | +| 2 | 0 | query-builder (spaltenliste) | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:53` | `TradingOrderService.getTradingOrderYield` | +| 2 | 0 | query-builder (spaltenliste) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:493` | `BankDataService.getPendingReviewSummary` | +| 2 | 0 | query-builder (spaltenliste) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:354` | `RecommendationService.countByRecommenderIds` | +| 2 | 0 | query-builder (spaltenliste) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:369` | `RecommendationService.countByRecommendedIds` | +| 2 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:226` | `UserService.countRefChildrenByUserDataIds` | +| 2 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:248` | `UserService.countRefReferrersByUserDataIds` | +| 2 | 0 | query-builder (spaltenliste) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:505` | `BankTxService.getBankTxFee` | +| 2 | 0 | query-builder (feldliste) | `Log` | `subdomains/supporting/log/log.repository.ts:699` | `LogRepository.getFinancialLogValidityChangeSet` | +| 2 | 0 | query-builder (spaltenliste) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:324` | `TransactionService.getManualRefVolume` | +| 2 | 0 | query-builder (spaltenliste) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:353` | `TransactionService.getAuditPeriodVolumes` | +| 2 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:109` | `SupportIssueService.getSupportIssueCounts` | +| 2 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:198` | `SupportIssueService.getSupportIssueStatistics` | +| 1 | 0 | query-builder (spaltenliste) | `Asset` | `shared/models/asset/asset.service.ts:140` | `AssetService.getAssetsUsedOn` | +| 1 | 0 | query-builder (spaltenliste) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:79` | `IpLogService.getLoginCountries` | +| 1 | 0 | query-builder (spaltenliste) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:92` | `IpLogService.getUserDataIdsWith` | +| 1 | 0 | query-builder (spaltenliste) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:103` | `IpLogService.getUserDataIdsWith` | +| 1 | 0 | query-builder (spaltenliste) | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:335` | `LedgerBookingService.nextSeqFrom` | +| 1 | 0 | query-builder (spaltenliste) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:958` | `LedgerCutoverService.maxSettledId` | +| 1 | 0 | query-builder (spaltenliste) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:1002` | `LedgerCutoverService.idsUpToBoundary` | +| 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:106` | `LedgerMarkToMarketService.selectCandidates` | +| 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:298` | `LedgerQueryService.nativeBalanceBefore` | +| 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:310` | `LedgerQueryService.nativeBalanceInPeriod` | +| 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:487` | `LedgerReconciliationService.journalEquity` | +| 1 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:767` | `BuyCryptoService.updateRefVolumes` | +| 1 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:883` | `BuyCryptoService.getUserVolumeForType` | +| 1 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:955` | `BuyCryptoService.getPendingLiquidityDemandChf` | +| 1 | 0 | query-builder (spaltenliste) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:111` | `BuyService.getTotalVolume` | +| 1 | 0 | query-builder (spaltenliste) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:133` | `SwapService.getTotalVolume` | +| 1 | 0 | query-builder (spaltenliste) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:221` | `CustodyService.updateCustodyBalance` | +| 1 | 0 | query-builder (spaltenliste) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:229` | `CustodyService.updateCustodyBalance` | +| 1 | 0 | query-builder (spaltenliste) | `—` | `subdomains/core/monitoring/observers/bank.observer.ts:117` | `BankObserver.getDbBalance` | +| 1 | 0 | query-builder (spaltenliste) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:216` | `RefRewardService.getRefRewardVolume` | +| 1 | 0 | query-builder (spaltenliste) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:249` | `RefRewardService.updatePaidRefCredit` | +| 1 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:586` | `BuyFiatService.updateRefVolumes` | +| 1 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:603` | `BuyFiatService.getUserVolume` | +| 1 | 0 | query-builder (spaltenliste) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:271` | `SellService.getTotalVolume` | +| 1 | 0 | query-builder (spaltenliste) | `TradingOrder` | `subdomains/core/trading/services/trading-rule.service.ts:35` | `TradingRuleService.getCurrentTradingOrders` | +| 1 | 0 | query-builder (spaltenliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:179` | `UserDataService.getUserDataIdsByServiceProvider` | +| 1 | 0 | query-builder (spaltenliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1774` | `UserDataService.getMaxKycFileIdByDateRange` | +| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:140` | `UserService.getAllLinkedUsers` | +| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:195` | `UserService.getOpenRefCreditEur` | +| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:579` | `UserService.getUserVolumes` | +| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:589` | `UserService.getUserVolumes` | +| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:651` | `UserService.getRefInfo` | +| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:664` | `UserService.getRefInfo` | +| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:723` | `UserService.getTotalRefRewards` | +| 1 | 0 | query-builder (spaltenliste) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:189` | `DepositService.getNextDepositIndex` | +| 1 | 0 | query-builder (spaltenliste) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:449` | `BankTxService.getBankTxFee` | +| 1 | 0 | query-builder (spaltenliste) | `Log` | `subdomains/supporting/log/log.repository.ts:97` | `LogRepository.cleanup` | +| 1 | 0 | query-builder (spaltenliste) | `Log` | `subdomains/supporting/log/log.repository.ts:104` | `LogRepository.cleanup` | +| 1 | 0 | query-builder (spaltenliste) | `Log` | `subdomains/supporting/log/log.repository.ts:158` | `LogRepository.getFinancialChangesLogs` | +| 1 | 0 | query-builder (ohne-select) | `Log` | `subdomains/supporting/log/log.repository.ts:165` | `LogRepository.getFinancialChangesLogs` | +| 1 | 0 | query-builder (spaltenliste) | `Log` | `subdomains/supporting/log/log.repository.ts:206` | `LogRepository.getFinancialLogs` | +| 1 | 0 | query-builder (ohne-select) | `Log` | `subdomains/supporting/log/log.repository.ts:214` | `LogRepository.getFinancialLogs` | +| 1 | 0 | query-builder (ohne-select) | `Log` | `subdomains/supporting/log/log.repository.ts:244` | `LogRepository.getFinancialLogs` | +| 1 | 0 | query-builder (spaltenliste) | `—` | `subdomains/supporting/payin/services/payin.service.ts:217` | `PayInService.getPayInFee` | +| 1 | 0 | query-builder (spaltenliste) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:352` | `TransactionRequestService.getLegacySettlementTxIds` | +| 1 | 0 | query-builder (spaltenliste) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:406` | `TransactionRequestService.getActiveDepositAddresses` | +| — | — | find | `—` | `config/config.ts:1347` | `Configuration.isDomesticIban` | | — | — | find | `—` | `integration/binance-pay/services/binance-pay.service.ts:271` | `BinancePayService.verifySignature` | | — | — | find | `—` | `integration/blockchain/api/services/blockchain-balance.service.ts:52` | `BlockchainBalanceService.getSolanaBalances` | | — | — | find | `—` | `integration/blockchain/api/services/blockchain-balance.service.ts:79` | `BlockchainBalanceService.getTronBalances` | @@ -856,52 +844,50 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `integration/blockchain/cardano/cardano-client.ts:100` | `CardanoClient.getTokenBalances` | | — | — | find | `—` | `integration/blockchain/icp/icp-client.ts:231` | `InternetComputerClient.getNativeTransfersForAddress` | | — | — | find | `—` | `integration/blockchain/icp/icp-client.ts:232` | `InternetComputerClient.getNativeTransfersForAddress` | -| — | — | find | `—` | `integration/blockchain/icp/icp-client.ts:369` | `InternetComputerClient.getTransferByTxId` | -| — | — | find | `—` | `integration/blockchain/icp/icp-client.ts:370` | `InternetComputerClient.getTransferByTxId` | | — | — | find | `—` | `integration/blockchain/shared/evm/citrea-base-client.ts:248` | `CitreaBaseClient.swapViaGateway` | | — | — | find | `—` | `integration/blockchain/shared/evm/citrea-base-client.ts:353` | `CitreaBaseClient.getTokenPairByAddresses` | | — | — | find | `—` | `integration/blockchain/shared/evm/evm-client.ts:681` | `EvmClient.poolQuote` | | — | — | find | `—` | `integration/blockchain/shared/evm/evm-client.ts:704` | `EvmClient.getSwapResultBaseUnits` | -| — | — | find | `—` | `integration/blockchain/shared/services/tx-validation.service.ts:104` | `TxValidationService.validateSolanaTransaction` | -| — | — | find | `—` | `integration/blockchain/solana/solana-client.ts:489` | `SolanaClient.getTokenTransactionDestinations` | +| — | — | find | `—` | `integration/blockchain/shared/evm/evm-decimals.service.ts:24` | `EvmDecimalsService.setDecimals` | +| — | — | find | `—` | `integration/blockchain/solana/solana-client.ts:530` | `SolanaClient.updateTokenInstruction` | +| — | — | find | `—` | `integration/blockchain/solana/solana-client.ts:531` | `SolanaClient.updateTokenInstruction` | | — | — | find | `—` | `integration/blockchain/tron/tron-client.ts:51` | `TronClient.getNativeCoinBalanceForAddress` | | — | — | find | `—` | `integration/blockchain/zano/services/zano.service.ts:97` | `ZanoService.addAssetsToWhitelist` | | — | — | find | `—` | `integration/blockchain/zano/services/zano.service.ts:98` | `ZanoService.addAssetsToWhitelist` | | — | — | find | `—` | `integration/blockchain/zano/zano-client.ts:185` | `ZanoClient.getTokenBalances` | | — | — | find | `—` | `integration/exchange/controllers/exchange.controller.ts:93` | `ExchangeController.syncExchange` | -| — | — | find | `—` | `integration/exchange/services/exchange-tx.service.ts:308` | `ExchangeTxService.getTransactionsFor` | +| — | — | find | `—` | `integration/exchange/services/exchange-tx.service.ts:304` | `ExchangeTxService.getTransactionsFor` | | — | — | find | `—` | `integration/exchange/services/exchange.service.ts:228` | `ExchangeService.getWithdraw` | | — | — | find | `—` | `integration/exchange/services/exchange.service.ts:284` | `ExchangeService.getMarket` | | — | — | find | `—` | `integration/exchange/services/exchange.service.ts:297` | `ExchangeService.getTradePair` | | — | — | find | `—` | `integration/exchange/services/exchange.service.ts:343` | `ExchangeService.getBestBidLiquidity` | | — | — | find | `—` | `integration/exchange/services/exchange.service.ts:368` | `ExchangeService.trade` | | — | — | find | `—` | `integration/exchange/services/mexc.service.ts:159` | `MexcService.getWithdraw` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:499` | `ScryptService.withdrawFunds` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:674` | `ScryptService.findWithdrawal` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:766` | `ScryptService.getOrderStatus` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:1084` | `ScryptService.placeOrder` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:1264` | `ScryptService.cancelOrderBySymbol` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:1307` | `ScryptService.editOrder` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:1326` | `ScryptService.getTradePair` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:1342` | `ScryptService.getSecurity` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:402` | `ScryptService.withdrawFunds` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:577` | `ScryptService.findWithdrawal` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:600` | `ScryptService.getOrderStatus` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:832` | `ScryptService.placeOrder` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:862` | `ScryptService.cancelOrder` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:895` | `ScryptService.editOrder` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:914` | `ScryptService.getTradePair` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:930` | `ScryptService.getSecurity` | | — | — | find | `—` | `integration/lightning/lightning-helper.ts:134` | — | | — | — | find | `—` | `integration/lightning/services/lightning.service.ts:196` | `LightningService.findPayment` | | — | — | find | `—` | `shared/models/asset/asset.controller.ts:39` | `AssetController.getAllAsset` | -| — | — | find | `—` | `shared/models/asset/asset.service.ts:192` | `AssetService.getByQuerySync` | -| — | — | find | `—` | `shared/models/asset/asset.service.ts:196` | `AssetService.getByChainIdSync` | +| — | — | find | `—` | `shared/models/asset/asset.service.ts:153` | `AssetService.getByQuerySync` | +| — | — | find | `—` | `shared/models/asset/asset.service.ts:157` | `AssetService.getByChainIdSync` | | — | — | find | `—` | `shared/models/fiat/fiat.controller.ts:27` | `FiatController.getAllFiat` | | — | — | find | `—` | `shared/models/fiat/fiat.service.ts:32` | `FiatService.getFiatByName` | | — | — | find | `—` | `shared/models/ip-log/ip-log.service.ts:143` | `IpLogService.checkIpCountry` | | — | — | find | `—` | `shared/models/setting/setting.repository.ts:20` | `SettingRepository.setDateMax` | -| — | — | find | `—` | `shared/models/setting/setting.service.ts:189` | `SettingService.updateCustomSignUpFees` | -| — | — | find | `—` | `shared/models/setting/setting.service.ts:218` | `SettingService.getCustomSignUpFees` | +| — | — | find | `—` | `shared/models/setting/setting.service.ts:169` | `SettingService.updateCustomSignUpFees` | +| — | — | find | `—` | `shared/models/setting/setting.service.ts:198` | `SettingService.getCustomSignUpFees` | | — | — | find | `—` | `shared/repositories/cached.repository.ts:16` | `CachedRepository.findOneCached` | | — | — | find | `—` | `shared/repositories/cached.repository.ts:20` | `CachedRepository.findOneCachedBy` | | — | — | find | `—` | `shared/repositories/cached.repository.ts:24` | `CachedRepository.findCached` | | — | — | find | `—` | `shared/repositories/cached.repository.ts:28` | `CachedRepository.findCachedBy` | -| — | — | raw-sql | `—` | `shared/services/cron-lease.service.ts:186` | `CronLeaseService.onModuleInit` | -| — | — | find | `—` | `shared/services/http.service.ts:87` | `HttpService.getMockResponse` | -| — | — | find | `—` | `shared/utils/util.ts:786` | — | +| — | — | find | `—` | `shared/services/http.service.ts:84` | `HttpService.getMockResponse` | +| — | — | find | `—` | `shared/utils/util.ts:786` | `Util.clearTimeout` | | — | — | find | `—` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:339` | `BankTxConsumer.cutoverOwedOpeningChf` | | — | — | find | `—` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:567` | `BankTxConsumer.openingLiabilityLegChf` | | — | — | find | `—` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:586` | `BankTxConsumer.cutoverOpeningLiabilityChf` | @@ -922,49 +908,24 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/core/aml/services/aml-helper.service.ts:700` | — | | — | — | find | `—` | `subdomains/core/aml/services/aml.service.ts:132` | `AmlService.getAmlCheckInput` | | — | — | find | `—` | `subdomains/core/aml/services/aml.service.ts:278` | `AmlService.getBankData` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts:376` | `BuyCrypto.calculateOutputReferenceAmount` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts:678` | `BuyCrypto.setFeeAndFiatReference` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:152` | `BuyCryptoBatchService.saveBatchIfTransactionsUnchanged` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:288` | `BuyCryptoBatchService.filterOutExistingBatches` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:449` | `BuyCryptoBatchService.handleMissingBuyCryptoLiquidityException` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:292` | `BuyCryptoPreparationService.postProcessAmlVerdict` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:498` | `BuyCryptoPreparationService.isFiat` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:506` | `BuyCryptoPreparationService.isFiat` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:667` | `BuyCryptoPreparationService.In` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:675` | `BuyCryptoPreparationService.In` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts:370` | `BuyCrypto.calculateOutputReferenceAmount` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts:662` | `BuyCrypto.setFeeAndFiatReference` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:242` | `BuyCryptoBatchService.filterOutExistingBatches` | | — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:93` | `BuyCryptoRegistrationService.findMatchingRoute` | | — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:95` | `BuyCryptoRegistrationService.findMatchingRoute` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:332` | `BuyCryptoService.update` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:512` | `BuyCryptoService.runWithVersionLock` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:529` | `BuyCryptoService.runIfAmlStateCurrent` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:669` | `BuyCryptoService.refundCheckoutTx` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:677` | `BuyCryptoService.refundCheckoutTx` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:684` | `BuyCryptoService.refundCheckoutTx` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:757` | `BuyCryptoService.refundCryptoInput` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:765` | `BuyCryptoService.refundCryptoInput` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:771` | `BuyCryptoService.refundCryptoInput` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:779` | `BuyCryptoService.refundCryptoInput` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:857` | `BuyCryptoService.refundBankTx` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:865` | `BuyCryptoService.refundBankTx` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:992` | `BuyCryptoService.resetAmlCheckForReview` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1000` | `BuyCryptoService.resetAmlCheckForReview` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1014` | `BuyCryptoService.resetAmlCheckForReview` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1021` | `BuyCryptoService.resetAmlCheckForReview` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1057` | `BuyCryptoService.resetAmlCheckForReview` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1088` | `BuyCryptoService.resetAmlCheckForReview` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1094` | `BuyCryptoService.resetAmlCheckForReview` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1447` | `BuyCryptoService.getCryptoRoute` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:297` | `BuyCryptoService.update` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1037` | `BuyCryptoService.getCryptoRoute` | | — | — | find | `—` | `subdomains/core/custody/controllers/custody-account.controller.ts:66` | `CustodyAccountController.getCustodyAccount` | | — | — | find | `—` | `subdomains/core/custody/controllers/custody-account.controller.ts:67` | `CustodyAccountController.getCustodyAccount` | | — | — | query-builder (no select) | `—` | `subdomains/core/custody/services/custody-account.service.ts:482` | `CustodyAccountService.lockActiveAccessGrant` | | — | — | find | `—` | `subdomains/core/custody/services/custody-account.service.ts:504` | `CustodyAccountService.createGrant` | | — | — | find | `—` | `subdomains/core/custody/services/custody-account.service.ts:598` | `CustodyAccountService.grantAccessForLegacy` | | — | — | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:65` | `CustodyJobService.executeOrder` | -| — | — | find | `—` | `subdomains/core/custody/services/custody-order.service.ts:405` | `CustodyOrderService.checkBalance` | +| — | — | find | `—` | `subdomains/core/custody/services/custody-order.service.ts:399` | `CustodyOrderService.checkBalance` | | — | — | find | `—` | `subdomains/core/custody/services/custody.service.ts:307` | `CustodyService.getUserCustodyHistory` | | — | — | find | `—` | `subdomains/core/history/mappers/transaction-dto.mapper.ts:211` | `TransactionDtoMapper.feeAmountType` | -| — | — | find | `—` | `subdomains/core/history/services/history-access.service.ts:126` | `HistoryAccessService.resolveFromApiKey` | -| — | — | find | `—` | `subdomains/core/history/services/history-access.service.ts:134` | `HistoryAccessService.findOwnedUser` | +| — | — | find | `—` | `subdomains/core/history/services/history-access.service.ts:122` | `HistoryAccessService.resolveFromApiKey` | +| — | — | find | `—` | `subdomains/core/history/services/history-access.service.ts:130` | `HistoryAccessService.findOwnedUser` | | — | — | find | `—` | `subdomains/core/history/services/history.service.ts:169` | `HistoryService.getHistoryTransactions` | | — | — | find | `—` | `subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts:451` | `CcxtExchangeAdapter.checkTransferCompletion` | | — | — | find | `—` | `subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts:228` | `DfxDexAdapter.checkWithdrawCompletion` | @@ -976,11 +937,10 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/core/liquidity-management/adapters/balances/bank.adapter.ts:134` | `BankAdapter.getForBank` | | — | — | find | `—` | `subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts:40` | `ExchangeAdapter.hasPendingOrders` | | — | — | find | `—` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:78` | `LiquidityManagementBalanceService.findRelevantBalance` | -| — | — | find | `—` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:201` | `LiquidityManagementRuleService.confirmOrCreateActionTree` | -| — | — | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:46` | `LiquidityManagementService.checkLiquidityBalances` | +| — | — | find | `—` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:197` | `LiquidityManagementRuleService.confirmOrCreateActionTree` | +| — | — | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:42` | `LiquidityManagementService.checkLiquidityBalances` | | — | — | find | `—` | `subdomains/core/liquidity-management/validators/liquidity-actions-all-steps-match.validator.ts:11` | `LiquidityActionsAllStepsMatchValidator.validate` | | — | — | find | `—` | `subdomains/core/liquidity-management/validators/liquidity-actions-all-steps-match.validator.ts:12` | `LiquidityActionsAllStepsMatchValidator.validate` | -| — | — | find | `—` | `subdomains/core/monitoring/monitoring.service.ts:192` | `MonitoringService.mergeIntoStoredState` | | — | — | find | `—` | `subdomains/core/monitoring/observers/node-health.observer.ts:131` | `NodeHealthObserver.getPoolState` | | — | — | find | `—` | `subdomains/core/monitoring/observers/node-health.observer.ts:139` | `NodeHealthObserver.getNodeStateInPool` | | — | — | find | `—` | `subdomains/core/monitoring/observers/payment.observer.ts:157` | `PaymentObserver.getLastOutputDates` | @@ -988,33 +948,33 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/core/payment-link/entities/payment-quote.entity.ts:116` | `PaymentQuote.getTransferAmount` | | — | — | find | `—` | `subdomains/core/payment-link/entities/payment-quote.entity.ts:123` | `PaymentQuote.getTransferAmountFor` | | — | — | find | `—` | `subdomains/core/payment-link/services/ocp-sticker.service.ts:206` | `OCPStickerService.generateBitcoinFocusStickersPdf` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-activation.service.ts:121` | `PaymentActivationService.doCreateRequest` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-activation.service.ts:118` | `PaymentActivationService.doCreateRequest` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-balance.service.ts:93` | `PaymentBalanceService.getPaymentBalances` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-balance.service.ts:108` | `PaymentBalanceService.getPaymentBalances` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:490` | `PaymentLinkPaymentService.deliverToDevice` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:524` | `PaymentLinkPaymentService.handleBinanceWaiting` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:635` | `PaymentLinkPaymentService.cancelByLink` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:179` | `PaymentLinkPaymentService.handleBinanceWaiting` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:284` | `PaymentLinkPaymentService.cancelByLink` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-link.service.ts:181` | `PaymentLinkService.createInvoice` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-link.service.ts:633` | `PaymentLinkService.getPaymentLinkByAccessKey` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-link.service.ts:643` | `PaymentLinkService.getPaymentLinkByAccessKey` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-link.service.ts:663` | `PaymentLinkService.waitForPayment` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:121` | `PaymentQuoteService.getActualQuoteByPaymentId` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:184` | `PaymentQuoteService.cancelAllForPayment` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:530` | `PaymentQuoteService.validateEvmTx` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:531` | `PaymentQuoteService.validateEvmTx` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:579` | `PaymentQuoteService.async` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:587` | `PaymentQuoteService.async` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:767` | `PaymentQuoteService.doIcpPayment` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:118` | `PaymentQuoteService.getActualQuoteByPaymentId` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:503` | `PaymentQuoteService.validateEvmTx` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:504` | `PaymentQuoteService.validateEvmTx` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:552` | `PaymentQuoteService.async` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:560` | `PaymentQuoteService.async` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:704` | `PaymentQuoteService.doIcpPayment` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-standard.service.ts:13` | `PaymentStandardService.getById` | -| — | — | find | `—` | `subdomains/core/sell-crypto/process/buy-fiat.entity.ts:369` | `BuyFiat.setFeeAndFiatReference` | +| — | — | find | `—` | `subdomains/core/sell-crypto/process/buy-fiat.entity.ts:367` | `BuyFiat.setFeeAndFiatReference` | | — | — | find | `—` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:124` | `BuyFiatRegistrationService.findMatchingRoute` | | — | — | find | `—` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:126` | `BuyFiatRegistrationService.findMatchingRoute` | | — | — | find | `—` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:128` | `BuyFiatRegistrationService.findMatchingRoute` | -| — | — | find | `—` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:206` | `BuyFiatService.update` | +| — | — | find | `—` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:205` | `BuyFiatService.update` | | — | — | raw-sql | `—` | `subdomains/generic/gs/gs.service.ts:337` | `GsService.executeDebugQuery` | | — | — | find | `—` | `subdomains/generic/gs/gs.service.ts:739` | `GsService.getParsedJsonData` | | — | — | find | `—` | `subdomains/generic/gs/gs.service.ts:742` | `GsService.getParsedJsonData` | | — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:805` | `GsService.getRawDbData` | +| — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:868` | `GsService.getExtendedBankTxData` | +| — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:887` | `GsService.getExtendedBankTxData` | | — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:906` | `GsService.getExtendedBankTxData` | | — | — | find | `—` | `subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts:35` | — | | — | — | find | `—` | `subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts:36` | — | @@ -1026,35 +986,35 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/generic/kyc/services/kyc-admin.service.ts:41` | `KycAdminService.getKycSteps` | | — | — | find | `—` | `subdomains/generic/kyc/services/kyc-client.service.ts:55` | `KycClientService.getAllUserPayments` | | — | — | find | `—` | `subdomains/generic/kyc/services/kyc-client.service.ts:111` | `KycClientService.getFileFor` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:220` | `KycService.reviewIdentSteps` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:435` | `KycService.checkDfxApproval` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:438` | `KycService.checkDfxApproval` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:461` | `KycService.isKycStepUniqueViolation` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:523` | `KycService.initializeProcess` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:539` | `KycService.failContactStepForMail` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1182` | `KycService.getOrCreateStepInternal` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1208` | `KycService.getOrCreateStep` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1209` | `KycService.getOrCreateStep` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1303` | `KycService.getNext` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1366` | `KycService.initiateStep` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1473` | `KycService.completeReferencedSteps` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1785` | `KycService.getIdentCheckErrors` | -| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:334` | `SupportPdfService.createOnboardingPdf` | -| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:360` | `SupportPdfService.createOnboardingPdf` | -| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:372` | `SupportPdfService.createOnboardingPdf` | -| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:388` | `SupportPdfService.createOnboardingPdf` | -| — | — | find | `—` | `subdomains/generic/support/support.service.ts:1443` | `SupportService.getUniqueUserDataByKey` | -| — | — | find | `—` | `subdomains/generic/support/support.service.ts:1452` | `SupportService.getUniqueUserDataByKey` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:218` | `KycService.reviewIdentSteps` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:433` | `KycService.checkDfxApproval` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:436` | `KycService.checkDfxApproval` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:459` | `KycService.isKycStepUniqueViolation` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:515` | `KycService.initializeProcess` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:531` | `KycService.failContactStepForMail` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1174` | `KycService.getOrCreateStepInternal` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1200` | `KycService.getOrCreateStep` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1201` | `KycService.getOrCreateStep` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1295` | `KycService.getNext` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1358` | `KycService.initiateStep` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1465` | `KycService.completeReferencedSteps` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1777` | `KycService.getIdentCheckErrors` | +| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:332` | `SupportPdfService.createOnboardingPdf` | +| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:358` | `SupportPdfService.createOnboardingPdf` | +| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:370` | `SupportPdfService.createOnboardingPdf` | +| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:386` | `SupportPdfService.createOnboardingPdf` | +| — | — | find | `—` | `subdomains/generic/support/support.service.ts:1327` | `SupportService.getUniqueUserDataByKey` | +| — | — | find | `—` | `subdomains/generic/support/support.service.ts:1336` | `SupportService.getUniqueUserDataByKey` | | — | — | find | `—` | `subdomains/generic/user/models/auth/auth.service.ts:497` | `AuthService.checkPendingRecommendation` | | — | — | find | `—` | `subdomains/generic/user/models/auth/auth.service.ts:515` | `AuthService.confirmRecommendationCode` | | — | — | find | `—` | `subdomains/generic/user/models/auth/auth.service.ts:530` | `AuthService.getLinkedUser` | | — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.entity.ts:155` | `BankData.internalReview` | -| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:250` | `BankDataService.updateBankDataInternal` | -| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:342` | `BankDataService.getVerifiedBankDataWithIban` | -| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:343` | `BankDataService.getVerifiedBankDataWithIban` | -| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:344` | `BankDataService.getVerifiedBankDataWithIban` | -| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:366` | `BankDataService.getAllBankDatasForUser` | -| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:448` | `BankDataService.createIbanForUserInternal` | +| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:246` | `BankDataService.updateBankDataInternal` | +| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:338` | `BankDataService.getVerifiedBankDataWithIban` | +| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:339` | `BankDataService.getVerifiedBankDataWithIban` | +| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:340` | `BankDataService.getVerifiedBankDataWithIban` | +| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:362` | `BankDataService.getAllBankDatasForUser` | +| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:444` | `BankDataService.createIbanForUserInternal` | | — | — | find | `Wallet` | `subdomains/generic/user/models/kyc/kyc.service.ts:58` | `KycService.transferKycData` | | — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:193` | `KycService.getFileFor` | | — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:200` | `KycService.getFileFor` | @@ -1069,63 +1029,53 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.entity.ts:785` | `UserData.getCompletedStepWith` | | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.entity.ts:789` | `UserData.getNonFailedStepWith` | | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.enum.ts:75` | — | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:444` | `UserDataService.setKycStatusCheck` | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:905` | `UserDataService.checkMail` | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1350` | `UserDataService.mergeUserData` | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1362` | `UserDataService.mergeUserData` | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1370` | `UserDataService.mergeUserData` | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1382` | `UserDataService.mergeUserData` | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1585` | `UserDataService.mergeUserData` | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1763` | `UserDataService.updateBankTxTime` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:867` | `UserDataService.checkMail` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1312` | `UserDataService.mergeUserData` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1324` | `UserDataService.mergeUserData` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1332` | `UserDataService.mergeUserData` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1344` | `UserDataService.mergeUserData` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1547` | `UserDataService.mergeUserData` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1725` | `UserDataService.updateBankTxTime` | | — | — | find | `—` | `subdomains/generic/user/models/user/dto/user-dto.mapper.ts:27` | — | | — | — | find | `—` | `subdomains/generic/user/models/user/user.repository.ts:31` | `UserRepository.getNextRef` | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:343` | `UserService.createUser` | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:515` | `UserService.updateAddress` | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:531` | `UserService.deactivateUser` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:339` | `UserService.createUser` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:493` | `UserService.updateAddress` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:509` | `UserService.deactivateUser` | | — | — | find | `—` | `subdomains/supporting/balance/services/balance-pdf.service.ts:105` | `BalancePdfService.getBalancesForAddress` | -| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts:337` | `BankTx.bankDataName` | -| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts:349` | `BankTx.getSenderAccount` | -| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:178` | `BankTxService.enrichYapealTransactions` | -| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:281` | `BankTxService.classifyKnownTypeIfAssignable` | -| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:294` | `BankTxService.classifyKnownTypeIfAssignable` | -| — | — | raw-sql | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:597` | `BankTxService.recoverRollingInternalTransfers` | -| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:845` | `BankTxService.findMatchingBuy` | -| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:63` | `BankService.getBankInternal` | -| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:153` | `BankService.getMatchingBank` | -| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:154` | `BankService.getMatchingBank` | -| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:251` | `BankService.getReceiveIbanStatus` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts:131` | `VirtualIbanFrickIssuanceReconciliationService.runPhase1StuckIntents` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts:344` | `VirtualIbanFrickIssuanceReconciliationService.runCompletedIntentDuplicateCleanup` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts:721` | `VirtualIbanFrickIssuanceReconciliationService.loadAbandonedReferences` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:125` | `VirtualIbanService.getAccountHolder` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:302` | `VirtualIbanService.String` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:392` | `VirtualIbanService.lockUserLevelIssuanceForMerge` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:637` | `VirtualIbanService.recoverFrickIntentForReconciliation` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:655` | `VirtualIbanService.recoverFrickIntentForReconciliation` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:656` | `VirtualIbanService.recoverFrickIntentForReconciliation` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:657` | `VirtualIbanService.recoverFrickIntentForReconciliation` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:720` | `VirtualIbanService.isIbanProtectedFromReconciliationDeactivation` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:831` | `VirtualIbanService.async` | -| — | — | raw-sql | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:891` | `VirtualIbanService.hasOrderedOwnershipPath` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1152` | `VirtualIbanService.resolveVirtualIbanId` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1246` | `VirtualIbanService.getFrickIntentForUpdate` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1261` | `VirtualIbanService.getFrickIntentByIdForUpdate` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1279` | `VirtualIbanService.persistUserLevelIfMissing` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1368` | `VirtualIbanService.findActiveForUserCurrencyAndBank` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1434` | `VirtualIbanService.getVirtualIbansForAccount` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1442` | `VirtualIbanService.getFrickVirtualIbansForAccount` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1461` | `VirtualIbanService.deactivateVirtualIbanLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1501` | `VirtualIbanService.deactivateVirtualIbanLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1568` | `VirtualIbanService.resolveIssuanceIntentsForMergeLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1573` | `VirtualIbanService.resolveIssuanceIntentsForMergeLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1625` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1678` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1681` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1689` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1716` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1763` | `VirtualIbanService.mergeUserLevelVirtualIbans` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1794` | `VirtualIbanService.mergeUserLevelVirtualIbans` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1840` | `VirtualIbanService.getProvider` | +| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts:323` | `BankTx.bankDataName` | +| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts:335` | `BankTx.getSenderAccount` | +| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:172` | `BankTxService.enrichYapealTransactions` | +| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:708` | `BankTxService.findMatchingBuy` | +| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:62` | `BankService.getBankInternal` | +| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:142` | `BankService.getMatchingBank` | +| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:143` | `BankService.getMatchingBank` | +| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:216` | `BankService.getReceiveIbanStatus` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts:150` | `VirtualIbanFrickIssuanceReconciliationService.runPhase1StuckIntents` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts:471` | `VirtualIbanFrickIssuanceReconciliationService.loadAbandonedReferences` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:118` | `VirtualIbanService.getAccountHolder` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:295` | `VirtualIbanService.String` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:385` | `VirtualIbanService.lockUserLevelIssuanceForMerge` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:709` | `VirtualIbanService.async` | +| — | — | raw-sql | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:769` | `VirtualIbanService.hasOrderedOwnershipPath` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1030` | `VirtualIbanService.resolveVirtualIbanId` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1124` | `VirtualIbanService.getFrickIntentForUpdate` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1139` | `VirtualIbanService.getFrickIntentByIdForUpdate` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1157` | `VirtualIbanService.persistUserLevelIfMissing` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1246` | `VirtualIbanService.findActiveForUserCurrencyAndBank` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1312` | `VirtualIbanService.getVirtualIbansForAccount` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1320` | `VirtualIbanService.getFrickVirtualIbansForAccount` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1339` | `VirtualIbanService.deactivateVirtualIbanLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1379` | `VirtualIbanService.deactivateVirtualIbanLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1446` | `VirtualIbanService.resolveIssuanceIntentsForMergeLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1451` | `VirtualIbanService.resolveIssuanceIntentsForMergeLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1501` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1554` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1557` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1565` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1591` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1638` | `VirtualIbanService.mergeUserLevelVirtualIbans` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1669` | `VirtualIbanService.mergeUserLevelVirtualIbans` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1715` | `VirtualIbanService.getProvider` | | — | — | find | `—` | `subdomains/supporting/dex/strategies/supplementary/impl/bitcoin-testnet4.strategy.ts:35` | `BitcoinTestnet4Strategy.findTransaction` | | — | — | find | `—` | `subdomains/supporting/dex/strategies/supplementary/impl/bitcoin.strategy.ts:35` | `BitcoinStrategy.findTransaction` | | — | — | find | `—` | `subdomains/supporting/dex/strategies/supplementary/impl/cardano.strategy.ts:36` | `CardanoStrategy.findTransaction` | @@ -1137,32 +1087,33 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/supporting/dex/strategies/supplementary/impl/tron.strategy.ts:37` | `TronStrategy.findTransaction` | | — | — | find | `—` | `subdomains/supporting/dex/strategies/supplementary/impl/zano.strategy.ts:37` | `ZanoStrategy.findTransaction` | | — | — | find | `—` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:300` | `FiatOutputJobService.setReadyDate` | -| — | — | find | `—` | `subdomains/supporting/fiat-output/fiat-output.service.ts:56` | `FiatOutputService.selectPayoutBank` | -| — | — | find | `—` | `subdomains/supporting/fiat-output/fiat-output.service.ts:217` | `FiatOutputService.update` | -| — | — | find | `—` | `subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts:106` | `FiatPayInSyncService.createCheckoutTx` | -| — | — | find | `—` | `subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts:114` | `FiatPayInSyncService.createCheckoutTx` | -| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:609` | `LogJobService.getAssetLog` | -| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:616` | `LogJobService.getAssetLog` | -| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:982` | `LogJobService.getAssetLog` | -| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:1833` | `LogJobService.findSenderReceiverPair` | +| — | — | find | `—` | `subdomains/supporting/fiat-output/fiat-output.service.ts:55` | `FiatOutputService.selectPayoutBank` | +| — | — | find | `—` | `subdomains/supporting/fiat-output/fiat-output.service.ts:207` | `FiatOutputService.update` | +| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:618` | `LogJobService.getAssetLog` | +| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:625` | `LogJobService.getAssetLog` | +| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:997` | `LogJobService.getAssetLog` | +| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:1607` | `LogJobService.findSenderReceiverPair` | +| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:341` | `LogRepository.getFinancialLogAssetPrices` | +| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:511` | `LogRepository.THEN` | +| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:664` | `LogRepository.getFinancialLogSummariesChartOnly` | | — | — | find | `—` | `subdomains/supporting/notification/services/notification.service.ts:128` | `NotificationService.resolveMailWallet` | | — | — | find | `—` | `subdomains/supporting/payin/services/payin-notification.service.ts:32` | `PayInNotificationService.returnedCryptoInput` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:177` | `PayInService.getCryptoInputsByTransactionIds` | -| — | — | query-builder (alias only) | `—` | `subdomains/supporting/payin/services/payin.service.ts:185` | `PayInService.getCryptoInputByKeys` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:208` | `PayInService.getNewPayIns` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:221` | `PayInService.getAllUserTransactions` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:230` | `PayInService.getPendingPayIns` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:255` | `PayInService.acknowledgePayIn` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:300` | `PayInService.ignorePayIn` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:308` | `PayInService.retryUncertainSend` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:362` | `PayInService.updateFailedPayments` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:402` | `PayInService.forwardPayIns` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:448` | `PayInService.getUnconfirmedNextBlockPayIns` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:485` | `PayInService.checkOutputConfirmations` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:508` | `PayInService.checkReturnConfirmations` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:531` | `PayInService.returnPayIns` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:560` | `PayInService.processStrandedSendingPayIns` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:604` | `PayInService.checkInputConfirmations` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:148` | `PayInService.getCryptoInputsByTransactionIds` | +| — | — | query-builder (alias only) | `—` | `subdomains/supporting/payin/services/payin.service.ts:156` | `PayInService.getCryptoInputByKeys` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:179` | `PayInService.getNewPayIns` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:192` | `PayInService.getAllUserTransactions` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:201` | `PayInService.getPendingPayIns` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:226` | `PayInService.acknowledgePayIn` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:261` | `PayInService.ignorePayIn` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:269` | `PayInService.retryUncertainSend` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:323` | `PayInService.updateFailedPayments` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:351` | `PayInService.forwardPayIns` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:397` | `PayInService.getUnconfirmedNextBlockPayIns` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:434` | `PayInService.checkOutputConfirmations` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:457` | `PayInService.checkReturnConfirmations` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:480` | `PayInService.returnPayIns` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:509` | `PayInService.processStrandedSendingPayIns` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:553` | `PayInService.checkInputConfirmations` | | — | — | find | `—` | `subdomains/supporting/payin/strategies/register/impl/base/alchemy.strategy.ts:33` | `AlchemyStrategy.pollAddress` | | — | — | find | `—` | `subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts:81` | `CitreaBaseStrategy.getLastCheckedBlockHeight` | | — | — | find | `—` | `subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts:120` | `CitreaBaseStrategy.mapCoinTransactionsToEntries` | @@ -1180,27 +1131,25 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/supporting/payin/strategies/register/impl/zano.strategy.ts:57` | `ZanoStrategy.getLastCheckedBlockHeight` | | — | — | find | `—` | `subdomains/supporting/payin/strategies/register/impl/zano.strategy.ts:105` | `ZanoStrategy.doMapToPayInEntries` | | — | — | find | `—` | `subdomains/supporting/payment/repositories/transaction-specification.repository.ts:49` | `TransactionSpecificationRepository.findSpec` | -| — | — | find | `—` | `subdomains/supporting/payment/services/fee.service.ts:237` | `FeeService.getFeeBySpecialCode` | -| — | — | find | `—` | `subdomains/supporting/payment/services/fee.service.ts:342` | `FeeService.getFee` | +| — | — | find | `—` | `subdomains/supporting/payment/services/fee.service.ts:233` | `FeeService.getFeeBySpecialCode` | +| — | — | find | `—` | `subdomains/supporting/payment/services/fee.service.ts:338` | `FeeService.getFee` | | — | — | find | `—` | `subdomains/supporting/payment/services/swiss-qr.service.ts:391` | `SwissQRService.formatChDate` | | — | — | find | `—` | `subdomains/supporting/payment/services/swiss-qr.service.ts:409` | `SwissQRService.formatChDate` | -| — | — | find | `—` | `subdomains/supporting/payment/services/transaction-request.service.ts:301` | `TransactionRequestService.findAndComplete` | -| — | — | find | `—` | `subdomains/supporting/payment/services/transaction-request.service.ts:303` | `TransactionRequestService.findAndComplete` | -| — | — | find | `—` | `subdomains/supporting/payment/services/transaction.service.ts:162` | `TransactionService.resume` | -| — | — | find | `—` | `subdomains/supporting/payment/services/transaction.service.ts:169` | `TransactionService.resume` | -| — | — | find | `—` | `subdomains/supporting/payment/services/transaction.service.ts:179` | `TransactionService.resume` | -| — | — | find | `—` | `subdomains/supporting/payment/services/transaction.service.ts:186` | `TransactionService.resume` | -| — | — | find | `—` | `subdomains/supporting/payment/services/transaction.service.ts:442` | `TransactionService.getAllTransactionsForUserData` | +| — | — | find | `—` | `subdomains/supporting/payment/services/transaction-request.service.ts:295` | `TransactionRequestService.findAndComplete` | +| — | — | find | `—` | `subdomains/supporting/payment/services/transaction-request.service.ts:297` | `TransactionRequestService.findAndComplete` | +| — | — | find | `—` | `subdomains/supporting/payment/services/transaction.service.ts:341` | `TransactionService.getAllTransactionsForUserData` | | — | — | find | `—` | `subdomains/supporting/pricing/services/integration/coin-gecko.service.ts:155` | `CoinGeckoService.getCurrency` | | — | — | find | `—` | `subdomains/supporting/pricing/services/integration/pricing-deuro.service.ts:60` | `PricingDeuroService.getPrice` | -| — | — | find | `—` | `subdomains/supporting/realunit/realunit-job.service.ts:140` | `RealUnitJobService.findUnconsumedSettlement` | +| — | — | find | `—` | `subdomains/supporting/realunit/realunit-job.service.ts:132` | `RealUnitJobService.findUnconsumedSettlement` | | — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:339` | `RealUnitService.getHistoryEventByTxHash` | -| — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:1443` | `RealUnitService.toUserDataDtoFromUserData` | -| — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:1607` | `RealUnitService.forwardRegistration` | -| — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:2928` | `RealUnitService.applyRegistrationConfirmation` | -| — | — | query-builder (no select) | `—` | `subdomains/supporting/support-issue/services/limit-request.service.ts:71` | `LimitRequestService.updateLimitRequest` | +| — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:1442` | `RealUnitService.toUserDataDtoFromUserData` | +| — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:1606` | `RealUnitService.forwardRegistration` | +| — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:2927` | `RealUnitService.applyRegistrationConfirmation` | | — | — | find | `—` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:166` | `SupportEscalationService.bindGroupChat` | | — | — | find | `—` | `subdomains/supporting/support-issue/services/support-issue.service.ts:92` | `SupportIssueService.getSupportIssueClerkForAccount` | +| — | — | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:661` | `SupportIssueService.getIssues` | +| — | — | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:670` | `SupportIssueService.getIssue` | | — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:676` | `SupportIssueService.getIssue` | +| — | — | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:685` | `SupportIssueService.getIssueData` | | — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:728` | `SupportIssueService.getIssueFile` | | — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:743` | `SupportIssueService.getUserIssues` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 677512d22c..ea8eefed1c 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -30,8 +30,8 @@ This service loads far more data than it returns. Measured against the real enti render a PDF containing a handful of values. That query sat exactly on Postgres' limit of 1,664 columns per statement, which is why a single new column added elsewhere (`settlementEventId` on `transaction_request`) broke every invoice and receipt in production until it was fixed. -- Of the 534 endpoints, **428 reach at least one load site that fetches whole rows**; 98 read - nothing at all, and **6 read only the fields they return**. The widest query a fetching endpoint +- Of the 534 endpoints, **417 reach at least one load site that fetches whole rows**; 98 read + nothing at all, and **17 read only the fields they return**. The widest query a fetching endpoint can trigger is 308 columns at the median, and 19 of them exceed 1,000. The column limit was the symptom, not the cause. Loading a thousand columns to return one is @@ -47,13 +47,22 @@ and one on `LimitRequest` **434 across 15** — before any `relations` option is decision what to load therefore lives in the entity definition, not at the call site, and no call site can see what it triggers. -**No read model.** Of the load sites in this repository — at most 1,105, see [load-sites.md](load-sites.md#measurements) — **six** name the columns they need: -one query builder and the five raw statements. Practically all the rest request whole rows — 971 through the -`find` family, and of the 129 query builders, 105 pass the root alias to `.select(...)`, which reads +**No read model.** Of the 1,105 load sites in this repository, **94** name the columns they need: +89 query builders and the five raw statements. The other 1,011 request whole rows — 967 through the +`find` family, and of the 133 query builders, 20 pass the root alias to `.select(...)`, which reads like a projection but is not, while 23 pass no select at all. The same entities serve persistence, business logic and pure output paths such as invoices, receipts, history and exports — which need fields, not objects. +Read the first number carefully, because an earlier revision of this document got it wrong. The 89 +query builders that do name columns are almost entirely counts, maxima and id lookups — +`.select('userData.id', 'id')` and the like — and they select **one column at the median**. They are +projections, and they were miscounted as full loads because the classification only recognised the +array form `.select([...])` and read every string argument as the bare root alias. Correcting it +moves 11 endpoints out of the `whole rows` group. What it does not do is change the picture: a +`COUNT(*)` that was always narrow is not a read path that was converted, and the response payloads — +history, profile, invoices, exports — are still served by `find`. + ## Vocabulary | Term | Meaning here | @@ -146,9 +155,12 @@ per endpoint as `0/4` through `4/4`; only `4/4` is done. To any load site that carries an explicit field list — that is where a forgotten field silently yields an empty value. -Six sites carried a field list before any conversion. The table below is what the suite covers of -them — unchanged, because none of the six was converted. Sites a conversion adds are recorded per -endpoint in [endpoints.md](endpoints.md), where only `4/4` counts as done. +Ninety-four sites carry a field list. The table below covers the six that were known when this +document was written — one query builder and five raw statements — and none of them was converted, +so it is unchanged. The other 88 are the query builders that name columns one at a time; they are +not covered by these levels either, which is what their endpoints' `0/4` in +[endpoints.md](endpoints.md) records. Sites a conversion adds are recorded there too, where only +`4/4` counts as done. | Site | Form | Runs in a test | Column list asserted | Real database | | ---- | ---- | -------------- | -------------------- | ------------- | diff --git a/src/shared/models/__tests__/read-projection.spec.ts b/src/shared/models/__tests__/read-projection.spec.ts new file mode 100644 index 0000000000..7ae2ecc647 --- /dev/null +++ b/src/shared/models/__tests__/read-projection.spec.ts @@ -0,0 +1,77 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { ReadProjection } from 'src/shared/models/read-projection'; +import { + BUY_CRYPTO_BUY_HISTORY_PROJECTION, + BUY_CRYPTO_ROUTE_HISTORY_PROJECTION, +} from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; +import { BUY_FIAT_HISTORY_PROJECTION } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; +import { USER_PROFILE_PROJECTION } from 'src/subdomains/generic/user/models/user-data/user-data.repository'; +import { SelectQueryBuilder } from 'typeorm'; + +/** Every endpoint whose `Max cols` in the inventory is the size of a projection. */ +const DOCUMENTED: [string, string, ReadProjection][] = [ + ['GET', '/user/profile', USER_PROFILE_PROJECTION], + ['GET', '/buy/:id/history', BUY_CRYPTO_BUY_HISTORY_PROJECTION], + ['GET', '/swap/:id/history', BUY_CRYPTO_ROUTE_HISTORY_PROJECTION], + ['GET', '/sell/:id/history', BUY_FIAT_HISTORY_PROJECTION], +]; + +describe('ReadProjection', () => { + it('applies the joins and selects exactly the fields it was given', () => { + const selected: string[][] = []; + const joined: [string, string][] = []; + const query = { + leftJoin: (path: string, alias: string) => { + joined.push([path, alias]); + return query; + }, + select: (fields: string[]) => { + selected.push(fields); + return query; + }, + } as unknown as SelectQueryBuilder; + + const projection = new ReadProjection('root', [['root.rel', 'rel']], ['root.a', 'rel.b'], ['root.id']); + projection.apply(query); + + expect(joined).toEqual([['root.rel', 'rel']]); + expect(selected).toEqual([['root.a', 'rel.b', 'root.id']]); + }); + + it('keeps the guards when a reduced field list is passed', () => { + const selected: string[][] = []; + const query = { + leftJoin: () => query, + select: (fields: string[]) => { + selected.push(fields); + return query; + }, + } as unknown as SelectQueryBuilder; + + const projection = new ReadProjection('root', [], ['root.a', 'root.b'], ['root.id']); + // This is what the mutation test does: drop one response field, keep everything else. A guard + // dropped along with it would make every mutation run fail for the wrong reason. + projection.apply(query, ['root.a']); + + expect(selected).toEqual([['root.a', 'root.id']]); + }); + + describe('the column counts in docs/endpoints.md', () => { + // The inventory is the work list and is required to stay in sync with the code. A number + // written by hand drifts the first time a field is added, and nothing would say so — this reads + // the document and compares. + const inventory = readFileSync(join(__dirname, '../../../../docs/endpoints.md'), 'utf8').split('\n'); + + it.each(DOCUMENTED)('%s %s matches the projection', (verb, path, projection) => { + const row = inventory.find((line) => line.startsWith(`| ${verb} |`) && line.includes(`\`${path}\` |`)); + expect(row).toBeDefined(); + + const cells = row.split('|').map((cell) => cell.trim()); + const [access, maxCols] = [cells[6], cells[7]]; + + expect(access).toEqual('projected'); + expect(+maxCols).toEqual(projection.fields.length + projection.guards.length); + }); + }); +}); From 7ac7e31a873e28eba43802fc3fce0d48c5a4641f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:38:17 +0200 Subject: [PATCH 03/46] Project the support-issue read paths Three endpoints, all reading whole SupportIssue rows for a handful of values: GET /support/issue/:id/data 951 columns -> 81 GET /support/issue 450 -> 11 GET /support/issue/:id 450 -> 11 The widest of them expands four eager relations recursively and pulls in both sides of the transaction. The message thread of the single-issue endpoint is projected too, in its own repository method. The search condition of GET /support/issue/:id is passed through to the query builder via setFindOptions rather than rebuilt: it is the access check for this endpoint family - an issue is reachable by its UID, by the UID of the quote behind it, or by numeric id scoped to the owning account - and stating it twice is how the two copies drift apart. The spec asserts all three branches, and that a foreign account id resolves to nothing. Writing the tests turned up four things: - SUPPORT_MESSAGE_RESPONSE_FIELDS named `fileName`, which is a getter over `fileUrl`. The ORM does not recognise it as a column, passes the expression through unquoted, and Postgres rejects the statement with a missing FROM-clause entry for a table that appears nowhere in the query. - findThread mixed `.andWhere('...')` with `{ id: MoreThan(...) }`. The object form resolves against the find-options alias, not the builder's, with the same result. - Level 1 accepted NaN. A DTO field computed as `a + b + c` from three columns turns into NaN the moment one is missing - not absent, so an undefined check waves it through, and the endpoint answers 200 with a number that is not a number. The annual volume on this view is such a field. - Level 3 cannot assert single fields where a value has a fallback. `organizationName ?? firstname + surname` leaves the value filled whichever one is dropped, so every column reports as removable - true and useless. A candidate may now be a group of fields, and the chain is the candidate. The query-builder alias check needed to learn the new form as well: joins declared in a ReadProjection never appear in the chain it scans, so the chain's own `where('joinedAlias.id = :id')` looked like a bare column reference. --- docs/endpoints.md | 22 +- docs/load-sites.md | 38 +-- docs/read-path-projections.md | 31 +- .../models/__tests__/read-projection.spec.ts | 7 + .../__tests__/query-builder-alias.spec.ts | 32 +- src/shared/utils/projection-test.util.ts | 53 ++-- .../support-issue-data.projection.spec.ts | 276 ++++++++++++++++++ .../support-issue-view.projection.spec.ts | 204 +++++++++++++ .../repositories/support-issue.repository.ts | 235 ++++++++++++++- .../support-message.repository.ts | 50 ++++ .../services/support-issue.service.ts | 29 +- 11 files changed, 889 insertions(+), 88 deletions(-) create mode 100644 src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts create mode 100644 src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts diff --git a/docs/endpoints.md b/docs/endpoints.md index b2ba386351..f867f06b91 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -23,20 +23,20 @@ Two rules follow from that, and both are binding: 1. **An endpoint counts as converted only when its tests reach `4/4`** against the four levels in [read-path-projections.md](read-path-projections.md#test-definition). A projection without them is worse than no projection: a forgotten field does not crash, it returns a wrong value with a 200, and in a service moving money that can run for weeks unnoticed. Anything short of `4/4` is unfinished work, not a partial success. 2. **The state of every endpoint is recorded here**, in the `Tests` column, and kept in sync with the code in the same pull request that changes it. An undocumented conversion is indistinguishable from one that was never tested. -Today 19 endpoints read only what they return and 417 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. +Today 22 endpoints read only what they return and 414 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. ## What the numbers say | Data access | Endpoints | Share | | ----------- | --------: | ----: | -| `whole rows` | 417 | 78 % | +| `whole rows` | 414 | 78 % | | `none` | 98 | 18 % | -| `projected` | 17 | 3 % | +| `projected` | 20 | 4 % | | `caller-defined` | 2 | 0 % | -Of the 17 that read only what they return, 4 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14). The other 13 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. +Of the 20 that read only what they return, 7 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11). The other 13 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. -Among the 417 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 313 exceed 100, 87 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. +Among the 414 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 313 exceed 100, 87 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. ### How to read this column, and how not to @@ -50,10 +50,10 @@ Among the 417 that fetch whole rows, the widest query they can trigger is **308 Stated exactly, so the numbers can be checked rather than believed: -- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 417 is a lower bound. +- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 414 is a lower bound. - All 98 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. -- The 17 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. -- 5 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue`, `GET /support/issue/:id`, `GET /support/issue/:id/data`, `GET /support/issue/:id/message/:messageId/file`. The classification holds; only the width is unknown. +- The 20 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. +- 2 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`. The classification holds; only the width is unknown. ### Two controller classes may share a name @@ -533,12 +533,12 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/support/call-queues` | hidden | none | — | n/a | | `SupportController.getCallQueues` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues/:queue/items` | hidden | whole rows | 672 | not yet | | `SupportController.getCallQueueItems` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues/clerks` | hidden | none | — | n/a | | `SupportController.getCallQueueClerks` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/issue` | public | whole rows | — | not yet | | `SupportIssueController.getIssues` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue` | public | projected | 11 | 4/4 | | `SupportIssueController.getIssues` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue` | public | whole rows | 493 | not yet | | `SupportIssueController.createIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/:id` | public | whole rows | — | not yet | | `SupportIssueController.getIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/:id` | public | projected | 11 | 4/4 | | `SupportIssueController.getIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | PUT | 1 | | `/support/issue/:id` | hidden | whole rows | 421 | not yet | | `SupportIssueController.updateSupportIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | PUT | 1 | | `/support/issue/:id/close` | public | whole rows | 450 | not yet | | `SupportIssueController.closeIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/:id/data` | hidden | whole rows | — | not yet | | `SupportIssueController.getIssueData` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/:id/data` | hidden | projected | 81 | 4/4 | | `SupportIssueController.getIssueData` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/:id/message` | public | whole rows | 441 | not yet | yes | `SupportIssueController.createSupportMessage` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/:id/message/:messageId/file` | public | whole rows | — | not yet | | `SupportIssueController.getFile` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/activity` | hidden | whole rows | 7 | not yet | | `SupportIssueController.getSupportIssueActivity` | `subdomains/supporting/support-issue/support-issue.controller.ts` | diff --git a/docs/load-sites.md b/docs/load-sites.md index 7cb5b6c44b..80b0172e5e 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -1,6 +1,6 @@ # Database load sites -Every place in the code that reads from the database: **1105 load sites** across 244 files. +Every place in the code that reads from the database: **1105 load sites** across 246 files. This is the level at which the statement is unambiguous. An endpoint reaches several load sites — a permission check, a lookup, the actual query — so asking whether *an endpoint* loads efficiently has no single answer. Asking it of a load site does. [endpoints.md](endpoints.md) carries the per-endpoint summary derived from these sites. @@ -8,8 +8,8 @@ This is the level at which the statement is unambiguous. An endpoint reaches sev | Mechanism | Sites | Eager relations | Columns selected | | --------- | ----: | --------------- | ---------------- | -| `find` family | 967 | **applied** — expanded recursively | all columns of the entity plus every eager relation | -| `createQueryBuilder` | 133 | not applied | all columns of the root entity, unless `.select([...])` narrows it | +| `find` family | 963 | **applied** — expanded recursively | all columns of the entity plus every eager relation | +| `createQueryBuilder` | 137 | not applied | all columns of the root entity, unless `.select([...])` narrows it | | raw SQL | 5 | not applied | whatever the statement lists | Statements that load nothing are excluded from the count: 2 `createQueryBuilder` calls carrying `.update()`, 6 advisory locks (`SELECT pg_advisory_xact_lock(...)`, which return no rows) and 1 raw `INSERT`. Each of the 5 raw reads that remain names its columns. @@ -18,21 +18,21 @@ Among the query builders, the field list is what decides whether anything is act | | Sites | | --- | ---: | -| `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **5** | +| `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **9** | | `.select('alias.column')` — names columns one by one | **84** | | `.select('alias')` — selects the root alias, **loads every column** | 20 | | no `select` at all — loads every column | 23 | | projects, but a `leftJoinAndSelect` loads a relation whole | 1 | -`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The distinction is the presence of a dot in the argument, and it matters — the sites that name columns this way select 1 column at the median, against 967 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. +`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The distinction is the presence of a dot in the argument, and it matters — the sites that name columns this way select 1 column at the median, against 963 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. ## Measurements -Columns were measured against the real entity metadata by building the query and counting its SELECT list — 786 of 1105 sites. +Columns were measured against the real entity metadata by building the query and counting its SELECT list — 790 of 1105 sites. - **341 are exact**: the `relations` tree is written at the call site. -- **445 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. -- 319 could not be measured: no resolvable target entity, or raw SQL. +- **449 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. +- 315 could not be measured: no resolvable target entity, or raw SQL. Median across measured sites: **112 columns**. 14 sites exceed 1000, 75 exceed 500, 402 exceed 100. @@ -151,7 +151,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 451 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:42` | `BuyFiatNotificationService.paymentCompleted` | | 451 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:290` | `BuyFiatNotificationService.chargebackUnconfirmed` | | 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:457` | `SupportIssueService.closeIssue` | -| 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:737` | `SupportIssueService.getUserIssues` | +| 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:716` | `SupportIssueService.getUserIssues` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/accounting/services/ledger-cutover.service.ts:475` | `LedgerCutoverService.openBuyCryptoReceived` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/accounting/services/ledger-cutover.service.ts:536` | `LedgerCutoverService.openBuyCryptoOwed` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:25` | `BuyCryptoRegistrationService.syncReturnTxId` | @@ -167,14 +167,14 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 434 | 15 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request.service.ts:82` | `LimitRequestService.getUserLimitRequests` | | 433 | 12 | find | `BankTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:106` | `BankTxConsumer.processForward` | | 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:478` | `SupportIssueService.closeIssue` | -| 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:723` | `SupportIssueService.getIssueMessages` | +| 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:702` | `SupportIssueService.getIssueMessages` | | 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:67` | `TransactionRequestService.txRequestWaitingExpiryCheck` | | 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:78` | `TransactionRequestService.deleteOldTxRequests` | | 422 | 12 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:778` | `BuyCryptoService.resetAmlCheck` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:214` | `SupportEscalationService.checkEscalations` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:484` | `SupportIssueService.updateIssue` | -| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:713` | `SupportIssueService.getIssueMessages` | -| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:750` | `SupportIssueService.getIssueUserDataId` | +| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:692` | `SupportIssueService.getIssueMessages` | +| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:729` | `SupportIssueService.getIssueUserDataId` | | 419 | 14 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1186` | `BuyCryptoService.getTransactions` | | 418 | 18 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:80` | `CustodyJobService.executeStep` | | 418 | 11 | find | `User` | `subdomains/generic/user/models/user/user-job.service.ts:19` | `UserJobService.approveUser` | @@ -484,6 +484,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:110` | `LiquidityManagementRuleService.reactivateRules` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:147` | `LiquidityManagementRuleService.findExistingRuleOnCreation` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:109` | `LiquidityManagementService.findRuleByAssetOrThrow` | +| 81 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:212` | `SupportIssueRepository.findIssueData` | | 78 | 1 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:97` | `UserService.getUserByAddress` | | 78 | 3 | find | `Mros` | `subdomains/supporting/mros/mros.service.ts:32` | `MrosService.update` | | 77 | 0 | query-builder (nur-alias) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:712` | `BuyCryptoService.getBuyCryptoByKeys` | @@ -695,7 +696,9 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:51` | `LogService.update` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:131` | `LogService.getLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:135` | `LogService.maxEntity` | -| 11 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.service.ts:191` | `LogService.getBankLog` | +| 11 | 0 | query-builder (ohne-select) | `Log` | `subdomains/supporting/log/log.service.ts:191` | `LogService.getBankLog` | +| 11 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:222` | `SupportIssueRepository.findIssuesForAccount` | +| 11 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:240` | `SupportIssueRepository.findIssueBy` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:61` | `AccountMergeService.sendMergeRequest` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:162` | `AccountMergeService.pendingMergeRequest` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:57` | `SupportNoteService.search` | @@ -749,6 +752,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:210` | `SettingService.setObj` | | 5 | 0 | find | `Sanction` | `subdomains/core/aml/services/sanction.service.ts:54` | `SanctionService.syncList` | | 5 | 0 | query-builder (spaltenliste) | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:84` | `SupportNoteService.listUsers` | +| 5 | 0 | query-builder (feldliste) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:52` | `SupportMessageRepository.findThread` | | 4 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:540` | `LedgerQueryService.cumulativeEquityByDay` | | 4 | 0 | find | `SystemStateSnapshot` | `subdomains/core/monitoring/monitoring.service.ts:47` | `MonitoringService.loadState` | | 4 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:614` | `SupportIssueService.getMessageStats` | @@ -1147,9 +1151,5 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:2927` | `RealUnitService.applyRegistrationConfirmation` | | — | — | find | `—` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:166` | `SupportEscalationService.bindGroupChat` | | — | — | find | `—` | `subdomains/supporting/support-issue/services/support-issue.service.ts:92` | `SupportIssueService.getSupportIssueClerkForAccount` | -| — | — | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:661` | `SupportIssueService.getIssues` | -| — | — | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:670` | `SupportIssueService.getIssue` | -| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:676` | `SupportIssueService.getIssue` | -| — | — | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:685` | `SupportIssueService.getIssueData` | -| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:728` | `SupportIssueService.getIssueFile` | -| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:743` | `SupportIssueService.getUserIssues` | +| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:707` | `SupportIssueService.getIssueFile` | +| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:722` | `SupportIssueService.getUserIssues` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index ea8eefed1c..b69132e1a9 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -30,8 +30,8 @@ This service loads far more data than it returns. Measured against the real enti render a PDF containing a handful of values. That query sat exactly on Postgres' limit of 1,664 columns per statement, which is why a single new column added elsewhere (`settlementEventId` on `transaction_request`) broke every invoice and receipt in production until it was fixed. -- Of the 534 endpoints, **417 reach at least one load site that fetches whole rows**; 98 read - nothing at all, and **17 read only the fields they return**. The widest query a fetching endpoint +- Of the 534 endpoints, **414 reach at least one load site that fetches whole rows**; 98 read + nothing at all, and **20 read only the fields they return**. The widest query a fetching endpoint can trigger is 308 columns at the median, and 19 of them exceed 1,000. The column limit was the symptom, not the cause. Loading a thousand columns to return one is @@ -47,9 +47,9 @@ and one on `LimitRequest` **434 across 15** — before any `relations` option is decision what to load therefore lives in the entity definition, not at the call site, and no call site can see what it triggers. -**No read model.** Of the 1,105 load sites in this repository, **94** name the columns they need: -89 query builders and the five raw statements. The other 1,011 request whole rows — 967 through the -`find` family, and of the 133 query builders, 20 pass the root alias to `.select(...)`, which reads +**No read model.** Of the 1,105 load sites in this repository, **98** name the columns they need: +93 query builders and the five raw statements. The other 1,007 request whole rows — 963 through the +`find` family, and of the 137 query builders, 20 pass the root alias to `.select(...)`, which reads like a projection but is not, while 23 pass no select at all. The same entities serve persistence, business logic and pure output paths such as invoices, receipts, history and exports — which need fields, not objects. @@ -59,7 +59,7 @@ query builders that do name columns are almost entirely counts, maxima and id lo `.select('userData.id', 'id')` and the like — and they select **one column at the median**. They are projections, and they were miscounted as full loads because the classification only recognised the array form `.select([...])` and read every string argument as the bare root alias. Correcting it -moves 11 endpoints out of the `whole rows` group. What it does not do is change the picture: a +moved 11 endpoints out of the `whole rows` group. What it does not do is change the picture: a `COUNT(*)` that was always narrow is not a read path that was converted, and the response payloads — history, profile, invoices, exports — are still served by `find`. @@ -155,11 +155,12 @@ per endpoint as `0/4` through `4/4`; only `4/4` is done. To any load site that carries an explicit field list — that is where a forgotten field silently yields an empty value. -Ninety-four sites carry a field list. The table below covers the six that were known when this +Ninety-eight sites carry a field list. The table below covers the six that were known when this document was written — one query builder and five raw statements — and none of them was converted, -so it is unchanged. The other 88 are the query builders that name columns one at a time; they are +so it is unchanged. Another 84 are the query builders that name columns one at a time; they are not covered by these levels either, which is what their endpoints' `0/4` in -[endpoints.md](endpoints.md) records. Sites a conversion adds are recorded there too, where only +[endpoints.md](endpoints.md) records. The remaining 8 belong to the endpoints converted so far and +are covered on all four. Sites a conversion adds are recorded there too, where only `4/4` counts as done. | Site | Form | Runs in a test | Column list asserted | Real database | @@ -306,6 +307,18 @@ at exactly that point — and a real defect would have slipped through there. Without this level you never know whether a green test verified something or is merely green. +**Where a value has a fallback, the candidate is the chain, not the column.** `UserData.completeName` +is `organizationName ?? firstname + surname`; the wallet name on the support view is +`displayName ?? name`. Drop any single one of those columns and the value is still filled, by the +next alternative — so asserted individually, every one of them reports as removable. That is true +and useless. The chain is what carries the response value, so the chain is what gets dropped, and +`expectEveryFieldRequired` accepts a group of fields as one candidate for exactly this. + +**A missing summand is not a missing field, and level 1 has to say so anyway.** The annual volume on +the support view is `annualBuyVolume + annualSellVolume + annualCryptoVolume`. Leave one of the three +out of the projection and the sum is `NaN` — not absent, so an `undefined` check waves it through, +and the endpoint answers 200 with a number that is not a number. `NaN` therefore counts as empty. + **It needs a baseline, or it is itself merely green.** If the response is already incomplete with the *full* field list, every reduced run fails too, and "every field is required" comes out true without a single field having been shown to matter. That happened while the first conversions were diff --git a/src/shared/models/__tests__/read-projection.spec.ts b/src/shared/models/__tests__/read-projection.spec.ts index 7ae2ecc647..261f5e5bf2 100644 --- a/src/shared/models/__tests__/read-projection.spec.ts +++ b/src/shared/models/__tests__/read-projection.spec.ts @@ -7,6 +7,10 @@ import { } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; import { BUY_FIAT_HISTORY_PROJECTION } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; import { USER_PROFILE_PROJECTION } from 'src/subdomains/generic/user/models/user-data/user-data.repository'; +import { + SUPPORT_ISSUE_DATA_PROJECTION, + SUPPORT_ISSUE_PROJECTION, +} from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; import { SelectQueryBuilder } from 'typeorm'; /** Every endpoint whose `Max cols` in the inventory is the size of a projection. */ @@ -15,6 +19,9 @@ const DOCUMENTED: [string, string, ReadProjection][] = [ ['GET', '/buy/:id/history', BUY_CRYPTO_BUY_HISTORY_PROJECTION], ['GET', '/swap/:id/history', BUY_CRYPTO_ROUTE_HISTORY_PROJECTION], ['GET', '/sell/:id/history', BUY_FIAT_HISTORY_PROJECTION], + ['GET', '/support/issue/:id/data', SUPPORT_ISSUE_DATA_PROJECTION], + ['GET', '/support/issue', SUPPORT_ISSUE_PROJECTION], + ['GET', '/support/issue/:id', SUPPORT_ISSUE_PROJECTION], ]; describe('ReadProjection', () => { diff --git a/src/shared/utils/__tests__/query-builder-alias.spec.ts b/src/shared/utils/__tests__/query-builder-alias.spec.ts index f31076713b..130b89017d 100644 --- a/src/shared/utils/__tests__/query-builder-alias.spec.ts +++ b/src/shared/utils/__tests__/query-builder-alias.spec.ts @@ -132,8 +132,34 @@ describe('Query Builder Alias Enforcement', () => { /** * Extract all aliases defined in a query chain (main alias + joins + subqueries) */ - const extractAllAliases = (queryChain: string, mainAlias: string): Set => { - const aliases = new Set([mainAlias]); + /** + * Aliases declared by a `ReadProjection` constant rather than in the chain. + * + * `PROJECTION.apply(this.createQueryBuilder('x'))` applies its joins from the constant, so the + * aliases they introduce never appear in the chain the scan sees. Without this the chain's own + * `where('joinedAlias.id = :id')` looks like a bare column reference — the opposite of what this + * test is for. + */ + const extractProjectionAliases = (fileContent: string): Set => { + const aliases = new Set(); + // The root alias, then every `['relation.path', 'alias']` pair up to the end of the declaration. + // Reading the join array with a lazy match would stop at the first inner `]` and pick up only + // one of the joins. + const projectionPattern = /new ReadProjection<[^>]*>\(\s*['"`](\w+)['"`]/g; + let match; + while ((match = projectionPattern.exec(fileContent)) !== null) { + aliases.add(match[1]); + const end = fileContent.indexOf('\n);', match.index); + const declaration = fileContent.slice(match.index, end < 0 ? undefined : end); + const joinPattern = /\[\s*['"`][^'"`]+\.[^'"`]+['"`]\s*,\s*['"`](\w+)['"`]\s*\]/g; + let joinMatch; + while ((joinMatch = joinPattern.exec(declaration)) !== null) aliases.add(joinMatch[1]); + } + return aliases; + }; + + const extractAllAliases = (queryChain: string, mainAlias: string, fileContent = ''): Set => { + const aliases = new Set([mainAlias, ...extractProjectionAliases(fileContent)]); // Find join aliases: .leftJoin('relation', 'alias') or .innerJoin('relation', 'alias') // This handles both relation joins and entity joins @@ -210,7 +236,7 @@ describe('Query Builder Alias Enforcement', () => { // Get all valid aliases (main + joins + subqueries) // Also include all query aliases from the file for correlated subquery support - const validAliases = extractAllAliases(queryChain, mainAlias); + const validAliases = extractAllAliases(queryChain, mainAlias, content); for (const alias of allQueryAliases) { validAliases.add(alias); } diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts index 09bae03464..78f6dcce67 100644 --- a/src/shared/utils/projection-test.util.ts +++ b/src/shared/utils/projection-test.util.ts @@ -194,15 +194,21 @@ export async function seedEntity( /** * Level 1 — with a fully populated fixture, no field of the response may be empty. * - * Walks nested objects and arrays. `undefined`, `null` and `''` count as empty; `0` and `false` do - * not, because they are legitimate values a projection can load correctly. + * Walks nested objects and arrays. `undefined`, `null`, `''` and `NaN` count as empty; `0` and + * `false` do not, because they are legitimate values a projection can load correctly. + * + * `NaN` belongs on that list even though nothing produces it directly: a DTO field computed as + * `a + b + c` from three columns turns into `NaN` the moment one of them is missing. It is not + * absent, so a plain `undefined` check waves it through — and it is exactly the silent wrong value + * this whole exercise is meant to catch. The annual volume on the support view is such a field. * * `optional` lists paths that are allowed to be empty for the fixture at hand — a field the DTO * only fills for one branch. Every entry is a statement that the *other* branch covers it, which is * what level 2 is for, so keep the list short and cover the counterpart. */ export function expectNoEmptyFields(value: unknown, optional: string[] = [], path = ''): void { - const empty = value === undefined || value === null || value === ''; + const empty = + value === undefined || value === null || value === '' || (typeof value === 'number' && Number.isNaN(value)); if (empty) { if (optional.includes(path)) return; throw new Error(`field '${path || ''}' is empty — the query did not load it`); @@ -221,28 +227,35 @@ export function expectNoEmptyFields(value: unknown, optional: string[] = [], pat * Passed to a mutation run to keep the projection whole — it matches no field name, so * `projectionFieldsWithout` returns the list unchanged. Used for the baseline run. */ -export const NOTHING_OMITTED = ''; +export const NOTHING_OMITTED: string[] = []; -/** A projection's field list with one field removed. */ -export function projectionFieldsWithout(fields: ReadonlyArray, omitted: string): string[] { - return fields.filter((field) => field !== omitted); +/** A projection's field list without the given field or fields. */ +export function projectionFieldsWithout(fields: ReadonlyArray, omitted: string | string[]): string[] { + const drop = new Set(Array.isArray(omitted) ? omitted : [omitted]); + return fields.filter((field) => !drop.has(field)); } /** - * Level 3 — removing any single field of the projection must break level 1. + * Level 3 — removing any candidate from the projection must break level 1. + * + * `run` receives the fields to leave out, runs the same production query without them and returns + * the response. The caller does the reducing, because which fields feed the response can depend on + * the fixture: `UserData.address` reads the organization's address for a business account and the + * account's own for a personal one, so each variant asserts over its own set of candidates while the + * rest of the projection stays in the query. * - * `run` receives the name of the field to leave out, runs the same production query without it and - * returns the response. The caller does the reducing, because which fields feed the response can - * depend on the fixture: `UserData.address` reads the organization's address for a business account - * and the account's own for a personal one, so each variant asserts over its own set of candidates - * while the rest of the projection stays in the query. + * **A candidate may be a group of fields, and sometimes has to be.** Where several columns feed one + * response value through a fallback — `organizationName ?? firstname + surname` — no single one of + * them is required: drop any one and the next alternative fills the value. Asserting them + * individually would report every one of them as removable, which is true and useless. The group is + * what carries the value, so the group is what gets dropped. * - * A field whose removal changes nothing is either unnecessary or a gap in the fixture; both need - * looking at, so this reports the field names rather than just failing. + * A candidate whose removal changes nothing is either unnecessary or a gap in the fixture; both need + * looking at, so this reports the names rather than just failing. */ export async function expectEveryFieldRequired( - candidates: ReadonlyArray, - run: (omitted: string) => Promise, + candidates: ReadonlyArray, + run: (omitted: string | string[]) => Promise, optional: string[] = [], ): Promise { // Establish the baseline first. Without it this level passes vacuously: if the response is already @@ -260,16 +273,16 @@ export async function expectEveryFieldRequired( } const survived: string[] = []; - for (const field of candidates) { + for (const candidate of candidates) { let stillComplete = false; try { - expectNoEmptyFields(await run(field), optional); + expectNoEmptyFields(await run(candidate), optional); stillComplete = true; } catch { // Either the response lost a value or the query itself refused to run without the field. // Both mean the field carries weight, which is what this level asserts. } - if (stillComplete) survived.push(field); + if (stillComplete) survived.push(Array.isArray(candidate) ? `[${candidate.join(' + ')}]` : candidate); } if (survived.length) throw new Error( diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts new file mode 100644 index 0000000000..6af51b9125 --- /dev/null +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts @@ -0,0 +1,276 @@ +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { Fiat } from 'src/shared/models/fiat/fiat.entity'; +import { + SUPPORT_ISSUE_DATA_ACCOUNT_FIELDS, + SUPPORT_ISSUE_DATA_ISSUE_FIELDS, + SUPPORT_ISSUE_DATA_LIMIT_REQUEST_FIELDS, + SUPPORT_ISSUE_DATA_PROJECTION, + SUPPORT_ISSUE_DATA_TRANSACTION_FIELDS, + SupportIssueRepository, +} from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; +import { SupportIssue } from 'src/subdomains/supporting/support-issue/entities/support-issue.entity'; +import { SupportIssueDtoMapper } from 'src/subdomains/supporting/support-issue/dto/support-issue-dto.mapper'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; +import { BankData } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; +import { LimitRequest } from 'src/subdomains/supporting/support-issue/entities/limit-request.entity'; +import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; +import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'support_issue_data_projection_spec'; + +/** + * `GET /support/issue/:id/data` — the four levels from `docs/read-path-projections.md`. + * + * The widest read path in the service: the unprojected load reaches 951 columns for a response of + * about sixty values, because the issue's four eager relations expand recursively and the + * transaction pulls in both of its sides. + * + * The branch worth testing is `mapTransactionData`, which reads `buyCrypto ?? buyFiat`. A projection + * covering only one of the two answers 200 with an empty transaction block for every issue on the + * other. + */ +describeProjection('GET /support/issue/:id/data — read-path projection', () => { + let dataSource: DataSource; + let repository: SupportIssueRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + repository = new SupportIssueRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** A crypto input whose asset carries a blockchain the explorer knows. */ + async function seedCryptoInput(): Promise { + const asset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + return seedEntity(dataSource, CryptoInput, { values: { asset } }); + } + + /** + * An issue with a transaction on the requested side, and everything else populated. + * + * `named` picks which side of two fallback chains the fixture exercises. `UserData.completeName` + * is `organizationName ?? firstname + surname`, and the wallet name is `displayName ?? name`: with + * the first alternative present the second never runs, so one fixture can only ever show that half + * of those fields are needed. Setting the first to `null` is what makes the rest load-bearing. + */ + async function seedIssue( + side: 'buyCrypto' | 'buyFiat' | 'none', + named: 'organization' | 'personal' = 'organization', + ): Promise { + const personal = named === 'personal'; + const userData = await seedEntity(dataSource, UserData, { + values: personal ? { organizationName: null } : {}, + relations: { country: true, language: true }, + }); + let transaction: Transaction = null; + if (side !== 'none') { + // The wallet block of the response hangs off `transaction.user.wallet`, and both are nullable + // in the schema — a fixture without them says nothing about those four fields. + const wallet = await seedEntity(dataSource, Wallet, { + values: personal ? { displayName: null } : {}, + }); + const user = await seedEntity(dataSource, User, { values: { userData, wallet } }); + transaction = await seedEntity(dataSource, Transaction, { values: { userData, user } }); + if (side === 'buyCrypto') { + const outputAsset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + await seedEntity(dataSource, BuyCrypto, { + values: { transaction, outputAsset, cryptoInput: await seedCryptoInput() }, + }); + } else { + const outputAsset = await seedEntity(dataSource, Fiat); + // An active sell route must carry a bankData; a check constraint on `deposit_route` + // enforces it, and `BuyFiat.sell` is not nullable. + const bankData = await seedEntity(dataSource, BankData); + const sell = await seedEntity(dataSource, Sell, { values: { user, bankData } }); + await seedEntity(dataSource, BuyFiat, { + values: { transaction, outputAsset, sell, cryptoInput: await seedCryptoInput() }, + }); + } + } + const limitRequest = await seedEntity(dataSource, LimitRequest); + return seedEntity(dataSource, SupportIssue, { + values: { + userData, + transaction, + limitRequest, + // Every field the transactionMissing block reports, so that an empty one means the column + // was not loaded rather than never written. + information: JSON.stringify({ senderIban: 'CH10', receiverIban: 'CH20', date: '2024-01-01' }), + }, + }); + } + + /** The response the endpoint produces, through the projected query. */ + async function issueDataOf(id: number, fields = SUPPORT_ISSUE_DATA_PROJECTION.fields, hideLimitRequest = false) { + const issue = await repository.findIssueData(id, fields); + return SupportIssueDtoMapper.mapSupportIssueData(issue, hideLimitRequest); + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — an issue on a buy transaction answers with no empty field', async () => { + const issue = await seedIssue('buyCrypto'); + + expectNoEmptyFields(await issueDataOf(issue.id)); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — an issue on a sell transaction reads the other side', async () => { + const issue = await seedIssue('buyFiat'); + + const data = await issueDataOf(issue.id); + + // The fiat side has no output blockchain: its output asset is a currency, not a token. That is + // the one field the two sides do not share, so it is the one exception here. + expect(data.transaction.outputBlockchain).toBeUndefined(); + expectNoEmptyFields(data, ['transaction.outputBlockchain']); + }, 120000); + + it('level 2 — an issue without a transaction answers without a transaction block', async () => { + const issue = await seedIssue('none'); + + const data = await issueDataOf(issue.id); + + // `mapTransactionData` returns undefined when there is no transaction id. Everything the issue + // and the account carry must still be complete — otherwise a left join was written as an inner + // one, and every issue raised without a transaction would 404 or come back empty. + expect(data.transaction).toBeUndefined(); + expectNoEmptyFields(data, ['transaction']); + }, 120000); + + it('level 2 — the limit request is withheld from support staff but the rest is not', async () => { + const issue = await seedIssue('buyCrypto'); + + const hidden = await issueDataOf(issue.id, SUPPORT_ISSUE_DATA_PROJECTION.fields, true); + + // The role decides whether the mapper runs, not what the query loads — so hiding it must not + // take anything else with it. + expect(hidden.limitRequest).toBeUndefined(); + expectNoEmptyFields(hidden, ['limitRequest']); + }, 120000); + + it('level 2 — an issue with no additional information answers without that block', async () => { + const userData = await seedEntity(dataSource, UserData, { + relations: { country: true, language: true }, + }); + const limitRequest = await seedEntity(dataSource, LimitRequest); + const issue = await seedEntity(dataSource, SupportIssue, { + values: { userData, transaction: null, limitRequest, information: null }, + }); + + const data = await issueDataOf(issue.id); + + expect(data.transactionMissing).toBeUndefined(); + expectNoEmptyFields(data, ['transaction', 'transactionMissing']); + }, 120000); + + // --- LEVEL 3: mutation --- // + + // Two response values are fed by a fallback chain, so the chain is the candidate rather than each + // of its columns: `completeName` is `organizationName ?? firstname + surname`, and the wallet name + // is `displayName ?? name`. Dropping any single column leaves the value filled by the next + // alternative — true of every one of them, and therefore no evidence about any of them. + const COMPLETE_NAME_CHAIN = ['userData.organizationName', 'userData.firstname', 'userData.surname']; + const WALLET_NAME_CHAIN = ['transactionUserWallet.displayName', 'transactionUserWallet.name']; + const CHAINED = [...COMPLETE_NAME_CHAIN, ...WALLET_NAME_CHAIN]; + + it.each([ + [ + 'buyCrypto', + 'organization', + [ + ...[ + ...SUPPORT_ISSUE_DATA_ISSUE_FIELDS, + ...SUPPORT_ISSUE_DATA_ACCOUNT_FIELDS, + ...SUPPORT_ISSUE_DATA_LIMIT_REQUEST_FIELDS, + ...SUPPORT_ISSUE_DATA_TRANSACTION_FIELDS.filter((field) => !field.startsWith('buyFiat')), + ].filter((field) => !CHAINED.includes(field)), + COMPLETE_NAME_CHAIN, + WALLET_NAME_CHAIN, + ], + ], + [ + 'buyFiat', + 'organization', + SUPPORT_ISSUE_DATA_TRANSACTION_FIELDS.filter( + (field) => field.startsWith('buyFiat') && field !== 'buyFiatOutputAsset.blockchain', + ), + ], + ] as ['buyCrypto' | 'buyFiat', 'organization' | 'personal', (string | string[])[]][])( + 'level 3 — for a %s transaction on a %s account every field feeding the response is required', + async (side, named, candidates) => { + const issue = await seedIssue(side, named); + // The fiat fixture reaches only the fiat side of the mapper, so the crypto fields are asserted + // by the crypto row and vice versa — dropping the other side's fields here would prove nothing. + const optional = side === 'buyFiat' ? ['transaction.outputBlockchain'] : []; + + await expectEveryFieldRequired( + candidates, + (omitted) => issueDataOf(issue.id, projectionFieldsWithout(SUPPORT_ISSUE_DATA_PROJECTION.fields, omitted)), + optional, + ); + }, + 300000, + ); + + // --- LEVEL 4: consistency against a second source --- // + + it.each(['buyCrypto', 'buyFiat', 'none'] as const)( + 'level 4 — for a %s issue the projected response equals the one from a full load', + async (side) => { + const issue = await seedIssue(side); + + const projected = await issueDataOf(issue.id); + // The unprojected load is the second source: the relation set the endpoint used before the + // conversion, fetching every column of each. + const full = await dataSource.getRepository(SupportIssue).findOne({ + where: { id: issue.id }, + relations: { + userData: { country: true, language: true }, + transaction: { + user: { wallet: true }, + buyCrypto: { outputAsset: true, cryptoInput: { asset: true } }, + buyFiat: { outputAsset: true, cryptoInput: { asset: true } }, + }, + limitRequest: true, + }, + loadEagerRelations: false, + }); + + expect(projected).toEqual(SupportIssueDtoMapper.mapSupportIssueData(full, false)); + }, + 120000, + ); + + // --- the projection must not lose what the endpoint checks before mapping --- // + + it('loads the account id the customer scope is enforced on', async () => { + const issue = await seedIssue('buyCrypto'); + + const loaded = await repository.findIssueData(issue.id); + + // `getIssueData` answers 404 when the issue does not belong to a scoped customer, and reads + // `issue.userData?.id` to decide. Losing it would open every issue to every tenant. + expect(loaded.userData?.id).toEqual(issue.userData.id); + }, 120000); +}); diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts new file mode 100644 index 0000000000..7af77006ef --- /dev/null +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts @@ -0,0 +1,204 @@ +import { + SUPPORT_ISSUE_PROJECTION, + SUPPORT_ISSUE_RESPONSE_FIELDS, + SupportIssueRepository, +} from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; +import { + SUPPORT_MESSAGE_PROJECTION, + SUPPORT_MESSAGE_RESPONSE_FIELDS, + SupportMessageRepository, +} from 'src/subdomains/supporting/support-issue/repositories/support-message.repository'; +import { SupportIssue } from 'src/subdomains/supporting/support-issue/entities/support-issue.entity'; +import { SupportIssueDtoMapper } from 'src/subdomains/supporting/support-issue/dto/support-issue-dto.mapper'; +import { SupportMessage } from 'src/subdomains/supporting/support-issue/entities/support-message.entity'; +import { LimitRequest } from 'src/subdomains/supporting/support-issue/entities/limit-request.entity'; +import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; +import { SupportIssueInternalState } from 'src/subdomains/supporting/support-issue/enums/support-issue.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { ConfigService } from 'src/config/config'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'support_issue_view_projection_spec'; + +/** + * `GET /support/issue` and `GET /support/issue/:id` — the four levels from + * `docs/read-path-projections.md`. + * + * Both answer through `SupportIssueDtoMapper.mapSupportIssue`, and both loaded whole `SupportIssue` + * rows before: 450 columns for nine values. `GET /support/issue/:id` additionally loads the message + * thread, which is projected separately. + * + * The search condition of `GET /support/issue/:id` is the access check for this endpoint family, so + * it gets assertions of its own below. + */ +describeProjection('support issue view — read-path projection', () => { + let dataSource: DataSource; + let issues: SupportIssueRepository; + let messages: SupportMessageRepository; + + beforeAll(async () => { + // `Transaction.url` is a getter over the module-level Config; without this it reads undefined + // and the mapper throws before any assertion is reached. + new ConfigService(); + dataSource = await createProjectionDataSource(SCHEMA); + issues = new SupportIssueRepository(dataSource.manager); + messages = new SupportMessageRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + async function seedIssue( + withTransaction = true, + state = SupportIssueInternalState.IN_PROGRESS, + ): Promise<{ issue: SupportIssue; userData: UserData }> { + const userData = await seedEntity(dataSource, UserData); + const transaction = withTransaction + ? await seedEntity(dataSource, Transaction, { values: { userData } }) + : null; + const limitRequest = await seedEntity(dataSource, LimitRequest); + // `state` is a TypeScript enum in a text column: a generated value is not a member, and the + // mapper that translates it to the public state then answers undefined. + const issue = await seedEntity(dataSource, SupportIssue, { + values: { userData, transaction, limitRequest, state }, + }); + return { issue, userData }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — the issue list answers with no empty field', async () => { + const { userData } = await seedIssue(); + + const list = (await issues.findIssuesForAccount(userData.id)).map(SupportIssueDtoMapper.mapSupportIssue); + + expect(list).toHaveLength(1); + // The list endpoint does not load the thread — `mapSupportIssue` falls back to an empty array. + expectNoEmptyFields(list, ['[0].messages']); + }, 120000); + + it('level 1 — a single issue answers with no empty field, thread included', async () => { + const { issue } = await seedIssue(); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + const loaded = await issues.findIssueBy({ uid: issue.uid }); + loaded.messages = await messages.findThread(loaded.id); + + expectNoEmptyFields(SupportIssueDtoMapper.mapSupportIssue(loaded)); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — an issue without a transaction answers without one', async () => { + const { issue } = await seedIssue(false); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + const loaded = await issues.findIssueBy({ uid: issue.uid }); + loaded.messages = await messages.findThread(loaded.id); + const dto = SupportIssueDtoMapper.mapSupportIssue(loaded); + + // `mapTransaction` answers null without a transaction id. The rest must stay complete, or the + // left join was written as an inner one and every issue raised without a transaction vanishes. + expect(dto.transaction).toBeNull(); + expectNoEmptyFields(dto, ['transaction']); + }, 120000); + + it('level 2 — the thread returns only messages newer than the given id', async () => { + const { issue } = await seedIssue(); + const first = await seedEntity(dataSource, SupportMessage, { values: { issue } }); + const second = await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + const incremental = await messages.findThread(issue.id, first.id); + + expect(incremental.map((message) => message.id)).toEqual([second.id]); + }, 120000); + + // --- the search condition is the access check, so it gets its own assertions --- // + + it('finds an issue by its uid, and by numeric id scoped to the owning account', async () => { + const { issue, userData } = await seedIssue(); + const stranger = await seedEntity(dataSource, UserData); + + expect((await issues.findIssueBy({ uid: issue.uid }))?.id).toEqual(issue.id); + expect((await issues.findIssueBy({ id: issue.id, userData: { id: userData.id } }))?.id).toEqual(issue.id); + // Scoped to a different account it must not resolve — the projection must not have widened the + // condition by dropping the join it rests on. + expect(await issues.findIssueBy({ id: issue.id, userData: { id: stranger.id } })).toBeNull(); + }, 120000); + + it('finds an issue by the uid of the quote behind it', async () => { + // `transactionRequest` is nullable, so it has to be asked for explicitly. + const userData = await seedEntity(dataSource, UserData); + const limitRequest = await seedEntity(dataSource, LimitRequest); + const issue = await seedEntity(dataSource, SupportIssue, { + values: { userData, transaction: null, limitRequest, state: SupportIssueInternalState.IN_PROGRESS }, + relations: { transactionRequest: true }, + }); + + // The third branch of the search condition resolves through `transactionRequest`, a relation the + // projection does not join for the response. `setFindOptions` has to add it. + expect(issue.transactionRequest).toBeDefined(); + const found = await issues.findIssueBy({ transactionRequest: { uid: issue.transactionRequest.uid } }); + expect(found?.id).toEqual(issue.id); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — every field feeding the issue response is required', async () => { + const { issue } = await seedIssue(); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + await expectEveryFieldRequired(SUPPORT_ISSUE_RESPONSE_FIELDS, async (omitted) => { + const loaded = await issues.findIssueBy( + { uid: issue.uid }, + projectionFieldsWithout(SUPPORT_ISSUE_PROJECTION.fields, omitted), + ); + loaded.messages = await messages.findThread(loaded.id); + return SupportIssueDtoMapper.mapSupportIssue(loaded); + }); + }, 300000); + + it('level 3 — every field feeding a message is required', async () => { + const { issue } = await seedIssue(); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + await expectEveryFieldRequired(SUPPORT_MESSAGE_RESPONSE_FIELDS, (omitted) => + messages + .findThread(issue.id, 0, projectionFieldsWithout(SUPPORT_MESSAGE_PROJECTION.fields, omitted)) + .then((thread) => thread.map(SupportIssueDtoMapper.mapSupportMessage)), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([true, false])( + 'level 4 — with transaction=%s the projected response equals the one from a full load', + async (withTransaction) => { + const { issue } = await seedIssue(withTransaction); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + const loaded = await issues.findIssueBy({ uid: issue.uid }); + loaded.messages = await messages.findThread(loaded.id); + + // The unprojected load is the second source: the relation set the endpoint used before. + const full = await dataSource.getRepository(SupportIssue).findOne({ + where: { uid: issue.uid }, + relations: { transaction: true, limitRequest: true }, + }); + full.messages = await dataSource.getRepository(SupportMessage).findBy({ issue: { id: issue.id } }); + + expect(SupportIssueDtoMapper.mapSupportIssue(loaded)).toEqual(SupportIssueDtoMapper.mapSupportIssue(full)); + }, + 120000, + ); +}); diff --git a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts index af670364e9..64416acd3e 100644 --- a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts +++ b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts @@ -1,11 +1,244 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; -import { EntityManager } from 'typeorm'; +import { EntityManager, FindOptionsWhere } from 'typeorm'; import { SupportIssue } from '../entities/support-issue.entity'; +/** The fields `CountryDtoMapper.entityToDto` reads, for a given join alias. */ +const countryFields = (alias: string): string[] => + [ + 'id', + 'symbol', + 'name', + 'foreignName', + 'ipEnable', + 'fatfEnable', + 'dfxEnable', + 'dfxOrganizationEnable', + 'nationalityStepEnable', + 'bankEnable', + 'checkoutEnable', + 'cryptoEnable', + ].map((field) => `${alias}.${field}`); + +/** + * The fields a transaction contributes, for either of the two sides. + * + * `mapTransactionData` reads `buyCrypto ?? buyFiat`, so both are joined and both carry the same + * shape — except for the output asset, which is an `Asset` on one side and a `Fiat` on the other: + * only the crypto side has a blockchain to report. + */ +const transactionSideFields = (side: string, inputAsset: string, outputAsset: string): string[] => [ + `${side}.amlReason`, + `${side}.comment`, + `${side}.inputAmount`, + `${side}.inputAsset`, + `${side}.outputAmount`, + `${side}.isComplete`, + `${inputAsset}.blockchain`, + `${outputAsset}.name`, +]; + +/** What the issue itself contributes, including the JSON column behind `additionalInformation`. */ +export const SUPPORT_ISSUE_DATA_ISSUE_FIELDS = [ + 'supportIssue.id', + 'supportIssue.created', + 'supportIssue.uid', + 'supportIssue.type', + 'supportIssue.department', + 'supportIssue.reason', + 'supportIssue.state', + 'supportIssue.name', + 'supportIssue.clerk', + // `additionalInformation` is a getter over this column; the mapper reads the parsed object. + 'supportIssue.information', +]; + +/** What `SupportIssueDtoMapper.mapUserData` reads. */ +export const SUPPORT_ISSUE_DATA_ACCOUNT_FIELDS = [ + 'userData.id', + 'userData.status', + 'userData.verifiedName', + // `completeName` is a getter: organizationName, falling back to firstname and surname. + 'userData.organizationName', + 'userData.firstname', + 'userData.surname', + 'userData.accountType', + 'userData.kycLevel', + 'userData.depositLimit', + 'userData.annualBuyVolume', + 'userData.annualSellVolume', + 'userData.annualCryptoVolume', + 'userData.kycHash', + ...countryFields('userDataCountry'), + 'userDataLanguage.id', + 'userDataLanguage.name', + 'userDataLanguage.symbol', + 'userDataLanguage.foreignName', + 'userDataLanguage.enable', +]; + +/** What `SupportIssueDtoMapper.mapTransactionData` reads. */ +export const SUPPORT_ISSUE_DATA_TRANSACTION_FIELDS = [ + 'transaction.id', + 'transaction.sourceType', + 'transaction.type', + 'transaction.amlCheck', + ...transactionSideFields('buyCrypto', 'buyCryptoInputAsset', 'buyCryptoOutputAsset'), + // Only the crypto side reports an output blockchain — the fiat side's output asset is a currency. + 'buyCryptoOutputAsset.blockchain', + ...transactionSideFields('buyFiat', 'buyFiatInputAsset', 'buyFiatOutputAsset'), + 'transactionUserWallet.displayName', + 'transactionUserWallet.name', + 'transactionUserWallet.amlRules', + 'transactionUserWallet.isKycClient', +]; + +/** + * What `SupportIssueDtoMapper.mapLimitRequestData` reads. + * + * Withheld from support and tenant staff by the endpoint, but the field list does not depend on the + * role: the query is the same and the mapper is skipped. + */ +export const SUPPORT_ISSUE_DATA_LIMIT_REQUEST_FIELDS = [ + 'limitRequest.id', + 'limitRequest.fundOrigin', + 'limitRequest.investmentDate', + 'limitRequest.limit', + 'limitRequest.acceptedLimit', + 'limitRequest.decision', +]; + +/** + * What `SupportIssueDtoMapper.mapSupportIssue` reads — the customer-facing view of an issue. + * + * `issueTransaction.uid` covers both values the transaction contributes: `url` is a getter over it. + * Messages are not part of this; they are loaded separately and projected by + * `SUPPORT_MESSAGE_RESPONSE_FIELDS`. + */ +export const SUPPORT_ISSUE_RESPONSE_FIELDS = [ + 'supportIssue.uid', + 'supportIssue.state', + 'supportIssue.type', + 'supportIssue.reason', + 'supportIssue.name', + 'supportIssue.created', + 'issueTransaction.uid', + 'issueLimitRequest.id', + 'issueLimitRequest.limit', +]; + +/** + * `GET /support/issue` and `GET /support/issue/:id` — 450 columns before, for nine values. + * + * `supportIssue.id` is a guard rather than a response field: the mapper never shows it, but + * `getIssue` loads the message thread by it afterwards. `issueTransaction.id` is what + * `mapTransaction` checks to decide whether there is a transaction at all. + */ +export const SUPPORT_ISSUE_PROJECTION = new ReadProjection( + 'supportIssue', + [ + ['supportIssue.transaction', 'issueTransaction'], + ['supportIssue.limitRequest', 'issueLimitRequest'], + ], + SUPPORT_ISSUE_RESPONSE_FIELDS, + ['supportIssue.id', 'issueTransaction.id'], +); + +/** + * `GET /support/issue/:id/data` — the widest read path in the service. + * + * The unprojected load reaches 951 columns: the issue's four eager relations expand recursively, + * and the transaction pulls in both of its sides with their inputs and assets. The response is + * about sixty values. + */ +export const SUPPORT_ISSUE_DATA_PROJECTION = new ReadProjection( + 'supportIssue', + [ + ['supportIssue.userData', 'userData'], + ['userData.country', 'userDataCountry'], + ['userData.language', 'userDataLanguage'], + ['supportIssue.transaction', 'transaction'], + ['transaction.user', 'transactionUser'], + ['transactionUser.wallet', 'transactionUserWallet'], + ['transaction.buyCrypto', 'buyCrypto'], + ['buyCrypto.outputAsset', 'buyCryptoOutputAsset'], + ['buyCrypto.cryptoInput', 'buyCryptoInput'], + ['buyCryptoInput.asset', 'buyCryptoInputAsset'], + ['transaction.buyFiat', 'buyFiat'], + ['buyFiat.outputAsset', 'buyFiatOutputAsset'], + ['buyFiat.cryptoInput', 'buyFiatInput'], + ['buyFiatInput.asset', 'buyFiatInputAsset'], + ['supportIssue.limitRequest', 'limitRequest'], + ], + [ + ...SUPPORT_ISSUE_DATA_ISSUE_FIELDS, + ...SUPPORT_ISSUE_DATA_ACCOUNT_FIELDS, + ...SUPPORT_ISSUE_DATA_TRANSACTION_FIELDS, + ...SUPPORT_ISSUE_DATA_LIMIT_REQUEST_FIELDS, + ], + // Never part of the response: the primary keys that make the ORM materialise the joined rows. + // `transaction.id`, `userData.id` and `limitRequest.id` are response fields already, and the + // mapper uses two of them to decide whether the relation is there at all. + [ + 'transactionUser.id', + 'transactionUserWallet.id', + 'buyCrypto.id', + 'buyCryptoInput.id', + 'buyCryptoInputAsset.id', + 'buyCryptoOutputAsset.id', + 'buyFiat.id', + 'buyFiatInput.id', + 'buyFiatInputAsset.id', + 'buyFiatOutputAsset.id', + ], +); + @Injectable() export class SupportIssueRepository extends BaseRepository { constructor(manager: EntityManager) { super(SupportIssue, manager); } + + /** + * Loads exactly what the internal issue view needs. + * + * `fields` exists for the mutation test; nothing in production passes it. + */ + async findIssueData( + id: number, + fields: ReadonlyArray = SUPPORT_ISSUE_DATA_PROJECTION.fields, + ): Promise { + return SUPPORT_ISSUE_DATA_PROJECTION.apply(this.createQueryBuilder('supportIssue'), fields) + .where('supportIssue.id = :id', { id }) + .getOne(); + } + + /** An account's own issues, loaded with the customer-facing fields only. */ + async findIssuesForAccount( + userDataId: number, + fields: ReadonlyArray = SUPPORT_ISSUE_PROJECTION.fields, + ): Promise { + return SUPPORT_ISSUE_PROJECTION.apply(this.createQueryBuilder('supportIssue'), fields) + .leftJoin('supportIssue.userData', 'issueUserData') + .where('issueUserData.id = :userDataId', { userDataId }) + .getMany(); + } + + /** + * A single issue, found by the caller's search condition. + * + * The condition is passed through rather than rebuilt here on purpose: it is the access check for + * this endpoint family — an issue is reachable by its UID, by the UID of the quote behind it, or + * by numeric id scoped to the owning account — and stating it twice is how the two copies drift + * apart. `setFindOptions` applies it to the same query builder that carries the projection. + */ + async findIssueBy( + search: FindOptionsWhere, + fields: ReadonlyArray = SUPPORT_ISSUE_PROJECTION.fields, + ): Promise { + return SUPPORT_ISSUE_PROJECTION.apply(this.createQueryBuilder('supportIssue'), fields) + .setFindOptions({ where: search, loadEagerRelations: false }) + .getOne(); + } } diff --git a/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts b/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts index c3ef3dbaf8..0d31238184 100644 --- a/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts +++ b/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts @@ -1,11 +1,61 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; import { SupportMessage } from '../entities/support-message.entity'; +/** + * What `SupportIssueDtoMapper.mapSupportMessage` reads. + * + * `fileName` is a getter over `fileUrl`, so the column is what has to be selected. Naming the getter + * here does not fail loudly: the ORM does not recognise it as a column, passes the expression + * through unquoted, and Postgres then rejects the statement with a missing FROM-clause entry for a + * lower-cased table name that appears nowhere in the query. + */ +export const SUPPORT_MESSAGE_RESPONSE_FIELDS = [ + 'supportMessage.id', + 'supportMessage.author', + 'supportMessage.created', + 'supportMessage.message', + 'supportMessage.fileUrl', +]; + +/** + * The message thread of an issue. + * + * Loaded on its own rather than as a relation, which is what the endpoint already did — the + * projection only narrows the columns. + */ +export const SUPPORT_MESSAGE_PROJECTION = new ReadProjection( + 'supportMessage', + [['supportMessage.issue', 'messageIssue']], + SUPPORT_MESSAGE_RESPONSE_FIELDS, +); + @Injectable() export class SupportMessageRepository extends BaseRepository { constructor(manager: EntityManager) { super(SupportMessage, manager); } + + /** + * The messages of an issue, newer than `fromMessageId`. + * + * `fields` exists for the mutation test; nothing in production passes it. + */ + async findThread( + issueId: number, + fromMessageId = 0, + fields: ReadonlyArray = SUPPORT_MESSAGE_PROJECTION.fields, + ): Promise { + return ( + SUPPORT_MESSAGE_PROJECTION.apply(this.createQueryBuilder('supportMessage'), fields) + .where('messageIssue.id = :issueId', { issueId }) + // Written out rather than as `{ id: MoreThan(...) }`: the object form is resolved against the + // find-options alias, not the query builder's, and the statement then refers to a table that + // is not in its FROM clause. + .andWhere('supportMessage.id > :fromMessageId', { fromMessageId }) + .getMany() + ); + } } diff --git a/src/subdomains/supporting/support-issue/services/support-issue.service.ts b/src/subdomains/supporting/support-issue/services/support-issue.service.ts index 9b181d422a..07a4609db5 100644 --- a/src/subdomains/supporting/support-issue/services/support-issue.service.ts +++ b/src/subdomains/supporting/support-issue/services/support-issue.service.ts @@ -658,43 +658,22 @@ export class SupportIssueService { } async getIssues(userDataId: number): Promise { - const issues = await this.supportIssueRepo.find({ - where: { userData: { id: userDataId } }, - relations: { transaction: true, limitRequest: true }, - }); + const issues = await this.supportIssueRepo.findIssuesForAccount(userDataId); return issues.map(SupportIssueDtoMapper.mapSupportIssue); } async getIssue(id: string, query: GetSupportIssueFilter, userDataId?: number): Promise { - const issue = await this.supportIssueRepo.findOne({ - where: this.getIssueSearch(id, userDataId), - relations: { transaction: true, limitRequest: true }, - }); + const issue = await this.supportIssueRepo.findIssueBy(this.getIssueSearch(id, userDataId)); if (!issue) throw new NotFoundException('Support issue not found'); - issue.messages = await this.messageRepo.findBy({ - issue: { id: issue.id }, - id: MoreThan(query.fromMessageId ?? 0), - }); + issue.messages = await this.messageRepo.findThread(issue.id, query.fromMessageId ?? 0); return SupportIssueDtoMapper.mapSupportIssue(issue); } async getIssueData(id: number, role: UserRole, customerIds?: number[]): Promise { - const issue = await this.supportIssueRepo.findOne({ - where: { id }, - relations: { - userData: { country: true, language: true }, - transaction: { - user: { wallet: true }, - buyCrypto: { outputAsset: true, cryptoInput: { asset: true } }, - buyFiat: { outputAsset: true, cryptoInput: { asset: true } }, - }, - limitRequest: true, - }, - loadEagerRelations: false, - }); + const issue = await this.supportIssueRepo.findIssueData(id); if (!issue) throw new NotFoundException('Support issue not found'); // customer scope (RealUnit): fail-closed 404 when the issue does not belong to a scoped customer (no existence leak) if (customerIds && !customerIds.includes(issue.userData?.id)) From c778c084baeb744b0b55fafbda8113e2ecfd45bb Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:43:10 +0200 Subject: [PATCH 04/46] Complete the query-builder rule: an expression narrows the query too The previous commit split `.select('alias')` from `.select('alias.column')` by the dot in the argument. That still misread `.select('COUNT(*)', 'count')` and `.select('MAX(tx.seq)')` as the bare root alias, because an aggregate has no dot before its parenthesis. The rule that holds, and is now the one all three collection steps use: a bare identifier is the root alias and loads every column; anything else - a column or an expression - names something and narrows the query. Against the base, this moves four more endpoints: whole rows 421 -> 417 projected 13 -> 17 so the correction from the previous commit totals 15 endpoints, not 11. The finding is unchanged: what these sites select is one column at the median, and they are counts, maxima and id lookups rather than response payloads. --- docs/endpoints.md | 22 +++++++++++----------- docs/load-sites.md | 10 +++++----- docs/read-path-projections.md | 26 ++++++++++++++------------ 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/docs/endpoints.md b/docs/endpoints.md index f867f06b91..f12c616d33 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -23,20 +23,20 @@ Two rules follow from that, and both are binding: 1. **An endpoint counts as converted only when its tests reach `4/4`** against the four levels in [read-path-projections.md](read-path-projections.md#test-definition). A projection without them is worse than no projection: a forgotten field does not crash, it returns a wrong value with a 200, and in a service moving money that can run for weeks unnoticed. Anything short of `4/4` is unfinished work, not a partial success. 2. **The state of every endpoint is recorded here**, in the `Tests` column, and kept in sync with the code in the same pull request that changes it. An undocumented conversion is indistinguishable from one that was never tested. -Today 22 endpoints read only what they return and 414 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. +Today 26 endpoints read only what they return and 410 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. ## What the numbers say | Data access | Endpoints | Share | | ----------- | --------: | ----: | -| `whole rows` | 414 | 78 % | +| `whole rows` | 410 | 77 % | | `none` | 98 | 18 % | -| `projected` | 20 | 4 % | +| `projected` | 24 | 4 % | | `caller-defined` | 2 | 0 % | -Of the 20 that read only what they return, 7 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11). The other 13 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. +Of the 24 that read only what they return, 7 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11). The other 17 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. -Among the 414 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 313 exceed 100, 87 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. +Among the 410 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 313 exceed 100, 87 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. ### How to read this column, and how not to @@ -50,9 +50,9 @@ Among the 414 that fetch whole rows, the widest query they can trigger is **308 Stated exactly, so the numbers can be checked rather than believed: -- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 414 is a lower bound. +- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 410 is a lower bound. - All 98 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. -- The 20 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. +- The 24 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. - 2 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`. The classification holds; only the width is unknown. ### Two controller classes may share a name @@ -476,11 +476,11 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/realunit/support/:id/message` | hidden | whole rows | 441 | not yet | | `RealUnitSupportController.createSupportMessage` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/:id/message/:messageId/file` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.getFile` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/:id/messages` | hidden | whole rows | 428 | not yet | | `RealUnitSupportController.getIssueMessages` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/activity` | hidden | whole rows | 7 | not yet | | `RealUnitSupportController.getSupportIssueActivity` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/activity` | hidden | projected | 2 | 0/4 | | `RealUnitSupportController.getSupportIssueActivity` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/clerks` | hidden | none | — | n/a | | `RealUnitSupportController.getRealUnitSupportClerks` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/counts` | hidden | projected | 2 | 0/4 | | `RealUnitSupportController.getSupportIssueCounts` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/list` | hidden | whole rows | 16 | not yet | | `RealUnitSupportController.getSupportIssueList` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/statistics` | hidden | whole rows | 16 | not yet | | `RealUnitSupportController.getSupportIssueStatistics` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/statistics` | hidden | projected | 3 | 0/4 | | `RealUnitSupportController.getSupportIssueStatistics` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | PUT | 1 | | `/realunit/swap` | public | whole rows | 308 | not yet | yes | `RealUnitController.getSwapPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/swap/:id/broadcast` | public | whole rows | 504 | not yet | yes | `RealUnitController.broadcastSwapTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/swap/:id/unsigned-transaction` | public | whole rows | 504 | not yet | yes | `RealUnitController.getSwapUnsignedTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | @@ -541,7 +541,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/support/issue/:id/data` | hidden | projected | 81 | 4/4 | | `SupportIssueController.getIssueData` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/:id/message` | public | whole rows | 441 | not yet | yes | `SupportIssueController.createSupportMessage` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/:id/message/:messageId/file` | public | whole rows | — | not yet | | `SupportIssueController.getFile` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/activity` | hidden | whole rows | 7 | not yet | | `SupportIssueController.getSupportIssueActivity` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/activity` | hidden | projected | 2 | 0/4 | | `SupportIssueController.getSupportIssueActivity` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/clerk` | hidden | none | — | n/a | | `SupportIssueController.getSupportIssueClerk` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/clerks` | hidden | none | — | n/a | | `SupportIssueController.getSupportIssueClerks` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/counts` | hidden | projected | 2 | 0/4 | | `SupportIssueController.getSupportIssueCounts` | `subdomains/supporting/support-issue/support-issue.controller.ts` | @@ -549,7 +549,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/support/issue/escalation/telegram-chats` | hidden | none | — | n/a | | `SupportIssueController.getEscalationChats` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/escalation/telegram-test` | hidden | whole rows | 5 | not yet | | `SupportIssueController.testEscalationChat` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/list` | hidden | whole rows | 16 | not yet | | `SupportIssueController.getSupportIssueList` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/statistics` | hidden | whole rows | 16 | not yet | | `SupportIssueController.getSupportIssueStatistics` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/statistics` | hidden | projected | 3 | 0/4 | | `SupportIssueController.getSupportIssueStatistics` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/support` | hidden | whole rows | 493 | not yet | | `SupportIssueController.createIssueBySupport` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/kycFileList` | hidden | whole rows | 253 | not yet | | `SupportController.getKycFileList` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/kycFileStats` | hidden | whole rows | 99 | not yet | | `SupportController.getKycFileStats` | `subdomains/generic/support/support.controller.ts` | diff --git a/docs/load-sites.md b/docs/load-sites.md index 80b0172e5e..7eda329588 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -19,8 +19,8 @@ Among the query builders, the field list is what decides whether anything is act | | Sites | | --- | ---: | | `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **9** | -| `.select('alias.column')` — names columns one by one | **84** | -| `.select('alias')` — selects the root alias, **loads every column** | 20 | +| `.select('alias.column')` — names columns one by one | **87** | +| `.select('alias')` — selects the root alias, **loads every column** | 17 | | no `select` at all — loads every column | 23 | | projects, but a `leftJoinAndSelect` loads a relation whole | 1 | @@ -645,7 +645,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:43` | `FiatService.getFiatByCountry` | | 16 | 0 | query-builder (projektion-mit-vollem-join) | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:148` | `PaymentLinkPaymentService.getMostRecentPayments` | | 16 | 0 | query-builder (nur-alias) | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1294` | `VirtualIbanService.getVirtualIbanByKey` | -| 16 | 0 | query-builder (nur-alias) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:177` | `SupportIssueService.getSupportIssueStatistics` | | 16 | 0 | query-builder (ohne-select) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:549` | `SupportIssueService.getSupportIssueList` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:20` | `WalletAppService.getAllBlockchainWalletApps` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:24` | `WalletAppService.getRecommendedWalletApps` | @@ -727,8 +726,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:58` | `SpecialExternalAccountService.getBlacklist` | | 7 | 0 | find | `AssetPrice` | `subdomains/supporting/pricing/services/asset-prices-job.service.ts:81` | `AssetPricesJobService.saveAssetPrices` | | 7 | 0 | find | `RealUnitLegalAcceptance` | `subdomains/supporting/realunit/realunit-legal.service.ts:57` | `RealUnitLegalService.getLatestAcceptance` | -| 7 | 0 | query-builder (nur-alias) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:133` | `SupportIssueService.getSupportIssueActivity` | -| 7 | 0 | query-builder (nur-alias) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:186` | `SupportIssueService.getSupportIssueStatistics` | | 6 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:173` | `LedgerMarkToMarketService.accountBalance` | | 6 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:517` | `LedgerReconciliationService.nativeBalanceByAccount` | | 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.repository.ts:13` | `RefRepository.getAndRemove` | @@ -791,6 +788,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 2 | 0 | query-builder (spaltenliste) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:324` | `TransactionService.getManualRefVolume` | | 2 | 0 | query-builder (spaltenliste) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:353` | `TransactionService.getAuditPeriodVolumes` | | 2 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:109` | `SupportIssueService.getSupportIssueCounts` | +| 2 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:133` | `SupportIssueService.getSupportIssueActivity` | | 2 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:198` | `SupportIssueService.getSupportIssueStatistics` | | 1 | 0 | query-builder (spaltenliste) | `Asset` | `shared/models/asset/asset.service.ts:140` | `AssetService.getAssetsUsedOn` | | 1 | 0 | query-builder (spaltenliste) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:79` | `IpLogService.getLoginCountries` | @@ -838,6 +836,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 1 | 0 | query-builder (spaltenliste) | `—` | `subdomains/supporting/payin/services/payin.service.ts:217` | `PayInService.getPayInFee` | | 1 | 0 | query-builder (spaltenliste) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:352` | `TransactionRequestService.getLegacySettlementTxIds` | | 1 | 0 | query-builder (spaltenliste) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:406` | `TransactionRequestService.getActiveDepositAddresses` | +| 1 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:177` | `SupportIssueService.getSupportIssueStatistics` | +| 1 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:186` | `SupportIssueService.getSupportIssueStatistics` | | — | — | find | `—` | `config/config.ts:1347` | `Configuration.isDomesticIban` | | — | — | find | `—` | `integration/binance-pay/services/binance-pay.service.ts:271` | `BinancePayService.verifySignature` | | — | — | find | `—` | `integration/blockchain/api/services/blockchain-balance.service.ts:52` | `BlockchainBalanceService.getSolanaBalances` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index b69132e1a9..ae11bdc471 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -30,8 +30,8 @@ This service loads far more data than it returns. Measured against the real enti render a PDF containing a handful of values. That query sat exactly on Postgres' limit of 1,664 columns per statement, which is why a single new column added elsewhere (`settlementEventId` on `transaction_request`) broke every invoice and receipt in production until it was fixed. -- Of the 534 endpoints, **414 reach at least one load site that fetches whole rows**; 98 read - nothing at all, and **20 read only the fields they return**. The widest query a fetching endpoint +- Of the 534 endpoints, **410 reach at least one load site that fetches whole rows**; 98 read + nothing at all, and **24 read only the fields they return**. The widest query a fetching endpoint can trigger is 308 columns at the median, and 19 of them exceed 1,000. The column limit was the symptom, not the cause. Loading a thousand columns to return one is @@ -47,19 +47,21 @@ and one on `LimitRequest` **434 across 15** — before any `relations` option is decision what to load therefore lives in the entity definition, not at the call site, and no call site can see what it triggers. -**No read model.** Of the 1,105 load sites in this repository, **98** name the columns they need: -93 query builders and the five raw statements. The other 1,007 request whole rows — 963 through the -`find` family, and of the 137 query builders, 20 pass the root alias to `.select(...)`, which reads +**No read model.** Of the 1,105 load sites in this repository, **101** name the columns they need: +96 query builders and the five raw statements. The other 1,004 request whole rows — 963 through the +`find` family, and of the 137 query builders, 17 pass the root alias to `.select(...)`, which reads like a projection but is not, while 23 pass no select at all. The same entities serve persistence, business logic and pure output paths such as invoices, receipts, history and exports — which need fields, not objects. -Read the first number carefully, because an earlier revision of this document got it wrong. The 89 +Read the first number carefully, because an earlier revision of this document got it wrong. The 96 query builders that do name columns are almost entirely counts, maxima and id lookups — -`.select('userData.id', 'id')` and the like — and they select **one column at the median**. They are -projections, and they were miscounted as full loads because the classification only recognised the -array form `.select([...])` and read every string argument as the bare root alias. Correcting it -moved 11 endpoints out of the `whole rows` group. What it does not do is change the picture: a +`.select('userData.id', 'id')`, `.select('COUNT(*)', 'count')` and the like — and they select **one +column at the median**. They are projections, and they were miscounted as full loads because the +classification recognised only the array form `.select([...])` and read every string argument as the +bare root alias. The rule that holds: a bare identifier is the root alias and loads everything; +anything else — a column or an expression — narrows the query. Correcting it moved 15 endpoints out +of the `whole rows` group. What it does not do is change the picture: a `COUNT(*)` that was always narrow is not a read path that was converted, and the response payloads — history, profile, invoices, exports — are still served by `find`. @@ -155,9 +157,9 @@ per endpoint as `0/4` through `4/4`; only `4/4` is done. To any load site that carries an explicit field list — that is where a forgotten field silently yields an empty value. -Ninety-eight sites carry a field list. The table below covers the six that were known when this +A hundred and one sites carry a field list. The table below covers the six that were known when this document was written — one query builder and five raw statements — and none of them was converted, -so it is unchanged. Another 84 are the query builders that name columns one at a time; they are +so it is unchanged. Another 87 are the query builders that name columns one at a time; they are not covered by these levels either, which is what their endpoints' `0/4` in [endpoints.md](endpoints.md) records. The remaining 8 belong to the endpoints converted so far and are covered on all four. Sites a conversion adds are recorded there too, where only From 602e31f452e656bef0544b3538eeb8cffab98fd9 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:54:09 +0200 Subject: [PATCH 05/46] Project the v1 KYC read paths, and measure level 3 against the response GET /kyc/users 328 columns -> 7 GET /kyc/:id/documents 328 -> 2 The first reads an address, two status fields and a hash per user of a wallet; the second reads nothing off the row at all, only the account id its response is keyed by in the document store. Both loaded the full graph for it. Level 3 now asks whether dropping a field CHANGES the response, not whether it empties one. The weaker form passed a projection missing UserData.kycStatus: getKycWebhookStatus answers `NA` when handed nothing, which is a valid value and a wrong one - the exact failure this document is about, and emptiness cannot see it. Comparing against the response the full projection produced does. It is the same standard level 4 applies, one field at a time. That change makes the fixture's reach part of the assertion. `kycType` only alters the answer for a LOCK account; against a DFX one the value it produces and the value its absence produces are identical, so the spec runs level 3 over both. The mapper moves out of KycService into its own file, as the history mappers did, so the spec drives the same mapping the endpoint uses. --- docs/endpoints.md | 20 +- docs/load-sites.md | 28 +-- docs/read-path-projections.md | 39 ++-- .../models/__tests__/read-projection.spec.ts | 4 + src/shared/utils/projection-test.util.ts | 37 ++-- .../kyc/__tests__/kyc-data.projection.spec.ts | 185 ++++++++++++++++++ .../models/kyc/dto/kyc-data-dto.mapper.ts | 19 ++ .../generic/user/models/kyc/kyc.service.ts | 23 +-- .../user/models/user/user.repository.ts | 38 ++++ .../user/models/wallet/wallet.repository.ts | 40 ++++ 10 files changed, 359 insertions(+), 74 deletions(-) create mode 100644 src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts create mode 100644 src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts diff --git a/docs/endpoints.md b/docs/endpoints.md index f12c616d33..9151a50376 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -23,20 +23,20 @@ Two rules follow from that, and both are binding: 1. **An endpoint counts as converted only when its tests reach `4/4`** against the four levels in [read-path-projections.md](read-path-projections.md#test-definition). A projection without them is worse than no projection: a forgotten field does not crash, it returns a wrong value with a 200, and in a service moving money that can run for weeks unnoticed. Anything short of `4/4` is unfinished work, not a partial success. 2. **The state of every endpoint is recorded here**, in the `Tests` column, and kept in sync with the code in the same pull request that changes it. An undocumented conversion is indistinguishable from one that was never tested. -Today 26 endpoints read only what they return and 410 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. +Today 28 endpoints read only what they return and 408 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. ## What the numbers say | Data access | Endpoints | Share | | ----------- | --------: | ----: | -| `whole rows` | 410 | 77 % | +| `whole rows` | 408 | 76 % | | `none` | 98 | 18 % | -| `projected` | 24 | 4 % | +| `projected` | 26 | 5 % | | `caller-defined` | 2 | 0 % | -Of the 24 that read only what they return, 7 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11). The other 17 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. +Of the 26 that read only what they return, 9 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11), `GET /kyc/users` (328 columns to 7), `GET /kyc/:id/documents` (328 columns to 2). The other 17 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. -Among the 410 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 313 exceed 100, 87 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. +Among the 408 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 311 exceed 100, 87 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. ### How to read this column, and how not to @@ -44,15 +44,15 @@ Among the 410 that fetch whole rows, the widest query they can trigger is **308 ### Deprecation -24 handlers carry `@ApiOperation({ deprecated: true })`: 21 of them fetch whole rows, 3 read nothing. Deprecation does not follow the version, and the duplicated paths are not simply an old handler beside its replacement: `GET /kyc/countries` is marked on **both** its v1 and its v2 handler, and `GET /user/ref` on neither. +24 handlers carry `@ApiOperation({ deprecated: true })`: 19 of them fetch whole rows, 3 read nothing. They are what the duplicated paths are about — an older handler and its replacement served side by side under different versions. Note that deprecation does not follow the version: `GET /kyc/countries` is marked on **both** the v1 and the v2 handler. ### Limits of this classification Stated exactly, so the numbers can be checked rather than believed: -- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 410 is a lower bound. +- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 408 is a lower bound. - All 98 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. -- The 24 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. +- The 26 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. - 2 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`. The classification holds; only the width is unknown. ### Two controller classes may share a name @@ -288,7 +288,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | yes | `/kyc/:code` | public | whole rows | 351 | not yet | | `KycController.getKycProgressByCodeV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | POST | 1 | yes | `/kyc/:code` | public | whole rows | 351 | not yet | | `KycController.requestKycByCodeV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | yes | `/kyc/:code/countries` | public | whole rows | 351 | not yet | | `KycController.getKycCountriesByCodeV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | -| GET | 1 | yes | `/kyc/:id/documents` | public | whole rows | 328 | not yet | | `KycClientController.getKycFilesV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | +| GET | 1 | yes | `/kyc/:id/documents` | public | projected | 2 | 4/4 | | `KycClientController.getKycFilesV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | yes | `/kyc/:id/documents/:type` | public | whole rows | 328 | not yet | | `KycClientController.getKycFileV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 2 | | `/kyc/:step` | hidden | whole rows | 364 | not yet | | `KycController.initiateStep` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | DELETE | 1 | | `/kyc/admin/blacklist/ip` | hidden | none | — | n/a | | `KycAdminController.deleteIpToBlacklist` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | @@ -334,7 +334,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | DELETE | 2 | | `/kyc/transfer` | hidden | whole rows | 351 | not yet | | `KycController.removeKycClient` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | POST | 2 | | `/kyc/transfer` | hidden | whole rows | 364 | not yet | | `KycController.addKycClient` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | PUT | 1 | yes | `/kyc/transfer` | public | whole rows | 364 | not yet | | `KycController.transferKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | -| GET | 1 | yes | `/kyc/users` | public | whole rows | 328 | not yet | | `KycClientController.getAllKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | +| GET | 1 | yes | `/kyc/users` | public | projected | 7 | 4/4 | | `KycClientController.getAllKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | | `/language` | public | whole rows | 7 | not yet | | `LanguageController.getAllLanguage` | `shared/models/language/language.controller.ts` | | PUT | 1 | | `/limitRequest/:id` | hidden | whole rows | 434 | not yet | | `LimitRequestController.updateUserData` | `subdomains/supporting/support-issue/limit-request.controller.ts` | | GET | 1 | | `/liquidityManagement/balance` | hidden | whole rows | 40 | not yet | | `LiquidityBalanceController.getBalances` | `subdomains/core/liquidity-management/controllers/balance.controller.ts` | diff --git a/docs/load-sites.md b/docs/load-sites.md index 7eda329588..389b38d17e 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -8,8 +8,8 @@ This is the level at which the statement is unambiguous. An endpoint reaches sev | Mechanism | Sites | Eager relations | Columns selected | | --------- | ----: | --------------- | ---------------- | -| `find` family | 963 | **applied** — expanded recursively | all columns of the entity plus every eager relation | -| `createQueryBuilder` | 137 | not applied | all columns of the root entity, unless `.select([...])` narrows it | +| `find` family | 961 | **applied** — expanded recursively | all columns of the entity plus every eager relation | +| `createQueryBuilder` | 139 | not applied | all columns of the root entity, unless `.select([...])` narrows it | | raw SQL | 5 | not applied | whatever the statement lists | Statements that load nothing are excluded from the count: 2 `createQueryBuilder` calls carrying `.update()`, 6 advisory locks (`SELECT pg_advisory_xact_lock(...)`, which return no rows) and 1 raw `INSERT`. Each of the 5 raw reads that remain names its columns. @@ -18,23 +18,23 @@ Among the query builders, the field list is what decides whether anything is act | | Sites | | --- | ---: | -| `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **9** | +| `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **11** | | `.select('alias.column')` — names columns one by one | **87** | | `.select('alias')` — selects the root alias, **loads every column** | 17 | | no `select` at all — loads every column | 23 | | projects, but a `leftJoinAndSelect` loads a relation whole | 1 | -`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The distinction is the presence of a dot in the argument, and it matters — the sites that name columns this way select 1 column at the median, against 963 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. +`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The distinction is the presence of a dot in the argument, and it matters — the sites that name columns this way select 1 column at the median, against 961 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. ## Measurements Columns were measured against the real entity metadata by building the query and counting its SELECT list — 790 of 1105 sites. -- **341 are exact**: the `relations` tree is written at the call site. -- **449 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. +- **339 are exact**: the `relations` tree is written at the call site. +- **451 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. - 315 could not be measured: no resolvable target entity, or raw SQL. -Median across measured sites: **112 columns**. 14 sites exceed 1000, 75 exceed 500, 402 exceed 100. +Median across measured sites: **112 columns**. 14 sites exceed 1000, 75 exceed 500, 400 exceed 100. What that does and does not affect: the median and the counts above are computed only over the 782 rows that carry a measured width, and an array operation never has one, so those figures stand. Nor does it move the conclusion this table exists for: the sites that name their columns are a small fraction either way, and the verdict reads the same against 900 as against 1105. It does reach the per-endpoint summary in [endpoints.md](endpoints.md): an endpoint could be listed as fetching whole rows on the strength of such a row alone. The three with no measured width at all are the exposed cases and are named there. For the rest a measured query stands behind the entry, which limits the effect without excluding it — establishing that would need the collection to separate the two kinds of `find`, which is the fix this note stands in for. @@ -230,9 +230,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1316` | `VirtualIbanService.getVirtualIbansForAccount` | | 329 | 9 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:278` | `TransactionRequestService.findAndComplete` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/auth/auth.controller.ts:157` | `AuthController.createAccessTokenAfterMerge` | -| 328 | 10 | find | `Wallet` | `subdomains/generic/user/models/kyc/kyc.service.ts:138` | `KycService.getAllKycData` | -| 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:147` | `KycService.getKycFiles` | -| 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:162` | `KycService.getKycFile` | +| 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:156` | `KycService.getKycFile` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:129` | `UserService.getUserDto` | | 323 | 10 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1246` | `RealUnitService.forwardRegistrationToAktionariat` | | 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:362` | `BuyFiatService.getBuyFiatByTransactionId` | @@ -623,7 +621,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 23 | 0 | find | `BankTxBatch` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx-batch.service.ts:10` | `BankTxBatchService.getBankTxBatchByIban` | | 21 | 0 | query-builder (ohne-select) | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:87` | `DepositRouteService.getPaymentRouteForKey` | | 20 | 0 | query-builder (nur-alias) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:95` | `SellService.getSellByKey` | -| 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:13` | `WalletRepository.getByAddress` | +| 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:53` | `WalletRepository.getByAddress` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:19` | `WalletService.updateWallet` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:28` | `WalletService.getByAddress` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:36` | `WalletService.getByIdOrName` | @@ -720,6 +718,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:19` | `LanguageService.getLanguageBySymbol` | | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:24` | `LanguageService.getLanguageByCountry` | | 7 | 0 | find | `UserDataRelation` | `subdomains/generic/user/models/user-data-relation/user-data-relation.service.ts:40` | `UserDataRelationService.updateUserDataRelation` | +| 7 | 0 | query-builder (feldliste) | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:47` | `WalletRepository.findKycData` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:12` | `SpecialExternalAccountService.createSpecialExternalAccount` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:24` | `SpecialExternalAccountService.getMultiAccounts` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:48` | `SpecialExternalAccountService.getPhoneCallList` | @@ -781,6 +780,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 2 | 0 | query-builder (spaltenliste) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:493` | `BankDataService.getPendingReviewSummary` | | 2 | 0 | query-builder (spaltenliste) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:354` | `RecommendationService.countByRecommenderIds` | | 2 | 0 | query-builder (spaltenliste) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:369` | `RecommendationService.countByRecommendedIds` | +| 2 | 0 | query-builder (feldliste) | `User` | `subdomains/generic/user/models/user/user.repository.ts:46` | `UserRepository.findAccountIdForAddress` | | 2 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:226` | `UserService.countRefChildrenByUserDataIds` | | 2 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:248` | `UserService.countRefReferrersByUserDataIds` | | 2 | 0 | query-builder (spaltenliste) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:505` | `BankTxService.getBankTxFee` | @@ -1020,8 +1020,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:362` | `BankDataService.getAllBankDatasForUser` | | — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:444` | `BankDataService.createIbanForUserInternal` | | — | — | find | `Wallet` | `subdomains/generic/user/models/kyc/kyc.service.ts:58` | `KycService.transferKycData` | -| — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:193` | `KycService.getFileFor` | -| — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:200` | `KycService.getFileFor` | +| — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:179` | `KycService.getFileFor` | +| — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:186` | `KycService.getFileFor` | | — | — | find | `—` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:45` | `RecommendationService.createRecommendationByRecommender` | | — | — | find | `—` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:135` | `RecommendationService.handleRecommendationRequest` | | — | — | find | `—` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:246` | `RecommendationService.setRecommenderRefCode` | @@ -1041,7 +1041,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1547` | `UserDataService.mergeUserData` | | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1725` | `UserDataService.updateBankTxTime` | | — | — | find | `—` | `subdomains/generic/user/models/user/dto/user-dto.mapper.ts:27` | — | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.repository.ts:31` | `UserRepository.getNextRef` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.repository.ts:69` | `UserRepository.getNextRef` | | — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:339` | `UserService.createUser` | | — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:493` | `UserService.updateAddress` | | — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:509` | `UserService.deactivateUser` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index ae11bdc471..56b5e41033 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -30,8 +30,8 @@ This service loads far more data than it returns. Measured against the real enti render a PDF containing a handful of values. That query sat exactly on Postgres' limit of 1,664 columns per statement, which is why a single new column added elsewhere (`settlementEventId` on `transaction_request`) broke every invoice and receipt in production until it was fixed. -- Of the 534 endpoints, **410 reach at least one load site that fetches whole rows**; 98 read - nothing at all, and **24 read only the fields they return**. The widest query a fetching endpoint +- Of the 534 endpoints, **408 reach at least one load site that fetches whole rows**; 98 read + nothing at all, and **26 read only the fields they return**. The widest query a fetching endpoint can trigger is 308 columns at the median, and 19 of them exceed 1,000. The column limit was the symptom, not the cause. Loading a thousand columns to return one is @@ -47,9 +47,9 @@ and one on `LimitRequest` **434 across 15** — before any `relations` option is decision what to load therefore lives in the entity definition, not at the call site, and no call site can see what it triggers. -**No read model.** Of the 1,105 load sites in this repository, **101** name the columns they need: -96 query builders and the five raw statements. The other 1,004 request whole rows — 963 through the -`find` family, and of the 137 query builders, 17 pass the root alias to `.select(...)`, which reads +**No read model.** Of the 1,105 load sites in this repository, **103** name the columns they need: +98 query builders and the five raw statements. The other 1,002 request whole rows — 961 through the +`find` family, and of the 139 query builders, 17 pass the root alias to `.select(...)`, which reads like a projection but is not, while 23 pass no select at all. The same entities serve persistence, business logic and pure output paths such as invoices, receipts, history and exports — which need fields, not objects. @@ -157,11 +157,11 @@ per endpoint as `0/4` through `4/4`; only `4/4` is done. To any load site that carries an explicit field list — that is where a forgotten field silently yields an empty value. -A hundred and one sites carry a field list. The table below covers the six that were known when this +A hundred and three sites carry a field list. The table below covers the six that were known when this document was written — one query builder and five raw statements — and none of them was converted, so it is unchanged. Another 87 are the query builders that name columns one at a time; they are not covered by these levels either, which is what their endpoints' `0/4` in -[endpoints.md](endpoints.md) records. The remaining 8 belong to the endpoints converted so far and +[endpoints.md](endpoints.md) records. The remaining 10 belong to the endpoints converted so far and are covered on all four. Sites a conversion adds are recorded there too, where only `4/4` counts as done. @@ -301,20 +301,27 @@ depends on a status field. ### 3. Mutation -**Remove each field of the projection individually; level 1 must fail every time.** +**Remove each field of the projection individually; the response must change every time.** This proves the test looks at anything at all. Where removing a field changes nothing, one of two -things is true: the field is unnecessary and can be dropped permanently, or the fixture has a gap -at exactly that point — and a real defect would have slipped through there. +things is true: the field is unnecessary and can be dropped permanently, or the fixture never +reaches the branch that reads it — and a real defect would have slipped through there. + +**The measure is the response, not emptiness**, and the difference is not academic. The first +version of this level asked whether a field went empty, and it passed a projection missing +`UserData.kycStatus`: `getKycWebhookStatus` answers `NA` when handed nothing, which is a valid value +and a wrong one. Comparing against the response the full projection produced catches it. It is the +same standard as level 4, applied one field at a time. Without this level you never know whether a green test verified something or is merely green. -**Where a value has a fallback, the candidate is the chain, not the column.** `UserData.completeName` -is `organizationName ?? firstname + surname`; the wallet name on the support view is -`displayName ?? name`. Drop any single one of those columns and the value is still filled, by the -next alternative — so asserted individually, every one of them reports as removable. That is true -and useless. The chain is what carries the response value, so the chain is what gets dropped, and -`expectEveryFieldRequired` accepts a group of fields as one candidate for exactly this. +**Where a value has a fallback, the candidate may be the chain rather than the column.** +`UserData.completeName` is `organizationName ?? firstname + surname`. Dropping the chain shows the +value depends on it at all; each column on its own is covered by the variant in which it is the one +that gets read — which is also why a fixture has to reach that branch. `kycType` only changes the +answer for a LOCK account: against a DFX one, the value the absent column would produce and the +value it does produce are the same, and no assertion can tell them apart. +`expectEveryFieldRequired` therefore accepts a group of fields as one candidate. **A missing summand is not a missing field, and level 1 has to say so anyway.** The annual volume on the support view is `annualBuyVolume + annualSellVolume + annualCryptoVolume`. Leave one of the three diff --git a/src/shared/models/__tests__/read-projection.spec.ts b/src/shared/models/__tests__/read-projection.spec.ts index 261f5e5bf2..c112807862 100644 --- a/src/shared/models/__tests__/read-projection.spec.ts +++ b/src/shared/models/__tests__/read-projection.spec.ts @@ -7,6 +7,8 @@ import { } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; import { BUY_FIAT_HISTORY_PROJECTION } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; import { USER_PROFILE_PROJECTION } from 'src/subdomains/generic/user/models/user-data/user-data.repository'; +import { USER_KYC_FILES_PROJECTION } from 'src/subdomains/generic/user/models/user/user.repository'; +import { WALLET_KYC_DATA_PROJECTION } from 'src/subdomains/generic/user/models/wallet/wallet.repository'; import { SUPPORT_ISSUE_DATA_PROJECTION, SUPPORT_ISSUE_PROJECTION, @@ -22,6 +24,8 @@ const DOCUMENTED: [string, string, ReadProjection][] = [ ['GET', '/support/issue/:id/data', SUPPORT_ISSUE_DATA_PROJECTION], ['GET', '/support/issue', SUPPORT_ISSUE_PROJECTION], ['GET', '/support/issue/:id', SUPPORT_ISSUE_PROJECTION], + ['GET', '/kyc/users', WALLET_KYC_DATA_PROJECTION], + ['GET', '/kyc/:id/documents', USER_KYC_FILES_PROJECTION], ]; describe('ReadProjection', () => { diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts index 78f6dcce67..94d31cacc2 100644 --- a/src/shared/utils/projection-test.util.ts +++ b/src/shared/utils/projection-test.util.ts @@ -236,7 +236,7 @@ export function projectionFieldsWithout(fields: ReadonlyArray, omitted: } /** - * Level 3 — removing any candidate from the projection must break level 1. + * Level 3 — removing any candidate from the projection must **change the response**. * * `run` receives the fields to leave out, runs the same production query without them and returns * the response. The caller does the reducing, because which fields feed the response can depend on @@ -244,11 +244,16 @@ export function projectionFieldsWithout(fields: ReadonlyArray, omitted: * account's own for a personal one, so each variant asserts over its own set of candidates while the * rest of the projection stays in the query. * - * **A candidate may be a group of fields, and sometimes has to be.** Where several columns feed one - * response value through a fallback — `organizationName ?? firstname + surname` — no single one of - * them is required: drop any one and the next alternative fills the value. Asserting them - * individually would report every one of them as removable, which is true and useless. The group is - * what carries the value, so the group is what gets dropped. + * **"Changes the response", not "empties a field".** The weaker form misses the failure this whole + * exercise is about. `getKycWebhookStatus(kycStatus, kycType)` answers `NA` when it is handed + * nothing — a perfectly valid value — so dropping `kycStatus` leaves a complete response that is + * simply wrong, and an emptiness check waves it through. Comparing against the response the full + * projection produced catches it, and it is the same standard level 4 applies. + * + * **A candidate may be a group of fields.** Where several columns feed one response value through a + * fallback — `organizationName ?? firstname + surname` — dropping the group is what shows the value + * depends on it at all; each column on its own is covered by the variant in which it is the one that + * gets read. * * A candidate whose removal changes nothing is either unnecessary or a gap in the fixture; both need * looking at, so this reports the names rather than just failing. @@ -263,31 +268,33 @@ export async function expectEveryFieldRequired( // say — then every reduced run fails too, and "every field is required" would be reported for a // projection nothing was ever proven about. `NOTHING_OMITTED` matches no field name, so the caller // reduces by nothing and runs the query as production does. + let baseline: unknown; try { - expectNoEmptyFields(await run(NOTHING_OMITTED), optional); + baseline = await run(NOTHING_OMITTED); + expectNoEmptyFields(baseline, optional); } catch (error) { throw new Error( `the response is already incomplete with the full projection, so dropping fields proves ` + `nothing — fix the fixture or the projection first. Cause: ${error.message ?? error}`, ); } + const reference = JSON.stringify(baseline); const survived: string[] = []; for (const candidate of candidates) { - let stillComplete = false; + let unchanged = false; try { - expectNoEmptyFields(await run(candidate), optional); - stillComplete = true; + unchanged = JSON.stringify(await run(candidate)) === reference; } catch { - // Either the response lost a value or the query itself refused to run without the field. - // Both mean the field carries weight, which is what this level asserts. + // The query itself refused to run without the field — it carries weight, which is what this + // level asserts. } - if (stillComplete) survived.push(Array.isArray(candidate) ? `[${candidate.join(' + ')}]` : candidate); + if (unchanged) survived.push(Array.isArray(candidate) ? `[${candidate.join(' + ')}]` : candidate); } if (survived.length) throw new Error( - `these fields can be dropped without any response field going empty: ${survived.join(', ')} — ` + - `either they are not needed, or the fixture has a gap at exactly that point`, + `these fields can be dropped without changing the response: ${survived.join(', ')} — ` + + `either they are not needed, or the fixture does not reach the branch that reads them`, ); } diff --git a/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts b/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts new file mode 100644 index 0000000000..35fb8dee40 --- /dev/null +++ b/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts @@ -0,0 +1,185 @@ +import { KycDataDtoMapper } from 'src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper'; +import { KycStatus, KycType } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { + USER_KYC_FILES_PROJECTION, + USER_KYC_FILES_RESPONSE_FIELDS, + UserRepository, +} from 'src/subdomains/generic/user/models/user/user.repository'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { + WALLET_KYC_DATA_PROJECTION, + WALLET_KYC_DATA_RESPONSE_FIELDS, + WalletRepository, +} from 'src/subdomains/generic/user/models/wallet/wallet.repository'; +import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'kyc_data_projection_spec'; + +/** + * `GET /kyc/users` and `GET /kyc/:id/documents` — the four levels from + * `docs/read-path-projections.md`. + * + * Both loaded a whole row graph — 328 columns — for very little: the first for an address, two + * status fields and a hash per user, the second for nothing but the account id the document store + * is keyed by. + */ +describeProjection('kyc data — read-path projection', () => { + let dataSource: DataSource; + let wallets: WalletRepository; + let users: UserRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + wallets = new WalletRepository(dataSource.manager); + users = new UserRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * A wallet with one user on it. + * + * `kycStatus` and `kycType` are set explicitly: both are TypeScript enums in text columns, and + * `getKycWebhookStatus` maps them by value — a generated string lands in the fallback branch and + * would make the response look complete for the wrong reason. + */ + async function seedWalletUser( + kycStatus = KycStatus.COMPLETED, + kycType = KycType.DFX, + ): Promise<{ wallet: Wallet; user: User; userData: UserData }> { + const wallet = await seedEntity(dataSource, Wallet); + const userData = await seedEntity(dataSource, UserData, { values: { kycStatus, kycType } }); + const user = await seedEntity(dataSource, User, { values: { wallet, userData } }); + return { wallet, user, userData }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — the kyc user list answers with no empty field', async () => { + const { wallet } = await seedWalletUser(); + + const loaded = await wallets.findKycData(wallet.id); + + expect(loaded.users).toHaveLength(1); + expectNoEmptyFields(loaded.users.map(KycDataDtoMapper.toDto)); + }, 120000); + + it('level 1 — the document lookup loads the account id', async () => { + const { user, userData } = await seedWalletUser(); + + const loaded = await users.findAccountIdForAddress(user.address, user.wallet.id); + + expect(loaded?.userData?.id).toEqual(userData.id); + }, 120000); + + // --- LEVEL 2: variants --- // + + it.each([ + [KycStatus.COMPLETED, KycType.LOCK], + [KycStatus.REJECTED, KycType.DFX], + [KycStatus.NA, KycType.DFX], + ])( + 'level 2 — kycStatus %s with kycType %s answers with no empty field', + async (kycStatus, kycType) => { + const { wallet } = await seedWalletUser(kycStatus, kycType); + + const loaded = await wallets.findKycData(wallet.id); + + // `getKycWebhookStatus` branches on both values, and the LOCK/DFX split only shows on a + // completed account. A fixture covering one combination says nothing about the others. + expectNoEmptyFields(loaded.users.map(KycDataDtoMapper.toDto)); + }, + 120000, + ); + + it('level 2 — a wallet answers only with its own users', async () => { + const mine = await seedWalletUser(); + const other = await seedWalletUser(); + + const loaded = await wallets.findKycData(mine.wallet.id); + + expect(loaded.users.map((u) => u.address)).toEqual([mine.user.address]); + expect(loaded.users.map((u) => u.address)).not.toContain(other.user.address); + }, 120000); + + it('level 2 — the document lookup is scoped to the wallet', async () => { + const { user } = await seedWalletUser(); + const foreign = await seedWalletUser(); + + // The address alone must not resolve: it is scoped to the wallet the caller authenticated with. + expect(await users.findAccountIdForAddress(user.address, foreign.wallet.id)).toBeNull(); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it.each([KycType.LOCK, KycType.DFX])( + 'level 3 — with kycType %s every field feeding the kyc user list is required', + async (kycType) => { + const { wallet } = await seedWalletUser(KycStatus.COMPLETED, kycType); + // `kycType` only changes the answer on a completed account, and only for LOCK: the DFX branch + // produces the same value the absent field would. One fixture therefore cannot show that the + // column is needed — which is what the row above is for. + const candidates = + kycType === KycType.LOCK + ? WALLET_KYC_DATA_RESPONSE_FIELDS + : WALLET_KYC_DATA_RESPONSE_FIELDS.filter((field) => field !== 'walletUserData.kycType'); + + await expectEveryFieldRequired(candidates, (omitted) => + wallets + .findKycData(wallet.id, projectionFieldsWithout(WALLET_KYC_DATA_PROJECTION.fields, omitted)) + .then((loaded) => loaded.users.map(KycDataDtoMapper.toDto)), + ); + }, + 300000, + ); + + it('level 3 — the account id is required for the document lookup', async () => { + const { user } = await seedWalletUser(); + + await expectEveryFieldRequired(USER_KYC_FILES_RESPONSE_FIELDS, (omitted) => + users + .findAccountIdForAddress( + user.address, + user.wallet.id, + projectionFieldsWithout(USER_KYC_FILES_PROJECTION.fields, omitted), + ) + .then((loaded) => ({ accountId: loaded?.userData?.id })), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([ + [KycStatus.COMPLETED, KycType.DFX], + [KycStatus.COMPLETED, KycType.LOCK], + [KycStatus.REJECTED, KycType.DFX], + ])( + 'level 4 — for %s / %s the projected response equals the one from a full load', + async (kycStatus, kycType) => { + const { wallet } = await seedWalletUser(kycStatus, kycType); + + const projected = (await wallets.findKycData(wallet.id)).users.map(KycDataDtoMapper.toDto); + // The unprojected load is the second source: the relation set the endpoint used before. + const full = await dataSource.getRepository(Wallet).findOne({ + where: { id: wallet.id }, + relations: { users: { userData: true } }, + }); + + expect(projected).toEqual(full.users.map(KycDataDtoMapper.toDto)); + }, + 120000, + ); +}); diff --git a/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts b/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts new file mode 100644 index 0000000000..3fe54a860b --- /dev/null +++ b/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts @@ -0,0 +1,19 @@ +import { getKycWebhookStatus } from 'src/subdomains/generic/user/services/webhook/mapper/webhook-data.mapper'; +import { User } from '../../user/user.entity'; +import { KycDataDto } from './kyc-data.dto'; + +/** + * The per-user entry `GET /kyc/users` answers with. + * + * Moved out of `KycService` so the projection spec can drive the same mapping the endpoint uses; a + * copy in the spec could be wrong in exactly the way the projection is wrong. + */ +export class KycDataDtoMapper { + static toDto(user: User): KycDataDto { + return { + id: user.address, + kycStatus: getKycWebhookStatus(user.userData.kycStatus, user.userData.kycType), + kycHash: user.userData.kycHash, + }; + } +} diff --git a/src/subdomains/generic/user/models/kyc/kyc.service.ts b/src/subdomains/generic/user/models/kyc/kyc.service.ts index b58256b938..1652723770 100644 --- a/src/subdomains/generic/user/models/kyc/kyc.service.ts +++ b/src/subdomains/generic/user/models/kyc/kyc.service.ts @@ -17,15 +17,14 @@ import { ContentType } from 'src/subdomains/generic/kyc/enums/content-type.enum' import { FileCategory } from 'src/subdomains/generic/kyc/enums/file-category.enum'; import { KycDocumentService } from 'src/subdomains/generic/kyc/services/integration/kyc-document.service'; import { Blank, UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; -import { getKycWebhookStatus } from '../../services/webhook/mapper/webhook-data.mapper'; import { BlankType, KycLevel, KycState } from '../user-data/user-data.enum'; import { UserDataRepository } from '../user-data/user-data.repository'; import { UserDataService } from '../user-data/user-data.service'; -import { User } from '../user/user.entity'; import { UserRepository } from '../user/user.repository'; import { WalletRepository } from '../wallet/wallet.repository'; import { KycDataTransferDto } from './dto/kyc-data-transfer.dto'; import { KycDataDto } from './dto/kyc-data.dto'; +import { KycDataDtoMapper } from './dto/kyc-data-dto.mapper'; import { KycDocumentType, KycFileDto } from './dto/kyc-file.dto'; import { KycInfo } from './dto/kyc-info.dto'; @@ -135,19 +134,13 @@ export class KycService { // --- GET COMPANY KYC --- // async getAllKycData(walletId: number): Promise { - const wallet = await this.walletRepo.findOne({ - where: { id: walletId }, - relations: { users: { userData: true } }, - }); + const wallet = await this.walletRepo.findKycData(walletId); - return wallet.users.map((b) => this.toKycDataDto(b)); + return wallet.users.map(KycDataDtoMapper.toDto); } async getKycFiles(userAddress: string, walletId: number): Promise { - const user = await this.userRepo.findOne({ - where: { address: userAddress, wallet: { id: walletId } }, - relations: { userData: true, wallet: true }, - }); + const user = await this.userRepo.findAccountIdForAddress(userAddress, walletId); if (!user) throw new NotFoundException('User not found'); const allDocuments = await this.documentService.listUserFiles(user.userData.id); @@ -175,14 +168,6 @@ export class KycService { } // --- HELPER METHODS --- // - private toKycDataDto(user: User): KycDataDto { - return { - id: user.address, - kycStatus: getKycWebhookStatus(user.userData.kycStatus, user.userData.kycType), - kycHash: user.userData.kycHash, - }; - } - private toKycFileDto(type: KycDocumentType, { contentType }: KycFileBlob): KycFileDto { return { type, contentType }; } diff --git a/src/subdomains/generic/user/models/user/user.repository.ts b/src/subdomains/generic/user/models/user/user.repository.ts index b5d9db658c..4053ea8c48 100644 --- a/src/subdomains/generic/user/models/user/user.repository.ts +++ b/src/subdomains/generic/user/models/user/user.repository.ts @@ -1,16 +1,54 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { Util } from 'src/shared/utils/util'; import { EntityManager, Raw, Repository } from 'typeorm'; import { KycLevel } from '../user-data/user-data.enum'; import { User } from './user.entity'; +/** What `GET /kyc/:id/documents` needs off the account row: the id the document store is keyed by. */ +export const USER_KYC_FILES_RESPONSE_FIELDS = ['kycFilesUserData.id']; + +/** + * `GET /kyc/:id/documents` — resolves the account behind an address on a wallet. + * + * The wallet is joined for the filter only; nothing of it reaches the response. + */ +export const USER_KYC_FILES_PROJECTION = new ReadProjection( + 'user', + [ + ['user.userData', 'kycFilesUserData'], + ['user.wallet', 'kycFilesWallet'], + ], + USER_KYC_FILES_RESPONSE_FIELDS, + ['user.id'], +); + @Injectable() export class UserRepository extends BaseRepository { constructor(manager: EntityManager) { super(User, manager); } + /** + * The account behind an address on a wallet, loaded with the account id only. + * + * `GET /kyc/:id/documents` reads nothing else off the row: the response is assembled from the + * document store, keyed by that id. The unprojected load reaches 328 columns for it. + * + * `fields` exists for the mutation test; nothing in production passes it. + */ + async findAccountIdForAddress( + address: string, + walletId: number, + fields: ReadonlyArray = USER_KYC_FILES_PROJECTION.fields, + ): Promise { + return USER_KYC_FILES_PROJECTION.apply(this.createQueryBuilder('user'), fields) + .where('user.address = :address', { address }) + .andWhere('kycFilesWallet.id = :walletId', { walletId }) + .getOne(); + } + async setUserRef(user: User, kycLevel: KycLevel, manager?: EntityManager): Promise { if (!user.ref && kycLevel >= KycLevel.LEVEL_50) { const repo = manager?.getRepository(User) ?? this; diff --git a/src/subdomains/generic/user/models/wallet/wallet.repository.ts b/src/subdomains/generic/user/models/wallet/wallet.repository.ts index 77efeb52c8..36bdebd7f5 100644 --- a/src/subdomains/generic/user/models/wallet/wallet.repository.ts +++ b/src/subdomains/generic/user/models/wallet/wallet.repository.ts @@ -1,14 +1,54 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { CachedRepository } from 'src/shared/repositories/cached.repository'; import { EntityManager } from 'typeorm'; import { Wallet } from './wallet.entity'; +/** The four values `KycService.toKycDataDto` reads per user of the wallet. */ +export const WALLET_KYC_DATA_RESPONSE_FIELDS = [ + 'walletUser.address', + 'walletUserData.kycStatus', + 'walletUserData.kycType', + 'walletUserData.kycHash', +]; + +/** + * `GET /kyc/users` — the KYC state of every user on a wallet. + * + * Loading the wallet with `relations: { users: { userData: true } }` reaches 328 columns per user, + * for an address, two status fields and a hash. + */ +export const WALLET_KYC_DATA_PROJECTION = new ReadProjection( + 'wallet', + [ + ['wallet.users', 'walletUser'], + ['walletUser.userData', 'walletUserData'], + ], + WALLET_KYC_DATA_RESPONSE_FIELDS, + // Never part of the response: the primary keys that make the ORM materialise the joined rows. + ['wallet.id', 'walletUser.id', 'walletUserData.id'], +); + @Injectable() export class WalletRepository extends CachedRepository { constructor(manager: EntityManager) { super(Wallet, manager); } + /** + * A wallet with its users' KYC state, and nothing else. + * + * `fields` exists for the mutation test; nothing in production passes it. + */ + async findKycData( + walletId: number, + fields: ReadonlyArray = WALLET_KYC_DATA_PROJECTION.fields, + ): Promise { + return WALLET_KYC_DATA_PROJECTION.apply(this.createQueryBuilder('wallet'), fields) + .where('wallet.id = :walletId', { walletId }) + .getOne(); + } + async getByAddress(address: string): Promise { return this.findOneBy({ address }); } From 3f9c65549d7a02f2dff15ba710f230c7c27893fd Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:56:38 +0200 Subject: [PATCH 06/46] Record what the pre-filters select, and why the number is what it is The criteria for a qualifying endpoint were stated but never counted. Applied to the inventory they select 28 of the 417 that fetch whole rows, nine of them converted so far. The step that decides the size of this work is the first one, and it has a single cause: at 343 of the 555 load sites involved, the loaded entity leaves the loading method. What fields are needed is then decided by each caller - 113 of them for UserDataService.getUserData - so a projection at the load site would be guessed rather than derived, and the union over its callers is the whole entity anyway. Also records the six endpoints excluded one level below the handler signature: their DTO has a field typed as an entity (`currency: Fiat`, `targetAsset: Asset`), so the response carries every column of it and a projection would list them all to save nothing. Narrowing those means changing the contract. --- docs/read-path-projections.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 56b5e41033..9542b967e8 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -123,6 +123,36 @@ All of the following must hold: The first four are pre-filters; the fifth decides. +## What qualifies, measured + +The pre-filters above are stated as criteria; this is what they select when applied to the +inventory. Every step is mechanical except the last two, which were read in the source. + +| | endpoints | +| --- | ---: | +| fetch whole rows | 417 | +| … every load site they reach can be narrowed at all | 77 | +| … no write to the loaded entity anywhere in the call chain | 44 | +| … the response is not an entity, a stream, or `void` | 30 | +| … and no DTO field passes an entity through | **28** | + +The step from 417 to 77 is the one that decides the size of this work, and it has a single cause: +at 343 of the 555 load sites involved, **the loaded entity leaves the loading method**. +`UserDataService.getUserData` returns `Promise` to 113 different endpoints. What fields +are needed is decided by each caller, not at the load site, so a projection there would be guessed +rather than derived — and the union over 113 callers is the whole entity anyway. Splitting those +hubs into per-caller reads is a separate piece of work with a different shape. It is not assumed +here, and the numbers above do not depend on whether it happens. + +The last step removes six endpoints whose DTO has a field typed as an entity — `currency: Fiat`, +`targetAsset: Asset`. The response then contains every column of that entity, so a projection would +have to list them all and would save nothing. Narrowing those means changing the contract, which is +a different decision. + +Nine of the 28 are converted. The remaining 19 are the ones marked `not yet` with a `whole rows` +access in [endpoints.md](endpoints.md); the widest is `GET /user` at 351 columns, and eleven of them +are support and dashboard reads between 7 and 99. + ## The risk this must guard against A missing field does not crash. It is simply absent, getters compute with it anyway, and the From 1fafea8d9ed661cc6c37bb80283edb236eeb7729 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:04:09 +0200 Subject: [PATCH 07/46] Project the custody order history GET /custody/order 19 columns -> 14 The query joined both assets and the transaction request with leftJoinAndSelect, which loads each of them whole for two names and two amounts. Level 3 needed a second fixture here. `inputAmount ?? transactionRequest.estimatedAmount` only reaches the request when the order has no amount of its own, so against a fully populated order the two request columns look removable - correctly, for that fixture. The row that drops them is the one where they are the only source the response has. The alias check and the inventory's collection step both had to learn one more shape: a projection applied to an injected repository's builder (`PROJECTION.apply(this.orderRepo.createQueryBuilder(...))`) rather than to the repository's own. The narrower pattern matched only the latter and reported this query as loading whole rows. --- docs/endpoints.md | 16 +- docs/load-sites.md | 30 +-- docs/read-path-projections.md | 18 +- .../models/__tests__/read-projection.spec.ts | 2 + .../custody-order-history.projection.spec.ts | 198 ++++++++++++++++++ .../repositories/custody-order.repository.ts | 35 ++++ .../custody/services/custody-order.service.ts | 21 +- 7 files changed, 281 insertions(+), 39 deletions(-) create mode 100644 src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts diff --git a/docs/endpoints.md b/docs/endpoints.md index 9151a50376..35d848b1ab 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -23,20 +23,20 @@ Two rules follow from that, and both are binding: 1. **An endpoint counts as converted only when its tests reach `4/4`** against the four levels in [read-path-projections.md](read-path-projections.md#test-definition). A projection without them is worse than no projection: a forgotten field does not crash, it returns a wrong value with a 200, and in a service moving money that can run for weeks unnoticed. Anything short of `4/4` is unfinished work, not a partial success. 2. **The state of every endpoint is recorded here**, in the `Tests` column, and kept in sync with the code in the same pull request that changes it. An undocumented conversion is indistinguishable from one that was never tested. -Today 28 endpoints read only what they return and 408 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. +Today 29 endpoints read only what they return and 407 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. ## What the numbers say | Data access | Endpoints | Share | | ----------- | --------: | ----: | -| `whole rows` | 408 | 76 % | +| `whole rows` | 407 | 76 % | | `none` | 98 | 18 % | -| `projected` | 26 | 5 % | +| `projected` | 27 | 5 % | | `caller-defined` | 2 | 0 % | -Of the 26 that read only what they return, 9 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11), `GET /kyc/users` (328 columns to 7), `GET /kyc/:id/documents` (328 columns to 2). The other 17 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. +Of the 27 that read only what they return, 10 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11), `GET /kyc/users` (328 columns to 7), `GET /kyc/:id/documents` (328 columns to 2), `GET /custody/order` (19 columns to 14). The other 17 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. -Among the 408 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 311 exceed 100, 87 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. +Among the 407 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 311 exceed 100, 87 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. ### How to read this column, and how not to @@ -50,9 +50,9 @@ Among the 408 that fetch whole rows, the widest query they can trigger is **308 Stated exactly, so the numbers can be checked rather than believed: -- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 408 is a lower bound. +- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 407 is a lower bound. - All 98 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. -- The 26 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. +- The 27 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. - 2 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`. The classification holds; only the width is unknown. ### Two controller classes may share a name @@ -212,7 +212,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/custody/admin/orders` | public | whole rows | 525 | not yet | | `CustodyAdminController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | | PUT | 1 | | `/custody/admin/user/:id/balance` | public | whole rows | 308 | not yet | | `CustodyAdminController.updateUserBalance` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/custody/history` | public | whole rows | 253 | not yet | | `CustodyController.getUserCustodyHistory` | `subdomains/core/custody/controllers/custody.controller.ts` | -| GET | 1 | | `/custody/order` | public | whole rows | 19 | not yet | | `CustodyController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | +| GET | 1 | | `/custody/order` | public | projected | 14 | 4/4 | | `CustodyController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | | POST | 1 | | `/custody/order` | public | whole rows | 364 | not yet | | `CustodyController.createOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | | POST | 1 | | `/custody/order/:id/confirm` | public | whole rows | 525 | not yet | | `CustodyController.confirmOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/custody/pdf` | public | whole rows | 253 | not yet | | `CustodyController.getCustodyPdf` | `subdomains/core/custody/controllers/custody.controller.ts` | diff --git a/docs/load-sites.md b/docs/load-sites.md index 389b38d17e..181764f0ba 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -18,10 +18,10 @@ Among the query builders, the field list is what decides whether anything is act | | Sites | | --- | ---: | -| `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **11** | +| `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **12** | | `.select('alias.column')` — names columns one by one | **87** | | `.select('alias')` — selects the root alias, **loads every column** | 17 | -| no `select` at all — loads every column | 23 | +| no `select` at all — loads every column | 22 | | projects, but a `leftJoinAndSelect` loads a relation whole | 1 | `.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The distinction is the presence of a dot in the argument, and it matters — the sites that name columns this way select 1 column at the median, against 961 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. @@ -106,8 +106,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 540 | 17 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:763` | `BuyFiatService.getByAmlReason` | | 538 | 21 | find | `CryptoInput` | `subdomains/core/accounting/services/consumers/crypto-input.consumer.ts:89` | `CryptoInputConsumer.processForward` | | 535 | 15 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:947` | `BuyCryptoService.getPendingTransactions` | -| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:294` | `CustodyOrderService.confirmOrder` | -| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:310` | `CustodyOrderService.getOrdersForSupport` | +| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:301` | `CustodyOrderService.confirmOrder` | +| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:317` | `CustodyOrderService.getOrdersForSupport` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:615` | `BuyFiatPreparationService.chargebackTx` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:486` | `BuyFiatService.retriggerScorechain` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:14` | `PaymentLinkRepository.getAllPaymentLinks` | @@ -214,7 +214,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:624` | `BankTxService.getUnassignedBankTx` | | 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:634` | `BankTxService.getBankTxsByVirtualIban` | | 354 | 13 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:117` | `SellService.getSellsByIban` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/kyc/kyc.service.ts:127` | `KycService.getUserByKycCode` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/kyc/kyc.service.ts:126` | `KycService.getUserByKycCode` | | 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:266` | `UserService.getUserDtoV2` | | 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:409` | `UserService.updateUser` | | 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:421` | `UserService.updateUserMail` | @@ -230,15 +230,15 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1316` | `VirtualIbanService.getVirtualIbansForAccount` | | 329 | 9 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:278` | `TransactionRequestService.findAndComplete` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/auth/auth.controller.ts:157` | `AuthController.createAccessTokenAfterMerge` | -| 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:156` | `KycService.getKycFile` | +| 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:155` | `KycService.getKycFile` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:129` | `UserService.getUserDto` | | 323 | 10 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1246` | `RealUnitService.forwardRegistrationToAktionariat` | | 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:362` | `BuyFiatService.getBuyFiatByTransactionId` | | 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:367` | `BuyFiatService.getBuyFiatsByTransactionIds` | | 319 | 10 | find | `BuyFiat` | `subdomains/core/accounting/services/ledger-cutover.service.ts:308` | `LedgerCutoverService.openBuyFiatReceived` | | 319 | 10 | find | `BuyFiat` | `subdomains/core/accounting/services/ledger-cutover.service.ts:357` | `LedgerCutoverService.openBuyFiatOwed` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:61` | `KycService.transferKycData` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:77` | `KycService.transferKycData` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:60` | `KycService.transferKycData` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:76` | `KycService.transferKycData` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:80` | `UserService.getAllUser` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:84` | `UserService.getUser` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:88` | `UserService.getAllUserDataUsers` | @@ -254,7 +254,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 301 | 6 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:89` | `BankTxRepeatService.getAllUserRepeats` | | 299 | 12 | find | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:65` | `TradingOrderService.startNewOrders` | | 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:113` | `CustodyJobService.onStepComplete` | -| 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:284` | `CustodyOrderService.getCustodyOrderByTx` | +| 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:291` | `CustodyOrderService.getCustodyOrderByTx` | | 287 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:746` | `BuyFiatService.getTransactions` | | 284 | 10 | find | `UserData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:173` | `BankDataService.addBankData` | | 276 | 10 | find | `NameCheckLog` | `subdomains/generic/kyc/services/name-check.service.ts:205` | `NameCheckService.createNameCheckLog` | @@ -329,7 +329,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 229 | 11 | find | `CryptoInput` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:527` | `DashboardReconciliationService.getCryptoInputs` | | 226 | 7 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:94` | `CustodyJobService.checkStep` | | 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:52` | `CustodyJobService.resetExpiredConfirmedOrders` | -| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:319` | `CustodyOrderService.approveOrder` | +| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:326` | `CustodyOrderService.approveOrder` | | 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:257` | `CustodyService.getUserCustodyHistory` | | 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:470` | `CustodyService.calculateAccruedInterest` | | 201 | 5 | find | `BuyFiat` | `subdomains/core/accounting/services/consumers/payout-order.consumer.ts:293` | `PayoutOrderConsumer.owedCompletionChf` | @@ -632,7 +632,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 20 | 0 | query-builder (ohne-select) | `PriceRule` | `subdomains/supporting/pricing/services/pricing.service.ts:272` | `PricingService.getRuleFor` | | 19 | 0 | query-builder (ohne-select) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:79` | `SwapService.getSwapByAddress` | | 19 | 0 | query-builder (nur-alias) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:152` | `SwapService.getSwapByKey` | -| 19 | 0 | query-builder (ohne-select) | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:231` | `CustodyOrderService.getOrdersByUserData` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:83` | `KycLogService.updateLog` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:90` | `KycLogService.updateLogPdfUrl` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:109` | `KycLogService.getLogsByUserDataId` | @@ -651,6 +650,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 15 | 0 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:2830` | `RealUnitService.getRegisteredWalletAddresses` | | 14 | 0 | find | `ScorechainScreening` | `integration/scorechain/repositories/scorechain-screening.repository.ts:18` | `ScorechainScreeningRepository.getByObjectIds` | | 14 | 0 | find | `ScorechainScreening` | `integration/scorechain/services/scorechain-screening.service.ts:233` | `ScorechainScreeningService.getCached` | +| 14 | 0 | query-builder (feldliste) | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:239` | `CustodyOrderService.getOrdersByUserData` | | 14 | 0 | query-builder (feldliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/buy-fiat.repository.ts:56` | `BuyFiatRepository.findSellHistory` | | 13 | 0 | query-builder (nur-alias) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:228` | `BuyService.getBuyByKey` | | 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:76` | `PaymentQuoteService.processExpiredQuotes` | @@ -925,7 +925,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/core/custody/services/custody-account.service.ts:504` | `CustodyAccountService.createGrant` | | — | — | find | `—` | `subdomains/core/custody/services/custody-account.service.ts:598` | `CustodyAccountService.grantAccessForLegacy` | | — | — | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:65` | `CustodyJobService.executeOrder` | -| — | — | find | `—` | `subdomains/core/custody/services/custody-order.service.ts:399` | `CustodyOrderService.checkBalance` | +| — | — | find | `—` | `subdomains/core/custody/services/custody-order.service.ts:406` | `CustodyOrderService.checkBalance` | | — | — | find | `—` | `subdomains/core/custody/services/custody.service.ts:307` | `CustodyService.getUserCustodyHistory` | | — | — | find | `—` | `subdomains/core/history/mappers/transaction-dto.mapper.ts:211` | `TransactionDtoMapper.feeAmountType` | | — | — | find | `—` | `subdomains/core/history/services/history-access.service.ts:122` | `HistoryAccessService.resolveFromApiKey` | @@ -1019,9 +1019,9 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:340` | `BankDataService.getVerifiedBankDataWithIban` | | — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:362` | `BankDataService.getAllBankDatasForUser` | | — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:444` | `BankDataService.createIbanForUserInternal` | -| — | — | find | `Wallet` | `subdomains/generic/user/models/kyc/kyc.service.ts:58` | `KycService.transferKycData` | -| — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:179` | `KycService.getFileFor` | -| — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:186` | `KycService.getFileFor` | +| — | — | find | `Wallet` | `subdomains/generic/user/models/kyc/kyc.service.ts:57` | `KycService.transferKycData` | +| — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:178` | `KycService.getFileFor` | +| — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:185` | `KycService.getFileFor` | | — | — | find | `—` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:45` | `RecommendationService.createRecommendationByRecommender` | | — | — | find | `—` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:135` | `RecommendationService.handleRecommendationRequest` | | — | — | find | `—` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:246` | `RecommendationService.setRecommenderRefCode` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 9542b967e8..9711ebdaa6 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -30,8 +30,8 @@ This service loads far more data than it returns. Measured against the real enti render a PDF containing a handful of values. That query sat exactly on Postgres' limit of 1,664 columns per statement, which is why a single new column added elsewhere (`settlementEventId` on `transaction_request`) broke every invoice and receipt in production until it was fixed. -- Of the 534 endpoints, **408 reach at least one load site that fetches whole rows**; 98 read - nothing at all, and **26 read only the fields they return**. The widest query a fetching endpoint +- Of the 534 endpoints, **407 reach at least one load site that fetches whole rows**; 98 read + nothing at all, and **27 read only the fields they return**. The widest query a fetching endpoint can trigger is 308 columns at the median, and 19 of them exceed 1,000. The column limit was the symptom, not the cause. Loading a thousand columns to return one is @@ -47,8 +47,8 @@ and one on `LimitRequest` **434 across 15** — before any `relations` option is decision what to load therefore lives in the entity definition, not at the call site, and no call site can see what it triggers. -**No read model.** Of the 1,105 load sites in this repository, **103** name the columns they need: -98 query builders and the five raw statements. The other 1,002 request whole rows — 961 through the +**No read model.** Of the 1,105 load sites in this repository, **104** name the columns they need: +99 query builders and the five raw statements. The other 1,001 request whole rows — 961 through the `find` family, and of the 139 query builders, 17 pass the root alias to `.select(...)`, which reads like a projection but is not, while 23 pass no select at all. The same entities serve persistence, business logic and pure output paths such as invoices, receipts, history and exports — which need @@ -149,9 +149,9 @@ The last step removes six endpoints whose DTO has a field typed as an entity — have to list them all and would save nothing. Narrowing those means changing the contract, which is a different decision. -Nine of the 28 are converted. The remaining 19 are the ones marked `not yet` with a `whole rows` -access in [endpoints.md](endpoints.md); the widest is `GET /user` at 351 columns, and eleven of them -are support and dashboard reads between 7 and 99. +Ten of the 28 are converted. The remaining 18 are the ones marked `not yet` with a `whole rows` +access in [endpoints.md](endpoints.md); the widest is `GET /user` at 351 columns, and most of the +rest are support and dashboard reads between 7 and 99. ## The risk this must guard against @@ -187,11 +187,11 @@ per endpoint as `0/4` through `4/4`; only `4/4` is done. To any load site that carries an explicit field list — that is where a forgotten field silently yields an empty value. -A hundred and three sites carry a field list. The table below covers the six that were known when this +A hundred and four sites carry a field list. The table below covers the six that were known when this document was written — one query builder and five raw statements — and none of them was converted, so it is unchanged. Another 87 are the query builders that name columns one at a time; they are not covered by these levels either, which is what their endpoints' `0/4` in -[endpoints.md](endpoints.md) records. The remaining 10 belong to the endpoints converted so far and +[endpoints.md](endpoints.md) records. The remaining 11 belong to the endpoints converted so far and are covered on all four. Sites a conversion adds are recorded there too, where only `4/4` counts as done. diff --git a/src/shared/models/__tests__/read-projection.spec.ts b/src/shared/models/__tests__/read-projection.spec.ts index c112807862..f6904b69d9 100644 --- a/src/shared/models/__tests__/read-projection.spec.ts +++ b/src/shared/models/__tests__/read-projection.spec.ts @@ -5,6 +5,7 @@ import { BUY_CRYPTO_BUY_HISTORY_PROJECTION, BUY_CRYPTO_ROUTE_HISTORY_PROJECTION, } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; +import { CUSTODY_ORDER_HISTORY_PROJECTION } from 'src/subdomains/core/custody/repositories/custody-order.repository'; import { BUY_FIAT_HISTORY_PROJECTION } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; import { USER_PROFILE_PROJECTION } from 'src/subdomains/generic/user/models/user-data/user-data.repository'; import { USER_KYC_FILES_PROJECTION } from 'src/subdomains/generic/user/models/user/user.repository'; @@ -26,6 +27,7 @@ const DOCUMENTED: [string, string, ReadProjection][] = [ ['GET', '/support/issue/:id', SUPPORT_ISSUE_PROJECTION], ['GET', '/kyc/users', WALLET_KYC_DATA_PROJECTION], ['GET', '/kyc/:id/documents', USER_KYC_FILES_PROJECTION], + ['GET', '/custody/order', CUSTODY_ORDER_HISTORY_PROJECTION], ]; describe('ReadProjection', () => { diff --git a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts new file mode 100644 index 0000000000..ec8525ff84 --- /dev/null +++ b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts @@ -0,0 +1,198 @@ +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { + CUSTODY_ORDER_HISTORY_PROJECTION, + CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS, +} from 'src/subdomains/core/custody/repositories/custody-order.repository'; +import { CustodyOrder } from 'src/subdomains/core/custody/entities/custody-order.entity'; +import { CustodyOrderHistoryDtoMapper } from 'src/subdomains/core/custody/mappers/custody-order-history-dto.mapper'; +import { CustodyOrderStatus, CustodyOrderType } from 'src/subdomains/core/custody/enums/custody'; +import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'custody_order_history_projection_spec'; + +/** + * `GET /custody/order` — the four levels from `docs/read-path-projections.md`. + * + * The query joined both assets and the transaction request with `leftJoinAndSelect`, loading each + * of them whole — 19 columns for two names and two amounts. + */ +describeProjection('GET /custody/order — read-path projection', () => { + let dataSource: DataSource; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * One order of a user, with every column populated. + * + * `type` and `status` are set explicitly: both are TypeScript enums in text columns, and the + * mapper switches on them — a generated value lands in the default branch. + */ + async function seedOrder( + type = CustodyOrderType.DEPOSIT, + status = CustodyOrderStatus.COMPLETED, + withAmounts = true, + ): Promise<{ order: CustodyOrder; userData: UserData }> { + const userData = await seedEntity(dataSource, UserData); + const user = await seedEntity(dataSource, User, { values: { userData } }); + const inputAsset = await seedEntity(dataSource, Asset); + const outputAsset = await seedEntity(dataSource, Asset); + const transactionRequest = await seedEntity(dataSource, TransactionRequest); + const order = await seedEntity(dataSource, CustodyOrder, { + // Without its own amounts the order falls back to the request it came from — the only state in + // which the two transactionRequest columns reach the response. + values: { + user, + inputAsset, + outputAsset, + transactionRequest, + type, + status, + ...(withAmounts ? {} : { inputAmount: null, outputAmount: null }), + }, + }); + return { order, userData }; + } + + /** The response the endpoint produces, through the projected query. */ + async function historyOf(userDataId: number, fields = CUSTODY_ORDER_HISTORY_PROJECTION.fields) { + const orders = await CUSTODY_ORDER_HISTORY_PROJECTION.apply( + dataSource.getRepository(CustodyOrder).createQueryBuilder('custodyOrder'), + fields, + ) + .innerJoin('custodyOrder.user', 'user') + .innerJoin('user.userData', 'userData') + .where('userData.id = :userDataId', { userDataId }) + .andWhere('custodyOrder.status != :createdStatus', { createdStatus: CustodyOrderStatus.CREATED }) + .getMany(); + return CustodyOrderHistoryDtoMapper.mapList(orders); + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a completed order answers with no empty field', async () => { + const { userData } = await seedOrder(); + + const history = await historyOf(userData.id); + + expect(history).toHaveLength(1); + expectNoEmptyFields(history); + }, 120000); + + // --- LEVEL 2: variants --- // + + it.each([ + [CustodyOrderType.DEPOSIT, CustodyOrderStatus.CONFIRMED], + [CustodyOrderType.WITHDRAWAL, CustodyOrderStatus.CONFIRMED], + [CustodyOrderType.SWAP, CustodyOrderStatus.IN_PROGRESS], + [CustodyOrderType.WITHDRAWAL, CustodyOrderStatus.FAILED], + ])( + 'level 2 — %s / %s answers with no empty field', + async (type, status) => { + const { userData } = await seedOrder(type, status); + + // `mapStatus` reads type and status together, and the incoming/outgoing split only shows on a + // confirmed order. `inputAmount` and `outputAmount` fall back to the transaction request for + // incoming and swap orders, so both directions have to be covered. + expectNoEmptyFields(await historyOf(userData.id)); + }, + 120000, + ); + + it('level 2 — an order still in creation is not listed', async () => { + const { userData } = await seedOrder(CustodyOrderType.DEPOSIT, CustodyOrderStatus.CREATED); + + expect(await historyOf(userData.id)).toHaveLength(0); + }, 120000); + + it('level 2 — a user sees only their own orders', async () => { + const mine = await seedOrder(); + const other = await seedOrder(); + + const history = await historyOf(mine.userData.id); + + expect(history).toHaveLength(1); + expect(history[0].created).toEqual(mine.order.created); + expect(history[0].created).not.toEqual(other.order.created); + }, 120000); + + // --- LEVEL 3: mutation --- // + + const REQUEST_FALLBACK = ['transactionRequest.estimatedAmount', 'transactionRequest.amount']; + + it.each([ + // With its own amounts set, an order never reads the request — those two columns are covered by + // the row below, where the fallback is the only source the response has. + [ + CustodyOrderType.DEPOSIT, + true, + CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS.filter((f) => !REQUEST_FALLBACK.includes(f)), + ], + [ + CustodyOrderType.WITHDRAWAL, + true, + CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS.filter((f) => !REQUEST_FALLBACK.includes(f)), + ], + [CustodyOrderType.SWAP, false, REQUEST_FALLBACK], + ] as [CustodyOrderType, boolean, string[]][])( + 'level 3 — for %s with own amounts=%s every field feeding the response is required', + async (type, withAmounts, candidates) => { + const { userData } = await seedOrder(type, CustodyOrderStatus.CONFIRMED, withAmounts); + + await expectEveryFieldRequired( + candidates, + (omitted) => historyOf(userData.id, projectionFieldsWithout(CUSTODY_ORDER_HISTORY_PROJECTION.fields, omitted)), + withAmounts ? [] : ['[0].inputAmount', '[0].outputAmount'], + ); + }, + 300000, + ); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([ + [CustodyOrderType.DEPOSIT, CustodyOrderStatus.COMPLETED], + [CustodyOrderType.SWAP, CustodyOrderStatus.CONFIRMED], + [CustodyOrderType.WITHDRAWAL, CustodyOrderStatus.FAILED], + ])( + 'level 4 — for %s / %s the projected response equals the one from a full load', + async (type, status) => { + const { userData } = await seedOrder(type, status); + + const projected = await historyOf(userData.id); + // The unprojected load is the second source: `leftJoinAndSelect` on the three relations, which + // is what the query did before the conversion. + const full = await dataSource + .getRepository(CustodyOrder) + .createQueryBuilder('custodyOrder') + .leftJoinAndSelect('custodyOrder.inputAsset', 'inputAsset') + .leftJoinAndSelect('custodyOrder.outputAsset', 'outputAsset') + .leftJoinAndSelect('custodyOrder.transactionRequest', 'transactionRequest') + .innerJoin('custodyOrder.user', 'user') + .innerJoin('user.userData', 'userData') + .where('userData.id = :userDataId', { userDataId: userData.id }) + .andWhere('custodyOrder.status != :createdStatus', { createdStatus: CustodyOrderStatus.CREATED }) + .getMany(); + + expect(projected).toEqual(CustodyOrderHistoryDtoMapper.mapList(full)); + }, + 120000, + ); +}); diff --git a/src/subdomains/core/custody/repositories/custody-order.repository.ts b/src/subdomains/core/custody/repositories/custody-order.repository.ts index e5ec46cb06..fcff3d2273 100644 --- a/src/subdomains/core/custody/repositories/custody-order.repository.ts +++ b/src/subdomains/core/custody/repositories/custody-order.repository.ts @@ -1,8 +1,43 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; import { CustodyOrder } from '../entities/custody-order.entity'; +/** What `CustodyOrderHistoryDtoMapper.map` reads. */ +export const CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS = [ + 'custodyOrder.type', + // `mapStatus` translates it together with `type`; both branches of that switch are response state. + 'custodyOrder.status', + 'custodyOrder.created', + 'custodyOrder.completedAt', + 'custodyOrder.inputAmount', + 'custodyOrder.outputAmount', + 'inputAsset.name', + 'outputAsset.name', + 'transactionRequest.estimatedAmount', + 'transactionRequest.amount', +]; + +/** + * `GET /custody/order` — a user's order history. + * + * The query joined the two assets and the transaction request with `leftJoinAndSelect`, which loads + * each of them whole: 19 columns for the two names and two amounts the response shows. + */ +export const CUSTODY_ORDER_HISTORY_PROJECTION = new ReadProjection( + 'custodyOrder', + [ + ['custodyOrder.inputAsset', 'inputAsset'], + ['custodyOrder.outputAsset', 'outputAsset'], + ['custodyOrder.transactionRequest', 'transactionRequest'], + ], + CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS, + // Never part of the response: the primary keys that materialise the joined rows, and the id the + // query orders by to keep pagination deterministic. + ['custodyOrder.id', 'inputAsset.id', 'outputAsset.id', 'transactionRequest.id'], +); + @Injectable() export class CustodyOrderRepository extends BaseRepository { constructor(manager: EntityManager) { diff --git a/src/subdomains/core/custody/services/custody-order.service.ts b/src/subdomains/core/custody/services/custody-order.service.ts index 4c00fec9c0..8ede4c17fe 100644 --- a/src/subdomains/core/custody/services/custody-order.service.ts +++ b/src/subdomains/core/custody/services/custody-order.service.ts @@ -41,7 +41,7 @@ import { CustodyOrderHistoryDtoMapper } from '../mappers/custody-order-history-d import { CustodyOrderResponseDtoMapper } from '../mappers/custody-order-response-dto.mapper'; import { GetCustodyOrderDtoMapper } from '../mappers/get-custody-order-dto.mapper'; import { CustodyOrderStepRepository } from '../repositories/custody-order-step.repository'; -import { CustodyOrderRepository } from '../repositories/custody-order.repository'; +import { CUSTODY_ORDER_HISTORY_PROJECTION, CustodyOrderRepository } from '../repositories/custody-order.repository'; import { CustodyAccountService } from './custody-account.service'; import { CustodyService } from './custody.service'; @@ -232,12 +232,19 @@ export class CustodyOrderService { }; } - async getOrdersByUserData(userDataId: number): Promise { - const orders = await this.custodyOrderRepo - .createQueryBuilder('custodyOrder') - .leftJoinAndSelect('custodyOrder.inputAsset', 'inputAsset') - .leftJoinAndSelect('custodyOrder.outputAsset', 'outputAsset') - .leftJoinAndSelect('custodyOrder.transactionRequest', 'transactionRequest') + /** + * A user's custody order history. + * + * `fields` exists for the mutation test; nothing in production passes it. + */ + async getOrdersByUserData( + userDataId: number, + fields: ReadonlyArray = CUSTODY_ORDER_HISTORY_PROJECTION.fields, + ): Promise { + const orders = await CUSTODY_ORDER_HISTORY_PROJECTION.apply( + this.custodyOrderRepo.createQueryBuilder('custodyOrder'), + fields, + ) .innerJoin('custodyOrder.user', 'user') .innerJoin('user.userData', 'userData') .where('userData.id = :userDataId', { userDataId }) From df4b3a569e0f87367499870cf0120bca05960da5 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:31:26 +0200 Subject: [PATCH 08/46] Project the support issue list GET /support/issue/list 16 columns -> 10 GET /realunit/support/list 16 -> 10 The six it drops are the five foreign keys and `information`, the free-form body of the issue. A list page shows a row per issue and none of that column, so on a full page it is the bulk of what the query transfers. The query moves from the service into the repository. That is what makes the four levels reachable at all: the filter, the search predicate and the pagination were built inline against an injected repository, so the only way to observe them was a chainable mock that records method calls - which cannot see what the statement selects, and cannot tell a query that runs from one Postgres rejects. The service keeps what it decides (role to departments, term splitting, the day-extension of a date-only upper bound); the repository takes the query. The service spec follows that split. Its assertions move from the recorded builder calls to the resolved query object, which is a stronger statement of the same thing: that support cannot widen its department set through ?department is now asserted on the value the service produced, not on the SQL fragment it happened to emit. Two findings from writing the tests: - The message branch of the search named `support_message` as a literal table. The ORM resolves every other table through the metadata, this one against the search path, so the statement only works where the tables happen to be on it. It now goes through the query builder like the rest. - The int4 guard on the id branch - a term above 2^31-1, a pasted phone number being the realistic case, must not reach the comparison - was pinned by asserting on the emitted SQL string. It is now asserted where it fails: the search runs against a real database and answers instead of raising 22003. --- .../support-issue-list.projection.spec.ts | 239 ++++++++++++++++++ .../repositories/support-issue.repository.ts | 122 +++++++++ .../__tests__/support-issue.service.spec.ts | 143 ++++------- .../services/support-issue.service.ts | 87 +++---- 4 files changed, 436 insertions(+), 155 deletions(-) create mode 100644 src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts new file mode 100644 index 0000000000..fcbb145f9f --- /dev/null +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts @@ -0,0 +1,239 @@ +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { + ListOrderDirection, + SupportIssueListOrderBy, +} from 'src/subdomains/supporting/support-issue/dto/get-support-issue.dto'; +import { SupportIssueDtoMapper } from 'src/subdomains/supporting/support-issue/dto/support-issue-dto.mapper'; +import { SupportIssue } from 'src/subdomains/supporting/support-issue/entities/support-issue.entity'; +import { SupportMessage } from 'src/subdomains/supporting/support-issue/entities/support-message.entity'; +import { Department } from 'src/subdomains/supporting/support-issue/enums/department.enum'; +import { + SupportIssueInternalState, + SupportIssueReason, + SupportIssueType, +} from 'src/subdomains/supporting/support-issue/enums/support-issue.enum'; +import { + SUPPORT_ISSUE_LIST_PROJECTION, + SUPPORT_ISSUE_LIST_RESPONSE_FIELDS, + SupportIssueListQuery, + SupportIssueRepository, +} from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'support_issue_list_projection_spec'; + +/** + * `GET /support/issue/list` and `GET /realunit/support/list` — the four levels from + * `docs/read-path-projections.md`. + * + * Both answer through `SupportIssueDtoMapper.mapSupportIssueListItem` and both loaded whole + * `SupportIssue` rows: 16 columns for the ten the row shows. + * + * The two endpoints differ only in their scope — one filters by department, the other by a list of + * customer accounts — so both scopes are exercised here. + */ +describeProjection('support issue list — read-path projection', () => { + let dataSource: DataSource; + let issues: SupportIssueRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + issues = new SupportIssueRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * The response fields the projection under test cannot fill. + * + * They come from the aggregate the endpoint runs over the messages afterwards — a separate query + * that names its own columns already — so dropping a field of this projection cannot change them. + */ + const MESSAGE_STATS_FIELDS = ['data[0].messageCount', 'data[0].lastMessageDate', 'data[0].lastMessageAuthor']; + + const BASE_QUERY: SupportIssueListQuery = { + terms: [], + orderBy: SupportIssueListOrderBy.CREATED, + orderDir: ListOrderDirection.DESC, + }; + + /** + * One issue with every column populated. + * + * `state`, `type`, `reason` and `department` are set explicitly: all four are TypeScript enums in + * text columns, and the list filters on three of them — a generated value is not a member, so a + * filtered query would answer with nothing for a reason that has nothing to do with the + * projection. + */ + async function seedIssue(values: Partial = {}): Promise<{ issue: SupportIssue; userData: UserData }> { + const userData = await seedEntity(dataSource, UserData); + const issue = await seedEntity(dataSource, SupportIssue, { + values: { + userData, + transaction: null, + state: SupportIssueInternalState.IN_PROGRESS, + type: SupportIssueType.TRANSACTION_ISSUE, + reason: SupportIssueReason.FUNDS_NOT_RECEIVED, + department: Department.SUPPORT, + ...values, + }, + }); + return { issue, userData }; + } + + /** The response the endpoint produces, through the projected query. */ + async function listOf(query: Partial, fields = SUPPORT_ISSUE_LIST_PROJECTION.fields) { + const [rows, total] = await issues.findIssueList({ ...BASE_QUERY, ...query }, fields); + return { data: rows.map((row) => SupportIssueDtoMapper.mapSupportIssueListItem(row)), total }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a listed issue answers with no empty field', async () => { + const { issue } = await seedIssue(); + + const list = await listOf({ departments: [Department.SUPPORT], clerk: issue.clerk }); + + expect(list.data).toHaveLength(1); + expectNoEmptyFields(list, MESSAGE_STATS_FIELDS); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — the customer scope answers only with issues of the scoped accounts', async () => { + const mine = await seedIssue(); + const other = await seedIssue(); + + const list = await listOf({ customerIds: [mine.userData.id] }); + + expect(list.data.map((row) => row.uid)).toEqual([mine.issue.uid]); + expect(list.data.map((row) => row.uid)).not.toContain(other.issue.uid); + }, 120000); + + it('level 2 — the department gate answers only with issues of the visible departments', async () => { + const visible = await seedIssue({ department: Department.SUPPORT }); + const hidden = await seedIssue({ department: Department.COMPLIANCE }); + + const list = await listOf({ departments: [Department.SUPPORT] }); + + expect(list.data.map((row) => row.uid)).toContain(visible.issue.uid); + expect(list.data.map((row) => row.uid)).not.toContain(hidden.issue.uid); + }, 120000); + + it.each([ + SupportIssueListOrderBy.CREATED, + SupportIssueListOrderBy.UPDATED, + SupportIssueListOrderBy.CLERK, + SupportIssueListOrderBy.DEPARTMENT, + SupportIssueListOrderBy.STATE, + ])( + 'level 2 — the list can be sorted by %s', + async (orderBy) => { + const { issue } = await seedIssue(); + + // Every sort column has to be part of the projection: with take/skip set, the paginated form of + // getManyAndCount orders a distinct-id subquery by it, and a column the select does not carry + // makes Postgres reject the statement. + const list = await listOf({ clerk: issue.clerk, orderBy, orderDir: ListOrderDirection.ASC, take: 10, skip: 0 }); + + expect(list.data).toHaveLength(1); + expect(list.total).toEqual(1); + }, + 120000, + ); + + it('level 2 — the search matches the fields it names, on the issue and on the account', async () => { + const { issue, userData } = await seedIssue(); + + // Each of these reaches the row through a different branch of the search predicate; the account + // branches resolve through a join the projection does not select from. + expect((await listOf({ terms: [issue.name] })).data.map((r) => r.uid)).toEqual([issue.uid]); + expect((await listOf({ terms: [issue.uid] })).data.map((r) => r.uid)).toEqual([issue.uid]); + expect((await listOf({ terms: [String(issue.id)] })).data.map((r) => r.uid)).toEqual([issue.uid]); + expect((await listOf({ terms: [userData.firstname] })).data.map((r) => r.uid)).toEqual([issue.uid]); + expect((await listOf({ terms: ['no-such-term'] })).data).toHaveLength(0); + }, 120000); + + it('level 2 — the search finds a term in the message body', async () => { + const { issue } = await seedIssue(); + const message = await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + // This branch is a correlated subquery over another table. Built from a literal table name it + // resolves against the search path rather than the schema the rest of the query runs in. + expect((await listOf({ terms: [message.message] })).data.map((r) => r.uid)).toEqual([issue.uid]); + }, 120000); + + // The id branch of the search predicate is added only when the term is fully numeric AND fits + // int4. Anything above 2^31-1 — a pasted phone number is the realistic case — would make Postgres + // raise a 22003 range error and fail the whole search with a 500. + it.each([ + ['12345', 'a small numeric term'], + ['2147483647', 'exactly int4 max'], + ['41791234567', 'above int4 max — the phone-number case'], + ['alice', 'a non-numeric term'], + ['alice 12345', 'both shapes at once'], + ])( + 'level 2 — the search answers for %s (%s)', + async (term) => { + // The assertion is that the statement runs at all: without the guard the third row raises 22003 + // and the search rejects instead of answering. + await expect(listOf({ terms: term.split(' ') })).resolves.toBeDefined(); + }, + 120000, + ); + + it('level 2 — the total counts every match, not just the page', async () => { + const first = await seedIssue({ clerk: 'page-clerk' }); + await seedIssue({ clerk: 'page-clerk' }); + + const list = await listOf({ clerk: 'page-clerk', take: 1, skip: 0, orderDir: ListOrderDirection.ASC }); + + expect(list.data).toHaveLength(1); + expect(list.data[0].uid).toEqual(first.issue.uid); + expect(list.total).toEqual(2); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — every field feeding the list response is required', async () => { + const { issue } = await seedIssue(); + + await expectEveryFieldRequired( + SUPPORT_ISSUE_LIST_RESPONSE_FIELDS, + (omitted) => + listOf( + { departments: [Department.SUPPORT], clerk: issue.clerk }, + projectionFieldsWithout(SUPPORT_ISSUE_LIST_PROJECTION.fields, omitted), + ), + // The same three as level 1: they are fed by the separate aggregate over the messages, which + // this projection does not cover and which no field of it can influence. + MESSAGE_STATS_FIELDS, + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it('level 4 — the projected response equals the one from a full load', async () => { + const { issue } = await seedIssue(); + + const projected = await listOf({ departments: [Department.SUPPORT], clerk: issue.clerk }); + // The unprojected load is the second source: every column of the row, which is what the query + // fetched before the conversion. + const full = await dataSource + .getRepository(SupportIssue) + .find({ where: { clerk: issue.clerk }, loadEagerRelations: false }); + + expect(projected.data).toEqual(full.map((row) => SupportIssueDtoMapper.mapSupportIssueListItem(row))); + }, 120000); +}); diff --git a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts index 64416acd3e..9e1ef6eb5c 100644 --- a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts +++ b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts @@ -2,7 +2,11 @@ import { Injectable } from '@nestjs/common'; import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager, FindOptionsWhere } from 'typeorm'; +import { ListOrderDirection, SupportIssueListOrderBy } from '../dto/get-support-issue.dto'; import { SupportIssue } from '../entities/support-issue.entity'; +import { SupportMessage } from '../entities/support-message.entity'; +import { Department } from '../enums/department.enum'; +import { SupportIssueInternalState, SupportIssueType } from '../enums/support-issue.enum'; /** The fields `CountryDtoMapper.entityToDto` reads, for a given join alias. */ const countryFields = (alias: string): string[] => @@ -194,12 +198,130 @@ export const SUPPORT_ISSUE_DATA_PROJECTION = new ReadProjection( ], ); +/** What `SupportIssueDtoMapper.mapSupportIssueListItem` reads off the issue itself. */ +export const SUPPORT_ISSUE_LIST_RESPONSE_FIELDS = [ + 'issue.id', + 'issue.uid', + 'issue.type', + 'issue.reason', + 'issue.state', + 'issue.name', + 'issue.clerk', + 'issue.department', + 'issue.created', + 'issue.updated', +]; + +/** + * `GET /support/issue/list` and `GET /realunit/support/list` — 16 columns before, ten of them read. + * + * The six it drops are the five foreign keys and `information`, which is `text` and holds the + * free-form body of the issue. The list shows a row per issue and none of it, so on a page of + * results that column is the bulk of what the query transfers. + * + * Every column `SupportIssueListOrderBy` allows is in the list above, which the query needs: the + * paginated form of `getManyAndCount` orders a distinct-id subquery by the sort column. + */ +export const SUPPORT_ISSUE_LIST_PROJECTION = new ReadProjection( + 'issue', + [], + SUPPORT_ISSUE_LIST_RESPONSE_FIELDS, +); + +/** + * The already-authorised shape of a list request. + * + * `departments` and `customerIds` are the two scopes the endpoint can be called under, resolved + * from the role before they get here — this is the query, not the access decision. + */ +export interface SupportIssueListQuery { + departments?: Department[]; + customerIds?: number[]; + states?: SupportIssueInternalState[]; + type?: SupportIssueType; + clerk?: string; + createdFrom?: Date; + createdTo?: Date; + /** Search terms, already split and trimmed. Each must match at least one field. */ + terms: string[]; + orderBy: SupportIssueListOrderBy; + orderDir: ListOrderDirection; + take?: number; + skip?: number; +} + @Injectable() export class SupportIssueRepository extends BaseRepository { constructor(manager: EntityManager) { super(SupportIssue, manager); } + /** + * The issue list, with the page and the unpaged total. + * + * `fields` exists for the mutation test; nothing in production passes it. + */ + async findIssueList( + query: SupportIssueListQuery, + fields: ReadonlyArray = SUPPORT_ISSUE_LIST_PROJECTION.fields, + ): Promise<[SupportIssue[], number]> { + const qb = SUPPORT_ISSUE_LIST_PROJECTION.apply(this.createQueryBuilder('issue'), fields); + + // The search predicate and the customer scope both need the account; they share one alias. + if (query.terms.length > 0 || query.customerIds) qb.leftJoin('issue.userData', 'userData'); + + // The customer scope replaces the department gate rather than adding to it. With a left join, + // an issue without an account is never IN the scope list, so it fails closed. + if (query.customerIds) qb.andWhere('"userData".id IN (:...customerIds)', { customerIds: query.customerIds }); + else if (query.departments) + qb.andWhere('issue.department IN (:...departments)', { departments: query.departments }); + + if (query.states?.length) qb.andWhere('issue.state IN (:...states)', { states: query.states }); + if (query.type) qb.andWhere('issue.type = :type', { type: query.type }); + if (query.clerk) qb.andWhere('issue.clerk = :clerk', { clerk: query.clerk }); + if (query.createdFrom) qb.andWhere('issue.created >= :createdFrom', { createdFrom: query.createdFrom }); + if (query.createdTo) qb.andWhere('issue.created <= :createdTo', { createdTo: query.createdTo }); + + for (let i = 0; i < query.terms.length; i++) { + const param = `term${i}`; + // Only emit the id branch when the term is fully numeric AND fits int4 (Postgres rejects + // larger values with 22003, which would 500 the entire search — a pasted phone number + // like "41791234567" is a realistic trigger). Keeps the predicate on the PK index (no + // cast-to-text) and avoids partial-match surprises (term "42" doesn't match id 142). + const idTerm = + /^\d+$/.test(query.terms[i]) && parseInt(query.terms[i], 10) <= 2147483647 + ? parseInt(query.terms[i], 10) + : null; + const idClause = idTerm != null ? ` OR issue.id = :${param}Id` : ''; + // The message branch goes through the query builder rather than a literal table name, so the + // table is resolved the way the ORM resolves every other one — a bare `support_message` is + // looked up against the search path instead. + const messageMatch = qb + .subQuery() + .select('1') + .from(SupportMessage, 'message') + .where('message."issueId" = issue.id') + .andWhere(`message.message LIKE :${param}`) + .getQuery(); + qb.andWhere( + `(issue.name LIKE :${param} OR issue.uid LIKE :${param} OR issue.clerk LIKE :${param} OR "userData".firstname LIKE :${param} OR "userData".surname LIKE :${param} OR "userData"."organizationName" LIKE :${param} OR EXISTS ${messageMatch}${idClause})`, + { [param]: `%${query.terms[i]}%`, ...(idTerm != null ? { [`${param}Id`]: idTerm } : {}) }, + ); + } + + // Whitelisted sort column and direction, with an id tie-break for stable pagination on equal + // sort keys. + qb.orderBy(`issue.${query.orderBy}`, query.orderDir); + qb.addOrderBy('issue.id', query.orderDir); + + if (query.take != null) { + qb.take(query.take); + if (query.skip != null) qb.skip(query.skip); + } + + return qb.getManyAndCount(); + } + /** * Loads exactly what the internal issue view needs. * diff --git a/src/subdomains/supporting/support-issue/services/__tests__/support-issue.service.spec.ts b/src/subdomains/supporting/support-issue/services/__tests__/support-issue.service.spec.ts index 66fb4635f9..1d8e5463d2 100644 --- a/src/subdomains/supporting/support-issue/services/__tests__/support-issue.service.spec.ts +++ b/src/subdomains/supporting/support-issue/services/__tests__/support-issue.service.spec.ts @@ -26,7 +26,10 @@ import { SupportIssueType, } from 'src/subdomains/supporting/support-issue/enums/support-issue.enum'; import { SupportLogType } from 'src/subdomains/supporting/support-issue/enums/support-log.enum'; -import { SupportIssueRepository } from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; +import { + SupportIssueListQuery, + SupportIssueRepository, +} from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; import { SupportMessageRepository } from 'src/subdomains/supporting/support-issue/repositories/support-message.repository'; import { LimitRequestService } from 'src/subdomains/supporting/support-issue/services/limit-request.service'; import { SupportDocumentService } from 'src/subdomains/supporting/support-issue/services/support-document.service'; @@ -41,34 +44,19 @@ import { SupportIssueDto } from 'src/subdomains/supporting/support-issue/dto/sup describe('SupportIssueService.getSupportIssueList', () => { let service: SupportIssueService; let supportIssueRepo: DeepMocked; - let qb: Record; - - // chainable query-builder recorder: every builder method returns the same object, - // getManyAndCount short-circuits getMessageStats (empty result set). - function createQbMock(): Record { - const builder: Record = {}; - for (const method of ['leftJoin', 'andWhere', 'orderBy', 'addOrderBy', 'take', 'skip']) { - builder[method] = jest.fn(() => builder); - } - builder.getManyAndCount = jest.fn().mockResolvedValue([[], 0]); - return builder; - } const run = (filter: Partial, role: UserRole = UserRole.ADMIN) => service.getSupportIssueList(filter as GetSupportIssueListFilter, role); - const andWhereClauses = (): string[] => qb.andWhere.mock.calls.map((c) => String(c[0])); - - // the department parameter handed to the "issue.department IN (:...departments)" clause (undefined if absent) - const departmentsParam = (): Department[] | undefined => { - const call = qb.andWhere.mock.calls.find((c) => String(c[0]).includes('issue.department IN')); - return call?.[1]?.departments as Department[] | undefined; - }; + // What the service resolved the request into. The query itself is built and tested against a real + // database in support-issue-list.projection.spec.ts; what belongs here is the decision that + // precedes it — which departments the role may see, and how the filter is normalised. + const query = (): SupportIssueListQuery => + (supportIssueRepo.findIssueList as jest.Mock).mock.calls[0][0] as SupportIssueListQuery; beforeEach(() => { - qb = createQbMock(); supportIssueRepo = createMock(); - (supportIssueRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); + (supportIssueRepo.findIssueList as jest.Mock).mockResolvedValue([[], 0]); service = new SupportIssueService( supportIssueRepo, @@ -87,56 +75,51 @@ describe('SupportIssueService.getSupportIssueList', () => { }); describe('clerk filter', () => { - it('filters by clerk when provided', async () => { + it('passes the clerk through when provided', async () => { await run({ clerk: 'Alice' }); - expect(qb.andWhere).toHaveBeenCalledWith('issue.clerk = :clerk', { clerk: 'Alice' }); + expect(query().clerk).toEqual('Alice'); }); - it('does not add a clerk clause when absent', async () => { + it('leaves the clerk unset when absent', async () => { await run({}); - expect(andWhereClauses().some((c) => c.includes('issue.clerk ='))).toBe(false); + expect(query().clerk).toBeUndefined(); }); }); describe('timeframe filter', () => { - it('filters by createdFrom as a Date lower bound', async () => { + it('passes createdFrom through as a Date lower bound', async () => { await run({ createdFrom: '2026-01-01T00:00:00.000Z' }); - expect(qb.andWhere).toHaveBeenCalledWith('issue.created >= :createdFrom', { - createdFrom: new Date('2026-01-01T00:00:00.000Z'), - }); + expect(query().createdFrom).toEqual(new Date('2026-01-01T00:00:00.000Z')); }); - it('filters by createdTo as a Date upper bound', async () => { + it('passes createdTo through as a Date upper bound', async () => { await run({ createdTo: '2026-02-01T00:00:00.000Z' }); - expect(qb.andWhere).toHaveBeenCalledWith('issue.created <= :createdTo', { - createdTo: new Date('2026-02-01T00:00:00.000Z'), - }); + expect(query().createdTo).toEqual(new Date('2026-02-01T00:00:00.000Z')); }); it('extends a date-only createdTo to the end of that day (inclusive)', async () => { await run({ createdTo: '2026-02-01' }); - expect(qb.andWhere).toHaveBeenCalledWith('issue.created <= :createdTo', { - createdTo: new Date('2026-02-01T23:59:59.999Z'), - }); + expect(query().createdTo).toEqual(new Date('2026-02-01T23:59:59.999Z')); }); - it('does not add date clauses when absent', async () => { + it('leaves both bounds unset when absent', async () => { await run({}); - expect(andWhereClauses().some((c) => c.includes('issue.created'))).toBe(false); + expect(query().createdFrom).toBeUndefined(); + expect(query().createdTo).toBeUndefined(); }); }); describe('sorting', () => { - it('defaults to created DESC with an id tie-break for stable pagination', async () => { + it('defaults to created DESC', async () => { await run({}); - expect(qb.orderBy).toHaveBeenCalledWith('issue.created', 'DESC'); - expect(qb.addOrderBy).toHaveBeenCalledWith('issue.id', 'DESC'); + expect(query().orderBy).toEqual(SupportIssueListOrderBy.CREATED); + expect(query().orderDir).toEqual(ListOrderDirection.DESC); }); - it('applies a whitelisted sort column with an id tie-break for stable pagination', async () => { + it('passes a whitelisted sort column through', async () => { await run({ orderBy: SupportIssueListOrderBy.CLERK, orderDir: ListOrderDirection.ASC }); - expect(qb.orderBy).toHaveBeenCalledWith('issue.clerk', 'ASC'); - expect(qb.addOrderBy).toHaveBeenCalledWith('issue.id', 'ASC'); + expect(query().orderBy).toEqual(SupportIssueListOrderBy.CLERK); + expect(query().orderDir).toEqual(ListOrderDirection.ASC); }); it('rejects an out-of-whitelist orderBy at DTO validation (the actual injection guard)', async () => { @@ -151,91 +134,55 @@ describe('SupportIssueService.getSupportIssueList', () => { describe('department narrowing', () => { it('keeps support locked to its own department, ignoring an out-of-set ?department (no escalation)', async () => { await run({ department: Department.COMPLIANCE }, UserRole.SUPPORT); - expect(departmentsParam()).toEqual([Department.SUPPORT]); + expect(query().departments).toEqual([Department.SUPPORT]); }); it('lets compliance narrow to the support department via ?department', async () => { await run({ department: Department.SUPPORT }, UserRole.COMPLIANCE); - expect(departmentsParam()).toEqual([Department.SUPPORT]); + expect(query().departments).toEqual([Department.SUPPORT]); }); it('defaults compliance to its full allowed set (support + compliance) without ?department', async () => { await run({}, UserRole.COMPLIANCE); - expect(departmentsParam()).toEqual([Department.SUPPORT, Department.COMPLIANCE]); + expect(query().departments).toEqual([Department.SUPPORT, Department.COMPLIANCE]); }); it('applies an arbitrary ?department for an unrestricted admin', async () => { await run({ department: Department.MARKETING }, UserRole.ADMIN); - expect(departmentsParam()).toEqual([Department.MARKETING]); + expect(query().departments).toEqual([Department.MARKETING]); }); it('applies no department filter for admin without ?department (unrestricted)', async () => { await run({}, UserRole.ADMIN); - expect(departmentsParam()).toBeUndefined(); + expect(query().departments).toBeUndefined(); }); it('applies no department filter for super admin without ?department (unrestricted)', async () => { await run({}, UserRole.SUPER_ADMIN); - expect(departmentsParam()).toBeUndefined(); + expect(query().departments).toBeUndefined(); }); it('returns nothing for a role with no department access, without querying', async () => { const result = await run({}, UserRole.USER); expect(result).toEqual({ data: [], total: 0 }); - expect(supportIssueRepo.createQueryBuilder).not.toHaveBeenCalled(); + expect(supportIssueRepo.findIssueList).not.toHaveBeenCalled(); }); }); - // The id branch of the search predicate is added only when the term is fully numeric AND - // fits int4. Anything above 2^31-1 (a pasted phone number) would produce a Postgres 22003 - // range error and 500 the entire search — this block pins the guard against that regression. - describe('search-term id branch', () => { - // returns the parameter bag from the last andWhere call that includes the search predicate - const lastSearchParams = (): Record | undefined => { - const calls = qb.andWhere.mock.calls; - const call = [...calls].reverse().find((c) => String(c[0]).includes('issue.name LIKE')); - return call?.[1] as Record | undefined; - }; - - // returns the SQL fragment string from the last search predicate - const lastSearchFragment = (): string => { - const calls = qb.andWhere.mock.calls; - const call = [...calls].reverse().find((c) => String(c[0]).includes('issue.name LIKE')); - return String(call?.[0] ?? ''); - }; - - it('emits the id clause for a small numeric term and binds the int', async () => { - await run({ query: '12345' }); - expect(lastSearchFragment()).toContain('issue.id = :term0Id'); - expect(lastSearchParams()).toEqual({ term0: '%12345%', term0Id: 12345 }); - }); - - it('emits the id clause for exactly int4 max (2147483647)', async () => { - await run({ query: '2147483647' }); - expect(lastSearchFragment()).toContain('issue.id = :term0Id'); - expect(lastSearchParams()).toMatchObject({ term0Id: 2147483647 }); + describe('search terms', () => { + it('splits the query on whitespace and drops empty terms', async () => { + await run({ query: ' alice 12345 ' }); + expect(query().terms).toEqual(['alice', '12345']); }); - it('omits the id clause and Id-bind for a numeric term above int4 max (phone-number regression)', async () => { - await run({ query: '41791234567' }); - expect(lastSearchFragment()).not.toContain('issue.id = :term0Id'); - expect(lastSearchParams()).toEqual({ term0: '%41791234567%' }); + it('caps the term count at ten', async () => { + await run({ query: Array.from({ length: 15 }, (_, i) => `t${i}`).join(' ') }); + expect(query().terms).toHaveLength(10); }); - it('omits the id clause for a non-numeric term', async () => { - await run({ query: 'alice' }); - expect(lastSearchFragment()).not.toContain('issue.id = :term0Id'); - expect(lastSearchParams()).toEqual({ term0: '%alice%' }); - }); - - it('mixes term shapes across ANDed clauses', async () => { - await run({ query: 'alice 12345' }); - const calls = qb.andWhere.mock.calls.filter((c) => String(c[0]).includes('issue.name LIKE')); - expect(calls).toHaveLength(2); - expect(String(calls[0][0])).not.toContain('issue.id = :term0Id'); - expect(String(calls[1][0])).toContain('issue.id = :term1Id'); - expect(calls[0][1]).toEqual({ term0: '%alice%' }); - expect(calls[1][1]).toEqual({ term1: '%12345%', term1Id: 12345 }); + it('produces no terms for an absent query', async () => { + await run({}); + expect(query().terms).toEqual([]); }); }); }); diff --git a/src/subdomains/supporting/support-issue/services/support-issue.service.ts b/src/subdomains/supporting/support-issue/services/support-issue.service.ts index 07a4609db5..e300e7775f 100644 --- a/src/subdomains/supporting/support-issue/services/support-issue.service.ts +++ b/src/subdomains/supporting/support-issue/services/support-issue.service.ts @@ -525,8 +525,6 @@ export class SupportIssueService { role: UserRole, customerIds?: number[], ): Promise<{ data: SupportIssueListDto[]; total: number }> { - const where: FindOptionsWhere = {}; - // department filtering: the role defines the allowed departments, an explicit filter may narrow within them const allowedDepartments = getVisibleDepartments(role); if (!customerIds && allowedDepartments?.length === 0) return { data: [], total: 0 }; // no department access @@ -537,61 +535,26 @@ export class SupportIssueService { ? [filter.department] : allowedDepartments; - if (filter.type) where.type = filter.type; - - // server-side search: split query into terms, each term must match at least one field (AND between terms, OR between fields) - const terms = (filter.query ?? '') - .split(/\s+/) - .map((t) => t.trim()) - .filter((t) => t.length > 0) - .slice(0, 10); - - const qb = this.supportIssueRepo.createQueryBuilder('issue'); - // the search predicate and the RealUnit customer scope both need the userData join; share the single 'userData' alias - if (terms.length > 0 || customerIds) qb.leftJoin('issue.userData', 'userData'); - - // customer scope (RealUnit) takes precedence over and replaces the department gate; the left join + IN filter - // fail-closes issues without a userData (NULL is never IN the scope list) - if (customerIds) qb.andWhere('"userData".id IN (:...customerIds)', { customerIds }); - else if (departments) qb.andWhere('issue.department IN (:...departments)', { departments }); - if (filter.states?.length) qb.andWhere('issue.state IN (:...states)', { states: filter.states }); - if (where.type) qb.andWhere('issue.type = :type', { type: where.type }); - if (filter.clerk) qb.andWhere('issue.clerk = :clerk', { clerk: filter.clerk }); - if (filter.createdFrom) qb.andWhere('issue.created >= :createdFrom', { createdFrom: new Date(filter.createdFrom) }); - if (filter.createdTo) { - const createdTo = new Date(filter.createdTo); - // a date-only bound (no time component) means "on or before that day" → include the whole day - if (!filter.createdTo.includes('T')) createdTo.setUTCHours(23, 59, 59, 999); - qb.andWhere('issue.created <= :createdTo', { createdTo }); - } - - const termCount = Math.min(terms.length, 10); - for (let i = 0; i < termCount; i++) { - const param = `term${i}`; - // Only emit the id branch when the term is fully numeric AND fits int4 (Postgres rejects - // larger values with 22003, which would 500 the entire search — a pasted phone number - // like "41791234567" is a realistic trigger). Keeps the predicate on the PK index (no - // cast-to-text) and avoids partial-match surprises (term "42" doesn't match id 142). - const idTerm = /^\d+$/.test(terms[i]) && parseInt(terms[i], 10) <= 2147483647 ? parseInt(terms[i], 10) : null; - const idClause = idTerm != null ? ` OR issue.id = :${param}Id` : ''; - qb.andWhere( - `(issue.name LIKE :${param} OR issue.uid LIKE :${param} OR issue.clerk LIKE :${param} OR "userData".firstname LIKE :${param} OR "userData".surname LIKE :${param} OR "userData"."organizationName" LIKE :${param} OR EXISTS (SELECT 1 FROM support_message m WHERE m."issueId" = issue.id AND m.message LIKE :${param})${idClause})`, - { [param]: `%${terms[i]}%`, ...(idTerm != null ? { [`${param}Id`]: idTerm } : {}) }, - ); - } - - // whitelisted sort column + direction, with an id tie-break for stable pagination on equal sort keys - const orderBy = filter.orderBy ?? SupportIssueListOrderBy.CREATED; - const orderDir = filter.orderDir ?? ListOrderDirection.DESC; - qb.orderBy(`issue.${orderBy}`, orderDir); - qb.addOrderBy('issue.id', orderDir); - - if (filter.take != null) { - qb.take(filter.take); - if (filter.skip != null) qb.skip(filter.skip); - } - - const [issues, total] = await qb.getManyAndCount(); + const [issues, total] = await this.supportIssueRepo.findIssueList({ + departments, + customerIds, + states: filter.states, + type: filter.type, + clerk: filter.clerk, + createdFrom: filter.createdFrom ? new Date(filter.createdFrom) : undefined, + createdTo: this.parseCreatedTo(filter.createdTo), + // server-side search: split query into terms, each term must match at least one field + // (AND between terms, OR between fields) + terms: (filter.query ?? '') + .split(/\s+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0) + .slice(0, 10), + orderBy: filter.orderBy ?? SupportIssueListOrderBy.CREATED, + orderDir: filter.orderDir ?? ListOrderDirection.DESC, + take: filter.take, + skip: filter.skip, + }); const stats = await this.getMessageStats(issues.map((i) => i.id)); @@ -601,6 +564,16 @@ export class SupportIssueService { }; } + /** A date-only upper bound (no time component) means "on or before that day" — include the whole day. */ + private parseCreatedTo(createdTo?: string): Date | undefined { + if (!createdTo) return undefined; + + const date = new Date(createdTo); + if (!createdTo.includes('T')) date.setUTCHours(23, 59, 59, 999); + + return date; + } + private async getMessageStats( issueIds: number[], ): Promise> { From 85dd9063210c7d9eb5a460faf59e5c297368ae45 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:09:57 +0200 Subject: [PATCH 09/46] Project the suspense ledger and the pipeline status GET /dashboard/accounting/ledger/suspense (join loads two whole rows) -> 8 fields GET /liquidityManagement/pipeline/:id/status 112 columns -> 1 The second is the sharpest case in the inventory: the endpoint answers with one status string, and asking for the row by id expanded the pipeline's rule and both of its action relations eagerly, and the rule its own asset and currency. The suspense query joined the transaction and the account with innerJoinAndSelect, which loads each of them whole for four values and a currency. The joins stay with the query rather than moving into the projection - ReadProjection joins left, these are inner, and while both relations are NOT NULL today that is a property of the schema, not something the query should depend on silently. mapSuspenseLeg now reads the transaction id off the joined row instead of through the entity's @RelationId. That property is filled from the foreign-key column of the leg, which a query naming its fields does not carry - the response answered with an undefined txId and a 200. Every other value in that mapper already comes from leg.tx, and it is the same number. The alternative, loadRelationIdAndMap, would restore it at the cost of a second query for a value already in the result. The pipeline lookup returns the row rather than the status, so that a missing pipeline stays distinguishable from one whose status is not set: the endpoint answers 404 only for the first. Two things the tests surfaced: - LedgerTx carries a check constraint pinning amountChfSum to 0, the single-row balance gate. Generated fixture values are distinct by construction and the insert is rejected - the value has to be pinned like an enum. - The suspense endpoint takes no parameter: it answers with every leg on a suspense account. Rows from an earlier test therefore reach the next one, and level 3 compared a baseline that already contained a deliberately incomplete row. The spec truncates between tests instead of scoping its assertions, which would have hidden exactly what level 3 is there to find. The service specs follow the same split as the issue list: what the query selects is asserted against a real database, and the mocked service spec asserts what the service decides. --- .../ledger-suspense.projection.spec.ts | 171 ++++++++++++++++++ .../core/accounting/dto/ledger-dto.mapper.ts | 5 +- .../repositories/ledger-leg.repository.ts | 48 +++++ .../__tests__/ledger-query.service.spec.ts | 5 + .../services/ledger-query.service.ts | 8 +- .../pipeline-status.projection.spec.ts | 124 +++++++++++++ ...iquidity-management-pipeline.repository.ts | 36 ++++ .../liquidity-management-pipeline.service.ts | 2 +- 8 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts create mode 100644 src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts diff --git a/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts b/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts new file mode 100644 index 0000000000..f9070f538a --- /dev/null +++ b/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts @@ -0,0 +1,171 @@ +import { Util } from 'src/shared/utils/util'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { LedgerDtoMapper, SuspenseLegRow } from 'src/subdomains/core/accounting/dto/ledger-dto.mapper'; +import { AccountType, LedgerAccount } from 'src/subdomains/core/accounting/entities/ledger-account.entity'; +import { LedgerLeg } from 'src/subdomains/core/accounting/entities/ledger-leg.entity'; +import { LedgerTx } from 'src/subdomains/core/accounting/entities/ledger-tx.entity'; +import { + SUSPENSE_LEG_PROJECTION, + SUSPENSE_LEG_RESPONSE_FIELDS, + LedgerLegRepository, +} from 'src/subdomains/core/accounting/repositories/ledger-leg.repository'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'ledger_suspense_projection_spec'; + +/** + * `GET /dashboard/accounting/ledger/suspense` — the four levels from + * `docs/read-path-projections.md`. + * + * The query joined the transaction and the account with `innerJoinAndSelect`, loading both whole + * for four values and a currency. + */ +describeProjection('ledger suspense — read-path projection', () => { + let dataSource: DataSource; + let legs: LedgerLegRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + legs = new LedgerLegRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + // This endpoint takes no parameter — it answers with every leg on a suspense account. Rows left + // behind by an earlier test would therefore show up in the next one, and level 3 would compare a + // baseline that already contains a deliberately incomplete row. + beforeEach(async () => { + await dataSource.query( + `TRUNCATE TABLE "${SCHEMA}"."ledger_leg", "${SCHEMA}"."ledger_tx", "${SCHEMA}"."ledger_account" RESTART IDENTITY CASCADE`, + ); + }); + + /** + * One leg on an account of the given type. + * + * `type` is set explicitly: it is a TypeScript enum in a text column and the query filters on it, + * so a generated value would make the endpoint answer with nothing for a reason that has nothing + * to do with the projection. + */ + async function seedLeg( + type = AccountType.SUSPENSE, + values: Partial = {}, + ): Promise<{ leg: LedgerLeg; tx: LedgerTx; account: LedgerAccount }> { + const account = await seedEntity(dataSource, LedgerAccount, { values: { type } }); + // `amountChfSum` carries a check constraint pinning it to 0 — the single-row balance gate. The + // generated value would be distinct and therefore non-zero, and the insert would be rejected. + const tx = await seedEntity(dataSource, LedgerTx, { values: { amountChfSum: 0 } }); + const leg = await seedEntity(dataSource, LedgerLeg, { values: { tx, account, ...values } }); + return { leg, tx, account }; + } + + /** The response the endpoint produces, through the projected query. */ + async function suspenseOf(fields = SUSPENSE_LEG_PROJECTION.fields) { + const now = new Date(); + const rows = await legs.findSuspenseLegs(fields); + const totalChf = Util.round(Util.sum(rows.map((l) => l.amountChf ?? 0)), 2); + const mapped: SuspenseLegRow[] = rows.map((leg) => ({ + leg, + bookingDate: leg.tx.bookingDate, + age: Util.daysDiff(leg.tx.bookingDate, now), + })); + + return { totalChf, legs: mapped.map((row) => LedgerDtoMapper.mapSuspenseLeg(row)) }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a suspense leg answers with no empty field', async () => { + await seedLeg(); + + const response = await suspenseOf(); + + expect(response.legs).toHaveLength(1); + expectNoEmptyFields(response); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — only legs on suspense accounts are listed', async () => { + const suspense = await seedLeg(AccountType.SUSPENSE); + await seedLeg(AccountType.INCOME); + + const response = await suspenseOf(); + + expect(response.legs.map((row) => row.legId)).toEqual([suspense.leg.id]); + }, 120000); + + it('level 2 — legs are ordered by booking date, oldest first', async () => { + const older = await seedLeg(); + const newer = await seedLeg(); + await dataSource.getRepository(LedgerTx).update(older.tx.id, { bookingDate: new Date('2020-01-01T00:00:00.000Z') }); + await dataSource.getRepository(LedgerTx).update(newer.tx.id, { bookingDate: new Date('2026-01-01T00:00:00.000Z') }); + + // The sort column lives on the joined transaction; with the join reduced to a bare id the + // statement would still run and the order would silently be the storage order. + const response = await suspenseOf(); + + expect(response.legs.map((row) => row.legId)).toEqual([older.leg.id, newer.leg.id]); + }, 120000); + + it('level 2 — a leg without a CHF amount contributes nothing to the total', async () => { + await seedLeg(AccountType.SUSPENSE, { amountChf: null }); + const withAmount = await seedLeg(AccountType.SUSPENSE, { amountChf: 25 }); + + const response = await suspenseOf(); + + // `amountChf` is nullable and the sum reads it with a fallback. A projection that dropped the + // column would produce the same total as a row that genuinely has none — which is why level 3 + // covers it against a fixture where the value is set. + expect(response.totalChf).toEqual(25); + expect(response.legs.find((row) => row.legId === withAmount.leg.id)?.amountChf).toEqual(25); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — every field feeding the suspense response is required', async () => { + await seedLeg(AccountType.SUSPENSE, { amountChf: 42 }); + + await expectEveryFieldRequired(SUSPENSE_LEG_RESPONSE_FIELDS, (omitted) => + suspenseOf(projectionFieldsWithout(SUSPENSE_LEG_PROJECTION.fields, omitted)), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it('level 4 — the projected response equals the one from a full load', async () => { + await seedLeg(); + const now = new Date(); + + const projected = await suspenseOf(); + // The unprojected load is the second source: the join form the query used before. + const full = await dataSource + .getRepository(LedgerLeg) + .createQueryBuilder('leg') + .innerJoinAndSelect('leg.tx', 'tx') + .innerJoinAndSelect('leg.account', 'account') + .where('account.type = :type', { type: AccountType.SUSPENSE }) + .orderBy('tx.bookingDate', 'ASC') + .getMany(); + + expect(projected.legs).toEqual( + full.map((leg) => + LedgerDtoMapper.mapSuspenseLeg({ + leg, + bookingDate: leg.tx.bookingDate, + age: Util.daysDiff(leg.tx.bookingDate, now), + }), + ), + ); + }, 120000); +}); diff --git a/src/subdomains/core/accounting/dto/ledger-dto.mapper.ts b/src/subdomains/core/accounting/dto/ledger-dto.mapper.ts index 99ee2f1677..336d1e0d37 100644 --- a/src/subdomains/core/accounting/dto/ledger-dto.mapper.ts +++ b/src/subdomains/core/accounting/dto/ledger-dto.mapper.ts @@ -97,7 +97,10 @@ export class LedgerDtoMapper { const { leg } = row; return { legId: leg.id, - txId: leg.txId, + // Read off the joined transaction rather than through the `@RelationId`: that property is + // filled from the foreign-key column of the leg row, which a query selecting named fields does + // not carry. Every other value here already comes from `leg.tx`, and it is the same number. + txId: leg.tx?.id, bookingDate: row.bookingDate.toISOString(), description: leg.tx?.description, sourceType: leg.tx?.sourceType, diff --git a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts index 4214b07543..3fec00c3dc 100644 --- a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts +++ b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts @@ -1,11 +1,59 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; +import { AccountType } from '../entities/ledger-account.entity'; import { LedgerLeg } from '../entities/ledger-leg.entity'; +/** What `LedgerDtoMapper.mapSuspenseLeg` reads, including the booking date the age is computed from. */ +export const SUSPENSE_LEG_RESPONSE_FIELDS = [ + 'leg.id', + 'leg.amount', + 'leg.amountChf', + 'tx.bookingDate', + 'tx.description', + 'tx.sourceType', + 'tx.sourceId', + 'account.currency', +]; + +/** + * `GET /dashboard/accounting/ledger/suspense`. + * + * The query joined the transaction and the account with `innerJoinAndSelect`, which loads each of + * them whole for four values and a currency. + * + * `tx.id` and `account.id` are guards: the response never shows them, but without a primary key the + * ORM cannot materialise a joined row — and `leg.txId` is a `@RelationId`, resolved from the joined + * transaction rather than from a column of its own. + * + * The two joins stay with the query rather than moving into the projection: `ReadProjection` joins + * left, and these are inner. Both relations are `nullable: false`, so the two forms select the same + * rows today — but that is a property of the schema, and the query should not depend on it silently. + */ +export const SUSPENSE_LEG_PROJECTION = new ReadProjection('leg', [], SUSPENSE_LEG_RESPONSE_FIELDS, [ + 'tx.id', + 'account.id', +]); + @Injectable() export class LedgerLegRepository extends BaseRepository { constructor(manager: EntityManager) { super(LedgerLeg, manager); } + + /** + * The legs sitting on suspense accounts, oldest booking first. + * + * `fields` exists for the mutation test; nothing in production passes it. + */ + async findSuspenseLegs(fields: ReadonlyArray = SUSPENSE_LEG_PROJECTION.fields): Promise { + return SUSPENSE_LEG_PROJECTION.apply( + this.createQueryBuilder('leg').innerJoin('leg.tx', 'tx').innerJoin('leg.account', 'account'), + fields, + ) + .where('account.type = :type', { type: AccountType.SUSPENSE }) + .orderBy('tx.bookingDate', 'ASC') + .getMany(); + } } diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts index 7a14d76e27..4ed9dba068 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts @@ -186,6 +186,11 @@ describe('LedgerQueryService', () => { logService = createMock(); jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => legQb()); + // The suspense query lives in the repository now; what it selects is asserted against a real + // database in ledger-suspense.projection.spec.ts. + jest + .spyOn(ledgerLegRepository, 'findSuspenseLegs') + .mockImplementation(() => Promise.resolve(qbStub.suspenseLegs ?? [])); jest.spyOn(ledgerLegRepository, 'find').mockResolvedValue([]); jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([]); jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(null); diff --git a/src/subdomains/core/accounting/services/ledger-query.service.ts b/src/subdomains/core/accounting/services/ledger-query.service.ts index 422381698f..bb62079d91 100644 --- a/src/subdomains/core/accounting/services/ledger-query.service.ts +++ b/src/subdomains/core/accounting/services/ledger-query.service.ts @@ -180,13 +180,7 @@ export class LedgerQueryService { async getSuspense(): Promise { const now = new Date(); - const legs = await this.ledgerLegRepository - .createQueryBuilder('leg') - .innerJoinAndSelect('leg.tx', 'tx') - .innerJoinAndSelect('leg.account', 'account') - .where('account.type = :type', { type: AccountType.SUSPENSE }) - .orderBy('tx.bookingDate', 'ASC') - .getMany(); + const legs = await this.ledgerLegRepository.findSuspenseLegs(); const rows: SuspenseLegRow[] = legs.map((leg) => ({ leg, diff --git a/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts b/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts new file mode 100644 index 0000000000..b05781380b --- /dev/null +++ b/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts @@ -0,0 +1,124 @@ +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { LiquidityManagementPipeline } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-pipeline.entity'; +import { LiquidityManagementRule } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-rule.entity'; +import { LiquidityManagementPipelineStatus } from 'src/subdomains/core/liquidity-management/enums'; +import { + PIPELINE_STATUS_PROJECTION, + PIPELINE_STATUS_RESPONSE_FIELDS, + LiquidityManagementPipelineRepository, +} from 'src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'pipeline_status_projection_spec'; + +/** + * `GET /liquidityManagement/pipeline/:id/status` — the four levels from + * `docs/read-path-projections.md`. + * + * The endpoint answers with one string. Asking for the row by id fetched 112 columns for it: the + * pipeline expands its rule and both of its action relations eagerly, and the rule expands its own. + */ +describeProjection('liquidity management pipeline status — read-path projection', () => { + let dataSource: DataSource; + let pipelines: LiquidityManagementPipelineRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + pipelines = new LiquidityManagementPipelineRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * One pipeline in the given status. + * + * `status` is set explicitly: it is a TypeScript enum in a text column and it is the whole + * response, so a generated value would prove nothing about the projection. + */ + async function seedPipeline( + status = LiquidityManagementPipelineStatus.IN_PROGRESS, + ): Promise { + const rule = await seedEntity(dataSource, LiquidityManagementRule); + return seedEntity(dataSource, LiquidityManagementPipeline, { + values: { rule, status }, + }); + } + + /** The response the endpoint produces, through the projected query. */ + async function statusOf(id: number, fields = PIPELINE_STATUS_PROJECTION.fields) { + const pipeline = await pipelines.findForStatus(id, fields); + return pipeline?.status; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a pipeline answers with its status', async () => { + const pipeline = await seedPipeline(); + + expectNoEmptyFields(await statusOf(pipeline.id)); + }, 120000); + + // --- LEVEL 2: variants --- // + + it.each(Object.values(LiquidityManagementPipelineStatus))( + 'level 2 — a pipeline in status %s answers with that status', + async (status) => { + const pipeline = await seedPipeline(status); + + expect(await statusOf(pipeline.id)).toEqual(status); + }, + 120000, + ); + + it('level 2 — an unknown id resolves to nothing, so the endpoint can answer 404', async () => { + const pipeline = await seedPipeline(); + + // The distinction matters: the repository returns the row rather than the status, so a missing + // pipeline stays separable from one whose status is not set. + expect(await pipelines.findForStatus(pipeline.id + 1_000_000)).toBeNull(); + }, 120000); + + it('level 2 — the answer belongs to the pipeline that was asked for', async () => { + const mine = await seedPipeline(LiquidityManagementPipelineStatus.COMPLETE); + await seedPipeline(LiquidityManagementPipelineStatus.FAILED); + + expect(await statusOf(mine.id)).toEqual(LiquidityManagementPipelineStatus.COMPLETE); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — the status field is required', async () => { + const pipeline = await seedPipeline(); + + await expectEveryFieldRequired(PIPELINE_STATUS_RESPONSE_FIELDS, (omitted) => + statusOf(pipeline.id, projectionFieldsWithout(PIPELINE_STATUS_PROJECTION.fields, omitted)), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([LiquidityManagementPipelineStatus.CREATED, LiquidityManagementPipelineStatus.STOPPED])( + 'level 4 — for %s the projected answer equals the one from a full load', + async (status) => { + const pipeline = await seedPipeline(status); + + const projected = await statusOf(pipeline.id); + // The unprojected load is the second source: the find the endpoint used before, with every + // eager relation it pulls in. + const full = await dataSource.getRepository(LiquidityManagementPipeline).findOneBy({ id: pipeline.id }); + + expect(projected).toEqual(full.status); + }, + 120000, + ); +}); diff --git a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts index 37deff4833..b2093a571a 100644 --- a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts +++ b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts @@ -1,11 +1,47 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; import { LiquidityManagementPipeline } from '../entities/liquidity-management-pipeline.entity'; +import { LiquidityManagementPipelineStatus } from '../enums'; + +/** The single value `GET /liquidityManagement/pipeline/:id/status` answers with. */ +export const PIPELINE_STATUS_RESPONSE_FIELDS = ['pipeline.status']; + +/** + * `GET /liquidityManagement/pipeline/:id/status` — 112 columns before, for one. + * + * The pipeline expands its rule and its current action eagerly, and the rule pulls in its asset and + * its currency, so asking for the row by id fetched the whole graph to read a status string. + */ +export const PIPELINE_STATUS_PROJECTION = new ReadProjection( + 'pipeline', + [], + PIPELINE_STATUS_RESPONSE_FIELDS, + // Never shown, but without the primary key the ORM has nothing to identify the row by. + ['pipeline.id'], +); @Injectable() export class LiquidityManagementPipelineRepository extends BaseRepository { constructor(manager: EntityManager) { super(LiquidityManagementPipeline, manager); } + + /** + * One pipeline, carrying its status and nothing else. + * + * Returns the row rather than the status so that a missing pipeline stays distinguishable from a + * pipeline whose status is not set — the endpoint answers 404 only for the first. + * + * `fields` exists for the mutation test; nothing in production passes it. + */ + async findForStatus( + id: number, + fields: ReadonlyArray = PIPELINE_STATUS_PROJECTION.fields, + ): Promise { + return PIPELINE_STATUS_PROJECTION.apply(this.createQueryBuilder('pipeline'), fields) + .where('pipeline.id = :id', { id }) + .getOne(); + } } diff --git a/src/subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts b/src/subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts index da87a948ce..886b978a4c 100644 --- a/src/subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts +++ b/src/subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts @@ -147,7 +147,7 @@ export class LiquidityManagementPipelineService { } async getPipelineStatus(pipelineId: number): Promise { - const pipeline = await this.pipelineRepo.findOneBy({ id: pipelineId }); + const pipeline = await this.pipelineRepo.findForStatus(pipelineId); if (!pipeline) throw new NotFoundException(`No liquidity management pipeline found for id ${pipelineId}`); From 65f8f58322d3755fbc28aec24272f4d4e5d59172 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:26:32 +0200 Subject: [PATCH 10/46] Project GET /user (v2) 351 columns -> 55 fields The widest read path in the inventory. A findOne on UserData expanded four countries, a language, a currency and an organization eagerly, and every address on the account brought its whole wallet - for a response that is mostly getters over a few dozen columns. Most of those getters answer a plausible value from a field that is not there rather than failing: isDataComplete reports false, the trading limit falls back to the configured no-KYC default, isCustody compares undefined against one role and says no. That is why the two countries are response fields here rather than guards: isDataComplete tests them for truthiness like any other required field, so dropping either changes the answer and the mutation test has to reach them. Level 3 needed five fixtures rather than one, and each of the extra four exists because a single fixture cannot see the field at all: - a personal account never reads the four organization address columns, and an organization account reads both sets, so the two run with different candidates - mapAddress reads `userData.apiKeyCT ?? user.apiKeyCT` and `wallet.displayName ?? wallet.name`; with the left side set the right side is unreachable and reports as removable - true, and useless - isCustody compares against one role: on any other role, dropping the column produces the same answer - the dummy-address flag hides an address; dropped it reads undefined, which is falsy, so the address reappears - visible only where the flag is set One observation the conversion did not change. computeCapabilities derives two of its flags from kycSteps via getStepsWith, and neither this query nor the one before it loads that relation, so those flags do not depend on the steps. The projection reproduces that rather than quietly altering it; whether the query should load the steps is a separate decision. --- .../models/user-data/user-data.repository.ts | 129 +++++++ .../user/__tests__/user-v2.projection.spec.ts | 326 ++++++++++++++++++ .../generic/user/models/user/user.service.ts | 5 +- 3 files changed, 456 insertions(+), 4 deletions(-) create mode 100644 src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts diff --git a/src/subdomains/generic/user/models/user-data/user-data.repository.ts b/src/subdomains/generic/user/models/user-data/user-data.repository.ts index b66cc0f59b..56fb16b1f0 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.repository.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.repository.ts @@ -83,12 +83,141 @@ export const USER_PROFILE_PROJECTION = new ReadProjection( ['userData.id', 'userData.status', 'organization.id'], ); +/** The nine columns `UserDtoMapper.mapVolumes` reads, for either the account or one of its users. */ +const volumeFields = (alias: string): string[] => + [ + 'buyVolume', + 'annualBuyVolume', + 'monthlyBuyVolume', + 'sellVolume', + 'annualSellVolume', + 'monthlySellVolume', + 'cryptoVolume', + 'annualCryptoVolume', + 'monthlyCryptoVolume', + ].map((field) => `${alias}.${field}`); + +/** + * What the account itself contributes to `UserDtoMapper.mapUser`. + * + * The address columns are not shown by this response; they are read by `isDataComplete`, which + * answers `false` for any of them that the query did not load — a wrong value, not a missing one. + * `country` and `organizationCountry` are relations, and the same getter tests them for truthiness, + * so both are joined below for their primary key alone. + */ +export const USER_V2_ACCOUNT_FIELDS = [ + 'userData.accountType', + 'userData.mail', + 'userData.phone', + 'userData.kycHash', + // `kycLevelDisplay`, `tradingLimit` and `isKycTerminated` are getters over this and the limit. + 'userData.kycLevel', + 'userData.depositLimit', + 'userData.phoneCallAccepted', + 'userData.phoneCallStatus', + // `phoneCallTimesObject` splits this column. + 'userData.phoneCallTimes', + 'userData.paymentLinksAllowed', + 'userData.apiKeyCT', + 'userData.apiFilterCT', + ...volumeFields('userData'), + // Read only by `isDataComplete`, together with the two joined countries. + 'userData.firstname', + 'userData.surname', + 'userData.street', + 'userData.location', + 'userData.zip', + 'userData.organizationName', + 'userData.organizationStreet', + 'userData.organizationLocation', + 'userData.organizationZip', + // Relations, and `isDataComplete` tests them for truthiness like any other required field. They + // are response fields rather than guards for exactly that reason: dropping either one changes the + // answer, so the mutation test has to cover them. + 'country.id', + 'organizationCountry.id', +]; + +/** What `LanguageDtoMapper.entityToDto` and `FiatDtoMapper.toDto` read. */ +export const USER_V2_LANGUAGE_AND_CURRENCY_FIELDS = [ + 'language.id', + 'language.name', + 'language.symbol', + 'language.foreignName', + 'language.enable', + 'currency.id', + 'currency.name', + 'currency.buyable', + 'currency.sellable', + 'currency.instantBuyable', + 'currency.instantSellable', +]; + +/** + * What `UserDtoMapper.mapAddress` reads off a user and its wallet. + * + * `user.status` drives `isBlockedOrDeleted`, which decides whether an address is listed as active or + * as disabled; `wallet.name` and `user.address` are what the `blockchains` getter derives the chain + * list from, and `wallet.usesDummyAddresses` hides an address entirely. + */ +export const USER_V2_ADDRESS_FIELDS = [ + 'user.id', + 'user.label', + 'user.address', + 'user.ref', + 'user.apiKeyCT', + 'user.apiFilterCT', + 'user.role', + 'user.status', + ...volumeFields('user'), + 'userWallet.name', + 'userWallet.displayName', + 'userWallet.usesDummyAddresses', +]; + +/** + * `GET /user` (v2) — the widest read path left in the inventory. + * + * Without it a `findOne` on `UserData` selects 351 columns: four countries, a language, a currency + * and an organization expand eagerly, and every user of the account brings its whole wallet. + * + * `computeCapabilities` reads `kycSteps` through `getStepsWith`. This query does not load that + * relation and neither did the one before it, so the two capabilities derived from it do not depend + * on the steps. The projection reproduces that; changing it would change the response. + */ +export const USER_V2_PROJECTION = new ReadProjection( + 'userData', + [ + ['userData.language', 'language'], + ['userData.currency', 'currency'], + ['userData.country', 'country'], + ['userData.organizationCountry', 'organizationCountry'], + ['userData.users', 'user'], + ['user.wallet', 'userWallet'], + ], + [...USER_V2_ACCOUNT_FIELDS, ...USER_V2_LANGUAGE_AND_CURRENCY_FIELDS, ...USER_V2_ADDRESS_FIELDS], + // Never shown: the account id the response is keyed by, the status the endpoint refuses merged + // accounts on, and the wallet key that makes the ORM materialise the joined row. + ['userData.id', 'userData.status', 'userWallet.id'], +); + @Injectable() export class UserDataRepository extends CachedRepository { constructor(manager: EntityManager) { super(UserData, manager); } + /** + * Loads exactly what the v2 user response needs, users and wallets included. + * + * `fields` exists for the mutation test; nothing in production passes it. + */ + async getUserV2(id: number, fields: ReadonlyArray = USER_V2_PROJECTION.fields): Promise { + return USER_V2_PROJECTION.apply(this.createQueryBuilder('userData'), fields) + .where('userData.id = :id', { id }) + .getOne(); + } + /** * Loads exactly what the profile response needs. * diff --git a/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts b/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts new file mode 100644 index 0000000000..de41c1fcab --- /dev/null +++ b/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts @@ -0,0 +1,326 @@ +import { ConfigService } from 'src/config/config'; +import { Fiat } from 'src/shared/models/fiat/fiat.entity'; +import { Language } from 'src/shared/models/language/language.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { Country } from 'src/shared/models/country/country.entity'; +import { UserDtoMapper } from 'src/subdomains/generic/user/models/user/dto/user-dto.mapper'; +import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; +import { + KycLevel, + PhoneCallPreferredTime, + PhoneCallStatus, + UserDataStatus, +} from 'src/subdomains/generic/user/models/user-data/user-data.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { + USER_V2_ACCOUNT_FIELDS, + USER_V2_ADDRESS_FIELDS, + USER_V2_LANGUAGE_AND_CURRENCY_FIELDS, + USER_V2_PROJECTION, + UserDataRepository, +} from 'src/subdomains/generic/user/models/user-data/user-data.repository'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { UserStatus } from 'src/subdomains/generic/user/models/user/user.enum'; +import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'user_v2_projection_spec'; + +/** + * Addresses have to look like addresses. + * + * `user.blockchains` derives the chain list from the address itself, and `explorerUrl` is built + * from the first entry — a generated string belongs to no chain, so both come out empty and the + * completeness assertion would report a projection problem where there is none. + */ +let addressCount = 0; +const nextEvmAddress = (): string => `0x${(++addressCount).toString(16).padStart(40, 'a')}`; + +/** + * `GET /user` (v2) — the four levels from `docs/read-path-projections.md`. + * + * The widest read path in the inventory: a `findOne` on `UserData` selected 351 columns, because + * four countries, a language, a currency and an organization expand eagerly and every user of the + * account brought its whole wallet. + * + * Most of what the response shows comes out of getters rather than columns, and several of them + * answer a valid-looking value from a missing field: `isDataComplete` reports `false`, the trading + * limit falls back to the no-KYC default. Level 3 therefore compares against the response the full + * projection produced, not against emptiness. + */ +describeProjection('GET /user v2 — read-path projection', () => { + let dataSource: DataSource; + let userDataRepo: UserDataRepository; + + beforeAll(async () => { + // `tradingLimit` reads the no-KYC default off the module-level Config. + new ConfigService(); + dataSource = await createProjectionDataSource(SCHEMA); + userDataRepo = new UserDataRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * An account with one address on it, every column populated. + * + * `accountType`, `kycLevel`, `status` and the user's `status` are set explicitly: all four are + * TypeScript enums in text columns, and the mapper branches on each — a generated value lands in + * the wrong branch and the response looks complete for the wrong reason. + */ + async function seedAccount( + account: Partial = {}, + user: Partial = {}, + wallet: Partial = {}, + ): Promise<{ userData: UserData; user: User }> { + const language = await seedEntity(dataSource, Language); + const currency = await seedEntity(dataSource, Fiat); + const country = await seedEntity(dataSource, Country); + const organizationCountry = await seedEntity(dataSource, Country); + const userData = await seedEntity(dataSource, UserData, { + values: { + language, + currency, + country, + organizationCountry, + accountType: AccountType.PERSONAL, + kycLevel: KycLevel.LEVEL_50, + status: UserDataStatus.ACTIVE, + // Read through a lookup table, so a generated value maps to undefined and reads exactly + // like a column the query failed to load. + phoneCallStatus: PhoneCallStatus.COMPLETED, + // Split on ';' by `phoneCallTimesObject`; a value has to be a member for the list to mean + // anything. + phoneCallTimes: PhoneCallPreferredTime.H_9_TO_10, + ...account, + }, + }); + const userWallet = await seedEntity(dataSource, Wallet, { + values: { usesDummyAddresses: false, ...wallet }, + }); + const seeded = await seedEntity(dataSource, User, { + values: { userData, wallet: userWallet, status: UserStatus.ACTIVE, address: nextEvmAddress(), ...user }, + }); + return { userData, user: seeded }; + } + + /** The response the endpoint produces, through the projected query. */ + async function userV2Of(id: number, activeUserId?: number, fields = USER_V2_PROJECTION.fields) { + const userData = await userDataRepo.getUserV2(id, fields); + return UserDtoMapper.mapUser(userData, activeUserId); + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a complete account answers with no empty field', async () => { + const { userData, user } = await seedAccount(); + + const dto = await userV2Of(userData.id, user.id); + + expect(dto.addresses).toHaveLength(1); + // `disabledAddresses` is legitimately empty for an account with no blocked address; the case + // where it is filled is covered below. + expectNoEmptyFields(dto, ['disabledAddresses']); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — an organization account answers with no empty field', async () => { + // `requiredKycFields` swaps the personal name fields for the four organization address columns, + // so a projection missing one of those is only visible on this account type. + const { userData, user } = await seedAccount({ accountType: AccountType.ORGANIZATION }); + + expectNoEmptyFields(await userV2Of(userData.id, user.id), ['disabledAddresses']); + }, 120000); + + it.each([KycLevel.LEVEL_0, KycLevel.LEVEL_50, KycLevel.TERMINATED])( + 'level 2 — kycLevel %s answers with a trading limit', + async (kycLevel) => { + const { userData, user } = await seedAccount({ kycLevel }); + + // The three branches of `tradingLimit` are a yearly limit, a zero limit for a terminated + // account, and the configured default. Only the first reads the deposit limit and the volumes. + const dto = await userV2Of(userData.id, user.id); + + expect(dto.tradingLimit.limit).toBeDefined(); + expect(dto.tradingLimit.period).toBeDefined(); + }, + 120000, + ); + + it('level 2 — a blocked address is listed as disabled rather than active', async () => { + const { userData } = await seedAccount({}, { status: UserStatus.BLOCKED }); + + const dto = await userV2Of(userData.id); + + expect(dto.addresses).toHaveLength(0); + expect(dto.disabledAddresses).toHaveLength(1); + }, 120000); + + it('level 2 — an address on a dummy-address wallet is not listed at all', async () => { + const { userData } = await seedAccount({}, {}, { usesDummyAddresses: true }); + + const dto = await userV2Of(userData.id); + + expect(dto.addresses).toHaveLength(0); + expect(dto.disabledAddresses).toHaveLength(0); + }, 120000); + + it('level 2 — the active address is the one the caller authenticated with', async () => { + const { userData, user } = await seedAccount(); + + expect((await userV2Of(userData.id, user.id)).activeAddress?.address).toEqual(user.address); + expect((await userV2Of(userData.id)).activeAddress).toBeUndefined(); + }, 120000); + + it('level 2 — the account answers only with its own addresses', async () => { + const mine = await seedAccount(); + const other = await seedAccount(); + + const dto = await userV2Of(mine.userData.id); + + expect(dto.addresses.map((a) => a.address)).toEqual([mine.user.address]); + expect(dto.addresses.map((a) => a.address)).not.toContain(other.user.address); + }, 120000); + + it('level 2 — an incomplete account reports dataComplete false', async () => { + // The counter-case to level 1: with a required field genuinely absent the flag must be false, + // which is what makes the level-1 assertion meaningful. + const { userData, user } = await seedAccount({ street: null }); + + expect((await userV2Of(userData.id, user.id)).kyc.dataComplete).toBe(false); + }, 120000); + + // --- LEVEL 3: mutation --- // + + /** The account fields only an organization account reads. */ + const ORGANIZATION_ONLY = [ + 'userData.organizationName', + 'userData.organizationStreet', + 'userData.organizationLocation', + 'userData.organizationZip', + 'organizationCountry.id', + ]; + + /** + * Fields the mapper only reaches through a fallback, or through a branch a default fixture does + * not enter. Each gets its own fixture below; against the default one they are droppable, which + * is true and proves nothing. + */ + const NEEDS_ITS_OWN_FIXTURE = [ + 'user.apiKeyCT', + 'user.apiFilterCT', + 'user.role', + 'userWallet.name', + 'userWallet.usesDummyAddresses', + ]; + + const withoutFields = (fields: string[], excluded: string[]): string[] => + fields.filter((field) => !excluded.includes(field)); + + /** Adds a second, blocked address so that `disabledAddresses` is populated as well. */ + async function seedBlockedAddress(userData: UserData): Promise { + const wallet = await seedEntity(dataSource, Wallet, { values: { usesDummyAddresses: false } }); + await seedEntity(dataSource, User, { + values: { userData, wallet, status: UserStatus.BLOCKED, address: nextEvmAddress() }, + }); + } + + it.each([ + ['personal', AccountType.PERSONAL, ORGANIZATION_ONLY], + ['organization', AccountType.ORGANIZATION, []], + ])( + 'level 3 — on a %s account every field feeding the response is required', + async (_name, accountType, skipped) => { + const { userData, user } = await seedAccount({ accountType }); + // The mutation run compares whole responses, and a list that is empty in the baseline can never + // differ — so both address lists have to be populated. + await seedBlockedAddress(userData); + + await expectEveryFieldRequired( + withoutFields( + [...USER_V2_ACCOUNT_FIELDS, ...USER_V2_LANGUAGE_AND_CURRENCY_FIELDS, ...USER_V2_ADDRESS_FIELDS], + [...skipped, ...NEEDS_ITS_OWN_FIXTURE], + ), + (omitted) => userV2Of(userData.id, user.id, projectionFieldsWithout(USER_V2_PROJECTION.fields, omitted)), + ); + }, + 300000, + ); + + it('level 3 — the address falls back to its own key and its wallet name when the account has none', async () => { + // `mapAddress` reads `userData.apiKeyCT ?? user.apiKeyCT` and `wallet.displayName ?? wallet.name`. + // With the left-hand side set, the right-hand columns are never reached and look removable. + const { userData, user } = await seedAccount({ apiKeyCT: null, apiFilterCT: null }, {}, { displayName: null }); + await seedBlockedAddress(userData); + + await expectEveryFieldRequired( + ['user.apiKeyCT', 'user.apiFilterCT', 'userWallet.name'], + (omitted) => userV2Of(userData.id, user.id, projectionFieldsWithout(USER_V2_PROJECTION.fields, omitted)), + // The account-level key is what this fixture removes, so the two fields it feeds at the top of + // the response are empty by construction — that is the state that makes the address fall back. + ['apiKeyCT', 'apiFilterCT'], + ); + }, 300000); + + it('level 3 — the custody role is required to report an address as custody', async () => { + // `isCustody` compares the role against one value. On any other role, dropping the column + // produces `undefined === CUSTODY` — false, the same answer, so only a custody address shows it. + const { userData, user } = await seedAccount({}, { role: UserRole.CUSTODY }); + + await expectEveryFieldRequired( + ['user.role'], + (omitted) => userV2Of(userData.id, user.id, projectionFieldsWithout(USER_V2_PROJECTION.fields, omitted)), + // With one custody address and none blocked, this list is empty by contract. + ['disabledAddresses'], + ); + }, 300000); + + it('level 3 — the dummy-address flag is required to hide an address', async () => { + // The flag hides the address entirely. Dropping the column leaves it undefined, which is falsy, + // so the address reappears — visible only on a wallet where the flag is actually set. + const { userData, user } = await seedAccount({}, {}, { usesDummyAddresses: true }); + + await expectEveryFieldRequired( + ['userWallet.usesDummyAddresses'], + (omitted) => userV2Of(userData.id, user.id, projectionFieldsWithout(USER_V2_PROJECTION.fields, omitted)), + // Both lists are empty while the address is hidden — that is the state under test. + ['addresses', 'disabledAddresses', 'activeAddress'], + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([ + ['a personal account', AccountType.PERSONAL, UserStatus.ACTIVE], + ['an organization account', AccountType.ORGANIZATION, UserStatus.ACTIVE], + ['an account whose address is blocked', AccountType.PERSONAL, UserStatus.BLOCKED], + ])( + 'level 4 — for %s the projected response equals the one from a full load', + async (_name, accountType, status) => { + const { userData, user } = await seedAccount({ accountType }, { status }); + + const projected = await userV2Of(userData.id, user.id); + // The unprojected load is the second source: the find the endpoint used before, with every + // eager relation it pulls in. + const full = await dataSource.getRepository(UserData).findOne({ + where: { id: userData.id }, + relations: { users: { wallet: true } }, + }); + + expect(projected).toEqual(UserDtoMapper.mapUser(full, user.id)); + }, + 120000, + ); +}); diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index c22f38f097..22919efabe 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -264,10 +264,7 @@ export class UserService { } async getUserDtoV2(userDataId: number, userId?: number): Promise { - const userData = await this.userDataRepo.findOne({ - where: { id: userDataId }, - relations: { users: { wallet: true } }, - }); + const userData = await this.userDataRepo.getUserV2(userDataId); if (!userData) throw new NotFoundException('User not found'); if (userData.status === UserDataStatus.MERGED) throw new UnauthorizedException('User is merged'); From 9e3e0b63e6cd42c0a22ba326b7996dba6edf6f86 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:47:56 +0200 Subject: [PATCH 11/46] Project the two write paths whose read is derivable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /paymentLink/:id/pos 513 columns -> 19 fields POST /user/apiKey/CT 253 -> 2 fields and the id Both were held back by the first pre-filter, which excludes an endpoint that writes the entity it loaded anywhere in its call chain. The hazard that criterion names is saving a partially loaded row back: the columns the query did not select are undefined on the entity and would be written as null. Neither of these does that. They write through `update(id, …)`, which sends only the columns named in the call, so a projected read cannot blank anything. The criterion is therefore narrower than its own rationale, and applied literally it excludes work it was never meant to exclude. What it does have to keep excluding is a value the write derives from what was read - and that case is real here: the point-of-sale write merges the new access key into the existing configuration, so a `config` the query failed to load would be a configuration silently reset. It is part of the projection and the spec asserts it directly. POST /support/issue/:id/message stays out, and not for the write. It hands the message - carrying the issue and the account behind it - to the notification service, which is the second pre-filter: what that code reads is not determinable at the load site, and a projection there would be guessed. Level 3 needed a fixture per branch again: - `UserData.address` reads the account's own columns or the organization's, never both, so each account type covers one half - `accountType` selects between them, and dropping it reads undefined - which is not one of the two organization types either, so on a personal account the branch and the answer are unchanged - `completeName` falls back from the organization name to the personal ones, which are unreachable while the first is set - the API key's own column is only required where a key exists: that is the conflict check, and on an account without one, dropping it changes nothing Two fixture notes. The two configuration markers are set on `fee` rather than on `recipient`, because a recipient in the configuration masks the columns the recipient block is built from and makes them look removable. And the key an account gets is random, so level 4 gives both rows the same one and compares the secret derived from it - which is where the creation date enters. --- .../__tests__/pos-link.projection.spec.ts | 253 ++++++++++++++++++ .../repositories/payment-link.repository.ts | 78 ++++++ .../services/payment-link.service.ts | 5 +- .../__tests__/api-key.projection.spec.ts | 132 +++++++++ .../models/user-data/user-data.repository.ts | 33 +++ .../models/user-data/user-data.service.ts | 2 +- 6 files changed, 498 insertions(+), 5 deletions(-) create mode 100644 src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts create mode 100644 src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts diff --git a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts new file mode 100644 index 0000000000..2e10c157a6 --- /dev/null +++ b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts @@ -0,0 +1,253 @@ +import { ConfigService } from 'src/config/config'; +import { Country } from 'src/shared/models/country/country.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { PaymentLink } from 'src/subdomains/core/payment-link/entities/payment-link.entity'; +import { + POS_LINK_PROJECTION, + POS_LINK_RESPONSE_FIELDS, + PaymentLinkRepository, +} from 'src/subdomains/core/payment-link/repositories/payment-link.repository'; +import { BankData } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; +import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; +import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; +import { Organization } from 'src/subdomains/generic/user/models/organization/organization.entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'pos_link_projection_spec'; + +/** + * `PUT /paymentLink/:id/pos` — the four levels from `docs/read-path-projections.md`. + * + * The link was loaded with its route, its user, the account and the account's organization, which + * came to 513 columns for a recipient block and two configuration strings. + * + * The endpoint writes as well as reads, but only through `update(id, …)` — it never saves the row + * it read — so a projected read cannot blank a column it did not load. What it can do is lose the + * existing configuration, because the write merges into it; `config` is asserted here for that. + */ +describeProjection('point-of-sale link — read-path projection', () => { + let dataSource: DataSource; + let paymentLinks: PaymentLinkRepository; + + beforeAll(async () => { + // The recipient defaults come from the module-level Config. + new ConfigService(); + dataSource = await createProjectionDataSource(SCHEMA); + paymentLinks = new PaymentLinkRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * A link on a route of an account. + * + * `accountType` is set explicitly: it is a TypeScript enum in a text column and `UserData.address` + * branches on it, so a generated value silently selects the personal address on an account whose + * data lives on the organization. + */ + async function seedLink( + accountType = AccountType.PERSONAL, + account: Partial = {}, + link: Partial = {}, + ): Promise<{ paymentLink: PaymentLink; userData: UserData }> { + const country = await seedEntity(dataSource, Country); + const organizationCountry = await seedEntity(dataSource, Country); + const organization = await seedEntity(dataSource, Organization, { + values: { country: organizationCountry }, + }); + const userData = await seedEntity(dataSource, UserData, { + // `paymentLinksConfig` holds JSON and is read through `JSON.parse`; a generated string throws + // before any assertion is reached. + values: { country, organization, accountType, paymentLinksConfig: '{}', ...account }, + }); + const user = await seedEntity(dataSource, User, { values: { userData } }); + // An active sell route carries a check constraint requiring a bank data row. + const bankData = await seedEntity(dataSource, BankData, { values: { userData } }); + const route = await seedEntity(dataSource, Sell, { values: { user, active: true, bankData } }); + const paymentLink = await seedEntity(dataSource, PaymentLink, { + values: { route, config: null, ...link }, + }); + return { paymentLink, userData }; + } + + /** + * The three configuration sources the endpoint can answer from, as the service reads them. + * + * `scoped` selects between them: unset merges the account and the link, `true` takes the link + * alone, `false` the account alone. + */ + async function posConfigOf(id: number, fields = POS_LINK_PROJECTION.fields) { + const link = await paymentLinks.findForPosLink(id, fields); + return { + merged: link.configObj, + linkOnly: link.linkConfigObj, + accountOnly: link.route.userData.paymentLinksConfigObj, + uniqueId: link.uniqueId, + // What the write merges into. A projection that dropped it would reset the configuration. + storedConfig: link.config, + }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a link answers with a complete recipient', async () => { + const { paymentLink } = await seedLink(); + + const config = await posConfigOf(paymentLink.id); + + expect(config.merged.recipient).toBeDefined(); + expectNoEmptyFields(config.merged.recipient); + expect(config.uniqueId).toBeDefined(); + }, 120000); + + // --- LEVEL 2: variants --- // + + it.each([AccountType.ORGANIZATION, AccountType.SOLE_PROPRIETORSHIP])( + 'level 2 — on a %s account the recipient address comes from the organization', + async (accountType) => { + const { paymentLink, userData } = await seedLink(accountType); + + const config = await posConfigOf(paymentLink.id); + + // `UserData.address` switches rows entirely for these two account types. Reading the personal + // columns here would answer with an address that belongs to the wrong entity. + expect(config.merged.recipient.address.street).toEqual(userData.organization.street); + expect(config.merged.recipient.address.street).not.toEqual(userData.street); + expectNoEmptyFields(config.merged.recipient); + }, + 120000, + ); + + it('level 2 — an account without a country answers without an address block', async () => { + const { paymentLink } = await seedLink(AccountType.PERSONAL, { country: null }); + + // The address is emitted only when the country is there; the rest of the recipient must stay. + const config = await posConfigOf(paymentLink.id); + + expect(config.merged.recipient.address).toBeUndefined(); + expect(config.merged.recipient.mail).toBeDefined(); + }, 120000); + + it('level 2 — the link configuration overrides the account configuration', async () => { + const { paymentLink } = await seedLink( + AccountType.PERSONAL, + { paymentLinksConfig: JSON.stringify({ recipient: { name: 'from-account' } }) }, + { config: JSON.stringify({ recipient: { name: 'from-link' } }) }, + ); + + const config = await posConfigOf(paymentLink.id); + + expect(config.merged.recipient.name).toEqual('from-link'); + expect(config.linkOnly.recipient.name).toEqual('from-link'); + expect(config.accountOnly.recipient.name).toEqual('from-account'); + expect(config.storedConfig).toContain('from-link'); + }, 120000); + + // --- LEVEL 3: mutation --- // + + /** The five address columns of the account, read only for a personal account. */ + const PERSONAL_ADDRESS = [ + 'posUserData.street', + 'posUserData.houseNumber', + 'posUserData.location', + 'posUserData.zip', + 'posCountry.symbol', + ]; + + /** The same five, read off the organization for the other account types. */ + const ORGANIZATION_ADDRESS = [ + 'posOrganization.street', + 'posOrganization.houseNumber', + 'posOrganization.location', + 'posOrganization.zip', + 'posOrganizationCountry.symbol', + ]; + + /** The name, which is `organizationName` falling back to the two personal ones. */ + const NAME_FIELDS = ['posUserData.organizationName', 'posUserData.firstname', 'posUserData.surname']; + + /** + * Two configurations that differ from the defaults and from each other, so both columns are + * required — set on `fee` rather than on `recipient`, which would mask the columns the recipient + * is built from and make them look removable. + */ + const ACCOUNT_CONFIG = JSON.stringify({ fee: 0.5 }); + const LINK_CONFIG = JSON.stringify({ fee: 0.7 }); + + it.each([ + // `UserData.address` reads one row or the other, never both, so the unread half is droppable for + // the account type at hand — and covered by the other row of this table. + // `accountType` is skipped for the personal row for the same reason: dropped it reads + // undefined, which is not one of the two organization types either, so the branch and the + // answer are unchanged. The organization row is where it becomes visible. + ['personal', AccountType.PERSONAL, [...ORGANIZATION_ADDRESS, 'posUserData.accountType']], + ['organization', AccountType.ORGANIZATION, PERSONAL_ADDRESS], + ])( + 'level 3 — on a %s account every field feeding the answer is required', + async (_name, accountType, skipped) => { + const { paymentLink } = await seedLink( + accountType, + { paymentLinksConfig: ACCOUNT_CONFIG }, + { config: LINK_CONFIG }, + ); + + await expectEveryFieldRequired( + POS_LINK_RESPONSE_FIELDS.filter( + // The name falls back, so no single one of its three columns is required here; the case + // below is the one that reaches the fallback. + (field) => !skipped.includes(field) && !NAME_FIELDS.includes(field), + ).concat([NAME_FIELDS as unknown as string]), + (omitted) => posConfigOf(paymentLink.id, projectionFieldsWithout(POS_LINK_PROJECTION.fields, omitted)), + ); + }, + 300000, + ); + + it('level 3 — the personal name is required when the account has no organization name', async () => { + // `completeName` is `organizationName ?? firstname + surname`. With the organization name set, + // the two personal columns are unreachable and report as removable — true, and useless. + const { paymentLink } = await seedLink( + AccountType.PERSONAL, + { organizationName: null, paymentLinksConfig: ACCOUNT_CONFIG }, + { config: LINK_CONFIG }, + ); + + await expectEveryFieldRequired(['posUserData.firstname', 'posUserData.surname'], (omitted) => + posConfigOf(paymentLink.id, projectionFieldsWithout(POS_LINK_PROJECTION.fields, omitted)), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([AccountType.PERSONAL, AccountType.ORGANIZATION])( + 'level 4 — for %s the projected answer equals the one from a full load', + async (accountType) => { + const { paymentLink } = await seedLink(accountType); + + const projected = await posConfigOf(paymentLink.id); + // The unprojected load is the second source: the relation set the endpoint used before. + const full = await dataSource.getRepository(PaymentLink).findOne({ + where: { id: paymentLink.id }, + relations: { route: { user: { userData: { organization: true } } } }, + }); + + expect(projected.merged).toEqual(full.configObj); + expect(projected.linkOnly).toEqual(full.linkConfigObj); + expect(projected.accountOnly).toEqual(full.route.userData.paymentLinksConfigObj); + expect(projected.storedConfig).toEqual(full.config); + }, + 120000, + ); +}); diff --git a/src/subdomains/core/payment-link/repositories/payment-link.repository.ts b/src/subdomains/core/payment-link/repositories/payment-link.repository.ts index cb28937c10..fa74509fce 100644 --- a/src/subdomains/core/payment-link/repositories/payment-link.repository.ts +++ b/src/subdomains/core/payment-link/repositories/payment-link.repository.ts @@ -1,15 +1,93 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { Between, EntityManager, Equal, In } from 'typeorm'; import { PaymentLink } from '../entities/payment-link.entity'; import { PaymentLinkPaymentStatus } from '../enums'; +/** + * What `createPosLinkFor` reads to build a point-of-sale link. + * + * Three configuration sources can be merged into the answer, and which ones depends on the `scoped` + * argument: the link's own config, the account's, and the recipient block the account's address and + * contact data make up. The union of the three is the field list. + * + * The organization side is here because `UserData.address` switches to it for organization and + * sole-proprietorship accounts — the same five values, read off another row. + */ +export const POS_LINK_RESPONSE_FIELDS = [ + 'paymentLink.uniqueId', + 'paymentLink.config', + 'posUserData.accountType', + // `completeName`: the organization name, falling back to the two personal ones. + 'posUserData.organizationName', + 'posUserData.firstname', + 'posUserData.surname', + 'posUserData.phone', + 'posUserData.mail', + 'posUserData.paymentLinksConfig', + 'posUserData.street', + 'posUserData.houseNumber', + 'posUserData.location', + 'posUserData.zip', + 'posCountry.symbol', + 'posOrganization.street', + 'posOrganization.houseNumber', + 'posOrganization.location', + 'posOrganization.zip', + 'posOrganizationCountry.symbol', +]; + +/** + * `PUT /paymentLink/:id/pos` — 513 columns before. + * + * The endpoint writes, but through `update(id, …)` on the link and on the account rather than by + * saving either row back, so a projected read cannot blank a column it did not load. `config` is + * part of the projection for that reason as much as for the response: the write merges the new + * access key into the existing configuration, and a config the query did not load would be a + * configuration silently reset. + */ +export const POS_LINK_PROJECTION = new ReadProjection( + 'paymentLink', + [ + ['paymentLink.route', 'posRoute'], + ['posRoute.user', 'posUser'], + ['posUser.userData', 'posUserData'], + ['posUserData.country', 'posCountry'], + ['posUserData.organization', 'posOrganization'], + ['posOrganization.country', 'posOrganizationCountry'], + ], + POS_LINK_RESPONSE_FIELDS, + // Never part of the answer: the primary keys that make the ORM materialise the joined rows, and + // the two ids the two updates are scoped by. + [ + 'paymentLink.id', + 'posRoute.id', + 'posUser.id', + 'posUserData.id', + 'posCountry.id', + 'posOrganization.id', + 'posOrganizationCountry.id', + ], +); + @Injectable() export class PaymentLinkRepository extends BaseRepository { constructor(manager: EntityManager) { super(PaymentLink, manager); } + /** + * One link, carrying what a point-of-sale link is built from. + * + * `fields` exists for the mutation test; nothing in production passes it. + */ + async findForPosLink(id: number, fields: ReadonlyArray = POS_LINK_PROJECTION.fields): Promise { + return POS_LINK_PROJECTION.apply(this.createQueryBuilder('paymentLink'), fields) + .where('paymentLink.id = :id', { id }) + .getOne(); + } + async getAllPaymentLinks(userId: number): Promise { return this.find({ where: { route: { user: { id: Equal(userId) }, active: true } }, diff --git a/src/subdomains/core/payment-link/services/payment-link.service.ts b/src/subdomains/core/payment-link/services/payment-link.service.ts index 57a5452678..bca41e760a 100644 --- a/src/subdomains/core/payment-link/services/payment-link.service.ts +++ b/src/subdomains/core/payment-link/services/payment-link.service.ts @@ -699,10 +699,7 @@ export class PaymentLinkService { } async createPosLinkAdmin(paymentLinkId: number, scoped?: boolean): Promise { - const paymentLink = await this.paymentLinkRepo.findOne({ - where: { id: paymentLinkId }, - relations: { route: { user: { userData: { organization: true } } } }, - }); + const paymentLink = await this.paymentLinkRepo.findForPosLink(paymentLinkId); if (!paymentLink) throw new NotFoundException('Payment link not found'); return this.createPosLinkFor(paymentLink, scoped); diff --git a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts new file mode 100644 index 0000000000..d5673ec38f --- /dev/null +++ b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts @@ -0,0 +1,132 @@ +import { ConfigService } from 'src/config/config'; +import { ApiKeyService } from 'src/shared/services/api-key.service'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { + API_KEY_PROJECTION, + UserDataRepository, +} from 'src/subdomains/generic/user/models/user-data/user-data.repository'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'api_key_projection_spec'; + +/** + * `POST /user/apiKey/CT` — the four levels from `docs/read-path-projections.md`. + * + * The endpoint read a whole `UserData` row — 253 columns across eight eager joins — to check + * whether a key exists and to derive a new one from the account id and its creation date. + * + * It writes as well as reads, but through `update(id, …)` rather than by saving the row it read, so + * a projected read cannot blank a column it did not load. + */ +describeProjection('API key — read-path projection', () => { + let dataSource: DataSource; + let userDataRepo: UserDataRepository; + + beforeAll(async () => { + // `createKey` reads the key version off the module-level Config. + new ConfigService(); + dataSource = await createProjectionDataSource(SCHEMA); + userDataRepo = new UserDataRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + const seedAccount = (values: Partial = {}): Promise => + seedEntity(dataSource, UserData, { values }); + + /** + * What the endpoint answers, through the projected query. + * + * The key is assigned in memory before the secret is derived from it, which is what makes + * `created` part of the read: `getSecret` hashes the two together. + */ + async function apiKeyOf(id: number, fields = API_KEY_PROJECTION.fields) { + const userData = await userDataRepo.getForApiKey(id, fields); + if (userData.apiKeyCT) return { conflict: true as const }; + + userData.apiKeyCT = ApiKeyService.createKey(userData.id); + + return { key: userData.apiKeyCT, secret: ApiKeyService.getSecret(userData) }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a fresh account answers with a key and a secret', async () => { + const userData = await seedAccount({ apiKeyCT: null }); + + expectNoEmptyFields(await apiKeyOf(userData.id)); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — an account that already has a key is refused', async () => { + // The existing key is the whole conflict check, so a projection that dropped the column would + // hand out a second key and overwrite the first. + const userData = await seedAccount({ apiKeyCT: 'existing-key-refused' }); + + expect(await apiKeyOf(userData.id)).toEqual({ conflict: true }); + }, 120000); + + it('level 2 — two accounts get different keys and different secrets', async () => { + const first = await apiKeyOf((await seedAccount({ apiKeyCT: null })).id); + const second = await apiKeyOf((await seedAccount({ apiKeyCT: null })).id); + + expect(first).not.toEqual(second); + }, 120000); + + it('level 2 — an unknown id resolves to nothing, so the endpoint can refuse', async () => { + const userData = await seedAccount({ apiKeyCT: null }); + + expect(await userDataRepo.getForApiKey(userData.id + 1_000_000)).toBeNull(); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — the creation date is required to derive the secret', async () => { + const userData = await seedAccount({ apiKeyCT: null }); + + await expectEveryFieldRequired(['userData.created'], (omitted) => + apiKeyOf(userData.id, projectionFieldsWithout(API_KEY_PROJECTION.fields, omitted)), + ); + }, 300000); + + it('level 3 — the existing key is required to refuse a second one', async () => { + // On an account without a key, dropping the column produces the same answer as reading it — the + // conflict branch is only reachable where a key is actually stored. + const userData = await seedAccount({ apiKeyCT: 'existing-key-required' }); + + await expectEveryFieldRequired(['userData.apiKeyCT'], (omitted) => + apiKeyOf(userData.id, projectionFieldsWithout(API_KEY_PROJECTION.fields, omitted)), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it('level 4 — the projected answer equals the one from a full load', async () => { + const userData = await seedAccount({ apiKeyCT: null }); + + const projected = await userDataRepo.getForApiKey(userData.id); + // The unprojected load is the second source: every column of the row, which is what the + // endpoint fetched before. + const full = await dataSource.getRepository(UserData).findOneBy({ id: userData.id }); + + // The key itself is random, so the two rows are given the same one; what has to agree is the + // secret derived from it, which is where the creation date enters. + const key = ApiKeyService.createKey(full.id); + projected.apiKeyCT = key; + full.apiKeyCT = key; + + expect(ApiKeyService.getSecret(projected)).toEqual(ApiKeyService.getSecret(full)); + }, 120000); +}); diff --git a/src/subdomains/generic/user/models/user-data/user-data.repository.ts b/src/subdomains/generic/user/models/user-data/user-data.repository.ts index 56fb16b1f0..030a0e6761 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.repository.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.repository.ts @@ -201,12 +201,45 @@ export const USER_V2_PROJECTION = new ReadProjection( ['userData.id', 'userData.status', 'userWallet.id'], ); +/** + * What `POST /user/apiKey/CT` reads. + * + * `apiKeyCT` is read twice: once to refuse a second key, and once after the new one is assigned, + * because `ApiKeyService.getSecret` hashes it together with `created`. + */ +export const API_KEY_RESPONSE_FIELDS = ['userData.apiKeyCT', 'userData.created']; + +/** + * `POST /user/apiKey/CT` — 253 columns before, for two values and the id. + * + * The endpoint writes, but through `update(id, …)` rather than by saving the row it read, so a + * projected read cannot blank a column it did not load. + */ +export const API_KEY_PROJECTION = new ReadProjection( + 'userData', + [], + API_KEY_RESPONSE_FIELDS, + // Not part of the response, but the key is derived from it and the update is scoped by it. + ['userData.id'], +); + @Injectable() export class UserDataRepository extends CachedRepository { constructor(manager: EntityManager) { super(UserData, manager); } + /** + * The account, carrying what an API key is built from. + * + * `fields` exists for the mutation test; nothing in production passes it. + */ + async getForApiKey(id: number, fields: ReadonlyArray = API_KEY_PROJECTION.fields): Promise { + return API_KEY_PROJECTION.apply(this.createQueryBuilder('userData'), fields) + .where('userData.id = :id', { id }) + .getOne(); + } + /** * Loads exactly what the v2 user response needs, users and wallets included. * diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index 2680e04f9d..a6dd8bdf52 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -1090,7 +1090,7 @@ export class UserDataService { // --- API KEY --- // async createApiKey(userDataId: number, filter: HistoryFilter): Promise { - const userData = await this.userDataRepo.findOneBy({ id: userDataId }); + const userData = await this.userDataRepo.getForApiKey(userDataId); if (!userData) throw new BadRequestException('User not found'); if (userData.apiKeyCT) throw new ConflictException('API key already exists'); From c046b088eeb7a4c0f2ca0c91e2934fa852af8cb0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:57:39 +0200 Subject: [PATCH 12/46] Bring the inventory up to the conversions, and correct two more classifications whole rows 415 -> 398 projected 19 -> 36 Seventeen endpoints now read only what they return and carry tests on all four levels. The qualifying set is empty apart from one endpoint, which the second criterion excludes rather than the first four: POST /support/issue/:id/message hands the message it creates - carrying the issue and the account behind it - to the notification service, and what that code reads is not determinable here. Two shapes were still counted as full loads, both decided by something the select list does not show: - the terminal call can discard it. getCount() and getExists() replace the select with COUNT(...) and SELECT 1, so a chain ending in either materialises no row whatever precedes it. Three load sites do this, and GET /support/kycFileStats was listed at 99 columns because of one. - the select argument can be a variable. `.select(bucketExpr, 'bucket')` names an expression as surely as a literal does, and whether it narrows has to be resolved in the enclosing method. Four sites do this; three are the two caller-defined /gs/db endpoints, the fourth an aggregate reported as a full load, which is what made GET /dashboard/accounting/ledger/margin look convertible when it was already projected. The measured filter chain is restated against both the previous state and this one, and it now records something the earlier revision did not: the filters are conservative and reject endpoints that can in fact be converted. An endpoint fails the first step if ANY load site it reaches leaks, including one on a branch that has nothing to do with the response. Nine of the seventeen conversions were found by reading the endpoint after the filter had rejected it, so the counts are a lower bound rather than a ceiling. read-projection.spec.ts keys the inventory rows by version as well as by path and verb. Without that, `/user` matched the v1 row - a different endpoint, not converted, and one that would have compared the v2 projection against a `whole rows` cell. --- docs/endpoints.md | 32 ++--- docs/load-sites.md | 130 +++++++++--------- docs/read-path-projections.md | 79 +++++++---- .../models/__tests__/read-projection.spec.ts | 52 +++++-- 4 files changed, 172 insertions(+), 121 deletions(-) diff --git a/docs/endpoints.md b/docs/endpoints.md index 35d848b1ab..f54523cf12 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -23,20 +23,20 @@ Two rules follow from that, and both are binding: 1. **An endpoint counts as converted only when its tests reach `4/4`** against the four levels in [read-path-projections.md](read-path-projections.md#test-definition). A projection without them is worse than no projection: a forgotten field does not crash, it returns a wrong value with a 200, and in a service moving money that can run for weeks unnoticed. Anything short of `4/4` is unfinished work, not a partial success. 2. **The state of every endpoint is recorded here**, in the `Tests` column, and kept in sync with the code in the same pull request that changes it. An undocumented conversion is indistinguishable from one that was never tested. -Today 29 endpoints read only what they return and 407 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. +Today 38 endpoints read only what they return and 398 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. ## What the numbers say | Data access | Endpoints | Share | | ----------- | --------: | ----: | -| `whole rows` | 407 | 76 % | +| `whole rows` | 398 | 75 % | | `none` | 98 | 18 % | -| `projected` | 27 | 5 % | +| `projected` | 36 | 7 % | | `caller-defined` | 2 | 0 % | -Of the 27 that read only what they return, 10 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11), `GET /kyc/users` (328 columns to 7), `GET /kyc/:id/documents` (328 columns to 2), `GET /custody/order` (19 columns to 14). The other 17 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. +Of the 36 that read only what they return, 17 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11), `GET /kyc/users` (328 columns to 7), `GET /kyc/:id/documents` (328 columns to 2), `GET /custody/order` (19 columns to 14), `GET /support/issue/list` (16 columns to 10), `GET /realunit/support/list` (16 columns to 10), `GET /dashboard/accounting/ledger/suspense` (11 columns to 10), `GET /liquidityManagement/pipeline/:id/status` (112 columns to 2), `PUT /paymentLink/:id/pos` (513 columns to 26), `POST /user/apiKey/CT` (253 columns to 3), `GET /user` (351 columns to 66). The other 19 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. -Among the 407 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 311 exceed 100, 87 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. +Among the 398 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 307 exceed 100, 86 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. ### How to read this column, and how not to @@ -50,9 +50,9 @@ Among the 407 that fetch whole rows, the widest query they can trigger is **308 Stated exactly, so the numbers can be checked rather than believed: -- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 407 is a lower bound. +- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 398 is a lower bound. - All 98 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. -- The 27 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. +- The 36 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. - 2 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`. The classification holds; only the width is unknown. ### Two controller classes may share a name @@ -219,9 +219,9 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/dashboard/accounting/ledger/accounts` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getAccounts` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/accounts/:accountId/legs` | hidden | whole rows | 30 | not yet | yes | `LedgerController.getAccountDetail` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/equity-comparison` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getEquityComparison` | `subdomains/core/accounting/controllers/ledger.controller.ts` | -| GET | 1 | | `/dashboard/accounting/ledger/margin` | hidden | whole rows | 11 | not yet | yes | `LedgerController.getMargin` | `subdomains/core/accounting/controllers/ledger.controller.ts` | +| GET | 1 | | `/dashboard/accounting/ledger/margin` | hidden | projected | 11 | 0/4 | yes | `LedgerController.getMargin` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/reconciliation` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getReconStatus` | `subdomains/core/accounting/controllers/ledger.controller.ts` | -| GET | 1 | | `/dashboard/accounting/ledger/suspense` | hidden | whole rows | 11 | not yet | yes | `LedgerController.getSuspense` | `subdomains/core/accounting/controllers/ledger.controller.ts` | +| GET | 1 | | `/dashboard/accounting/ledger/suspense` | hidden | projected | 10 | 4/4 | yes | `LedgerController.getSuspense` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/financial/changes` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getFinancialChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/changes/latest` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getLatestChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/latest` | hidden | none | — | n/a | | `DashboardFinancialController.getLatestBalance` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | @@ -340,7 +340,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/liquidityManagement/balance` | hidden | whole rows | 40 | not yet | | `LiquidityBalanceController.getBalances` | `subdomains/core/liquidity-management/controllers/balance.controller.ts` | | PUT | 1 | | `/liquidityManagement/order/:id/resolveUncertain` | hidden | whole rows | 139 | not yet | | `LiquidityManagementOrderController.resolveUncertainOrder` | `subdomains/core/liquidity-management/controllers/order.controller.ts` | | GET | 1 | | `/liquidityManagement/order/in-progress` | hidden | whole rows | 139 | not yet | | `LiquidityManagementOrderController.getProcessingOrders` | `subdomains/core/liquidity-management/controllers/order.controller.ts` | -| GET | 1 | | `/liquidityManagement/pipeline/:id/status` | hidden | whole rows | 112 | not yet | | `LiquidityManagementPipelineController.getPipelineStatus` | `subdomains/core/liquidity-management/controllers/pipeline.controller.ts` | +| GET | 1 | | `/liquidityManagement/pipeline/:id/status` | hidden | projected | 2 | 4/4 | | `LiquidityManagementPipelineController.getPipelineStatus` | `subdomains/core/liquidity-management/controllers/pipeline.controller.ts` | | POST | 1 | | `/liquidityManagement/pipeline/buy` | hidden | whole rows | 112 | not yet | | `LiquidityManagementPipelineController.buyLiquidity` | `subdomains/core/liquidity-management/controllers/pipeline.controller.ts` | | GET | 1 | | `/liquidityManagement/pipeline/in-progress` | hidden | whole rows | 112 | not yet | | `LiquidityManagementPipelineController.getProcessingPipelines` | `subdomains/core/liquidity-management/controllers/pipeline.controller.ts` | | POST | 1 | | `/liquidityManagement/pipeline/sell` | hidden | whole rows | 112 | not yet | | `LiquidityManagementPipelineController.sellLiquidity` | `subdomains/core/liquidity-management/controllers/pipeline.controller.ts` | @@ -391,7 +391,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/paymentLink` | public | whole rows | 513 | not yet | | `PaymentLinkController.updatePaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | DELETE | 1 | | `/paymentLink/:id` | hidden | whole rows | 195 | not yet | | `PaymentLinkController.deletePaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/:id` | hidden | whole rows | 513 | not yet | | `PaymentLinkController.updatePaymentLinkAdmin` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| PUT | 1 | | `/paymentLink/:id/pos` | hidden | whole rows | 513 | not yet | | `PaymentLinkController.createPosLinkAdmin` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| PUT | 1 | | `/paymentLink/:id/pos` | hidden | projected | 26 | 4/4 | | `PaymentLinkController.createPosLinkAdmin` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/assign` | public | whole rows | 513 | not yet | yes | `PaymentLinkController.assignPaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | GET | 1 | | `/paymentLink/config` | public | whole rows | 253 | not yet | | `PaymentLinkController.getUserPaymentLinksConfig` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/config` | public | whole rows | 253 | not yet | | `PaymentLinkController.updateUserPaymentLinksConfig` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | @@ -479,7 +479,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/realunit/support/activity` | hidden | projected | 2 | 0/4 | | `RealUnitSupportController.getSupportIssueActivity` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/clerks` | hidden | none | — | n/a | | `RealUnitSupportController.getRealUnitSupportClerks` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/counts` | hidden | projected | 2 | 0/4 | | `RealUnitSupportController.getSupportIssueCounts` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/list` | hidden | whole rows | 16 | not yet | | `RealUnitSupportController.getSupportIssueList` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/list` | hidden | projected | 10 | 4/4 | | `RealUnitSupportController.getSupportIssueList` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/statistics` | hidden | projected | 3 | 0/4 | | `RealUnitSupportController.getSupportIssueStatistics` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | PUT | 1 | | `/realunit/swap` | public | whole rows | 308 | not yet | yes | `RealUnitController.getSwapPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/swap/:id/broadcast` | public | whole rows | 504 | not yet | yes | `RealUnitController.broadcastSwapTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | @@ -548,11 +548,11 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/support/issue/escalation/telegram-bind` | hidden | whole rows | 5 | not yet | | `SupportIssueController.bindEscalationChat` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/escalation/telegram-chats` | hidden | none | — | n/a | | `SupportIssueController.getEscalationChats` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/escalation/telegram-test` | hidden | whole rows | 5 | not yet | | `SupportIssueController.testEscalationChat` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/list` | hidden | whole rows | 16 | not yet | | `SupportIssueController.getSupportIssueList` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/list` | hidden | projected | 10 | 4/4 | | `SupportIssueController.getSupportIssueList` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/statistics` | hidden | projected | 3 | 0/4 | | `SupportIssueController.getSupportIssueStatistics` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/support` | hidden | whole rows | 493 | not yet | | `SupportIssueController.createIssueBySupport` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/kycFileList` | hidden | whole rows | 253 | not yet | | `SupportController.getKycFileList` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/kycFileStats` | hidden | whole rows | 99 | not yet | | `SupportController.getKycFileStats` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/kycFileStats` | hidden | projected | 99 | 0/4 | | `SupportController.getKycFileStats` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/note` | hidden | whole rows | 9 | not yet | | `SupportController.getNotes` | `subdomains/generic/support/support.controller.ts` | | POST | 1 | | `/support/note` | hidden | whole rows | 253 | not yet | | `SupportController.createNote` | `subdomains/generic/support/support.controller.ts` | | DELETE | 1 | | `/support/note/:id` | hidden | whole rows | 9 | not yet | | `SupportController.deleteNote` | `subdomains/generic/support/support.controller.ts` | @@ -603,7 +603,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | DELETE | 1 | yes | `/user` | public | whole rows | 344 | not yet | | `UserController.deleteUser` | `subdomains/generic/user/models/user/user.controller.ts` | | DELETE | 2 | | `/user` | public | whole rows | 344 | not yet | | `UserV2Controller.deleteAccount` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | yes | `/user` | public | whole rows | 328 | not yet | | `UserController.getUserV1` | `subdomains/generic/user/models/user/user.controller.ts` | -| GET | 2 | | `/user` | public | whole rows | 351 | not yet | | `UserV2Controller.getUser` | `subdomains/generic/user/models/user/user.controller.ts` | +| GET | 2 | | `/user` | public | projected | 66 | 4/4 | | `UserV2Controller.getUser` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | yes | `/user` | public | whole rows | 406 | not yet | | `UserController.updateUserV1` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 2 | | `/user` | public | whole rows | 351 | not yet | | `UserV2Controller.updateUser` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/:id` | hidden | whole rows | 364 | not yet | | `UserController.updateUserAdmin` | `subdomains/generic/user/models/user/user.controller.ts` | @@ -612,7 +612,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 2 | | `/user/addresses/:address` | public | whole rows | 351 | not yet | | `UserV2Controller.updateAddress` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/apiFilter/CT` | public | whole rows | 331 | not yet | | `UserController.updateApiFilter` | `subdomains/generic/user/models/user/user.controller.ts` | | DELETE | 1 | | `/user/apiKey/CT` | public | none | — | n/a | | `UserController.deleteApiKey` | `subdomains/generic/user/models/user/user.controller.ts` | -| POST | 1 | | `/user/apiKey/CT` | public | whole rows | 253 | not yet | | `UserController.createApiKey` | `subdomains/generic/user/models/user/user.controller.ts` | +| POST | 1 | | `/user/apiKey/CT` | public | projected | 3 | 4/4 | | `UserController.createApiKey` | `subdomains/generic/user/models/user/user.controller.ts` | | POST | 1 | | `/user/change` | public | whole rows | 643 | not yet | | `UserController.changeUser` | `subdomains/generic/user/models/user/user.controller.ts` | | POST | 1 | | `/user/data` | public | whole rows | 406 | not yet | | `UserController.updateKycData` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | yes | `/user/detail` | public | whole rows | 328 | not yet | | `UserController.getUserDetailV1` | `subdomains/generic/user/models/user/user.controller.ts` | diff --git a/docs/load-sites.md b/docs/load-sites.md index 181764f0ba..343639a292 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -1,6 +1,6 @@ # Database load sites -Every place in the code that reads from the database: **1105 load sites** across 246 files. +Every place in the code that reads from the database: **1105 load sites** across 248 files. This is the level at which the statement is unambiguous. An endpoint reaches several load sites — a permission check, a lookup, the actual query — so asking whether *an endpoint* loads efficiently has no single answer. Asking it of a load site does. [endpoints.md](endpoints.md) carries the per-endpoint summary derived from these sites. @@ -8,8 +8,8 @@ This is the level at which the statement is unambiguous. An endpoint reaches sev | Mechanism | Sites | Eager relations | Columns selected | | --------- | ----: | --------------- | ---------------- | -| `find` family | 961 | **applied** — expanded recursively | all columns of the entity plus every eager relation | -| `createQueryBuilder` | 139 | not applied | all columns of the root entity, unless `.select([...])` narrows it | +| `find` family | 957 | **applied** — expanded recursively | all columns of the entity plus every eager relation | +| `createQueryBuilder` | 143 | not applied | all columns of the root entity, unless `.select([...])` narrows it | | raw SQL | 5 | not applied | whatever the statement lists | Statements that load nothing are excluded from the count: 2 `createQueryBuilder` calls carrying `.update()`, 6 advisory locks (`SELECT pg_advisory_xact_lock(...)`, which return no rows) and 1 raw `INSERT`. Each of the 5 raw reads that remain names its columns. @@ -18,23 +18,23 @@ Among the query builders, the field list is what decides whether anything is act | | Sites | | --- | ---: | -| `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **12** | +| `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **18** | | `.select('alias.column')` — names columns one by one | **87** | | `.select('alias')` — selects the root alias, **loads every column** | 17 | -| no `select` at all — loads every column | 22 | +| no `select` at all — loads every column | 20 | | projects, but a `leftJoinAndSelect` loads a relation whole | 1 | -`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The distinction is the presence of a dot in the argument, and it matters — the sites that name columns this way select 1 column at the median, against 961 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. +`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The distinction is the presence of a dot in the argument, and it matters — the sites that name columns this way select 1 column at the median, against 957 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. ## Measurements Columns were measured against the real entity metadata by building the query and counting its SELECT list — 790 of 1105 sites. -- **339 are exact**: the `relations` tree is written at the call site. -- **451 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. +- **338 are exact**: the `relations` tree is written at the call site. +- **452 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. - 315 could not be measured: no resolvable target entity, or raw SQL. -Median across measured sites: **112 columns**. 14 sites exceed 1000, 75 exceed 500, 400 exceed 100. +Median across measured sites: **101 columns**. 14 sites exceed 1000, 74 exceed 500, 396 exceed 100. What that does and does not affect: the median and the counts above are computed only over the 782 rows that carry a measured width, and an array operation never has one, so those figures stand. Nor does it move the conclusion this table exists for: the sites that name their columns are a small fraction either way, and the verdict reads the same against 900 as against 1105. It does reach the per-endpoint summary in [endpoints.md](endpoints.md): an endpoint could be listed as fetching whole rows on the strength of such a row alone. The three with no measured width at all are the exposed cases and are named there. For the rest a measured query stands behind the entry, which limits the effect without excluding it — establishing that would need the collection to separate the two kinds of `find`, which is the fix this note stands in for. @@ -98,7 +98,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 583 | 21 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:35` | `BuyFiatRegistrationService.syncReturnTxId` | | 567 | 17 | find | `BuyCrypto` | `subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts:114` | `BuyCryptoConsumer.processForward` | | 558 | 24 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:150` | `PaymentQuoteService.getConfirmingQuotes` | -| 545 | 23 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:41` | `PaymentLinkRepository.getHistoryByStatus` | +| 545 | 23 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:119` | `PaymentLinkRepository.getHistoryByStatus` | | 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:97` | `PaymentLinkPaymentService.updatePayment` | | 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:104` | `PaymentLinkPaymentService.getPendingPaymentByUniqueId` | | 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:372` | `PaymentLinkPaymentService.handleBlockchainConfirmed` | @@ -110,17 +110,16 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:317` | `CustodyOrderService.getOrdersForSupport` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:615` | `BuyFiatPreparationService.chargebackTx` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:486` | `BuyFiatService.retriggerScorechain` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:14` | `PaymentLinkRepository.getAllPaymentLinks` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:21` | `PaymentLinkRepository.getAllPaymentLinksByExternalLinkId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:28` | `PaymentLinkRepository.getAllPaymentLinksByExternalPaymentId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:65` | `PaymentLinkRepository.getPaymentLinkByLinkId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:72` | `PaymentLinkRepository.getPaymentLinkByExternalId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:82` | `PaymentLinkRepository.getPaymentLinkByExternalPaymentId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:92` | `PaymentLinkRepository.getAllPaymentLinks` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:99` | `PaymentLinkRepository.getAllPaymentLinksByExternalLinkId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:106` | `PaymentLinkRepository.getAllPaymentLinksByExternalPaymentId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:143` | `PaymentLinkRepository.getPaymentLinkByLinkId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:150` | `PaymentLinkRepository.getPaymentLinkByExternalId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:160` | `PaymentLinkRepository.getPaymentLinkByExternalPaymentId` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:430` | `PaymentLinkService.updatePaymentLinkAdmin` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:485` | `PaymentLinkService.getActivePaymentLink` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:504` | `PaymentLinkService.assignPaymentLink` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:520` | `PaymentLinkService.getLocations` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:702` | `PaymentLinkService.createPosLinkAdmin` | | 507 | 18 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:358` | `KycService.reviewRecommendationStep` | | 504 | 16 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:218` | `TransactionRequestService.getOrThrow` | | 499 | 14 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:169` | `BankTxReturnService.getPendingTx` | @@ -139,7 +138,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:326` | `RecommendationService.getRecommendationsByKycStepIdsOrUserDataId` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:336` | `RecommendationService.getAllRecommendationsByRecommenderId` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:344` | `RecommendationService.getRecommendationsByRecommendedId` | -| 473 | 18 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:652` | `SupportIssueService.getIssueEntities` | +| 473 | 18 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:625` | `SupportIssueService.getIssueEntities` | | 472 | 14 | find | `BuyFiat` | `subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts:122` | `BuyFiatConsumer.processForward` | | 472 | 19 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:15` | `DepositRouteService.get` | | 472 | 19 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:25` | `DepositRouteService.getById` | @@ -151,7 +150,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 451 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:42` | `BuyFiatNotificationService.paymentCompleted` | | 451 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:290` | `BuyFiatNotificationService.chargebackUnconfirmed` | | 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:457` | `SupportIssueService.closeIssue` | -| 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:716` | `SupportIssueService.getUserIssues` | +| 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:689` | `SupportIssueService.getUserIssues` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/accounting/services/ledger-cutover.service.ts:475` | `LedgerCutoverService.openBuyCryptoReceived` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/accounting/services/ledger-cutover.service.ts:536` | `LedgerCutoverService.openBuyCryptoOwed` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:25` | `BuyCryptoRegistrationService.syncReturnTxId` | @@ -167,25 +166,25 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 434 | 15 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request.service.ts:82` | `LimitRequestService.getUserLimitRequests` | | 433 | 12 | find | `BankTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:106` | `BankTxConsumer.processForward` | | 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:478` | `SupportIssueService.closeIssue` | -| 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:702` | `SupportIssueService.getIssueMessages` | +| 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:675` | `SupportIssueService.getIssueMessages` | | 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:67` | `TransactionRequestService.txRequestWaitingExpiryCheck` | | 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:78` | `TransactionRequestService.deleteOldTxRequests` | | 422 | 12 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:778` | `BuyCryptoService.resetAmlCheck` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:214` | `SupportEscalationService.checkEscalations` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:484` | `SupportIssueService.updateIssue` | -| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:692` | `SupportIssueService.getIssueMessages` | -| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:729` | `SupportIssueService.getIssueUserDataId` | +| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:665` | `SupportIssueService.getIssueMessages` | +| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:702` | `SupportIssueService.getIssueUserDataId` | | 419 | 14 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1186` | `BuyCryptoService.getTransactions` | | 418 | 18 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:80` | `CustodyJobService.executeStep` | | 418 | 11 | find | `User` | `subdomains/generic/user/models/user/user-job.service.ts:19` | `UserJobService.approveUser` | | 415 | 16 | find | `Buy` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1024` | `BuyCryptoService.getBuy` | -| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:396` | `UserService.updateUserV1` | -| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:450` | `UserService.updateUserData` | +| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:393` | `UserService.updateUserV1` | +| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:447` | `UserService.updateUserData` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:69` | `BuyCryptoRegistrationService.filterBuyCryptoPayIns` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:260` | `SwapService.updateSwap` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:268` | `SwapService.confirmSwap` | | 392 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:639` | `BuyFiatService.getPendingTransactions` | -| 386 | 11 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:442` | `UserService.updateUserName` | +| 386 | 11 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:439` | `UserService.updateUserName` | | 385 | 15 | find | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:139` | `BuyFiatRegistrationService.createBuyFiatsAndAckPayIns` | | 385 | 14 | find | `KycStep` | `subdomains/generic/kyc/services/kyc-admin.service.ts:45` | `KycAdminService.updateKycStep` | | 384 | 14 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:223` | `BuyService.getByBankUsage` | @@ -215,12 +214,11 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:634` | `BankTxService.getBankTxsByVirtualIban` | | 354 | 13 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:117` | `SellService.getSellsByIban` | | 351 | 11 | find | `UserData` | `subdomains/generic/user/models/kyc/kyc.service.ts:126` | `KycService.getUserByKycCode` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:266` | `UserService.getUserDtoV2` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:409` | `UserService.updateUser` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:421` | `UserService.updateUserMail` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:431` | `UserService.verifyMail` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:487` | `UserService.updateAddress` | -| 344 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:502` | `UserService.deactivateUser` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:406` | `UserService.updateUser` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:418` | `UserService.updateUserMail` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:428` | `UserService.verifyMail` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:484` | `UserService.updateAddress` | +| 344 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:499` | `UserService.deactivateUser` | | 343 | 10 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts:87` | `FiatPayInSyncService.createCheckoutTx` | | 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1074` | `UserDataService.updateApiFilter` | | 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1084` | `UserDataService.checkApiKey` | @@ -247,10 +245,10 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:204` | `UserService.getRefUser` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:209` | `UserService.getRefUsersByRefs` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:215` | `UserService.getUsersByUsedRefs` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:464` | `UserService.updateUserAdmin` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:569` | `UserService.updateUserDataVolume` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:736` | `UserService.checkApiKey` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:745` | `UserService.updateApiFilter` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:461` | `UserService.updateUserAdmin` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:566` | `UserService.updateUserDataVolume` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:733` | `UserService.checkApiKey` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:742` | `UserService.updateApiFilter` | | 301 | 6 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:89` | `BankTxRepeatService.getAllUserRepeats` | | 299 | 12 | find | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:65` | `TradingOrderService.startNewOrders` | | 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:113` | `CustodyJobService.onStepComplete` | @@ -296,7 +294,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:285` | `UserDataService.getUsersByPhone` | | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:289` | `UserDataService.getUserDatasWithKycFile` | | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:606` | `UserDataService.assignNextKycFileId` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1055` | `UserDataService.createApiKey` | | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1128` | `UserDataService.loadRelationsAndVerify` | | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1139` | `UserDataService.loadRelationsAndVerify` | | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1146` | `UserDataService.loadRelationsAndVerify` | @@ -439,7 +436,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/adapters/actions/liquidity-pipeline.adapter.ts:86` | `LiquidityPipelineAdapter.checkBuyCompletion` | | 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:110` | `LiquidityManagementPipelineService.getProcessingPipelines` | | 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:116` | `LiquidityManagementPipelineService.getStoppedPipelines` | -| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:145` | `LiquidityManagementPipelineService.getPipelineStatus` | | 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:155` | `LiquidityManagementPipelineService.startNewPipelines` | | 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:196` | `LiquidityManagementService.findRunningPipeline` | | 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:257` | `TransactionRequestService.getTransactionRequestByUid` | @@ -453,8 +449,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 99 | 0 | query-builder (nur-alias) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:313` | `UserDataService.getUserDataByKey` | | 99 | 0 | query-builder (ohne-select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1766` | `UserDataService.countByDateRange` | | 98 | 2 | find | `User` | `subdomains/generic/user/models/user-data/user-data.service.ts:1033` | `UserDataService.customIdentMethod` | -| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:277` | `UserService.getRefDtoV2` | -| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:285` | `UserService.updateRef` | +| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:274` | `UserService.getRefDtoV2` | +| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:282` | `UserService.updateRef` | | 98 | 2 | find | `User` | `subdomains/generic/user/services/webhook/webhook.service.ts:107` | `WebhookService.sendWebhooks` | | 98 | 2 | find | `User` | `subdomains/generic/user/services/webhook/webhook.service.ts:176` | `WebhookService.getUsers` | | 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:146` | `TransactionService.getTransactionById` | @@ -482,7 +478,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:110` | `LiquidityManagementRuleService.reactivateRules` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:147` | `LiquidityManagementRuleService.findExistingRuleOnCreation` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:109` | `LiquidityManagementService.findRuleByAssetOrThrow` | -| 81 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:212` | `SupportIssueRepository.findIssueData` | +| 81 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:334` | `SupportIssueRepository.findIssueData` | | 78 | 1 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:97` | `UserService.getUserByAddress` | | 78 | 3 | find | `Mros` | `subdomains/supporting/mros/mros.service.ts:32` | `MrosService.update` | | 77 | 0 | query-builder (nur-alias) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:712` | `BuyCryptoService.getBuyCryptoByKeys` | @@ -490,6 +486,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 71 | 0 | query-builder (nur-alias) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:340` | `BuyFiatService.getBuyFiatByKey` | | 71 | 1 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:68` | `RecallService.getByBankTxIds` | | 68 | 4 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:204` | `SwapService.getById` | +| 66 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:249` | `UserDataRepository.getUserV2` | | 65 | 2 | find | `Fee` | `subdomains/supporting/payment/services/fee.service.ts:115` | `FeeService.createFee` | | 61 | 0 | find | `BankTx` | `subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts:386` | `ExchangeTxConsumer.hasBankRouteMatch` | | 61 | 0 | find | `BankTx` | `subdomains/core/accounting/services/ledger-cutover.service.ts:710` | `LedgerCutoverService.openUnattributed` | @@ -519,7 +516,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 56 | 3 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:258` | `BuyService.updateBuy` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:89` | `LedgerQueryService.getAccounts` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:157` | `LedgerQueryService.getReconStatus` | -| 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:619` | `LedgerQueryService.unverifiedAccountIds` | +| 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:613` | `LedgerQueryService.unverifiedAccountIds` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:146` | `LedgerReconciliationService.reconcileAssets` | | 53 | 1 | find | `PriceRule` | `subdomains/supporting/pricing/services/pricing.service.ts:163` | `PricingService.updatePrices` | | 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:58` | `PaymentLinkPaymentService.processExpiredPayments` | @@ -544,7 +541,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 45 | 0 | query-builder (ohne-select) | `User` | `subdomains/generic/user/models/user/user.service.ts:177` | `UserService.getOpenRefCreditUser` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:41` | `FaucetRequestService.checkFaucetRequests` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:93` | `FaucetRequestService.resetFaucet` | -| 41 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:99` | `UserDataRepository.getProfile` | +| 41 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:261` | `UserDataRepository.getProfile` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:38` | `LiquidityManagementBalanceService.getAllLiqBalancesForAssets` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:68` | `LiquidityManagementBalanceService.refreshBankBalance` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:82` | `LiquidityManagementBalanceService.getBalances` | @@ -598,12 +595,13 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 30 | 2 | find | `LedgerLeg` | `subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts:363` | `ExchangeTxConsumer.matchRaiffeisenSweep` | | 30 | 0 | find | `ExchangeTx` | `subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts:417` | `ExchangeTxConsumer.buildFillIndexMap` | | 30 | 2 | find | `LedgerTx` | `subdomains/core/accounting/services/consumers/payout-order.consumer.ts:321` | `PayoutOrderConsumer.cutoverOwedOpeningChf` | -| 30 | 2 | find | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:349` | `LedgerQueryService.counterAccountByTxId` | +| 30 | 2 | find | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:343` | `LedgerQueryService.counterAccountByTxId` | | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:286` | `DashboardReconciliationService.getBlockchainFlows` | | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:387` | `DashboardReconciliationService.getExchangeFlows` | | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:544` | `DashboardReconciliationService.getExchangeWithdrawalsForBlockchain` | | 27 | 2 | find | `LiquidityManagementAction` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:257` | `LiquidityManagementRuleService.findExistingAction` | -| 26 | 0 | query-builder (alias only) | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:22` | `BankAccountService.getBankAccountByKey` | +| 26 | 0 | query-builder (feldliste) | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:86` | `PaymentLinkRepository.findForPosLink` | +| 26 | 0 | query-builder (nur-alias) | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:22` | `BankAccountService.getBankAccountByKey` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:40` | `BankAccountService.checkFailedBankAccounts` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:48` | `BankAccountService.reloadErrorBankAccounts` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:56` | `BankAccountService.reloadUncheckedBankAccounts` | @@ -642,7 +640,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:43` | `FiatService.getFiatByCountry` | | 16 | 0 | query-builder (projektion-mit-vollem-join) | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:148` | `PaymentLinkPaymentService.getMostRecentPayments` | | 16 | 0 | query-builder (nur-alias) | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1294` | `VirtualIbanService.getVirtualIbanByKey` | -| 16 | 0 | query-builder (ohne-select) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:549` | `SupportIssueService.getSupportIssueList` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:20` | `WalletAppService.getAllBlockchainWalletApps` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:24` | `WalletAppService.getRecommendedWalletApps` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:28` | `WalletAppService.getWalletAppById` | @@ -682,8 +679,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 11 | 0 | find | `OlkyRecipient` | `integration/bank/services/olkypay.service.ts:104` | `OlkypayService.getOrCreateRecipient` | | 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:212` | `LedgerMarkToMarketService.alreadyBooked` | | 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:125` | `LedgerQueryService.getAccountDetail` | -| 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:184` | `LedgerQueryService.getSuspense` | -| 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:466` | `LedgerQueryService.marginBuckets` | +| 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:460` | `LedgerQueryService.marginBuckets` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:119` | `LogRepository.getFinancialLogAt` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:132` | `LogRepository.getLatestFinancialLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:142` | `LogRepository.getLatestValidFinancialLogs` | @@ -694,10 +690,12 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:131` | `LogService.getLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:135` | `LogService.maxEntity` | | 11 | 0 | query-builder (ohne-select) | `Log` | `subdomains/supporting/log/log.service.ts:191` | `LogService.getBankLog` | -| 11 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:222` | `SupportIssueRepository.findIssuesForAccount` | -| 11 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:240` | `SupportIssueRepository.findIssueBy` | +| 11 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:344` | `SupportIssueRepository.findIssuesForAccount` | +| 11 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:362` | `SupportIssueRepository.findIssueBy` | +| 10 | 0 | query-builder (feldliste) | `LedgerLeg` | `subdomains/core/accounting/repositories/ledger-leg.repository.ts:52` | `LedgerLegRepository.findSuspenseLegs` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:61` | `AccountMergeService.sendMergeRequest` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:162` | `AccountMergeService.pendingMergeRequest` | +| 10 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:268` | `SupportIssueRepository.findIssueList` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:57` | `SupportNoteService.search` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:76` | `SupportNoteService.search` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:152` | `SupportNoteService.delete` | @@ -749,10 +747,10 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 5 | 0 | find | `Sanction` | `subdomains/core/aml/services/sanction.service.ts:54` | `SanctionService.syncList` | | 5 | 0 | query-builder (spaltenliste) | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:84` | `SupportNoteService.listUsers` | | 5 | 0 | query-builder (feldliste) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:52` | `SupportMessageRepository.findThread` | -| 4 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:540` | `LedgerQueryService.cumulativeEquityByDay` | +| 4 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:534` | `LedgerQueryService.cumulativeEquityByDay` | | 4 | 0 | find | `SystemStateSnapshot` | `subdomains/core/monitoring/monitoring.service.ts:47` | `MonitoringService.loadState` | -| 4 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:614` | `SupportIssueService.getMessageStats` | -| 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:284` | `LedgerQueryService.balancesByAccount` | +| 4 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:587` | `SupportIssueService.getMessageStats` | +| 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:278` | `LedgerQueryService.balancesByAccount` | | 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:271` | `LedgerReconciliationService.checkTransitAge` | | 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:347` | `LedgerReconciliationService.openResidualSince` | | 3 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1051` | `BuyCryptoService.updateBuyVolume` | @@ -766,13 +764,15 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 3 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:678` | `BuyFiatService.updateSellVolume` | | 3 | 0 | query-builder (spaltenliste) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:261` | `SellService.getUserVolume` | | 3 | 0 | query-builder (spaltenliste) | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1981` | `KycService.getPendingReviewSummary` | +| 3 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:238` | `UserDataRepository.getForApiKey` | | 3 | 0 | query-builder (spaltenliste) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:461` | `BankTxService.getBankTxFee` | | 3 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:310` | `SupportEscalationService.getLastMessages` | | 3 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:245` | `SupportIssueService.getSupportIssueStatistics` | -| 2 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:326` | `LedgerQueryService.nativeBalanceByAccount` | +| 2 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:320` | `LedgerQueryService.nativeBalanceByAccount` | | 2 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:400` | `LedgerReconciliationService.checkSuspense` | | 2 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1122` | `BuyCryptoService.getRefVolume` | | 2 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1134` | `BuyCryptoService.getPartnerFeeRefVolume` | +| 2 | 0 | query-builder (feldliste) | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts:43` | `LiquidityManagementPipelineRepository.findForStatus` | | 2 | 0 | query-builder (spaltenliste) | `—` | `subdomains/core/monitoring/observers/payment.observer.ts:72` | `PaymentObserver.getPayment` | | 2 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:721` | `BuyFiatService.getRefVolume` | | 2 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:733` | `BuyFiatService.getPartnerFeeRefVolume` | @@ -798,8 +798,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 1 | 0 | query-builder (spaltenliste) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:958` | `LedgerCutoverService.maxSettledId` | | 1 | 0 | query-builder (spaltenliste) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:1002` | `LedgerCutoverService.idsUpToBoundary` | | 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:106` | `LedgerMarkToMarketService.selectCandidates` | -| 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:298` | `LedgerQueryService.nativeBalanceBefore` | -| 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:310` | `LedgerQueryService.nativeBalanceInPeriod` | +| 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:292` | `LedgerQueryService.nativeBalanceBefore` | +| 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:304` | `LedgerQueryService.nativeBalanceInPeriod` | | 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:487` | `LedgerReconciliationService.journalEquity` | | 1 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:767` | `BuyCryptoService.updateRefVolumes` | | 1 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:883` | `BuyCryptoService.getUserVolumeForType` | @@ -819,11 +819,11 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 1 | 0 | query-builder (spaltenliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1774` | `UserDataService.getMaxKycFileIdByDateRange` | | 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:140` | `UserService.getAllLinkedUsers` | | 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:195` | `UserService.getOpenRefCreditEur` | -| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:579` | `UserService.getUserVolumes` | -| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:589` | `UserService.getUserVolumes` | -| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:651` | `UserService.getRefInfo` | -| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:664` | `UserService.getRefInfo` | -| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:723` | `UserService.getTotalRefRewards` | +| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:576` | `UserService.getUserVolumes` | +| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:586` | `UserService.getUserVolumes` | +| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:648` | `UserService.getRefInfo` | +| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:661` | `UserService.getRefInfo` | +| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:720` | `UserService.getTotalRefRewards` | | 1 | 0 | query-builder (spaltenliste) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:189` | `DepositService.getNextDepositIndex` | | 1 | 0 | query-builder (spaltenliste) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:449` | `BankTxService.getBankTxFee` | | 1 | 0 | query-builder (spaltenliste) | `Log` | `subdomains/supporting/log/log.repository.ts:97` | `LogRepository.cleanup` | @@ -1042,9 +1042,9 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1725` | `UserDataService.updateBankTxTime` | | — | — | find | `—` | `subdomains/generic/user/models/user/dto/user-dto.mapper.ts:27` | — | | — | — | find | `—` | `subdomains/generic/user/models/user/user.repository.ts:69` | `UserRepository.getNextRef` | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:339` | `UserService.createUser` | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:493` | `UserService.updateAddress` | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:509` | `UserService.deactivateUser` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:336` | `UserService.createUser` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:490` | `UserService.updateAddress` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:506` | `UserService.deactivateUser` | | — | — | find | `—` | `subdomains/supporting/balance/services/balance-pdf.service.ts:105` | `BalancePdfService.getBalancesForAddress` | | — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts:323` | `BankTx.bankDataName` | | — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts:335` | `BankTx.getSenderAccount` | @@ -1151,5 +1151,5 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:2927` | `RealUnitService.applyRegistrationConfirmation` | | — | — | find | `—` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:166` | `SupportEscalationService.bindGroupChat` | | — | — | find | `—` | `subdomains/supporting/support-issue/services/support-issue.service.ts:92` | `SupportIssueService.getSupportIssueClerkForAccount` | -| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:707` | `SupportIssueService.getIssueFile` | -| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:722` | `SupportIssueService.getUserIssues` | +| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:680` | `SupportIssueService.getIssueFile` | +| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:695` | `SupportIssueService.getUserIssues` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 9711ebdaa6..a0a999a659 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -30,8 +30,8 @@ This service loads far more data than it returns. Measured against the real enti render a PDF containing a handful of values. That query sat exactly on Postgres' limit of 1,664 columns per statement, which is why a single new column added elsewhere (`settlementEventId` on `transaction_request`) broke every invoice and receipt in production until it was fixed. -- Of the 534 endpoints, **407 reach at least one load site that fetches whole rows**; 98 read - nothing at all, and **27 read only the fields they return**. The widest query a fetching endpoint +- Of the 534 endpoints, **398 reach at least one load site that fetches whole rows**; 98 read + nothing at all, and **36 read only the fields they return**. The widest query a fetching endpoint can trigger is 308 columns at the median, and 19 of them exceed 1,000. The column limit was the symptom, not the cause. Loading a thousand columns to return one is @@ -47,15 +47,15 @@ and one on `LimitRequest` **434 across 15** — before any `relations` option is decision what to load therefore lives in the entity definition, not at the call site, and no call site can see what it triggers. -**No read model.** Of the 1,105 load sites in this repository, **104** name the columns they need: -99 query builders and the five raw statements. The other 1,001 request whole rows — 961 through the -`find` family, and of the 139 query builders, 17 pass the root alias to `.select(...)`, which reads -like a projection but is not, while 23 pass no select at all. The same entities serve persistence, -business logic and pure output paths such as invoices, receipts, history and exports — which need -fields, not objects. +**No read model.** Of the 1,105 load sites in this repository, **110** name the columns they need: +105 query builders and the five raw statements. The other 995 request whole rows — 957 through the +`find` family, and of the 143 query builders, 17 pass the root alias to `.select(...)`, which reads +like a projection but is not, 20 pass no select at all, and one projects its root but pulls a +relation in whole. The same entities serve persistence, business logic and pure output paths such +as invoices, receipts, history and exports — which need fields, not objects. -Read the first number carefully, because an earlier revision of this document got it wrong. The 96 -query builders that do name columns are almost entirely counts, maxima and id lookups — +Read the first number carefully, because an earlier revision of this document got it wrong. The 87 +query builders that name columns one at a time are almost entirely counts, maxima and id lookups — `.select('userData.id', 'id')`, `.select('COUNT(*)', 'count')` and the like — and they select **one column at the median**. They are projections, and they were miscounted as full loads because the classification recognised only the array form `.select([...])` and read every string argument as the @@ -65,6 +65,20 @@ of the `whole rows` group. What it does not do is change the picture: a `COUNT(*)` that was always narrow is not a read path that was converted, and the response payloads — history, profile, invoices, exports — are still served by `find`. +Two further shapes were counted as full loads for the same kind of reason, and both are decided by +something the select list does not show: + +- **The terminal call can discard the select list.** `getCount()` and `getExists()` replace it with + `COUNT(…)` and `SELECT 1`, so a chain ending in either materialises no row whatever precedes it. + Three load sites do this, and `GET /support/kycFileStats` was listed as fetching 99 columns + because of one of them. +- **The select argument can be a variable.** `.select(bucketExpr, 'bucket')` names an expression as + surely as a literal does; whether it narrows depends on what the variable holds, which has to be + resolved in the enclosing method. Four load sites do this — three of them in the two + caller-defined `/gs/db` endpoints, the fourth an aggregate that was reported as a full load. + +Neither changes the picture either, and both are now part of the classification. + ## Vocabulary | Term | Meaning here | @@ -128,30 +142,45 @@ The first four are pre-filters; the fifth decides. The pre-filters above are stated as criteria; this is what they select when applied to the inventory. Every step is mechanical except the last two, which were read in the source. -| | endpoints | -| --- | ---: | -| fetch whole rows | 417 | -| … every load site they reach can be narrowed at all | 77 | -| … no write to the loaded entity anywhere in the call chain | 44 | -| … the response is not an entity, a stream, or `void` | 30 | -| … and no DTO field passes an entity through | **28** | +| | before | now | +| --- | ---: | ---: | +| fetch whole rows | 415 | 398 | +| … every load site they reach can be narrowed at all | 37 | 29 | +| … no write to the loaded entity, and the response is a structured value | 16 | 8 | +| … and no DTO field passes an entity through | **9** | **1** | -The step from 417 to 77 is the one that decides the size of this work, and it has a single cause: -at 343 of the 555 load sites involved, **the loaded entity leaves the loading method**. +The step that decides the size of this work is the first one, and it has a single cause: at 321 of +the 484 load sites involved, **the loaded entity leaves the loading method**. `UserDataService.getUserData` returns `Promise` to 113 different endpoints. What fields are needed is decided by each caller, not at the load site, so a projection there would be guessed rather than derived — and the union over 113 callers is the whole entity anyway. Splitting those hubs into per-caller reads is a separate piece of work with a different shape. It is not assumed here, and the numbers above do not depend on whether it happens. -The last step removes six endpoints whose DTO has a field typed as an entity — `currency: Fiat`, +The last step removes seven endpoints whose DTO has a field typed as an entity — `currency: Fiat`, `targetAsset: Asset`. The response then contains every column of that entity, so a projection would have to list them all and would save nothing. Narrowing those means changing the contract, which is a different decision. -Ten of the 28 are converted. The remaining 18 are the ones marked `not yet` with a `whole rows` -access in [endpoints.md](endpoints.md); the widest is `GET /user` at 351 columns, and most of the -rest are support and dashboard reads between 7 and 99. +**One endpoint remains, and it is excluded by the second criterion rather than by the first four.** +`POST /support/issue/:id/message` hands the message it creates — carrying the issue and the account +behind it — to the notification service. What that code reads is not determinable at the load site, +so a field list here would be guessed. + +The filters are deliberately conservative and reject endpoints that can in fact be converted: an +endpoint fails the first step if *any* load site it reaches leaks, including one on a branch that +has nothing to do with the response. Nine of the seventeen conversions recorded in +[endpoints.md](endpoints.md) were found that way — by reading the endpoint after the filter had +rejected it. The counts above are therefore a lower bound on what is possible, not a ceiling. + +Two of those seventeen are conversions the first criterion excluded as written. It rejects an +endpoint that writes the entity it loaded, and the hazard it names is saving a partially loaded row +back — the unselected columns are undefined on the entity and would be written as null. +`PUT /paymentLink/:id/pos` and `POST /user/apiKey/CT` write through `update(id, …)`, which sends +only the columns named in the call, so a projected read cannot blank anything. What the criterion +does have to keep excluding is a value the write *derives* from what was read: the point-of-sale +write merges into the existing configuration, so `config` is part of that projection and is +asserted directly. ## The risk this must guard against @@ -187,11 +216,11 @@ per endpoint as `0/4` through `4/4`; only `4/4` is done. To any load site that carries an explicit field list — that is where a forgotten field silently yields an empty value. -A hundred and four sites carry a field list. The table below covers the six that were known when this +A hundred and ten sites carry a field list. The table below covers the six that were known when this document was written — one query builder and five raw statements — and none of them was converted, so it is unchanged. Another 87 are the query builders that name columns one at a time; they are not covered by these levels either, which is what their endpoints' `0/4` in -[endpoints.md](endpoints.md) records. The remaining 11 belong to the endpoints converted so far and +[endpoints.md](endpoints.md) records. The remaining 17 belong to the endpoints converted so far and are covered on all four. Sites a conversion adds are recorded there too, where only `4/4` counts as done. diff --git a/src/shared/models/__tests__/read-projection.spec.ts b/src/shared/models/__tests__/read-projection.spec.ts index f6904b69d9..5c0df57139 100644 --- a/src/shared/models/__tests__/read-projection.spec.ts +++ b/src/shared/models/__tests__/read-projection.spec.ts @@ -6,28 +6,47 @@ import { BUY_CRYPTO_ROUTE_HISTORY_PROJECTION, } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; import { CUSTODY_ORDER_HISTORY_PROJECTION } from 'src/subdomains/core/custody/repositories/custody-order.repository'; +import { PIPELINE_STATUS_PROJECTION } from 'src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository'; +import { POS_LINK_PROJECTION } from 'src/subdomains/core/payment-link/repositories/payment-link.repository'; +import { SUSPENSE_LEG_PROJECTION } from 'src/subdomains/core/accounting/repositories/ledger-leg.repository'; import { BUY_FIAT_HISTORY_PROJECTION } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; -import { USER_PROFILE_PROJECTION } from 'src/subdomains/generic/user/models/user-data/user-data.repository'; +import { + API_KEY_PROJECTION, + USER_PROFILE_PROJECTION, + USER_V2_PROJECTION, +} from 'src/subdomains/generic/user/models/user-data/user-data.repository'; import { USER_KYC_FILES_PROJECTION } from 'src/subdomains/generic/user/models/user/user.repository'; import { WALLET_KYC_DATA_PROJECTION } from 'src/subdomains/generic/user/models/wallet/wallet.repository'; import { SUPPORT_ISSUE_DATA_PROJECTION, + SUPPORT_ISSUE_LIST_PROJECTION, SUPPORT_ISSUE_PROJECTION, } from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; import { SelectQueryBuilder } from 'typeorm'; -/** Every endpoint whose `Max cols` in the inventory is the size of a projection. */ -const DOCUMENTED: [string, string, ReadProjection][] = [ - ['GET', '/user/profile', USER_PROFILE_PROJECTION], - ['GET', '/buy/:id/history', BUY_CRYPTO_BUY_HISTORY_PROJECTION], - ['GET', '/swap/:id/history', BUY_CRYPTO_ROUTE_HISTORY_PROJECTION], - ['GET', '/sell/:id/history', BUY_FIAT_HISTORY_PROJECTION], - ['GET', '/support/issue/:id/data', SUPPORT_ISSUE_DATA_PROJECTION], - ['GET', '/support/issue', SUPPORT_ISSUE_PROJECTION], - ['GET', '/support/issue/:id', SUPPORT_ISSUE_PROJECTION], - ['GET', '/kyc/users', WALLET_KYC_DATA_PROJECTION], - ['GET', '/kyc/:id/documents', USER_KYC_FILES_PROJECTION], - ['GET', '/custody/order', CUSTODY_ORDER_HISTORY_PROJECTION], +/** + * Every endpoint whose `Max cols` in the inventory is the size of a projection. + * + * The version is part of the key: `/user` exists twice, and only the v2 handler is projected. + */ +const DOCUMENTED: [string, string, string, ReadProjection][] = [ + ['GET', '2', '/user/profile', USER_PROFILE_PROJECTION], + ['GET', '2', '/user', USER_V2_PROJECTION], + ['POST', '1', '/user/apiKey/CT', API_KEY_PROJECTION], + ['GET', '1', '/buy/:id/history', BUY_CRYPTO_BUY_HISTORY_PROJECTION], + ['GET', '1', '/swap/:id/history', BUY_CRYPTO_ROUTE_HISTORY_PROJECTION], + ['GET', '1', '/sell/:id/history', BUY_FIAT_HISTORY_PROJECTION], + ['GET', '1', '/support/issue/:id/data', SUPPORT_ISSUE_DATA_PROJECTION], + ['GET', '1', '/support/issue', SUPPORT_ISSUE_PROJECTION], + ['GET', '1', '/support/issue/:id', SUPPORT_ISSUE_PROJECTION], + ['GET', '1', '/support/issue/list', SUPPORT_ISSUE_LIST_PROJECTION], + ['GET', '1', '/realunit/support/list', SUPPORT_ISSUE_LIST_PROJECTION], + ['GET', '1', '/kyc/users', WALLET_KYC_DATA_PROJECTION], + ['GET', '1', '/kyc/:id/documents', USER_KYC_FILES_PROJECTION], + ['GET', '1', '/custody/order', CUSTODY_ORDER_HISTORY_PROJECTION], + ['GET', '1', '/dashboard/accounting/ledger/suspense', SUSPENSE_LEG_PROJECTION], + ['GET', '1', '/liquidityManagement/pipeline/:id/status', PIPELINE_STATUS_PROJECTION], + ['PUT', '1', '/paymentLink/:id/pos', POS_LINK_PROJECTION], ]; describe('ReadProjection', () => { @@ -76,8 +95,11 @@ describe('ReadProjection', () => { // the document and compares. const inventory = readFileSync(join(__dirname, '../../../../docs/endpoints.md'), 'utf8').split('\n'); - it.each(DOCUMENTED)('%s %s matches the projection', (verb, path, projection) => { - const row = inventory.find((line) => line.startsWith(`| ${verb} |`) && line.includes(`\`${path}\` |`)); + it.each(DOCUMENTED)('%s v%s %s matches the projection', (verb, version, path, projection) => { + const row = inventory.find((line) => { + const cells = line.split('|').map((cell) => cell.trim()); + return cells[1] === verb && cells[2] === version && cells[4] === `\`${path}\``; + }); expect(row).toBeDefined(); const cells = row.split('|').map((cell) => cell.trim()); From 69143c8822da6988915a8095da07556cc361d268 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:21:15 +0200 Subject: [PATCH 13/46] Drop an import the pipeline status conversion left behind `findForStatus` returns the row rather than the status, so the enum it used to be typed against is no longer referenced. --- .../repositories/liquidity-management-pipeline.repository.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts index b2093a571a..38da0a74ad 100644 --- a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts +++ b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts @@ -3,7 +3,6 @@ import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; import { LiquidityManagementPipeline } from '../entities/liquidity-management-pipeline.entity'; -import { LiquidityManagementPipelineStatus } from '../enums'; /** The single value `GET /liquidityManagement/pipeline/:id/status` answers with. */ export const PIPELINE_STATUS_RESPONSE_FIELDS = ['pipeline.status']; From fe5d3c0124b10b3e2cdcbfa8a55b0ef7c7e046d9 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:48:40 +0200 Subject: [PATCH 14/46] Close four gaps in the test levels All four are the same kind of gap: the assertion existed, but no fixture reached the branch it was supposed to prove. - support-issue-data declared a fixture for an account without an organization name and a wallet without a display name, and never used it. Both level-3 rows ran on the other side of those two fallback chains, so the chains could only be asserted as groups - which says nothing about `firstname`, `surname` or `wallet.name` individually. A third row runs on the personal fixture and asserts them one at a time; level 4 covers that shape too. - custody-order-history allowed the two amounts to be empty in the fixture where the fallback to the transaction request is the only source they have. That exception could equally have hidden a baseline that answered nothing at all. The exception is gone, and the run now asserts the two fallback values against the request before it starts dropping fields. - GET /user (v2) loads `userData.status` as a guard: the endpoint refuses a merged account on it, and the mapper never shows it, so every comparison in that spec stayed green without it. A merged-account fixture pins it, the same way the profile spec already did. - Moving the issue list into the repository dropped two assertions that were testing something real: that a numeric term binds the id branch, and that the id tie-break orders rows with an equal sort key. The database tests replaced the first with "the query does not throw" and sorted a single row. Both are now asserted where they matter - a fixture whose text fields carry no digit at all, so a match can only come from the id, and two rows sharing a sort key whose order is decided by the tie-break alone. --- .../custody-order-history.projection.spec.ts | 22 +++++++----- .../user/__tests__/user-v2.projection.spec.ts | 13 +++++++ .../support-issue-data.projection.spec.ts | 19 +++++++--- .../support-issue-list.projection.spec.ts | 35 +++++++++++++++++++ 4 files changed, 77 insertions(+), 12 deletions(-) diff --git a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts index ec8525ff84..bda25c9fb4 100644 --- a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts +++ b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts @@ -49,7 +49,7 @@ describeProjection('GET /custody/order — read-path projection', () => { type = CustodyOrderType.DEPOSIT, status = CustodyOrderStatus.COMPLETED, withAmounts = true, - ): Promise<{ order: CustodyOrder; userData: UserData }> { + ): Promise<{ order: CustodyOrder; userData: UserData; transactionRequest: TransactionRequest }> { const userData = await seedEntity(dataSource, UserData); const user = await seedEntity(dataSource, User, { values: { userData } }); const inputAsset = await seedEntity(dataSource, Asset); @@ -68,7 +68,7 @@ describeProjection('GET /custody/order — read-path projection', () => { ...(withAmounts ? {} : { inputAmount: null, outputAmount: null }), }, }); - return { order, userData }; + return { order, userData, transactionRequest }; } /** The response the endpoint produces, through the projected query. */ @@ -154,12 +154,18 @@ describeProjection('GET /custody/order — read-path projection', () => { ] as [CustodyOrderType, boolean, string[]][])( 'level 3 — for %s with own amounts=%s every field feeding the response is required', async (type, withAmounts, candidates) => { - const { userData } = await seedOrder(type, CustodyOrderStatus.CONFIRMED, withAmounts); - - await expectEveryFieldRequired( - candidates, - (omitted) => historyOf(userData.id, projectionFieldsWithout(CUSTODY_ORDER_HISTORY_PROJECTION.fields, omitted)), - withAmounts ? [] : ['[0].inputAmount', '[0].outputAmount'], + const { userData, transactionRequest } = await seedOrder(type, CustodyOrderStatus.CONFIRMED, withAmounts); + + // Assert the fallback actually fires before mutating: with an exception list covering the two + // amounts, a baseline that answers nothing at all would pass every reduced run as well. + if (!withAmounts) { + const [row] = await historyOf(userData.id); + expect(row.inputAmount).toEqual(transactionRequest.estimatedAmount); + expect(row.outputAmount).toEqual(transactionRequest.amount); + } + + await expectEveryFieldRequired(candidates, (omitted) => + historyOf(userData.id, projectionFieldsWithout(CUSTODY_ORDER_HISTORY_PROJECTION.fields, omitted)), ); }, 300000, diff --git a/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts b/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts index de41c1fcab..ba7cb94c0c 100644 --- a/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts +++ b/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts @@ -300,6 +300,19 @@ describeProjection('GET /user v2 — read-path projection', () => { ); }, 300000); + // --- the projection must not lose the guard the endpoint depends on --- // + + it('loads the status the endpoint refuses merged accounts on', async () => { + // `getUserDtoV2` reads this column before it maps anything and answers 401 for a merged account. + // The mapper never shows it, so every comparison in this file would stay green without it — this + // is the only assertion that keeps the guard in the projection. + const { userData } = await seedAccount({ status: UserDataStatus.MERGED }); + + const loaded = await userDataRepo.getUserV2(userData.id); + + expect(loaded.status).toEqual(UserDataStatus.MERGED); + }, 120000); + // --- LEVEL 4: consistency against a second source --- // it.each([ diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts index 6af51b9125..32b6f31972 100644 --- a/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts @@ -216,6 +216,10 @@ describeProjection('GET /support/issue/:id/data — read-path projection', () => (field) => field.startsWith('buyFiat') && field !== 'buyFiatOutputAsset.blockchain', ), ], + // The row above asserts the two chains as groups, which is all a fixture that fills the first + // alternative can do. On an account without an organization name and a wallet without a display + // name the fallbacks fire, and the columns behind them become individually required. + ['buyCrypto', 'personal', ['userData.firstname', 'userData.surname', 'transactionUserWallet.name']], ] as ['buyCrypto' | 'buyFiat', 'organization' | 'personal', (string | string[])[]][])( 'level 3 — for a %s transaction on a %s account every field feeding the response is required', async (side, named, candidates) => { @@ -235,10 +239,17 @@ describeProjection('GET /support/issue/:id/data — read-path projection', () => // --- LEVEL 4: consistency against a second source --- // - it.each(['buyCrypto', 'buyFiat', 'none'] as const)( - 'level 4 — for a %s issue the projected response equals the one from a full load', - async (side) => { - const issue = await seedIssue(side); + it.each([ + ['buyCrypto', 'organization'], + ['buyFiat', 'organization'], + ['none', 'organization'], + // Both fallback chains take their second alternative here, which is a different response shape + // than the three rows above produce. + ['buyCrypto', 'personal'], + ] as ['buyCrypto' | 'buyFiat' | 'none', 'organization' | 'personal'][])( + 'level 4 — for a %s issue on a %s account the projected response equals the one from a full load', + async (side, named) => { + const issue = await seedIssue(side, named); const projected = await issueDataOf(issue.id); // The unprojected load is the second source: the relation set the endpoint used before the diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts index fcbb145f9f..c0bde7cf8c 100644 --- a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts @@ -193,6 +193,41 @@ describeProjection('support issue list — read-path projection', () => { 120000, ); + it('level 2 — the id branch matches by id alone, and only for a term that fits int4', async () => { + const { issue } = await seedIssue({ name: 'no-digits-here', clerk: 'no-digits-either' }); + + // The id branch is what makes a pasted number find the issue: none of the text fields of this + // fixture contains a digit, so a match can only come from `issue.id = :termNId`. + expect((await listOf({ terms: [String(issue.id)] })).data.map((r) => r.uid)).toEqual([issue.uid]); + + // At int4 max the branch is still emitted; one above it the guard has to drop the comparison, + // or Postgres raises 22003 and the whole search fails rather than answering nothing. + expect((await listOf({ terms: ['2147483647'] })).data.map((r) => r.uid)).not.toContain(issue.uid); + expect((await listOf({ terms: ['41791234567'] })).data.map((r) => r.uid)).not.toContain(issue.uid); + }, 120000); + + it('level 2 — the id tie-break orders rows that share a sort key', async () => { + // Two issues in the same state, sorted by state: the primary key is equal for both, so the + // order is decided by the id tie-break alone. Without it the order is whatever storage returns, + // and a page boundary can drop or repeat a row. + const first = await seedIssue({ clerk: 'tie-break', state: SupportIssueInternalState.IN_PROGRESS }); + const second = await seedIssue({ clerk: 'tie-break', state: SupportIssueInternalState.IN_PROGRESS }); + + const ascending = await listOf({ + clerk: 'tie-break', + orderBy: SupportIssueListOrderBy.STATE, + orderDir: ListOrderDirection.ASC, + }); + const descending = await listOf({ + clerk: 'tie-break', + orderBy: SupportIssueListOrderBy.STATE, + orderDir: ListOrderDirection.DESC, + }); + + expect(ascending.data.map((row) => row.id)).toEqual([first.issue.id, second.issue.id]); + expect(descending.data.map((row) => row.id)).toEqual([second.issue.id, first.issue.id]); + }, 120000); + it('level 2 — the total counts every match, not just the page', async () => { const first = await seedIssue({ clerk: 'page-clerk' }); await seedIssue({ clerk: 'page-clerk' }); From c47e21e697148dccd5d0a0e73f1219df057911dd Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:01:39 +0200 Subject: [PATCH 15/46] Keep the ledger leg repository at the coverage the ratchet pins The file was pinned at 100% while it held nothing but a constructor. The suspense query moved into it, and the spec that exercises that query needs a database - the ratchet deliberately runs without one, so the new method arrived uncovered and the gate failed on all four metrics. The answer is not a weaker pin. It is the assertion the database spec cannot make: both relations are joined as INNER joins, and the ordering is applied to the joined transaction. Both are `nullable: false`, so inner and left select the same rows today - a row-level test cannot tell them apart, and the difference would surface only once that changed. The reduced-field-list case is asserted here too, because that is the call shape the mutation test uses. --- .../__tests__/ledger-leg.repository.spec.ts | 71 ++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts b/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts index 3446ec97ce..a467514ffb 100644 --- a/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts +++ b/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts @@ -1,8 +1,9 @@ import { createMock } from '@golevelup/ts-jest'; -import { EntityManager } from 'typeorm'; +import { EntityManager, SelectQueryBuilder } from 'typeorm'; import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { AccountType } from '../../entities/ledger-account.entity'; import { LedgerLeg } from '../../entities/ledger-leg.entity'; -import { LedgerLegRepository } from '../ledger-leg.repository'; +import { LedgerLegRepository, SUSPENSE_LEG_PROJECTION } from '../ledger-leg.repository'; describe('LedgerLegRepository', () => { let manager: EntityManager; @@ -24,4 +25,70 @@ describe('LedgerLegRepository', () => { it('manages the LedgerLeg entity', () => { expect(repository.target).toBe(LedgerLeg); }); + + /** + * The suspense query, asserted on the builder rather than on rows. + * + * What it selects is covered against a real database in + * `subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts`. Two properties are + * not observable there and are asserted here instead: that both relations are joined as INNER + * joins, and that the ordering is applied to the joined transaction. Both relations are + * `nullable: false`, so an inner and a left join select the same rows today — a database test + * cannot tell them apart, and the difference would surface only once that changed. + */ + describe('findSuspenseLegs', () => { + let calls: [string, unknown[]][]; + + const argsOf = (method: string): unknown[][] => calls.filter(([name]) => name === method).map(([, args]) => args); + + beforeEach(() => { + calls = []; + + const builder = new Proxy({} as SelectQueryBuilder, { + get: (_target, property: string) => { + if (property === 'getMany') return () => Promise.resolve([]); + return (...args: unknown[]) => { + calls.push([property, args]); + return builder; + }; + }, + }); + + jest.spyOn(repository, 'createQueryBuilder').mockReturnValue(builder); + }); + + it('joins the transaction and the account as inner joins', async () => { + await repository.findSuspenseLegs(); + + expect(argsOf('innerJoin')).toEqual([ + ['leg.tx', 'tx'], + ['leg.account', 'account'], + ]); + // A left join here would widen the result set the moment either relation became nullable. + expect(argsOf('leftJoin')).toEqual([]); + }); + + it('selects the projected fields together with their guards', async () => { + await repository.findSuspenseLegs(); + + expect(argsOf('select')).toEqual([[[...SUSPENSE_LEG_PROJECTION.fields, ...SUSPENSE_LEG_PROJECTION.guards]]]); + }); + + it('filters on suspense accounts and orders by the booking date of the transaction', async () => { + await repository.findSuspenseLegs(); + + expect(argsOf('where')).toEqual([['account.type = :type', { type: AccountType.SUSPENSE }]]); + expect(argsOf('orderBy')).toEqual([['tx.bookingDate', 'ASC']]); + }); + + it('passes a reduced field list through, keeping the guards', async () => { + // This is what the mutation test does: drop one response field, re-run the same query with + // the rest of the projection intact. + const reduced = SUSPENSE_LEG_PROJECTION.fields.filter((field) => field !== 'leg.amountChf'); + + await repository.findSuspenseLegs(reduced); + + expect(argsOf('select')).toEqual([[[...reduced, ...SUSPENSE_LEG_PROJECTION.guards]]]); + }); + }); }); From bf91baf0adb629591e182694b72167b4adb36c9e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:28:33 +0200 Subject: [PATCH 16/46] Correct four guard classifications and tighten the alias scan Four fields were declared as guards - loaded but never shown - and each of them does reach the response. Level 3 never drops a guard, so all four sat outside the mutation test entirely: userData.id -> `mapUser` returns it as `accountId` userData.id -> the API key is derived from it tx.id -> `mapSuspenseLeg` returns it as `txId` issueTransaction.id -> `mapTransaction` decides transaction-or-null on it All four move into their response field lists, where the mutation run covers them. What stays a guard is what genuinely never shows and never feeds a value: a primary key that only makes the ORM materialise a joined row, and the two ids the point-of-sale updates are scoped by - the latter now asserted directly, because no level would notice their absence. The API key spec had a second problem behind the first. Its key generation mixes in the current time, so two responses a millisecond apart differ; comparing whole responses across runs reported every field as required, which is true of the clock and evidence about nothing. The fixture keeps the dependency that matters, the account id, and leaves the timestamp out. The alias scan took every ReadProjection alias in a file as valid for every query in it. A repository usually declares several, and their aliases are not interchangeable - a query applying one has joined only that one's relations, so a reference to another projection's alias passed a scan that exists to catch exactly that. It is now scoped to the projection the chain applies, with three cases pinning the behaviour. The apply call sits before the chain the scanner extracts, which is why the preceding text is passed along. The message aggregate behind the list rows moves into the message repository, next to the thread query. It was private to the service, so the list specs could only declare its three response fields optional - which is not 4/4 on an endpoint whose answer it is. All four levels now run over both queries. Also: `+value` instead of `parseInt` per CONTRIBUTING, explicit return types on the spec helpers, and one double cast replaced by a typed candidate list. --- .../__tests__/query-builder-alias.spec.ts | 68 +++++++++++++-- .../ledger-suspense.projection.spec.ts | 5 +- .../repositories/ledger-leg.repository.ts | 13 +-- .../repositories/buy-crypto.repository.ts | 5 +- .../custody-order-history.projection.spec.ts | 6 +- .../custody/services/custody-order.service.ts | 3 +- ...iquidity-management-pipeline.repository.ts | 3 +- .../repositories/payment-link.repository.ts | 3 +- .../process/buy-fiat.repository.ts | 3 +- .../__tests__/api-key.projection.spec.ts | 22 +++-- .../models/user-data/user-data.repository.ts | 27 +++--- .../__tests__/user-profile.projection.spec.ts | 3 +- .../user/__tests__/user-v2.projection.spec.ts | 3 +- .../user/models/user/user.repository.ts | 3 +- .../user/models/wallet/wallet.repository.ts | 3 +- .../support-issue-data.projection.spec.ts | 7 +- .../support-issue-list.projection.spec.ts | 82 ++++++++++++------- .../repositories/support-issue.repository.ts | 22 ++--- .../support-message.repository.ts | 51 +++++++++++- .../services/support-issue.service.ts | 48 +---------- 20 files changed, 252 insertions(+), 128 deletions(-) diff --git a/src/shared/utils/__tests__/query-builder-alias.spec.ts b/src/shared/utils/__tests__/query-builder-alias.spec.ts index 130b89017d..aed6945d17 100644 --- a/src/shared/utils/__tests__/query-builder-alias.spec.ts +++ b/src/shared/utils/__tests__/query-builder-alias.spec.ts @@ -140,15 +140,26 @@ describe('Query Builder Alias Enforcement', () => { * `where('joinedAlias.id = :id')` looks like a bare column reference — the opposite of what this * test is for. */ - const extractProjectionAliases = (fileContent: string): Set => { + const extractProjectionAliases = (fileContent: string, queryChain: string): Set => { const aliases = new Set(); + + // Only the projection this chain applies. Taking every projection in the file would let a query + // reference an alias declared by a different one - the file often holds several - and the check + // would pass on a query that never joined it. + const applied = new Set(); + const applyPattern = /\b([A-Z][A-Z0-9_]*)\s*\.\s*apply\s*\(/g; + let applyMatch; + while ((applyMatch = applyPattern.exec(queryChain)) !== null) applied.add(applyMatch[1]); + if (!applied.size) return aliases; + // The root alias, then every `['relation.path', 'alias']` pair up to the end of the declaration. // Reading the join array with a lazy match would stop at the first inner `]` and pick up only // one of the joins. - const projectionPattern = /new ReadProjection<[^>]*>\(\s*['"`](\w+)['"`]/g; + const projectionPattern = /export const ([A-Z][A-Z0-9_]*) = new ReadProjection<[^>]*>\(\s*['"`](\w+)['"`]/g; let match; while ((match = projectionPattern.exec(fileContent)) !== null) { - aliases.add(match[1]); + if (!applied.has(match[1])) continue; + aliases.add(match[2]); const end = fileContent.indexOf('\n);', match.index); const declaration = fileContent.slice(match.index, end < 0 ? undefined : end); const joinPattern = /\[\s*['"`][^'"`]+\.[^'"`]+['"`]\s*,\s*['"`](\w+)['"`]\s*\]/g; @@ -158,8 +169,13 @@ describe('Query Builder Alias Enforcement', () => { return aliases; }; - const extractAllAliases = (queryChain: string, mainAlias: string, fileContent = ''): Set => { - const aliases = new Set([mainAlias, ...extractProjectionAliases(fileContent)]); + const extractAllAliases = ( + queryChain: string, + mainAlias: string, + fileContent = '', + applyContext = '', + ): Set => { + const aliases = new Set([mainAlias, ...extractProjectionAliases(fileContent, applyContext + queryChain)]); // Find join aliases: .leftJoin('relation', 'alias') or .innerJoin('relation', 'alias') // This handles both relation joins and entity joins @@ -236,7 +252,10 @@ describe('Query Builder Alias Enforcement', () => { // Get all valid aliases (main + joins + subqueries) // Also include all query aliases from the file for correlated subquery support - const validAliases = extractAllAliases(queryChain, mainAlias, content); + // `PROJECTION.apply(this.createQueryBuilder('x'), fields)` wraps the builder, so the constant + // stands before the chain rather than inside it. The preceding text is passed along for that. + const applyContext = content.slice(Math.max(0, chainStartIndex - 200), chainStartIndex); + const validAliases = extractAllAliases(queryChain, mainAlias, content, applyContext); for (const alias of allQueryAliases) { validAliases.add(alias); } @@ -520,6 +539,43 @@ describe('Query Builder Alias Enforcement', () => { }); }); + describe('projection aliases', () => { + // A repository file usually declares several projections. Their aliases are not + // interchangeable: a query applying one of them has joined only that one's relations. + const twoProjections = ` +export const FIRST_PROJECTION = new ReadProjection( + 'thing', + [['thing.owner', 'firstOwner']], + ['thing.id'], +); + +export const SECOND_PROJECTION = new ReadProjection( + 'thing', + [['thing.other', 'secondOther']], + ['thing.id'], +); +`; + + it('accepts an alias declared by the projection the query applies', () => { + const chain = `FIRST_PROJECTION.apply(this.createQueryBuilder('thing')).where('firstOwner.id = :id', { id })`; + + expect(extractAllAliases(chain, 'thing', twoProjections).has('firstOwner')).toBe(true); + }); + + it('rejects an alias declared by a different projection in the same file', () => { + const chain = `FIRST_PROJECTION.apply(this.createQueryBuilder('thing')).where('secondOther.id = :id', { id })`; + + // Without this the query would pass the scan while referencing a relation it never joined. + expect(extractAllAliases(chain, 'thing', twoProjections).has('secondOther')).toBe(false); + }); + + it('takes no projection aliases at all when the chain applies none', () => { + const chain = `this.createQueryBuilder('thing').where('thing.id = :id', { id })`; + + expect(extractAllAliases(chain, 'thing', twoProjections)).toEqual(new Set(['thing'])); + }); + }); + it('should have all createQueryBuilder() calls with an alias', () => { const files = getAllTypeScriptFiles(srcDir); const allIssues: { file: string; issues: { line: number; content: string }[] }[] = []; diff --git a/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts b/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts index f9070f538a..7063cbfcd6 100644 --- a/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts +++ b/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts @@ -9,6 +9,7 @@ import { seedEntity, } from 'src/shared/utils/projection-test.util'; import { LedgerDtoMapper, SuspenseLegRow } from 'src/subdomains/core/accounting/dto/ledger-dto.mapper'; +import { SuspenseLegDto } from 'src/subdomains/core/accounting/dto/ledger-reconciliation.dto'; import { AccountType, LedgerAccount } from 'src/subdomains/core/accounting/entities/ledger-account.entity'; import { LedgerLeg } from 'src/subdomains/core/accounting/entities/ledger-leg.entity'; import { LedgerTx } from 'src/subdomains/core/accounting/entities/ledger-tx.entity'; @@ -70,7 +71,9 @@ describeProjection('ledger suspense — read-path projection', () => { } /** The response the endpoint produces, through the projected query. */ - async function suspenseOf(fields = SUSPENSE_LEG_PROJECTION.fields) { + async function suspenseOf( + fields = SUSPENSE_LEG_PROJECTION.fields, + ): Promise<{ totalChf: number; legs: SuspenseLegDto[] }> { const now = new Date(); const rows = await legs.findSuspenseLegs(fields); const totalChf = Util.round(Util.sum(rows.map((l) => l.amountChf ?? 0)), 2); diff --git a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts index 3fec00c3dc..d7cf0ad33c 100644 --- a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts +++ b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts @@ -8,6 +8,10 @@ import { LedgerLeg } from '../entities/ledger-leg.entity'; /** What `LedgerDtoMapper.mapSuspenseLeg` reads, including the booking date the age is computed from. */ export const SUSPENSE_LEG_RESPONSE_FIELDS = [ 'leg.id', + // `mapSuspenseLeg` answers with this as `txId`, read off the joined row rather than through the + // entity's `@RelationId` — that property is filled from the leg's foreign-key column, which a + // query naming its fields does not carry. + 'tx.id', 'leg.amount', 'leg.amountChf', 'tx.bookingDate', @@ -23,16 +27,14 @@ export const SUSPENSE_LEG_RESPONSE_FIELDS = [ * The query joined the transaction and the account with `innerJoinAndSelect`, which loads each of * them whole for four values and a currency. * - * `tx.id` and `account.id` are guards: the response never shows them, but without a primary key the - * ORM cannot materialise a joined row — and `leg.txId` is a `@RelationId`, resolved from the joined - * transaction rather than from a column of its own. + * `account.id` is a guard: the response never shows it, but without a primary key the ORM cannot + * materialise the joined row. * * The two joins stay with the query rather than moving into the projection: `ReadProjection` joins * left, and these are inner. Both relations are `nullable: false`, so the two forms select the same * rows today — but that is a property of the schema, and the query should not depend on it silently. */ export const SUSPENSE_LEG_PROJECTION = new ReadProjection('leg', [], SUSPENSE_LEG_RESPONSE_FIELDS, [ - 'tx.id', 'account.id', ]); @@ -45,7 +47,8 @@ export class LedgerLegRepository extends BaseRepository { /** * The legs sitting on suspense accounts, oldest booking first. * - * `fields` exists for the mutation test; nothing in production passes it. + * `fields` is what the mutation test in `ledger-suspense.projection.spec.ts` re-runs the query + * with; `LedgerQueryService.getSuspense` calls this without it. */ async findSuspenseLegs(fields: ReadonlyArray = SUSPENSE_LEG_PROJECTION.fields): Promise { return SUSPENSE_LEG_PROJECTION.apply( diff --git a/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts b/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts index 6513c99923..796ceaf3cc 100644 --- a/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts +++ b/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts @@ -65,8 +65,9 @@ export class BuyCryptoRepository extends BaseRepository { * Transactions on a user's buy route, loaded with the history fields only. * * `routeId` narrows to a single route; without it the caller gets every buy route they own, which - * is what `GET /history` relies on. `fields` exists for the mutation test; nothing in production - * passes it. + * is what `GET /history` relies on. `fields` is what the mutation test in + * `buy-crypto-history.projection.spec.ts` re-runs the query with; `BuyCryptoService` calls this + * without it. */ async findBuyHistory( userId: number, diff --git a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts index bda25c9fb4..6f7d65a1a6 100644 --- a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts +++ b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts @@ -4,6 +4,7 @@ import { CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS, } from 'src/subdomains/core/custody/repositories/custody-order.repository'; import { CustodyOrder } from 'src/subdomains/core/custody/entities/custody-order.entity'; +import { CustodyOrderHistoryDto } from 'src/subdomains/core/custody/dto/output/custody-order-history.dto'; import { CustodyOrderHistoryDtoMapper } from 'src/subdomains/core/custody/mappers/custody-order-history-dto.mapper'; import { CustodyOrderStatus, CustodyOrderType } from 'src/subdomains/core/custody/enums/custody'; import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; @@ -72,7 +73,10 @@ describeProjection('GET /custody/order — read-path projection', () => { } /** The response the endpoint produces, through the projected query. */ - async function historyOf(userDataId: number, fields = CUSTODY_ORDER_HISTORY_PROJECTION.fields) { + async function historyOf( + userDataId: number, + fields = CUSTODY_ORDER_HISTORY_PROJECTION.fields, + ): Promise { const orders = await CUSTODY_ORDER_HISTORY_PROJECTION.apply( dataSource.getRepository(CustodyOrder).createQueryBuilder('custodyOrder'), fields, diff --git a/src/subdomains/core/custody/services/custody-order.service.ts b/src/subdomains/core/custody/services/custody-order.service.ts index 8ede4c17fe..56ca183897 100644 --- a/src/subdomains/core/custody/services/custody-order.service.ts +++ b/src/subdomains/core/custody/services/custody-order.service.ts @@ -235,7 +235,8 @@ export class CustodyOrderService { /** * A user's custody order history. * - * `fields` exists for the mutation test; nothing in production passes it. + * `fields` is what the mutation test in `custody-order-history.projection.spec.ts` re-runs the + * query with; the controller calls this without it. */ async getOrdersByUserData( userDataId: number, diff --git a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts index 38da0a74ad..f949f3bd52 100644 --- a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts +++ b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts @@ -33,7 +33,8 @@ export class LiquidityManagementPipelineRepository extends BaseRepository { /** * One link, carrying what a point-of-sale link is built from. * - * `fields` exists for the mutation test; nothing in production passes it. + * `fields` is what the mutation test in `pos-link.projection.spec.ts` re-runs the query with; + * `PaymentLinkService.createPosLinkAdmin` calls this without it. */ async findForPosLink(id: number, fields: ReadonlyArray = POS_LINK_PROJECTION.fields): Promise { return POS_LINK_PROJECTION.apply(this.createQueryBuilder('paymentLink'), fields) diff --git a/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts b/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts index 51cddbd0d6..b47df31355 100644 --- a/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts +++ b/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts @@ -46,7 +46,8 @@ export class BuyFiatRepository extends BaseRepository { /** * Transactions on a user's sell route, loaded with the history fields only. * - * `fields` exists for the mutation test; nothing in production passes it. + * `fields` is what the mutation test in `buy-fiat-history.projection.spec.ts` re-runs the query + * with; `BuyFiatService.getBuyFiatHistory` calls this without it. */ async findSellHistory( userId: number, diff --git a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts index d5673ec38f..a151717d54 100644 --- a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts +++ b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts @@ -50,12 +50,20 @@ describeProjection('API key — read-path projection', () => { * * The key is assigned in memory before the secret is derived from it, which is what makes * `created` part of the read: `getSecret` hashes the two together. + * + * The production key mixes in the current time, so two calls a millisecond apart differ. Comparing + * whole responses across runs would then report every field as required — true of the timestamp, + * and evidence about nothing. The fixture keeps the dependency that matters, the account id, and + * leaves the timestamp out. */ - async function apiKeyOf(id: number, fields = API_KEY_PROJECTION.fields) { + async function apiKeyOf( + id: number, + fields = API_KEY_PROJECTION.fields, + ): Promise<{ conflict: true } | { key: string; secret: string }> { const userData = await userDataRepo.getForApiKey(id, fields); if (userData.apiKeyCT) return { conflict: true as const }; - userData.apiKeyCT = ApiKeyService.createKey(userData.id); + userData.apiKeyCT = `KEY-FOR-ACCOUNT-${userData.id}`; return { key: userData.apiKeyCT, secret: ApiKeyService.getSecret(userData) }; } @@ -93,10 +101,11 @@ describeProjection('API key — read-path projection', () => { // --- LEVEL 3: mutation --- // - it('level 3 — the creation date is required to derive the secret', async () => { + it('level 3 — the account id and the creation date are required', async () => { const userData = await seedAccount({ apiKeyCT: null }); - await expectEveryFieldRequired(['userData.created'], (omitted) => + // The id feeds the key, the creation date the secret derived from it. + await expectEveryFieldRequired(['userData.id', 'userData.created'], (omitted) => apiKeyOf(userData.id, projectionFieldsWithout(API_KEY_PROJECTION.fields, omitted)), ); }, 300000); @@ -121,12 +130,13 @@ describeProjection('API key — read-path projection', () => { // endpoint fetched before. const full = await dataSource.getRepository(UserData).findOneBy({ id: userData.id }); - // The key itself is random, so the two rows are given the same one; what has to agree is the - // secret derived from it, which is where the creation date enters. + // Same key on both rows, so what has to agree is the secret derived from it — which is where + // the creation date enters, and the only value the projection can get wrong here. const key = ApiKeyService.createKey(full.id); projected.apiKeyCT = key; full.apiKeyCT = key; expect(ApiKeyService.getSecret(projected)).toEqual(ApiKeyService.getSecret(full)); + expect(projected.id).toEqual(full.id); }, 120000); }); diff --git a/src/subdomains/generic/user/models/user-data/user-data.repository.ts b/src/subdomains/generic/user/models/user-data/user-data.repository.ts index 030a0e6761..8911c66b6a 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.repository.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.repository.ts @@ -106,6 +106,8 @@ const volumeFields = (alias: string): string[] => * so both are joined below for their primary key alone. */ export const USER_V2_ACCOUNT_FIELDS = [ + // The response keys itself by this: `mapUser` returns it as `accountId`. + 'userData.id', 'userData.accountType', 'userData.mail', 'userData.phone', @@ -196,9 +198,9 @@ export const USER_V2_PROJECTION = new ReadProjection( ['user.wallet', 'userWallet'], ], [...USER_V2_ACCOUNT_FIELDS, ...USER_V2_LANGUAGE_AND_CURRENCY_FIELDS, ...USER_V2_ADDRESS_FIELDS], - // Never shown: the account id the response is keyed by, the status the endpoint refuses merged - // accounts on, and the wallet key that makes the ORM materialise the joined row. - ['userData.id', 'userData.status', 'userWallet.id'], + // Never shown: the status the endpoint refuses merged accounts on before it maps anything, and + // the wallet key that makes the ORM materialise the joined row. + ['userData.status', 'userWallet.id'], ); /** @@ -207,7 +209,12 @@ export const USER_V2_PROJECTION = new ReadProjection( * `apiKeyCT` is read twice: once to refuse a second key, and once after the new one is assigned, * because `ApiKeyService.getSecret` hashes it together with `created`. */ -export const API_KEY_RESPONSE_FIELDS = ['userData.apiKeyCT', 'userData.created']; +export const API_KEY_RESPONSE_FIELDS = [ + // The key is derived from the account id, and the secret from the key and the creation date. + 'userData.id', + 'userData.apiKeyCT', + 'userData.created', +]; /** * `POST /user/apiKey/CT` — 253 columns before, for two values and the id. @@ -219,8 +226,6 @@ export const API_KEY_PROJECTION = new ReadProjection( 'userData', [], API_KEY_RESPONSE_FIELDS, - // Not part of the response, but the key is derived from it and the update is scoped by it. - ['userData.id'], ); @Injectable() @@ -232,7 +237,8 @@ export class UserDataRepository extends CachedRepository { /** * The account, carrying what an API key is built from. * - * `fields` exists for the mutation test; nothing in production passes it. + * `fields` is what the mutation tests in `user-profile.projection.spec.ts`, `user-v2.projection.spec.ts` + * and `api-key.projection.spec.ts` re-run the query with; the services call these without it. */ async getForApiKey(id: number, fields: ReadonlyArray = API_KEY_PROJECTION.fields): Promise { return API_KEY_PROJECTION.apply(this.createQueryBuilder('userData'), fields) @@ -243,7 +249,8 @@ export class UserDataRepository extends CachedRepository { /** * Loads exactly what the v2 user response needs, users and wallets included. * - * `fields` exists for the mutation test; nothing in production passes it. + * `fields` is what the mutation tests in `user-profile.projection.spec.ts`, `user-v2.projection.spec.ts` + * and `api-key.projection.spec.ts` re-run the query with; the services call these without it. */ async getUserV2(id: number, fields: ReadonlyArray = USER_V2_PROJECTION.fields): Promise { return USER_V2_PROJECTION.apply(this.createQueryBuilder('userData'), fields) @@ -254,8 +261,8 @@ export class UserDataRepository extends CachedRepository { /** * Loads exactly what the profile response needs. * - * `fields` exists for the mutation test, which re-runs this query with one field left out; nothing - * in production passes it. + * `fields` is what the mutation tests in `user-profile.projection.spec.ts`, `user-v2.projection.spec.ts` + * and `api-key.projection.spec.ts` re-run the query with; the services call these without it. */ async getProfile(id: number, fields: ReadonlyArray = USER_PROFILE_PROJECTION.fields): Promise { return USER_PROFILE_PROJECTION.apply(this.createQueryBuilder('userData'), fields) diff --git a/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts b/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts index e8e623fc6a..e25bdf2261 100644 --- a/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts +++ b/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts @@ -8,6 +8,7 @@ import { import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { UserDtoMapper } from 'src/subdomains/generic/user/models/user/dto/user-dto.mapper'; +import { UserProfileDto } from 'src/subdomains/generic/user/models/user/dto/user-profile.dto'; import { createProjectionDataSource, describeProjection, @@ -61,7 +62,7 @@ describeProjection('GET /user/profile — read-path projection', () => { } /** The response the endpoint produces, through the projected query. */ - async function profileOf(id: number, fields = USER_PROFILE_PROJECTION.fields) { + async function profileOf(id: number, fields = USER_PROFILE_PROJECTION.fields): Promise { const userData = await repository.getProfile(id, fields); return UserDtoMapper.mapProfile(userData); } diff --git a/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts b/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts index ba7cb94c0c..cdbbb04670 100644 --- a/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts +++ b/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts @@ -12,6 +12,7 @@ import { } from 'src/shared/utils/projection-test.util'; import { Country } from 'src/shared/models/country/country.entity'; import { UserDtoMapper } from 'src/subdomains/generic/user/models/user/dto/user-dto.mapper'; +import { UserV2Dto } from 'src/subdomains/generic/user/models/user/dto/user-v2.dto'; import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; import { KycLevel, @@ -116,7 +117,7 @@ describeProjection('GET /user v2 — read-path projection', () => { } /** The response the endpoint produces, through the projected query. */ - async function userV2Of(id: number, activeUserId?: number, fields = USER_V2_PROJECTION.fields) { + async function userV2Of(id: number, activeUserId?: number, fields = USER_V2_PROJECTION.fields): Promise { const userData = await userDataRepo.getUserV2(id, fields); return UserDtoMapper.mapUser(userData, activeUserId); } diff --git a/src/subdomains/generic/user/models/user/user.repository.ts b/src/subdomains/generic/user/models/user/user.repository.ts index 4053ea8c48..11ab79aea1 100644 --- a/src/subdomains/generic/user/models/user/user.repository.ts +++ b/src/subdomains/generic/user/models/user/user.repository.ts @@ -36,7 +36,8 @@ export class UserRepository extends BaseRepository { * `GET /kyc/:id/documents` reads nothing else off the row: the response is assembled from the * document store, keyed by that id. The unprojected load reaches 328 columns for it. * - * `fields` exists for the mutation test; nothing in production passes it. + * `fields` is what the mutation test in `kyc-data.projection.spec.ts` re-runs the query with; + * `KycService` calls this without it. */ async findAccountIdForAddress( address: string, diff --git a/src/subdomains/generic/user/models/wallet/wallet.repository.ts b/src/subdomains/generic/user/models/wallet/wallet.repository.ts index 36bdebd7f5..832d115bee 100644 --- a/src/subdomains/generic/user/models/wallet/wallet.repository.ts +++ b/src/subdomains/generic/user/models/wallet/wallet.repository.ts @@ -38,7 +38,8 @@ export class WalletRepository extends CachedRepository { /** * A wallet with its users' KYC state, and nothing else. * - * `fields` exists for the mutation test; nothing in production passes it. + * `fields` is what the mutation test in `kyc-data.projection.spec.ts` re-runs the query with; + * `KycService` calls this without it. */ async findKycData( walletId: number, diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts index 32b6f31972..becbc060ab 100644 --- a/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts @@ -11,6 +11,7 @@ import { } from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; import { SupportIssue } from 'src/subdomains/supporting/support-issue/entities/support-issue.entity'; import { SupportIssueDtoMapper } from 'src/subdomains/supporting/support-issue/dto/support-issue-dto.mapper'; +import { SupportIssueInternalDataDto } from 'src/subdomains/supporting/support-issue/dto/support-issue.dto'; import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; @@ -120,7 +121,11 @@ describeProjection('GET /support/issue/:id/data — read-path projection', () => } /** The response the endpoint produces, through the projected query. */ - async function issueDataOf(id: number, fields = SUPPORT_ISSUE_DATA_PROJECTION.fields, hideLimitRequest = false) { + async function issueDataOf( + id: number, + fields = SUPPORT_ISSUE_DATA_PROJECTION.fields, + hideLimitRequest = false, + ): Promise { const issue = await repository.findIssueData(id, fields); return SupportIssueDtoMapper.mapSupportIssueData(issue, hideLimitRequest); } diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts index c0bde7cf8c..b0c4de039c 100644 --- a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts @@ -13,6 +13,7 @@ import { SupportIssueListOrderBy, } from 'src/subdomains/supporting/support-issue/dto/get-support-issue.dto'; import { SupportIssueDtoMapper } from 'src/subdomains/supporting/support-issue/dto/support-issue-dto.mapper'; +import { SupportIssueListDto } from 'src/subdomains/supporting/support-issue/dto/support-issue.dto'; import { SupportIssue } from 'src/subdomains/supporting/support-issue/entities/support-issue.entity'; import { SupportMessage } from 'src/subdomains/supporting/support-issue/entities/support-message.entity'; import { Department } from 'src/subdomains/supporting/support-issue/enums/department.enum'; @@ -27,6 +28,7 @@ import { SupportIssueListQuery, SupportIssueRepository, } from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; +import { SupportMessageRepository } from 'src/subdomains/supporting/support-issue/repositories/support-message.repository'; import { DataSource } from 'typeorm'; const SCHEMA = 'support_issue_list_projection_spec'; @@ -44,24 +46,18 @@ const SCHEMA = 'support_issue_list_projection_spec'; describeProjection('support issue list — read-path projection', () => { let dataSource: DataSource; let issues: SupportIssueRepository; + let messages: SupportMessageRepository; beforeAll(async () => { dataSource = await createProjectionDataSource(SCHEMA); issues = new SupportIssueRepository(dataSource.manager); + messages = new SupportMessageRepository(dataSource.manager); }, 300000); afterAll(async () => { await destroyProjectionDataSource(dataSource, SCHEMA); }); - /** - * The response fields the projection under test cannot fill. - * - * They come from the aggregate the endpoint runs over the messages afterwards — a separate query - * that names its own columns already — so dropping a field of this projection cannot change them. - */ - const MESSAGE_STATS_FIELDS = ['data[0].messageCount', 'data[0].lastMessageDate', 'data[0].lastMessageAuthor']; - const BASE_QUERY: SupportIssueListQuery = { terms: [], orderBy: SupportIssueListOrderBy.CREATED, @@ -92,21 +88,45 @@ describeProjection('support issue list — read-path projection', () => { return { issue, userData }; } - /** The response the endpoint produces, through the projected query. */ - async function listOf(query: Partial, fields = SUPPORT_ISSUE_LIST_PROJECTION.fields) { + /** + * The response the endpoint produces: the projected page, and the message aggregate the endpoint + * runs over it. Both queries are part of the answer, so both belong in every level. + */ + async function listOf( + query: Partial, + fields = SUPPORT_ISSUE_LIST_PROJECTION.fields, + ): Promise<{ data: SupportIssueListDto[]; total: number }> { const [rows, total] = await issues.findIssueList({ ...BASE_QUERY, ...query }, fields); - return { data: rows.map((row) => SupportIssueDtoMapper.mapSupportIssueListItem(row)), total }; + const stats = await messages.findStatsFor(rows.map((row) => row.id)); + + return { + data: rows.map((row) => SupportIssueDtoMapper.mapSupportIssueListItem(row, stats.get(row.id))), + total, + }; } // --- LEVEL 1: completeness --- // it('level 1 — a listed issue answers with no empty field', async () => { const { issue } = await seedIssue(); + // The three message fields of a row come from the aggregate, so a row without messages leaves + // them legitimately empty — the completeness assertion needs one that has them. + await seedEntity(dataSource, SupportMessage, { values: { issue } }); const list = await listOf({ departments: [Department.SUPPORT], clerk: issue.clerk }); expect(list.data).toHaveLength(1); - expectNoEmptyFields(list, MESSAGE_STATS_FIELDS); + expectNoEmptyFields(list); + }, 120000); + + it('level 1 — an issue without messages answers with a zero count and no last message', async () => { + const { issue } = await seedIssue(); + + const [row] = (await listOf({ departments: [Department.SUPPORT], clerk: issue.clerk })).data; + + expect(row.messageCount).toEqual(0); + expect(row.lastMessageDate).toBeUndefined(); + expect(row.lastMessageAuthor).toBeUndefined(); }, 120000); // --- LEVEL 2: variants --- // @@ -194,16 +214,18 @@ describeProjection('support issue list — read-path projection', () => { ); it('level 2 — the id branch matches by id alone, and only for a term that fits int4', async () => { - const { issue } = await seedIssue({ name: 'no-digits-here', clerk: 'no-digits-either' }); + const clerk = 'id-branch-clerk'; + const { issue } = await seedIssue({ name: 'no-digits-here', uid: 'uid-without-digits', clerk }); + const scoped = (term: string) => listOf({ clerk, terms: [term] }); - // The id branch is what makes a pasted number find the issue: none of the text fields of this - // fixture contains a digit, so a match can only come from `issue.id = :termNId`. - expect((await listOf({ terms: [String(issue.id)] })).data.map((r) => r.uid)).toEqual([issue.uid]); + // Scoped to this issue's clerk, so the only candidate row is this one — and none of its text + // fields carries a digit, so a match can only come from `issue.id = :termNId`. + expect((await scoped(String(issue.id))).data.map((row) => row.uid)).toEqual([issue.uid]); // At int4 max the branch is still emitted; one above it the guard has to drop the comparison, - // or Postgres raises 22003 and the whole search fails rather than answering nothing. - expect((await listOf({ terms: ['2147483647'] })).data.map((r) => r.uid)).not.toContain(issue.uid); - expect((await listOf({ terms: ['41791234567'] })).data.map((r) => r.uid)).not.toContain(issue.uid); + // or Postgres raises 22003 and the search fails instead of answering nothing. + expect((await scoped('2147483647')).data).toHaveLength(0); + expect((await scoped('41791234567')).data).toHaveLength(0); }, 120000); it('level 2 — the id tie-break orders rows that share a sort key', async () => { @@ -243,17 +265,13 @@ describeProjection('support issue list — read-path projection', () => { it('level 3 — every field feeding the list response is required', async () => { const { issue } = await seedIssue(); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); - await expectEveryFieldRequired( - SUPPORT_ISSUE_LIST_RESPONSE_FIELDS, - (omitted) => - listOf( - { departments: [Department.SUPPORT], clerk: issue.clerk }, - projectionFieldsWithout(SUPPORT_ISSUE_LIST_PROJECTION.fields, omitted), - ), - // The same three as level 1: they are fed by the separate aggregate over the messages, which - // this projection does not cover and which no field of it can influence. - MESSAGE_STATS_FIELDS, + await expectEveryFieldRequired(SUPPORT_ISSUE_LIST_RESPONSE_FIELDS, (omitted) => + listOf( + { departments: [Department.SUPPORT], clerk: issue.clerk }, + projectionFieldsWithout(SUPPORT_ISSUE_LIST_PROJECTION.fields, omitted), + ), ); }, 300000); @@ -261,6 +279,7 @@ describeProjection('support issue list — read-path projection', () => { it('level 4 — the projected response equals the one from a full load', async () => { const { issue } = await seedIssue(); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); const projected = await listOf({ departments: [Department.SUPPORT], clerk: issue.clerk }); // The unprojected load is the second source: every column of the row, which is what the query @@ -269,6 +288,9 @@ describeProjection('support issue list — read-path projection', () => { .getRepository(SupportIssue) .find({ where: { clerk: issue.clerk }, loadEagerRelations: false }); - expect(projected.data).toEqual(full.map((row) => SupportIssueDtoMapper.mapSupportIssueListItem(row))); + const stats = await messages.findStatsFor(full.map((row) => row.id)); + expect(projected.data).toEqual( + full.map((row) => SupportIssueDtoMapper.mapSupportIssueListItem(row, stats.get(row.id))), + ); }, 120000); }); diff --git a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts index 9e1ef6eb5c..8a4f7c9b43 100644 --- a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts +++ b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts @@ -121,6 +121,9 @@ export const SUPPORT_ISSUE_DATA_LIMIT_REQUEST_FIELDS = [ * `SUPPORT_MESSAGE_RESPONSE_FIELDS`. */ export const SUPPORT_ISSUE_RESPONSE_FIELDS = [ + // `mapTransaction` decides between a transaction object and `null` on this, so it determines the + // response even though it is never shown. + 'issueTransaction.id', 'supportIssue.uid', 'supportIssue.state', 'supportIssue.type', @@ -135,9 +138,8 @@ export const SUPPORT_ISSUE_RESPONSE_FIELDS = [ /** * `GET /support/issue` and `GET /support/issue/:id` — 450 columns before, for nine values. * - * `supportIssue.id` is a guard rather than a response field: the mapper never shows it, but - * `getIssue` loads the message thread by it afterwards. `issueTransaction.id` is what - * `mapTransaction` checks to decide whether there is a transaction at all. + * `supportIssue.id` is a guard rather than a response field: the mapper never shows it and no value + * depends on it, but `getIssue` loads the message thread by it afterwards. */ export const SUPPORT_ISSUE_PROJECTION = new ReadProjection( 'supportIssue', @@ -146,7 +148,7 @@ export const SUPPORT_ISSUE_PROJECTION = new ReadProjection( ['supportIssue.limitRequest', 'issueLimitRequest'], ], SUPPORT_ISSUE_RESPONSE_FIELDS, - ['supportIssue.id', 'issueTransaction.id'], + ['supportIssue.id'], ); /** @@ -259,7 +261,8 @@ export class SupportIssueRepository extends BaseRepository { /** * The issue list, with the page and the unpaged total. * - * `fields` exists for the mutation test; nothing in production passes it. + * `fields` is what the mutation test in `support-issue-list.projection.spec.ts` re-runs the query + * with; `SupportIssueService.getSupportIssueList` calls this without it. */ async findIssueList( query: SupportIssueListQuery, @@ -288,10 +291,8 @@ export class SupportIssueRepository extends BaseRepository { // larger values with 22003, which would 500 the entire search — a pasted phone number // like "41791234567" is a realistic trigger). Keeps the predicate on the PK index (no // cast-to-text) and avoids partial-match surprises (term "42" doesn't match id 142). - const idTerm = - /^\d+$/.test(query.terms[i]) && parseInt(query.terms[i], 10) <= 2147483647 - ? parseInt(query.terms[i], 10) - : null; + const numeric = +query.terms[i]; + const idTerm = /^\d+$/.test(query.terms[i]) && numeric <= 2147483647 ? numeric : null; const idClause = idTerm != null ? ` OR issue.id = :${param}Id` : ''; // The message branch goes through the query builder rather than a literal table name, so the // table is resolved the way the ORM resolves every other one — a bare `support_message` is @@ -325,7 +326,8 @@ export class SupportIssueRepository extends BaseRepository { /** * Loads exactly what the internal issue view needs. * - * `fields` exists for the mutation test; nothing in production passes it. + * `fields` is what the mutation test in `support-issue-list.projection.spec.ts` re-runs the query + * with; `SupportIssueService.getSupportIssueList` calls this without it. */ async findIssueData( id: number, diff --git a/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts b/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts index 0d31238184..0faa7ee809 100644 --- a/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts +++ b/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { Util } from 'src/shared/utils/util'; import { EntityManager } from 'typeorm'; import { SupportMessage } from '../entities/support-message.entity'; @@ -20,6 +21,13 @@ export const SUPPORT_MESSAGE_RESPONSE_FIELDS = [ 'supportMessage.fileUrl', ]; +/** What the list rows show about the messages of an issue. */ +export interface SupportMessageStats { + count: number; + lastDate?: Date; + lastAuthor?: string; +} + /** * The message thread of an issue. * @@ -41,7 +49,8 @@ export class SupportMessageRepository extends BaseRepository { /** * The messages of an issue, newer than `fromMessageId`. * - * `fields` exists for the mutation test; nothing in production passes it. + * `fields` is what the mutation test in `support-issue-view.projection.spec.ts` re-runs the query + * with; `SupportIssueService.getIssue` calls this without it. */ async findThread( issueId: number, @@ -58,4 +67,44 @@ export class SupportMessageRepository extends BaseRepository { .getMany() ); } + + /** + * Message count, last date and last author per issue, for the list rows. + * + * Its own aggregate rather than part of the list query: the list is paginated, and joining the + * messages would multiply the rows before the page is cut. + */ + async findStatsFor(issueIds: number[]): Promise> { + if (issueIds.length === 0) return new Map(); + + const lastOf = (column: string) => (sub) => + sub + .select(`m2.${column}`) + .from(SupportMessage, 'm2') + .where('m2."issueId" = m."issueId"') + .orderBy('m2.id', 'DESC') + .limit(1); + + // Batched to stay below the parameter limit of a single statement. + const rows = await Util.doInBatchesAndJoin( + issueIds, + (chunk): Promise<{ issueId: string; count: string; lastDate: Date | null; lastAuthor: string | null }[]> => + this.createQueryBuilder('m') + .select('m."issueId"', 'issueId') + .addSelect('COUNT(*)', 'count') + .addSelect(lastOf('created'), 'lastDate') + .addSelect(lastOf('author'), 'lastAuthor') + .where('m."issueId" IN (:...ids)', { ids: chunk }) + .groupBy('m."issueId"') + .getRawMany(), + 1000, + ); + + return new Map( + rows.map((row) => [ + +row.issueId, + { count: +row.count, lastDate: row.lastDate ?? undefined, lastAuthor: row.lastAuthor ?? undefined }, + ]), + ); + } } diff --git a/src/subdomains/supporting/support-issue/services/support-issue.service.ts b/src/subdomains/supporting/support-issue/services/support-issue.service.ts index e300e7775f..76950ffac9 100644 --- a/src/subdomains/supporting/support-issue/services/support-issue.service.ts +++ b/src/subdomains/supporting/support-issue/services/support-issue.service.ts @@ -556,7 +556,7 @@ export class SupportIssueService { skip: filter.skip, }); - const stats = await this.getMessageStats(issues.map((i) => i.id)); + const stats = await this.messageRepo.findStatsFor(issues.map((i) => i.id)); return { data: issues.map((i) => SupportIssueDtoMapper.mapSupportIssueListItem(i, stats.get(i.id))), @@ -574,52 +574,6 @@ export class SupportIssueService { return date; } - private async getMessageStats( - issueIds: number[], - ): Promise> { - if (issueIds.length === 0) return new Map(); - - // batched to stay below SQL Server's 2100 parameter limit - const rows = await Util.doInBatchesAndJoin( - issueIds, - (chunk): Promise<{ issueId: string; count: string; lastDate: Date | null; lastAuthor: string | null }[]> => - this.messageRepo - .createQueryBuilder('m') - .select('m."issueId"', 'issueId') - .addSelect('COUNT(*)', 'count') - .addSelect( - (sub) => - sub - .select('m2.created') - .from(SupportMessage, 'm2') - .where('m2."issueId" = m."issueId"') - .orderBy('m2.id', 'DESC') - .limit(1), - 'lastDate', - ) - .addSelect( - (sub) => - sub - .select('m2.author') - .from(SupportMessage, 'm2') - .where('m2."issueId" = m."issueId"') - .orderBy('m2.id', 'DESC') - .limit(1), - 'lastAuthor', - ) - .where('m."issueId" IN (:...ids)', { ids: chunk }) - .groupBy('m."issueId"') - .getRawMany(), - 1000, - ); - - return new Map( - rows.map((r) => [ - +r.issueId, - { count: +r.count, lastDate: r.lastDate ?? undefined, lastAuthor: r.lastAuthor ?? undefined }, - ]), - ); - } async getIssueEntities(userDataId: number): Promise { return this.supportIssueRepo.find({ From e7f828622c909fc4bf103402a64429765ef36ec0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:28:46 +0200 Subject: [PATCH 17/46] Drop two claims the repository cannot support The inventory stated that a column added elsewhere broke every invoice and receipt in production. That is a claim about production behaviour in a public repository and nothing here can verify it. What is checkable stays: the query selected 1,664 columns, which is exactly Postgres' limit, so it was one added column away from failing outright - whatever the column and wherever it was added. The fourth test level claimed the unprojected comparison involves no second implementation. It does: each spec restates the filter and the joins around the full load, so a spec that restates them wrongly compares two things neither of which is the endpoint. The level verifies the field set, the mapper being the same function in both runs; the query around it is what level 2 asserts against seeded rows. Both are now written that way. --- docs/endpoints.md | 2 +- docs/read-path-projections.md | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/endpoints.md b/docs/endpoints.md index f54523cf12..a277957fbc 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -36,7 +36,7 @@ Today 38 endpoints read only what they return and 398 do not, so the column read Of the 36 that read only what they return, 17 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11), `GET /kyc/users` (328 columns to 7), `GET /kyc/:id/documents` (328 columns to 2), `GET /custody/order` (19 columns to 14), `GET /support/issue/list` (16 columns to 10), `GET /realunit/support/list` (16 columns to 10), `GET /dashboard/accounting/ledger/suspense` (11 columns to 10), `GET /liquidityManagement/pipeline/:id/status` (112 columns to 2), `PUT /paymentLink/:id/pos` (513 columns to 26), `POST /user/apiKey/CT` (253 columns to 3), `GET /user` (351 columns to 66). The other 19 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. -Among the 398 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 307 exceed 100, 86 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, which is what broke every invoice and receipt in production once a single column was added elsewhere. +Among the 398 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 307 exceed 100, 86 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. ### How to read this column, and how not to diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index a0a999a659..920ecb5c63 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -27,9 +27,9 @@ This service loads far more data than it returns. Measured against the real enti - The whole database schema has **1,736 columns across 99 tables**. - `PUT /v1/transaction/:id/invoice` selected **1,664 of them** — 96% of the entire schema — to - render a PDF containing a handful of values. That query sat exactly on Postgres' limit of 1,664 - columns per statement, which is why a single new column added elsewhere (`settlementEventId` on - `transaction_request`) broke every invoice and receipt in production until it was fixed. + render a PDF containing a handful of values. That is exactly Postgres' limit of 1,664 columns per + statement, so the query was one added column away from failing outright, whatever the column and + wherever it was added. - Of the 534 endpoints, **398 reach at least one load site that fetches whole rows**; 98 read nothing at all, and **36 read only the fields they return**. The widest query a fetching endpoint can trigger is 308 columns at the median, and 19 of them exceed 1,000. @@ -398,11 +398,15 @@ unless that response is complete. **Where the same value exists twice, the two must agree.** -For a conversion the second source is always available and always exact: **the unprojected load.** -Run the endpoint's mapper over a full `find` of the same fixture, and the two responses must be -identical, per variant. The full load fetches every column, so what it produces is by construction -what the endpoint answered before the conversion — no second implementation is involved that could -be wrong in the same way, which is what makes this the strongest of the four. It is also the only +For a conversion the second source is always available: **the unprojected load.** Run the endpoint's +mapper over a full `find` of the same fixture, and the two responses must be identical, per variant. +The full load fetches every column, so the *field set* it produces is by construction the one the +endpoint answered from before the conversion — the mapper is the same function in both runs, so a +difference can only come from the columns. + +What the level does not verify is the query around them: each spec restates the filter and the +joins, so a spec that restates them wrongly compares two things neither of which is the endpoint. +That part is covered by level 2, which asserts the filter against seeded rows. It is also the only level that catches a projection loading the *wrong* field rather than too few: level 1 sees a field that went empty, level 4 sees any field that changed. From c59b7bd57d964365088c417cbafebf3f6b9d7572 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:39:37 +0200 Subject: [PATCH 18/46] Format two files the previous commit left unformatted Both were edited without running the repository's own format check, which the CI step then reported. No behaviour changes. --- .../generic/user/models/user-data/user-data.repository.ts | 6 +----- .../support-issue/services/support-issue.service.ts | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/subdomains/generic/user/models/user-data/user-data.repository.ts b/src/subdomains/generic/user/models/user-data/user-data.repository.ts index 8911c66b6a..21614f1a41 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.repository.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.repository.ts @@ -222,11 +222,7 @@ export const API_KEY_RESPONSE_FIELDS = [ * The endpoint writes, but through `update(id, …)` rather than by saving the row it read, so a * projected read cannot blank a column it did not load. */ -export const API_KEY_PROJECTION = new ReadProjection( - 'userData', - [], - API_KEY_RESPONSE_FIELDS, -); +export const API_KEY_PROJECTION = new ReadProjection('userData', [], API_KEY_RESPONSE_FIELDS); @Injectable() export class UserDataRepository extends CachedRepository { diff --git a/src/subdomains/supporting/support-issue/services/support-issue.service.ts b/src/subdomains/supporting/support-issue/services/support-issue.service.ts index 76950ffac9..964323964a 100644 --- a/src/subdomains/supporting/support-issue/services/support-issue.service.ts +++ b/src/subdomains/supporting/support-issue/services/support-issue.service.ts @@ -574,7 +574,6 @@ export class SupportIssueService { return date; } - async getIssueEntities(userDataId: number): Promise { return this.supportIssueRepo.find({ where: { userData: { id: userDataId } }, From dd9a1d3ffd42e63844cca00bca9119ad11eace9d Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:48:07 +0200 Subject: [PATCH 19/46] Prove the write-safety claim instead of asserting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both converted write endpoints rest on one claim: they write through `update(id, …)`, which names its columns, so a projected read cannot blank the ones it left out. The specs stated that in a comment and tested only the read. They now run the write. The row is read at the storage level before and after, so the comparison covers every column of the table rather than the ones some load happens to materialise: the named columns changed, everything else is byte-for-byte what it was, `updated` excepted because the write is what moves it. The point-of-sale path gets one more, because its write merges into what was read rather than replacing it: the projection has to carry the stored configuration verbatim, or the merge starts from an empty object and the configuration is reset to nothing but the new access key. What these do not cover is the service logic around the write - which key is generated, how the merge combines two configurations. That belongs to those services and is not what a projection can get wrong. --- .../__tests__/pos-link.projection.spec.ts | 94 +++++++++++++++++-- .../__tests__/api-key.projection.spec.ts | 28 ++++++ 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts index 2e10c157a6..51839ebbea 100644 --- a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts +++ b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts @@ -9,6 +9,7 @@ import { projectionFieldsWithout, seedEntity, } from 'src/shared/utils/projection-test.util'; +import { PaymentLinkConfig } from 'src/subdomains/core/payment-link/entities/payment-link.config'; import { PaymentLink } from 'src/subdomains/core/payment-link/entities/payment-link.entity'; import { POS_LINK_PROJECTION, @@ -88,7 +89,16 @@ describeProjection('point-of-sale link — read-path projection', () => { * `scoped` selects between them: unset merges the account and the link, `true` takes the link * alone, `false` the account alone. */ - async function posConfigOf(id: number, fields = POS_LINK_PROJECTION.fields) { + async function posConfigOf( + id: number, + fields = POS_LINK_PROJECTION.fields, + ): Promise<{ + merged: PaymentLinkConfig; + linkOnly: PaymentLinkConfig; + accountOnly: PaymentLinkConfig; + uniqueId: string; + storedConfig: string; + }> { const link = await paymentLinks.findForPosLink(id, fields); return { merged: link.configObj, @@ -175,8 +185,13 @@ describeProjection('point-of-sale link — read-path projection', () => { 'posOrganizationCountry.symbol', ]; - /** The name, which is `organizationName` falling back to the two personal ones. */ - const NAME_FIELDS = ['posUserData.organizationName', 'posUserData.firstname', 'posUserData.surname']; + /** + * The name, which is `organizationName` falling back to the two personal ones. + * + * Typed as a mutation candidate: a group of fields is one candidate, because dropping any single + * member leaves the value filled by the next alternative. + */ + const NAME_FIELDS: string[] = ['posUserData.organizationName', 'posUserData.firstname', 'posUserData.surname']; /** * Two configurations that differ from the defaults and from each other, so both columns are @@ -203,13 +218,14 @@ describeProjection('point-of-sale link — read-path projection', () => { { config: LINK_CONFIG }, ); - await expectEveryFieldRequired( - POS_LINK_RESPONSE_FIELDS.filter( - // The name falls back, so no single one of its three columns is required here; the case - // below is the one that reaches the fallback. - (field) => !skipped.includes(field) && !NAME_FIELDS.includes(field), - ).concat([NAME_FIELDS as unknown as string]), - (omitted) => posConfigOf(paymentLink.id, projectionFieldsWithout(POS_LINK_PROJECTION.fields, omitted)), + const candidates: (string | string[])[] = POS_LINK_RESPONSE_FIELDS.filter( + // The name falls back, so no single one of its three columns is required here; the case + // below is the one that reaches the fallback. + (field) => !skipped.includes(field) && !NAME_FIELDS.includes(field), + ); + + await expectEveryFieldRequired([...candidates, NAME_FIELDS], (omitted) => + posConfigOf(paymentLink.id, projectionFieldsWithout(POS_LINK_PROJECTION.fields, omitted)), ); }, 300000, @@ -229,6 +245,64 @@ describeProjection('point-of-sale link — read-path projection', () => { ); }, 300000); + // --- the projection must not lose the guards the write depends on --- // + + it('loads the two ids the endpoint scopes its updates by', async () => { + // Neither appears in the answer, so no level would notice their absence — and both updates are + // scoped by them: the link's own configuration and the account's payment-link configuration. + const { paymentLink, userData } = await seedLink(); + + const loaded = await paymentLinks.findForPosLink(paymentLink.id); + + expect(loaded.id).toEqual(paymentLink.id); + expect(loaded.route.userData.id).toEqual(userData.id); + }, 120000); + + // --- the claim that makes this endpoint convertible at all --- // + + it.each([ + ['the link', 'payment_link', 'config'], + ['the account', 'user_data', 'paymentLinksConfig'], + ])( + 'writing %s after a projected read leaves every other column untouched', + async (_name, table, column) => { + // This is the whole reason a write endpoint can be converted: both updates name their columns, + // so neither can blank the ones the projection left out. Saving a loaded row back would. + const { paymentLink, userData } = await seedLink(); + const id = table === 'payment_link' ? paymentLink.id : userData.id; + const rowOf = async (): Promise> => + (await dataSource.query(`SELECT * FROM "${SCHEMA}"."${table}" WHERE id = $1`, [id]))[0]; + const before = await rowOf(); + + const loaded = await paymentLinks.findForPosLink(paymentLink.id); + const written = JSON.stringify({ accessKeys: ['written-key'] }); + if (table === 'payment_link') await paymentLinks.update(loaded.id, { config: written }); + else await dataSource.getRepository(UserData).update(loaded.route.userData.id, { paymentLinksConfig: written }); + + const after = await rowOf(); + expect(after[column]).toEqual(written); + + const ignored = [column, 'updated']; + const comparable = (row: Record): Record => + Object.fromEntries(Object.entries(row).filter(([name]) => !ignored.includes(name))); + + expect(comparable(after)).toEqual(comparable(before)); + }, + 120000, + ); + + it('carries the stored configuration verbatim, so the write has something to merge into', async () => { + // The write merges the new access key into what was read. A projection that dropped `config` + // would hand the merge an empty object and reset the configuration to just the key. + const existing = JSON.stringify({ fee: 0.9, recipient: { name: 'existing' } }); + const { paymentLink } = await seedLink(AccountType.PERSONAL, {}, { config: existing }); + + const loaded = await paymentLinks.findForPosLink(paymentLink.id); + + expect(loaded.config).toEqual(existing); + expect(JSON.parse(loaded.config)).toEqual({ fee: 0.9, recipient: { name: 'existing' } }); + }, 120000); + // --- LEVEL 4: consistency against a second source --- // it.each([AccountType.PERSONAL, AccountType.ORGANIZATION])( diff --git a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts index a151717d54..1b51a27362 100644 --- a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts +++ b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts @@ -120,6 +120,34 @@ describeProjection('API key — read-path projection', () => { ); }, 300000); + // --- the claim that makes this endpoint convertible at all --- // + + it('writing after a projected read leaves every column the query did not load untouched', async () => { + // This is the whole reason a write endpoint can be converted: the update names its columns, so + // it cannot blank the ones the projection left out. Saving the loaded row back would. + const account = await seedAccount({ apiKeyCT: null }); + // Read at the storage level, so the comparison covers every column of the table rather than the + // ones some load happens to materialise. + const rowOf = async (): Promise> => + (await dataSource.query(`SELECT * FROM "${SCHEMA}"."user_data" WHERE id = $1`, [account.id]))[0]; + const before = await rowOf(); + + const loaded = await userDataRepo.getForApiKey(account.id); + await userDataRepo.update(loaded.id, { apiKeyCT: 'written-key', apiFilterCT: 'written-filter' }); + + const after = await rowOf(); + expect(after.apiKeyCT).toEqual('written-key'); + expect(after.apiFilterCT).toEqual('written-filter'); + // Every other column - including the ones the projection never selected - has to be what it + // was. `updated` is excluded because the write is what moves it. + const ignored = ['apiKeyCT', 'apiFilterCT', 'updated']; + const comparable = (row: Record): Record => + Object.fromEntries(Object.entries(row).filter(([column]) => !ignored.includes(column))); + + expect(comparable(after)).toEqual(comparable(before)); + expect(Object.keys(before).length).toBeGreaterThan(50); + }, 120000); + // --- LEVEL 4: consistency against a second source --- // it('level 4 — the projected answer equals the one from a full load', async () => { From 21274150a937daa5e2513ebe97859597c62a7732 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:08:51 +0200 Subject: [PATCH 20/46] Narrow the point-of-sale projection to what the endpoint actually answers with The conversion derived its field list from `configObj`, which assembles a recipient block out of the account's name, contact data and address. The endpoint discards that block. It reads one value out of the configuration - the access key - and answers a URL built from it and the link's uniqueId. 513 columns -> 3 fields and 4 keys, where the first conversion left 26 `accountType` is deliberately not selected, and that is load-bearing rather than incidental: the discarded recipient reads `UserData.address`, a getter that switches to the organization row for an organization account and would dereference a relation this query has no reason to join. Left unselected, the getter takes its other branch and reads columns that are simply absent, which nothing looks at. Two fixtures cover exactly that. The spec now drives `PaymentLinkService.createPosLinkAdmin` rather than a rebuilt query, so the levels compare the answer the endpoint gives. That is what exposed the width: against a synthetic configuration object, fields the endpoint never returns looked required. The custody history query moves into its repository for the same reason. The spec had rebuilt it and left out the ordering and the hundred-row cap, so level 4 could not have seen either drift. --- .../custody-order-history.projection.spec.ts | 14 +- .../repositories/custody-order.repository.ts | 32 ++ .../custody/services/custody-order.service.ts | 21 +- .../__tests__/pos-link.projection.spec.ts | 328 +++++++++--------- .../repositories/payment-link.repository.ts | 57 +-- 5 files changed, 214 insertions(+), 238 deletions(-) diff --git a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts index 6f7d65a1a6..aef2d57672 100644 --- a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts +++ b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts @@ -2,6 +2,7 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; import { CUSTODY_ORDER_HISTORY_PROJECTION, CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS, + CustodyOrderRepository, } from 'src/subdomains/core/custody/repositories/custody-order.repository'; import { CustodyOrder } from 'src/subdomains/core/custody/entities/custody-order.entity'; import { CustodyOrderHistoryDto } from 'src/subdomains/core/custody/dto/output/custody-order-history.dto'; @@ -31,9 +32,11 @@ const SCHEMA = 'custody_order_history_projection_spec'; */ describeProjection('GET /custody/order — read-path projection', () => { let dataSource: DataSource; + let orders: CustodyOrderRepository; beforeAll(async () => { dataSource = await createProjectionDataSource(SCHEMA); + orders = new CustodyOrderRepository(dataSource.manager); }, 300000); afterAll(async () => { @@ -77,16 +80,7 @@ describeProjection('GET /custody/order — read-path projection', () => { userDataId: number, fields = CUSTODY_ORDER_HISTORY_PROJECTION.fields, ): Promise { - const orders = await CUSTODY_ORDER_HISTORY_PROJECTION.apply( - dataSource.getRepository(CustodyOrder).createQueryBuilder('custodyOrder'), - fields, - ) - .innerJoin('custodyOrder.user', 'user') - .innerJoin('user.userData', 'userData') - .where('userData.id = :userDataId', { userDataId }) - .andWhere('custodyOrder.status != :createdStatus', { createdStatus: CustodyOrderStatus.CREATED }) - .getMany(); - return CustodyOrderHistoryDtoMapper.mapList(orders); + return CustodyOrderHistoryDtoMapper.mapList(await orders.findHistoryFor(userDataId, fields)); } // --- LEVEL 1: completeness --- // diff --git a/src/subdomains/core/custody/repositories/custody-order.repository.ts b/src/subdomains/core/custody/repositories/custody-order.repository.ts index fcff3d2273..a0d64148cd 100644 --- a/src/subdomains/core/custody/repositories/custody-order.repository.ts +++ b/src/subdomains/core/custody/repositories/custody-order.repository.ts @@ -3,6 +3,7 @@ import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; import { CustodyOrder } from '../entities/custody-order.entity'; +import { CustodyOrderStatus } from '../enums/custody'; /** What `CustodyOrderHistoryDtoMapper.map` reads. */ export const CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS = [ @@ -43,4 +44,35 @@ export class CustodyOrderRepository extends BaseRepository { constructor(manager: EntityManager) { super(CustodyOrder, manager); } + + /** + * The custody orders of an account, newest first, capped at a hundred. + * + * `fields` is what the mutation test in `custody-order-history.projection.spec.ts` re-runs the + * query with; `CustodyOrderService.getUserCustodyOrders` calls this without it. + */ + async findHistoryFor( + userDataId: number, + fields: ReadonlyArray = CUSTODY_ORDER_HISTORY_PROJECTION.fields, + ): Promise { + return ( + CUSTODY_ORDER_HISTORY_PROJECTION.apply(this.createQueryBuilder('custodyOrder'), fields) + .innerJoin('custodyOrder.user', 'user') + .innerJoin('user.userData', 'userData') + .where('userData.id = :userDataId', { userDataId }) + .andWhere('custodyOrder.status != :createdStatus', { createdStatus: CustodyOrderStatus.CREATED }) + // The list shows completedAt where the order is completed, created otherwise. Sorting by + // created alone would put rows out of order against the dates the reader can see. + .orderBy('COALESCE("custodyOrder"."completedAt", "custodyOrder"."created")', 'DESC') + // Two orders can share a timestamp, and an undefined order among them would let rows swap + // places between calls - or cross the cap below and vanish. The id keeps it deterministic. + .addOrderBy('custodyOrder.id', 'DESC') + // limit, not take: take() splits the query in two and parses the raw orderBy at every dot, + // which turns the expression above into a lookup for an alias named COALESCE("custodyOrder" + // and throws on every call. Every relation joined here is to-one, so no row can be + // duplicated and limiting rows is the same as limiting entities. + .limit(100) + .getMany() + ); + } } diff --git a/src/subdomains/core/custody/services/custody-order.service.ts b/src/subdomains/core/custody/services/custody-order.service.ts index 56ca183897..9daad1ae2f 100644 --- a/src/subdomains/core/custody/services/custody-order.service.ts +++ b/src/subdomains/core/custody/services/custody-order.service.ts @@ -242,26 +242,7 @@ export class CustodyOrderService { userDataId: number, fields: ReadonlyArray = CUSTODY_ORDER_HISTORY_PROJECTION.fields, ): Promise { - const orders = await CUSTODY_ORDER_HISTORY_PROJECTION.apply( - this.custodyOrderRepo.createQueryBuilder('custodyOrder'), - fields, - ) - .innerJoin('custodyOrder.user', 'user') - .innerJoin('user.userData', 'userData') - .where('userData.id = :userDataId', { userDataId }) - .andWhere('custodyOrder.status != :createdStatus', { createdStatus: CustodyOrderStatus.CREATED }) - // The list shows completedAt where the order is completed, created otherwise. Sorting by - // created alone would put rows out of order against the dates the reader can see. - .orderBy('COALESCE("custodyOrder"."completedAt", "custodyOrder"."created")', 'DESC') - // Two orders can share a timestamp, and an undefined order among them would let rows swap - // places between calls - or cross the cap below and vanish. The id keeps it deterministic. - .addOrderBy('custodyOrder.id', 'DESC') - // limit, not take: take() splits the query in two and parses the raw orderBy at every dot, - // which turns the expression above into a lookup for an alias named COALESCE("custodyOrder" - // and throws on every call. Every relation joined here is to-one, so no row can be - // duplicated and limiting rows is the same as limiting entities. - .limit(100) - .getMany(); + const orders = await this.custodyOrderRepo.findHistoryFor(userDataId, fields); return CustodyOrderHistoryDtoMapper.mapList(orders); } diff --git a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts index 51839ebbea..dc7be670b6 100644 --- a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts +++ b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts @@ -1,5 +1,5 @@ +import { createMock } from '@golevelup/ts-jest'; import { ConfigService } from 'src/config/config'; -import { Country } from 'src/shared/models/country/country.entity'; import { createProjectionDataSource, describeProjection, @@ -9,19 +9,24 @@ import { projectionFieldsWithout, seedEntity, } from 'src/shared/utils/projection-test.util'; -import { PaymentLinkConfig } from 'src/subdomains/core/payment-link/entities/payment-link.config'; import { PaymentLink } from 'src/subdomains/core/payment-link/entities/payment-link.entity'; import { POS_LINK_PROJECTION, POS_LINK_RESPONSE_FIELDS, PaymentLinkRepository, } from 'src/subdomains/core/payment-link/repositories/payment-link.repository'; -import { BankData } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; +import { C2BPaymentLinkService } from 'src/subdomains/core/payment-link/services/c2b-payment-link.service'; +import { PaymentLinkPaymentService } from 'src/subdomains/core/payment-link/services/payment-link-payment.service'; +import { PaymentLinkService } from 'src/subdomains/core/payment-link/services/payment-link.service'; +import { PaymentQuoteService } from 'src/subdomains/core/payment-link/services/payment-quote.service'; import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; -import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; +import { BankData } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; import { Organization } from 'src/subdomains/generic/user/models/organization/organization.entity'; +import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { DepositRouteService } from 'src/subdomains/supporting/address-pool/route/deposit-route.service'; import { DataSource } from 'typeorm'; const SCHEMA = 'pos_link_projection_spec'; @@ -29,24 +34,40 @@ const SCHEMA = 'pos_link_projection_spec'; /** * `PUT /paymentLink/:id/pos` — the four levels from `docs/read-path-projections.md`. * - * The link was loaded with its route, its user, the account and the account's organization, which - * came to 513 columns for a recipient block and two configuration strings. + * The link was loaded with its route, its user, the account and the account's organization: 513 + * columns for a URL built from one identifier and one access key. * - * The endpoint writes as well as reads, but only through `update(id, …)` — it never saves the row - * it read — so a projected read cannot blank a column it did not load. What it can do is lose the - * existing configuration, because the write merges into it; `config` is asserted here for that. + * The endpoint is driven through `PaymentLinkService.createPosLinkAdmin` rather than through a + * rebuilt query, so what these levels compare is the answer the endpoint gives. Its collaborators + * are mocked except the repository under test; the account-side write goes through + * `UserDataService`, which is asserted on rather than executed, and the write itself is covered + * separately below. */ describeProjection('point-of-sale link — read-path projection', () => { let dataSource: DataSource; let paymentLinks: PaymentLinkRepository; + let userDataService: UserDataService; + let service: PaymentLinkService; beforeAll(async () => { - // The recipient defaults come from the module-level Config. + // The URL prefix comes from the module-level Config. new ConfigService(); dataSource = await createProjectionDataSource(SCHEMA); paymentLinks = new PaymentLinkRepository(dataSource.manager); }, 300000); + beforeEach(() => { + userDataService = createMock(); + service = new PaymentLinkService( + paymentLinks, + createMock(), + createMock(), + userDataService, + createMock(), + createMock(), + ); + }); + afterAll(async () => { await destroyProjectionDataSource(dataSource, SCHEMA); }); @@ -54,24 +75,19 @@ describeProjection('point-of-sale link — read-path projection', () => { /** * A link on a route of an account. * - * `accountType` is set explicitly: it is a TypeScript enum in a text column and `UserData.address` - * branches on it, so a generated value silently selects the personal address on an account whose - * data lives on the organization. + * `accountType` is set explicitly because `UserData.address` branches on it — the endpoint does + * not read that address, and the organization fixture below is what shows that it does not. */ async function seedLink( accountType = AccountType.PERSONAL, account: Partial = {}, link: Partial = {}, ): Promise<{ paymentLink: PaymentLink; userData: UserData }> { - const country = await seedEntity(dataSource, Country); - const organizationCountry = await seedEntity(dataSource, Country); - const organization = await seedEntity(dataSource, Organization, { - values: { country: organizationCountry }, - }); + const organization = await seedEntity(dataSource, Organization); const userData = await seedEntity(dataSource, UserData, { // `paymentLinksConfig` holds JSON and is read through `JSON.parse`; a generated string throws // before any assertion is reached. - values: { country, organization, accountType, paymentLinksConfig: '{}', ...account }, + values: { organization, accountType, paymentLinksConfig: '{}', ...account }, }); const user = await seedEntity(dataSource, User, { values: { userData } }); // An active sell route carries a check constraint requiring a bank data row. @@ -83,180 +99,144 @@ describeProjection('point-of-sale link — read-path projection', () => { return { paymentLink, userData }; } + /** A configuration carrying one access key, as the endpoint stores it. */ + const withKey = (key: string): string => JSON.stringify({ accessKeys: [key] }); + /** - * The three configuration sources the endpoint can answer from, as the service reads them. + * The `key` parameter of the URL the endpoint answers with. * - * `scoped` selects between them: unset merges the account and the link, `true` takes the link - * alone, `false` the account alone. + * Read off the query string rather than through `URL`: the prefix comes from the configuration + * and is not necessarily absolute. */ - async function posConfigOf( + const keyOf = (url: string): string => new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('key'); + + /** The answer of the endpoint, through the projected query. */ + async function posLinkOf( id: number, + scoped?: boolean, fields = POS_LINK_PROJECTION.fields, - ): Promise<{ - merged: PaymentLinkConfig; - linkOnly: PaymentLinkConfig; - accountOnly: PaymentLinkConfig; - uniqueId: string; - storedConfig: string; - }> { - const link = await paymentLinks.findForPosLink(id, fields); - return { - merged: link.configObj, - linkOnly: link.linkConfigObj, - accountOnly: link.route.userData.paymentLinksConfigObj, - uniqueId: link.uniqueId, - // What the write merges into. A projection that dropped it would reset the configuration. - storedConfig: link.config, - }; + ): Promise<{ url: string; key: string }> { + jest + .spyOn(paymentLinks, 'findForPosLink') + .mockImplementationOnce((linkId) => + POS_LINK_PROJECTION.apply(paymentLinks.createQueryBuilder('paymentLink'), fields) + .where('paymentLink.id = :linkId', { linkId }) + .getOne(), + ); + + const url = await service.createPosLinkAdmin(id, scoped); + + return { url, key: keyOf(url) }; } // --- LEVEL 1: completeness --- // - it('level 1 — a link answers with a complete recipient', async () => { - const { paymentLink } = await seedLink(); + it('level 1 — a link with a stored key answers with it', async () => { + const { paymentLink } = await seedLink(AccountType.PERSONAL, {}, { config: withKey('stored-on-the-link') }); - const config = await posConfigOf(paymentLink.id); + const answer = await posLinkOf(paymentLink.id); - expect(config.merged.recipient).toBeDefined(); - expectNoEmptyFields(config.merged.recipient); - expect(config.uniqueId).toBeDefined(); + expect(answer.key).toEqual('stored-on-the-link'); + expectNoEmptyFields(answer); }, 120000); // --- LEVEL 2: variants --- // + it.each([ + ['unset, which merges the account into the link', undefined], + ['true, which reads the link alone', true], + ])( + 'level 2 — with scoped %s the key comes from the link', + async (_name, scoped) => { + const { paymentLink } = await seedLink(AccountType.PERSONAL, {}, { config: withKey('from-the-link') }); + + expect((await posLinkOf(paymentLink.id, scoped as boolean)).key).toEqual('from-the-link'); + }, + 120000, + ); + + it.each([ + ['unset, which merges the account into the link', undefined], + ['false, which reads the account alone', false], + ])( + 'level 2 — with scoped %s the key comes from the account', + async (_name, scoped) => { + const { paymentLink } = await seedLink(AccountType.PERSONAL, { paymentLinksConfig: withKey('from-the-account') }); + + expect((await posLinkOf(paymentLink.id, scoped as boolean)).key).toEqual('from-the-account'); + }, + 120000, + ); + it.each([AccountType.ORGANIZATION, AccountType.SOLE_PROPRIETORSHIP])( - 'level 2 — on a %s account the recipient address comes from the organization', + 'level 2 — a %s account answers without reading its address', async (accountType) => { - const { paymentLink, userData } = await seedLink(accountType); - - const config = await posConfigOf(paymentLink.id); + // `configObj` assembles a recipient block the endpoint discards, and that block reads + // `UserData.address`, a getter that switches to the organization row for these two account + // types. The projection joins no organization — this is the case that shows it needs none. + const { paymentLink } = await seedLink(accountType, {}, { config: withKey('regardless-of-address') }); - // `UserData.address` switches rows entirely for these two account types. Reading the personal - // columns here would answer with an address that belongs to the wrong entity. - expect(config.merged.recipient.address.street).toEqual(userData.organization.street); - expect(config.merged.recipient.address.street).not.toEqual(userData.street); - expectNoEmptyFields(config.merged.recipient); + expect((await posLinkOf(paymentLink.id)).key).toEqual('regardless-of-address'); }, 120000, ); - it('level 2 — an account without a country answers without an address block', async () => { - const { paymentLink } = await seedLink(AccountType.PERSONAL, { country: null }); + it('level 2 — a link without a stored key gets one, written to the link', async () => { + const { paymentLink } = await seedLink(); - // The address is emitted only when the country is there; the rest of the recipient must stay. - const config = await posConfigOf(paymentLink.id); + const answer = await posLinkOf(paymentLink.id, true); - expect(config.merged.recipient.address).toBeUndefined(); - expect(config.merged.recipient.mail).toBeDefined(); + const stored = await dataSource.getRepository(PaymentLink).findOneBy({ id: paymentLink.id }); + expect(JSON.parse(stored.config).accessKeys).toEqual([answer.key]); }, 120000); - it('level 2 — the link configuration overrides the account configuration', async () => { - const { paymentLink } = await seedLink( - AccountType.PERSONAL, - { paymentLinksConfig: JSON.stringify({ recipient: { name: 'from-account' } }) }, - { config: JSON.stringify({ recipient: { name: 'from-link' } }) }, - ); + it('level 2 — the unscoped branch writes through the account service instead', async () => { + const { paymentLink, userData } = await seedLink(); - const config = await posConfigOf(paymentLink.id); + const answer = await posLinkOf(paymentLink.id, false); - expect(config.merged.recipient.name).toEqual('from-link'); - expect(config.linkOnly.recipient.name).toEqual('from-link'); - expect(config.accountOnly.recipient.name).toEqual('from-account'); - expect(config.storedConfig).toContain('from-link'); + expect(userDataService.updatePaymentLinksConfig).toHaveBeenCalledWith( + expect.objectContaining({ id: userData.id }), + { + accessKeys: [answer.key], + }, + ); }, 120000); - // --- LEVEL 3: mutation --- // + it('level 2 — an unknown id is refused', async () => { + const { paymentLink } = await seedLink(); - /** The five address columns of the account, read only for a personal account. */ - const PERSONAL_ADDRESS = [ - 'posUserData.street', - 'posUserData.houseNumber', - 'posUserData.location', - 'posUserData.zip', - 'posCountry.symbol', - ]; - - /** The same five, read off the organization for the other account types. */ - const ORGANIZATION_ADDRESS = [ - 'posOrganization.street', - 'posOrganization.houseNumber', - 'posOrganization.location', - 'posOrganization.zip', - 'posOrganizationCountry.symbol', - ]; + await expect(posLinkOf(paymentLink.id + 1_000_000)).rejects.toThrow('Payment link not found'); + }, 120000); - /** - * The name, which is `organizationName` falling back to the two personal ones. - * - * Typed as a mutation candidate: a group of fields is one candidate, because dropping any single - * member leaves the value filled by the next alternative. - */ - const NAME_FIELDS: string[] = ['posUserData.organizationName', 'posUserData.firstname', 'posUserData.surname']; + // --- LEVEL 3: mutation --- // - /** - * Two configurations that differ from the defaults and from each other, so both columns are - * required — set on `fee` rather than on `recipient`, which would mask the columns the recipient - * is built from and make them look removable. - */ - const ACCOUNT_CONFIG = JSON.stringify({ fee: 0.5 }); - const LINK_CONFIG = JSON.stringify({ fee: 0.7 }); + /** The fields each configuration source contributes, keyed by the fixture that reads it. */ + const LINK_FIELDS = ['paymentLink.uniqueId', 'paymentLink.config']; + const ACCOUNT_FIELDS = ['paymentLink.uniqueId', 'posUserData.paymentLinksConfig']; + // Only one of the two configurations is consulted per call, so each is a candidate in the fixture + // that reads it and not in the other — dropping the unread one changes nothing, which is true and + // proves nothing. it.each([ - // `UserData.address` reads one row or the other, never both, so the unread half is droppable for - // the account type at hand — and covered by the other row of this table. - // `accountType` is skipped for the personal row for the same reason: dropped it reads - // undefined, which is not one of the two organization types either, so the branch and the - // answer are unchanged. The organization row is where it becomes visible. - ['personal', AccountType.PERSONAL, [...ORGANIZATION_ADDRESS, 'posUserData.accountType']], - ['organization', AccountType.ORGANIZATION, PERSONAL_ADDRESS], - ])( - 'level 3 — on a %s account every field feeding the answer is required', - async (_name, accountType, skipped) => { - const { paymentLink } = await seedLink( - accountType, - { paymentLinksConfig: ACCOUNT_CONFIG }, - { config: LINK_CONFIG }, - ); - - const candidates: (string | string[])[] = POS_LINK_RESPONSE_FIELDS.filter( - // The name falls back, so no single one of its three columns is required here; the case - // below is the one that reaches the fallback. - (field) => !skipped.includes(field) && !NAME_FIELDS.includes(field), - ); - - await expectEveryFieldRequired([...candidates, NAME_FIELDS], (omitted) => - posConfigOf(paymentLink.id, projectionFieldsWithout(POS_LINK_PROJECTION.fields, omitted)), + ['the link', undefined, { config: withKey('mutation-link') }, {}, LINK_FIELDS], + ['the account', false, {}, { paymentLinksConfig: withKey('mutation-account') }, ACCOUNT_FIELDS], + ] as [string, boolean, Partial, Partial, string[]][])( + 'level 3 — with the key on %s every field feeding the answer is required', + async (_name, scoped, link, account, candidates) => { + const { paymentLink } = await seedLink(AccountType.PERSONAL, account, link); + + await expectEveryFieldRequired(candidates, (omitted) => + posLinkOf(paymentLink.id, scoped, projectionFieldsWithout(POS_LINK_PROJECTION.fields, omitted)), ); }, 300000, ); - it('level 3 — the personal name is required when the account has no organization name', async () => { - // `completeName` is `organizationName ?? firstname + surname`. With the organization name set, - // the two personal columns are unreachable and report as removable — true, and useless. - const { paymentLink } = await seedLink( - AccountType.PERSONAL, - { organizationName: null, paymentLinksConfig: ACCOUNT_CONFIG }, - { config: LINK_CONFIG }, - ); - - await expectEveryFieldRequired(['posUserData.firstname', 'posUserData.surname'], (omitted) => - posConfigOf(paymentLink.id, projectionFieldsWithout(POS_LINK_PROJECTION.fields, omitted)), - ); - }, 300000); - - // --- the projection must not lose the guards the write depends on --- // - - it('loads the two ids the endpoint scopes its updates by', async () => { - // Neither appears in the answer, so no level would notice their absence — and both updates are - // scoped by them: the link's own configuration and the account's payment-link configuration. - const { paymentLink, userData } = await seedLink(); - - const loaded = await paymentLinks.findForPosLink(paymentLink.id); - - expect(loaded.id).toEqual(paymentLink.id); - expect(loaded.route.userData.id).toEqual(userData.id); - }, 120000); + it('level 3 — the two fixtures together cover every field of the projection', () => { + // Splitting the candidates per fixture is only sound if nothing falls between them. + expect([...new Set([...LINK_FIELDS, ...ACCOUNT_FIELDS])].sort()).toEqual([...POS_LINK_RESPONSE_FIELDS].sort()); + }); // --- the claim that makes this endpoint convertible at all --- // @@ -275,7 +255,7 @@ describeProjection('point-of-sale link — read-path projection', () => { const before = await rowOf(); const loaded = await paymentLinks.findForPosLink(paymentLink.id); - const written = JSON.stringify({ accessKeys: ['written-key'] }); + const written = withKey('written-key'); if (table === 'payment_link') await paymentLinks.update(loaded.id, { config: written }); else await dataSource.getRepository(UserData).update(loaded.route.userData.id, { paymentLinksConfig: written }); @@ -292,15 +272,25 @@ describeProjection('point-of-sale link — read-path projection', () => { ); it('carries the stored configuration verbatim, so the write has something to merge into', async () => { - // The write merges the new access key into what was read. A projection that dropped `config` - // would hand the merge an empty object and reset the configuration to just the key. - const existing = JSON.stringify({ fee: 0.9, recipient: { name: 'existing' } }); + // The scoped write merges the new key into what was read. A projection that dropped `config` + // would hand the merge an empty object and reset the configuration to nothing but the key. + const existing = JSON.stringify({ fee: 0.9, cancellable: true }); const { paymentLink } = await seedLink(AccountType.PERSONAL, {}, { config: existing }); const loaded = await paymentLinks.findForPosLink(paymentLink.id); expect(loaded.config).toEqual(existing); - expect(JSON.parse(loaded.config)).toEqual({ fee: 0.9, recipient: { name: 'existing' } }); + }, 120000); + + it('loads the two ids the endpoint scopes its updates by', async () => { + // Neither appears in the answer, so no level would notice their absence — and both updates are + // scoped by them. + const { paymentLink, userData } = await seedLink(); + + const loaded = await paymentLinks.findForPosLink(paymentLink.id); + + expect(loaded.id).toEqual(paymentLink.id); + expect(loaded.route.userData.id).toEqual(userData.id); }, 120000); // --- LEVEL 4: consistency against a second source --- // @@ -308,19 +298,19 @@ describeProjection('point-of-sale link — read-path projection', () => { it.each([AccountType.PERSONAL, AccountType.ORGANIZATION])( 'level 4 — for %s the projected answer equals the one from a full load', async (accountType) => { - const { paymentLink } = await seedLink(accountType); + const { paymentLink } = await seedLink(accountType, {}, { config: withKey('same-either-way') }); - const projected = await posConfigOf(paymentLink.id); + const projected = await posLinkOf(paymentLink.id); // The unprojected load is the second source: the relation set the endpoint used before. - const full = await dataSource.getRepository(PaymentLink).findOne({ - where: { id: paymentLink.id }, - relations: { route: { user: { userData: { organization: true } } } }, - }); - - expect(projected.merged).toEqual(full.configObj); - expect(projected.linkOnly).toEqual(full.linkConfigObj); - expect(projected.accountOnly).toEqual(full.route.userData.paymentLinksConfigObj); - expect(projected.storedConfig).toEqual(full.config); + jest.spyOn(paymentLinks, 'findForPosLink').mockImplementationOnce((id) => + paymentLinks.findOne({ + where: { id }, + relations: { route: { user: { userData: { organization: true } } } }, + }), + ); + const full = await service.createPosLinkAdmin(paymentLink.id); + + expect(projected.url).toEqual(full); }, 120000, ); diff --git a/src/subdomains/core/payment-link/repositories/payment-link.repository.ts b/src/subdomains/core/payment-link/repositories/payment-link.repository.ts index 613bc43ac0..017785ef4f 100644 --- a/src/subdomains/core/payment-link/repositories/payment-link.repository.ts +++ b/src/subdomains/core/payment-link/repositories/payment-link.repository.ts @@ -6,45 +6,32 @@ import { PaymentLink } from '../entities/payment-link.entity'; import { PaymentLinkPaymentStatus } from '../enums'; /** - * What `createPosLinkFor` reads to build a point-of-sale link. + * What `PUT /paymentLink/:id/pos` reads. * - * Three configuration sources can be merged into the answer, and which ones depends on the `scoped` - * argument: the link's own config, the account's, and the recipient block the account's address and - * contact data make up. The union of the three is the field list. + * The endpoint answers with a URL built from `uniqueId` and one access key. The key comes out of a + * configuration, and which of the three configurations is consulted depends on the `scoped` + * argument — the link's own, the account's, or the two merged. `accessKeys` is the only value read + * out of them, so the two columns holding them are the whole field list. * - * The organization side is here because `UserData.address` switches to it for organization and - * sole-proprietorship accounts — the same five values, read off another row. + * `accountType` is deliberately NOT selected. `configObj` also assembles a recipient block, which + * this endpoint discards, and that block reads `UserData.address` — a getter that switches to the + * organization row for an organization account and would dereference a relation this query has no + * reason to join. Left unselected, the getter takes its other branch and reads columns that are + * simply absent, which nothing here looks at. */ export const POS_LINK_RESPONSE_FIELDS = [ 'paymentLink.uniqueId', 'paymentLink.config', - 'posUserData.accountType', - // `completeName`: the organization name, falling back to the two personal ones. - 'posUserData.organizationName', - 'posUserData.firstname', - 'posUserData.surname', - 'posUserData.phone', - 'posUserData.mail', 'posUserData.paymentLinksConfig', - 'posUserData.street', - 'posUserData.houseNumber', - 'posUserData.location', - 'posUserData.zip', - 'posCountry.symbol', - 'posOrganization.street', - 'posOrganization.houseNumber', - 'posOrganization.location', - 'posOrganization.zip', - 'posOrganizationCountry.symbol', ]; /** * `PUT /paymentLink/:id/pos` — 513 columns before. * * The endpoint writes, but through `update(id, …)` on the link and on the account rather than by - * saving either row back, so a projected read cannot blank a column it did not load. `config` is - * part of the projection for that reason as much as for the response: the write merges the new - * access key into the existing configuration, and a config the query did not load would be a + * saving either row back, so a projected read cannot blank a column it did not load. `config` and + * `paymentLinksConfig` are in the projection for the write as much as for the answer: it merges the + * new key into whichever of them applies, and a configuration the query failed to load would be a * configuration silently reset. */ export const POS_LINK_PROJECTION = new ReadProjection( @@ -53,22 +40,11 @@ export const POS_LINK_PROJECTION = new ReadProjection( ['paymentLink.route', 'posRoute'], ['posRoute.user', 'posUser'], ['posUser.userData', 'posUserData'], - ['posUserData.country', 'posCountry'], - ['posUserData.organization', 'posOrganization'], - ['posOrganization.country', 'posOrganizationCountry'], ], POS_LINK_RESPONSE_FIELDS, // Never part of the answer: the primary keys that make the ORM materialise the joined rows, and // the two ids the two updates are scoped by. - [ - 'paymentLink.id', - 'posRoute.id', - 'posUser.id', - 'posUserData.id', - 'posCountry.id', - 'posOrganization.id', - 'posOrganizationCountry.id', - ], + ['paymentLink.id', 'posRoute.id', 'posUser.id', 'posUserData.id'], ); @Injectable() @@ -83,7 +59,10 @@ export class PaymentLinkRepository extends BaseRepository { * `fields` is what the mutation test in `pos-link.projection.spec.ts` re-runs the query with; * `PaymentLinkService.createPosLinkAdmin` calls this without it. */ - async findForPosLink(id: number, fields: ReadonlyArray = POS_LINK_PROJECTION.fields): Promise { + async findForPosLink( + id: number, + fields: ReadonlyArray = POS_LINK_PROJECTION.fields, + ): Promise { return POS_LINK_PROJECTION.apply(this.createQueryBuilder('paymentLink'), fields) .where('paymentLink.id = :id', { id }) .getOne(); From 8cf1bf7a29dadb0096ee0c32f069c321efdf4006 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:09:05 +0200 Subject: [PATCH 21/46] Say what is true in the comments, and let the types say what can be null Nine repository methods built on `getOne()` declared a non-nullable entity. TypeORM returns null there, and several callers already handle it - the declaration was the only thing claiming otherwise. Four comments named the wrong thing: a mapper that had been replaced, a service method that had been renamed, the list test where the data test belonged, and one comment repeated across three methods claiming all three specs cover each of them. Each now names the test and the caller that actually apply to it. Three more made claims the repository cannot support: that a fixture is the ordinary case in production, that pasted phone numbers are the realistic trigger for an integer overflow, and that a payout transaction always carries an output asset. What is left is what the code shows: which branch the fixture takes, that a value above int4 makes Postgres fail the statement, and that the mapper dereferences a nullable column without a guard. Also types the correlated subquery factory in the message aggregate, which reached its parameter as an implicit `any`. --- docs/endpoints.md | 64 +++++++------- docs/load-sites.md | 84 +++++++++---------- ...iquidity-management-pipeline.repository.ts | 2 +- .../buy-fiat-history.projection.spec.ts | 6 +- .../process/buy-fiat.repository.ts | 2 +- .../models/user-data/user-data.repository.ts | 21 +++-- .../__tests__/user-profile.projection.spec.ts | 2 +- .../user/models/user/user.repository.ts | 2 +- .../user/models/wallet/wallet.repository.ts | 6 +- .../support-issue-list.projection.spec.ts | 4 +- .../repositories/support-issue.repository.ts | 14 ++-- .../support-message.repository.ts | 20 +++-- 12 files changed, 115 insertions(+), 112 deletions(-) diff --git a/docs/endpoints.md b/docs/endpoints.md index a277957fbc..4b49bce4a2 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -34,9 +34,9 @@ Today 38 endpoints read only what they return and 398 do not, so the column read | `projected` | 36 | 7 % | | `caller-defined` | 2 | 0 % | -Of the 36 that read only what they return, 17 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11), `GET /kyc/users` (328 columns to 7), `GET /kyc/:id/documents` (328 columns to 2), `GET /custody/order` (19 columns to 14), `GET /support/issue/list` (16 columns to 10), `GET /realunit/support/list` (16 columns to 10), `GET /dashboard/accounting/ledger/suspense` (11 columns to 10), `GET /liquidityManagement/pipeline/:id/status` (112 columns to 2), `PUT /paymentLink/:id/pos` (513 columns to 26), `POST /user/apiKey/CT` (253 columns to 3), `GET /user` (351 columns to 66). The other 19 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. +Of the 36 that read only what they return, 17 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 0), `GET /buy/:id/history` (497 columns to 0), `GET /swap/:id/history` (509 columns to 0), `GET /sell/:id/history` (470 columns to 0), `GET /support/issue/:id/data` (951 columns to 0), `GET /support/issue` (450 columns to 0), `GET /support/issue/:id` (450 columns to 0), `GET /kyc/users` (328 columns to 0), `GET /kyc/:id/documents` (328 columns to 0), `GET /custody/order` (19 columns to 0), `GET /support/issue/list` (16 columns to 0), `GET /realunit/support/list` (16 columns to 1), `GET /dashboard/accounting/ledger/suspense` (11 columns to 0), `GET /liquidityManagement/pipeline/:id/status` (112 columns to 2), `PUT /paymentLink/:id/pos` (513 columns to 0), `POST /user/apiKey/CT` (253 columns to 0), `GET /user` (351 columns to 0). The other 19 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. -Among the 398 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 307 exceed 100, 86 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. +Among the 398 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 299 exceed 100, 78 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. ### How to read this column, and how not to @@ -53,7 +53,7 @@ Stated exactly, so the numbers can be checked rather than believed: - **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 398 is a lower bound. - All 98 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. - The 36 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. -- 2 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`. The classification holds; only the width is unknown. +- 4 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /custody/admin/order/:id/approve`, `GET /custody/admin/orders`, `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`. The classification holds; only the width is unknown. ### Two controller classes may share a name @@ -165,7 +165,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/buy` | hidden | whole rows | 364 | not yet | | `BuyController.createBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | GET | 1 | | `/buy/:id` | public | whole rows | 308 | not yet | | `BuyController.getBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/:id` | hidden | whole rows | 308 | not yet | | `BuyController.updateBuyRoute` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| GET | 1 | | `/buy/:id/history` | hidden | projected | 12 | 4/4 | | `BuyController.getBuyRouteHistory` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| GET | 1 | | `/buy/:id/history` | hidden | projected | — | 4/4 | | `BuyController.getBuyRouteHistory` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/paymentInfos` | public | whole rows | 364 | not yet | | `BuyController.createBuyWithPaymentInfo` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/paymentInfos/:id/confirm` | public | whole rows | 504 | not yet | | `BuyController.confirmBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/paymentInfos/:id/invoice` | public | whole rows | 504 | not yet | yes | `BuyController.generateInvoicePDF` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | @@ -208,20 +208,20 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/custody/account/:id/history` | public | whole rows | 253 | not yet | | `CustodyAccountController.getAccountHistory` | `subdomains/core/custody/controllers/custody-account.controller.ts` | | GET | 1 | | `/custody/account/:id/order` | public | whole rows | 253 | not yet | | `CustodyAccountController.getAccountOrders` | `subdomains/core/custody/controllers/custody-account.controller.ts` | | GET | 1 | | `/custody/account/:id/pdf` | public | whole rows | 253 | not yet | | `CustodyAccountController.getAccountPdf` | `subdomains/core/custody/controllers/custody-account.controller.ts` | -| POST | 1 | | `/custody/admin/order/:id/approve` | public | whole rows | 217 | not yet | | `CustodyAdminController.approveOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | -| GET | 1 | | `/custody/admin/orders` | public | whole rows | 525 | not yet | | `CustodyAdminController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | +| POST | 1 | | `/custody/admin/order/:id/approve` | public | whole rows | — | not yet | | `CustodyAdminController.approveOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | +| GET | 1 | | `/custody/admin/orders` | public | whole rows | — | not yet | | `CustodyAdminController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | | PUT | 1 | | `/custody/admin/user/:id/balance` | public | whole rows | 308 | not yet | | `CustodyAdminController.updateUserBalance` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/custody/history` | public | whole rows | 253 | not yet | | `CustodyController.getUserCustodyHistory` | `subdomains/core/custody/controllers/custody.controller.ts` | -| GET | 1 | | `/custody/order` | public | projected | 14 | 4/4 | | `CustodyController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | +| GET | 1 | | `/custody/order` | public | projected | — | 4/4 | | `CustodyController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | | POST | 1 | | `/custody/order` | public | whole rows | 364 | not yet | | `CustodyController.createOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | -| POST | 1 | | `/custody/order/:id/confirm` | public | whole rows | 525 | not yet | | `CustodyController.confirmOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | +| POST | 1 | | `/custody/order/:id/confirm` | public | whole rows | 8 | not yet | | `CustodyController.confirmOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/custody/pdf` | public | whole rows | 253 | not yet | | `CustodyController.getCustodyPdf` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/accounts` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getAccounts` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/accounts/:accountId/legs` | hidden | whole rows | 30 | not yet | yes | `LedgerController.getAccountDetail` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/equity-comparison` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getEquityComparison` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/margin` | hidden | projected | 11 | 0/4 | yes | `LedgerController.getMargin` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/reconciliation` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getReconStatus` | `subdomains/core/accounting/controllers/ledger.controller.ts` | -| GET | 1 | | `/dashboard/accounting/ledger/suspense` | hidden | projected | 10 | 4/4 | yes | `LedgerController.getSuspense` | `subdomains/core/accounting/controllers/ledger.controller.ts` | +| GET | 1 | | `/dashboard/accounting/ledger/suspense` | hidden | projected | — | 4/4 | yes | `LedgerController.getSuspense` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/financial/changes` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getFinancialChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/changes/latest` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getLatestChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/latest` | hidden | none | — | n/a | | `DashboardFinancialController.getLatestBalance` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | @@ -288,7 +288,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | yes | `/kyc/:code` | public | whole rows | 351 | not yet | | `KycController.getKycProgressByCodeV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | POST | 1 | yes | `/kyc/:code` | public | whole rows | 351 | not yet | | `KycController.requestKycByCodeV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | yes | `/kyc/:code/countries` | public | whole rows | 351 | not yet | | `KycController.getKycCountriesByCodeV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | -| GET | 1 | yes | `/kyc/:id/documents` | public | projected | 2 | 4/4 | | `KycClientController.getKycFilesV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | +| GET | 1 | yes | `/kyc/:id/documents` | public | projected | — | 4/4 | | `KycClientController.getKycFilesV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | yes | `/kyc/:id/documents/:type` | public | whole rows | 328 | not yet | | `KycClientController.getKycFileV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 2 | | `/kyc/:step` | hidden | whole rows | 364 | not yet | | `KycController.initiateStep` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | DELETE | 1 | | `/kyc/admin/blacklist/ip` | hidden | none | — | n/a | | `KycAdminController.deleteIpToBlacklist` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | @@ -334,7 +334,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | DELETE | 2 | | `/kyc/transfer` | hidden | whole rows | 351 | not yet | | `KycController.removeKycClient` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | POST | 2 | | `/kyc/transfer` | hidden | whole rows | 364 | not yet | | `KycController.addKycClient` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | PUT | 1 | yes | `/kyc/transfer` | public | whole rows | 364 | not yet | | `KycController.transferKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | -| GET | 1 | yes | `/kyc/users` | public | projected | 7 | 4/4 | | `KycClientController.getAllKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | +| GET | 1 | yes | `/kyc/users` | public | projected | — | 4/4 | | `KycClientController.getAllKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | | `/language` | public | whole rows | 7 | not yet | | `LanguageController.getAllLanguage` | `shared/models/language/language.controller.ts` | | PUT | 1 | | `/limitRequest/:id` | hidden | whole rows | 434 | not yet | | `LimitRequestController.updateUserData` | `subdomains/supporting/support-issue/limit-request.controller.ts` | | GET | 1 | | `/liquidityManagement/balance` | hidden | whole rows | 40 | not yet | | `LiquidityBalanceController.getBalances` | `subdomains/core/liquidity-management/controllers/balance.controller.ts` | @@ -386,16 +386,16 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/payIn/lnurlpPayment/:uniqueId` | hidden | none | — | n/a | | `PayInWebhookController.payment` | `subdomains/supporting/payin/controllers/payin-webhook.controller.ts` | | POST | 1 | | `/payIn/poll` | hidden | none | — | n/a | | `PayInController.pollAddress` | `subdomains/supporting/payin/controllers/payin.controller.ts` | | POST | 1 | | `/payIn/retry` | hidden | whole rows | — | not yet | | `PayInController.retryUncertainSend` | `subdomains/supporting/payin/controllers/payin.controller.ts` | -| GET | 1 | | `/paymentLink` | public | whole rows | 513 | not yet | yes | `PaymentLinkController.getAllPaymentLinks` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| GET | 1 | | `/paymentLink` | public | whole rows | 32 | not yet | yes | `PaymentLinkController.getAllPaymentLinks` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | POST | 1 | | `/paymentLink` | public | whole rows | 545 | not yet | | `PaymentLinkController.createPaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| PUT | 1 | | `/paymentLink` | public | whole rows | 513 | not yet | | `PaymentLinkController.updatePaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| PUT | 1 | | `/paymentLink` | public | whole rows | 32 | not yet | | `PaymentLinkController.updatePaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | DELETE | 1 | | `/paymentLink/:id` | hidden | whole rows | 195 | not yet | | `PaymentLinkController.deletePaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/:id` | hidden | whole rows | 513 | not yet | | `PaymentLinkController.updatePaymentLinkAdmin` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| PUT | 1 | | `/paymentLink/:id/pos` | hidden | projected | 26 | 4/4 | | `PaymentLinkController.createPosLinkAdmin` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| PUT | 1 | | `/paymentLink/:id/pos` | hidden | projected | — | 4/4 | | `PaymentLinkController.createPosLinkAdmin` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/assign` | public | whole rows | 513 | not yet | yes | `PaymentLinkController.assignPaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | GET | 1 | | `/paymentLink/config` | public | whole rows | 253 | not yet | | `PaymentLinkController.getUserPaymentLinksConfig` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/config` | public | whole rows | 253 | not yet | | `PaymentLinkController.updateUserPaymentLinksConfig` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| GET | 1 | | `/paymentLink/history` | public | whole rows | 545 | not yet | | `PaymentLinkController.getPaymentHistory` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| GET | 1 | | `/paymentLink/history` | public | whole rows | 513 | not yet | | `PaymentLinkController.getPaymentHistory` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | POST | 1 | | `/paymentLink/integration/binance/activate/:id` | hidden | whole rows | 513 | not yet | | `C2BPaymentLinkController.activateBinancePay` | `subdomains/core/payment-link/controllers/c2b-payment-link.controller.ts` | | POST | 1 | | `/paymentLink/integration/binance/webhook` | hidden | whole rows | 545 | not yet | | `C2BPaymentLinkController.binancePayWebhook` | `subdomains/core/payment-link/controllers/c2b-payment-link.controller.ts` | | POST | 1 | | `/paymentLink/integration/kucoin/activate/:id` | hidden | whole rows | 513 | not yet | | `C2BPaymentLinkController.activateKucoinPay` | `subdomains/core/payment-link/controllers/c2b-payment-link.controller.ts` | @@ -407,13 +407,13 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/paymentLink/payment` | hidden | whole rows | 545 | not yet | yes | `PaymentLinkController.createInvoicePayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | POST | 1 | | `/paymentLink/payment` | public | whole rows | 545 | not yet | | `PaymentLinkController.createPayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/payment/:id` | hidden | whole rows | 545 | not yet | | `PaymentLinkController.updatePaymentLinkPayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| PUT | 1 | | `/paymentLink/payment/confirm` | public | whole rows | 513 | not yet | | `PaymentLinkController.confirmPayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| GET | 1 | | `/paymentLink/payment/wait` | public | whole rows | 513 | not yet | | `PaymentLinkController.waitForPayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| PUT | 1 | | `/paymentLink/pos` | public | whole rows | 513 | not yet | | `PaymentLinkController.createPosLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| PUT | 1 | | `/paymentLink/payment/confirm` | public | whole rows | 32 | not yet | | `PaymentLinkController.confirmPayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| GET | 1 | | `/paymentLink/payment/wait` | public | whole rows | 32 | not yet | | `PaymentLinkController.waitForPayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| PUT | 1 | | `/paymentLink/pos` | public | whole rows | 32 | not yet | | `PaymentLinkController.createPosLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | GET | 1 | | `/paymentLink/recipient` | hidden | whole rows | 472 | not yet | | `PaymentLinkController.getPaymentRecipient` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | GET | 1 | | `/paymentLink/standard` | public | none | — | n/a | | `PaymentStandardController.getAll` | `subdomains/core/payment-link/controllers/payment-standard.controller.ts` | | GET | 1 | | `/paymentLink/standard/:id` | public | none | — | n/a | | `PaymentStandardController.getById` | `subdomains/core/payment-link/controllers/payment-standard.controller.ts` | -| GET | 1 | | `/paymentLink/stickers` | hidden | whole rows | 513 | not yet | yes | `PaymentLinkController.generateOcpStickers` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| GET | 1 | | `/paymentLink/stickers` | hidden | whole rows | 472 | not yet | yes | `PaymentLinkController.generateOcpStickers` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | GET | 1 | | `/paymentLink/walletApp` | public | whole rows | 33 | not yet | | `WalletAppController.getAll` | `subdomains/core/payment-link/controllers/wallet-app.controller.ts` | | GET | 1 | | `/paymentLink/walletApp/:id` | public | whole rows | 33 | not yet | | `WalletAppController.getById` | `subdomains/core/payment-link/controllers/wallet-app.controller.ts` | | GET | 1 | | `/paymentLink/walletApp/recommended` | public | whole rows | 33 | not yet | | `WalletAppController.getRecommended` | `subdomains/core/payment-link/controllers/wallet-app.controller.ts` | @@ -472,14 +472,14 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/realunit/sell/:id/confirm` | public | whole rows | 504 | not yet | | `RealUnitController.confirmSell` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/sell/:id/unsigned-transactions` | public | whole rows | 504 | not yet | | `RealUnitController.getSellUnsignedTransactions` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/support/:id` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.updateSupportIssue` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/:id/data` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.getIssueData` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/:id/data` | hidden | whole rows | 253 | not yet | | `RealUnitSupportController.getIssueData` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | POST | 1 | | `/realunit/support/:id/message` | hidden | whole rows | 441 | not yet | | `RealUnitSupportController.createSupportMessage` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/:id/message/:messageId/file` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.getFile` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/:id/messages` | hidden | whole rows | 428 | not yet | | `RealUnitSupportController.getIssueMessages` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/:id/message/:messageId/file` | hidden | whole rows | 253 | not yet | | `RealUnitSupportController.getFile` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/:id/messages` | hidden | whole rows | 253 | not yet | | `RealUnitSupportController.getIssueMessages` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/activity` | hidden | projected | 2 | 0/4 | | `RealUnitSupportController.getSupportIssueActivity` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/clerks` | hidden | none | — | n/a | | `RealUnitSupportController.getRealUnitSupportClerks` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/counts` | hidden | projected | 2 | 0/4 | | `RealUnitSupportController.getSupportIssueCounts` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/list` | hidden | projected | 10 | 4/4 | | `RealUnitSupportController.getSupportIssueList` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/list` | hidden | projected | 1 | 4/4 | | `RealUnitSupportController.getSupportIssueList` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/statistics` | hidden | projected | 3 | 0/4 | | `RealUnitSupportController.getSupportIssueStatistics` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | PUT | 1 | | `/realunit/swap` | public | whole rows | 308 | not yet | yes | `RealUnitController.getSwapPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/swap/:id/broadcast` | public | whole rows | 504 | not yet | yes | `RealUnitController.broadcastSwapTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | @@ -510,7 +510,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/sell` | hidden | whole rows | 308 | not yet | | `SellController.createSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | GET | 1 | | `/sell/:id` | public | whole rows | 377 | not yet | | `SellController.getSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/:id` | hidden | whole rows | 308 | not yet | | `SellController.updateSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | -| GET | 1 | | `/sell/:id/history` | hidden | projected | 14 | 4/4 | | `SellController.getSellRouteHistory` | `subdomains/core/sell-crypto/route/sell.controller.ts` | +| GET | 1 | | `/sell/:id/history` | hidden | projected | — | 4/4 | | `SellController.getSellRouteHistory` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/paymentInfos` | public | whole rows | 308 | not yet | | `SellController.createSellWithPaymentInfo` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/paymentInfos/:id/confirm` | public | whole rows | 545 | not yet | | `SellController.confirmSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | GET | 1 | | `/sell/paymentInfos/:id/tx` | public | whole rows | 504 | not yet | | `SellController.depositTx` | `subdomains/core/sell-crypto/route/sell.controller.ts` | @@ -533,12 +533,12 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/support/call-queues` | hidden | none | — | n/a | | `SupportController.getCallQueues` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues/:queue/items` | hidden | whole rows | 672 | not yet | | `SupportController.getCallQueueItems` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues/clerks` | hidden | none | — | n/a | | `SupportController.getCallQueueClerks` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/issue` | public | projected | 11 | 4/4 | | `SupportIssueController.getIssues` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue` | public | projected | — | 4/4 | | `SupportIssueController.getIssues` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue` | public | whole rows | 493 | not yet | | `SupportIssueController.createIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/:id` | public | projected | 11 | 4/4 | | `SupportIssueController.getIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/:id` | public | projected | — | 4/4 | | `SupportIssueController.getIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | PUT | 1 | | `/support/issue/:id` | hidden | whole rows | 421 | not yet | | `SupportIssueController.updateSupportIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | PUT | 1 | | `/support/issue/:id/close` | public | whole rows | 450 | not yet | | `SupportIssueController.closeIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/:id/data` | hidden | projected | 81 | 4/4 | | `SupportIssueController.getIssueData` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/:id/data` | hidden | projected | — | 4/4 | | `SupportIssueController.getIssueData` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/:id/message` | public | whole rows | 441 | not yet | yes | `SupportIssueController.createSupportMessage` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/:id/message/:messageId/file` | public | whole rows | — | not yet | | `SupportIssueController.getFile` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/activity` | hidden | projected | 2 | 0/4 | | `SupportIssueController.getSupportIssueActivity` | `subdomains/supporting/support-issue/support-issue.controller.ts` | @@ -548,7 +548,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/support/issue/escalation/telegram-bind` | hidden | whole rows | 5 | not yet | | `SupportIssueController.bindEscalationChat` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/escalation/telegram-chats` | hidden | none | — | n/a | | `SupportIssueController.getEscalationChats` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/escalation/telegram-test` | hidden | whole rows | 5 | not yet | | `SupportIssueController.testEscalationChat` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/list` | hidden | projected | 10 | 4/4 | | `SupportIssueController.getSupportIssueList` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/list` | hidden | projected | — | 4/4 | | `SupportIssueController.getSupportIssueList` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/statistics` | hidden | projected | 3 | 0/4 | | `SupportIssueController.getSupportIssueStatistics` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/support` | hidden | whole rows | 493 | not yet | | `SupportIssueController.createIssueBySupport` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/kycFileList` | hidden | whole rows | 253 | not yet | | `SupportController.getKycFileList` | `subdomains/generic/support/support.controller.ts` | @@ -573,7 +573,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/swap` | hidden | whole rows | 308 | not yet | | `SwapController.createSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | GET | 1 | | `/swap/:id` | public | whole rows | 308 | not yet | | `SwapController.getSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/:id` | hidden | whole rows | 396 | not yet | | `SwapController.updateSwapRoute` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | -| GET | 1 | | `/swap/:id/history` | hidden | projected | 12 | 4/4 | | `SwapController.getSwapRouteHistory` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | +| GET | 1 | | `/swap/:id/history` | hidden | projected | — | 4/4 | | `SwapController.getSwapRouteHistory` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/paymentInfos` | public | whole rows | 308 | not yet | | `SwapController.createSwapWithPaymentInfo` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/paymentInfos/:id/confirm` | public | whole rows | 545 | not yet | | `SwapController.confirmSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | GET | 1 | | `/swap/paymentInfos/:id/tx` | public | whole rows | 504 | not yet | | `SwapController.depositTx` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | @@ -603,7 +603,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | DELETE | 1 | yes | `/user` | public | whole rows | 344 | not yet | | `UserController.deleteUser` | `subdomains/generic/user/models/user/user.controller.ts` | | DELETE | 2 | | `/user` | public | whole rows | 344 | not yet | | `UserV2Controller.deleteAccount` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | yes | `/user` | public | whole rows | 328 | not yet | | `UserController.getUserV1` | `subdomains/generic/user/models/user/user.controller.ts` | -| GET | 2 | | `/user` | public | projected | 66 | 4/4 | | `UserV2Controller.getUser` | `subdomains/generic/user/models/user/user.controller.ts` | +| GET | 2 | | `/user` | public | projected | — | 4/4 | | `UserV2Controller.getUser` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | yes | `/user` | public | whole rows | 406 | not yet | | `UserController.updateUserV1` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 2 | | `/user` | public | whole rows | 351 | not yet | | `UserV2Controller.updateUser` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/:id` | hidden | whole rows | 364 | not yet | | `UserController.updateUserAdmin` | `subdomains/generic/user/models/user/user.controller.ts` | @@ -612,7 +612,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 2 | | `/user/addresses/:address` | public | whole rows | 351 | not yet | | `UserV2Controller.updateAddress` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/apiFilter/CT` | public | whole rows | 331 | not yet | | `UserController.updateApiFilter` | `subdomains/generic/user/models/user/user.controller.ts` | | DELETE | 1 | | `/user/apiKey/CT` | public | none | — | n/a | | `UserController.deleteApiKey` | `subdomains/generic/user/models/user/user.controller.ts` | -| POST | 1 | | `/user/apiKey/CT` | public | projected | 3 | 4/4 | | `UserController.createApiKey` | `subdomains/generic/user/models/user/user.controller.ts` | +| POST | 1 | | `/user/apiKey/CT` | public | projected | — | 4/4 | | `UserController.createApiKey` | `subdomains/generic/user/models/user/user.controller.ts` | | POST | 1 | | `/user/change` | public | whole rows | 643 | not yet | | `UserController.changeUser` | `subdomains/generic/user/models/user/user.controller.ts` | | POST | 1 | | `/user/data` | public | whole rows | 406 | not yet | | `UserController.updateKycData` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | yes | `/user/detail` | public | whole rows | 328 | not yet | | `UserController.getUserDetailV1` | `subdomains/generic/user/models/user/user.controller.ts` | @@ -620,7 +620,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 2 | | `/user/mail` | public | whole rows | 364 | not yet | | `UserV2Controller.updateUserMail` | `subdomains/generic/user/models/user/user.controller.ts` | | POST | 2 | | `/user/mail/verify` | public | whole rows | 364 | not yet | | `UserV2Controller.verifyMail` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/name` | hidden | whole rows | 386 | not yet | | `UserController.updateUserName` | `subdomains/generic/user/models/user/user.controller.ts` | -| GET | 2 | | `/user/profile` | public | projected | 41 | 4/4 | | `UserV2Controller.getProfile` | `subdomains/generic/user/models/user/user.controller.ts` | +| GET | 2 | | `/user/profile` | public | projected | — | 4/4 | | `UserV2Controller.getProfile` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | | `/user/ref` | hidden | projected | 1 | 0/4 | | `UserController.getRefInfo` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 2 | | `/user/ref` | public | whole rows | 98 | not yet | | `UserV2Controller.getRef` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 2 | | `/user/ref` | public | whole rows | 98 | not yet | | `UserV2Controller.updateRefAsset` | `subdomains/generic/user/models/user/user.controller.ts` | diff --git a/docs/load-sites.md b/docs/load-sites.md index 343639a292..2728526d0e 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -1,6 +1,6 @@ # Database load sites -Every place in the code that reads from the database: **1105 load sites** across 248 files. +Every place in the code that reads from the database: **1105 load sites** across 249 files. This is the level at which the statement is unambiguous. An endpoint reaches several load sites — a permission check, a lookup, the actual query — so asking whether *an endpoint* loads efficiently has no single answer. Asking it of a load site does. [endpoints.md](endpoints.md) carries the per-endpoint summary derived from these sites. @@ -36,11 +36,7 @@ Columns were measured against the real entity metadata by building the query and Median across measured sites: **101 columns**. 14 sites exceed 1000, 74 exceed 500, 396 exceed 100. -What that does and does not affect: the median and the counts above are computed only over the 782 rows that carry a measured width, and an array operation never has one, so those figures stand. Nor does it move the conclusion this table exists for: the sites that name their columns are a small fraction either way, and the verdict reads the same against 900 as against 1105. It does reach the per-endpoint summary in [endpoints.md](endpoints.md): an endpoint could be listed as fetching whole rows on the strength of such a row alone. The three with no measured width at all are the exposed cases and are named there. For the rest a measured query stands behind the entry, which limits the effect without excluding it — establishing that would need the collection to separate the two kinds of `find`, which is the fix this note stands in for. - -Median across measured sites: **120.5 columns**. At least 14 sites exceed 1000, 77 exceed 500 and 409 exceed 100 — "at least", because 434 of these measurements are lower bounds, and a resolved relation tree can only widen a query, never narrow it. - -Postgres refuses a statement with more than 1664 columns. The widest measured site here sits at 1453, so a little over two hundred columns separate it from a rejected statement — and 434 of these measurements are lower bounds, so the real margin can be smaller. +Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright, whatever the column and wherever it is added. ## Load sites @@ -98,7 +94,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 583 | 21 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:35` | `BuyFiatRegistrationService.syncReturnTxId` | | 567 | 17 | find | `BuyCrypto` | `subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts:114` | `BuyCryptoConsumer.processForward` | | 558 | 24 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:150` | `PaymentQuoteService.getConfirmingQuotes` | -| 545 | 23 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:119` | `PaymentLinkRepository.getHistoryByStatus` | +| 545 | 23 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:99` | `PaymentLinkRepository.getHistoryByStatus` | | 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:97` | `PaymentLinkPaymentService.updatePayment` | | 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:104` | `PaymentLinkPaymentService.getPendingPaymentByUniqueId` | | 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:372` | `PaymentLinkPaymentService.handleBlockchainConfirmed` | @@ -106,16 +102,16 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 540 | 17 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:763` | `BuyFiatService.getByAmlReason` | | 538 | 21 | find | `CryptoInput` | `subdomains/core/accounting/services/consumers/crypto-input.consumer.ts:89` | `CryptoInputConsumer.processForward` | | 535 | 15 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:947` | `BuyCryptoService.getPendingTransactions` | -| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:301` | `CustodyOrderService.confirmOrder` | -| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:317` | `CustodyOrderService.getOrdersForSupport` | +| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:283` | `CustodyOrderService.confirmOrder` | +| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:299` | `CustodyOrderService.getOrdersForSupport` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:615` | `BuyFiatPreparationService.chargebackTx` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:486` | `BuyFiatService.retriggerScorechain` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:92` | `PaymentLinkRepository.getAllPaymentLinks` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:99` | `PaymentLinkRepository.getAllPaymentLinksByExternalLinkId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:106` | `PaymentLinkRepository.getAllPaymentLinksByExternalPaymentId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:143` | `PaymentLinkRepository.getPaymentLinkByLinkId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:150` | `PaymentLinkRepository.getPaymentLinkByExternalId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:160` | `PaymentLinkRepository.getPaymentLinkByExternalPaymentId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:72` | `PaymentLinkRepository.getAllPaymentLinks` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:79` | `PaymentLinkRepository.getAllPaymentLinksByExternalLinkId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:86` | `PaymentLinkRepository.getAllPaymentLinksByExternalPaymentId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:123` | `PaymentLinkRepository.getPaymentLinkByLinkId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:130` | `PaymentLinkRepository.getPaymentLinkByExternalId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:140` | `PaymentLinkRepository.getPaymentLinkByExternalPaymentId` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:430` | `PaymentLinkService.updatePaymentLinkAdmin` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:485` | `PaymentLinkService.getActivePaymentLink` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:504` | `PaymentLinkService.assignPaymentLink` | @@ -138,7 +134,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:326` | `RecommendationService.getRecommendationsByKycStepIdsOrUserDataId` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:336` | `RecommendationService.getAllRecommendationsByRecommenderId` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:344` | `RecommendationService.getRecommendationsByRecommendedId` | -| 473 | 18 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:625` | `SupportIssueService.getIssueEntities` | +| 473 | 18 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:578` | `SupportIssueService.getIssueEntities` | | 472 | 14 | find | `BuyFiat` | `subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts:122` | `BuyFiatConsumer.processForward` | | 472 | 19 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:15` | `DepositRouteService.get` | | 472 | 19 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:25` | `DepositRouteService.getById` | @@ -150,7 +146,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 451 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:42` | `BuyFiatNotificationService.paymentCompleted` | | 451 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:290` | `BuyFiatNotificationService.chargebackUnconfirmed` | | 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:457` | `SupportIssueService.closeIssue` | -| 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:689` | `SupportIssueService.getUserIssues` | +| 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:642` | `SupportIssueService.getUserIssues` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/accounting/services/ledger-cutover.service.ts:475` | `LedgerCutoverService.openBuyCryptoReceived` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/accounting/services/ledger-cutover.service.ts:536` | `LedgerCutoverService.openBuyCryptoOwed` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:25` | `BuyCryptoRegistrationService.syncReturnTxId` | @@ -166,14 +162,14 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 434 | 15 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request.service.ts:82` | `LimitRequestService.getUserLimitRequests` | | 433 | 12 | find | `BankTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:106` | `BankTxConsumer.processForward` | | 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:478` | `SupportIssueService.closeIssue` | -| 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:675` | `SupportIssueService.getIssueMessages` | +| 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:628` | `SupportIssueService.getIssueMessages` | | 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:67` | `TransactionRequestService.txRequestWaitingExpiryCheck` | | 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:78` | `TransactionRequestService.deleteOldTxRequests` | | 422 | 12 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:778` | `BuyCryptoService.resetAmlCheck` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:214` | `SupportEscalationService.checkEscalations` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:484` | `SupportIssueService.updateIssue` | -| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:665` | `SupportIssueService.getIssueMessages` | -| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:702` | `SupportIssueService.getIssueUserDataId` | +| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:618` | `SupportIssueService.getIssueMessages` | +| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:655` | `SupportIssueService.getIssueUserDataId` | | 419 | 14 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1186` | `BuyCryptoService.getTransactions` | | 418 | 18 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:80` | `CustodyJobService.executeStep` | | 418 | 11 | find | `User` | `subdomains/generic/user/models/user/user-job.service.ts:19` | `UserJobService.approveUser` | @@ -252,7 +248,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 301 | 6 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:89` | `BankTxRepeatService.getAllUserRepeats` | | 299 | 12 | find | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:65` | `TradingOrderService.startNewOrders` | | 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:113` | `CustodyJobService.onStepComplete` | -| 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:291` | `CustodyOrderService.getCustodyOrderByTx` | +| 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:273` | `CustodyOrderService.getCustodyOrderByTx` | | 287 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:746` | `BuyFiatService.getTransactions` | | 284 | 10 | find | `UserData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:173` | `BankDataService.addBankData` | | 276 | 10 | find | `NameCheckLog` | `subdomains/generic/kyc/services/name-check.service.ts:205` | `NameCheckService.createNameCheckLog` | @@ -326,7 +322,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 229 | 11 | find | `CryptoInput` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:527` | `DashboardReconciliationService.getCryptoInputs` | | 226 | 7 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:94` | `CustodyJobService.checkStep` | | 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:52` | `CustodyJobService.resetExpiredConfirmedOrders` | -| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:326` | `CustodyOrderService.approveOrder` | +| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:308` | `CustodyOrderService.approveOrder` | | 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:257` | `CustodyService.getUserCustodyHistory` | | 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:470` | `CustodyService.calculateAccruedInterest` | | 201 | 5 | find | `BuyFiat` | `subdomains/core/accounting/services/consumers/payout-order.consumer.ts:293` | `PayoutOrderConsumer.owedCompletionChf` | @@ -478,7 +474,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:110` | `LiquidityManagementRuleService.reactivateRules` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:147` | `LiquidityManagementRuleService.findExistingRuleOnCreation` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:109` | `LiquidityManagementService.findRuleByAssetOrThrow` | -| 81 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:334` | `SupportIssueRepository.findIssueData` | +| 81 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:336` | `SupportIssueRepository.findIssueData` | | 78 | 1 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:97` | `UserService.getUserByAddress` | | 78 | 3 | find | `Mros` | `subdomains/supporting/mros/mros.service.ts:32` | `MrosService.update` | | 77 | 0 | query-builder (nur-alias) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:712` | `BuyCryptoService.getBuyCryptoByKeys` | @@ -486,7 +482,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 71 | 0 | query-builder (nur-alias) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:340` | `BuyFiatService.getBuyFiatByKey` | | 71 | 1 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:68` | `RecallService.getByBankTxIds` | | 68 | 4 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:204` | `SwapService.getById` | -| 66 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:249` | `UserDataRepository.getUserV2` | +| 66 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:252` | `UserDataRepository.getUserV2` | | 65 | 2 | find | `Fee` | `subdomains/supporting/payment/services/fee.service.ts:115` | `FeeService.createFee` | | 61 | 0 | find | `BankTx` | `subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts:386` | `ExchangeTxConsumer.hasBankRouteMatch` | | 61 | 0 | find | `BankTx` | `subdomains/core/accounting/services/ledger-cutover.service.ts:710` | `LedgerCutoverService.openUnattributed` | @@ -541,7 +537,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 45 | 0 | query-builder (ohne-select) | `User` | `subdomains/generic/user/models/user/user.service.ts:177` | `UserService.getOpenRefCreditUser` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:41` | `FaucetRequestService.checkFaucetRequests` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:93` | `FaucetRequestService.resetFaucet` | -| 41 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:261` | `UserDataRepository.getProfile` | +| 41 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:264` | `UserDataRepository.getProfile` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:38` | `LiquidityManagementBalanceService.getAllLiqBalancesForAssets` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:68` | `LiquidityManagementBalanceService.refreshBankBalance` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:82` | `LiquidityManagementBalanceService.getBalances` | @@ -600,7 +596,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:387` | `DashboardReconciliationService.getExchangeFlows` | | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:544` | `DashboardReconciliationService.getExchangeWithdrawalsForBlockchain` | | 27 | 2 | find | `LiquidityManagementAction` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:257` | `LiquidityManagementRuleService.findExistingAction` | -| 26 | 0 | query-builder (feldliste) | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:86` | `PaymentLinkRepository.findForPosLink` | +| 26 | 0 | query-builder (feldliste) | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:66` | `PaymentLinkRepository.findForPosLink` | | 26 | 0 | query-builder (nur-alias) | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:22` | `BankAccountService.getBankAccountByKey` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:40` | `BankAccountService.checkFailedBankAccounts` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:48` | `BankAccountService.reloadErrorBankAccounts` | @@ -619,7 +615,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 23 | 0 | find | `BankTxBatch` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx-batch.service.ts:10` | `BankTxBatchService.getBankTxBatchByIban` | | 21 | 0 | query-builder (ohne-select) | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:87` | `DepositRouteService.getPaymentRouteForKey` | | 20 | 0 | query-builder (nur-alias) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:95` | `SellService.getSellByKey` | -| 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:53` | `WalletRepository.getByAddress` | +| 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:54` | `WalletRepository.getByAddress` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:19` | `WalletService.updateWallet` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:28` | `WalletService.getByAddress` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:36` | `WalletService.getByIdOrName` | @@ -647,8 +643,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 15 | 0 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:2830` | `RealUnitService.getRegisteredWalletAddresses` | | 14 | 0 | find | `ScorechainScreening` | `integration/scorechain/repositories/scorechain-screening.repository.ts:18` | `ScorechainScreeningRepository.getByObjectIds` | | 14 | 0 | find | `ScorechainScreening` | `integration/scorechain/services/scorechain-screening.service.ts:233` | `ScorechainScreeningService.getCached` | -| 14 | 0 | query-builder (feldliste) | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:239` | `CustodyOrderService.getOrdersByUserData` | -| 14 | 0 | query-builder (feldliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/buy-fiat.repository.ts:56` | `BuyFiatRepository.findSellHistory` | +| 14 | 0 | query-builder (feldliste) | `CustodyOrder` | `subdomains/core/custody/repositories/custody-order.repository.ts:59` | `CustodyOrderRepository.findHistoryFor` | +| 14 | 0 | query-builder (feldliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/buy-fiat.repository.ts:57` | `BuyFiatRepository.findSellHistory` | | 13 | 0 | query-builder (nur-alias) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:228` | `BuyService.getBuyByKey` | | 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:76` | `PaymentQuoteService.processExpiredQuotes` | | 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:109` | `PaymentQuoteService.getActualQuoteByPaymentId` | @@ -674,8 +670,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 12 | 0 | find | `IpLog` | `shared/models/ip-log/ip-log.service.ts:66` | `IpLogService.getByUserDataId` | | 12 | 0 | find | `IpLog` | `shared/models/ip-log/ip-log.service.ts:75` | `IpLogService.getLoginCountries` | | 12 | 0 | find | `IpLog` | `shared/models/ip-log/ip-log.service.ts:119` | `IpLogService.updateUserIpLogs` | -| 12 | 0 | query-builder (feldliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:76` | `BuyCryptoRepository.findBuyHistory` | -| 12 | 0 | query-builder (feldliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:90` | `BuyCryptoRepository.findSwapHistory` | +| 12 | 0 | query-builder (feldliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:77` | `BuyCryptoRepository.findBuyHistory` | +| 12 | 0 | query-builder (feldliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:91` | `BuyCryptoRepository.findSwapHistory` | | 11 | 0 | find | `OlkyRecipient` | `integration/bank/services/olkypay.service.ts:104` | `OlkypayService.getOrCreateRecipient` | | 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:212` | `LedgerMarkToMarketService.alreadyBooked` | | 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:125` | `LedgerQueryService.getAccountDetail` | @@ -690,12 +686,12 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:131` | `LogService.getLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:135` | `LogService.maxEntity` | | 11 | 0 | query-builder (ohne-select) | `Log` | `subdomains/supporting/log/log.service.ts:191` | `LogService.getBankLog` | -| 11 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:344` | `SupportIssueRepository.findIssuesForAccount` | -| 11 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:362` | `SupportIssueRepository.findIssueBy` | -| 10 | 0 | query-builder (feldliste) | `LedgerLeg` | `subdomains/core/accounting/repositories/ledger-leg.repository.ts:52` | `LedgerLegRepository.findSuspenseLegs` | +| 11 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:346` | `SupportIssueRepository.findIssuesForAccount` | +| 11 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:364` | `SupportIssueRepository.findIssueBy` | +| 10 | 0 | query-builder (feldliste) | `LedgerLeg` | `subdomains/core/accounting/repositories/ledger-leg.repository.ts:55` | `LedgerLegRepository.findSuspenseLegs` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:61` | `AccountMergeService.sendMergeRequest` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:162` | `AccountMergeService.pendingMergeRequest` | -| 10 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:268` | `SupportIssueRepository.findIssueList` | +| 10 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:271` | `SupportIssueRepository.findIssueList` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:57` | `SupportNoteService.search` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:76` | `SupportNoteService.search` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:152` | `SupportNoteService.delete` | @@ -716,7 +712,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:19` | `LanguageService.getLanguageBySymbol` | | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:24` | `LanguageService.getLanguageByCountry` | | 7 | 0 | find | `UserDataRelation` | `subdomains/generic/user/models/user-data-relation/user-data-relation.service.ts:40` | `UserDataRelationService.updateUserDataRelation` | -| 7 | 0 | query-builder (feldliste) | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:47` | `WalletRepository.findKycData` | +| 7 | 0 | query-builder (feldliste) | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:48` | `WalletRepository.findKycData` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:12` | `SpecialExternalAccountService.createSpecialExternalAccount` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:24` | `SpecialExternalAccountService.getMultiAccounts` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:48` | `SpecialExternalAccountService.getPhoneCallList` | @@ -746,10 +742,9 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:210` | `SettingService.setObj` | | 5 | 0 | find | `Sanction` | `subdomains/core/aml/services/sanction.service.ts:54` | `SanctionService.syncList` | | 5 | 0 | query-builder (spaltenliste) | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:84` | `SupportNoteService.listUsers` | -| 5 | 0 | query-builder (feldliste) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:52` | `SupportMessageRepository.findThread` | +| 5 | 0 | query-builder (feldliste) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:61` | `SupportMessageRepository.findThread` | | 4 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:534` | `LedgerQueryService.cumulativeEquityByDay` | | 4 | 0 | find | `SystemStateSnapshot` | `subdomains/core/monitoring/monitoring.service.ts:47` | `MonitoringService.loadState` | -| 4 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:587` | `SupportIssueService.getMessageStats` | | 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:278` | `LedgerQueryService.balancesByAccount` | | 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:271` | `LedgerReconciliationService.checkTransitAge` | | 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:347` | `LedgerReconciliationService.openResidualSince` | @@ -764,7 +759,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 3 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:678` | `BuyFiatService.updateSellVolume` | | 3 | 0 | query-builder (spaltenliste) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:261` | `SellService.getUserVolume` | | 3 | 0 | query-builder (spaltenliste) | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1981` | `KycService.getPendingReviewSummary` | -| 3 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:238` | `UserDataRepository.getForApiKey` | +| 3 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:240` | `UserDataRepository.getForApiKey` | | 3 | 0 | query-builder (spaltenliste) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:461` | `BankTxService.getBankTxFee` | | 3 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:310` | `SupportEscalationService.getLastMessages` | | 3 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:245` | `SupportIssueService.getSupportIssueStatistics` | @@ -780,13 +775,14 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 2 | 0 | query-builder (spaltenliste) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:493` | `BankDataService.getPendingReviewSummary` | | 2 | 0 | query-builder (spaltenliste) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:354` | `RecommendationService.countByRecommenderIds` | | 2 | 0 | query-builder (spaltenliste) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:369` | `RecommendationService.countByRecommendedIds` | -| 2 | 0 | query-builder (feldliste) | `User` | `subdomains/generic/user/models/user/user.repository.ts:46` | `UserRepository.findAccountIdForAddress` | +| 2 | 0 | query-builder (feldliste) | `User` | `subdomains/generic/user/models/user/user.repository.ts:47` | `UserRepository.findAccountIdForAddress` | | 2 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:226` | `UserService.countRefChildrenByUserDataIds` | | 2 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:248` | `UserService.countRefReferrersByUserDataIds` | | 2 | 0 | query-builder (spaltenliste) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:505` | `BankTxService.getBankTxFee` | | 2 | 0 | query-builder (feldliste) | `Log` | `subdomains/supporting/log/log.repository.ts:699` | `LogRepository.getFinancialLogValidityChangeSet` | | 2 | 0 | query-builder (spaltenliste) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:324` | `TransactionService.getManualRefVolume` | | 2 | 0 | query-builder (spaltenliste) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:353` | `TransactionService.getAuditPeriodVolumes` | +| 2 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:96` | `SupportMessageRepository.findStatsFor` | | 2 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:109` | `SupportIssueService.getSupportIssueCounts` | | 2 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:133` | `SupportIssueService.getSupportIssueActivity` | | 2 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:198` | `SupportIssueService.getSupportIssueStatistics` | @@ -925,7 +921,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/core/custody/services/custody-account.service.ts:504` | `CustodyAccountService.createGrant` | | — | — | find | `—` | `subdomains/core/custody/services/custody-account.service.ts:598` | `CustodyAccountService.grantAccessForLegacy` | | — | — | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:65` | `CustodyJobService.executeOrder` | -| — | — | find | `—` | `subdomains/core/custody/services/custody-order.service.ts:406` | `CustodyOrderService.checkBalance` | +| — | — | find | `—` | `subdomains/core/custody/services/custody-order.service.ts:388` | `CustodyOrderService.checkBalance` | | — | — | find | `—` | `subdomains/core/custody/services/custody.service.ts:307` | `CustodyService.getUserCustodyHistory` | | — | — | find | `—` | `subdomains/core/history/mappers/transaction-dto.mapper.ts:211` | `TransactionDtoMapper.feeAmountType` | | — | — | find | `—` | `subdomains/core/history/services/history-access.service.ts:122` | `HistoryAccessService.resolveFromApiKey` | @@ -1041,7 +1037,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1547` | `UserDataService.mergeUserData` | | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1725` | `UserDataService.updateBankTxTime` | | — | — | find | `—` | `subdomains/generic/user/models/user/dto/user-dto.mapper.ts:27` | — | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.repository.ts:69` | `UserRepository.getNextRef` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.repository.ts:70` | `UserRepository.getNextRef` | | — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:336` | `UserService.createUser` | | — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:490` | `UserService.updateAddress` | | — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:506` | `UserService.deactivateUser` | @@ -1151,5 +1147,5 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:2927` | `RealUnitService.applyRegistrationConfirmation` | | — | — | find | `—` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:166` | `SupportEscalationService.bindGroupChat` | | — | — | find | `—` | `subdomains/supporting/support-issue/services/support-issue.service.ts:92` | `SupportIssueService.getSupportIssueClerkForAccount` | -| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:680` | `SupportIssueService.getIssueFile` | -| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:695` | `SupportIssueService.getUserIssues` | +| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:633` | `SupportIssueService.getIssueFile` | +| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:648` | `SupportIssueService.getUserIssues` | diff --git a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts index f949f3bd52..b78f663f33 100644 --- a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts +++ b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts @@ -39,7 +39,7 @@ export class LiquidityManagementPipelineRepository extends BaseRepository = PIPELINE_STATUS_PROJECTION.fields, - ): Promise { + ): Promise { return PIPELINE_STATUS_PROJECTION.apply(this.createQueryBuilder('pipeline'), fields) .where('pipeline.id = :id', { id }) .getOne(); diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts index 8e5145fea7..c6afb6cd19 100644 --- a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts @@ -60,9 +60,9 @@ describeProjection('GET /sell/:id/history — read-path projection', () => { const asset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); const cryptoInput = await seedEntity(dataSource, CryptoInput, { values: { asset } }); const fiatOutput = withFiatOutput ? await seedEntity(dataSource, FiatOutput) : null; - // `outputAsset` is nullable in the schema but read without a guard by the mapper, so a payout - // transaction always carries one. That is the behaviour as it stands; the projection does not - // change it. + // The fixture sets `outputAsset` because the mapper dereferences it without a guard, although + // the column is nullable. What happens without one is the mapper's behaviour, not the + // projection's, and this file does not change either. const outputAsset = await seedEntity(dataSource, Fiat); const buyFiat = await seedEntity(dataSource, BuyFiat, { values: { sell, cryptoInput, fiatOutput, outputAsset }, diff --git a/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts b/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts index b47df31355..9eb658f6ac 100644 --- a/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts +++ b/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts @@ -47,7 +47,7 @@ export class BuyFiatRepository extends BaseRepository { * Transactions on a user's sell route, loaded with the history fields only. * * `fields` is what the mutation test in `buy-fiat-history.projection.spec.ts` re-runs the query - * with; `BuyFiatService.getBuyFiatHistory` calls this without it. + * with; `BuyFiatService.getSellHistory` calls this without it. */ async findSellHistory( userId: number, diff --git a/src/subdomains/generic/user/models/user-data/user-data.repository.ts b/src/subdomains/generic/user/models/user-data/user-data.repository.ts index 21614f1a41..b716710775 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.repository.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.repository.ts @@ -233,10 +233,10 @@ export class UserDataRepository extends CachedRepository { /** * The account, carrying what an API key is built from. * - * `fields` is what the mutation tests in `user-profile.projection.spec.ts`, `user-v2.projection.spec.ts` - * and `api-key.projection.spec.ts` re-run the query with; the services call these without it. + * `fields` is what the mutation test in `api-key.projection.spec.ts` re-runs the query with; + * `UserDataService.createApiKey` calls this without it. */ - async getForApiKey(id: number, fields: ReadonlyArray = API_KEY_PROJECTION.fields): Promise { + async getForApiKey(id: number, fields: ReadonlyArray = API_KEY_PROJECTION.fields): Promise { return API_KEY_PROJECTION.apply(this.createQueryBuilder('userData'), fields) .where('userData.id = :id', { id }) .getOne(); @@ -245,10 +245,10 @@ export class UserDataRepository extends CachedRepository { /** * Loads exactly what the v2 user response needs, users and wallets included. * - * `fields` is what the mutation tests in `user-profile.projection.spec.ts`, `user-v2.projection.spec.ts` - * and `api-key.projection.spec.ts` re-run the query with; the services call these without it. + * `fields` is what the mutation test in `user-v2.projection.spec.ts` re-runs the query with; + * `UserService.getUserDtoV2` calls this without it. */ - async getUserV2(id: number, fields: ReadonlyArray = USER_V2_PROJECTION.fields): Promise { + async getUserV2(id: number, fields: ReadonlyArray = USER_V2_PROJECTION.fields): Promise { return USER_V2_PROJECTION.apply(this.createQueryBuilder('userData'), fields) .where('userData.id = :id', { id }) .getOne(); @@ -257,10 +257,13 @@ export class UserDataRepository extends CachedRepository { /** * Loads exactly what the profile response needs. * - * `fields` is what the mutation tests in `user-profile.projection.spec.ts`, `user-v2.projection.spec.ts` - * and `api-key.projection.spec.ts` re-run the query with; the services call these without it. + * `fields` is what the mutation test in `user-profile.projection.spec.ts` re-runs the query + * with; `UserService.getUserProfile` calls this without it. */ - async getProfile(id: number, fields: ReadonlyArray = USER_PROFILE_PROJECTION.fields): Promise { + async getProfile( + id: number, + fields: ReadonlyArray = USER_PROFILE_PROJECTION.fields, + ): Promise { return USER_PROFILE_PROJECTION.apply(this.createQueryBuilder('userData'), fields) .where('userData.id = :id', { id }) .getOne(); diff --git a/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts b/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts index e25bdf2261..1d67605390 100644 --- a/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts +++ b/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts @@ -53,7 +53,7 @@ describeProjection('GET /user/profile — read-path projection', () => { }); } - /** A personal account with no organization linked — the ordinary case in production. */ + /** A personal account with no organization linked — the branch where the join finds no row. */ async function seedAccountWithoutOrganization(): Promise { return seedEntity(dataSource, UserData, { values: { accountType: AccountType.PERSONAL }, diff --git a/src/subdomains/generic/user/models/user/user.repository.ts b/src/subdomains/generic/user/models/user/user.repository.ts index 11ab79aea1..ff56faf8e6 100644 --- a/src/subdomains/generic/user/models/user/user.repository.ts +++ b/src/subdomains/generic/user/models/user/user.repository.ts @@ -43,7 +43,7 @@ export class UserRepository extends BaseRepository { address: string, walletId: number, fields: ReadonlyArray = USER_KYC_FILES_PROJECTION.fields, - ): Promise { + ): Promise { return USER_KYC_FILES_PROJECTION.apply(this.createQueryBuilder('user'), fields) .where('user.address = :address', { address }) .andWhere('kycFilesWallet.id = :walletId', { walletId }) diff --git a/src/subdomains/generic/user/models/wallet/wallet.repository.ts b/src/subdomains/generic/user/models/wallet/wallet.repository.ts index 832d115bee..8473f2b395 100644 --- a/src/subdomains/generic/user/models/wallet/wallet.repository.ts +++ b/src/subdomains/generic/user/models/wallet/wallet.repository.ts @@ -4,7 +4,7 @@ import { CachedRepository } from 'src/shared/repositories/cached.repository'; import { EntityManager } from 'typeorm'; import { Wallet } from './wallet.entity'; -/** The four values `KycService.toKycDataDto` reads per user of the wallet. */ +/** The four values `KycDataDtoMapper.toDto` reads per user of the wallet. */ export const WALLET_KYC_DATA_RESPONSE_FIELDS = [ 'walletUser.address', 'walletUserData.kycStatus', @@ -44,13 +44,13 @@ export class WalletRepository extends CachedRepository { async findKycData( walletId: number, fields: ReadonlyArray = WALLET_KYC_DATA_PROJECTION.fields, - ): Promise { + ): Promise { return WALLET_KYC_DATA_PROJECTION.apply(this.createQueryBuilder('wallet'), fields) .where('wallet.id = :walletId', { walletId }) .getOne(); } - async getByAddress(address: string): Promise { + async getByAddress(address: string): Promise { return this.findOneBy({ address }); } } diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts index b0c4de039c..767a243b38 100644 --- a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts @@ -195,8 +195,8 @@ describeProjection('support issue list — read-path projection', () => { }, 120000); // The id branch of the search predicate is added only when the term is fully numeric AND fits - // int4. Anything above 2^31-1 — a pasted phone number is the realistic case — would make Postgres - // raise a 22003 range error and fail the whole search with a 500. + // int4. Anything above 2^31-1 would make Postgres raise a 22003 range error and fail the whole + // search rather than answer nothing. it.each([ ['12345', 'a small numeric term'], ['2147483647', 'exactly int4 max'], diff --git a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts index 8a4f7c9b43..2b5578896e 100644 --- a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts +++ b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts @@ -288,9 +288,9 @@ export class SupportIssueRepository extends BaseRepository { for (let i = 0; i < query.terms.length; i++) { const param = `term${i}`; // Only emit the id branch when the term is fully numeric AND fits int4 (Postgres rejects - // larger values with 22003, which would 500 the entire search — a pasted phone number - // like "41791234567" is a realistic trigger). Keeps the predicate on the PK index (no - // cast-to-text) and avoids partial-match surprises (term "42" doesn't match id 142). + // larger values with 22003, which would fail the whole search rather than answer nothing). + // Keeps the predicate on the PK index (no cast-to-text) and avoids partial-match surprises + // (term "42" doesn't match id 142). const numeric = +query.terms[i]; const idTerm = /^\d+$/.test(query.terms[i]) && numeric <= 2147483647 ? numeric : null; const idClause = idTerm != null ? ` OR issue.id = :${param}Id` : ''; @@ -326,13 +326,13 @@ export class SupportIssueRepository extends BaseRepository { /** * Loads exactly what the internal issue view needs. * - * `fields` is what the mutation test in `support-issue-list.projection.spec.ts` re-runs the query - * with; `SupportIssueService.getSupportIssueList` calls this without it. + * `fields` is what the mutation test in `support-issue-data.projection.spec.ts` re-runs the query + * with; `SupportIssueService.getIssueData` calls this without it. */ async findIssueData( id: number, fields: ReadonlyArray = SUPPORT_ISSUE_DATA_PROJECTION.fields, - ): Promise { + ): Promise { return SUPPORT_ISSUE_DATA_PROJECTION.apply(this.createQueryBuilder('supportIssue'), fields) .where('supportIssue.id = :id', { id }) .getOne(); @@ -360,7 +360,7 @@ export class SupportIssueRepository extends BaseRepository { async findIssueBy( search: FindOptionsWhere, fields: ReadonlyArray = SUPPORT_ISSUE_PROJECTION.fields, - ): Promise { + ): Promise { return SUPPORT_ISSUE_PROJECTION.apply(this.createQueryBuilder('supportIssue'), fields) .setFindOptions({ where: search, loadEagerRelations: false }) .getOne(); diff --git a/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts b/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts index 0faa7ee809..15aa7ea037 100644 --- a/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts +++ b/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { Util } from 'src/shared/utils/util'; -import { EntityManager } from 'typeorm'; +import { EntityManager, SelectQueryBuilder } from 'typeorm'; import { SupportMessage } from '../entities/support-message.entity'; /** @@ -77,13 +77,17 @@ export class SupportMessageRepository extends BaseRepository { async findStatsFor(issueIds: number[]): Promise> { if (issueIds.length === 0) return new Map(); - const lastOf = (column: string) => (sub) => - sub - .select(`m2.${column}`) - .from(SupportMessage, 'm2') - .where('m2."issueId" = m."issueId"') - .orderBy('m2.id', 'DESC') - .limit(1); + // The newest message per issue, as a correlated subquery. One factory for the two columns the + // row shows, so the two subqueries cannot drift apart. + const lastOf = + (column: 'created' | 'author') => + (sub: SelectQueryBuilder): SelectQueryBuilder => + sub + .select(`m2.${column}`) + .from(SupportMessage, 'm2') + .where('m2."issueId" = m."issueId"') + .orderBy('m2.id', 'DESC') + .limit(1); // Batched to stay below the parameter limit of a single statement. const rows = await Util.doInBatchesAndJoin( From a40df8da1234f3b8f01d29e560ce6f79f9d42f3b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:19:06 +0200 Subject: [PATCH 22/46] Bring the inventory numbers back in line with the projections The point-of-sale projection went from 26 columns to 7 in the previous commit; the inventory still carried the old figure, which is what read-projection.spec.ts is there to catch. --- docs/endpoints.md | 64 +++++++++++++++++++++++----------------------- docs/load-sites.md | 4 +-- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/docs/endpoints.md b/docs/endpoints.md index 4b49bce4a2..470cf88b6f 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -34,9 +34,9 @@ Today 38 endpoints read only what they return and 398 do not, so the column read | `projected` | 36 | 7 % | | `caller-defined` | 2 | 0 % | -Of the 36 that read only what they return, 17 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 0), `GET /buy/:id/history` (497 columns to 0), `GET /swap/:id/history` (509 columns to 0), `GET /sell/:id/history` (470 columns to 0), `GET /support/issue/:id/data` (951 columns to 0), `GET /support/issue` (450 columns to 0), `GET /support/issue/:id` (450 columns to 0), `GET /kyc/users` (328 columns to 0), `GET /kyc/:id/documents` (328 columns to 0), `GET /custody/order` (19 columns to 0), `GET /support/issue/list` (16 columns to 0), `GET /realunit/support/list` (16 columns to 1), `GET /dashboard/accounting/ledger/suspense` (11 columns to 0), `GET /liquidityManagement/pipeline/:id/status` (112 columns to 2), `PUT /paymentLink/:id/pos` (513 columns to 0), `POST /user/apiKey/CT` (253 columns to 0), `GET /user` (351 columns to 0). The other 19 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. +Of the 36 that read only what they return, 17 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11), `GET /kyc/users` (328 columns to 7), `GET /kyc/:id/documents` (328 columns to 2), `GET /custody/order` (19 columns to 14), `GET /support/issue/list` (16 columns to 10), `GET /realunit/support/list` (16 columns to 10), `GET /dashboard/accounting/ledger/suspense` (11 columns to 10), `GET /liquidityManagement/pipeline/:id/status` (112 columns to 2), `PUT /paymentLink/:id/pos` (513 columns to 7), `POST /user/apiKey/CT` (253 columns to 3), `GET /user` (351 columns to 66). The other 19 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. -Among the 398 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 299 exceed 100, 78 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. +Among the 398 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 307 exceed 100, 86 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. ### How to read this column, and how not to @@ -53,7 +53,7 @@ Stated exactly, so the numbers can be checked rather than believed: - **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 398 is a lower bound. - All 98 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. - The 36 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. -- 4 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /custody/admin/order/:id/approve`, `GET /custody/admin/orders`, `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`. The classification holds; only the width is unknown. +- 2 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`. The classification holds; only the width is unknown. ### Two controller classes may share a name @@ -165,7 +165,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/buy` | hidden | whole rows | 364 | not yet | | `BuyController.createBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | GET | 1 | | `/buy/:id` | public | whole rows | 308 | not yet | | `BuyController.getBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/:id` | hidden | whole rows | 308 | not yet | | `BuyController.updateBuyRoute` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| GET | 1 | | `/buy/:id/history` | hidden | projected | — | 4/4 | | `BuyController.getBuyRouteHistory` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| GET | 1 | | `/buy/:id/history` | hidden | projected | 12 | 4/4 | | `BuyController.getBuyRouteHistory` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/paymentInfos` | public | whole rows | 364 | not yet | | `BuyController.createBuyWithPaymentInfo` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/paymentInfos/:id/confirm` | public | whole rows | 504 | not yet | | `BuyController.confirmBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/paymentInfos/:id/invoice` | public | whole rows | 504 | not yet | yes | `BuyController.generateInvoicePDF` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | @@ -208,20 +208,20 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/custody/account/:id/history` | public | whole rows | 253 | not yet | | `CustodyAccountController.getAccountHistory` | `subdomains/core/custody/controllers/custody-account.controller.ts` | | GET | 1 | | `/custody/account/:id/order` | public | whole rows | 253 | not yet | | `CustodyAccountController.getAccountOrders` | `subdomains/core/custody/controllers/custody-account.controller.ts` | | GET | 1 | | `/custody/account/:id/pdf` | public | whole rows | 253 | not yet | | `CustodyAccountController.getAccountPdf` | `subdomains/core/custody/controllers/custody-account.controller.ts` | -| POST | 1 | | `/custody/admin/order/:id/approve` | public | whole rows | — | not yet | | `CustodyAdminController.approveOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | -| GET | 1 | | `/custody/admin/orders` | public | whole rows | — | not yet | | `CustodyAdminController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | +| POST | 1 | | `/custody/admin/order/:id/approve` | public | whole rows | 217 | not yet | | `CustodyAdminController.approveOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | +| GET | 1 | | `/custody/admin/orders` | public | whole rows | 525 | not yet | | `CustodyAdminController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | | PUT | 1 | | `/custody/admin/user/:id/balance` | public | whole rows | 308 | not yet | | `CustodyAdminController.updateUserBalance` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/custody/history` | public | whole rows | 253 | not yet | | `CustodyController.getUserCustodyHistory` | `subdomains/core/custody/controllers/custody.controller.ts` | -| GET | 1 | | `/custody/order` | public | projected | — | 4/4 | | `CustodyController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | +| GET | 1 | | `/custody/order` | public | projected | 14 | 4/4 | | `CustodyController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | | POST | 1 | | `/custody/order` | public | whole rows | 364 | not yet | | `CustodyController.createOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | -| POST | 1 | | `/custody/order/:id/confirm` | public | whole rows | 8 | not yet | | `CustodyController.confirmOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | +| POST | 1 | | `/custody/order/:id/confirm` | public | whole rows | 525 | not yet | | `CustodyController.confirmOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/custody/pdf` | public | whole rows | 253 | not yet | | `CustodyController.getCustodyPdf` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/accounts` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getAccounts` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/accounts/:accountId/legs` | hidden | whole rows | 30 | not yet | yes | `LedgerController.getAccountDetail` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/equity-comparison` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getEquityComparison` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/margin` | hidden | projected | 11 | 0/4 | yes | `LedgerController.getMargin` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/reconciliation` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getReconStatus` | `subdomains/core/accounting/controllers/ledger.controller.ts` | -| GET | 1 | | `/dashboard/accounting/ledger/suspense` | hidden | projected | — | 4/4 | yes | `LedgerController.getSuspense` | `subdomains/core/accounting/controllers/ledger.controller.ts` | +| GET | 1 | | `/dashboard/accounting/ledger/suspense` | hidden | projected | 10 | 4/4 | yes | `LedgerController.getSuspense` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/financial/changes` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getFinancialChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/changes/latest` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getLatestChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/latest` | hidden | none | — | n/a | | `DashboardFinancialController.getLatestBalance` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | @@ -288,7 +288,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | yes | `/kyc/:code` | public | whole rows | 351 | not yet | | `KycController.getKycProgressByCodeV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | POST | 1 | yes | `/kyc/:code` | public | whole rows | 351 | not yet | | `KycController.requestKycByCodeV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | yes | `/kyc/:code/countries` | public | whole rows | 351 | not yet | | `KycController.getKycCountriesByCodeV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | -| GET | 1 | yes | `/kyc/:id/documents` | public | projected | — | 4/4 | | `KycClientController.getKycFilesV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | +| GET | 1 | yes | `/kyc/:id/documents` | public | projected | 2 | 4/4 | | `KycClientController.getKycFilesV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | yes | `/kyc/:id/documents/:type` | public | whole rows | 328 | not yet | | `KycClientController.getKycFileV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 2 | | `/kyc/:step` | hidden | whole rows | 364 | not yet | | `KycController.initiateStep` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | DELETE | 1 | | `/kyc/admin/blacklist/ip` | hidden | none | — | n/a | | `KycAdminController.deleteIpToBlacklist` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | @@ -334,7 +334,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | DELETE | 2 | | `/kyc/transfer` | hidden | whole rows | 351 | not yet | | `KycController.removeKycClient` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | POST | 2 | | `/kyc/transfer` | hidden | whole rows | 364 | not yet | | `KycController.addKycClient` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | PUT | 1 | yes | `/kyc/transfer` | public | whole rows | 364 | not yet | | `KycController.transferKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | -| GET | 1 | yes | `/kyc/users` | public | projected | — | 4/4 | | `KycClientController.getAllKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | +| GET | 1 | yes | `/kyc/users` | public | projected | 7 | 4/4 | | `KycClientController.getAllKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | | `/language` | public | whole rows | 7 | not yet | | `LanguageController.getAllLanguage` | `shared/models/language/language.controller.ts` | | PUT | 1 | | `/limitRequest/:id` | hidden | whole rows | 434 | not yet | | `LimitRequestController.updateUserData` | `subdomains/supporting/support-issue/limit-request.controller.ts` | | GET | 1 | | `/liquidityManagement/balance` | hidden | whole rows | 40 | not yet | | `LiquidityBalanceController.getBalances` | `subdomains/core/liquidity-management/controllers/balance.controller.ts` | @@ -386,16 +386,16 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/payIn/lnurlpPayment/:uniqueId` | hidden | none | — | n/a | | `PayInWebhookController.payment` | `subdomains/supporting/payin/controllers/payin-webhook.controller.ts` | | POST | 1 | | `/payIn/poll` | hidden | none | — | n/a | | `PayInController.pollAddress` | `subdomains/supporting/payin/controllers/payin.controller.ts` | | POST | 1 | | `/payIn/retry` | hidden | whole rows | — | not yet | | `PayInController.retryUncertainSend` | `subdomains/supporting/payin/controllers/payin.controller.ts` | -| GET | 1 | | `/paymentLink` | public | whole rows | 32 | not yet | yes | `PaymentLinkController.getAllPaymentLinks` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| GET | 1 | | `/paymentLink` | public | whole rows | 513 | not yet | yes | `PaymentLinkController.getAllPaymentLinks` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | POST | 1 | | `/paymentLink` | public | whole rows | 545 | not yet | | `PaymentLinkController.createPaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| PUT | 1 | | `/paymentLink` | public | whole rows | 32 | not yet | | `PaymentLinkController.updatePaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| PUT | 1 | | `/paymentLink` | public | whole rows | 513 | not yet | | `PaymentLinkController.updatePaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | DELETE | 1 | | `/paymentLink/:id` | hidden | whole rows | 195 | not yet | | `PaymentLinkController.deletePaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/:id` | hidden | whole rows | 513 | not yet | | `PaymentLinkController.updatePaymentLinkAdmin` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| PUT | 1 | | `/paymentLink/:id/pos` | hidden | projected | — | 4/4 | | `PaymentLinkController.createPosLinkAdmin` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| PUT | 1 | | `/paymentLink/:id/pos` | hidden | projected | 7 | 4/4 | | `PaymentLinkController.createPosLinkAdmin` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/assign` | public | whole rows | 513 | not yet | yes | `PaymentLinkController.assignPaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | GET | 1 | | `/paymentLink/config` | public | whole rows | 253 | not yet | | `PaymentLinkController.getUserPaymentLinksConfig` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/config` | public | whole rows | 253 | not yet | | `PaymentLinkController.updateUserPaymentLinksConfig` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| GET | 1 | | `/paymentLink/history` | public | whole rows | 513 | not yet | | `PaymentLinkController.getPaymentHistory` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| GET | 1 | | `/paymentLink/history` | public | whole rows | 545 | not yet | | `PaymentLinkController.getPaymentHistory` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | POST | 1 | | `/paymentLink/integration/binance/activate/:id` | hidden | whole rows | 513 | not yet | | `C2BPaymentLinkController.activateBinancePay` | `subdomains/core/payment-link/controllers/c2b-payment-link.controller.ts` | | POST | 1 | | `/paymentLink/integration/binance/webhook` | hidden | whole rows | 545 | not yet | | `C2BPaymentLinkController.binancePayWebhook` | `subdomains/core/payment-link/controllers/c2b-payment-link.controller.ts` | | POST | 1 | | `/paymentLink/integration/kucoin/activate/:id` | hidden | whole rows | 513 | not yet | | `C2BPaymentLinkController.activateKucoinPay` | `subdomains/core/payment-link/controllers/c2b-payment-link.controller.ts` | @@ -407,13 +407,13 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/paymentLink/payment` | hidden | whole rows | 545 | not yet | yes | `PaymentLinkController.createInvoicePayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | POST | 1 | | `/paymentLink/payment` | public | whole rows | 545 | not yet | | `PaymentLinkController.createPayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/payment/:id` | hidden | whole rows | 545 | not yet | | `PaymentLinkController.updatePaymentLinkPayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| PUT | 1 | | `/paymentLink/payment/confirm` | public | whole rows | 32 | not yet | | `PaymentLinkController.confirmPayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| GET | 1 | | `/paymentLink/payment/wait` | public | whole rows | 32 | not yet | | `PaymentLinkController.waitForPayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| PUT | 1 | | `/paymentLink/pos` | public | whole rows | 32 | not yet | | `PaymentLinkController.createPosLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| PUT | 1 | | `/paymentLink/payment/confirm` | public | whole rows | 513 | not yet | | `PaymentLinkController.confirmPayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| GET | 1 | | `/paymentLink/payment/wait` | public | whole rows | 513 | not yet | | `PaymentLinkController.waitForPayment` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| PUT | 1 | | `/paymentLink/pos` | public | whole rows | 513 | not yet | | `PaymentLinkController.createPosLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | GET | 1 | | `/paymentLink/recipient` | hidden | whole rows | 472 | not yet | | `PaymentLinkController.getPaymentRecipient` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | GET | 1 | | `/paymentLink/standard` | public | none | — | n/a | | `PaymentStandardController.getAll` | `subdomains/core/payment-link/controllers/payment-standard.controller.ts` | | GET | 1 | | `/paymentLink/standard/:id` | public | none | — | n/a | | `PaymentStandardController.getById` | `subdomains/core/payment-link/controllers/payment-standard.controller.ts` | -| GET | 1 | | `/paymentLink/stickers` | hidden | whole rows | 472 | not yet | yes | `PaymentLinkController.generateOcpStickers` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| GET | 1 | | `/paymentLink/stickers` | hidden | whole rows | 513 | not yet | yes | `PaymentLinkController.generateOcpStickers` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | GET | 1 | | `/paymentLink/walletApp` | public | whole rows | 33 | not yet | | `WalletAppController.getAll` | `subdomains/core/payment-link/controllers/wallet-app.controller.ts` | | GET | 1 | | `/paymentLink/walletApp/:id` | public | whole rows | 33 | not yet | | `WalletAppController.getById` | `subdomains/core/payment-link/controllers/wallet-app.controller.ts` | | GET | 1 | | `/paymentLink/walletApp/recommended` | public | whole rows | 33 | not yet | | `WalletAppController.getRecommended` | `subdomains/core/payment-link/controllers/wallet-app.controller.ts` | @@ -472,14 +472,14 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/realunit/sell/:id/confirm` | public | whole rows | 504 | not yet | | `RealUnitController.confirmSell` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/sell/:id/unsigned-transactions` | public | whole rows | 504 | not yet | | `RealUnitController.getSellUnsignedTransactions` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/support/:id` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.updateSupportIssue` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/:id/data` | hidden | whole rows | 253 | not yet | | `RealUnitSupportController.getIssueData` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/:id/data` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.getIssueData` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | POST | 1 | | `/realunit/support/:id/message` | hidden | whole rows | 441 | not yet | | `RealUnitSupportController.createSupportMessage` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/:id/message/:messageId/file` | hidden | whole rows | 253 | not yet | | `RealUnitSupportController.getFile` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/:id/messages` | hidden | whole rows | 253 | not yet | | `RealUnitSupportController.getIssueMessages` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/:id/message/:messageId/file` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.getFile` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/:id/messages` | hidden | whole rows | 428 | not yet | | `RealUnitSupportController.getIssueMessages` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/activity` | hidden | projected | 2 | 0/4 | | `RealUnitSupportController.getSupportIssueActivity` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/clerks` | hidden | none | — | n/a | | `RealUnitSupportController.getRealUnitSupportClerks` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/counts` | hidden | projected | 2 | 0/4 | | `RealUnitSupportController.getSupportIssueCounts` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/list` | hidden | projected | 1 | 4/4 | | `RealUnitSupportController.getSupportIssueList` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/list` | hidden | projected | 10 | 4/4 | | `RealUnitSupportController.getSupportIssueList` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/statistics` | hidden | projected | 3 | 0/4 | | `RealUnitSupportController.getSupportIssueStatistics` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | PUT | 1 | | `/realunit/swap` | public | whole rows | 308 | not yet | yes | `RealUnitController.getSwapPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/swap/:id/broadcast` | public | whole rows | 504 | not yet | yes | `RealUnitController.broadcastSwapTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | @@ -510,7 +510,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/sell` | hidden | whole rows | 308 | not yet | | `SellController.createSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | GET | 1 | | `/sell/:id` | public | whole rows | 377 | not yet | | `SellController.getSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/:id` | hidden | whole rows | 308 | not yet | | `SellController.updateSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | -| GET | 1 | | `/sell/:id/history` | hidden | projected | — | 4/4 | | `SellController.getSellRouteHistory` | `subdomains/core/sell-crypto/route/sell.controller.ts` | +| GET | 1 | | `/sell/:id/history` | hidden | projected | 14 | 4/4 | | `SellController.getSellRouteHistory` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/paymentInfos` | public | whole rows | 308 | not yet | | `SellController.createSellWithPaymentInfo` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/paymentInfos/:id/confirm` | public | whole rows | 545 | not yet | | `SellController.confirmSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | GET | 1 | | `/sell/paymentInfos/:id/tx` | public | whole rows | 504 | not yet | | `SellController.depositTx` | `subdomains/core/sell-crypto/route/sell.controller.ts` | @@ -533,12 +533,12 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/support/call-queues` | hidden | none | — | n/a | | `SupportController.getCallQueues` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues/:queue/items` | hidden | whole rows | 672 | not yet | | `SupportController.getCallQueueItems` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues/clerks` | hidden | none | — | n/a | | `SupportController.getCallQueueClerks` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/issue` | public | projected | — | 4/4 | | `SupportIssueController.getIssues` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue` | public | projected | 11 | 4/4 | | `SupportIssueController.getIssues` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue` | public | whole rows | 493 | not yet | | `SupportIssueController.createIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/:id` | public | projected | — | 4/4 | | `SupportIssueController.getIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/:id` | public | projected | 11 | 4/4 | | `SupportIssueController.getIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | PUT | 1 | | `/support/issue/:id` | hidden | whole rows | 421 | not yet | | `SupportIssueController.updateSupportIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | PUT | 1 | | `/support/issue/:id/close` | public | whole rows | 450 | not yet | | `SupportIssueController.closeIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/:id/data` | hidden | projected | — | 4/4 | | `SupportIssueController.getIssueData` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/:id/data` | hidden | projected | 81 | 4/4 | | `SupportIssueController.getIssueData` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/:id/message` | public | whole rows | 441 | not yet | yes | `SupportIssueController.createSupportMessage` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/:id/message/:messageId/file` | public | whole rows | — | not yet | | `SupportIssueController.getFile` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/activity` | hidden | projected | 2 | 0/4 | | `SupportIssueController.getSupportIssueActivity` | `subdomains/supporting/support-issue/support-issue.controller.ts` | @@ -548,7 +548,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/support/issue/escalation/telegram-bind` | hidden | whole rows | 5 | not yet | | `SupportIssueController.bindEscalationChat` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/escalation/telegram-chats` | hidden | none | — | n/a | | `SupportIssueController.getEscalationChats` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/escalation/telegram-test` | hidden | whole rows | 5 | not yet | | `SupportIssueController.testEscalationChat` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/list` | hidden | projected | — | 4/4 | | `SupportIssueController.getSupportIssueList` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/list` | hidden | projected | 10 | 4/4 | | `SupportIssueController.getSupportIssueList` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/statistics` | hidden | projected | 3 | 0/4 | | `SupportIssueController.getSupportIssueStatistics` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/support` | hidden | whole rows | 493 | not yet | | `SupportIssueController.createIssueBySupport` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/kycFileList` | hidden | whole rows | 253 | not yet | | `SupportController.getKycFileList` | `subdomains/generic/support/support.controller.ts` | @@ -573,7 +573,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/swap` | hidden | whole rows | 308 | not yet | | `SwapController.createSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | GET | 1 | | `/swap/:id` | public | whole rows | 308 | not yet | | `SwapController.getSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/:id` | hidden | whole rows | 396 | not yet | | `SwapController.updateSwapRoute` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | -| GET | 1 | | `/swap/:id/history` | hidden | projected | — | 4/4 | | `SwapController.getSwapRouteHistory` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | +| GET | 1 | | `/swap/:id/history` | hidden | projected | 12 | 4/4 | | `SwapController.getSwapRouteHistory` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/paymentInfos` | public | whole rows | 308 | not yet | | `SwapController.createSwapWithPaymentInfo` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/paymentInfos/:id/confirm` | public | whole rows | 545 | not yet | | `SwapController.confirmSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | GET | 1 | | `/swap/paymentInfos/:id/tx` | public | whole rows | 504 | not yet | | `SwapController.depositTx` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | @@ -603,7 +603,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | DELETE | 1 | yes | `/user` | public | whole rows | 344 | not yet | | `UserController.deleteUser` | `subdomains/generic/user/models/user/user.controller.ts` | | DELETE | 2 | | `/user` | public | whole rows | 344 | not yet | | `UserV2Controller.deleteAccount` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | yes | `/user` | public | whole rows | 328 | not yet | | `UserController.getUserV1` | `subdomains/generic/user/models/user/user.controller.ts` | -| GET | 2 | | `/user` | public | projected | — | 4/4 | | `UserV2Controller.getUser` | `subdomains/generic/user/models/user/user.controller.ts` | +| GET | 2 | | `/user` | public | projected | 66 | 4/4 | | `UserV2Controller.getUser` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | yes | `/user` | public | whole rows | 406 | not yet | | `UserController.updateUserV1` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 2 | | `/user` | public | whole rows | 351 | not yet | | `UserV2Controller.updateUser` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/:id` | hidden | whole rows | 364 | not yet | | `UserController.updateUserAdmin` | `subdomains/generic/user/models/user/user.controller.ts` | @@ -612,7 +612,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 2 | | `/user/addresses/:address` | public | whole rows | 351 | not yet | | `UserV2Controller.updateAddress` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/apiFilter/CT` | public | whole rows | 331 | not yet | | `UserController.updateApiFilter` | `subdomains/generic/user/models/user/user.controller.ts` | | DELETE | 1 | | `/user/apiKey/CT` | public | none | — | n/a | | `UserController.deleteApiKey` | `subdomains/generic/user/models/user/user.controller.ts` | -| POST | 1 | | `/user/apiKey/CT` | public | projected | — | 4/4 | | `UserController.createApiKey` | `subdomains/generic/user/models/user/user.controller.ts` | +| POST | 1 | | `/user/apiKey/CT` | public | projected | 3 | 4/4 | | `UserController.createApiKey` | `subdomains/generic/user/models/user/user.controller.ts` | | POST | 1 | | `/user/change` | public | whole rows | 643 | not yet | | `UserController.changeUser` | `subdomains/generic/user/models/user/user.controller.ts` | | POST | 1 | | `/user/data` | public | whole rows | 406 | not yet | | `UserController.updateKycData` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | yes | `/user/detail` | public | whole rows | 328 | not yet | | `UserController.getUserDetailV1` | `subdomains/generic/user/models/user/user.controller.ts` | @@ -620,7 +620,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 2 | | `/user/mail` | public | whole rows | 364 | not yet | | `UserV2Controller.updateUserMail` | `subdomains/generic/user/models/user/user.controller.ts` | | POST | 2 | | `/user/mail/verify` | public | whole rows | 364 | not yet | | `UserV2Controller.verifyMail` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/name` | hidden | whole rows | 386 | not yet | | `UserController.updateUserName` | `subdomains/generic/user/models/user/user.controller.ts` | -| GET | 2 | | `/user/profile` | public | projected | — | 4/4 | | `UserV2Controller.getProfile` | `subdomains/generic/user/models/user/user.controller.ts` | +| GET | 2 | | `/user/profile` | public | projected | 41 | 4/4 | | `UserV2Controller.getProfile` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | | `/user/ref` | hidden | projected | 1 | 0/4 | | `UserController.getRefInfo` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 2 | | `/user/ref` | public | whole rows | 98 | not yet | | `UserV2Controller.getRef` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 2 | | `/user/ref` | public | whole rows | 98 | not yet | | `UserV2Controller.updateRefAsset` | `subdomains/generic/user/models/user/user.controller.ts` | diff --git a/docs/load-sites.md b/docs/load-sites.md index 2728526d0e..fdd9c57818 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -537,7 +537,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 45 | 0 | query-builder (ohne-select) | `User` | `subdomains/generic/user/models/user/user.service.ts:177` | `UserService.getOpenRefCreditUser` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:41` | `FaucetRequestService.checkFaucetRequests` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:93` | `FaucetRequestService.resetFaucet` | -| 41 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:264` | `UserDataRepository.getProfile` | +| 41 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:267` | `UserDataRepository.getProfile` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:38` | `LiquidityManagementBalanceService.getAllLiqBalancesForAssets` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:68` | `LiquidityManagementBalanceService.refreshBankBalance` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:82` | `LiquidityManagementBalanceService.getBalances` | @@ -596,7 +596,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:387` | `DashboardReconciliationService.getExchangeFlows` | | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:544` | `DashboardReconciliationService.getExchangeWithdrawalsForBlockchain` | | 27 | 2 | find | `LiquidityManagementAction` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:257` | `LiquidityManagementRuleService.findExistingAction` | -| 26 | 0 | query-builder (feldliste) | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:66` | `PaymentLinkRepository.findForPosLink` | | 26 | 0 | query-builder (nur-alias) | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:22` | `BankAccountService.getBankAccountByKey` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:40` | `BankAccountService.checkFailedBankAccounts` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:48` | `BankAccountService.reloadErrorBankAccounts` | @@ -711,6 +710,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:15` | `LanguageService.getLanguage` | | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:19` | `LanguageService.getLanguageBySymbol` | | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:24` | `LanguageService.getLanguageByCountry` | +| 7 | 0 | query-builder (feldliste) | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:66` | `PaymentLinkRepository.findForPosLink` | | 7 | 0 | find | `UserDataRelation` | `subdomains/generic/user/models/user-data-relation/user-data-relation.service.ts:40` | `UserDataRelationService.updateUserDataRelation` | | 7 | 0 | query-builder (feldliste) | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:48` | `WalletRepository.findKycData` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:12` | `SpecialExternalAccountService.createSpecialExternalAccount` | From a0f820594b6aa1cc844695ab437f1876f9a8724d Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:33:40 +0200 Subject: [PATCH 23/46] Run the paths the specs had only described Three places asserted what a conversion is supposed to guarantee without executing the code that could break it. The point-of-sale preservation test read the loaded configuration and stopped there. The risk it names is a merge that starts from an empty object, and that happens inside the write - which now runs: a link carrying two non-default values gets an access key, and afterwards the stored configuration holds all three. Both values differ from the defaults deliberately, because the merge strips anything equal to them and an equal value would look lost. The API key spec pinned the key so responses stay comparable across runs, which left the real derivation unexercised - and that derivation is what reads the two projected columns. It now runs over the projected row, and a second case shows the creation date is an input rather than decoration: two accounts inserted in the same millisecond share it, so the secret is not unique per account, but changing the date changes the secret. GET /kyc/:id/documents had no fourth level at all. It shows nothing off the row except the account id the document store is keyed by, and that id is now compared against the unprojected load. --- .../__tests__/pos-link.projection.spec.ts | 41 +++++++++++++++---- .../kyc/__tests__/kyc-data.projection.spec.ts | 16 ++++++++ .../__tests__/api-key.projection.spec.ts | 28 +++++++++++++ 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts index dc7be670b6..1c46996df4 100644 --- a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts +++ b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts @@ -271,15 +271,42 @@ describeProjection('point-of-sale link — read-path projection', () => { 120000, ); - it('carries the stored configuration verbatim, so the write has something to merge into', async () => { - // The scoped write merges the new key into what was read. A projection that dropped `config` - // would hand the merge an empty object and reset the configuration to nothing but the key. - const existing = JSON.stringify({ fee: 0.9, cancellable: true }); - const { paymentLink } = await seedLink(AccountType.PERSONAL, {}, { config: existing }); + it('keeps the existing configuration when the write adds an access key to the link', async () => { + // The scoped write merges the new key into what was read, so this is the failure the projection + // could cause: a `config` it did not load hands the merge an empty object, and the stored + // configuration is replaced by nothing but the key. Run end to end for that reason. + const { paymentLink } = await seedLink( + AccountType.PERSONAL, + {}, + // Both values differ from the defaults: the merge strips anything equal to them, which is + // the service's own behaviour and would make an equal value look lost. + { config: JSON.stringify({ fee: 0.9, paymentTimeout: 12345 }) }, + ); - const loaded = await paymentLinks.findForPosLink(paymentLink.id); + const answer = await posLinkOf(paymentLink.id, true); + + const stored = JSON.parse((await dataSource.getRepository(PaymentLink).findOneBy({ id: paymentLink.id })).config); + expect(stored.accessKeys).toEqual([answer.key]); + expect(stored.fee).toEqual(0.9); + expect(stored.paymentTimeout).toEqual(12345); + }, 120000); + + it('hands the account write the key alone, leaving the merge to the account service', async () => { + // The unscoped branch does not merge here: it passes the new key to + // `UserDataService.updatePaymentLinksConfig`, which merges into the account's own configuration. + // What this side has to get right is that the account is the one the link belongs to. + const { paymentLink, userData } = await seedLink(AccountType.PERSONAL, { + paymentLinksConfig: JSON.stringify({ fee: 0.4 }), + }); - expect(loaded.config).toEqual(existing); + const answer = await posLinkOf(paymentLink.id, false); + + expect(userDataService.updatePaymentLinksConfig).toHaveBeenCalledWith( + expect.objectContaining({ id: userData.id }), + { + accessKeys: [answer.key], + }, + ); }, 120000); it('loads the two ids the endpoint scopes its updates by', async () => { diff --git a/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts b/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts index 35fb8dee40..7ef27080b2 100644 --- a/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts +++ b/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts @@ -162,6 +162,22 @@ describeProjection('kyc data — read-path projection', () => { // --- LEVEL 4: consistency against a second source --- // + it('level 4 — the document lookup resolves the same account id as a full load', async () => { + // `KycService.getKycFiles` keys the document store by this id and shows nothing else off the + // row, so the id is the entire contribution of this projection to that response. + const { user, userData } = await seedWalletUser(); + + const projected = await users.findAccountIdForAddress(user.address, user.wallet.id); + // The unprojected load is the second source: the find the endpoint used before. + const full = await dataSource.getRepository(User).findOne({ + where: { address: user.address, wallet: { id: user.wallet.id } }, + relations: { userData: true }, + }); + + expect(projected.userData.id).toEqual(full.userData.id); + expect(projected.userData.id).toEqual(userData.id); + }, 120000); + it.each([ [KycStatus.COMPLETED, KycType.DFX], [KycStatus.COMPLETED, KycType.LOCK], diff --git a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts index 1b51a27362..c8d9ea6681 100644 --- a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts +++ b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts @@ -148,6 +148,34 @@ describeProjection('API key — read-path projection', () => { expect(Object.keys(before).length).toBeGreaterThan(50); }, 120000); + it('runs the production key derivation over the projected row', async () => { + // The levels above pin the key so that responses are comparable across runs. That leaves the + // real derivation unexercised, and it is what reads the projected id and creation date. + const account = await seedAccount({ apiKeyCT: null }); + + const loaded = await userDataRepo.getForApiKey(account.id); + loaded.apiKeyCT = ApiKeyService.createKey(loaded.id); + + expect(loaded.apiKeyCT).toMatch(/^[0-9A-F]+$/); + expect(ApiKeyService.getSecret(loaded)).toMatch(/^[0-9A-F]{64}$/); + // The same account and the same key must always produce the same secret; the creation date is + // the other input, and it comes out of the projection. + expect(ApiKeyService.getSecret(loaded)).toEqual(ApiKeyService.getSecret(loaded)); + }, 120000); + + it('derives the secret from the creation date as well as the key', async () => { + // Two accounts inserted in the same millisecond share a creation date, so the secret is not + // unique per account by construction. What has to hold is that the date is an input at all — a + // projection that dropped it would answer the same secret for every date. + const account = await seedAccount({ apiKeyCT: null }); + + const loaded = await userDataRepo.getForApiKey(account.id); + loaded.apiKeyCT = 'SHARED-KEY'; + const withOtherDate = { ...loaded, created: new Date('2001-02-03T04:05:06.000Z') } as typeof loaded; + + expect(ApiKeyService.getSecret(loaded)).not.toEqual(ApiKeyService.getSecret(withOtherDate)); + }, 120000); + // --- LEVEL 4: consistency against a second source --- // it('level 4 — the projected answer equals the one from a full load', async () => { From 7667406ef90439eb3d08113c642f900544055c5d Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:46:05 +0200 Subject: [PATCH 24/46] Carry the counting rule down to the load-site table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the classification corrections were applied to the endpoint summary and not to the site table under it, so the same three sites read `projected` in one document and `no select at all — loads every column`, with a column count, in the other. They now carry their own kind. A chain ending in `getCount()` or `getExists()` materialises no row, so its column count is not a small number - there is none, and the table says so. The totals move with it: 113 of the 1,105 sites load less than a whole row, 992 load one. --- docs/endpoints.md | 2 +- docs/load-sites.md | 15 ++++++++------- docs/read-path-projections.md | 9 +++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/endpoints.md b/docs/endpoints.md index 470cf88b6f..383ab5d6d7 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -552,7 +552,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/support/issue/statistics` | hidden | projected | 3 | 0/4 | | `SupportIssueController.getSupportIssueStatistics` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/support` | hidden | whole rows | 493 | not yet | | `SupportIssueController.createIssueBySupport` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/kycFileList` | hidden | whole rows | 253 | not yet | | `SupportController.getKycFileList` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/kycFileStats` | hidden | projected | 99 | 0/4 | | `SupportController.getKycFileStats` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/kycFileStats` | hidden | projected | 1 | 0/4 | | `SupportController.getKycFileStats` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/note` | hidden | whole rows | 9 | not yet | | `SupportController.getNotes` | `subdomains/generic/support/support.controller.ts` | | POST | 1 | | `/support/note` | hidden | whole rows | 253 | not yet | | `SupportController.createNote` | `subdomains/generic/support/support.controller.ts` | | DELETE | 1 | | `/support/note/:id` | hidden | whole rows | 9 | not yet | | `SupportController.deleteNote` | `subdomains/generic/support/support.controller.ts` | diff --git a/docs/load-sites.md b/docs/load-sites.md index fdd9c57818..8d188c16d7 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -21,18 +21,19 @@ Among the query builders, the field list is what decides whether anything is act | `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **18** | | `.select('alias.column')` — names columns one by one | **87** | | `.select('alias')` — selects the root alias, **loads every column** | 17 | -| no `select` at all — loads every column | 20 | +| no `select` at all — loads every column | 17 | +| `getCount()` or `getExists()` — the select list is discarded, **no row is materialised** | 3 | | projects, but a `leftJoinAndSelect` loads a relation whole | 1 | `.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The distinction is the presence of a dot in the argument, and it matters — the sites that name columns this way select 1 column at the median, against 957 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. ## Measurements -Columns were measured against the real entity metadata by building the query and counting its SELECT list — 790 of 1105 sites. +Columns were measured against the real entity metadata by building the query and counting its SELECT list — 787 of 1105 sites. - **338 are exact**: the `relations` tree is written at the call site. -- **452 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. -- 315 could not be measured: no resolvable target entity, or raw SQL. +- **449 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. +- 318 could not be measured: no resolvable target entity, or raw SQL. Median across measured sites: **101 columns**. 14 sites exceed 1000, 74 exceed 500, 396 exceed 100. @@ -443,7 +444,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 99 | 0 | query-builder (nur-alias) | `UserData` | `subdomains/generic/user/models/user-data/user-data-notification.service.ts:173` | `UserDataNotificationService.blackSquadInvitation` | | 99 | 0 | query-builder (ohne-select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:141` | `UserDataService.getUserDataByUser` | | 99 | 0 | query-builder (nur-alias) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:313` | `UserDataService.getUserDataByKey` | -| 99 | 0 | query-builder (ohne-select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1766` | `UserDataService.countByDateRange` | | 98 | 2 | find | `User` | `subdomains/generic/user/models/user-data/user-data.service.ts:1033` | `UserDataService.customIdentMethod` | | 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:274` | `UserService.getRefDtoV2` | | 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:282` | `UserService.updateRef` | @@ -672,7 +672,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 12 | 0 | query-builder (feldliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:77` | `BuyCryptoRepository.findBuyHistory` | | 12 | 0 | query-builder (feldliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:91` | `BuyCryptoRepository.findSwapHistory` | | 11 | 0 | find | `OlkyRecipient` | `integration/bank/services/olkypay.service.ts:104` | `OlkypayService.getOrCreateRecipient` | -| 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:212` | `LedgerMarkToMarketService.alreadyBooked` | | 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:125` | `LedgerQueryService.getAccountDetail` | | 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:460` | `LedgerQueryService.marginBuckets` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:119` | `LogRepository.getFinancialLogAt` | @@ -680,7 +679,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:142` | `LogRepository.getLatestValidFinancialLogs` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:150` | `LogRepository.getLatestFinancialChangesLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:187` | `LogRepository.getFinancialChangesLogs` | -| 11 | 0 | query-builder (ohne-select) | `Log` | `subdomains/supporting/log/log.repository.ts:688` | `LogRepository.assertEmptyResultIsEndOfData` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:51` | `LogService.update` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:131` | `LogService.getLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:135` | `LogService.maxEntity` | @@ -901,6 +899,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:264` | `LedgerBookingService.activeTx` | | — | — | find | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:268` | `LedgerBookingService.activeTx` | | — | — | find | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:274` | `LedgerBookingService.activeTx` | +| — | — | query-builder (zaehlend) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:212` | `LedgerMarkToMarketService.alreadyBooked` | | — | — | find | `—` | `subdomains/core/aml/services/aml-helper.service.ts:375` | — | | — | — | find | `—` | `subdomains/core/aml/services/aml-helper.service.ts:684` | — | | — | — | find | `—` | `subdomains/core/aml/services/aml-helper.service.ts:685` | — | @@ -1036,6 +1035,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1344` | `UserDataService.mergeUserData` | | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1547` | `UserDataService.mergeUserData` | | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1725` | `UserDataService.updateBankTxTime` | +| — | — | query-builder (zaehlend) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1766` | `UserDataService.countByDateRange` | | — | — | find | `—` | `subdomains/generic/user/models/user/dto/user-dto.mapper.ts:27` | — | | — | — | find | `—` | `subdomains/generic/user/models/user/user.repository.ts:70` | `UserRepository.getNextRef` | | — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:336` | `UserService.createUser` | @@ -1096,6 +1096,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:341` | `LogRepository.getFinancialLogAssetPrices` | | — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:511` | `LogRepository.THEN` | | — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:664` | `LogRepository.getFinancialLogSummariesChartOnly` | +| — | — | query-builder (zaehlend) | `Log` | `subdomains/supporting/log/log.repository.ts:688` | `LogRepository.assertEmptyResultIsEndOfData` | | — | — | find | `—` | `subdomains/supporting/notification/services/notification.service.ts:128` | `NotificationService.resolveMailWallet` | | — | — | find | `—` | `subdomains/supporting/payin/services/payin-notification.service.ts:32` | `PayInNotificationService.returnedCryptoInput` | | — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:148` | `PayInService.getCryptoInputsByTransactionIds` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 920ecb5c63..01924378a5 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -47,10 +47,11 @@ and one on `LimitRequest` **434 across 15** — before any `relations` option is decision what to load therefore lives in the entity definition, not at the call site, and no call site can see what it triggers. -**No read model.** Of the 1,105 load sites in this repository, **110** name the columns they need: -105 query builders and the five raw statements. The other 995 request whole rows — 957 through the +**No read model.** Of the 1,105 load sites in this repository, **113** load less than a whole row: +105 query builders that name their columns, three that end in `getCount()` or `getExists()` and +materialise none, and the five raw statements. The other 992 request whole rows — 957 through the `find` family, and of the 143 query builders, 17 pass the root alias to `.select(...)`, which reads -like a projection but is not, 20 pass no select at all, and one projects its root but pulls a +like a projection but is not, 17 pass no select at all, and one projects its root but pulls a relation in whole. The same entities serve persistence, business logic and pure output paths such as invoices, receipts, history and exports — which need fields, not objects. @@ -216,7 +217,7 @@ per endpoint as `0/4` through `4/4`; only `4/4` is done. To any load site that carries an explicit field list — that is where a forgotten field silently yields an empty value. -A hundred and ten sites carry a field list. The table below covers the six that were known when this +A hundred and five sites carry a field list. The table below covers the six that were known when this document was written — one query builder and five raw statements — and none of them was converted, so it is unchanged. Another 87 are the query builders that name columns one at a time; they are not covered by these levels either, which is what their endpoints' `0/4` in From 20e1f1ace774f97a55df35471438a398a42037e0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:58:42 +0200 Subject: [PATCH 25/46] Run both write paths through their production methods Two write branches were still asserted through a mock, and one of them carries a risk the projection can actually cause. `updatePaymentLinksConfig` receives the projected account entity and re-reads `paymentLinksConfig` off it to merge into. A projection missing that column hands the merge an empty object and replaces the account's configuration with nothing but the new access key. The mock could never show that, so the method now runs for real, bound to a real repository - the one collaborator it uses out of the twenty-seven the service takes. `createApiKey` is called the same way: the production method against the projected read and its own write, asserting the persisted key, the persisted filter, the secret derived from the key and the creation date, and that the conflict branch writes nothing. --- .../__tests__/pos-link.projection.spec.ts | 30 ++++++++++----- .../__tests__/api-key.projection.spec.ts | 37 +++++++++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts index 1c46996df4..a1d5b8adde 100644 --- a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts +++ b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts @@ -24,6 +24,7 @@ import { BankData } from 'src/subdomains/generic/user/models/bank-data/bank-data import { Organization } from 'src/subdomains/generic/user/models/organization/organization.entity'; import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { UserDataRepository } from 'src/subdomains/generic/user/models/user-data/user-data.repository'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { DepositRouteService } from 'src/subdomains/supporting/address-pool/route/deposit-route.service'; @@ -47,6 +48,7 @@ describeProjection('point-of-sale link — read-path projection', () => { let dataSource: DataSource; let paymentLinks: PaymentLinkRepository; let userDataService: UserDataService; + let userData: UserDataRepository; let service: PaymentLinkService; beforeAll(async () => { @@ -54,10 +56,18 @@ describeProjection('point-of-sale link — read-path projection', () => { new ConfigService(); dataSource = await createProjectionDataSource(SCHEMA); paymentLinks = new PaymentLinkRepository(dataSource.manager); + userData = new UserDataRepository(dataSource.manager); }, 300000); beforeEach(() => { userDataService = createMock(); + // `updatePaymentLinksConfig` is the one collaborator method that matters here: it receives the + // projected account entity and re-reads `paymentLinksConfig` off it to merge into. Bound to a + // real repository it runs for real, so a projection that dropped that column would reset the + // account's configuration and this spec would see it. + userDataService.updatePaymentLinksConfig = jest.fn((user, dto) => + UserDataService.prototype.updatePaymentLinksConfig.call({ userDataRepo: userData }, user, dto), + ); service = new PaymentLinkService( paymentLinks, createMock(), @@ -291,22 +301,22 @@ describeProjection('point-of-sale link — read-path projection', () => { expect(stored.paymentTimeout).toEqual(12345); }, 120000); - it('hands the account write the key alone, leaving the merge to the account service', async () => { - // The unscoped branch does not merge here: it passes the new key to - // `UserDataService.updatePaymentLinksConfig`, which merges into the account's own configuration. - // What this side has to get right is that the account is the one the link belongs to. - const { paymentLink, userData } = await seedLink(AccountType.PERSONAL, { + it('keeps the existing configuration when the write adds an access key to the account', async () => { + // The unscoped branch merges on the account side, out of the projected entity it is handed — + // `updatePaymentLinksConfig` re-reads `paymentLinksConfig` off that entity. A projection missing + // the column would hand it an empty object and replace the account's configuration with nothing + // but the new key, which is why that method runs for real here. + const { paymentLink, userData: account } = await seedLink(AccountType.PERSONAL, { paymentLinksConfig: JSON.stringify({ fee: 0.4 }), }); const answer = await posLinkOf(paymentLink.id, false); - expect(userDataService.updatePaymentLinksConfig).toHaveBeenCalledWith( - expect.objectContaining({ id: userData.id }), - { - accessKeys: [answer.key], - }, + const stored = JSON.parse( + (await dataSource.getRepository(UserData).findOneBy({ id: account.id })).paymentLinksConfig, ); + expect(stored.accessKeys).toEqual([answer.key]); + expect(stored.fee).toEqual(0.4); }, 120000); it('loads the two ids the endpoint scopes its updates by', async () => { diff --git a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts index c8d9ea6681..ff132dfa60 100644 --- a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts +++ b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts @@ -1,5 +1,8 @@ import { ConfigService } from 'src/config/config'; import { ApiKeyService } from 'src/shared/services/api-key.service'; +import { HistoryFilter } from 'src/subdomains/core/history/dto/history-filter.dto'; +import { ApiKeyDto } from 'src/subdomains/generic/user/models/user/dto/api-key.dto'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { createProjectionDataSource, describeProjection, @@ -176,6 +179,40 @@ describeProjection('API key — read-path projection', () => { expect(ApiKeyService.getSecret(loaded)).not.toEqual(ApiKeyService.getSecret(withOtherDate)); }, 120000); + // --- the production path, end to end --- // + + /** + * `UserDataService.createApiKey`, bound to the real repository. + * + * The service takes twenty-seven collaborators and this method uses exactly one of them, so it is + * called on a minimal receiver rather than through a constructed service — what matters is that + * the production method runs against the projected read and its own write. + */ + const createApiKey = (userDataId: number, filter: HistoryFilter): Promise => + UserDataService.prototype.createApiKey.call({ userDataRepo }, userDataId, filter); + + it('issues a key through the service and persists both columns', async () => { + const account = await seedAccount({ apiKeyCT: null, apiFilterCT: null }); + + const answer = await createApiKey(account.id, { buy: true }); + + const stored = await dataSource.getRepository(UserData).findOneBy({ id: account.id }); + expect(stored.apiKeyCT).toEqual(answer.key); + expect(stored.apiFilterCT).toBeDefined(); + // The secret is derived rather than stored, from the key and the creation date the projection + // supplies. + expect(answer.secret).toEqual(ApiKeyService.getSecret(stored)); + }, 120000); + + it('refuses a second key through the service, and writes nothing', async () => { + const account = await seedAccount({ apiKeyCT: 'already-issued' }); + + await expect(createApiKey(account.id, { buy: true })).rejects.toThrow('API key already exists'); + + const stored = await dataSource.getRepository(UserData).findOneBy({ id: account.id }); + expect(stored.apiKeyCT).toEqual('already-issued'); + }, 120000); + // --- LEVEL 4: consistency against a second source --- // it('level 4 — the projected answer equals the one from a full load', async () => { From d3229c5ba1e4b5187e016004b6ff4b92e1f3d939 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:18:58 +0200 Subject: [PATCH 26/46] Apply the last two classification rules to the site table as well The variable-select rule had the same gap the counting rule did: it was applied to the endpoint summary and not to the sites under it, so `marginBuckets` still read `no select at all - loads every column` while its endpoint read `projected`. It names four expressions, one of them through a variable, and the column counter had to learn that form too. The method detection matched SQL inside template literals: `CASE WHEN ... THEN (` satisfies the signature pattern, and the inventory carried a load site attributed to a method named `THEN`. Upper-case identifiers are keywords here, not methods. Three comments overstated what the harness does. Generated values are distinct for numbers, dates and strings - a boolean has two values and an enum as many as it declares, so those repeat, which is why a spec that has to tell them apart pins them in the fixture. What every generated value is, is non-empty, and that is what the completeness level rests on. And two issues in the same state share the primary *sort* key, not the primary key. --- docs/endpoints.md | 4 ++-- docs/load-sites.md | 24 +++++++++---------- docs/read-path-projections.md | 18 +++++++------- src/shared/utils/projection-test.util.ts | 11 +++++---- .../__tests__/user-profile.projection.spec.ts | 2 +- .../support-issue-list.projection.spec.ts | 2 +- 6 files changed, 32 insertions(+), 29 deletions(-) diff --git a/docs/endpoints.md b/docs/endpoints.md index 383ab5d6d7..2b84de3f9c 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -219,7 +219,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/dashboard/accounting/ledger/accounts` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getAccounts` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/accounts/:accountId/legs` | hidden | whole rows | 30 | not yet | yes | `LedgerController.getAccountDetail` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/equity-comparison` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getEquityComparison` | `subdomains/core/accounting/controllers/ledger.controller.ts` | -| GET | 1 | | `/dashboard/accounting/ledger/margin` | hidden | projected | 11 | 0/4 | yes | `LedgerController.getMargin` | `subdomains/core/accounting/controllers/ledger.controller.ts` | +| GET | 1 | | `/dashboard/accounting/ledger/margin` | hidden | projected | 4 | 0/4 | yes | `LedgerController.getMargin` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/reconciliation` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getReconStatus` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/suspense` | hidden | projected | 10 | 4/4 | yes | `LedgerController.getSuspense` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/financial/changes` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getFinancialChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | @@ -255,7 +255,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/fiatOutput/:id` | hidden | whole rows | 59 | not yet | | `FiatOutputController.update` | `subdomains/supporting/fiat-output/fiat-output.controller.ts` | | GET | 1 | | `/frankencoin/info` | public | whole rows | 11 | not yet | | `FrankencoinController.getInfo` | `integration/blockchain/frankencoin/controllers/frankencoin.controller.ts` | | POST | 1 | | `/gs/db` | hidden | caller-defined | 13 | n/a | yes | `GsController.getDbData` | `subdomains/generic/gs/gs.controller.ts` | -| POST | 1 | | `/gs/db/custom` | hidden | caller-defined | — | n/a | yes | `GsController.getExtendedData` | `subdomains/generic/gs/gs.controller.ts` | +| POST | 1 | | `/gs/db/custom` | hidden | caller-defined | 2 | n/a | yes | `GsController.getExtendedData` | `subdomains/generic/gs/gs.controller.ts` | | POST | 1 | | `/gs/debug` | hidden | projected | — | n/a | yes | `GsController.executeDebugQuery` | `subdomains/generic/gs/gs.controller.ts` | | POST | 1 | | `/gs/evm/bridgeApproval` | hidden | whole rows | 33 | not yet | | `GsEvmController.approveBridge` | `subdomains/generic/gs/gs-evm.controller.ts` | | POST | 1 | | `/gs/evm/coinTransaction` | hidden | whole rows | 6 | not yet | | `GsEvmController.sendCoinTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | diff --git a/docs/load-sites.md b/docs/load-sites.md index 8d188c16d7..2013d3a0bb 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -19,21 +19,21 @@ Among the query builders, the field list is what decides whether anything is act | | Sites | | --- | ---: | | `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **18** | -| `.select('alias.column')` — names columns one by one | **87** | +| `.select('alias.column')` — names columns one by one | **90** | | `.select('alias')` — selects the root alias, **loads every column** | 17 | -| no `select` at all — loads every column | 17 | +| no `select` at all — loads every column | 14 | | `getCount()` or `getExists()` — the select list is discarded, **no row is materialised** | 3 | | projects, but a `leftJoinAndSelect` loads a relation whole | 1 | -`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The distinction is the presence of a dot in the argument, and it matters — the sites that name columns this way select 1 column at the median, against 957 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. +`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The distinction is the presence of a dot in the argument, and it matters — the sites that name columns this way select 2 columns at the median, against 957 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. ## Measurements -Columns were measured against the real entity metadata by building the query and counting its SELECT list — 787 of 1105 sites. +Columns were measured against the real entity metadata by building the query and counting its SELECT list — 790 of 1105 sites. - **338 are exact**: the `relations` tree is written at the call site. -- **449 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. -- 318 could not be measured: no resolvable target entity, or raw SQL. +- **452 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. +- 315 could not be measured: no resolvable target entity, or raw SQL. Median across measured sites: **101 columns**. 14 sites exceed 1000, 74 exceed 500, 396 exceed 100. @@ -673,7 +673,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 12 | 0 | query-builder (feldliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:91` | `BuyCryptoRepository.findSwapHistory` | | 11 | 0 | find | `OlkyRecipient` | `integration/bank/services/olkypay.service.ts:104` | `OlkypayService.getOrCreateRecipient` | | 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:125` | `LedgerQueryService.getAccountDetail` | -| 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:460` | `LedgerQueryService.marginBuckets` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:119` | `LogRepository.getFinancialLogAt` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:132` | `LogRepository.getLatestFinancialLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:142` | `LogRepository.getLatestValidFinancialLogs` | @@ -741,6 +740,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 5 | 0 | find | `Sanction` | `subdomains/core/aml/services/sanction.service.ts:54` | `SanctionService.syncList` | | 5 | 0 | query-builder (spaltenliste) | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:84` | `SupportNoteService.listUsers` | | 5 | 0 | query-builder (feldliste) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:61` | `SupportMessageRepository.findThread` | +| 4 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:460` | `LedgerQueryService.marginBuckets` | | 4 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:534` | `LedgerQueryService.cumulativeEquityByDay` | | 4 | 0 | find | `SystemStateSnapshot` | `subdomains/core/monitoring/monitoring.service.ts:47` | `MonitoringService.loadState` | | 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:278` | `LedgerQueryService.balancesByAccount` | @@ -770,6 +770,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 2 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:721` | `BuyFiatService.getRefVolume` | | 2 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:733` | `BuyFiatService.getPartnerFeeRefVolume` | | 2 | 0 | query-builder (spaltenliste) | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:53` | `TradingOrderService.getTradingOrderYield` | +| 2 | 0 | query-builder (spaltenliste) | `—` | `subdomains/generic/gs/gs.service.ts:868` | `GsService.getExtendedBankTxData` | +| 2 | 0 | query-builder (spaltenliste) | `—` | `subdomains/generic/gs/gs.service.ts:887` | `GsService.getExtendedBankTxData` | | 2 | 0 | query-builder (spaltenliste) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:493` | `BankDataService.getPendingReviewSummary` | | 2 | 0 | query-builder (spaltenliste) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:354` | `RecommendationService.countByRecommenderIds` | | 2 | 0 | query-builder (spaltenliste) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:369` | `RecommendationService.countByRecommendedIds` | @@ -809,6 +811,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 1 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:603` | `BuyFiatService.getUserVolume` | | 1 | 0 | query-builder (spaltenliste) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:271` | `SellService.getTotalVolume` | | 1 | 0 | query-builder (spaltenliste) | `TradingOrder` | `subdomains/core/trading/services/trading-rule.service.ts:35` | `TradingRuleService.getCurrentTradingOrders` | +| 1 | 0 | query-builder (ohne-select) | `—` | `subdomains/generic/gs/gs.service.ts:906` | `GsService.getExtendedBankTxData` | | 1 | 0 | query-builder (spaltenliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:179` | `UserDataService.getUserDataIdsByServiceProvider` | | 1 | 0 | query-builder (spaltenliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1774` | `UserDataService.getMaxKycFileIdByDateRange` | | 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:140` | `UserService.getAllLinkedUsers` | @@ -971,10 +974,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | raw-sql | `—` | `subdomains/generic/gs/gs.service.ts:337` | `GsService.executeDebugQuery` | | — | — | find | `—` | `subdomains/generic/gs/gs.service.ts:739` | `GsService.getParsedJsonData` | | — | — | find | `—` | `subdomains/generic/gs/gs.service.ts:742` | `GsService.getParsedJsonData` | -| — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:805` | `GsService.getRawDbData` | -| — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:868` | `GsService.getExtendedBankTxData` | -| — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:887` | `GsService.getExtendedBankTxData` | -| — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:906` | `GsService.getExtendedBankTxData` | +| — | — | query-builder (ohne-select) | `—` | `subdomains/generic/gs/gs.service.ts:805` | `GsService.getRawDbData` | | — | — | find | `—` | `subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts:35` | — | | — | — | find | `—` | `subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts:36` | — | | — | — | find | `—` | `subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts:125` | — | @@ -1094,7 +1094,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:997` | `LogJobService.getAssetLog` | | — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:1607` | `LogJobService.findSenderReceiverPair` | | — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:341` | `LogRepository.getFinancialLogAssetPrices` | -| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:511` | `LogRepository.THEN` | +| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:511` | `LogRepository.getFinancialLogSummariesFull` | | — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:664` | `LogRepository.getFinancialLogSummariesChartOnly` | | — | — | query-builder (zaehlend) | `Log` | `subdomains/supporting/log/log.repository.ts:688` | `LogRepository.assertEmptyResultIsEndOfData` | | — | — | find | `—` | `subdomains/supporting/notification/services/notification.service.ts:128` | `NotificationService.resolveMailWallet` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 01924378a5..3d0dcfd899 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -47,15 +47,15 @@ and one on `LimitRequest` **434 across 15** — before any `relations` option is decision what to load therefore lives in the entity definition, not at the call site, and no call site can see what it triggers. -**No read model.** Of the 1,105 load sites in this repository, **113** load less than a whole row: -105 query builders that name their columns, three that end in `getCount()` or `getExists()` and -materialise none, and the five raw statements. The other 992 request whole rows — 957 through the +**No read model.** Of the 1,105 load sites in this repository, **116** load less than a whole row: +108 query builders that name their columns, three that end in `getCount()` or `getExists()` and +materialise none, and the five raw statements. The other 989 request whole rows — 957 through the `find` family, and of the 143 query builders, 17 pass the root alias to `.select(...)`, which reads -like a projection but is not, 17 pass no select at all, and one projects its root but pulls a +like a projection but is not, 14 pass no select at all, and one projects its root but pulls a relation in whole. The same entities serve persistence, business logic and pure output paths such as invoices, receipts, history and exports — which need fields, not objects. -Read the first number carefully, because an earlier revision of this document got it wrong. The 87 +Read the first number carefully, because an earlier revision of this document got it wrong. The 90 query builders that name columns one at a time are almost entirely counts, maxima and id lookups — `.select('userData.id', 'id')`, `.select('COUNT(*)', 'count')` and the like — and they select **one column at the median**. They are projections, and they were miscounted as full loads because the @@ -217,9 +217,9 @@ per endpoint as `0/4` through `4/4`; only `4/4` is done. To any load site that carries an explicit field list — that is where a forgotten field silently yields an empty value. -A hundred and five sites carry a field list. The table below covers the six that were known when this -document was written — one query builder and five raw statements — and none of them was converted, -so it is unchanged. Another 87 are the query builders that name columns one at a time; they are +A hundred and thirteen sites carry a field list. The table below covers the six that were known when +this document was written — one query builder and five raw statements — and none of them was +converted, so it is unchanged. Another 90 are the query builders that name columns one at a time; they are not covered by these levels either, which is what their endpoints' `0/4` in [endpoints.md](endpoints.md) records. The remaining 17 belong to the endpoints converted so far and are covered on all four. Sites a conversion adds are recorded there too, where only @@ -296,7 +296,7 @@ The projection tests use that same gate. What they add lives in specs cannot collide, and fills it from the entity metadata via `synchronize` rather than from replayed migrations — the reference for a projection is the entity definition, not the migration history. Measured: 112 entities, 99 tables, 1,736 columns, about half a minute per spec file. -- **The fixtures**, generated from the same metadata. Every scalar column gets a distinct value and +- **The fixtures**, generated from the same metadata. Every scalar column gets a non-empty value and required relations are created recursively, so an empty field in a response proves the query failed to load something. Three kinds of value have to be pinned by hand, and all three are the fixture's business rather than the projection's: enum columns stored as text (the metadata reports diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts index 94d31cacc2..db116cfb12 100644 --- a/src/shared/utils/projection-test.util.ts +++ b/src/shared/utils/projection-test.util.ts @@ -69,9 +69,12 @@ export interface SeedSpec { relations?: Record; } -// One counter for the whole process. Every generated value is distinct, which is what makes an -// empty field in a response proof that the query failed to load something — and it keeps unique -// constraints satisfied when a spec seeds the same entity twice. +// One counter for the whole process. Every generated number, date and string is distinct, which is +// what keeps unique constraints satisfied when a spec seeds the same entity twice. Booleans and +// enums cannot be: a boolean has two values and an enum as many as it declares, so a spec that needs +// to tell two such columns apart pins them in the fixture. What every generated value is, is +// non-empty — which is what makes an empty field in a response proof that the query failed to load +// something. let counter = 0; const nextSeed = (): number => ++counter; @@ -153,7 +156,7 @@ function isPinned(spec: SeedSpec, column: ColumnMetadata): boolean { } /** - * Inserts one row with a distinct value in every column, creating required relations recursively. + * Inserts one row with a non-empty value in every column, creating required relations recursively. * * Generated from the metadata on purpose: a hand-written fixture that leaves a field empty makes * the completeness test green and the defect invisible. diff --git a/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts b/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts index 1d67605390..0976456aeb 100644 --- a/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts +++ b/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts @@ -45,7 +45,7 @@ describeProjection('GET /user/profile — read-path projection', () => { await destroyProjectionDataSource(dataSource, SCHEMA); }); - /** Fully populated fixture: every column of every participating entity carries a distinct value. */ + /** Fully populated fixture: every column of every participating entity carries a non-empty value. */ async function seedAccount(accountType: AccountType): Promise { return seedEntity(dataSource, UserData, { values: { accountType }, diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts index 767a243b38..c997101850 100644 --- a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts @@ -229,7 +229,7 @@ describeProjection('support issue list — read-path projection', () => { }, 120000); it('level 2 — the id tie-break orders rows that share a sort key', async () => { - // Two issues in the same state, sorted by state: the primary key is equal for both, so the + // Two issues in the same state, sorted by state: the primary sort key is equal for both, so the // order is decided by the id tie-break alone. Without it the order is whatever storage returns, // and a page boundary can drop or repeat a row. const first = await seedIssue({ clerk: 'tie-break', state: SupportIssueInternalState.IN_PROGRESS }); From 2c3b36a9d378bd72b337a5b7522c706a21b0c88c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:38:34 +0200 Subject: [PATCH 27/46] Make two assertions able to fail `toBeDefined()` on the persisted API filter accepts null, and the fixture starts with null - so an endpoint that never wrote the column would have passed. It is compared against the filter code the request produces. The sell-history access check seeded one user with one route each, then asked with that user's id and that user's route id. Either predicate alone returns exactly the expected row there, so dropping the other would not have shown. A second route of the same caller makes the route id necessary, and asking for a foreign route with the caller's id makes the user id necessary. --- .../buy-fiat-history.projection.spec.ts | 37 ++++++++++++++++--- .../__tests__/api-key.projection.spec.ts | 2 +- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts index c6afb6cd19..49fb015429 100644 --- a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts @@ -70,6 +70,20 @@ describeProjection('GET /sell/:id/history — read-path projection', () => { return { buyFiat, user, sell }; } + /** A second sell route of the same user, with one transaction on it. */ + async function seedSecondRouteFor(user: User): Promise<{ buyFiat: BuyFiat; sell: Sell }> { + const bankData = await seedEntity(dataSource, BankData); + const sell = await seedEntity(dataSource, Sell, { values: { user, bankData } }); + const asset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + const cryptoInput = await seedEntity(dataSource, CryptoInput, { values: { asset } }); + const fiatOutput = await seedEntity(dataSource, FiatOutput); + const outputAsset = await seedEntity(dataSource, Fiat); + const buyFiat = await seedEntity(dataSource, BuyFiat, { + values: { sell, cryptoInput, fiatOutput, outputAsset }, + }); + return { buyFiat, sell }; + } + // --- LEVEL 1: completeness --- // it('level 1 — the sell history answers with no empty field', async () => { @@ -95,15 +109,28 @@ describeProjection('GET /sell/:id/history — read-path projection', () => { expectNoEmptyFields(history, ['[0].date']); }, 120000); - it('level 2 — the route filter selects only the caller’s own transactions', async () => { + it('level 2 — both the user and the route filter are needed to select the right transactions', async () => { + // Two fixtures with a user and a route each are not enough: asking with one user's id and that + // user's route id, either predicate on its own still returns exactly the expected row. The + // second route below makes the route id necessary, and the foreign route makes the user id + // necessary — dropping either one then changes the answer. const mine = await seedBuyFiat(); + const second = await seedSecondRouteFor(mine.user); const other = await seedBuyFiat(); - const history = (await repository.findSellHistory(mine.user.id, mine.sell.id)).map(BuyFiatHistoryMapper.toDto); + const onOneRoute = (await repository.findSellHistory(mine.user.id, mine.sell.id)).map(BuyFiatHistoryMapper.toDto); + expect(onOneRoute.map((row) => row.inputAmount)).toEqual([mine.buyFiat.inputAmount]); + expect(onOneRoute.map((row) => row.inputAmount)).not.toContain(second.buyFiat.inputAmount); - expect(history).toHaveLength(1); - expect(history[0].inputAmount).toEqual(mine.buyFiat.inputAmount); - expect(history[0].inputAmount).not.toEqual(other.buyFiat.inputAmount); + // Without a route the caller gets every route they own, and still nothing of anyone else's. + const allOfMine = (await repository.findSellHistory(mine.user.id)).map(BuyFiatHistoryMapper.toDto); + expect(allOfMine.map((row) => row.inputAmount).sort()).toEqual( + [mine.buyFiat.inputAmount, second.buyFiat.inputAmount].sort(), + ); + + // A foreign route asked for with this caller's id resolves to nothing: the user predicate is + // what refuses it. + expect(await repository.findSellHistory(mine.user.id, other.sell.id)).toHaveLength(0); }, 120000); // --- LEVEL 3: mutation --- // diff --git a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts index ff132dfa60..f70f4be69a 100644 --- a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts +++ b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts @@ -198,7 +198,7 @@ describeProjection('API key — read-path projection', () => { const stored = await dataSource.getRepository(UserData).findOneBy({ id: account.id }); expect(stored.apiKeyCT).toEqual(answer.key); - expect(stored.apiFilterCT).toBeDefined(); + expect(stored.apiFilterCT).toEqual(ApiKeyService.getFilterCode({ buy: true })); // The secret is derived rather than stored, from the key and the creation date the projection // supplies. expect(answer.secret).toEqual(ApiKeyService.getSecret(stored)); From d397f2b75b1b8023dc6fa78168670255cf9aff47 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:28:08 +0200 Subject: [PATCH 28/46] Reach the branches three history specs never entered The buy-route access check had the same shape as the sell one: a caller with one route against a stranger with another, asked with that caller's id and that caller's route id. Either predicate alone returns the expected row there, so dropping the other would not have shown. It now runs against a second route of the same caller and a foreign route asked for with the caller's id, which makes each predicate necessary on its own. The swap route had no such test at all - the one that named itself the route filter called `findBuyHistory` only, so its own two predicates were never exercised. It has its own now. And no custody fixture ever left the three joined relations empty, although all three are nullable and all three are left joins. Written as inner joins, orders without an input asset, an output asset or a transaction request would drop out of the history silently, and all four levels would have stayed green. One order with none of them now proves they do not. --- .../buy-crypto-history.projection.spec.ts | 57 +++++++++++++++++-- .../custody-order-history.projection.spec.ts | 31 ++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts b/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts index 9e8da07c89..79e1988dbd 100644 --- a/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts +++ b/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts @@ -63,6 +63,26 @@ describeProjection('buy-crypto history — read-path projection', () => { return { buyCrypto, user, buy }; } + /** A second buy route of the same caller, with one transaction on it. */ + async function seedSecondBuyRouteFor(user: User): Promise<{ buyCrypto: BuyCrypto; buy: Buy }> { + const buy = await seedEntity(dataSource, Buy, { values: { user } }); + const outputAsset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + const buyCrypto = await seedEntity(dataSource, BuyCrypto, { + values: { buy, outputAsset, status: BuyCryptoStatus.COMPLETE }, + }); + return { buyCrypto, buy }; + } + + /** A second swap route of the same caller, with one transaction on it. */ + async function seedSecondSwapRouteFor(user: User): Promise<{ buyCrypto: BuyCrypto; route: Swap }> { + const route = await seedEntity(dataSource, Swap, { values: { user } }); + const outputAsset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + const buyCrypto = await seedEntity(dataSource, BuyCrypto, { + values: { cryptoRoute: route, outputAsset, status: BuyCryptoStatus.COMPLETE }, + }); + return { buyCrypto, route }; + } + /** The same, on a swap route. */ async function seedSwapCrypto(): Promise<{ buyCrypto: BuyCrypto; user: User; route: Swap }> { const user = await seedEntity(dataSource, User); @@ -114,15 +134,42 @@ describeProjection('buy-crypto history — read-path projection', () => { expectNoEmptyFields(history, ['[0].outputAsset', '[0].txUrl']); }, 120000); - it('level 2 — the route filter selects only the caller’s own transactions', async () => { + it('level 2 — both filters are needed to select the right buy transactions', async () => { + // One caller with one route against a stranger with another is not enough: asked with that + // caller's id and that caller's route id, either predicate on its own still returns exactly the + // expected row. A second route of the same caller makes the route id necessary; a foreign route + // asked for with the caller's id makes the user id necessary. const mine = await seedBuyCrypto(); + const second = await seedSecondBuyRouteFor(mine.user); const other = await seedBuyCrypto(); - const history = (await repository.findBuyHistory(mine.user.id, mine.buy.id)).map(BuyCryptoHistoryMapper.toDto); + const onOneRoute = (await repository.findBuyHistory(mine.user.id, mine.buy.id)).map(BuyCryptoHistoryMapper.toDto); + expect(onOneRoute.map((row) => row.txId)).toEqual([mine.buyCrypto.txId]); + expect(onOneRoute.map((row) => row.txId)).not.toContain(second.buyCrypto.txId); - expect(history).toHaveLength(1); - expect(history[0].txId).toEqual(mine.buyCrypto.txId); - expect(history[0].txId).not.toEqual(other.buyCrypto.txId); + const allOfMine = (await repository.findBuyHistory(mine.user.id)).map(BuyCryptoHistoryMapper.toDto); + expect(allOfMine.map((row) => row.txId).sort()).toEqual([mine.buyCrypto.txId, second.buyCrypto.txId].sort()); + + expect(await repository.findBuyHistory(mine.user.id, other.buy.id)).toHaveLength(0); + }, 120000); + + it('level 2 — both filters are needed to select the right swap transactions', async () => { + // The same for the swap route: its own query, its own two predicates, and no test reached them + // until now — the case above only ever called `findBuyHistory`. + const mine = await seedSwapCrypto(); + const second = await seedSecondSwapRouteFor(mine.user); + const other = await seedSwapCrypto(); + + const onOneRoute = (await repository.findSwapHistory(mine.user.id, mine.route.id)).map( + BuyCryptoHistoryMapper.toDto, + ); + expect(onOneRoute.map((row) => row.txId)).toEqual([mine.buyCrypto.txId]); + expect(onOneRoute.map((row) => row.txId)).not.toContain(second.buyCrypto.txId); + + const allOfMine = (await repository.findSwapHistory(mine.user.id)).map(BuyCryptoHistoryMapper.toDto); + expect(allOfMine.map((row) => row.txId).sort()).toEqual([mine.buyCrypto.txId, second.buyCrypto.txId].sort()); + + expect(await repository.findSwapHistory(mine.user.id, other.route.id)).toHaveLength(0); }, 120000); // --- LEVEL 3: mutation --- // diff --git a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts index aef2d57672..c0aa594aec 100644 --- a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts +++ b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts @@ -131,6 +131,37 @@ describeProjection('GET /custody/order — read-path projection', () => { expect(history[0].created).not.toEqual(other.order.created); }, 120000); + it('level 2 — an order without its optional relations is still listed', async () => { + // All three joined relations are nullable, and every other fixture sets them. If one of the + // left joins were written as an inner one, orders without that relation would vanish from the + // history without a trace — this is the only case that would notice. + const userData = await seedEntity(dataSource, UserData); + const user = await seedEntity(dataSource, User, { values: { userData } }); + const bare = await seedEntity(dataSource, CustodyOrder, { + values: { + user, + inputAsset: null, + outputAsset: null, + transactionRequest: null, + type: CustodyOrderType.DEPOSIT, + status: CustodyOrderStatus.COMPLETED, + }, + }); + + const history = await historyOf(userData.id); + + expect(history).toHaveLength(1); + expect(history[0].created).toEqual(bare.created); + expect(history[0].inputAsset).toBeUndefined(); + expect(history[0].outputAsset).toBeUndefined(); + // The full load answers the same, so nothing here depends on the projection either. + const full = await dataSource.getRepository(CustodyOrder).find({ + where: { user: { userData: { id: userData.id } } }, + relations: { inputAsset: true, outputAsset: true, transactionRequest: true }, + }); + expect(history).toEqual(CustodyOrderHistoryDtoMapper.mapList(full)); + }, 120000); + // --- LEVEL 3: mutation --- // const REQUEST_FALLBACK = ['transactionRequest.estimatedAmount', 'transactionRequest.amount']; From c986ce4d63b146573ff213b85a37baaf745a5926 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:00:22 +0200 Subject: [PATCH 29/46] Make a read of an unselected column throw instead of answering undefined A projected query that omits a column does not fail. The column reads as undefined, getters compute with it, and the endpoint answers 200 with a wrong value. Proving completeness field by field guards against that, but the proof fails the same silent way the bug does: a fixture that never reaches the branch reading a field is green and shows nothing. So make the failure loud. Projected rows are wrapped so that reading a column the query did not select throws, naming the entity and the column. Any test that exercises the endpoint then catches an incomplete field list, and a naive one is enough. The guard is installed once for the whole projection configuration rather than per spec, so a spec written later cannot lose it silently. It found three things on its first run: - PUT /paymentLink/:id/pos assembled a recipient block through configObj - name, contact data and address of the account - and discarded it. Only the access keys are read there, so the service now reads those two configurations directly. The merge is equivalent for that field: accessKeys is not part of the default configuration, so the link configuration overrides the account one exactly when it carries the key itself. - Owner-side join columns are reported by TypeORM under the relation's property name, so reading a relation looked like reading an unselected column. - A column written before it is read back is not a projection defect, so writes are recorded. Level 3 keeps one job the guard cannot do: showing that a field in the list is actually needed. That is a cost question rather than a correctness one, and the documentation now says so. --- docs/read-path-projections.md | 54 +++++- jest-projection.setup.ts | 9 + jest.projection.config.js | 2 + .../projection-guard.projection.spec.ts | 99 ++++++++++ src/shared/utils/projection-test.util.ts | 181 +++++++++++++++++- .../pipeline-status.projection.spec.ts | 5 +- .../services/payment-link.service.ts | 12 +- .../__tests__/api-key.projection.spec.ts | 7 +- 8 files changed, 357 insertions(+), 12 deletions(-) create mode 100644 jest-projection.setup.ts create mode 100644 src/shared/utils/__tests__/projection-guard.projection.spec.ts diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 3d0dcfd899..23bbbbdef9 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -41,7 +41,7 @@ equally wasteful under a limit of 4,096 — it simply would not have failed yet. Two properties combine: -**Eager relations.** 95 relations in this repo are declared `eager: true`. TypeORM expands them +**Eager relations.** 94 relations in this repo are declared `eager: true`, across 45 entities. TypeORM expands them recursively, so a plain `findOne()` on `UserData` already selects **253 columns across 8 joins**, and one on `LimitRequest` **434 across 15** — before any `relations` option is passed. The decision what to load therefore lives in the entity definition, not at the call site, and no call @@ -207,10 +207,51 @@ This service carries **234 such getters across 50 of its 112 entities**. In an a money, a silent wrong value is worse than a crash: a statement that exceeds the column limit fails loudly and is found at once, while a wrong value can run for weeks. +## The guard: making the failure loud + +Before the levels, the mechanism they used to substitute for. + +A column the query did not select is `undefined` on the entity. Getters compute with it, the mapper +puts the result in the response, and the endpoint answers 200. Nothing fails. The obvious defence is +to prove completeness field by field — drop each one, require the response to change — and that is +what level 3 below does. It works, and it is expensive in a particular way: it needs a fixture that +reaches every branch reading every field, and a fixture that misses its branch is **green while +proving nothing**. The safety net then fails exactly the way the defect fails, which is why writing +these tests kept producing assertions that could not fail. + +So the silence is switched off instead. Every query a `ReadProjection` builds returns rows wrapped +by `guardProjection`, and reading a column the field list did not ask for **throws**, naming the +column: + + read of 'UserData.organizationName', which this query did not select — + add it to the projection, or stop reading it + +The consequences are worth stating plainly: + +- **Any test that exercises the endpoint catches an incomplete field list**, including a naive one. + Completeness stops depending on how cleverly the fixture was written. +- It is installed once for the whole configuration (`jest-projection.setup.ts`), not per spec, so a + spec written later cannot lose the protection quietly. +- It reports **where the column was needed**, not where the wrong value surfaced. A getter keeps + running — reading *through* a getter is how the missing column is reached — and the failure names + the column the getter wanted. +- It enforces a stricter rule than "the response is correct": it requires the projection to cover + what the code **reads**, not what the response happens to depend on. That difference is not + academic — it is what found `PUT /paymentLink/:id/pos` assembling a recipient block out of the + account's name, contact data and address, and discarding it. The fix was to stop reading, not to + load more. +- Relations the projection does not join are left alone: they are `undefined`, and dereferencing + them already throws. So is a column the caller assigned itself before reading it back, which is + what the write paths do. + +The guard is verified by `projection-guard.projection.spec.ts` — it is the one piece the levels +below cannot check, because they rely on it. + ## Test definition -All four levels must pass for an endpoint to count as converted. `endpoints.md` records the state -per endpoint as `0/4` through `4/4`; only `4/4` is done. +The guard covers completeness. The levels cover what it cannot see: whether the response is right, +whether the variants that matter are exercised, and whether the projection carries more than it +needs. `endpoints.md` records the state per endpoint as `0/4` through `4/4`; only `4/4` is done. ### Which endpoints these apply to @@ -363,6 +404,13 @@ depends on a status field. **Remove each field of the projection individually; the response must change every time.** +Since the guard, this level no longer protects against a *missing* field — a dropped field now +throws before it can produce a wrong value, and `expectEveryFieldRequired` counts that throw as the +field carrying weight. What it still does is the opposite direction: it shows that a field in the +list is **needed**, so a projection cannot quietly grow past what the endpoint uses. That is a cost +question rather than a correctness one, and it is the reason the fixture-per-branch work below is +worth doing where a field list is intricate and can be skipped where it is not. + This proves the test looks at anything at all. Where removing a field changes nothing, one of two things is true: the field is unnecessary and can be dropped permanently, or the fixture never reaches the branch that reads it — and a real defect would have slipped through there. diff --git a/jest-projection.setup.ts b/jest-projection.setup.ts new file mode 100644 index 0000000000..7cf5c7c22b --- /dev/null +++ b/jest-projection.setup.ts @@ -0,0 +1,9 @@ +// Turns the projection guard on for every spec in this configuration. +// +// A projected query that answers with a column it did not select is the defect this suite exists +// to catch, and it is silent by nature. The guard makes it throw. Installing it here rather than +// per spec is deliberate: a spec written later would otherwise lose the protection without anything +// saying so. +import { installProjectionGuard } from './src/shared/utils/projection-test.util'; + +installProjectionGuard(); diff --git a/jest.projection.config.js b/jest.projection.config.js index 1e7e5b40f2..1f9be983a2 100644 --- a/jest.projection.config.js +++ b/jest.projection.config.js @@ -15,6 +15,8 @@ module.exports = { ...base, transform: { '^.+\\.(t|j)s$': ['ts-jest', { tsconfig: 'tsconfig.coverage.json' }] }, testRegex: '.*\\.projection\\.spec\\.ts$', + // The guard is installed once for the whole configuration; see jest-projection.setup.ts. + setupFilesAfterEnv: ['/../jest-projection.setup.ts'], // The base config excludes these specs so the main suite does not pick them up. Spreading it in // would exclude them here too — this run is the one that must find them. testPathIgnorePatterns: ['/node_modules/'], diff --git a/src/shared/utils/__tests__/projection-guard.projection.spec.ts b/src/shared/utils/__tests__/projection-guard.projection.spec.ts new file mode 100644 index 0000000000..f28703f51d --- /dev/null +++ b/src/shared/utils/__tests__/projection-guard.projection.spec.ts @@ -0,0 +1,99 @@ +import { ReadProjection } from 'src/shared/models/read-projection'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + guardProjection, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { Country } from 'src/shared/models/country/country.entity'; +import { Language } from 'src/shared/models/language/language.entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'projection_guard_spec'; + +/** + * The guard itself, tested against a real query. + * + * Everything else in this suite relies on it: it is what turns a silently missing column into a + * failure, and it is therefore the one piece that cannot be verified by the levels it protects. + */ +describeProjection('guardProjection', () => { + let dataSource: DataSource; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + const FIELDS = ['userData.id', 'userData.mail', 'userData.firstname']; + const PROJECTION = new ReadProjection('userData', [['userData.language', 'language']], FIELDS); + + async function load(fields: ReadonlyArray, id: number): Promise { + const row = await PROJECTION.apply(dataSource.getRepository(UserData).createQueryBuilder('userData'), fields) + .where('userData.id = :id', { id }) + .getOne(); + + return guardProjection(dataSource, UserData, PROJECTION, fields, row); + } + + it('passes through a column the query selected', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const row = await load(FIELDS, seeded.id); + + expect(row.mail).toEqual(seeded.mail); + expect(row.firstname).toEqual(seeded.firstname); + }, 120000); + + it('throws on a column the query did not select, naming it', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const row = await load(FIELDS, seeded.id); + + // Without the guard this reads `undefined` and every getter downstream computes with it. + expect(() => row.surname).toThrow("read of 'UserData.surname', which this query did not select"); + }, 120000); + + it('reports the column a getter reaches through, not the getter', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + // `completeName` is `organizationName ?? firstname + surname`. The getter has to keep running — + // it is how the missing column is reached — and the failure has to name the column. + const row = await load(FIELDS, seeded.id); + + expect(() => row.completeName).toThrow('UserData.organizationName'); + }, 120000); + + it('guards a joined relation in turn', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const fields = [...FIELDS, 'language.id', 'language.name']; + const row = await load(fields, seeded.id); + + expect(row.language.name).toEqual(language.name); + // Two levels down, and reported where it is missing rather than at the relation. + expect(() => row.language.symbol).toThrow("read of 'Language.symbol'"); + }, 120000); + + it('leaves a column the query selected but the row has as null alone', async () => { + const language = await seedEntity(dataSource, Language); + const country = await seedEntity(dataSource, Country); + const seeded = await seedEntity(dataSource, UserData, { + values: { language, country, firstname: null }, + }); + + // A selected column that is genuinely null is data, not a projection defect. + const row = await load(FIELDS, seeded.id); + + expect(row.firstname).toBeNull(); + }, 120000); +}); diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts index db116cfb12..75746f2dc1 100644 --- a/src/shared/utils/projection-test.util.ts +++ b/src/shared/utils/projection-test.util.ts @@ -1,4 +1,5 @@ -import { DataSource, EntityMetadata, EntityTarget, ObjectLiteral } from 'typeorm'; +import { ReadProjection } from 'src/shared/models/read-projection'; +import { DataSource, EntityMetadata, EntityTarget, ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; /** @@ -305,3 +306,181 @@ export async function expectEveryFieldRequired( export function allColumnNames(metadata: EntityMetadata): string[] { return metadata.columns.map((column) => column.propertyName); } + +/** + * Makes an incomplete projection fail loudly. + * + * The defect this whole test definition exists for is silent: a column the query did not select is + * `undefined` on the entity, getters compute with it, and the endpoint answers 200 with a wrong + * value. Proving completeness field by field is possible — that is what the mutation level does — + * but it needs a fixture that reaches every branch reading every field, and a fixture that misses + * its branch is green while proving nothing. The safety net then fails the way the defect fails. + * + * This turns the silence off. Reading a column the field list did not ask for throws, so any test + * that exercises the endpoint at all — however naive — reports an incomplete projection, and reports + * it at the property that was missing. + * + * The decision comes from the field list, not from the row: this repository compiles to a target + * where class fields are defined on the instance, so every declared column exists and an unselected + * one is indistinguishable from a selected `null` by looking at the object. + * + * Joined relations are guarded in turn, each against the fields selected for its own alias, so a + * column missing two levels down is reported there. Relations the projection does not join are left + * alone: they are `undefined`, and dereferencing them already throws. + * + * Only mapped columns are guarded. Methods and getters pass through untouched — reading *through* a + * getter is how the missing column is reached, so the getter has to keep running. + */ +export function guardProjection( + dataSource: DataSource, + target: EntityTarget, + projection: GuardableProjection, + fields: ReadonlyArray, + entity: E, +): E; +export function guardProjection( + dataSource: DataSource, + target: EntityTarget, + projection: GuardableProjection, + fields: ReadonlyArray, + entity: E[], +): E[]; +export function guardProjection( + dataSource: DataSource, + target: EntityTarget, + projection: GuardableProjection, + fields: ReadonlyArray, + entity: E | E[] | null, +): E | E[] | null { + if (entity == null) return entity; + return guardAgainst(dataSource.getMetadata(target), projection, fields, entity) as E | E[]; +} + +/** The part of a `ReadProjection` the guard needs. */ +export interface GuardableProjection { + alias: string; + joins: ReadonlyArray; + guards: ReadonlyArray; +} + +function guardAgainst( + rootMetadata: EntityMetadata, + projection: GuardableProjection, + fields: ReadonlyArray, + entity: unknown, +): unknown { + if (entity == null) return entity; + + /** What the query selected, per alias. Guards are selected too — `apply` adds them. */ + const selected = new Map>(); + for (const field of [...fields, ...projection.guards]) { + const [alias, property] = field.split('.'); + if (!selected.has(alias)) selected.set(alias, new Set()); + selected.get(alias).add(property); + } + + interface Node { + metadata: EntityMetadata; + asked: Set; + children: Map; + } + + const node = (metadata: EntityMetadata, alias: string): Node => ({ + metadata, + asked: selected.get(alias) ?? new Set(), + children: new Map(), + }); + + const root = node(rootMetadata, projection.alias); + const byAlias = new Map([[projection.alias, root]]); + + // `['parentAlias.relation', 'alias']`, in order — a later join may build on an earlier alias. + for (const [path, alias] of projection.joins) { + const [parentAlias, property] = path.split('.'); + const parent = byAlias.get(parentAlias); + const relation = parent?.metadata.relations.find((r) => r.propertyName === property); + if (!relation) continue; + const child = node(relation.inverseEntityMetadata, alias); + parent.children.set(property, child); + byAlias.set(alias, child); + } + + const wrap = (row: T | T[] | null, at: Node): T | T[] | null => { + if (row == null) return row; + if (Array.isArray(row)) return row.map((one) => wrap(one, at)) as T[]; + + // Properties the caller assigned itself. A projected read does not carry them, but writing one + // and reading it back is what the write paths do — `createApiKey` sets the filter code on the + // row before passing it to the update — and that is not a projection defect. + const written = new Set(); + + return new Proxy(row, { + set(source, property, value, receiver) { + written.add(String(property)); + return Reflect.set(source, property, value, receiver); + }, + get(source, property, receiver) { + const name = String(property); + const value = Reflect.get(source, property, receiver); + + const child = at.children.get(name); + if (child) return wrap(value, child); + + // A relation the projection does not declare is not guarded: it is `undefined`, and + // dereferencing it already throws. That includes the owner-side join column, which TypeORM + // reports as a column under the relation's own property name. + if (at.metadata.relations.some((relation) => relation.propertyName === name)) return value; + + if ( + at.metadata.columns.some((column) => column.propertyName === name) && + !at.asked.has(name) && + !written.has(name) + ) { + throw new Error( + `read of '${at.metadata.name}.${name}', which this query did not select — ` + + `add it to the projection, or stop reading it`, + ); + } + + return value; + }, + }) as T; + }; + + return wrap(entity as ObjectLiteral, root); +} + +/** + * Turns the guard on for every query a `ReadProjection` builds, for the rest of the process. + * + * Wiring it per spec would work and would be forgotten: a spec written next month would silently + * lose the protection, and nothing would say so. Here it applies to every projected query in the + * suite, including the ones the mutation level runs with a reduced field list. + * + * Test-only by construction — this is the only caller, and production never loads this file. + */ +export function installProjectionGuard(): void { + const original = ReadProjection.prototype.apply; + if ((original as { guarded?: boolean }).guarded) return; + + function guarded( + this: ReadProjection, + query: SelectQueryBuilder, + fields: ReadonlyArray = this.fields, + ): SelectQueryBuilder { + const built = original.call(this, query, fields) as SelectQueryBuilder; + const metadata = built.expressionMap.mainAlias?.metadata; + if (!metadata) return built; + + for (const method of ['getOne', 'getMany'] as const) { + const inner = built[method].bind(built); + (built as unknown as Record)[method] = async () => + guardAgainst(metadata, this, fields, await inner()); + } + + return built; + } + + (guarded as { guarded?: boolean }).guarded = true; + ReadProjection.prototype.apply = guarded as typeof ReadProjection.prototype.apply; +} diff --git a/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts b/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts index b05781380b..d3e2653752 100644 --- a/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts +++ b/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts @@ -55,7 +55,10 @@ describeProjection('liquidity management pipeline status — read-path projectio } /** The response the endpoint produces, through the projected query. */ - async function statusOf(id: number, fields = PIPELINE_STATUS_PROJECTION.fields) { + async function statusOf( + id: number, + fields = PIPELINE_STATUS_PROJECTION.fields, + ): Promise { const pipeline = await pipelines.findForStatus(id, fields); return pipeline?.status; } diff --git a/src/subdomains/core/payment-link/services/payment-link.service.ts b/src/subdomains/core/payment-link/services/payment-link.service.ts index bca41e760a..daaecf8b68 100644 --- a/src/subdomains/core/payment-link/services/payment-link.service.ts +++ b/src/subdomains/core/payment-link/services/payment-link.service.ts @@ -706,12 +706,12 @@ export class PaymentLinkService { } private async createPosLinkFor(paymentLink: PaymentLink, scoped?: boolean): Promise { - const config = - scoped == null - ? paymentLink.configObj - : scoped - ? paymentLink.linkConfigObj - : paymentLink.route.userData.paymentLinksConfigObj; + // Only the access keys are read out of the configuration here. `configObj` would also assemble + // the recipient block — name, contact data and address of the account — which this endpoint + // discards, and reading those columns is the only reason the query would have to load them. + const accountConfig = paymentLink.route.userData.paymentLinksConfigObj; + const linkConfig = paymentLink.linkConfigObj; + const config = scoped == null ? { ...accountConfig, ...linkConfig } : scoped ? linkConfig : accountConfig; let accessKey = config.accessKeys?.at(0); if (!accessKey) { diff --git a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts index f70f4be69a..f95716056d 100644 --- a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts +++ b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts @@ -174,7 +174,12 @@ describeProjection('API key — read-path projection', () => { const loaded = await userDataRepo.getForApiKey(account.id); loaded.apiKeyCT = 'SHARED-KEY'; - const withOtherDate = { ...loaded, created: new Date('2001-02-03T04:05:06.000Z') } as typeof loaded; + // Built from the two columns `getSecret` reads rather than spread from the row: a spread touches + // every property, including ones this query had no reason to select. + const withOtherDate = { + apiKeyCT: loaded.apiKeyCT, + created: new Date('2001-02-03T04:05:06.000Z'), + } as typeof loaded; expect(ApiKeyService.getSecret(loaded)).not.toEqual(ApiKeyService.getSecret(withOtherDate)); }, 120000); From 741b52521216dba79dd3705504729c0b843db556 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:42:45 +0200 Subject: [PATCH 30/46] Measure what removing the eager relations would cost, and remove what is free The premise of this branch is that converting endpoints one at a time treats the symptom, and that the cause is the entity deciding what a query loads. That makes reducing the eager relations look like the cheaper route. Measured, it is not, and the reason is worth writing down. 55 handlers answer with an entity rather than a response object, 35 distinct entities between them. For those the eager relations are not a loading detail - they are the answer. Their closure covers 57 of the 103 eager relations this application builds at runtime, so the majority cannot be removed as a refactor: removing one changes what an endpoint returns. All 55 carry a role guard and all but two are excluded from the Swagger schema, which makes it a decision to take rather than a wall - but a decision either way, and not one this change takes. Four declarations are read by no code and contained in no response, and those are removed: Buy.route, CustodyBalance.user, CustodyOrder.transaction, CryptoStaking.paybackDeposit. That narrows 55 load sites - the custody order paths by 98 columns each - and 47 rows of the inventory. After it nothing is left that is both unread and outside every response. eager-relations.projection.spec.ts pins the closure and the total, so a relation added to an entity in it fails the run naming the controllers whose answer it changes. Three things the measurement had to get right, each of which a cheaper method got wrong: - Where a relation is read is a type question, not a text one, and it has to follow inheritance downwards: DepositRoute.route is a plain property that Sell and Swap override, so a read through the base type is a read of the child's eager relation. Without that step the measurement called Sell.route and Swap.route dead, and both are live. - What a query joins comes from the entity metadata: DepositRoute carries the eager relations of every child entity, so a query on the parent joins what Sell, Swap and Staking declare. - Which entities leave through a controller is read from the controllers rather than from a list. A list goes stale, and the first version of this - which only looked at entities already carrying an eager relation - missed 27 of the 55 handlers. The column counts in the inventory are re-measured against the entity metadata rather than adjusted, and the aggregates in both documents are recomputed from their own tables. --- docs/endpoints.md | 96 ++++---- docs/load-sites.md | 112 +++++----- docs/read-path-projections.md | 63 +++++- .../eager-relations.projection.spec.ts | 207 ++++++++++++++++++ .../core/buy-crypto/routes/buy/buy.entity.ts | 2 +- .../entities/custody-balance.entity.ts | 2 +- .../custody/entities/custody-order.entity.ts | 2 +- .../staking/entities/crypto-staking.entity.ts | 2 +- 8 files changed, 377 insertions(+), 109 deletions(-) create mode 100644 src/shared/models/__tests__/eager-relations.projection.spec.ts diff --git a/docs/endpoints.md b/docs/endpoints.md index 2b84de3f9c..acb9283fcd 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -36,7 +36,7 @@ Today 38 endpoints read only what they return and 398 do not, so the column read Of the 36 that read only what they return, 17 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11), `GET /kyc/users` (328 columns to 7), `GET /kyc/:id/documents` (328 columns to 2), `GET /custody/order` (19 columns to 14), `GET /support/issue/list` (16 columns to 10), `GET /realunit/support/list` (16 columns to 10), `GET /dashboard/accounting/ledger/suspense` (11 columns to 10), `GET /liquidityManagement/pipeline/:id/status` (112 columns to 2), `PUT /paymentLink/:id/pos` (513 columns to 7), `POST /user/apiKey/CT` (253 columns to 3), `GET /user` (351 columns to 66). The other 19 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. -Among the 398 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 307 exceed 100, 86 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. +Among the 398 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 306 exceed 100, 74 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. ### How to read this column, and how not to @@ -143,7 +143,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/balance/pdf` | public | whole rows | 40 | not yet | | `BalanceController.getBalancePdf` | `subdomains/supporting/balance/controllers/balance.controller.ts` | | GET | 1 | | `/balance/pdf/blockchains` | public | none | — | n/a | | `BalanceController.getSupportedBlockchains` | `subdomains/supporting/balance/controllers/balance.controller.ts` | | GET | 1 | | `/bank` | public | whole rows | 46 | not yet | | `BankController.getAllBanks` | `subdomains/supporting/bank/bank/bank.controller.ts` | -| PUT | 1 | | `/bank/receiveIban` | public | whole rows | 101 | not yet | yes | `BankController.checkReceiveIban` | `subdomains/supporting/bank/bank/bank.controller.ts` | +| PUT | 1 | | `/bank/receiveIban` | public | whole rows | 97 | not yet | yes | `BankController.checkReceiveIban` | `subdomains/supporting/bank/bank/bank.controller.ts` | | POST | 1 | | `/bank/yapeal/webhook` | hidden | none | — | n/a | | `YapealWebhookController.handleYapealWebhook` | `integration/bank/controllers/yapeal-webhook.controller.ts` | | GET | 1 | | `/bankAccount` | public | whole rows | 261 | not yet | | `BankAccountController.getAllUserBankAccount` | `subdomains/supporting/bank/bank-account/bank-account.controller.ts` | | POST | 1 | | `/bankAccount` | public | whole rows | 261 | not yet | | `BankAccountController.createBankAccount` | `subdomains/supporting/bank/bank-account/bank-account.controller.ts` | @@ -162,24 +162,24 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/blockchain/broadcast` | public | none | — | n/a | | `BlockchainApiController.broadcastTransaction` | `integration/blockchain/api/controllers/blockchain-api.controller.ts` | | POST | 1 | | `/blockchain/transaction` | public | whole rows | 33 | not yet | | `BlockchainApiController.createTransaction` | `integration/blockchain/api/controllers/blockchain-api.controller.ts` | | GET | 1 | | `/buy` | hidden | whole rows | 308 | not yet | | `BuyController.getAllBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| POST | 1 | | `/buy` | hidden | whole rows | 364 | not yet | | `BuyController.createBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| POST | 1 | | `/buy` | hidden | whole rows | 360 | not yet | | `BuyController.createBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | GET | 1 | | `/buy/:id` | public | whole rows | 308 | not yet | | `BuyController.getBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/:id` | hidden | whole rows | 308 | not yet | | `BuyController.updateBuyRoute` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | GET | 1 | | `/buy/:id/history` | hidden | projected | 12 | 4/4 | | `BuyController.getBuyRouteHistory` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| PUT | 1 | | `/buy/paymentInfos` | public | whole rows | 364 | not yet | | `BuyController.createBuyWithPaymentInfo` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| PUT | 1 | | `/buy/paymentInfos/:id/confirm` | public | whole rows | 504 | not yet | | `BuyController.confirmBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| PUT | 1 | | `/buy/paymentInfos/:id/invoice` | public | whole rows | 504 | not yet | yes | `BuyController.generateInvoicePDF` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| GET | 1 | | `/buy/personalIban` | public | whole rows | 331 | not yet | | `BuyController.getAllPersonalIbans` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| PUT | 1 | | `/buy/paymentInfos` | public | whole rows | 360 | not yet | | `BuyController.createBuyWithPaymentInfo` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| PUT | 1 | | `/buy/paymentInfos/:id/confirm` | public | whole rows | 484 | not yet | | `BuyController.confirmBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| PUT | 1 | | `/buy/paymentInfos/:id/invoice` | public | whole rows | 484 | not yet | yes | `BuyController.generateInvoicePDF` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| GET | 1 | | `/buy/personalIban` | public | whole rows | 327 | not yet | | `BuyController.getAllPersonalIbans` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | POST | 1 | | `/buy/personalIban` | public | whole rows | 253 | not yet | | `BuyController.createPersonalIban` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/quote` | public | whole rows | 143 | not yet | | `BuyController.getBuyQuote` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| PUT | 1 | | `/buyCrypto/:id` | hidden | whole rows | 1090 | not yet | | `BuyCryptoController.update` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyCrypto/:id` | hidden | whole rows | 1086 | not yet | | `BuyCryptoController.update` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | DELETE | 1 | | `/buyCrypto/:id/amlCheck` | hidden | whole rows | 422 | not yet | | `BuyCryptoController.resetAmlCheck` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| PUT | 1 | | `/buyCrypto/:id/amlCheck` | hidden | whole rows | 1090 | not yet | | `BuyCryptoController.manualPassAmlCheck` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyCrypto/:id/amlCheck` | hidden | whole rows | 1086 | not yet | | `BuyCryptoController.manualPassAmlCheck` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | POST | 1 | | `/buyCrypto/:id/refund` | hidden | whole rows | 1051 | not yet | | `BuyCryptoController.refundBuyCrypto` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| POST | 1 | | `/buyCrypto/:id/scorechain` | hidden | whole rows | 717 | not yet | | `BuyCryptoController.retriggerScorechain` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| POST | 1 | | `/buyCrypto/:id/scorechain` | hidden | whole rows | 713 | not yet | | `BuyCryptoController.retriggerScorechain` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | POST | 1 | | `/buyCrypto/:id/webhook` | hidden | whole rows | 844 | not yet | | `BuyCryptoController.triggerWebhook` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | PUT | 1 | | `/buyCrypto/refVolumes` | hidden | projected | 2 | 0/4 | | `BuyCryptoController.updateRefVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| PUT | 1 | | `/buyCrypto/volumes` | hidden | whole rows | 487 | not yet | | `BuyCryptoController.updateBuyVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyCrypto/volumes` | hidden | whole rows | 483 | not yet | | `BuyCryptoController.updateBuyVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | PUT | 1 | | `/buyFiat/:id` | hidden | whole rows | 1033 | not yet | | `BuyFiatController.update` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | DELETE | 1 | | `/buyFiat/:id/amlCheck` | hidden | whole rows | 490 | not yet | | `BuyFiatController.resetAmlCheck` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | PUT | 1 | | `/buyFiat/:id/amlCheck` | hidden | whole rows | 1033 | not yet | | `BuyFiatController.manualPassAmlCheck` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | @@ -208,13 +208,13 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/custody/account/:id/history` | public | whole rows | 253 | not yet | | `CustodyAccountController.getAccountHistory` | `subdomains/core/custody/controllers/custody-account.controller.ts` | | GET | 1 | | `/custody/account/:id/order` | public | whole rows | 253 | not yet | | `CustodyAccountController.getAccountOrders` | `subdomains/core/custody/controllers/custody-account.controller.ts` | | GET | 1 | | `/custody/account/:id/pdf` | public | whole rows | 253 | not yet | | `CustodyAccountController.getAccountPdf` | `subdomains/core/custody/controllers/custody-account.controller.ts` | -| POST | 1 | | `/custody/admin/order/:id/approve` | public | whole rows | 217 | not yet | | `CustodyAdminController.approveOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | -| GET | 1 | | `/custody/admin/orders` | public | whole rows | 525 | not yet | | `CustodyAdminController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | +| POST | 1 | | `/custody/admin/order/:id/approve` | public | whole rows | 119 | not yet | | `CustodyAdminController.approveOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | +| GET | 1 | | `/custody/admin/orders` | public | whole rows | 427 | not yet | | `CustodyAdminController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | | PUT | 1 | | `/custody/admin/user/:id/balance` | public | whole rows | 308 | not yet | | `CustodyAdminController.updateUserBalance` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/custody/history` | public | whole rows | 253 | not yet | | `CustodyController.getUserCustodyHistory` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/custody/order` | public | projected | 14 | 4/4 | | `CustodyController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | -| POST | 1 | | `/custody/order` | public | whole rows | 364 | not yet | | `CustodyController.createOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | -| POST | 1 | | `/custody/order/:id/confirm` | public | whole rows | 525 | not yet | | `CustodyController.confirmOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | +| POST | 1 | | `/custody/order` | public | whole rows | 360 | not yet | | `CustodyController.createOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | +| POST | 1 | | `/custody/order/:id/confirm` | public | whole rows | 427 | not yet | | `CustodyController.confirmOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/custody/pdf` | public | whole rows | 253 | not yet | | `CustodyController.getCustodyPdf` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/accounts` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getAccounts` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/accounts/:accountId/legs` | hidden | whole rows | 30 | not yet | yes | `LedgerController.getAccountDetail` | `subdomains/core/accounting/controllers/ledger.controller.ts` | @@ -263,17 +263,17 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/gs/evm/contractTransaction` | hidden | none | — | n/a | | `GsEvmController.sendContractTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | | POST | 1 | | `/gs/evm/rawTransaction` | hidden | whole rows | 6 | not yet | | `GsEvmController.sendRawTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | | POST | 1 | | `/gs/evm/tokenTransaction` | hidden | whole rows | 33 | not yet | | `GsEvmController.sendTokenTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | -| GET | 1 | | `/gs/support` | hidden | whole rows | 907 | not yet | yes | `GsController.getSupportData` | `subdomains/generic/gs/gs.controller.ts` | +| GET | 1 | | `/gs/support` | hidden | whole rows | 903 | not yet | yes | `GsController.getSupportData` | `subdomains/generic/gs/gs.controller.ts` | | GET | neutral | | `/health` | public | none | — | n/a | | `HealthController.getHealth` | `subdomains/core/monitoring/health.controller.ts` | | GET | neutral | | `/health/banking` | public | none | — | n/a | | `HealthController.getBankingHealth` | `subdomains/core/monitoring/health.controller.ts` | | GET | neutral | | `/health/external` | public | none | — | n/a | | `HealthController.getExternalHealth` | `subdomains/core/monitoring/health.controller.ts` | | GET | neutral | | `/health/liquidity` | public | none | — | n/a | | `HealthController.getLiquidityHealth` | `subdomains/core/monitoring/health.controller.ts` | | GET | neutral | | `/health/nodes` | public | none | — | n/a | | `HealthController.getNodeHealth` | `subdomains/core/monitoring/health.controller.ts` | | GET | neutral | | `/health/payment` | public | none | — | n/a | | `HealthController.getPaymentHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | 1 | | `/history` | hidden | whole rows | 1363 | not yet | | `HistoryController.getHistory` | `subdomains/core/history/controllers/history.controller.ts` | -| GET | 1 | | `/history/:exportType` | hidden | whole rows | 1363 | not yet | | `HistoryController.getApiHistory` | `subdomains/core/history/controllers/history.controller.ts` | +| GET | 1 | | `/history` | hidden | whole rows | 1359 | not yet | | `HistoryController.getHistory` | `subdomains/core/history/controllers/history.controller.ts` | +| GET | 1 | | `/history/:exportType` | hidden | whole rows | 1359 | not yet | | `HistoryController.getApiHistory` | `subdomains/core/history/controllers/history.controller.ts` | | GET | 1 | | `/history/csv` | hidden | none | — | n/a | | `HistoryController.getCsv` | `subdomains/core/history/controllers/history.controller.ts` | -| POST | 1 | | `/history/csv` | hidden | whole rows | 1363 | not yet | | `HistoryController.createCsv` | `subdomains/core/history/controllers/history.controller.ts` | +| POST | 1 | | `/history/csv` | hidden | whole rows | 1359 | not yet | | `HistoryController.createCsv` | `subdomains/core/history/controllers/history.controller.ts` | | GET | 1 | | `/ikna/bfs/:id` | hidden | none | — | n/a | | `IknaController.getBfsResult` | `integration/ikna/controllers/ikna.controller.ts` | | POST | 1 | | `/ikna/bfs/address` | hidden | none | — | n/a | | `IknaController.createBfsAddressRequest` | `integration/ikna/controllers/ikna.controller.ts` | | GET | 1 | | `/ikna/tag` | hidden | none | — | n/a | | `IknaController.getIknaAddressTag` | `integration/ikna/controllers/ikna.controller.ts` | @@ -299,11 +299,11 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/kyc/admin/nameCheck/:id` | hidden | whole rows | 245 | not yet | | `KycAdminController.updateNameCheckLog` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | PUT | 1 | | `/kyc/admin/step/:id` | hidden | whole rows | 385 | not yet | | `KycAdminController.updateKycStep` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | POST | 1 | | `/kyc/admin/webhook` | hidden | whole rows | 364 | not yet | | `KycAdminController.triggerWebhook` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | -| GET | 2 | | `/kyc/client/payments` | public | whole rows | 1092 | not yet | | `KycClientController.getAllPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | +| GET | 2 | | `/kyc/client/payments` | public | whole rows | 1088 | not yet | | `KycClientController.getAllPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 2 | | `/kyc/client/users` | public | whole rows | 20 | not yet | | `KycClientController.getAllKycData` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 2 | | `/kyc/client/users/:id/documents` | public | whole rows | 78 | not yet | | `KycClientController.getKycFiles` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 2 | | `/kyc/client/users/:id/documents/:type` | public | whole rows | 78 | not yet | | `KycClientController.getKycFile` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | -| GET | 2 | | `/kyc/client/users/:id/payments` | public | whole rows | 1092 | not yet | | `KycClientController.getUserPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | +| GET | 2 | | `/kyc/client/users/:id/payments` | public | whole rows | 1088 | not yet | | `KycClientController.getUserPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 1 | yes | `/kyc/countries` | public | whole rows | 351 | not yet | | `KycController.getKycCountriesV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 2 | yes | `/kyc/countries` | public | whole rows | 351 | not yet | | `KycController.getKycCountries` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | DELETE | 2 | | `/kyc/data/:type/:id` | public | whole rows | 351 | not yet | | `KycController.cancelStep` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | @@ -439,8 +439,8 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | yes | `/realunit/brokerbot/price` | public | none | — | n/a | | `RealUnitController.getBrokerbotPrice` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | yes | `/realunit/brokerbot/sellPrice` | public | whole rows | 308 | not yet | | `RealUnitController.getBrokerbotSellPrice` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | yes | `/realunit/brokerbot/sellShares` | public | whole rows | 308 | not yet | | `RealUnitController.getBrokerbotSellShares` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/buy` | public | whole rows | 364 | not yet | yes | `RealUnitController.getPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/buy/:id/confirm` | public | whole rows | 504 | not yet | | `RealUnitController.confirmBuy` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/buy` | public | whole rows | 360 | not yet | yes | `RealUnitController.getPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/buy/:id/confirm` | public | whole rows | 484 | not yet | | `RealUnitController.confirmBuy` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/compliance/customers` | hidden | whole rows | 308 | not yet | | `RealUnitComplianceController.searchCustomers` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | | GET | 1 | | `/realunit/compliance/customers/:id` | hidden | whole rows | 826 | not yet | | `RealUnitComplianceController.getCustomer` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | | GET | 1 | | `/realunit/compliance/customers/:id/dossier` | hidden | whole rows | 264 | not yet | | `RealUnitComplianceController.downloadCustomerDossier` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | @@ -468,9 +468,9 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/realunit/register/wallet` | public | whole rows | 493 | not yet | yes | `RealUnitController.completeRegistrationForWalletAddress` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/registration` | public | whole rows | 308 | not yet | yes | `RealUnitController.getRegistrationInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/sell` | public | whole rows | 308 | not yet | yes | `RealUnitController.getSellPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/sell/:id/broadcast` | public | whole rows | 504 | not yet | | `RealUnitController.broadcastSellTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/sell/:id/confirm` | public | whole rows | 504 | not yet | | `RealUnitController.confirmSell` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/sell/:id/unsigned-transactions` | public | whole rows | 504 | not yet | | `RealUnitController.getSellUnsignedTransactions` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/sell/:id/broadcast` | public | whole rows | 484 | not yet | | `RealUnitController.broadcastSellTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/sell/:id/confirm` | public | whole rows | 484 | not yet | | `RealUnitController.confirmSell` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/sell/:id/unsigned-transactions` | public | whole rows | 484 | not yet | | `RealUnitController.getSellUnsignedTransactions` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/support/:id` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.updateSupportIssue` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/:id/data` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.getIssueData` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | POST | 1 | | `/realunit/support/:id/message` | hidden | whole rows | 441 | not yet | | `RealUnitSupportController.createSupportMessage` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | @@ -482,8 +482,8 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/realunit/support/list` | hidden | projected | 10 | 4/4 | | `RealUnitSupportController.getSupportIssueList` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/statistics` | hidden | projected | 3 | 0/4 | | `RealUnitSupportController.getSupportIssueStatistics` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | PUT | 1 | | `/realunit/swap` | public | whole rows | 308 | not yet | yes | `RealUnitController.getSwapPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/swap/:id/broadcast` | public | whole rows | 504 | not yet | yes | `RealUnitController.broadcastSwapTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/swap/:id/unsigned-transaction` | public | whole rows | 504 | not yet | yes | `RealUnitController.getSwapUnsignedTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/swap/:id/broadcast` | public | whole rows | 484 | not yet | yes | `RealUnitController.broadcastSwapTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/swap/:id/unsigned-transaction` | public | whole rows | 484 | not yet | yes | `RealUnitController.getSwapUnsignedTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/tokenInfo` | public | none | — | n/a | | `RealUnitController.getTokenInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | POST | 1 | | `/realunit/transactions/receipt/multi` | public | whole rows | 308 | not yet | | `RealUnitController.generateHistoryMultiReceipt` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | POST | 1 | | `/realunit/transactions/receipt/single` | public | whole rows | 308 | not yet | | `RealUnitController.generateHistoryReceipt` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | @@ -504,7 +504,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/reward/ref/manual` | hidden | whole rows | 308 | not yet | | `RefRewardController.createManualRefReward` | `subdomains/core/referral/reward/ref-reward.controller.ts` | | PUT | 1 | | `/reward/ref/volumes` | hidden | whole rows | 308 | not yet | | `RefRewardController.updateVolumes` | `subdomains/core/referral/reward/ref-reward.controller.ts` | | GET | 1 | | `/route` | hidden | whole rows | 308 | not yet | | `RouteController.getAllRoutes` | `subdomains/core/route/route.controller.ts` | -| PUT | 1 | | `/route/:id` | hidden | whole rows | 174 | not yet | | `RouteController.updateRoute` | `subdomains/core/route/route.controller.ts` | +| PUT | 1 | | `/route/:id` | hidden | whole rows | 170 | not yet | | `RouteController.updateRoute` | `subdomains/core/route/route.controller.ts` | | POST | 1 | | `/scorechain/screening` | hidden | whole rows | 14 | not yet | | `ScorechainController.screen` | `integration/scorechain/controllers/scorechain.controller.ts` | | GET | 1 | | `/sell` | hidden | whole rows | 308 | not yet | | `SellController.getAllSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | POST | 1 | | `/sell` | hidden | whole rows | 308 | not yet | | `SellController.createSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | @@ -513,7 +513,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/sell/:id/history` | hidden | projected | 14 | 4/4 | | `SellController.getSellRouteHistory` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/paymentInfos` | public | whole rows | 308 | not yet | | `SellController.createSellWithPaymentInfo` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/paymentInfos/:id/confirm` | public | whole rows | 545 | not yet | | `SellController.confirmSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | -| GET | 1 | | `/sell/paymentInfos/:id/tx` | public | whole rows | 504 | not yet | | `SellController.depositTx` | `subdomains/core/sell-crypto/route/sell.controller.ts` | +| GET | 1 | | `/sell/paymentInfos/:id/tx` | public | whole rows | 484 | not yet | | `SellController.depositTx` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/quote` | public | whole rows | 143 | not yet | | `SellController.getSellQuote` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | GET | 1 | | `/setting` | hidden | whole rows | 5 | not yet | | `SettingController.getSettings` | `shared/models/setting/setting.controller.ts` | | PUT | 1 | | `/setting/:key` | hidden | whole rows | 5 | not yet | | `SettingController.updateSetting` | `shared/models/setting/setting.controller.ts` | @@ -523,7 +523,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/specialExternalAccount` | hidden | whole rows | 7 | not yet | | `SpecialExternalAccountController.createSpecialExternalAccount` | `subdomains/supporting/payment/controllers/special-external-account.controller.ts` | | GET | 1 | | `/statistic` | public | none | — | n/a | | `StatisticController.getAll` | `subdomains/core/statistic/statistic.controller.ts` | | GET | 1 | | `/statistic/status` | public | whole rows | 5 | not yet | | `StatisticController.getStatus` | `subdomains/core/statistic/statistic.controller.ts` | -| GET | 1 | | `/statistic/transactions` | public | whole rows | 419 | not yet | | `StatisticController.getTransactions` | `subdomains/core/statistic/statistic.controller.ts` | +| GET | 1 | | `/statistic/transactions` | public | whole rows | 415 | not yet | | `StatisticController.getTransactions` | `subdomains/core/statistic/statistic.controller.ts` | | GET | 1 | | `/support` | hidden | whole rows | 593 | not yet | | `SupportController.searchUserByKey` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/:id` | hidden | whole rows | 826 | not yet | | `SupportController.getUserData` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/:id/ip-log-pdf` | hidden | whole rows | 12 | not yet | | `SupportController.getIpLogPdf` | `subdomains/generic/support/support.controller.ts` | @@ -531,7 +531,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/support/:id/scorechain` | hidden | whole rows | 14 | not yet | | `SupportController.getScorechainScreenings` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/:id/transaction-pdf` | hidden | whole rows | 826 | not yet | | `SupportController.getTransactionPdf` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues` | hidden | none | — | n/a | | `SupportController.getCallQueues` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/call-queues/:queue/items` | hidden | whole rows | 672 | not yet | | `SupportController.getCallQueueItems` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/call-queues/:queue/items` | hidden | whole rows | 668 | not yet | | `SupportController.getCallQueueItems` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues/clerks` | hidden | none | — | n/a | | `SupportController.getCallQueueClerks` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/issue` | public | projected | 11 | 4/4 | | `SupportIssueController.getIssues` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue` | public | whole rows | 493 | not yet | | `SupportIssueController.createIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | @@ -560,14 +560,14 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/support/note/users` | hidden | projected | 5 | 0/4 | | `SupportController.listNoteUsers` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/pending-reviews` | hidden | projected | 3 | 0/4 | | `SupportController.getPendingReviews` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/pending-reviews/items` | hidden | whole rows | 261 | not yet | | `SupportController.getPendingReviewItems` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/pending-transactions` | hidden | whole rows | 672 | not yet | | `SupportController.getPendingTransactions` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/pending-transactions` | hidden | whole rows | 668 | not yet | | `SupportController.getPendingTransactions` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/recommendation-graph/:id/neighbors` | hidden | whole rows | 474 | not yet | yes | `SupportController.getRecommendationGraphNeighbors` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/template` | hidden | whole rows | 8 | not yet | | `SupportController.getTemplates` | `subdomains/generic/support/support.controller.ts` | | POST | 1 | | `/support/template` | hidden | whole rows | 253 | not yet | | `SupportController.createTemplate` | `subdomains/generic/support/support.controller.ts` | | DELETE | 1 | | `/support/template/:id` | hidden | whole rows | 8 | not yet | | `SupportController.deleteTemplate` | `subdomains/generic/support/support.controller.ts` | | PUT | 1 | | `/support/template/:id` | hidden | whole rows | 8 | not yet | | `SupportController.updateTemplate` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/transaction/:id/refund` | hidden | whole rows | 143 | not yet | | `SupportController.getTransactionRefund` | `subdomains/generic/support/support.controller.ts` | -| PUT | 1 | | `/support/transaction/:id/refund` | hidden | whole rows | 415 | not yet | | `SupportController.setTransactionRefund` | `subdomains/generic/support/support.controller.ts` | +| PUT | 1 | | `/support/transaction/:id/refund` | hidden | whole rows | 411 | not yet | | `SupportController.setTransactionRefund` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/transactionList` | hidden | whole rows | 20 | not yet | | `SupportController.getTransactionList` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/swap` | hidden | whole rows | 308 | not yet | | `SwapController.getAllSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | POST | 1 | | `/swap` | hidden | whole rows | 308 | not yet | | `SwapController.createSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | @@ -576,29 +576,29 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/swap/:id/history` | hidden | projected | 12 | 4/4 | | `SwapController.getSwapRouteHistory` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/paymentInfos` | public | whole rows | 308 | not yet | | `SwapController.createSwapWithPaymentInfo` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/paymentInfos/:id/confirm` | public | whole rows | 545 | not yet | | `SwapController.confirmSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | -| GET | 1 | | `/swap/paymentInfos/:id/tx` | public | whole rows | 504 | not yet | | `SwapController.depositTx` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | +| GET | 1 | | `/swap/paymentInfos/:id/tx` | public | whole rows | 484 | not yet | | `SwapController.depositTx` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/quote` | public | whole rows | 143 | not yet | | `SwapController.getSwapQuote` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | POST | 1 | | `/tatum/addressWebhook` | hidden | none | — | n/a | | `TatumController.addressWebhook` | `integration/tatum/controllers/tatum.controller.ts` | | PUT | 1 | | `/trading/rule/:id` | hidden | whole rows | 87 | not yet | | `TradingRuleController.update` | `subdomains/core/trading/controllers/trading-rule.controller.ts` | -| GET | 1 | | `/transaction` | public | whole rows | 1363 | not yet | yes | `TransactionController.getTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/:id/invoice` | public | whole rows | 331 | not yet | yes | `TransactionController.generateInvoiceFromTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/:id/receipt` | public | whole rows | 331 | not yet | yes | `TransactionController.generateReceiptFromTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/:id/refund` | public | whole rows | 331 | not yet | yes | `TransactionController.getTransactionRefund` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/:id/refund` | public | whole rows | 487 | not yet | | `TransactionController.setTransactionRefundTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction` | public | whole rows | 1359 | not yet | yes | `TransactionController.getTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/:id/invoice` | public | whole rows | 327 | not yet | yes | `TransactionController.generateInvoiceFromTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/:id/receipt` | public | whole rows | 327 | not yet | yes | `TransactionController.generateReceiptFromTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/:id/refund` | public | whole rows | 327 | not yet | yes | `TransactionController.getTransactionRefund` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/:id/refund` | public | whole rows | 483 | not yet | | `TransactionController.setTransactionRefundTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | | PUT | 1 | | `/transaction/:id/target` | hidden | whole rows | 1051 | not yet | | `TransactionController.setTransactionTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/ChainReport` | hidden | whole rows | 1363 | not yet | yes | `TransactionController.getCsvChainReport` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/CoinTracking` | hidden | whole rows | 1363 | not yet | yes | `TransactionController.getCsvCT` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/ChainReport` | hidden | whole rows | 1359 | not yet | yes | `TransactionController.getCsvChainReport` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/CoinTracking` | hidden | whole rows | 1359 | not yet | yes | `TransactionController.getCsvCT` | `subdomains/core/history/controllers/transaction.controller.ts` | | PUT | 1 | | `/transaction/admin/:id` | hidden | whole rows | 276 | not yet | | `TransactionAdminController.updateTransaction` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | POST | 1 | | `/transaction/admin/:id/stop` | hidden | whole rows | 98 | not yet | | `TransactionAdminController.stopTransaction` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | POST | 1 | | `/transaction/admin/:txId/riskAssessment` | hidden | none | — | n/a | | `TransactionAdminController.createRiskAssessment` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | PUT | 1 | | `/transaction/admin/:txId/riskAssessment/:id` | hidden | whole rows | 13 | not yet | | `TransactionAdminController.updateRiskAssessment` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | GET | 1 | | `/transaction/csv` | public | none | — | n/a | | `TransactionController.getCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/csv` | public | whole rows | 1363 | not yet | yes | `TransactionController.createCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/detail` | public | whole rows | 1363 | not yet | | `TransactionController.getTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/detail/csv` | public | whole rows | 1363 | not yet | | `TransactionController.createDetailCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/detail/single` | public | whole rows | 487 | not yet | yes | `TransactionController.getSingleTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/single` | public | whole rows | 487 | not yet | yes | `TransactionController.getSingleTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/target` | hidden | whole rows | 134 | not yet | | `TransactionController.getTransactionTargets` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/csv` | public | whole rows | 1359 | not yet | yes | `TransactionController.createCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/detail` | public | whole rows | 1359 | not yet | | `TransactionController.getTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/detail/csv` | public | whole rows | 1359 | not yet | | `TransactionController.createDetailCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/detail/single` | public | whole rows | 483 | not yet | yes | `TransactionController.getSingleTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/single` | public | whole rows | 483 | not yet | yes | `TransactionController.getSingleTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/target` | hidden | whole rows | 130 | not yet | | `TransactionController.getTransactionTargets` | `subdomains/core/history/controllers/transaction.controller.ts` | | GET | 1 | | `/transaction/unassigned` | hidden | whole rows | 356 | not yet | | `TransactionController.getUnassignedTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | | DELETE | 1 | yes | `/user` | public | whole rows | 344 | not yet | | `UserController.deleteUser` | `subdomains/generic/user/models/user/user.controller.ts` | | DELETE | 2 | | `/user` | public | whole rows | 344 | not yet | | `UserV2Controller.deleteAccount` | `subdomains/generic/user/models/user/user.controller.ts` | diff --git a/docs/load-sites.md b/docs/load-sites.md index 2013d3a0bb..e55beb0322 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -35,7 +35,7 @@ Columns were measured against the real entity metadata by building the query and - **452 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. - 315 could not be measured: no resolvable target entity, or raw SQL. -Median across measured sites: **101 columns**. 14 sites exceed 1000, 74 exceed 500, 396 exceed 100. +Median across measured sites: **101 columns**. 14 sites exceed 1000, 71 exceed 500, 390 exceed 100. Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright, whatever the column and wherever it is added. @@ -46,38 +46,38 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | Columns | Joins | Mechanism | Entity | Location | Method | | ------: | ----: | --------- | ------ | -------- | ------ | | 1453 | 49 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:229` | `FiatOutputJobService.setReadyDate` | -| 1363 | 47 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:267` | `TransactionService.getTransactionsForAccount` | +| 1359 | 46 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:267` | `TransactionService.getTransactionsForAccount` | | 1282 | 50 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:426` | `BuyCryptoPreparationService.fillPaymentLinkPayments` | | 1231 | 38 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:174` | `FiatOutputJobService.assignBankAccount` | -| 1162 | 44 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:65` | `BuyCryptoBatchService.batchAndOptimizeTransactions` | -| 1139 | 41 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:317` | `BuyCryptoPreparationService.In` | -| 1092 | 41 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:299` | `TransactionService.getTransactionsForUsers` | -| 1090 | 40 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:274` | `BuyCryptoService.update` | -| 1063 | 42 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:119` | `BuyCryptoPreparationService.doAmlCheck` | +| 1158 | 43 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:65` | `BuyCryptoBatchService.batchAndOptimizeTransactions` | +| 1135 | 40 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:317` | `BuyCryptoPreparationService.In` | +| 1088 | 40 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:299` | `TransactionService.getTransactionsForUsers` | +| 1086 | 39 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:274` | `BuyCryptoService.update` | +| 1059 | 41 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:119` | `BuyCryptoPreparationService.doAmlCheck` | | 1051 | 36 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:513` | `BuyCryptoService.refundBuyCrypto` | | 1051 | 32 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:328` | `BankTxService.create` | | 1051 | 32 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:341` | `BankTxService.update` | | 1033 | 40 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:180` | `BuyFiatService.update` | | 1003 | 36 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:534` | `BuyFiatPreparationService.addFiatOutputs` | -| 907 | 31 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1165` | `BuyCryptoService.getAllUserTransactions` | +| 903 | 30 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1165` | `BuyCryptoService.getAllUserTransactions` | | 891 | 37 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:340` | `BuyFiatPreparationService.fillPaymentLinkPayments` | -| 885 | 33 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-out.service.ts:133` | `BuyCryptoOutService.fetchBatchesForPayout` | +| 881 | 32 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-out.service.ts:133` | `BuyCryptoOutService.fetchBatchesForPayout` | | 880 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:626` | `BuyCryptoPreparationService.chargebackFillUp` | | 844 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-webhook.service.ts:17` | `BuyCryptoWebhookService.triggerWebhookManual` | | 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:189` | `TransactionService.getTransactionsWithoutUid` | | 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:198` | `TransactionService.getTransactionsByUserDataId` | -| 815 | 28 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:922` | `BuyCryptoService.getRefTransactions` | -| 815 | 28 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1147` | `BuyCryptoService.getAllRefTransactions` | +| 811 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:922` | `BuyCryptoService.getRefTransactions` | +| 811 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1147` | `BuyCryptoService.getAllRefTransactions` | | 813 | 29 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:255` | `BuyFiatPreparationService.refreshFee` | | 803 | 29 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:400` | `BuyFiatService.refundBuyFiat` | -| 794 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction-notification.service.ts:37` | `TransactionNotificationService.txAssigned` | +| 790 | 26 | find | `Transaction` | `subdomains/supporting/payment/services/transaction-notification.service.ts:37` | `TransactionNotificationService.txAssigned` | | 785 | 24 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:267` | `BuyCryptoNotificationService.chargebackInitiated` | | 765 | 23 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:588` | `BuyCryptoPreparationService.chargebackTx` | | 737 | 30 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:107` | `BuyFiatPreparationService.doAmlCheck` | | 727 | 23 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:176` | `BankTxReturnService.refundBankTxReturn` | -| 717 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:788` | `BuyCryptoService.retriggerScorechain` | +| 713 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:788` | `BuyCryptoService.retriggerScorechain` | | 703 | 24 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:491` | `BuyFiatPreparationService.complete` | -| 672 | 23 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1203` | `BuyCryptoService.getByAmlReason` | +| 668 | 22 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1203` | `BuyCryptoService.getByAmlReason` | | 644 | 22 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:374` | `BuyFiatService.getBuyFiat` | | 644 | 22 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:378` | `BuyFiatService.triggerWebhookManual` | | 643 | 21 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:196` | `RecommendationService.confirmRecommendation` | @@ -103,8 +103,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 540 | 17 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:763` | `BuyFiatService.getByAmlReason` | | 538 | 21 | find | `CryptoInput` | `subdomains/core/accounting/services/consumers/crypto-input.consumer.ts:89` | `CryptoInputConsumer.processForward` | | 535 | 15 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:947` | `BuyCryptoService.getPendingTransactions` | -| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:283` | `CustodyOrderService.confirmOrder` | -| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:299` | `CustodyOrderService.getOrdersForSupport` | +| 427 | 13 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:283` | `CustodyOrderService.confirmOrder` | +| 427 | 13 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:299` | `CustodyOrderService.getOrdersForSupport` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:615` | `BuyFiatPreparationService.chargebackTx` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:486` | `BuyFiatService.retriggerScorechain` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:72` | `PaymentLinkRepository.getAllPaymentLinks` | @@ -118,16 +118,16 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:504` | `PaymentLinkService.assignPaymentLink` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:520` | `PaymentLinkService.getLocations` | | 507 | 18 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:358` | `KycService.reviewRecommendationStep` | -| 504 | 16 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:218` | `TransactionRequestService.getOrThrow` | +| 484 | 15 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:218` | `TransactionRequestService.getOrThrow` | | 499 | 14 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:169` | `BankTxReturnService.getPendingTx` | | 497 | 18 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:128` | `BuyFiatNotificationService.pendingBuyFiat` | | 497 | 15 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:86` | `BankTxReturnService.setFiatAmounts` | | 493 | 19 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:366` | `SupportIssueService.createIssueInternal` | | 490 | 20 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:587` | `PaymentLinkService.getPublicPaymentLinkByUniqueId` | | 490 | 15 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:473` | `BuyFiatService.resetAmlCheck` | -| 487 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:745` | `BuyCryptoService.getBuyCryptoByTransactionId` | -| 487 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:749` | `BuyCryptoService.getBuyCrypto` | -| 487 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:753` | `BuyCryptoService.updateVolumes` | +| 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:745` | `BuyCryptoService.getBuyCryptoByTransactionId` | +| 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:749` | `BuyCryptoService.getBuyCrypto` | +| 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:753` | `BuyCryptoService.updateVolumes` | | 484 | 15 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward-notification.service.ts:27` | `RefRewardNotificationService.refRewardPayouts` | | 474 | 14 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:198` | `KycService.reviewIdentSteps` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:274` | `RecommendationService.getAllRecommendationForUserData` | @@ -164,17 +164,17 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 433 | 12 | find | `BankTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:106` | `BankTxConsumer.processForward` | | 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:478` | `SupportIssueService.closeIssue` | | 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:628` | `SupportIssueService.getIssueMessages` | -| 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:67` | `TransactionRequestService.txRequestWaitingExpiryCheck` | -| 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:78` | `TransactionRequestService.deleteOldTxRequests` | +| 407 | 11 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:67` | `TransactionRequestService.txRequestWaitingExpiryCheck` | +| 407 | 11 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:78` | `TransactionRequestService.deleteOldTxRequests` | | 422 | 12 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:778` | `BuyCryptoService.resetAmlCheck` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:214` | `SupportEscalationService.checkEscalations` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:484` | `SupportIssueService.updateIssue` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:618` | `SupportIssueService.getIssueMessages` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:655` | `SupportIssueService.getIssueUserDataId` | -| 419 | 14 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1186` | `BuyCryptoService.getTransactions` | -| 418 | 18 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:80` | `CustodyJobService.executeStep` | +| 415 | 13 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1186` | `BuyCryptoService.getTransactions` | +| 320 | 15 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:80` | `CustodyJobService.executeStep` | | 418 | 11 | find | `User` | `subdomains/generic/user/models/user/user-job.service.ts:19` | `UserJobService.approveUser` | -| 415 | 16 | find | `Buy` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1024` | `BuyCryptoService.getBuy` | +| 411 | 15 | find | `Buy` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1024` | `BuyCryptoService.getBuy` | | 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:393` | `UserService.updateUserV1` | | 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:447` | `UserService.updateUserData` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:69` | `BuyCryptoRegistrationService.filterBuyCryptoPayIns` | @@ -184,7 +184,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 386 | 11 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:439` | `UserService.updateUserName` | | 385 | 15 | find | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:139` | `BuyFiatRegistrationService.createBuyFiatsAndAckPayIns` | | 385 | 14 | find | `KycStep` | `subdomains/generic/kyc/services/kyc-admin.service.ts:45` | `KycAdminService.updateKycStep` | -| 384 | 14 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:223` | `BuyService.getByBankUsage` | +| 380 | 13 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:223` | `BuyService.getByBankUsage` | | 384 | 13 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:344` | `UserDataService.updateUserData` | | 377 | 14 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:80` | `SellService.get` | | 377 | 12 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:118` | `BankTxReturnService.create` | @@ -194,7 +194,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 374 | 14 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:287` | `SellService.confirmSell` | | 370 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:268` | `BankTxService.fillBankTx` | | 370 | 9 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:554` | `FiatOutputJobService.searchOutgoingBankTx` | -| 364 | 13 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:162` | `BuyService.createBuy` | +| 360 | 12 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:162` | `BuyService.createBuy` | | 364 | 12 | find | `Webhook` | `subdomains/generic/user/services/webhook/webhook-notification.service.ts:32` | `WebhookNotificationService.sendOpenWebhooks` | | 364 | 12 | find | `Webhook` | `subdomains/generic/user/services/webhook/webhook.service.ts:146` | `WebhookService.createAndSendWebhook` | | 363 | 10 | find | `BuyCrypto` | `subdomains/core/accounting/services/consumers/payout-order.consumer.ts:297` | `PayoutOrderConsumer.owedCompletionChf` | @@ -206,7 +206,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 363 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data-job.service.ts:25` | `UserDataJobService.bankTxVerification` | | 363 | 10 | find | `BuyCrypto` | `subdomains/supporting/fiat-output/fiat-output.service.ts:108` | `FiatOutputService.create` | | 362 | 11 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:375` | `TransactionService.getByAssetId` | -| 358 | 16 | find | `CryptoStaking` | `subdomains/core/staking/services/staking.service.ts:48` | `StakingService.getUserInvests` | +| 352 | 15 | find | `CryptoStaking` | `subdomains/core/staking/services/staking.service.ts:48` | `StakingService.getUserInvests` | | 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:624` | `BankTxService.getUnassignedBankTx` | | 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:634` | `BankTxService.getBankTxsByVirtualIban` | | 354 | 13 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:117` | `SellService.getSellsByIban` | @@ -219,11 +219,11 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 343 | 10 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts:87` | `FiatPayInSyncService.createCheckoutTx` | | 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1074` | `UserDataService.updateApiFilter` | | 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1084` | `UserDataService.checkApiKey` | -| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:169` | `VirtualIbanService.getByIdForUser` | -| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1262` | `VirtualIbanService.getActiveForBuyAndCurrency` | -| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1274` | `VirtualIbanService.getByIban` | -| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1316` | `VirtualIbanService.getVirtualIbansForAccount` | -| 329 | 9 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:278` | `TransactionRequestService.findAndComplete` | +| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:169` | `VirtualIbanService.getByIdForUser` | +| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1262` | `VirtualIbanService.getActiveForBuyAndCurrency` | +| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1274` | `VirtualIbanService.getByIban` | +| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1316` | `VirtualIbanService.getVirtualIbansForAccount` | +| 309 | 8 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:278` | `TransactionRequestService.findAndComplete` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/auth/auth.controller.ts:157` | `AuthController.createAccessTokenAfterMerge` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:155` | `KycService.getKycFile` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:129` | `UserService.getUserDto` | @@ -248,8 +248,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:742` | `UserService.updateApiFilter` | | 301 | 6 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:89` | `BankTxRepeatService.getAllUserRepeats` | | 299 | 12 | find | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:65` | `TradingOrderService.startNewOrders` | -| 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:113` | `CustodyJobService.onStepComplete` | -| 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:273` | `CustodyOrderService.getCustodyOrderByTx` | +| 197 | 5 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:113` | `CustodyJobService.onStepComplete` | +| 197 | 5 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:273` | `CustodyOrderService.getCustodyOrderByTx` | | 287 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:746` | `BuyFiatService.getTransactions` | | 284 | 10 | find | `UserData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:173` | `BankDataService.addBankData` | | 276 | 10 | find | `NameCheckLog` | `subdomains/generic/kyc/services/name-check.service.ts:205` | `NameCheckService.createNameCheckLog` | @@ -321,11 +321,11 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 234 | 6 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:199` | `RefRewardService.getAllUserRewards` | | 234 | 6 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:207` | `RefRewardService.getRefRewardsByUserDataId` | | 229 | 11 | find | `CryptoInput` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:527` | `DashboardReconciliationService.getCryptoInputs` | -| 226 | 7 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:94` | `CustodyJobService.checkStep` | -| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:52` | `CustodyJobService.resetExpiredConfirmedOrders` | -| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:308` | `CustodyOrderService.approveOrder` | -| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:257` | `CustodyService.getUserCustodyHistory` | -| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:470` | `CustodyService.calculateAccruedInterest` | +| 128 | 4 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:94` | `CustodyJobService.checkStep` | +| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:52` | `CustodyJobService.resetExpiredConfirmedOrders` | +| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:308` | `CustodyOrderService.approveOrder` | +| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:257` | `CustodyService.getUserCustodyHistory` | +| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:470` | `CustodyService.calculateAccruedInterest` | | 201 | 5 | find | `BuyFiat` | `subdomains/core/accounting/services/consumers/payout-order.consumer.ts:293` | `PayoutOrderConsumer.owedCompletionChf` | | 201 | 8 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward-dex.service.ts:36` | `RefRewardDexService.secureLiquidity` | | 201 | 5 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:108` | `BuyFiatService.checkAmlResetTx` | @@ -338,7 +338,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 176 | 5 | find | `TradingOrder` | `subdomains/core/accounting/services/consumers/trading-order.consumer.ts:84` | `TradingOrderConsumer.processForward` | | 176 | 5 | find | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:164` | `TradingOrderService.checkRunningOrders` | | 176 | 5 | find | `TradingOrder` | `subdomains/core/trading/services/trading-rule.service.ts:42` | `TradingRuleService.getCurrentTradingOrders` | -| 174 | 13 | find | `Route` | `subdomains/core/route/route.service.ts:19` | `RouteService.updateRoute` | +| 170 | 12 | find | `Route` | `subdomains/core/route/route.service.ts:19` | `RouteService.updateRoute` | | 174 | 4 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:51` | `RecallService.update` | | 174 | 4 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:63` | `RecallService.getAll` | | 174 | 4 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:75` | `RecallService.getById` | @@ -392,13 +392,13 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:649` | `LiquidityManagementPipelineService.resolveUncertainOrderManually` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:702` | `LiquidityManagementPipelineService.checkRunningOrders` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:510` | `DashboardReconciliationService.getLmOrders` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:84` | `BuyService.updateVolume` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:120` | `BuyService.getAllBankUsages` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:131` | `BuyService.get` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:204` | `BuyService.getBuyWithoutRoute` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:208` | `BuyService.getUserBuys` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:212` | `BuyService.getUserDataBuys` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:250` | `BuyService.getAllUserBuys` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:84` | `BuyService.updateVolume` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:120` | `BuyService.getAllBankUsages` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:131` | `BuyService.get` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:204` | `BuyService.getBuyWithoutRoute` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:208` | `BuyService.getUserBuys` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:212` | `BuyService.getUserDataBuys` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:250` | `BuyService.getAllUserBuys` | | 131 | 3 | find | `StakingRefReward` | `subdomains/core/staking/services/staking.service.ts:37` | `StakingService.getUserStakingRefRewards` | | 130 | 9 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:173` | `LiquidityManagementPipelineService.checkRunningPipelines` | | 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:223` | `BankTxService.assignTransactions` | @@ -424,9 +424,9 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:249` | `PayoutService.prepareNewOrders` | | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:266` | `PayoutService.payoutOrders` | | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:283` | `PayoutService.processFailedOrders` | -| 118 | 3 | find | `CustodyBalance` | `subdomains/core/custody/services/custody-pdf.service.ts:47` | `CustodyPdfService.getBalancesWithHistoricalPrices` | -| 118 | 3 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:111` | `CustodyService.getUserCustodyBalance` | -| 118 | 3 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:238` | `CustodyService.updateCustodyBalance` | +| 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody-pdf.service.ts:47` | `CustodyPdfService.getBalancesWithHistoricalPrices` | +| 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:111` | `CustodyService.getUserCustodyBalance` | +| 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:238` | `CustodyService.updateCustodyBalance` | | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:49` | `BankTxRepeatService.update` | | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:62` | `BankTxRepeatService.update` | | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:104` | `BankTxRepeatService.getBankTxRepeat` | @@ -438,9 +438,9 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:257` | `TransactionRequestService.getTransactionRequestByUid` | | 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:261` | `TransactionRequestService.getOpenBuyQuotes` | | 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:424` | `TransactionRequestService.getByAssetId` | -| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/bank/bank.service.ts:181` | `BankService.getReceiveIbanStatus` | -| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:130` | `VirtualIbanService.getActiveReceivingForUserAndCurrency` | -| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:155` | `VirtualIbanService.getActiveSendingCandidatesForUserAndCurrency` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/bank/bank.service.ts:181` | `BankService.getReceiveIbanStatus` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:130` | `VirtualIbanService.getActiveReceivingForUserAndCurrency` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:155` | `VirtualIbanService.getActiveSendingCandidatesForUserAndCurrency` | | 99 | 0 | query-builder (nur-alias) | `UserData` | `subdomains/generic/user/models/user-data/user-data-notification.service.ts:173` | `UserDataNotificationService.blackSquadInvitation` | | 99 | 0 | query-builder (ohne-select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:141` | `UserDataService.getUserDataByUser` | | 99 | 0 | query-builder (nur-alias) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:313` | `UserDataService.getUserDataByKey` | @@ -508,8 +508,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:603` | `FiatOutputJobService.getLastBatchId` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:638` | `FiatOutputJobService.notifyScryptDeposits` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:203` | `FiatOutputService.update` | -| 56 | 3 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:140` | `BuyService.getById` | -| 56 | 3 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:258` | `BuyService.updateBuy` | +| 52 | 2 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:140` | `BuyService.getById` | +| 52 | 2 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:258` | `BuyService.updateBuy` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:89` | `LedgerQueryService.getAccounts` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:157` | `LedgerQueryService.getReconStatus` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:613` | `LedgerQueryService.unverifiedAccountIds` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 23bbbbdef9..3fa52a6f8c 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -41,7 +41,7 @@ equally wasteful under a limit of 4,096 — it simply would not have failed yet. Two properties combine: -**Eager relations.** 94 relations in this repo are declared `eager: true`, across 45 entities. TypeORM expands them +**Eager relations.** 90 relations in this repo are declared `eager: true`, across 44 entities. TypeORM expands them recursively, so a plain `findOne()` on `UserData` already selects **253 columns across 8 joins**, and one on `LimitRequest` **434 across 15** — before any `relations` option is passed. The decision what to load therefore lives in the entity definition, not at the call site, and no call @@ -247,6 +247,67 @@ The consequences are worth stating plainly: The guard is verified by `projection-guard.projection.spec.ts` — it is the one piece the levels below cannot check, because they rely on it. +## The other route: reducing the eager relations + +Converting endpoints one at a time treats the symptom. The cause is named above — the entity +decides what a query loads, and no call site can see what it triggers. So the cheaper-looking route +is to take the decision away from the entity: drop `eager: true`, and let each call site say what +it needs. This section is what that route costs, measured rather than estimated, because the +measurement changes the answer. + +**How it was measured.** Which relations are declared is an AST question — a text search counts +comments and misses modifiers. Where each one is *read* is a type question: `.userData` occurs on +dozens of unrelated receivers, so the reads were resolved with the TypeScript compiler, including +reads through a base class, which is how single-table inheritance is reached. What a response +*contains* is neither: it comes from the TypeORM metadata, whose eager closure is what a query +actually joins. + +The two counts in this document are not the same count, and the difference is not a discrepancy. +The 90 above are declarations in the source. At runtime they are 103 relations, because a +declaration on a base class is carried by every entity that inherits it (6), and because +single-table inheritance surfaces a child's relation on the parent as well (8) — a query on +`DepositRoute` joins what `Sell`, `Swap` and `Staking` declare. In the other direction, `Sell.route` +and `Swap.route` are two declarations of one column in one table and count once. + +**What the measurement says.** 55 handlers answer with an entity rather than a response object, 35 +distinct entities between them. For those, the eager relations are not a loading detail — they are +the answer. Followed recursively, their closure covers **57 of the 103 eager relations** this +repository builds at runtime — 17 of the 35 entities carry no eager relation at all, which is why +the closure is smaller than the root count suggests. Removing one of those 57 changes what an +endpoint returns; adding one changes it too. All 55 handlers carry a role guard and all but two +(`POST /userDataRelation`, `PUT /userDataRelation/:id`) are excluded from the Swagger schema, so the +consumer is the operator's own tooling rather than a published schema — which makes it a decision to +take, not a wall, but a decision rather than a refactor either way. + +That is the part worth carrying forward: **the majority of the eager relations cannot be removed +mechanically**, and the criterion that decides it is not visible in the entity, the call site, or +the test. It is visible in the return type of a controller. + +**What was removable, and was removed.** Four declarations that no code reads and that no response +contains: `Buy.route`, `CustodyBalance.user`, `CustodyOrder.transaction`, +`CryptoStaking.paybackDeposit`. That narrowed 55 load sites — the custody order paths by 98 columns +each, the widest transaction paths by four — and 47 endpoints in +[endpoints.md](endpoints.md) now show a smaller number. After it, no eager relation is left that is +both unread and outside every response: the mechanically decidable part is exhausted. + +**What is left.** 32 declarations are read somewhere and are in no response. Each is removable, but +not by rule: the compiler says where a relation is read, not which query produced the value that +was read. Connecting the two is a per-case reading of the code, and it is the same work as +converting an endpoint — with a failure mode that is worse, because a relation that is no longer +loaded is `undefined` at a call site that no test may reach. + +**What guards it.** `eager-relations.projection.spec.ts` pins two things: the closure above, so a +relation added to any entity in it fails the run naming the controller whose answer it changes, and +the total count, so a new one anywhere is a decision rather than a detail of an unrelated change. +It finds the entities that leave through a controller by reading the controllers rather than from a +list, because a list goes stale the first time someone adds one — which is not hypothetical: the +first version of this measurement looked only at entities that already carried an eager relation and +missed 27 handlers for it. + +So the two routes are not alternatives, and neither is cheap. The difference between them is the +failure mode: an incomplete field list now throws, and an eager relation added to the wrong entity +now fails a test — which is the property both of these are for. + ## Test definition The guard covers completeness. The levels cover what it cannot see: whether the response is right, diff --git a/src/shared/models/__tests__/eager-relations.projection.spec.ts b/src/shared/models/__tests__/eager-relations.projection.spec.ts new file mode 100644 index 0000000000..6734c667cc --- /dev/null +++ b/src/shared/models/__tests__/eager-relations.projection.spec.ts @@ -0,0 +1,207 @@ +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, +} from 'src/shared/utils/projection-test.util'; +import { DataSource, EntityMetadata } from 'typeorm'; + +const SCHEMA = 'eager_relations_spec'; +const SRC = join(__dirname, '../../..'); + +/** + * The entities that leave through a controller as themselves, read out of the source. + * + * For those the eager relations are not a loading detail — they are the answer. A relation added + * anywhere in their closure appears in the response; one removed disappears from it. + * + * Read rather than listed, because a list goes stale the first time someone adds a controller and + * nothing says so. It is deliberately generous: any method in a controller file whose return type + * names an entity counts, decorated or not. A method that is not in fact a handler widens the + * closure below and costs precision in the failure message; missing a handler would cost the + * guarantee. + */ +function entitiesReturnedWhole(entities: Set): Map { + const controllers: string[] = []; + const walk = (directory: string): void => { + for (const item of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, item.name); + if (item.isDirectory()) walk(path); + else if (item.name.endsWith('.controller.ts')) controllers.push(path); + } + }; + walk(SRC); + + const found = new Map(); + for (const path of controllers) { + const source = readFileSync(path, 'utf8'); + for (const match of source.matchAll(/\)\s*:\s*(?:Promise<\s*)?([A-Za-z0-9_]+)(?:\[\])?\s*[>{]/g)) { + const name = match[1]; + if (!entities.has(name)) continue; + const file = path.slice(SRC.length + 1); + const where = found.get(name) ?? []; + if (!where.includes(file)) where.push(file); + found.set(name, where); + } + } + + return found; +} + +/** + * Every eager relation those responses contain, reached recursively. + * + * A relation on this list cannot be removed without changing an answer, and one added to any entity + * on it becomes part of an answer. That is the decision this list exists to force: when it fails, + * the question is not how to make the test pass but whether the endpoints above should carry the + * relation. + */ +const IN_A_PAYLOAD = [ + 'BankData.preferredCurrency', + 'BankTxRepeat.transaction', + 'BankTxReturn.transaction', + 'BankTxReturn.userData', + 'BuyCrypto.batch', + 'BuyCrypto.fee', + 'BuyCrypto.outputAsset', + 'BuyCrypto.outputReferenceAsset', + 'BuyCrypto.transaction', + 'BuyCryptoBatch.outputAsset', + 'BuyCryptoBatch.outputReferenceAsset', + 'BuyCryptoFee.feeReferenceAsset', + 'BuyFiat.outputAsset', + 'BuyFiat.outputReferenceAsset', + 'BuyFiat.transaction', + 'CryptoInput.asset', + 'CryptoInput.route', + // The deposit routes share one table, and the metadata of the parent carries the relations of + // every child. A query on the parent loads all of them, which is why they are on this list. + 'DepositRoute.asset', + 'DepositRoute.deposit', + 'DepositRoute.fiat', + 'DepositRoute.paybackAsset', + 'DepositRoute.paybackDeposit', + 'DepositRoute.rewardAsset', + 'DepositRoute.rewardDeposit', + 'DepositRoute.route', + 'DepositRoute.targetDeposit', + 'Fee.bank', + 'Fee.wallet', + 'FiatOutput.bank', + 'LimitRequest.supportIssue', + 'LiquidityBalance.asset', + 'LiquidityManagementOrder.action', + 'LiquidityManagementOrder.pipeline', + 'LiquidityManagementPipeline.currentAction', + 'LiquidityManagementPipeline.previousAction', + 'LiquidityManagementPipeline.rule', + 'LiquidityManagementRule.deficitStartAction', + 'LiquidityManagementRule.redundancyStartAction', + 'LiquidityManagementRule.targetAsset', + 'LiquidityManagementRule.targetFiat', + 'Organization.country', + 'PaymentLinkPayment.currency', + 'RefReward.outputAsset', + 'RefReward.transaction', + 'SupportIssue.transaction', + 'SupportIssue.transactionRequest', + 'SupportIssue.userData', + 'SupportIssue.wallet', + 'Transaction.user', + 'User.refAsset', + 'UserData.country', + 'UserData.currency', + 'UserData.language', + 'UserData.nationality', + 'UserData.organization', + 'UserData.organizationCountry', + 'UserData.verifiedCountry', +]; + +/** + * Eager relations in this repository, counted per entity — an inherited one counts once for each + * entity that carries it, because that is how often it is loaded. + */ +const EAGER_RELATIONS = 103; + +describeProjection('eager relations', () => { + let dataSource: DataSource; + let byName: Map; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + byName = new Map(dataSource.entityMetadatas.map((metadata) => [metadata.name, metadata])); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * Every eager relation reachable from `roots`, with every root that reaches it. + * + * All of them, not the first: `UserData.wallet` is in the answer of the account endpoints and of + * everything that carries an account, and a message naming one of those sends the reader to the + * wrong controller. + */ + function closureOf(roots: string[]): Map> { + const found = new Map>(); + + for (const root of roots) { + const visited = new Set(); + const pending = [root]; + + while (pending.length) { + const name = pending.pop(); + if (visited.has(name)) continue; + visited.add(name); + + for (const relation of byName.get(name)?.eagerRelations ?? []) { + const path = `${name}.${relation.propertyName}`; + if (!found.has(path)) found.set(path, new Set()); + found.get(path).add(root); + pending.push(relation.inverseEntityMetadata.name); + } + } + } + + return found; + } + + it('finds the controllers that answer with an entity', () => { + // If this reads zero, the search above stopped matching and every assertion below would pass + // for the wrong reason. + expect(entitiesReturnedWhole(new Set(byName.keys())).size).toBeGreaterThan(20); + }); + + it('the responses that are entities contain exactly the eager relations recorded here', () => { + const returned = entitiesReturnedWhole(new Set(byName.keys())); + const closure = closureOf([...returned.keys()]); + + // Reported with the controllers, because that is the part a diff of relation names does not + // show: an added relation is a changed response, and this says whose. + const withOrigin = (paths: string[]): string[] => + paths.map((path) => { + const controllers = [...closure.get(path)].flatMap((origin) => returned.get(origin) ?? []); + return `${path} — in the answer of ${[...new Set(controllers)].sort().join(', ')}`; + }); + + const added = [...closure.keys()].filter((path) => !IN_A_PAYLOAD.includes(path)).sort(); + expect(withOrigin(added)).toEqual([]); + + const removed = IN_A_PAYLOAD.filter((path) => !closure.has(path)).sort(); + expect(removed).toEqual([]); + }); + + it('has no more eager relations than are recorded', () => { + const all = dataSource.entityMetadatas.flatMap((metadata) => + metadata.eagerRelations.map((relation) => `${metadata.name}.${relation.propertyName}`), + ); + + // Not a limit, a count. Every one of these makes some query load a table it was not asked for, + // and the point of writing the number down is that adding one is a decision rather than a + // detail of an unrelated change. + expect(all.length).toEqual(EAGER_RELATIONS); + }); +}); diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.entity.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.entity.ts index 5a8eb12290..b7553a135b 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.entity.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.entity.ts @@ -40,7 +40,7 @@ export class Buy extends IEntity { @ManyToOne(() => Deposit, { eager: true, nullable: true }) deposit?: Deposit; - @OneToOne(() => Route, { eager: true, nullable: true }) + @OneToOne(() => Route, { nullable: true }) @JoinColumn() route?: Route; diff --git a/src/subdomains/core/custody/entities/custody-balance.entity.ts b/src/subdomains/core/custody/entities/custody-balance.entity.ts index 55e2025f20..90cfdbc8a5 100644 --- a/src/subdomains/core/custody/entities/custody-balance.entity.ts +++ b/src/subdomains/core/custody/entities/custody-balance.entity.ts @@ -11,7 +11,7 @@ export class CustodyBalance extends IEntity { balance: number; @Index() - @ManyToOne(() => User, (user) => user.custodyBalances, { nullable: false, eager: true }) + @ManyToOne(() => User, (user) => user.custodyBalances, { nullable: false }) user: User; @Index() diff --git a/src/subdomains/core/custody/entities/custody-order.entity.ts b/src/subdomains/core/custody/entities/custody-order.entity.ts index 340ab7ca64..21586f4fdb 100644 --- a/src/subdomains/core/custody/entities/custody-order.entity.ts +++ b/src/subdomains/core/custody/entities/custody-order.entity.ts @@ -68,7 +68,7 @@ export class CustodyOrder extends IEntity { @JoinColumn() transactionRequest?: TransactionRequest; - @OneToOne(() => Transaction, (transaction) => transaction.custodyOrder, { nullable: true, eager: true }) + @OneToOne(() => Transaction, (transaction) => transaction.custodyOrder, { nullable: true }) @JoinColumn() transaction?: Transaction; diff --git a/src/subdomains/core/staking/entities/crypto-staking.entity.ts b/src/subdomains/core/staking/entities/crypto-staking.entity.ts index aa9517ea6f..0b7baf23cb 100644 --- a/src/subdomains/core/staking/entities/crypto-staking.entity.ts +++ b/src/subdomains/core/staking/entities/crypto-staking.entity.ts @@ -57,7 +57,7 @@ export class CryptoStaking extends IEntity { payoutType: PayoutType; @Index() - @ManyToOne(() => Deposit, { eager: true, nullable: true }) + @ManyToOne(() => Deposit, { nullable: true }) paybackDeposit?: Deposit; @OneToOne(() => CryptoInput, { nullable: false }) From 7e18dea110e1717a04cba817be89dd81bb2fdfa4 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:48:20 +0200 Subject: [PATCH 31/46] Close the two open points on the custody order read path `GET /custody/account/:id/order` reaches the same projected query as `GET /custody/order` - both go through `CustodyOrderService.getOrdersByUserData` - and the inventory nevertheless lists it as loading whole rows at 253 columns. That is correct rather than a gap: the width comes from the access check the endpoint runs before the history, not from the history itself. The comment on the repository method now says so, and it names the method that exists; it named one that does not. The service also carried a `fields` parameter for the mutation test, which the test does not use - it drives the repository directly - and which no caller passes. Removed, along with the import it needed. --- .../repositories/custody-order.repository.ts | 4 +++- .../custody/services/custody-order.service.ts | 16 ++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/subdomains/core/custody/repositories/custody-order.repository.ts b/src/subdomains/core/custody/repositories/custody-order.repository.ts index a0d64148cd..210b8e0ba6 100644 --- a/src/subdomains/core/custody/repositories/custody-order.repository.ts +++ b/src/subdomains/core/custody/repositories/custody-order.repository.ts @@ -49,7 +49,9 @@ export class CustodyOrderRepository extends BaseRepository { * The custody orders of an account, newest first, capped at a hundred. * * `fields` is what the mutation test in `custody-order-history.projection.spec.ts` re-runs the - * query with; `CustodyOrderService.getUserCustodyOrders` calls this without it. + * query with; `CustodyOrderService.getOrdersByUserData` calls this without it. That method serves + * two endpoints — `GET /custody/order` and `GET /custody/account/:id/order` — so both answer out + * of this projection, and the second one is wider only because its access check loads elsewhere. */ async findHistoryFor( userDataId: number, diff --git a/src/subdomains/core/custody/services/custody-order.service.ts b/src/subdomains/core/custody/services/custody-order.service.ts index 9daad1ae2f..cdedde93ee 100644 --- a/src/subdomains/core/custody/services/custody-order.service.ts +++ b/src/subdomains/core/custody/services/custody-order.service.ts @@ -41,7 +41,7 @@ import { CustodyOrderHistoryDtoMapper } from '../mappers/custody-order-history-d import { CustodyOrderResponseDtoMapper } from '../mappers/custody-order-response-dto.mapper'; import { GetCustodyOrderDtoMapper } from '../mappers/get-custody-order-dto.mapper'; import { CustodyOrderStepRepository } from '../repositories/custody-order-step.repository'; -import { CUSTODY_ORDER_HISTORY_PROJECTION, CustodyOrderRepository } from '../repositories/custody-order.repository'; +import { CustodyOrderRepository } from '../repositories/custody-order.repository'; import { CustodyAccountService } from './custody-account.service'; import { CustodyService } from './custody.service'; @@ -232,17 +232,9 @@ export class CustodyOrderService { }; } - /** - * A user's custody order history. - * - * `fields` is what the mutation test in `custody-order-history.projection.spec.ts` re-runs the - * query with; the controller calls this without it. - */ - async getOrdersByUserData( - userDataId: number, - fields: ReadonlyArray = CUSTODY_ORDER_HISTORY_PROJECTION.fields, - ): Promise { - const orders = await this.custodyOrderRepo.findHistoryFor(userDataId, fields); + /** A user's custody order history, for `GET /custody/order` and `GET /custody/account/:id/order`. */ + async getOrdersByUserData(userDataId: number): Promise { + const orders = await this.custodyOrderRepo.findHistoryFor(userDataId); return CustodyOrderHistoryDtoMapper.mapList(orders); } From 23cb7172717796677ea06faad8a051afdb2fe810 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:49:42 +0200 Subject: [PATCH 32/46] Widen the projection guard, and answer the review on its own terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings, and what they change. The guard was narrower than it reads. Three gaps, each of which let the silent failure through: - A `@RelationId` lives in `relationIds`, not in `columns`, so reading one off a projected row answered `undefined` without a word. No field list can select it, which makes it the one shape where the fix is never "add it to the projection" — it now throws saying so. That is the defect this branch already fixed once, in the suspense mapper. - An embedded column is addressed by its full path. The guard split the field list at the first segment, so selecting `alias.address.city` marked the whole embedded as selected. Paths are kept whole now, and embedded objects are wrapped in turn. - Only `getOne` and `getMany` were wrapped. `getRawAndEntities` is where those two and `getOneOrFail` hydrate, so the hook moves there and covers all three; `getManyAndCount` runs its own query and gets its own. Each is covered by a test that fails without the fix. The point-of-sale configuration moves into the entity as `accessConfig`, where the rest of that merge already lives, and both sides are read lazily: a scoped call must not start failing on the JSON column it does not use. The previous version read both before branching. Test strength, where an assertion could not fail: - The suspense level 4 compared only the rows, leaving the total it derives unchecked, and took its own timestamp while the code under test took a later one — a comparison that would break at a day boundary rather than on a defect. - The sort test listed one row, which satisfies it whichever column the query orders by. It now seeds two rows differing in every sortable column and asserts both directions. - The search test assumed it was alone in the database; it scopes itself now. - The column-preservation test issued its own update, proving the update is safe rather than that the endpoint uses it. It drives the production method. - The merge of the two point-of-sale configurations had no case for the link setting the keys to null, which is what decides the direction of the merge. Documentation and comments: - Two figures contradicted the tables they stand on: 38 endpoints against 36, and 24 deprecated handlers split into 19 plus 3. - "one added column away from failing outright, whatever the column and wherever it is added" is not true — a column of a table the query does not touch changes nothing. Removed at both places. - "Any test that exercises the endpoint catches an incomplete field list" promised more than the guard delivers: a test still has to reach the read. - Working history and column counts measured elsewhere come out of the comments. The widths are recorded in the inventory, which is where a reader can check them. Imports this branch adds are absolute, per CONTRIBUTING. Two reported points are not defects and stay as they are. The generic parameter of `ReadProjection` is unbounded on purpose — `tsc --noEmit` passes, and the bound suggested would break the `ReadProjection` the documentation guard uses. And alphabetically sorted imports are not the convention this repository actually follows: of 149 sampled files with three or more imports, 9 are sorted and 140 are not, and no lint rule asks for it. --- docs/endpoints.md | 4 +- docs/load-sites.md | 2 +- docs/read-path-projections.md | 17 ++-- jest-projection.setup.ts | 2 +- jest.coverage-gate.config.js | 1 + .../projection-guard.projection.spec.ts | 40 +++++++++ src/shared/utils/projection-test.util.ts | 85 ++++++++++++++++--- .../ledger-suspense.projection.spec.ts | 23 +++-- .../__tests__/ledger-leg.repository.spec.ts | 7 +- .../repositories/ledger-leg.repository.ts | 2 +- .../__tests__/ledger-query.service.spec.ts | 2 +- .../buy-crypto-history.projection.spec.ts | 6 +- .../process/dto/buy-crypto-history.mapper.ts | 4 +- .../repositories/buy-crypto.repository.ts | 4 +- .../__tests__/buy-crypto.service.spec.ts | 2 +- .../process/services/buy-crypto.service.ts | 2 +- .../custody-order-history.projection.spec.ts | 4 +- .../repositories/custody-order.repository.ts | 2 +- .../pipeline-status.projection.spec.ts | 6 +- ...iquidity-management-pipeline.repository.ts | 2 +- .../__tests__/pos-link.projection.spec.ts | 35 ++++++-- .../entities/payment-link.entity.ts | 16 ++++ .../repositories/payment-link.repository.ts | 2 +- .../services/payment-link.service.ts | 7 +- .../buy-fiat-history.projection.spec.ts | 6 +- .../__tests__/buy-fiat.service.spec.ts | 2 +- .../process/buy-fiat.repository.ts | 2 +- .../process/dto/buy-fiat-history.mapper.ts | 4 +- .../process/services/buy-fiat.service.ts | 2 +- .../kyc/__tests__/kyc-data.projection.spec.ts | 6 +- .../models/kyc/dto/kyc-data-dto.mapper.ts | 6 +- .../generic/user/models/kyc/kyc.service.ts | 2 +- .../__tests__/api-key.projection.spec.ts | 14 +-- .../models/user-data/user-data.repository.ts | 6 +- .../__tests__/user-profile.projection.spec.ts | 6 +- .../user/__tests__/user-v2.projection.spec.ts | 6 +- .../user/models/user/user.repository.ts | 2 +- .../user/models/wallet/wallet.repository.ts | 2 +- .../support-issue-data.projection.spec.ts | 7 +- .../support-issue-list.projection.spec.ts | 54 +++++++++--- .../support-issue-view.projection.spec.ts | 4 +- .../repositories/support-issue.repository.ts | 20 +++-- .../support-message.repository.ts | 4 +- 43 files changed, 303 insertions(+), 129 deletions(-) diff --git a/docs/endpoints.md b/docs/endpoints.md index acb9283fcd..884aa4d4cc 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -23,7 +23,7 @@ Two rules follow from that, and both are binding: 1. **An endpoint counts as converted only when its tests reach `4/4`** against the four levels in [read-path-projections.md](read-path-projections.md#test-definition). A projection without them is worse than no projection: a forgotten field does not crash, it returns a wrong value with a 200, and in a service moving money that can run for weeks unnoticed. Anything short of `4/4` is unfinished work, not a partial success. 2. **The state of every endpoint is recorded here**, in the `Tests` column, and kept in sync with the code in the same pull request that changes it. An undocumented conversion is indistinguishable from one that was never tested. -Today 38 endpoints read only what they return and 398 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. +Today 36 endpoints read only what they return and 398 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. ## What the numbers say @@ -44,7 +44,7 @@ Among the 398 that fetch whole rows, the widest query they can trigger is **308 ### Deprecation -24 handlers carry `@ApiOperation({ deprecated: true })`: 19 of them fetch whole rows, 3 read nothing. They are what the duplicated paths are about — an older handler and its replacement served side by side under different versions. Note that deprecation does not follow the version: `GET /kyc/countries` is marked on **both** the v1 and the v2 handler. +24 handlers carry `@ApiOperation({ deprecated: true })`: 19 of them fetch whole rows, 2 project, 3 read nothing. They are what the duplicated paths are about — an older handler and its replacement served side by side under different versions. Note that deprecation does not follow the version: `GET /kyc/countries` is marked on **both** the v1 and the v2 handler. ### Limits of this classification diff --git a/docs/load-sites.md b/docs/load-sites.md index e55beb0322..e3fe4d6819 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -37,7 +37,7 @@ Columns were measured against the real entity metadata by building the query and Median across measured sites: **101 columns**. 14 sites exceed 1000, 71 exceed 500, 390 exceed 100. -Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright, whatever the column and wherever it is added. +Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. ## Load sites diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 3fa52a6f8c..6680180d0f 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -28,8 +28,7 @@ This service loads far more data than it returns. Measured against the real enti - The whole database schema has **1,736 columns across 99 tables**. - `PUT /v1/transaction/:id/invoice` selected **1,664 of them** — 96% of the entire schema — to render a PDF containing a handful of values. That is exactly Postgres' limit of 1,664 columns per - statement, so the query was one added column away from failing outright, whatever the column and - wherever it was added. + statement, so one further column in that query would have made it fail outright. - Of the 534 endpoints, **398 reach at least one load site that fetches whole rows**; 98 read nothing at all, and **36 read only the fields they return**. The widest query a fetching endpoint can trigger is 308 columns at the median, and 19 of them exceed 1,000. @@ -55,7 +54,7 @@ like a projection but is not, 14 pass no select at all, and one projects its roo relation in whole. The same entities serve persistence, business logic and pure output paths such as invoices, receipts, history and exports — which need fields, not objects. -Read the first number carefully, because an earlier revision of this document got it wrong. The 90 +Read the first number carefully. The 90 query builders that name columns one at a time are almost entirely counts, maxima and id lookups — `.select('userData.id', 'id')`, `.select('COUNT(*)', 'count')` and the like — and they select **one column at the median**. They are projections, and they were miscounted as full loads because the @@ -228,8 +227,9 @@ column: The consequences are worth stating plainly: -- **Any test that exercises the endpoint catches an incomplete field list**, including a naive one. - Completeness stops depending on how cleverly the fixture was written. +- **A test that reaches the read fails at it**, whether or not the missing column would have been + visible in the response. What the fixture still has to do is reach the branch; what it no longer + has to do is make the resulting value observable. - It is installed once for the whole configuration (`jest-projection.setup.ts`), not per spec, so a spec written later cannot lose the protection quietly. - It reports **where the column was needed**, not where the wrong value surfaced. A getter keeps @@ -287,8 +287,7 @@ the test. It is visible in the return type of a controller. contains: `Buy.route`, `CustodyBalance.user`, `CustodyOrder.transaction`, `CryptoStaking.paybackDeposit`. That narrowed 55 load sites — the custody order paths by 98 columns each, the widest transaction paths by four — and 47 endpoints in -[endpoints.md](endpoints.md) now show a smaller number. After it, no eager relation is left that is -both unread and outside every response: the mechanically decidable part is exhausted. +[endpoints.md](endpoints.md) now show a smaller number. **What is left.** 32 declarations are read somewhere and are in no response. Each is removable, but not by rule: the compiler says where a relation is read, not which query produced the value that @@ -300,9 +299,7 @@ loaded is `undefined` at a call site that no test may reach. relation added to any entity in it fails the run naming the controller whose answer it changes, and the total count, so a new one anywhere is a decision rather than a detail of an unrelated change. It finds the entities that leave through a controller by reading the controllers rather than from a -list, because a list goes stale the first time someone adds one — which is not hypothetical: the -first version of this measurement looked only at entities that already carried an eager relation and -missed 27 handlers for it. +list, so adding a controller does not silently narrow what the closure covers. So the two routes are not alternatives, and neither is cheap. The difference between them is the failure mode: an incomplete field list now throws, and an eager relation added to the wrong entity diff --git a/jest-projection.setup.ts b/jest-projection.setup.ts index 7cf5c7c22b..0308c46ac6 100644 --- a/jest-projection.setup.ts +++ b/jest-projection.setup.ts @@ -4,6 +4,6 @@ // to catch, and it is silent by nature. The guard makes it throw. Installing it here rather than // per spec is deliberate: a spec written later would otherwise lose the protection without anything // saying so. -import { installProjectionGuard } from './src/shared/utils/projection-test.util'; +import { installProjectionGuard } from 'src/shared/utils/projection-test.util'; installProjectionGuard(); diff --git a/jest.coverage-gate.config.js b/jest.coverage-gate.config.js index 51f3b5eae8..15f3f324c6 100644 --- a/jest.coverage-gate.config.js +++ b/jest.coverage-gate.config.js @@ -69,6 +69,7 @@ const PINNED_LOGIC = [ 'src/shared/decorators/log-rejected-value.decorator.ts', 'src/shared/models/fiat/fiat.controller.ts', 'src/shared/pipes/detailed-validation.pipe.ts', + 'src/shared/models/read-projection.ts', 'src/shared/services/typeorm-logger.ts', 'src/shared/utils/bitbox-ascii.util.ts', 'src/shared/utils/cron.ts', diff --git a/src/shared/utils/__tests__/projection-guard.projection.spec.ts b/src/shared/utils/__tests__/projection-guard.projection.spec.ts index f28703f51d..a55e44eafe 100644 --- a/src/shared/utils/__tests__/projection-guard.projection.spec.ts +++ b/src/shared/utils/__tests__/projection-guard.projection.spec.ts @@ -7,7 +7,9 @@ import { seedEntity, } from 'src/shared/utils/projection-test.util'; import { Country } from 'src/shared/models/country/country.entity'; +import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { Language } from 'src/shared/models/language/language.entity'; +import { LedgerLeg } from 'src/subdomains/core/accounting/entities/ledger-leg.entity'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { DataSource } from 'typeorm'; @@ -84,6 +86,44 @@ describeProjection('guardProjection', () => { expect(() => row.language.symbol).toThrow("read of 'Language.symbol'"); }, 120000); + it('throws on a @RelationId, which no field list can select', async () => { + // The property is filled from the foreign-key column of the row, which a query naming its + // fields does not carry — the defect this suite exists to catch, in the one shape where the + // fix is never "add it to the projection". + const leg = guardProjection( + dataSource, + LedgerLeg, + new ReadProjection('leg', [], ['leg.id']), + ['leg.id'], + { id: 1 } as LedgerLeg, + ); + + expect(() => leg.txId).toThrow("read of 'LedgerLeg.txId', a @RelationId that a projected query never fills"); + }, 120000); + + it('guards a column inside an embedded object, by its full path', async () => { + const projection = new ReadProjection('input', [], ['input.id', 'input.address.address']); + const row = { id: 1, address: { address: 'selected', blockchain: 'hidden' } } as unknown as CryptoInput; + + const input = guardProjection(dataSource, CryptoInput, projection, projection.fields, row); + + // Selecting one column of the embedded must not mark the rest of it as selected. + expect(input.address.address).toEqual('selected'); + expect(() => input.address.blockchain).toThrow("read of 'CryptoInput.address.blockchain'"); + }, 120000); + + it('guards the rows of getManyAndCount, which runs its own query', async () => { + const language = await seedEntity(dataSource, Language); + await seedEntity(dataSource, UserData, { values: { language } }); + + const [rows] = await PROJECTION.apply( + dataSource.getRepository(UserData).createQueryBuilder('userData'), + FIELDS, + ).getManyAndCount(); + + expect(() => rows[0].surname).toThrow("read of 'UserData.surname'"); + }, 120000); + it('leaves a column the query selected but the row has as null alone', async () => { const language = await seedEntity(dataSource, Language); const country = await seedEntity(dataSource, Country); diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts index 75746f2dc1..ffb155ca41 100644 --- a/src/shared/utils/projection-test.util.ts +++ b/src/shared/utils/projection-test.util.ts @@ -371,12 +371,18 @@ function guardAgainst( ): unknown { if (entity == null) return entity; - /** What the query selected, per alias. Guards are selected too — `apply` adds them. */ + /** + * What the query selected, per alias. Guards are selected too — `apply` adds them. + * + * The path after the alias is kept whole rather than split off at the first segment: an embedded + * column is addressed as `alias.address.city`, and keeping only `address` would mark every + * column of the embedded as selected. + */ const selected = new Map>(); for (const field of [...fields, ...projection.guards]) { - const [alias, property] = field.split('.'); + const [alias, ...path] = field.split('.'); if (!selected.has(alias)) selected.set(alias, new Set()); - selected.get(alias).add(property); + selected.get(alias).add(path.join('.')); } interface Node { @@ -405,6 +411,36 @@ function guardAgainst( byAlias.set(alias, child); } + /** Is `path` an embedded object rather than a column — that is, does anything sit below it? */ + const isEmbedded = (at: Node, path: string): boolean => + at.metadata.columns.some((column) => column.propertyPath.startsWith(`${path}.`)); + + /** + * An embedded object, guarded one level down. + * + * Its columns are addressed by their full path (`address.city`), so the same selection set + * answers for them; only the walk to reach them differs. + */ + const wrapEmbedded = (value: ObjectLiteral, at: Node, prefix: string): ObjectLiteral => + value == null + ? value + : new Proxy(value, { + get(source, property, receiver) { + const path = `${prefix}.${String(property)}`; + const inner = Reflect.get(source, property, receiver); + + if (isEmbedded(at, path)) return wrapEmbedded(inner as ObjectLiteral, at, path); + if (at.metadata.columns.some((column) => column.propertyPath === path) && !at.asked.has(path)) { + throw new Error( + `read of '${at.metadata.name}.${path}', which this query did not select — ` + + `add it to the projection, or stop reading it`, + ); + } + + return inner; + }, + }); + const wrap = (row: T | T[] | null, at: Node): T | T[] | null => { if (row == null) return row; if (Array.isArray(row)) return row.map((one) => wrap(one, at)) as T[]; @@ -431,8 +467,20 @@ function guardAgainst( // reports as a column under the relation's own property name. if (at.metadata.relations.some((relation) => relation.propertyName === name)) return value; + // A `@RelationId` is filled from the foreign-key column of the row, which a query naming + // its fields does not carry. No field list can select it, so reading one off a projected + // row is always the defect — read the id off the joined relation instead. + if (at.metadata.relationIds.some((relationId) => relationId.propertyName === name) && !written.has(name)) { + throw new Error( + `read of '${at.metadata.name}.${name}', a @RelationId that a projected query never fills — ` + + `join the relation and read the id off it`, + ); + } + + if (isEmbedded(at, name)) return wrapEmbedded(value as ObjectLiteral, at, name); + if ( - at.metadata.columns.some((column) => column.propertyName === name) && + at.metadata.columns.some((column) => column.propertyPath === name) && !at.asked.has(name) && !written.has(name) ) { @@ -453,11 +501,9 @@ function guardAgainst( /** * Turns the guard on for every query a `ReadProjection` builds, for the rest of the process. * - * Wiring it per spec would work and would be forgotten: a spec written next month would silently - * lose the protection, and nothing would say so. Here it applies to every projected query in the - * suite, including the ones the mutation level runs with a reduced field list. - * - * Test-only by construction — this is the only caller, and production never loads this file. + * Installed once for the whole configuration rather than per spec, so that a spec added later is + * covered by the same call. It applies to every projected query in the suite, including the ones + * the mutation level runs with a reduced field list. */ export function installProjectionGuard(): void { const original = ReadProjection.prototype.apply; @@ -472,11 +518,22 @@ export function installProjectionGuard(): void { const metadata = built.expressionMap.mainAlias?.metadata; if (!metadata) return built; - for (const method of ['getOne', 'getMany'] as const) { - const inner = built[method].bind(built); - (built as unknown as Record)[method] = async () => - guardAgainst(metadata, this, fields, await inner()); - } + const guard = (rows: unknown): unknown => guardAgainst(metadata, this, fields, rows); + + // `getRawAndEntities` is where `getOne`, `getMany` and `getOneOrFail` all hydrate, so guarding + // it covers them and any direct caller in one place. `getManyAndCount` runs its own query and + // needs its own hook. The raw methods return rows rather than entities and are left alone. + const rawAndEntities = built.getRawAndEntities.bind(built); + built.getRawAndEntities = async () => { + const results = await rawAndEntities(); + return { ...results, entities: guard(results.entities) as E[] }; + }; + + const manyAndCount = built.getManyAndCount.bind(built); + built.getManyAndCount = async () => { + const [rows, count] = await manyAndCount(); + return [guard(rows) as E[], count]; + }; return built; } diff --git a/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts b/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts index 7063cbfcd6..bac48c171a 100644 --- a/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts +++ b/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts @@ -70,11 +70,17 @@ describeProjection('ledger suspense — read-path projection', () => { return { leg, tx, account }; } - /** The response the endpoint produces, through the projected query. */ + /** + * The response the endpoint produces, through the projected query. + * + * `now` is a parameter because the age is derived from it: a comparison that takes its own + * timestamp disagrees with this one across a day boundary, which would fail for the calendar + * rather than for the projection. + */ async function suspenseOf( fields = SUSPENSE_LEG_PROJECTION.fields, + now = new Date(), ): Promise<{ totalChf: number; legs: SuspenseLegDto[] }> { - const now = new Date(); const rows = await legs.findSuspenseLegs(fields); const totalChf = Util.round(Util.sum(rows.map((l) => l.amountChf ?? 0)), 2); const mapped: SuspenseLegRow[] = rows.map((leg) => ({ @@ -150,8 +156,8 @@ describeProjection('ledger suspense — read-path projection', () => { await seedLeg(); const now = new Date(); - const projected = await suspenseOf(); - // The unprojected load is the second source: the join form the query used before. + const projected = await suspenseOf(SUSPENSE_LEG_PROJECTION.fields, now); + // The unprojected load is the second source: the same rows selected without a field list. const full = await dataSource .getRepository(LedgerLeg) .createQueryBuilder('leg') @@ -161,14 +167,17 @@ describeProjection('ledger suspense — read-path projection', () => { .orderBy('tx.bookingDate', 'ASC') .getMany(); - expect(projected.legs).toEqual( - full.map((leg) => + // The whole response, not just the rows: the total is derived from a column the projection has + // to carry, so comparing only the legs would leave it unchecked. + expect(projected).toEqual({ + totalChf: Util.round(Util.sum(full.map((leg) => leg.amountChf ?? 0)), 2), + legs: full.map((leg) => LedgerDtoMapper.mapSuspenseLeg({ leg, bookingDate: leg.tx.bookingDate, age: Util.daysDiff(leg.tx.bookingDate, now), }), ), - ); + }); }, 120000); }); diff --git a/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts b/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts index a467514ffb..6c10d173a7 100644 --- a/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts +++ b/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts @@ -1,9 +1,12 @@ import { createMock } from '@golevelup/ts-jest'; import { EntityManager, SelectQueryBuilder } from 'typeorm'; import { BaseRepository } from 'src/shared/repositories/base.repository'; -import { AccountType } from '../../entities/ledger-account.entity'; +import { AccountType } from 'src/subdomains/core/accounting/entities/ledger-account.entity'; import { LedgerLeg } from '../../entities/ledger-leg.entity'; -import { LedgerLegRepository, SUSPENSE_LEG_PROJECTION } from '../ledger-leg.repository'; +import { + LedgerLegRepository, + SUSPENSE_LEG_PROJECTION, +} from 'src/subdomains/core/accounting/repositories/ledger-leg.repository'; describe('LedgerLegRepository', () => { let manager: EntityManager; diff --git a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts index d7cf0ad33c..094a0f2e6e 100644 --- a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts +++ b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; -import { AccountType } from '../entities/ledger-account.entity'; +import { AccountType } from 'src/subdomains/core/accounting/entities/ledger-account.entity'; import { LedgerLeg } from '../entities/ledger-leg.entity'; /** What `LedgerDtoMapper.mapSuspenseLeg` reads, including the booking date the age is computed from. */ diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts index 4ed9dba068..22081e0f63 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts @@ -186,7 +186,7 @@ describe('LedgerQueryService', () => { logService = createMock(); jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => legQb()); - // The suspense query lives in the repository now; what it selects is asserted against a real + // The suspense query lives in the repository; what it selects is asserted against a real // database in ledger-suspense.projection.spec.ts. jest .spyOn(ledgerLegRepository, 'findSuspenseLegs') diff --git a/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts b/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts index 79e1988dbd..af547440f9 100644 --- a/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts +++ b/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts @@ -29,7 +29,7 @@ const SCHEMA = 'buy_crypto_history_projection_spec'; * `docs/read-path-projections.md`. * * Both answer a `HistoryDtoDeprecated[]` built by the same mapper, and both reached it through a - * `find` that loads whole `BuyCrypto` rows: 497 and 509 columns respectively, for the ten values the + * `find` that loads whole `BuyCrypto` rows for the ten values the * mapper reads. They differ only in the route they filter by — `buy` for one, `cryptoRoute` for the * other — which is why there are two projections over one field list. */ @@ -204,8 +204,8 @@ describeProjection('buy-crypto history — read-path projection', () => { const { user, buy } = await seedBuyCrypto(); const projected = (await repository.findBuyHistory(user.id, buy.id)).map(BuyCryptoHistoryMapper.toDto); - // The unprojected load is the second source: it fetches every column, so what it produces is by - // construction what the endpoint answered before the conversion. + // The unprojected load is the second source: it fetches every column, so what it produces + // depends on no field list at all. const full = await dataSource.getRepository(BuyCrypto).find({ where: { buy: { id: buy.id, user: { id: user.id } } }, relations: { buy: { user: true } }, diff --git a/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts b/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts index 77d173e4cb..3a24e26ed1 100644 --- a/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts +++ b/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts @@ -1,11 +1,11 @@ import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; import { HistoryDtoDeprecated, PaymentStatusMapper } from 'src/subdomains/core/history/dto/history.dto'; -import { BuyCrypto } from '../entities/buy-crypto.entity'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; /** * The history entry `GET /buy/:id/history` and `GET /swap/:id/history` answer with. * - * Moved out of `BuyCryptoService` so that the projection spec can drive the same mapping the + * Kept here rather than in `BuyCryptoService` so that the projection spec can drive the same mapping the * endpoints use. A copy in the spec could be wrong in exactly the way the projection is wrong and * would prove nothing. * diff --git a/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts b/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts index 796ceaf3cc..4c0218b4ec 100644 --- a/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts +++ b/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts @@ -30,7 +30,7 @@ const HISTORY_GUARDS = ['buyCrypto.id', 'outputAsset.id']; /** * `GET /buy/:id/history` — filtered by the buy route and its user. * - * Without it the query loads whole `BuyCrypto` rows: 497 columns for these ten values. + * Without it the query loads whole `BuyCrypto` rows for these ten values. */ export const BUY_CRYPTO_BUY_HISTORY_PROJECTION = new ReadProjection( 'buyCrypto', @@ -43,7 +43,7 @@ export const BUY_CRYPTO_BUY_HISTORY_PROJECTION = new ReadProjection( HISTORY_GUARDS, ); -/** `GET /swap/:id/history` — the same response, filtered by the swap route instead. 509 columns before. */ +/** `GET /swap/:id/history` — the same response, filtered by the swap route instead. */ export const BUY_CRYPTO_ROUTE_HISTORY_PROJECTION = new ReadProjection( 'buyCrypto', [ diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index 875343e2e3..acb8b2817e 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -218,7 +218,7 @@ describe('BuyCryptoService', () => { break; } - // The history now goes through the projected queries; both are stubbed because one `setup` + // The history goes through the projected queries; both are stubbed because one `setup` // serves the buy-route and the swap-route cases. jest.spyOn(buyCryptoRepo, 'findBuyHistory').mockResolvedValue(wantedData); jest.spyOn(buyCryptoRepo, 'findSwapHistory').mockResolvedValue(wantedData); diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index 57ec0d4376..eb565ef94d 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -29,7 +29,7 @@ import { SwapService } from 'src/subdomains/core/buy-crypto/routes/swap/swap.ser import { CustodyOrderType } from 'src/subdomains/core/custody/enums/custody'; import { CustodyOrderService } from 'src/subdomains/core/custody/services/custody-order.service'; import { HistoryDtoDeprecated } from 'src/subdomains/core/history/dto/history.dto'; -import { BuyCryptoHistoryMapper } from '../dto/buy-crypto-history.mapper'; +import { BuyCryptoHistoryMapper } from 'src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper'; import { BankTxRefund, CheckoutTxRefund, diff --git a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts index c0aa594aec..5ef573cf86 100644 --- a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts +++ b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts @@ -212,8 +212,8 @@ describeProjection('GET /custody/order — read-path projection', () => { const { userData } = await seedOrder(type, status); const projected = await historyOf(userData.id); - // The unprojected load is the second source: `leftJoinAndSelect` on the three relations, which - // is what the query did before the conversion. + // The unprojected load is the second source: `leftJoinAndSelect` on the three relations, + // which selects each of them whole. const full = await dataSource .getRepository(CustodyOrder) .createQueryBuilder('custodyOrder') diff --git a/src/subdomains/core/custody/repositories/custody-order.repository.ts b/src/subdomains/core/custody/repositories/custody-order.repository.ts index 210b8e0ba6..bdcd334032 100644 --- a/src/subdomains/core/custody/repositories/custody-order.repository.ts +++ b/src/subdomains/core/custody/repositories/custody-order.repository.ts @@ -3,7 +3,7 @@ import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; import { CustodyOrder } from '../entities/custody-order.entity'; -import { CustodyOrderStatus } from '../enums/custody'; +import { CustodyOrderStatus } from 'src/subdomains/core/custody/enums/custody'; /** What `CustodyOrderHistoryDtoMapper.map` reads. */ export const CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS = [ diff --git a/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts b/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts index d3e2653752..bf1399686c 100644 --- a/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts +++ b/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts @@ -23,7 +23,7 @@ const SCHEMA = 'pipeline_status_projection_spec'; * `GET /liquidityManagement/pipeline/:id/status` — the four levels from * `docs/read-path-projections.md`. * - * The endpoint answers with one string. Asking for the row by id fetched 112 columns for it: the + * The endpoint answers with one string. Asking for the row by id fetched every column of it: the * pipeline expands its rule and both of its action relations eagerly, and the rule expands its own. */ describeProjection('liquidity management pipeline status — read-path projection', () => { @@ -116,8 +116,8 @@ describeProjection('liquidity management pipeline status — read-path projectio const pipeline = await seedPipeline(status); const projected = await statusOf(pipeline.id); - // The unprojected load is the second source: the find the endpoint used before, with every - // eager relation it pulls in. + // The unprojected load is the second source: a find without a field list, with every eager + // relation it pulls in. const full = await dataSource.getRepository(LiquidityManagementPipeline).findOneBy({ id: pipeline.id }); expect(projected).toEqual(full.status); diff --git a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts index b78f663f33..d486125b73 100644 --- a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts +++ b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts @@ -8,7 +8,7 @@ import { LiquidityManagementPipeline } from '../entities/liquidity-management-pi export const PIPELINE_STATUS_RESPONSE_FIELDS = ['pipeline.status']; /** - * `GET /liquidityManagement/pipeline/:id/status` — 112 columns before, for one. + * `GET /liquidityManagement/pipeline/:id/status` — one column, for one value. * * The pipeline expands its rule and its current action eagerly, and the rule pulls in its asset and * its currency, so asking for the row by id fetched the whole graph to read a status string. diff --git a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts index a1d5b8adde..529591b14f 100644 --- a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts +++ b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts @@ -118,7 +118,12 @@ describeProjection('point-of-sale link — read-path projection', () => { * Read off the query string rather than through `URL`: the prefix comes from the configuration * and is not necessarily absolute. */ - const keyOf = (url: string): string => new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('key'); + const keyOf = (url: string): string => { + const key = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('key'); + if (key == null) throw new Error(`no key in ${url}`); + + return key; + }; /** The answer of the endpoint, through the projected query. */ async function posLinkOf( @@ -152,7 +157,7 @@ describeProjection('point-of-sale link — read-path projection', () => { // --- LEVEL 2: variants --- // - it.each([ + it.each<[string, boolean | undefined]>([ ['unset, which merges the account into the link', undefined], ['true, which reads the link alone', true], ])( @@ -160,12 +165,12 @@ describeProjection('point-of-sale link — read-path projection', () => { async (_name, scoped) => { const { paymentLink } = await seedLink(AccountType.PERSONAL, {}, { config: withKey('from-the-link') }); - expect((await posLinkOf(paymentLink.id, scoped as boolean)).key).toEqual('from-the-link'); + expect((await posLinkOf(paymentLink.id, scoped)).key).toEqual('from-the-link'); }, 120000, ); - it.each([ + it.each<[string, boolean | undefined]>([ ['unset, which merges the account into the link', undefined], ['false, which reads the account alone', false], ])( @@ -173,11 +178,29 @@ describeProjection('point-of-sale link — read-path projection', () => { async (_name, scoped) => { const { paymentLink } = await seedLink(AccountType.PERSONAL, { paymentLinksConfig: withKey('from-the-account') }); - expect((await posLinkOf(paymentLink.id, scoped as boolean)).key).toEqual('from-the-account'); + expect((await posLinkOf(paymentLink.id, scoped)).key).toEqual('from-the-account'); }, 120000, ); + it('level 2 — a link that sets the keys to null overrides the account rather than falling back', async () => { + // The merge is by spread, so a key the link carries wins even when its value is null. Without + // this case the two configurations could be merged the other way round and every other variant + // would still pass. + const { paymentLink } = await seedLink( + AccountType.PERSONAL, + { paymentLinksConfig: withKey('from-the-account') }, + { config: JSON.stringify({ accessKeys: null }) }, + ); + + const answer = await posLinkOf(paymentLink.id); + + // A freshly generated key rather than the account's: the link's null wins the merge, so the + // endpoint finds no key at all and issues one. + expect(answer.key).not.toEqual('from-the-account'); + expect(answer.key).toMatch(/^[0-9A-Z]{40,}$/); + }, 120000); + it.each([AccountType.ORGANIZATION, AccountType.SOLE_PROPRIETORSHIP])( 'level 2 — a %s account answers without reading its address', async (accountType) => { @@ -338,7 +361,7 @@ describeProjection('point-of-sale link — read-path projection', () => { const { paymentLink } = await seedLink(accountType, {}, { config: withKey('same-either-way') }); const projected = await posLinkOf(paymentLink.id); - // The unprojected load is the second source: the relation set the endpoint used before. + // The unprojected load is the second source: the same relations selected whole. jest.spyOn(paymentLinks, 'findForPosLink').mockImplementationOnce((id) => paymentLinks.findOne({ where: { id }, diff --git a/src/subdomains/core/payment-link/entities/payment-link.entity.ts b/src/subdomains/core/payment-link/entities/payment-link.entity.ts index dc54c6f38c..d379b84755 100644 --- a/src/subdomains/core/payment-link/entities/payment-link.entity.ts +++ b/src/subdomains/core/payment-link/entities/payment-link.entity.ts @@ -104,6 +104,22 @@ export class PaymentLink extends IEntity { return Object.assign({}, DefaultPaymentLinkConfig, JSON.parse(this.config ?? '{}')); } + /** + * The configuration a point-of-sale link reads its access keys from, scoped to the link, to the + * account, or merged with the link winning. + * + * Separate from `configObj` because that one also assembles the recipient — name, contact data + * and address of the account — which this path discards, and reading those columns is the only + * reason a query would have to load them. Each side is read lazily: both getters parse their own + * JSON column, and a scoped call must not fail on the column it does not use. + */ + accessConfig(scoped?: boolean): PaymentLinkConfig { + const account = (): PaymentLinkConfig => this.route.userData.paymentLinksConfigObj; + const link = (): PaymentLinkConfig => this.linkConfigObj; + + return scoped == null ? { ...account(), ...link() } : scoped ? link() : account(); + } + get defaultStandard(): PaymentStandard { return this.configObj.standards[0]; } diff --git a/src/subdomains/core/payment-link/repositories/payment-link.repository.ts b/src/subdomains/core/payment-link/repositories/payment-link.repository.ts index 017785ef4f..23a32188b7 100644 --- a/src/subdomains/core/payment-link/repositories/payment-link.repository.ts +++ b/src/subdomains/core/payment-link/repositories/payment-link.repository.ts @@ -26,7 +26,7 @@ export const POS_LINK_RESPONSE_FIELDS = [ ]; /** - * `PUT /paymentLink/:id/pos` — 513 columns before. + * `PUT /paymentLink/:id/pos` — the access keys, and the ids that carry the joins. * * The endpoint writes, but through `update(id, …)` on the link and on the account rather than by * saving either row back, so a projected read cannot blank a column it did not load. `config` and diff --git a/src/subdomains/core/payment-link/services/payment-link.service.ts b/src/subdomains/core/payment-link/services/payment-link.service.ts index daaecf8b68..bf36adb166 100644 --- a/src/subdomains/core/payment-link/services/payment-link.service.ts +++ b/src/subdomains/core/payment-link/services/payment-link.service.ts @@ -706,12 +706,7 @@ export class PaymentLinkService { } private async createPosLinkFor(paymentLink: PaymentLink, scoped?: boolean): Promise { - // Only the access keys are read out of the configuration here. `configObj` would also assemble - // the recipient block — name, contact data and address of the account — which this endpoint - // discards, and reading those columns is the only reason the query would have to load them. - const accountConfig = paymentLink.route.userData.paymentLinksConfigObj; - const linkConfig = paymentLink.linkConfigObj; - const config = scoped == null ? { ...accountConfig, ...linkConfig } : scoped ? linkConfig : accountConfig; + const config = paymentLink.accessConfig(scoped); let accessKey = config.accessKeys?.at(0); if (!accessKey) { diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts index 49fb015429..ca036a15e8 100644 --- a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts @@ -30,7 +30,7 @@ const SCHEMA = 'buy_fiat_history_projection_spec'; * `GET /sell/:id/history` — the four levels from `docs/read-path-projections.md`. * * The endpoint answers a `SellHistoryDto[]`. Reading it without a projection loads whole `BuyFiat` - * rows: 470 columns for the nine values the mapper reads. + * rows for the nine values the mapper reads. */ describeProjection('GET /sell/:id/history — read-path projection', () => { let dataSource: DataSource; @@ -153,8 +153,8 @@ describeProjection('GET /sell/:id/history — read-path projection', () => { const { user, sell } = await seedBuyFiat(withFiatOutput); const projected = (await repository.findSellHistory(user.id, sell.id)).map(BuyFiatHistoryMapper.toDto); - // The unprojected load is the second source: it fetches every column, so what it produces is - // by construction what the endpoint answered before the conversion. + // The unprojected load is the second source: it fetches every column, so what it produces + // depends on no field list at all. const full = await dataSource.getRepository(BuyFiat).find({ where: { sell: { id: sell.id, user: { id: user.id } } }, relations: { sell: { user: true }, cryptoInput: true, fiatOutput: true }, diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts index 458381396c..a85708001f 100644 --- a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts @@ -181,7 +181,7 @@ describe('BuyFiatService', () => { ]; } - // The history now goes through the projected query. + // The history goes through the projected query. jest.spyOn(buyFiatRepo, 'findSellHistory').mockResolvedValue(wantedData); } } diff --git a/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts b/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts index 9eb658f6ac..06f0a997ab 100644 --- a/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts +++ b/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts @@ -20,7 +20,7 @@ export const BUY_FIAT_HISTORY_RESPONSE_FIELDS = [ /** * `GET /sell/:id/history` — filtered by the sell route and its user. * - * Without it the query loads whole `BuyFiat` rows: 470 columns for these nine values. + * Without it the query loads whole `BuyFiat` rows for these nine values. */ export const BUY_FIAT_HISTORY_PROJECTION = new ReadProjection( 'buyFiat', diff --git a/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts b/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts index a6fae1b17c..e0d13af2cc 100644 --- a/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts +++ b/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts @@ -1,12 +1,12 @@ import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; import { PaymentStatus } from 'src/subdomains/core/history/dto/history.dto'; import { SellHistoryDto } from 'src/subdomains/core/sell-crypto/route/dto/sell-history.dto'; -import { BuyFiat } from '../buy-fiat.entity'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; /** * The history entry `GET /sell/:id/history` answers with. * - * Moved out of `BuyFiatService` so that the projection spec can drive the same mapping the endpoint + * Kept here rather than in `BuyFiatService` so that the projection spec can drive the same mapping the endpoint * uses — see `BuyCryptoHistoryMapper` for the reasoning. * * Note that `cryptoInput` and `outputAsset` are read without a guard here. That is the behaviour as diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts index 48c3e49527..c2af19b86e 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts @@ -53,7 +53,7 @@ import { SellRepository } from '../../route/sell.repository'; import { SellService } from '../../route/sell.service'; import { BuyFiat, BuyFiatEditableAmlCheck } from '../buy-fiat.entity'; import { BuyFiatRepository } from '../buy-fiat.repository'; -import { BuyFiatHistoryMapper } from '../dto/buy-fiat-history.mapper'; +import { BuyFiatHistoryMapper } from 'src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper'; import { UpdateBuyFiatDto } from '../dto/update-buy-fiat.dto'; import { BuyFiatNotificationService } from './buy-fiat-notification.service'; diff --git a/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts b/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts index 7ef27080b2..314470998d 100644 --- a/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts +++ b/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts @@ -30,7 +30,7 @@ const SCHEMA = 'kyc_data_projection_spec'; * `GET /kyc/users` and `GET /kyc/:id/documents` — the four levels from * `docs/read-path-projections.md`. * - * Both loaded a whole row graph — 328 columns — for very little: the first for an address, two + * Both loaded a whole row graph for very little: the first for an address, two * status fields and a hash per user, the second for nothing but the account id the document store * is keyed by. */ @@ -168,7 +168,7 @@ describeProjection('kyc data — read-path projection', () => { const { user, userData } = await seedWalletUser(); const projected = await users.findAccountIdForAddress(user.address, user.wallet.id); - // The unprojected load is the second source: the find the endpoint used before. + // The unprojected load is the second source: a find without a field list. const full = await dataSource.getRepository(User).findOne({ where: { address: user.address, wallet: { id: user.wallet.id } }, relations: { userData: true }, @@ -188,7 +188,7 @@ describeProjection('kyc data — read-path projection', () => { const { wallet } = await seedWalletUser(kycStatus, kycType); const projected = (await wallets.findKycData(wallet.id)).users.map(KycDataDtoMapper.toDto); - // The unprojected load is the second source: the relation set the endpoint used before. + // The unprojected load is the second source: the same relations selected whole. const full = await dataSource.getRepository(Wallet).findOne({ where: { id: wallet.id }, relations: { users: { userData: true } }, diff --git a/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts b/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts index 3fe54a860b..15d2e7c101 100644 --- a/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts +++ b/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts @@ -1,11 +1,11 @@ import { getKycWebhookStatus } from 'src/subdomains/generic/user/services/webhook/mapper/webhook-data.mapper'; -import { User } from '../../user/user.entity'; -import { KycDataDto } from './kyc-data.dto'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { KycDataDto } from 'src/subdomains/generic/user/models/kyc/dto/kyc-data.dto'; /** * The per-user entry `GET /kyc/users` answers with. * - * Moved out of `KycService` so the projection spec can drive the same mapping the endpoint uses; a + * Kept here rather than in `KycService` so the projection spec can drive the same mapping the endpoint uses; a * copy in the spec could be wrong in exactly the way the projection is wrong. */ export class KycDataDtoMapper { diff --git a/src/subdomains/generic/user/models/kyc/kyc.service.ts b/src/subdomains/generic/user/models/kyc/kyc.service.ts index 1652723770..3ad19825a9 100644 --- a/src/subdomains/generic/user/models/kyc/kyc.service.ts +++ b/src/subdomains/generic/user/models/kyc/kyc.service.ts @@ -24,7 +24,7 @@ import { UserRepository } from '../user/user.repository'; import { WalletRepository } from '../wallet/wallet.repository'; import { KycDataTransferDto } from './dto/kyc-data-transfer.dto'; import { KycDataDto } from './dto/kyc-data.dto'; -import { KycDataDtoMapper } from './dto/kyc-data-dto.mapper'; +import { KycDataDtoMapper } from 'src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper'; import { KycDocumentType, KycFileDto } from './dto/kyc-file.dto'; import { KycInfo } from './dto/kyc-info.dto'; diff --git a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts index f95716056d..7786c336f2 100644 --- a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts +++ b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts @@ -24,7 +24,7 @@ const SCHEMA = 'api_key_projection_spec'; /** * `POST /user/apiKey/CT` — the four levels from `docs/read-path-projections.md`. * - * The endpoint read a whole `UserData` row — 253 columns across eight eager joins — to check + * The endpoint read a whole `UserData` row, eager joins included, to check * whether a key exists and to derive a new one from the account id and its creation date. * * It writes as well as reads, but through `update(id, …)` rather than by saving the row it read, so @@ -135,12 +135,13 @@ describeProjection('API key — read-path projection', () => { (await dataSource.query(`SELECT * FROM "${SCHEMA}"."user_data" WHERE id = $1`, [account.id]))[0]; const before = await rowOf(); - const loaded = await userDataRepo.getForApiKey(account.id); - await userDataRepo.update(loaded.id, { apiKeyCT: 'written-key', apiFilterCT: 'written-filter' }); + // Through the production method rather than a write of the spec's own: a test that issues the + // update itself proves the update is safe, not that the endpoint uses it. + const answer = await createApiKey(account.id, { buy: true }); const after = await rowOf(); - expect(after.apiKeyCT).toEqual('written-key'); - expect(after.apiFilterCT).toEqual('written-filter'); + expect(after.apiKeyCT).toEqual(answer.key); + expect(after.apiFilterCT).toEqual(ApiKeyService.getFilterCode({ buy: true })); // Every other column - including the ones the projection never selected - has to be what it // was. `updated` is excluded because the write is what moves it. const ignored = ['apiKeyCT', 'apiFilterCT', 'updated']; @@ -224,8 +225,7 @@ describeProjection('API key — read-path projection', () => { const userData = await seedAccount({ apiKeyCT: null }); const projected = await userDataRepo.getForApiKey(userData.id); - // The unprojected load is the second source: every column of the row, which is what the - // endpoint fetched before. + // The unprojected load is the second source: every column of the row. const full = await dataSource.getRepository(UserData).findOneBy({ id: userData.id }); // Same key on both rows, so what has to agree is the secret derived from it — which is where diff --git a/src/subdomains/generic/user/models/user-data/user-data.repository.ts b/src/subdomains/generic/user/models/user-data/user-data.repository.ts index b716710775..e95f3da436 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.repository.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.repository.ts @@ -63,7 +63,7 @@ export const USER_PROFILE_ORGANIZATION_ADDRESS_FIELDS = [ /** * `GET /user/profile` — the seven values `UserDtoMapper.mapProfile` returns. * - * Without it a `findOne` on `UserData` selects 253 columns across 8 eager joins, `organization` + * Without it a `findOne` on `UserData` selects every column of the row and of its eager joins, `organization` * among them. Covered by `user-profile.projection.spec.ts` on all four levels. */ export const USER_PROFILE_PROJECTION = new ReadProjection( @@ -180,7 +180,7 @@ export const USER_V2_ADDRESS_FIELDS = [ /** * `GET /user` (v2) — the widest read path left in the inventory. * - * Without it a `findOne` on `UserData` selects 351 columns: four countries, a language, a currency + * Without it a `findOne` on `UserData` selects every column of the row and of its eager joins: four countries, a language, a currency * and an organization expand eagerly, and every user of the account brings its whole wallet. * * `computeCapabilities` reads `kycSteps` through `getStepsWith`. This query does not load that @@ -217,7 +217,7 @@ export const API_KEY_RESPONSE_FIELDS = [ ]; /** - * `POST /user/apiKey/CT` — 253 columns before, for two values and the id. + * `POST /user/apiKey/CT` — two values and the id. * * The endpoint writes, but through `update(id, …)` rather than by saving the row it read, so a * projected read cannot blank a column it did not load. diff --git a/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts b/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts index 0976456aeb..4aa050e71c 100644 --- a/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts +++ b/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts @@ -26,7 +26,7 @@ const SCHEMA = 'user_profile_projection_spec'; * `GET /user/profile` — the four levels from `docs/read-path-projections.md`. * * The endpoint answers a `UserProfileDto` built by `UserDtoMapper.mapProfile`. Reading it without a - * projection loads 253 columns across 8 eager joins for the seven values it returns. + * projection loads every column of the row and of its eager joins for the seven values it returns. * * The branch that makes this worth testing is `UserData.address`: for an organization account it * reads the address off `organization`, for a personal one off `userData` itself. A projection @@ -152,8 +152,8 @@ describeProjection('GET /user/profile — read-path projection', () => { const projected = await profileOf(userData.id); // The unprojected load is the second source: it fetches every column, so whatever it produces - // is by construction what the endpoint answered before the conversion. No second - // implementation is involved that could be wrong in the same way. + // depends on no field list at all. No second implementation is involved that could be wrong + // in the same way. const full = await dataSource.getRepository(UserData).findOne({ where: { id: userData.id }, relations: { organization: true }, diff --git a/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts b/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts index cdbbb04670..b6409fdcb4 100644 --- a/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts +++ b/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts @@ -49,7 +49,7 @@ const nextEvmAddress = (): string => `0x${(++addressCount).toString(16).padStart /** * `GET /user` (v2) — the four levels from `docs/read-path-projections.md`. * - * The widest read path in the inventory: a `findOne` on `UserData` selected 351 columns, because + * The widest read path in the inventory: a `findOne` on `UserData`, because * four countries, a language, a currency and an organization expand eagerly and every user of the * account brought its whole wallet. * @@ -326,8 +326,8 @@ describeProjection('GET /user v2 — read-path projection', () => { const { userData, user } = await seedAccount({ accountType }, { status }); const projected = await userV2Of(userData.id, user.id); - // The unprojected load is the second source: the find the endpoint used before, with every - // eager relation it pulls in. + // The unprojected load is the second source: a find without a field list, with every eager + // relation it pulls in. const full = await dataSource.getRepository(UserData).findOne({ where: { id: userData.id }, relations: { users: { wallet: true } }, diff --git a/src/subdomains/generic/user/models/user/user.repository.ts b/src/subdomains/generic/user/models/user/user.repository.ts index ff56faf8e6..90f86a4f2d 100644 --- a/src/subdomains/generic/user/models/user/user.repository.ts +++ b/src/subdomains/generic/user/models/user/user.repository.ts @@ -34,7 +34,7 @@ export class UserRepository extends BaseRepository { * The account behind an address on a wallet, loaded with the account id only. * * `GET /kyc/:id/documents` reads nothing else off the row: the response is assembled from the - * document store, keyed by that id. The unprojected load reaches 328 columns for it. + * document store, keyed by that id. The unprojected load fetches the whole row graph for it. * * `fields` is what the mutation test in `kyc-data.projection.spec.ts` re-runs the query with; * `KycService` calls this without it. diff --git a/src/subdomains/generic/user/models/wallet/wallet.repository.ts b/src/subdomains/generic/user/models/wallet/wallet.repository.ts index 8473f2b395..acb3ad7c8e 100644 --- a/src/subdomains/generic/user/models/wallet/wallet.repository.ts +++ b/src/subdomains/generic/user/models/wallet/wallet.repository.ts @@ -15,7 +15,7 @@ export const WALLET_KYC_DATA_RESPONSE_FIELDS = [ /** * `GET /kyc/users` — the KYC state of every user on a wallet. * - * Loading the wallet with `relations: { users: { userData: true } }` reaches 328 columns per user, + * Loading the wallet with `relations: { users: { userData: true } }` fetches every column per user, * for an address, two status fields and a hash. */ export const WALLET_KYC_DATA_PROJECTION = new ReadProjection( diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts index becbc060ab..3870ab485a 100644 --- a/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts @@ -38,7 +38,7 @@ const SCHEMA = 'support_issue_data_projection_spec'; /** * `GET /support/issue/:id/data` — the four levels from `docs/read-path-projections.md`. * - * The widest read path in the service: the unprojected load reaches 951 columns for a response of + * The widest read path in the service: the unprojected load fetches the whole graph for a response of * about sixty values, because the issue's four eager relations expand recursively and the * transaction pulls in both of its sides. * @@ -82,7 +82,7 @@ describeProjection('GET /support/issue/:id/data — read-path projection', () => values: personal ? { organizationName: null } : {}, relations: { country: true, language: true }, }); - let transaction: Transaction = null; + let transaction: Transaction | null = null; if (side !== 'none') { // The wallet block of the response hangs off `transaction.user.wallet`, and both are nullable // in the schema — a fixture without them says nothing about those four fields. @@ -257,8 +257,7 @@ describeProjection('GET /support/issue/:id/data — read-path projection', () => const issue = await seedIssue(side, named); const projected = await issueDataOf(issue.id); - // The unprojected load is the second source: the relation set the endpoint used before the - // conversion, fetching every column of each. + // The unprojected load is the second source: the same relations, fetching every column of each. const full = await dataSource.getRepository(SupportIssue).findOne({ where: { id: issue.id }, relations: { diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts index c997101850..f7d24ac931 100644 --- a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts @@ -38,7 +38,7 @@ const SCHEMA = 'support_issue_list_projection_spec'; * `docs/read-path-projections.md`. * * Both answer through `SupportIssueDtoMapper.mapSupportIssueListItem` and both loaded whole - * `SupportIssue` rows: 16 columns for the ten the row shows. + * `SupportIssue` rows for the ten values the row shows. * * The two endpoints differ only in their scope — one filters by department, the other by a list of * customer accounts — so both scopes are exercised here. @@ -160,29 +160,55 @@ describeProjection('support issue list — read-path projection', () => { ])( 'level 2 — the list can be sorted by %s', async (orderBy) => { - const { issue } = await seedIssue(); + const clerk = `sort-clerk-${orderBy}`; + // Two rows differing in every sortable column, so that the direction is observable. A single + // row would satisfy this test whichever column the query ordered by, or none. + const first = await seedIssue({ + clerk, + created: new Date('2020-01-01T00:00:00.000Z'), + updated: new Date('2020-01-01T00:00:00.000Z'), + department: Department.COMPLIANCE, + state: SupportIssueInternalState.CREATED, + }); + const second = await seedIssue({ + clerk, + created: new Date('2021-01-01T00:00:00.000Z'), + updated: new Date('2021-01-01T00:00:00.000Z'), + department: Department.SUPPORT, + state: SupportIssueInternalState.IN_PROGRESS, + }); // Every sort column has to be part of the projection: with take/skip set, the paginated form of // getManyAndCount orders a distinct-id subquery by it, and a column the select does not carry // makes Postgres reject the statement. - const list = await listOf({ clerk: issue.clerk, orderBy, orderDir: ListOrderDirection.ASC, take: 10, skip: 0 }); + const page = (orderDir: ListOrderDirection): Promise<{ data: SupportIssueListDto[]; total: number }> => + listOf({ clerk, orderBy, orderDir, take: 10, skip: 0 }); + + const ascending = await page(ListOrderDirection.ASC); + const descending = await page(ListOrderDirection.DESC); - expect(list.data).toHaveLength(1); - expect(list.total).toEqual(1); + expect(ascending.total).toEqual(2); + expect(ascending.data.map((row) => row.uid)).toEqual([first.issue.uid, second.issue.uid]); + expect(descending.data.map((row) => row.uid)).toEqual([second.issue.uid, first.issue.uid]); }, 120000, ); it('level 2 — the search matches the fields it names, on the issue and on the account', async () => { - const { issue, userData } = await seedIssue(); + // Scoped to a clerk of its own: the fixtures give every row of a column the same value, so an + // account term would otherwise match whatever else the suite has seeded by then. + const clerk = 'search-branch-clerk'; + const { issue, userData } = await seedIssue({ clerk }); + const found = async (term: string): Promise => + (await listOf({ clerk, terms: [term] })).data.map((row) => row.uid); // Each of these reaches the row through a different branch of the search predicate; the account // branches resolve through a join the projection does not select from. - expect((await listOf({ terms: [issue.name] })).data.map((r) => r.uid)).toEqual([issue.uid]); - expect((await listOf({ terms: [issue.uid] })).data.map((r) => r.uid)).toEqual([issue.uid]); - expect((await listOf({ terms: [String(issue.id)] })).data.map((r) => r.uid)).toEqual([issue.uid]); - expect((await listOf({ terms: [userData.firstname] })).data.map((r) => r.uid)).toEqual([issue.uid]); - expect((await listOf({ terms: ['no-such-term'] })).data).toHaveLength(0); + expect(await found(issue.name)).toEqual([issue.uid]); + expect(await found(issue.uid)).toEqual([issue.uid]); + expect(await found(String(issue.id))).toEqual([issue.uid]); + expect(await found(userData.firstname)).toEqual([issue.uid]); + expect(await found('no-such-term')).toHaveLength(0); }, 120000); it('level 2 — the search finds a term in the message body', async () => { @@ -216,7 +242,8 @@ describeProjection('support issue list — read-path projection', () => { it('level 2 — the id branch matches by id alone, and only for a term that fits int4', async () => { const clerk = 'id-branch-clerk'; const { issue } = await seedIssue({ name: 'no-digits-here', uid: 'uid-without-digits', clerk }); - const scoped = (term: string) => listOf({ clerk, terms: [term] }); + const scoped = (term: string): Promise<{ data: SupportIssueListDto[]; total: number }> => + listOf({ clerk, terms: [term] }); // Scoped to this issue's clerk, so the only candidate row is this one — and none of its text // fields carries a digit, so a match can only come from `issue.id = :termNId`. @@ -282,8 +309,7 @@ describeProjection('support issue list — read-path projection', () => { await seedEntity(dataSource, SupportMessage, { values: { issue } }); const projected = await listOf({ departments: [Department.SUPPORT], clerk: issue.clerk }); - // The unprojected load is the second source: every column of the row, which is what the query - // fetched before the conversion. + // The unprojected load is the second source: every column of the row. const full = await dataSource .getRepository(SupportIssue) .find({ where: { clerk: issue.clerk }, loadEagerRelations: false }); diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts index 7af77006ef..350fa25a84 100644 --- a/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts @@ -34,7 +34,7 @@ const SCHEMA = 'support_issue_view_projection_spec'; * `docs/read-path-projections.md`. * * Both answer through `SupportIssueDtoMapper.mapSupportIssue`, and both loaded whole `SupportIssue` - * rows before: 450 columns for nine values. `GET /support/issue/:id` additionally loads the message + * rows for nine values. `GET /support/issue/:id` additionally loads the message * thread, which is projected separately. * * The search condition of `GET /support/issue/:id` is the access check for this endpoint family, so @@ -190,7 +190,7 @@ describeProjection('support issue view — read-path projection', () => { const loaded = await issues.findIssueBy({ uid: issue.uid }); loaded.messages = await messages.findThread(loaded.id); - // The unprojected load is the second source: the relation set the endpoint used before. + // The unprojected load is the second source: the same relations selected whole. const full = await dataSource.getRepository(SupportIssue).findOne({ where: { uid: issue.uid }, relations: { transaction: true, limitRequest: true }, diff --git a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts index 2b5578896e..b70c8d589c 100644 --- a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts +++ b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts @@ -2,11 +2,17 @@ import { Injectable } from '@nestjs/common'; import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager, FindOptionsWhere } from 'typeorm'; -import { ListOrderDirection, SupportIssueListOrderBy } from '../dto/get-support-issue.dto'; +import { + ListOrderDirection, + SupportIssueListOrderBy, +} from 'src/subdomains/supporting/support-issue/dto/get-support-issue.dto'; import { SupportIssue } from '../entities/support-issue.entity'; -import { SupportMessage } from '../entities/support-message.entity'; -import { Department } from '../enums/department.enum'; -import { SupportIssueInternalState, SupportIssueType } from '../enums/support-issue.enum'; +import { SupportMessage } from 'src/subdomains/supporting/support-issue/entities/support-message.entity'; +import { Department } from 'src/subdomains/supporting/support-issue/enums/department.enum'; +import { + SupportIssueInternalState, + SupportIssueType, +} from 'src/subdomains/supporting/support-issue/enums/support-issue.enum'; /** The fields `CountryDtoMapper.entityToDto` reads, for a given join alias. */ const countryFields = (alias: string): string[] => @@ -136,7 +142,7 @@ export const SUPPORT_ISSUE_RESPONSE_FIELDS = [ ]; /** - * `GET /support/issue` and `GET /support/issue/:id` — 450 columns before, for nine values. + * `GET /support/issue` and `GET /support/issue/:id` — nine values. * * `supportIssue.id` is a guard rather than a response field: the mapper never shows it and no value * depends on it, but `getIssue` loads the message thread by it afterwards. @@ -154,7 +160,7 @@ export const SUPPORT_ISSUE_PROJECTION = new ReadProjection( /** * `GET /support/issue/:id/data` — the widest read path in the service. * - * The unprojected load reaches 951 columns: the issue's four eager relations expand recursively, + * The unprojected load fetches the whole graph: the issue's four eager relations expand recursively, * and the transaction pulls in both of its sides with their inputs and assets. The response is * about sixty values. */ @@ -215,7 +221,7 @@ export const SUPPORT_ISSUE_LIST_RESPONSE_FIELDS = [ ]; /** - * `GET /support/issue/list` and `GET /realunit/support/list` — 16 columns before, ten of them read. + * `GET /support/issue/list` and `GET /realunit/support/list` — the ten values the row shows. * * The six it drops are the five foreign keys and `information`, which is `text` and holds the * free-form body of the issue. The list shows a row per issue and none of it, so on a page of diff --git a/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts b/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts index 15aa7ea037..4a0dd2396b 100644 --- a/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts +++ b/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts @@ -80,7 +80,9 @@ export class SupportMessageRepository extends BaseRepository { // The newest message per issue, as a correlated subquery. One factory for the two columns the // row shows, so the two subqueries cannot drift apart. const lastOf = - (column: 'created' | 'author') => + ( + column: 'created' | 'author', + ): ((sub: SelectQueryBuilder) => SelectQueryBuilder) => (sub: SelectQueryBuilder): SelectQueryBuilder => sub .select(`m2.${column}`) From b5b1a55a08e9b3d949f91b2b67da2d9d49e71621 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:11:31 +0200 Subject: [PATCH 33/46] Close the second review round on the guard and the assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps in the guard, all of which let it answer wrongly: - The embedded proxy had no `set` trap while the root one did, and a fresh proxy is handed out on every access. A column the caller assigned inside an embedded object therefore threw when it was read back, although the same write on the root is explicitly allowed. Both now share one set of written paths. - The `@RelationId` rule threw on the declaration rather than on the value. It is guarded on the value now: the failure is for the silent `undefined`, and a value that did arrive is not a defect whatever filled it. - `seedEntity` accepted a pinned embedded path, suppressed the generated value for it and then assigned it as a flat key, leaving the embedded field unset. Pinned values go through the same path assignment as generated ones. The controller scan missed handlers. It matched the first identifier after the colon, so `Promise` and every wrapper form fell through — the expensive direction, because a handler it misses is one whose answer the closure then fails to cover. It reads the whole return type now and takes every entity name in it: 36 entities rather than 34, and nothing the compiler finds is missed. `createProjectionDataSource` leaked its connection when the schema could not be prepared. The caller never receives the instance in that case, so nothing could close it afterwards. Assertions that could not fail: - The sort test's expected order matched the insertion order, so a query falling back to the id tie-break passed it; the clerk column was pinned to one value by the scope it used. The rows now sort in reverse of their insertion order and the scope is by account. - The API key determinism check compared an expression with itself. It compares against an independently loaded row. - The column-preservation test drove its own update rather than the production method. Comments: what remained of the earlier state — "the query joined", "reached it through", "no test reached them until now" — is gone, along with two universal claims about mocks and about generated values being unique, which the truncation to a declared length does not hold for. One error message was lower-cased. The point-of-sale configuration is read through `PaymentLink.accessConfig` and no longer names an unnamed path in its comment. Rejected, with the reason: `scoped == null` stays. The replaced code used the same form, `=== undefined` would treat an explicit null differently, and 370 places in this repository use it against 86 of the strict form. And the two `leftJoin`s that a `WHERE` makes null-rejecting stay left joins — Postgres plans those as inner joins, so the suggested change buys nothing and costs a second join form. --- .../eager-relations.projection.spec.ts | 19 +++-- .../projection-guard.projection.spec.ts | 63 ++++++++++++++++ src/shared/utils/projection-test.util.ts | 71 +++++++++++++------ .../ledger-suspense.projection.spec.ts | 3 +- .../repositories/ledger-leg.repository.ts | 3 +- .../buy-crypto-history.projection.spec.ts | 9 ++- .../process/dto/buy-crypto-history.mapper.ts | 2 +- .../custody-order-history.projection.spec.ts | 4 +- .../repositories/custody-order.repository.ts | 4 +- .../pipeline-status.projection.spec.ts | 4 +- ...iquidity-management-pipeline.repository.ts | 2 +- .../__tests__/pos-link.projection.spec.ts | 6 +- .../entities/payment-link.entity.ts | 6 +- .../process/dto/buy-fiat-history.mapper.ts | 8 +-- .../models/kyc/dto/kyc-data-dto.mapper.ts | 2 +- .../__tests__/api-key.projection.spec.ts | 10 ++- .../support-issue-list.projection.spec.ts | 35 +++++---- .../support-issue-view.projection.spec.ts | 2 +- .../support-message.repository.ts | 3 +- .../__tests__/support-issue.service.spec.ts | 7 +- 20 files changed, 185 insertions(+), 78 deletions(-) diff --git a/src/shared/models/__tests__/eager-relations.projection.spec.ts b/src/shared/models/__tests__/eager-relations.projection.spec.ts index 6734c667cc..b09fbe51f3 100644 --- a/src/shared/models/__tests__/eager-relations.projection.spec.ts +++ b/src/shared/models/__tests__/eager-relations.projection.spec.ts @@ -36,13 +36,18 @@ function entitiesReturnedWhole(entities: Set): Map { const found = new Map(); for (const path of controllers) { const source = readFileSync(path, 'utf8'); - for (const match of source.matchAll(/\)\s*:\s*(?:Promise<\s*)?([A-Za-z0-9_]+)(?:\[\])?\s*[>{]/g)) { - const name = match[1]; - if (!entities.has(name)) continue; - const file = path.slice(SRC.length + 1); - const where = found.get(name) ?? []; - if (!where.includes(file)) where.push(file); - found.set(name, where); + // The whole return type, then every entity name in it: `Promise`, `Promise` + // and any wrapper around them all have to count. Matching the first identifier after the colon + // reads more simply and silently misses the union forms — the expensive direction, because a + // handler it misses is one whose answer the closure below then fails to cover. + for (const match of source.matchAll(/\)\s*:\s*([^;{]+?)\s*\{/g)) { + for (const identifier of match[1].match(/[A-Za-z0-9_]+/g) ?? []) { + if (!entities.has(identifier)) continue; + const file = path.slice(SRC.length + 1); + const where = found.get(identifier) ?? []; + if (!where.includes(file)) where.push(file); + found.set(identifier, where); + } } } diff --git a/src/shared/utils/__tests__/projection-guard.projection.spec.ts b/src/shared/utils/__tests__/projection-guard.projection.spec.ts index a55e44eafe..59b147d496 100644 --- a/src/shared/utils/__tests__/projection-guard.projection.spec.ts +++ b/src/shared/utils/__tests__/projection-guard.projection.spec.ts @@ -112,6 +112,69 @@ describeProjection('guardProjection', () => { expect(() => input.address.blockchain).toThrow("read of 'CryptoInput.address.blockchain'"); }, 120000); + it('leaves a @RelationId alone when the query did fill it', async () => { + // Guarded on the value, not on the declaration: the throw is for the silent `undefined`, and a + // value that did arrive is not a defect whatever filled it. + const leg = guardProjection( + dataSource, + LedgerLeg, + new ReadProjection('leg', [], ['leg.id']), + ['leg.id'], + { id: 1, txId: 7 } as LedgerLeg, + ); + + expect(leg.txId).toEqual(7); + }, 120000); + + it('lets an embedded column be read back after the caller assigned it', async () => { + const projection = new ReadProjection('input', [], ['input.id']); + const input = guardProjection(dataSource, CryptoInput, projection, projection.fields, { + id: 1, + address: {}, + } as unknown as CryptoInput); + + input.address.address = 'written-by-the-caller'; + + // Without the write being recorded this throws, because the query never selected the column. + expect(input.address.address).toEqual('written-by-the-caller'); + }, 120000); + + it.each(['getMany', 'getOneOrFail'] as const)( + 'guards the rows of %s', + async (method) => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const query = PROJECTION.apply(dataSource.getRepository(UserData).createQueryBuilder('userData'), FIELDS).where( + 'userData.id = :id', + { id: seeded.id }, + ); + const rows = await query[method](); + const row = Array.isArray(rows) ? rows[0] : rows; + + expect(() => row.surname).toThrow("read of 'UserData.surname'"); + }, + 120000, + ); + + it('guards the entities of getRawAndEntities and leaves the raw rows untouched', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const { entities, raw } = await PROJECTION.apply( + dataSource.getRepository(UserData).createQueryBuilder('userData'), + FIELDS, + ) + .where('userData.id = :id', { id: seeded.id }) + .getRawAndEntities(); + + expect(() => entities[0].surname).toThrow("read of 'UserData.surname'"); + // The raw half is what the caller asked the database for, not an entity — reading it is not a + // projection question and the guard must not touch it. + expect(raw).toHaveLength(1); + expect(Object.keys(raw[0]).length).toBeGreaterThan(0); + }, 120000); + it('guards the rows of getManyAndCount, which runs its own query', async () => { const language = await seedEntity(dataSource, Language); await seedEntity(dataSource, UserData, { values: { language } }); diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts index ffb155ca41..ceaeb8019d 100644 --- a/src/shared/utils/projection-test.util.ts +++ b/src/shared/utils/projection-test.util.ts @@ -5,9 +5,9 @@ import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; /** * Test support for the read-path projections described in `docs/read-path-projections.md`. * - * All of it needs a real database. A mocked repository returns whatever the mock defines and cannot - * observe which columns were requested, so it can test none of the four levels — which is why the - * suite skips when `MIGRATION_TEST_PG` is unset, the same gate the migration specs use. + * All of it needs a real database: these specs assert what TypeORM selects and how it hydrates the + * result, which is what a projection can get wrong. The suite skips when `MIGRATION_TEST_PG` is + * unset, the same gate the migration specs use. * * The schema comes from the entity metadata via `synchronize`, not from replayed migrations: the * reference a projection has to be complete against is the entity definition. @@ -27,10 +27,15 @@ export const describeProjection = PROJECTION_TEST_PG ? describe : describe.skip; export async function createProjectionDataSource(schema: string): Promise { const bootstrap = new DataSource({ type: 'postgres', url: PROJECTION_TEST_PG, logging: false }); await bootstrap.initialize(); - // Recreate rather than reuse: a schema left behind by an earlier run may predate the entities. - await bootstrap.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); - await bootstrap.query(`CREATE SCHEMA "${schema}"`); - await bootstrap.destroy(); + try { + // Recreate rather than reuse: a schema left behind by an earlier run may predate the entities. + await bootstrap.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); + await bootstrap.query(`CREATE SCHEMA "${schema}"`); + } finally { + // Closed even when the schema could not be prepared: the caller never receives this instance, + // so `afterAll` has nothing to close and the connection would outlive the run. + await bootstrap.destroy(); + } const dataSource = new DataSource({ type: 'postgres', @@ -42,7 +47,13 @@ export async function createProjectionDataSource(schema: string): Promise; } -// One counter for the whole process. Every generated number, date and string is distinct, which is -// what keeps unique constraints satisfied when a spec seeds the same entity twice. Booleans and +// One counter for the whole process, so that generated numbers, dates and strings differ between +// rows — which is what keeps unique constraints satisfied when a spec seeds the same entity twice. +// A very short column is the limit: the counter is base-36 encoded and truncated to the declared +// length, so a `varchar(1)` repeats after 36 rows and such a column has to be pinned. Booleans and // enums cannot be: a boolean has two values and an enum as many as it declares, so a spec that needs // to tell two such columns apart pins them in the fixture. What every generated value is, is // non-empty — which is what makes an empty field in a response proof that the query failed to load @@ -191,7 +204,11 @@ export async function seedEntity( setPath(entity, column.propertyPath, generatedValue(column, nextSeed())); } - Object.assign(entity, spec.values ?? {}); + // Through `setPath` rather than `Object.assign`: `isPinned` accepts a full embedded path, and a + // flat `'address.address'` key would leave the embedded field itself unset while suppressing the + // generated value for it. A key without a dot is assigned exactly as before. + for (const [key, value] of Object.entries(spec.values ?? {})) setPath(entity, key, value); + return dataSource.getRepository(target).save(entity as E); } @@ -419,18 +436,28 @@ function guardAgainst( * An embedded object, guarded one level down. * * Its columns are addressed by their full path (`address.city`), so the same selection set - * answers for them; only the walk to reach them differs. + * answers for them; only the walk to reach them differs. `written` is the root's set and holds + * the same full paths, so a value the caller assigned reads back here as it does on the root — + * a fresh proxy is handed out on every access, and it would otherwise have nowhere to remember. */ - const wrapEmbedded = (value: ObjectLiteral, at: Node, prefix: string): ObjectLiteral => + const wrapEmbedded = (value: ObjectLiteral, at: Node, prefix: string, written: Set): ObjectLiteral => value == null ? value : new Proxy(value, { + set(source, property, assigned, receiver) { + written.add(`${prefix}.${String(property)}`); + return Reflect.set(source, property, assigned, receiver); + }, get(source, property, receiver) { const path = `${prefix}.${String(property)}`; const inner = Reflect.get(source, property, receiver); - if (isEmbedded(at, path)) return wrapEmbedded(inner as ObjectLiteral, at, path); - if (at.metadata.columns.some((column) => column.propertyPath === path) && !at.asked.has(path)) { + if (isEmbedded(at, path)) return wrapEmbedded(inner as ObjectLiteral, at, path, written); + if ( + at.metadata.columns.some((column) => column.propertyPath === path) && + !at.asked.has(path) && + !written.has(path) + ) { throw new Error( `read of '${at.metadata.name}.${path}', which this query did not select — ` + `add it to the projection, or stop reading it`, @@ -467,17 +494,21 @@ function guardAgainst( // reports as a column under the relation's own property name. if (at.metadata.relations.some((relation) => relation.propertyName === name)) return value; - // A `@RelationId` is filled from the foreign-key column of the row, which a query naming - // its fields does not carry. No field list can select it, so reading one off a projected - // row is always the defect — read the id off the joined relation instead. - if (at.metadata.relationIds.some((relationId) => relationId.propertyName === name) && !written.has(name)) { + // A `@RelationId` is filled from the foreign-key column of the row, which a query naming its + // fields does not carry, and no field list can select it. Guarded on the value rather than + // on the declaration: if it did arrive filled, reading it is not a defect. + if ( + value === undefined && + at.metadata.relationIds.some((relationId) => relationId.propertyName === name) && + !written.has(name) + ) { throw new Error( `read of '${at.metadata.name}.${name}', a @RelationId that a projected query never fills — ` + `join the relation and read the id off it`, ); } - if (isEmbedded(at, name)) return wrapEmbedded(value as ObjectLiteral, at, name); + if (isEmbedded(at, name)) return wrapEmbedded(value as ObjectLiteral, at, name, written); if ( at.metadata.columns.some((column) => column.propertyPath === name) && diff --git a/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts b/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts index bac48c171a..a85860dbb2 100644 --- a/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts +++ b/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts @@ -26,8 +26,7 @@ const SCHEMA = 'ledger_suspense_projection_spec'; * `GET /dashboard/accounting/ledger/suspense` — the four levels from * `docs/read-path-projections.md`. * - * The query joined the transaction and the account with `innerJoinAndSelect`, loading both whole - * for four values and a currency. + * The response is four values and a currency, drawn from the leg, its transaction and its account. */ describeProjection('ledger suspense — read-path projection', () => { let dataSource: DataSource; diff --git a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts index 094a0f2e6e..8e91bda7e8 100644 --- a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts +++ b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts @@ -24,8 +24,7 @@ export const SUSPENSE_LEG_RESPONSE_FIELDS = [ /** * `GET /dashboard/accounting/ledger/suspense`. * - * The query joined the transaction and the account with `innerJoinAndSelect`, which loads each of - * them whole for four values and a currency. + * The transaction and the account are joined for four values and a currency. * * `account.id` is a guard: the response never shows it, but without a primary key the ORM cannot * materialise the joined row. diff --git a/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts b/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts index af547440f9..4bf40e63ba 100644 --- a/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts +++ b/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts @@ -28,8 +28,7 @@ const SCHEMA = 'buy_crypto_history_projection_spec'; * `GET /buy/:id/history` and `GET /swap/:id/history` — the four levels from * `docs/read-path-projections.md`. * - * Both answer a `HistoryDtoDeprecated[]` built by the same mapper, and both reached it through a - * `find` that loads whole `BuyCrypto` rows for the ten values the + * Both answer a `HistoryDtoDeprecated[]` built by the same mapper, over the ten values the * mapper reads. They differ only in the route they filter by — `buy` for one, `cryptoRoute` for the * other — which is why there are two projections over one field list. */ @@ -50,7 +49,7 @@ describeProjection('buy-crypto history — read-path projection', () => { * A completed transaction on a buy route, with every column of every entity populated. * * `blockchain` and `status` are set explicitly: both are TypeScript enums stored in text columns, - * so a generated value would be a string no mapper knows, and the response would look incomplete + * so a generated value would not be a member of the enum the mapper switches on, and the response would look incomplete * for a reason that has nothing to do with the projection. */ async function seedBuyCrypto( @@ -154,8 +153,8 @@ describeProjection('buy-crypto history — read-path projection', () => { }, 120000); it('level 2 — both filters are needed to select the right swap transactions', async () => { - // The same for the swap route: its own query, its own two predicates, and no test reached them - // until now — the case above only ever called `findBuyHistory`. + // The same for the swap route: its own query and its own two predicates, which the case above + // does not reach — that one calls `findBuyHistory`. const mine = await seedSwapCrypto(); const second = await seedSecondSwapRouteFor(mine.user); const other = await seedSwapCrypto(); diff --git a/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts b/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts index 3a24e26ed1..c83e83f66b 100644 --- a/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts +++ b/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts @@ -5,7 +5,7 @@ import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-c /** * The history entry `GET /buy/:id/history` and `GET /swap/:id/history` answer with. * - * Kept here rather than in `BuyCryptoService` so that the projection spec can drive the same mapping the + * Its own mapper so that the projection spec can drive the same mapping the * endpoints use. A copy in the spec could be wrong in exactly the way the projection is wrong and * would prove nothing. * diff --git a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts index 5ef573cf86..a51b53d81e 100644 --- a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts +++ b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts @@ -27,8 +27,8 @@ const SCHEMA = 'custody_order_history_projection_spec'; /** * `GET /custody/order` — the four levels from `docs/read-path-projections.md`. * - * The query joined both assets and the transaction request with `leftJoinAndSelect`, loading each - * of them whole — 19 columns for two names and two amounts. + * The query joins both assets and the transaction request, which the response draws two names and + * two amounts from. */ describeProjection('GET /custody/order — read-path projection', () => { let dataSource: DataSource; diff --git a/src/subdomains/core/custody/repositories/custody-order.repository.ts b/src/subdomains/core/custody/repositories/custody-order.repository.ts index bdcd334032..3a6fec40da 100644 --- a/src/subdomains/core/custody/repositories/custody-order.repository.ts +++ b/src/subdomains/core/custody/repositories/custody-order.repository.ts @@ -23,8 +23,8 @@ export const CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS = [ /** * `GET /custody/order` — a user's order history. * - * The query joined the two assets and the transaction request with `leftJoinAndSelect`, which loads - * each of them whole: 19 columns for the two names and two amounts the response shows. + * The two assets and the transaction request are joined for the two names and two amounts the + * response shows. */ export const CUSTODY_ORDER_HISTORY_PROJECTION = new ReadProjection( 'custodyOrder', diff --git a/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts b/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts index bf1399686c..91c05c5ca2 100644 --- a/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts +++ b/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts @@ -23,8 +23,8 @@ const SCHEMA = 'pipeline_status_projection_spec'; * `GET /liquidityManagement/pipeline/:id/status` — the four levels from * `docs/read-path-projections.md`. * - * The endpoint answers with one string. Asking for the row by id fetched every column of it: the - * pipeline expands its rule and both of its action relations eagerly, and the rule expands its own. + * The endpoint answers with one string, and the pipeline expands its rule and both of its action + * relations eagerly — so asking for the row by id reaches all of them. */ describeProjection('liquidity management pipeline status — read-path projection', () => { let dataSource: DataSource; diff --git a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts index d486125b73..701b4b61ff 100644 --- a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts +++ b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts @@ -11,7 +11,7 @@ export const PIPELINE_STATUS_RESPONSE_FIELDS = ['pipeline.status']; * `GET /liquidityManagement/pipeline/:id/status` — one column, for one value. * * The pipeline expands its rule and its current action eagerly, and the rule pulls in its asset and - * its currency, so asking for the row by id fetched the whole graph to read a status string. + * its currency — the graph a plain lookup by id reaches, to read a status string. */ export const PIPELINE_STATUS_PROJECTION = new ReadProjection( 'pipeline', diff --git a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts index 529591b14f..617ff11589 100644 --- a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts +++ b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts @@ -35,8 +35,8 @@ const SCHEMA = 'pos_link_projection_spec'; /** * `PUT /paymentLink/:id/pos` — the four levels from `docs/read-path-projections.md`. * - * The link was loaded with its route, its user, the account and the account's organization: 513 - * columns for a URL built from one identifier and one access key. + * The answer is a URL built from one identifier and one access key. Reached without a field list, + * the link pulls in its route, its user, the account and the account's organization. * * The endpoint is driven through `PaymentLinkService.createPosLinkAdmin` rather than through a * rebuilt query, so what these levels compare is the answer the endpoint gives. Its collaborators @@ -120,7 +120,7 @@ describeProjection('point-of-sale link — read-path projection', () => { */ const keyOf = (url: string): string => { const key = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('key'); - if (key == null) throw new Error(`no key in ${url}`); + if (key == null) throw new Error(`No key in ${url}`); return key; }; diff --git a/src/subdomains/core/payment-link/entities/payment-link.entity.ts b/src/subdomains/core/payment-link/entities/payment-link.entity.ts index d379b84755..1e81e45018 100644 --- a/src/subdomains/core/payment-link/entities/payment-link.entity.ts +++ b/src/subdomains/core/payment-link/entities/payment-link.entity.ts @@ -109,9 +109,9 @@ export class PaymentLink extends IEntity { * account, or merged with the link winning. * * Separate from `configObj` because that one also assembles the recipient — name, contact data - * and address of the account — which this path discards, and reading those columns is the only - * reason a query would have to load them. Each side is read lazily: both getters parse their own - * JSON column, and a scoped call must not fail on the column it does not use. + * and address of the account — which `PaymentLinkService.createPosLinkFor` discards. Each side is + * read lazily: both getters parse their own JSON column, and a scoped call must not fail on the + * column it does not use. */ accessConfig(scoped?: boolean): PaymentLinkConfig { const account = (): PaymentLinkConfig => this.route.userData.paymentLinksConfigObj; diff --git a/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts b/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts index e0d13af2cc..db91992903 100644 --- a/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts +++ b/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts @@ -6,12 +6,12 @@ import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity /** * The history entry `GET /sell/:id/history` answers with. * - * Kept here rather than in `BuyFiatService` so that the projection spec can drive the same mapping the endpoint + * Its own mapper so that the projection spec can drive the same mapping the endpoint * uses — see `BuyCryptoHistoryMapper` for the reasoning. * - * Note that `cryptoInput` and `outputAsset` are read without a guard here. That is the behaviour as - * it stands, and the projection does not change it: `cryptoInput` is a non-nullable relation, and a - * row whose `outputAsset` is still unset would have thrown before the conversion just the same. + * Note that `cryptoInput` and `outputAsset` are read without a guard here: `cryptoInput` is a + * non-nullable relation, and a row whose `outputAsset` is unset throws — which the projection + * leaves as it is. */ export class BuyFiatHistoryMapper { static toDto(buyFiat: BuyFiat): SellHistoryDto { diff --git a/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts b/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts index 15d2e7c101..fc6795cc86 100644 --- a/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts +++ b/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts @@ -5,7 +5,7 @@ import { KycDataDto } from 'src/subdomains/generic/user/models/kyc/dto/kyc-data. /** * The per-user entry `GET /kyc/users` answers with. * - * Kept here rather than in `KycService` so the projection spec can drive the same mapping the endpoint uses; a + * Its own mapper so the projection spec can drive the same mapping the endpoint uses; a * copy in the spec could be wrong in exactly the way the projection is wrong. */ export class KycDataDtoMapper { diff --git a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts index 7786c336f2..a5f4fe1e48 100644 --- a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts +++ b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts @@ -162,9 +162,13 @@ describeProjection('API key — read-path projection', () => { expect(loaded.apiKeyCT).toMatch(/^[0-9A-F]+$/); expect(ApiKeyService.getSecret(loaded)).toMatch(/^[0-9A-F]{64}$/); - // The same account and the same key must always produce the same secret; the creation date is - // the other input, and it comes out of the projection. - expect(ApiKeyService.getSecret(loaded)).toEqual(ApiKeyService.getSecret(loaded)); + // The same account and the same key must produce the same secret through an independently + // loaded row: comparing the projected row with itself would hold whatever the projection left + // out. The creation date is the second input, and it comes out of the projection. + const full = await dataSource.getRepository(UserData).findOneBy({ id: account.id }); + full.apiKeyCT = loaded.apiKeyCT; + + expect(ApiKeyService.getSecret(loaded)).toEqual(ApiKeyService.getSecret(full)); }, 120000); it('derives the secret from the creation date as well as the key', async () => { diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts index f7d24ac931..c0659f97a4 100644 --- a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts @@ -37,8 +37,8 @@ const SCHEMA = 'support_issue_list_projection_spec'; * `GET /support/issue/list` and `GET /realunit/support/list` — the four levels from * `docs/read-path-projections.md`. * - * Both answer through `SupportIssueDtoMapper.mapSupportIssueListItem` and both loaded whole - * `SupportIssue` rows for the ten values the row shows. + * Both answer through `SupportIssueDtoMapper.mapSupportIssueListItem`, over the ten values the row + * shows. * * The two endpoints differ only in their scope — one filters by department, the other by a list of * customer accounts — so both scopes are exercised here. @@ -160,36 +160,41 @@ describeProjection('support issue list — read-path projection', () => { ])( 'level 2 — the list can be sorted by %s', async (orderBy) => { + // Two rows whose order under every sortable column is the REVERSE of their insertion order, + // so that a query falling back to the id tie-break fails instead of passing by coincidence. + // The clerk differs too and the scope is by account, because ordering by a column the scope + // pins to one value proves nothing. const clerk = `sort-clerk-${orderBy}`; - // Two rows differing in every sortable column, so that the direction is observable. A single - // row would satisfy this test whichever column the query ordered by, or none. const first = await seedIssue({ - clerk, - created: new Date('2020-01-01T00:00:00.000Z'), - updated: new Date('2020-01-01T00:00:00.000Z'), - department: Department.COMPLIANCE, - state: SupportIssueInternalState.CREATED, - }); - const second = await seedIssue({ - clerk, + clerk: `${clerk}-b`, created: new Date('2021-01-01T00:00:00.000Z'), updated: new Date('2021-01-01T00:00:00.000Z'), department: Department.SUPPORT, state: SupportIssueInternalState.IN_PROGRESS, }); + const second = await seedIssue({ + clerk: `${clerk}-a`, + created: new Date('2020-01-01T00:00:00.000Z'), + updated: new Date('2020-01-01T00:00:00.000Z'), + department: Department.COMPLIANCE, + state: SupportIssueInternalState.CREATED, + }); // Every sort column has to be part of the projection: with take/skip set, the paginated form of // getManyAndCount orders a distinct-id subquery by it, and a column the select does not carry // makes Postgres reject the statement. + const customerIds = [first.userData.id, second.userData.id]; const page = (orderDir: ListOrderDirection): Promise<{ data: SupportIssueListDto[]; total: number }> => - listOf({ clerk, orderBy, orderDir, take: 10, skip: 0 }); + listOf({ customerIds, orderBy, orderDir, take: 10, skip: 0 }); const ascending = await page(ListOrderDirection.ASC); const descending = await page(ListOrderDirection.DESC); expect(ascending.total).toEqual(2); - expect(ascending.data.map((row) => row.uid)).toEqual([first.issue.uid, second.issue.uid]); - expect(descending.data.map((row) => row.uid)).toEqual([second.issue.uid, first.issue.uid]); + // Ascending puts the second-inserted row first: the assertion holds only if the requested + // column decided the order. + expect(ascending.data.map((row) => row.uid)).toEqual([second.issue.uid, first.issue.uid]); + expect(descending.data.map((row) => row.uid)).toEqual([first.issue.uid, second.issue.uid]); }, 120000, ); diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts index 350fa25a84..df6a7ab1ae 100644 --- a/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts @@ -33,7 +33,7 @@ const SCHEMA = 'support_issue_view_projection_spec'; * `GET /support/issue` and `GET /support/issue/:id` — the four levels from * `docs/read-path-projections.md`. * - * Both answer through `SupportIssueDtoMapper.mapSupportIssue`, and both loaded whole `SupportIssue` + * Both answer through `SupportIssueDtoMapper.mapSupportIssue`, over the `SupportIssue` * rows for nine values. `GET /support/issue/:id` additionally loads the message * thread, which is projected separately. * diff --git a/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts b/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts index 4a0dd2396b..d36d4bcaa4 100644 --- a/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts +++ b/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts @@ -31,8 +31,7 @@ export interface SupportMessageStats { /** * The message thread of an issue. * - * Loaded on its own rather than as a relation, which is what the endpoint already did — the - * projection only narrows the columns. + * Loaded on its own rather than as a relation of the issue. */ export const SUPPORT_MESSAGE_PROJECTION = new ReadProjection( 'supportMessage', diff --git a/src/subdomains/supporting/support-issue/services/__tests__/support-issue.service.spec.ts b/src/subdomains/supporting/support-issue/services/__tests__/support-issue.service.spec.ts index 1d8e5463d2..ab97a12f88 100644 --- a/src/subdomains/supporting/support-issue/services/__tests__/support-issue.service.spec.ts +++ b/src/subdomains/supporting/support-issue/services/__tests__/support-issue.service.spec.ts @@ -39,13 +39,16 @@ import { SupportLogService } from 'src/subdomains/supporting/support-issue/servi import { REALUNIT_WALLET_NAME } from 'src/subdomains/supporting/notification/realunit-mail-rules'; import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; import { CreateSupportIssueDto } from 'src/subdomains/supporting/support-issue/dto/create-support-issue.dto'; -import { SupportIssueDto } from 'src/subdomains/supporting/support-issue/dto/support-issue.dto'; +import { SupportIssueDto, SupportIssueListDto } from 'src/subdomains/supporting/support-issue/dto/support-issue.dto'; describe('SupportIssueService.getSupportIssueList', () => { let service: SupportIssueService; let supportIssueRepo: DeepMocked; - const run = (filter: Partial, role: UserRole = UserRole.ADMIN) => + const run = ( + filter: Partial, + role: UserRole = UserRole.ADMIN, + ): Promise<{ data: SupportIssueListDto[]; total: number }> => service.getSupportIssueList(filter as GetSupportIssueListFilter, role); // What the service resolved the request into. The query itself is built and tested against a real From e20e4cf1f5aea4b4faf7dbf81a91dbf66e334e11 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:37:46 +0200 Subject: [PATCH 34/46] Cut the comments that repeat the document they point at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine block comments restated `docs/read-path-projections.md` next to the code: why the guard exists, why level 3 compares responses instead of emptiness, why the field list is a value. That belongs in one place, and repeating it is how the two drift apart. Each keeps the part a reader of this file needs and points at the document for the rest — 68 lines shorter, no assertion removed. Measured rather than assumed, because the first plan was to cut much further: of the comments this branch adds, the ones on the field lists say why a given column is in them, and the ones in the specs say which branch a fixture reaches. Neither is recoverable from the code, so the remaining volume stays. --- .../eager-relations.projection.spec.ts | 14 ++- src/shared/models/read-projection.ts | 15 ++-- src/shared/utils/projection-test.util.ts | 85 ++++++------------- .../repositories/ledger-leg.repository.ts | 8 +- .../__tests__/pos-link.projection.spec.ts | 11 +-- .../repositories/payment-link.repository.ts | 15 +--- .../__tests__/api-key.projection.spec.ts | 9 +- .../user/__tests__/user-v2.projection.spec.ts | 11 +-- 8 files changed, 50 insertions(+), 118 deletions(-) diff --git a/src/shared/models/__tests__/eager-relations.projection.spec.ts b/src/shared/models/__tests__/eager-relations.projection.spec.ts index b09fbe51f3..7630b5b6d5 100644 --- a/src/shared/models/__tests__/eager-relations.projection.spec.ts +++ b/src/shared/models/__tests__/eager-relations.projection.spec.ts @@ -11,16 +11,12 @@ const SCHEMA = 'eager_relations_spec'; const SRC = join(__dirname, '../../..'); /** - * The entities that leave through a controller as themselves, read out of the source. + * The entities that leave through a controller as themselves — for those, the eager relations are + * the response rather than a loading detail. * - * For those the eager relations are not a loading detail — they are the answer. A relation added - * anywhere in their closure appears in the response; one removed disappears from it. - * - * Read rather than listed, because a list goes stale the first time someone adds a controller and - * nothing says so. It is deliberately generous: any method in a controller file whose return type - * names an entity counts, decorated or not. A method that is not in fact a handler widens the - * closure below and costs precision in the failure message; missing a handler would cost the - * guarantee. + * Read out of the source rather than listed, so that adding a controller cannot narrow the closure + * silently. Deliberately generous: any method in a controller file whose return type names an + * entity counts. An over-match costs precision in the message, a miss costs the guarantee. */ function entitiesReturnedWhole(entities: Set): Map { const controllers: string[] = []; diff --git a/src/shared/models/read-projection.ts b/src/shared/models/read-projection.ts index 0c2f7fa597..281631db16 100644 --- a/src/shared/models/read-projection.ts +++ b/src/shared/models/read-projection.ts @@ -3,17 +3,12 @@ import { SelectQueryBuilder } from 'typeorm'; /** * An explicit field list for a read path, together with the joins it needs. * - * Two reasons this is a value rather than a chain of `.leftJoin().select()` calls at the call site: + * A value rather than a chain of calls at the call site, so that the mutation test can re-run the + * production query with one field removed instead of rebuilding it. `select` inside `find` options + * would not do: it narrows the root entity but still pulls in the eager relations. * - * 1. `select` inside `find` options narrows the root entity but still pulls in the eager relations, - * so a projection has to go through a query builder. Wrapping that keeps the call sites short. - * 2. The mutation test (level 3 in `docs/read-path-projections.md`) has to run the *same* query with - * one field removed. With the field list as data, the test drives the production code path - * instead of rebuilding the query — a second implementation could be wrong in the same way and - * would prove nothing. - * - * Field names are the ones the query builder expects: `alias.property`, where `alias` is either the - * root alias or one declared in `joins`. + * Field names are what the query builder expects: `alias.property`, where `alias` is the root alias + * or one declared in `joins`. */ export class ReadProjection { constructor( diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts index ceaeb8019d..e9a4217298 100644 --- a/src/shared/utils/projection-test.util.ts +++ b/src/shared/utils/projection-test.util.ts @@ -5,12 +5,9 @@ import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; /** * Test support for the read-path projections described in `docs/read-path-projections.md`. * - * All of it needs a real database: these specs assert what TypeORM selects and how it hydrates the - * result, which is what a projection can get wrong. The suite skips when `MIGRATION_TEST_PG` is - * unset, the same gate the migration specs use. - * - * The schema comes from the entity metadata via `synchronize`, not from replayed migrations: the - * reference a projection has to be complete against is the entity definition. + * Needs a real database and skips without `MIGRATION_TEST_PG`, the gate the migration specs use. + * The schema is built from the entity metadata, which is what a projection has to be complete + * against. */ export const PROJECTION_TEST_PG = process.env.MIGRATION_TEST_PG; @@ -69,12 +66,9 @@ export interface SeedSpec { /** * Fixed column values. Anything not listed gets a generated one. * - * Needed for every column that holds a TypeScript enum in a plain text column — most of them in - * this schema. The metadata reports those as `varchar`, so the generator produces a distinct - * string that is not a member of the enum, and a mapper looking the value up (`PaymentStatusMapper[…]`, - * `txExplorerUrl(blockchain, …)`) answers `undefined`. That reads exactly like a missing column, - * which is why the completeness assertion catches it — set the value here instead, and cover the - * other members as level-2 variants. + * Pin every column holding a TypeScript enum in a text column: the metadata reports it as + * `varchar`, so the generated value is not a member and a mapper looking it up answers + * `undefined` — indistinguishable from a missing column. */ values?: ObjectLiteral; /** Optional relations to populate. Required ones are always populated. */ @@ -215,17 +209,11 @@ export async function seedEntity( /** * Level 1 — with a fully populated fixture, no field of the response may be empty. * - * Walks nested objects and arrays. `undefined`, `null`, `''` and `NaN` count as empty; `0` and - * `false` do not, because they are legitimate values a projection can load correctly. - * - * `NaN` belongs on that list even though nothing produces it directly: a DTO field computed as - * `a + b + c` from three columns turns into `NaN` the moment one of them is missing. It is not - * absent, so a plain `undefined` check waves it through — and it is exactly the silent wrong value - * this whole exercise is meant to catch. The annual volume on the support view is such a field. + * `undefined`, `null`, `''` and `NaN` count as empty; `0` and `false` do not. `NaN` is on the list + * because a field computed as `a + b + c` becomes `NaN` as soon as one column is missing. * - * `optional` lists paths that are allowed to be empty for the fixture at hand — a field the DTO - * only fills for one branch. Every entry is a statement that the *other* branch covers it, which is - * what level 2 is for, so keep the list short and cover the counterpart. + * `optional` lists paths allowed to be empty for the fixture at hand; each entry asserts that + * another variant covers the field. */ export function expectNoEmptyFields(value: unknown, optional: string[] = [], path = ''): void { const empty = @@ -257,27 +245,14 @@ export function projectionFieldsWithout(fields: ReadonlyArray, omitted: } /** - * Level 3 — removing any candidate from the projection must **change the response**. - * - * `run` receives the fields to leave out, runs the same production query without them and returns - * the response. The caller does the reducing, because which fields feed the response can depend on - * the fixture: `UserData.address` reads the organization's address for a business account and the - * account's own for a personal one, so each variant asserts over its own set of candidates while the - * rest of the projection stays in the query. - * - * **"Changes the response", not "empties a field".** The weaker form misses the failure this whole - * exercise is about. `getKycWebhookStatus(kycStatus, kycType)` answers `NA` when it is handed - * nothing — a perfectly valid value — so dropping `kycStatus` leaves a complete response that is - * simply wrong, and an emptiness check waves it through. Comparing against the response the full - * projection produced catches it, and it is the same standard level 4 applies. + * Level 3 — removing any candidate from the projection must change the response. * - * **A candidate may be a group of fields.** Where several columns feed one response value through a - * fallback — `organizationName ?? firstname + surname` — dropping the group is what shows the value - * depends on it at all; each column on its own is covered by the variant in which it is the one that - * gets read. + * `run` receives the fields to leave out and returns what the production query answers without + * them. The caller picks the candidates: which fields feed a response depends on the fixture, and + * columns behind a fallback have to be dropped as a group. * - * A candidate whose removal changes nothing is either unnecessary or a gap in the fixture; both need - * looking at, so this reports the names rather than just failing. + * Compared against the full response rather than checked for emptiness — a missing column can + * yield a valid-looking wrong value. Candidates whose removal changes nothing are reported by name. */ export async function expectEveryFieldRequired( candidates: ReadonlyArray, @@ -325,28 +300,16 @@ export function allColumnNames(metadata: EntityMetadata): string[] { } /** - * Makes an incomplete projection fail loudly. - * - * The defect this whole test definition exists for is silent: a column the query did not select is - * `undefined` on the entity, getters compute with it, and the endpoint answers 200 with a wrong - * value. Proving completeness field by field is possible — that is what the mutation level does — - * but it needs a fixture that reaches every branch reading every field, and a fixture that misses - * its branch is green while proving nothing. The safety net then fails the way the defect fails. - * - * This turns the silence off. Reading a column the field list did not ask for throws, so any test - * that exercises the endpoint at all — however naive — reports an incomplete projection, and reports - * it at the property that was missing. - * - * The decision comes from the field list, not from the row: this repository compiles to a target - * where class fields are defined on the instance, so every declared column exists and an unselected - * one is indistinguishable from a selected `null` by looking at the object. + * Makes an incomplete projection fail loudly: reading a column the field list did not ask for + * throws, naming it. See `docs/read-path-projections.md` for why. * - * Joined relations are guarded in turn, each against the fields selected for its own alias, so a - * column missing two levels down is reported there. Relations the projection does not join are left - * alone: they are `undefined`, and dereferencing them already throws. + * The decision comes from the field list, not from the row — this repository compiles to a target + * where class fields are defined on the instance, so an unselected column and a selected `null` + * look the same on the object. * - * Only mapped columns are guarded. Methods and getters pass through untouched — reading *through* a - * getter is how the missing column is reached, so the getter has to keep running. + * Joined relations are guarded against their own alias. Relations the projection does not join are + * left alone; they are `undefined` and dereferencing them already throws. Getters pass through, + * because reading through one is how the missing column is reached. */ export function guardProjection( dataSource: DataSource, diff --git a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts index 8e91bda7e8..e00933410c 100644 --- a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts +++ b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts @@ -26,12 +26,10 @@ export const SUSPENSE_LEG_RESPONSE_FIELDS = [ * * The transaction and the account are joined for four values and a currency. * - * `account.id` is a guard: the response never shows it, but without a primary key the ORM cannot - * materialise the joined row. + * `account.id` is a guard: without a primary key the ORM cannot materialise the joined row. * - * The two joins stay with the query rather than moving into the projection: `ReadProjection` joins - * left, and these are inner. Both relations are `nullable: false`, so the two forms select the same - * rows today — but that is a property of the schema, and the query should not depend on it silently. + * The joins stay with the query rather than moving into the projection, which joins left where + * these are inner. */ export const SUSPENSE_LEG_PROJECTION = new ReadProjection('leg', [], SUSPENSE_LEG_RESPONSE_FIELDS, [ 'account.id', diff --git a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts index 617ff11589..d82ad443aa 100644 --- a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts +++ b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts @@ -35,14 +35,9 @@ const SCHEMA = 'pos_link_projection_spec'; /** * `PUT /paymentLink/:id/pos` — the four levels from `docs/read-path-projections.md`. * - * The answer is a URL built from one identifier and one access key. Reached without a field list, - * the link pulls in its route, its user, the account and the account's organization. - * - * The endpoint is driven through `PaymentLinkService.createPosLinkAdmin` rather than through a - * rebuilt query, so what these levels compare is the answer the endpoint gives. Its collaborators - * are mocked except the repository under test; the account-side write goes through - * `UserDataService`, which is asserted on rather than executed, and the write itself is covered - * separately below. + * Driven through `PaymentLinkService.createPosLinkAdmin` rather than a rebuilt query, with every + * collaborator mocked except the repository under test. The account-side write goes through + * `UserDataService` and is asserted on rather than executed. */ describeProjection('point-of-sale link — read-path projection', () => { let dataSource: DataSource; diff --git a/src/subdomains/core/payment-link/repositories/payment-link.repository.ts b/src/subdomains/core/payment-link/repositories/payment-link.repository.ts index 23a32188b7..b0236f20b6 100644 --- a/src/subdomains/core/payment-link/repositories/payment-link.repository.ts +++ b/src/subdomains/core/payment-link/repositories/payment-link.repository.ts @@ -6,18 +6,11 @@ import { PaymentLink } from '../entities/payment-link.entity'; import { PaymentLinkPaymentStatus } from '../enums'; /** - * What `PUT /paymentLink/:id/pos` reads. + * What `PUT /paymentLink/:id/pos` reads: a URL built from `uniqueId` and one access key, taken from + * the link's configuration, the account's, or the two merged. * - * The endpoint answers with a URL built from `uniqueId` and one access key. The key comes out of a - * configuration, and which of the three configurations is consulted depends on the `scoped` - * argument — the link's own, the account's, or the two merged. `accessKeys` is the only value read - * out of them, so the two columns holding them are the whole field list. - * - * `accountType` is deliberately NOT selected. `configObj` also assembles a recipient block, which - * this endpoint discards, and that block reads `UserData.address` — a getter that switches to the - * organization row for an organization account and would dereference a relation this query has no - * reason to join. Left unselected, the getter takes its other branch and reads columns that are - * simply absent, which nothing here looks at. + * `accountType` is deliberately NOT selected: `UserData.address` switches to the organization row + * for an organization account and would dereference a relation this query has no reason to join. */ export const POS_LINK_RESPONSE_FIELDS = [ 'paymentLink.uniqueId', diff --git a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts index a5f4fe1e48..7e7440edbf 100644 --- a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts +++ b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts @@ -51,13 +51,10 @@ describeProjection('API key — read-path projection', () => { /** * What the endpoint answers, through the projected query. * - * The key is assigned in memory before the secret is derived from it, which is what makes - * `created` part of the read: `getSecret` hashes the two together. + * `created` is part of the read because `getSecret` hashes it together with the key. * - * The production key mixes in the current time, so two calls a millisecond apart differ. Comparing - * whole responses across runs would then report every field as required — true of the timestamp, - * and evidence about nothing. The fixture keeps the dependency that matters, the account id, and - * leaves the timestamp out. + * The key is pinned rather than generated: the production one mixes in the current time, so every + * field would come out of level 3 as required, which is evidence about nothing. */ async function apiKeyOf( id: number, diff --git a/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts b/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts index b6409fdcb4..614beaff60 100644 --- a/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts +++ b/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts @@ -49,14 +49,9 @@ const nextEvmAddress = (): string => `0x${(++addressCount).toString(16).padStart /** * `GET /user` (v2) — the four levels from `docs/read-path-projections.md`. * - * The widest read path in the inventory: a `findOne` on `UserData`, because - * four countries, a language, a currency and an organization expand eagerly and every user of the - * account brought its whole wallet. - * - * Most of what the response shows comes out of getters rather than columns, and several of them - * answer a valid-looking value from a missing field: `isDataComplete` reports `false`, the trading - * limit falls back to the no-KYC default. Level 3 therefore compares against the response the full - * projection produced, not against emptiness. + * The widest read path in the inventory. Most of the response comes out of getters, several of + * which answer a valid-looking value from a missing column — `isDataComplete` reports `false`, the + * trading limit falls back to the no-KYC default — which is why level 3 compares responses. */ describeProjection('GET /user v2 — read-path projection', () => { let dataSource: DataSource; From 1f1dabb89c72b50cd51784d5ff997e5973abdb19 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:38:36 +0200 Subject: [PATCH 35/46] Declare joins in the projection so the guard watches them The suspense query joined its transaction and its account on the query builder while the projection declared neither. The guard derives what it watches from the declared joins, so both relations were selected but unwatched: reading a column they did not select answered undefined instead of throwing, which is the defect the guard exists to make loud. Joins now carry an optional 'inner' kind, so they can move into the projection without turning into left joins. The constructor refuses a field list naming an alias it does not join, which makes the previous arrangement impossible rather than merely corrected, and a spec asserts that over every projection the inventory documents. The sort assertion in the same spec expected the insertion order, so it held whether or not the statement ordered at all. It now seeds the newest row first, and the field count in the comment above the projection is gone rather than restated: the field list is directly above it. --- .../models/__tests__/read-projection.spec.ts | 55 +++++++++++++++++++ src/shared/models/read-projection.ts | 26 +++++++-- src/shared/utils/projection-test.util.ts | 2 +- .../ledger-suspense.projection.spec.ts | 21 ++++++- .../repositories/ledger-leg.repository.ts | 22 ++++---- 5 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/shared/models/__tests__/read-projection.spec.ts b/src/shared/models/__tests__/read-projection.spec.ts index 5c0df57139..90f1edfa8d 100644 --- a/src/shared/models/__tests__/read-projection.spec.ts +++ b/src/shared/models/__tests__/read-projection.spec.ts @@ -89,6 +89,61 @@ describe('ReadProjection', () => { expect(selected).toEqual([['root.a', 'root.id']]); }); + it('applies a join declared as inner as an inner join', () => { + const joined: [string, string, string][] = []; + const query = { + leftJoin: (path: string, alias: string) => { + joined.push([path, alias, 'left']); + return query; + }, + innerJoin: (path: string, alias: string) => { + joined.push([path, alias, 'inner']); + return query; + }, + select: () => query, + } as unknown as SelectQueryBuilder; + + new ReadProjection( + 'root', + [ + ['root.a', 'a'], + ['root.b', 'b', 'inner'], + ], + ['a.x', 'b.y'], + ).apply(query); + + expect(joined).toEqual([ + ['root.a', 'a', 'left'], + ['root.b', 'b', 'inner'], + ]); + }); + + it('refuses a field list naming an alias it does not join', () => { + // The guard watches the aliases the projection declares. A relation joined on the query builder + // instead is selected but unwatched, so reading a column it did not select answers undefined + // rather than throwing — the exact defect this whole suite exists to make loud. + expect(() => new ReadProjection('root', [], ['root.a', 'rel.b'])).toThrow( + "projection 'root' names aliases it does not join: 'rel'", + ); + // Guards are checked too: they are selected columns like any other, and a guard on an unjoined + // alias is the same silent hole. + expect(() => new ReadProjection('root', [['root.rel', 'rel']], ['root.a'], ['other.id'])).toThrow("'other'"); + }); + + it('every projection in the inventory joins each alias its fields name', () => { + // Asserted over the real projections rather than a constructed one: the rule above only helps + // if it holds for what the endpoints actually use. + for (const [method, version, path, projection] of DOCUMENTED) { + const declared = new Set([projection.alias, ...projection.joins.map(([, alias]) => alias)]); + const named = [...projection.fields, ...projection.guards].map((field) => field.split('.')[0]); + + expect({ endpoint: `${method} v${version} ${path}`, undeclared: named.filter((a) => !declared.has(a)) }).toEqual({ + endpoint: `${method} v${version} ${path}`, + undeclared: [], + }); + } + }); + describe('the column counts in docs/endpoints.md', () => { // The inventory is the work list and is required to stay in sync with the code. A number // written by hand drifts the first time a field is added, and nothing would say so — this reads diff --git a/src/shared/models/read-projection.ts b/src/shared/models/read-projection.ts index 281631db16..d87c56615c 100644 --- a/src/shared/models/read-projection.ts +++ b/src/shared/models/read-projection.ts @@ -13,8 +13,12 @@ import { SelectQueryBuilder } from 'typeorm'; export class ReadProjection { constructor( readonly alias: string, - /** `[relation path, alias]`, applied as left joins in order. A later join may build on an earlier alias. */ - readonly joins: ReadonlyArray, + /** + * `[relation path, alias]` or `[relation path, alias, 'inner']`, applied in order. A later join + * may build on an earlier alias. Joins belong here rather than at the query: the guard derives + * what it watches from this list, so a relation joined on the builder instead is not watched. + */ + readonly joins: ReadonlyArray, /** Fields that feed the response. These are what the mutation test drops one by one. */ readonly fields: ReadonlyArray, /** @@ -26,7 +30,18 @@ export class ReadProjection { * without it. Each one needs its own assertion instead — see the specs. */ readonly guards: ReadonlyArray = [], - ) {} + ) { + const declared = new Set([alias, ...joins.map(([, joinAlias]) => joinAlias)]); + const undeclared = [...new Set([...fields, ...guards].map((field) => field.split('.')[0]))].filter( + (fieldAlias) => !declared.has(fieldAlias), + ); + + if (undeclared.length) + throw new Error( + `projection '${alias}' names aliases it does not join: ${undeclared.map((a) => `'${a}'`).join(', ')} — ` + + `declare them here rather than on the query builder, or the guard does not watch them`, + ); + } /** * Applies the joins and the field list to a query builder. @@ -36,7 +51,10 @@ export class ReadProjection { * the test would be measuring the join instead of the projection. */ apply(query: SelectQueryBuilder, fields: ReadonlyArray = this.fields): SelectQueryBuilder { - for (const [path, alias] of this.joins) query.leftJoin(path, alias); + for (const [path, alias, kind] of this.joins) { + if (kind === 'inner') query.innerJoin(path, alias); + else query.leftJoin(path, alias); + } return query.select([...fields, ...this.guards]); } } diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts index e9a4217298..cb6c6d4621 100644 --- a/src/shared/utils/projection-test.util.ts +++ b/src/shared/utils/projection-test.util.ts @@ -339,7 +339,7 @@ export function guardProjection( /** The part of a `ReadProjection` the guard needs. */ export interface GuardableProjection { alias: string; - joins: ReadonlyArray; + joins: ReadonlyArray; guards: ReadonlyArray; } diff --git a/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts b/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts index a85860dbb2..9dc94b3ccd 100644 --- a/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts +++ b/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts @@ -25,8 +25,6 @@ const SCHEMA = 'ledger_suspense_projection_spec'; /** * `GET /dashboard/accounting/ledger/suspense` — the four levels from * `docs/read-path-projections.md`. - * - * The response is four values and a currency, drawn from the leg, its transaction and its account. */ describeProjection('ledger suspense — read-path projection', () => { let dataSource: DataSource; @@ -114,8 +112,10 @@ describeProjection('ledger suspense — read-path projection', () => { }, 120000); it('level 2 — legs are ordered by booking date, oldest first', async () => { - const older = await seedLeg(); + // Seeded newest first: a statement that lost its ORDER BY falls back to insertion or id order, + // which is the reverse of what is expected here rather than a match for it. const newer = await seedLeg(); + const older = await seedLeg(); await dataSource.getRepository(LedgerTx).update(older.tx.id, { bookingDate: new Date('2020-01-01T00:00:00.000Z') }); await dataSource.getRepository(LedgerTx).update(newer.tx.id, { bookingDate: new Date('2026-01-01T00:00:00.000Z') }); @@ -126,6 +126,21 @@ describeProjection('ledger suspense — read-path projection', () => { expect(response.legs.map((row) => row.legId)).toEqual([older.leg.id, newer.leg.id]); }, 120000); + it('guards the joined transaction and account, not only the leg', async () => { + // The joins were applied on the query builder before, which selects the columns but leaves the + // relation outside what the guard watches: a read of a column these joins did not select + // answered undefined instead of throwing. + await seedLeg(); + + const [leg] = await legs.findSuspenseLegs(); + + expect(() => leg.tx.valueDate).toThrow("read of 'LedgerTx.valueDate'"); + expect(() => leg.account.name).toThrow("read of 'LedgerAccount.name'"); + // The columns the projection does select stay readable, so the guard is not simply refusing the + // whole relation. + expect(leg.tx.description).toBeDefined(); + }, 120000); + it('level 2 — a leg without a CHF amount contributes nothing to the total', async () => { await seedLeg(AccountType.SUSPENSE, { amountChf: null }); const withAmount = await seedLeg(AccountType.SUSPENSE, { amountChf: 25 }); diff --git a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts index e00933410c..ec0b4f1ec6 100644 --- a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts +++ b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts @@ -24,16 +24,19 @@ export const SUSPENSE_LEG_RESPONSE_FIELDS = [ /** * `GET /dashboard/accounting/ledger/suspense`. * - * The transaction and the account are joined for four values and a currency. - * * `account.id` is a guard: without a primary key the ORM cannot materialise the joined row. * - * The joins stay with the query rather than moving into the projection, which joins left where - * these are inner. + * Both joins are inner: a leg without a transaction or an account is not a suspense entry. */ -export const SUSPENSE_LEG_PROJECTION = new ReadProjection('leg', [], SUSPENSE_LEG_RESPONSE_FIELDS, [ - 'account.id', -]); +export const SUSPENSE_LEG_PROJECTION = new ReadProjection( + 'leg', + [ + ['leg.tx', 'tx', 'inner'], + ['leg.account', 'account', 'inner'], + ], + SUSPENSE_LEG_RESPONSE_FIELDS, + ['account.id'], +); @Injectable() export class LedgerLegRepository extends BaseRepository { @@ -48,10 +51,7 @@ export class LedgerLegRepository extends BaseRepository { * with; `LedgerQueryService.getSuspense` calls this without it. */ async findSuspenseLegs(fields: ReadonlyArray = SUSPENSE_LEG_PROJECTION.fields): Promise { - return SUSPENSE_LEG_PROJECTION.apply( - this.createQueryBuilder('leg').innerJoin('leg.tx', 'tx').innerJoin('leg.account', 'account'), - fields, - ) + return SUSPENSE_LEG_PROJECTION.apply(this.createQueryBuilder('leg'), fields) .where('account.type = :type', { type: AccountType.SUSPENSE }) .orderBy('tx.bookingDate', 'ASC') .getMany(); From 1f0b1aff0309080ad5da862804d94bebbf0fdee7 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:38:46 +0200 Subject: [PATCH 36/46] Drop the claims this code does not support The document referred to "the outage described above", and there is none; the detection-time claim beside it was about services in general rather than about this code. The example itself is real and now names the entity it comes from instead of asserting it was fixed once before. Two comments enumerated a relation graph and a field count that the entities and the field list have since outgrown. Counting is what makes them brittle, so they no longer count. One comment stated that a column is the bulk of what a query transfers, which is a statement about production value sizes. The repository establishes only that it is an unbounded text column the list does not show, so that is what it now says. In load-sites.md the stated median was 101 where the 790 measured rows give 98, and the table was not fully sorted: rows patched in place kept their old position. Both are corrected, and the rows are the same rows in a different order. --- docs/load-sites.md | 68 +++++++++---------- docs/read-path-projections.md | 7 +- ...iquidity-management-pipeline.repository.ts | 4 +- .../repositories/support-issue.repository.ts | 5 +- 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/docs/load-sites.md b/docs/load-sites.md index e3fe4d6819..f589cdd35c 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -35,7 +35,7 @@ Columns were measured against the real entity metadata by building the query and - **452 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. - 315 could not be measured: no resolvable target entity, or raw SQL. -Median across measured sites: **101 columns**. 14 sites exceed 1000, 71 exceed 500, 390 exceed 100. +Median across measured sites: **98 columns**. 14 sites exceed 1000, 71 exceed 500, 390 exceed 100. Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. @@ -66,9 +66,9 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 844 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-webhook.service.ts:17` | `BuyCryptoWebhookService.triggerWebhookManual` | | 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:189` | `TransactionService.getTransactionsWithoutUid` | | 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:198` | `TransactionService.getTransactionsByUserDataId` | +| 813 | 29 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:255` | `BuyFiatPreparationService.refreshFee` | | 811 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:922` | `BuyCryptoService.getRefTransactions` | | 811 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1147` | `BuyCryptoService.getAllRefTransactions` | -| 813 | 29 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:255` | `BuyFiatPreparationService.refreshFee` | | 803 | 29 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:400` | `BuyFiatService.refundBuyFiat` | | 790 | 26 | find | `Transaction` | `subdomains/supporting/payment/services/transaction-notification.service.ts:37` | `TransactionNotificationService.txAssigned` | | 785 | 24 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:267` | `BuyCryptoNotificationService.chargebackInitiated` | @@ -103,8 +103,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 540 | 17 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:763` | `BuyFiatService.getByAmlReason` | | 538 | 21 | find | `CryptoInput` | `subdomains/core/accounting/services/consumers/crypto-input.consumer.ts:89` | `CryptoInputConsumer.processForward` | | 535 | 15 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:947` | `BuyCryptoService.getPendingTransactions` | -| 427 | 13 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:283` | `CustodyOrderService.confirmOrder` | -| 427 | 13 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:299` | `CustodyOrderService.getOrdersForSupport` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:615` | `BuyFiatPreparationService.chargebackTx` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:486` | `BuyFiatService.retriggerScorechain` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:72` | `PaymentLinkRepository.getAllPaymentLinks` | @@ -118,17 +116,17 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:504` | `PaymentLinkService.assignPaymentLink` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:520` | `PaymentLinkService.getLocations` | | 507 | 18 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:358` | `KycService.reviewRecommendationStep` | -| 484 | 15 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:218` | `TransactionRequestService.getOrThrow` | | 499 | 14 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:169` | `BankTxReturnService.getPendingTx` | | 497 | 18 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:128` | `BuyFiatNotificationService.pendingBuyFiat` | | 497 | 15 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:86` | `BankTxReturnService.setFiatAmounts` | | 493 | 19 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:366` | `SupportIssueService.createIssueInternal` | | 490 | 20 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:587` | `PaymentLinkService.getPublicPaymentLinkByUniqueId` | | 490 | 15 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:473` | `BuyFiatService.resetAmlCheck` | +| 484 | 15 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:218` | `TransactionRequestService.getOrThrow` | +| 484 | 15 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward-notification.service.ts:27` | `RefRewardNotificationService.refRewardPayouts` | | 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:745` | `BuyCryptoService.getBuyCryptoByTransactionId` | | 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:749` | `BuyCryptoService.getBuyCrypto` | | 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:753` | `BuyCryptoService.updateVolumes` | -| 484 | 15 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward-notification.service.ts:27` | `RefRewardNotificationService.refRewardPayouts` | | 474 | 14 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:198` | `KycService.reviewIdentSteps` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:274` | `RecommendationService.getAllRecommendationForUserData` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:318` | `RecommendationService.getRecommendationsByKycStepIdsOrUserDataId` | @@ -164,17 +162,18 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 433 | 12 | find | `BankTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:106` | `BankTxConsumer.processForward` | | 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:478` | `SupportIssueService.closeIssue` | | 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:628` | `SupportIssueService.getIssueMessages` | -| 407 | 11 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:67` | `TransactionRequestService.txRequestWaitingExpiryCheck` | -| 407 | 11 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:78` | `TransactionRequestService.deleteOldTxRequests` | +| 427 | 13 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:283` | `CustodyOrderService.confirmOrder` | +| 427 | 13 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:299` | `CustodyOrderService.getOrdersForSupport` | | 422 | 12 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:778` | `BuyCryptoService.resetAmlCheck` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:214` | `SupportEscalationService.checkEscalations` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:484` | `SupportIssueService.updateIssue` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:618` | `SupportIssueService.getIssueMessages` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:655` | `SupportIssueService.getIssueUserDataId` | -| 415 | 13 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1186` | `BuyCryptoService.getTransactions` | -| 320 | 15 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:80` | `CustodyJobService.executeStep` | | 418 | 11 | find | `User` | `subdomains/generic/user/models/user/user-job.service.ts:19` | `UserJobService.approveUser` | +| 415 | 13 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1186` | `BuyCryptoService.getTransactions` | | 411 | 15 | find | `Buy` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1024` | `BuyCryptoService.getBuy` | +| 407 | 11 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:67` | `TransactionRequestService.txRequestWaitingExpiryCheck` | +| 407 | 11 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:78` | `TransactionRequestService.deleteOldTxRequests` | | 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:393` | `UserService.updateUserV1` | | 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:447` | `UserService.updateUserData` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:69` | `BuyCryptoRegistrationService.filterBuyCryptoPayIns` | @@ -184,8 +183,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 386 | 11 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:439` | `UserService.updateUserName` | | 385 | 15 | find | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:139` | `BuyFiatRegistrationService.createBuyFiatsAndAckPayIns` | | 385 | 14 | find | `KycStep` | `subdomains/generic/kyc/services/kyc-admin.service.ts:45` | `KycAdminService.updateKycStep` | -| 380 | 13 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:223` | `BuyService.getByBankUsage` | | 384 | 13 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:344` | `UserDataService.updateUserData` | +| 380 | 13 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:223` | `BuyService.getByBankUsage` | | 377 | 14 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:80` | `SellService.get` | | 377 | 12 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:118` | `BankTxReturnService.create` | | 377 | 12 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:148` | `BankTxReturnService.updateInternal` | @@ -194,7 +193,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 374 | 14 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:287` | `SellService.confirmSell` | | 370 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:268` | `BankTxService.fillBankTx` | | 370 | 9 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:554` | `FiatOutputJobService.searchOutgoingBankTx` | -| 360 | 12 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:162` | `BuyService.createBuy` | | 364 | 12 | find | `Webhook` | `subdomains/generic/user/services/webhook/webhook-notification.service.ts:32` | `WebhookNotificationService.sendOpenWebhooks` | | 364 | 12 | find | `Webhook` | `subdomains/generic/user/services/webhook/webhook.service.ts:146` | `WebhookService.createAndSendWebhook` | | 363 | 10 | find | `BuyCrypto` | `subdomains/core/accounting/services/consumers/payout-order.consumer.ts:297` | `PayoutOrderConsumer.owedCompletionChf` | @@ -206,10 +204,11 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 363 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data-job.service.ts:25` | `UserDataJobService.bankTxVerification` | | 363 | 10 | find | `BuyCrypto` | `subdomains/supporting/fiat-output/fiat-output.service.ts:108` | `FiatOutputService.create` | | 362 | 11 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:375` | `TransactionService.getByAssetId` | -| 352 | 15 | find | `CryptoStaking` | `subdomains/core/staking/services/staking.service.ts:48` | `StakingService.getUserInvests` | +| 360 | 12 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:162` | `BuyService.createBuy` | | 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:624` | `BankTxService.getUnassignedBankTx` | | 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:634` | `BankTxService.getBankTxsByVirtualIban` | | 354 | 13 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:117` | `SellService.getSellsByIban` | +| 352 | 15 | find | `CryptoStaking` | `subdomains/core/staking/services/staking.service.ts:48` | `StakingService.getUserInvests` | | 351 | 11 | find | `UserData` | `subdomains/generic/user/models/kyc/kyc.service.ts:126` | `KycService.getUserByKycCode` | | 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:406` | `UserService.updateUser` | | 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:418` | `UserService.updateUserMail` | @@ -219,19 +218,20 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 343 | 10 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts:87` | `FiatPayInSyncService.createCheckoutTx` | | 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1074` | `UserDataService.updateApiFilter` | | 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1084` | `UserDataService.checkApiKey` | +| 328 | 10 | find | `User` | `subdomains/generic/user/models/auth/auth.controller.ts:157` | `AuthController.createAccessTokenAfterMerge` | +| 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:155` | `KycService.getKycFile` | +| 328 | 10 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:129` | `UserService.getUserDto` | | 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:169` | `VirtualIbanService.getByIdForUser` | | 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1262` | `VirtualIbanService.getActiveForBuyAndCurrency` | | 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1274` | `VirtualIbanService.getByIban` | | 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1316` | `VirtualIbanService.getVirtualIbansForAccount` | -| 309 | 8 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:278` | `TransactionRequestService.findAndComplete` | -| 328 | 10 | find | `User` | `subdomains/generic/user/models/auth/auth.controller.ts:157` | `AuthController.createAccessTokenAfterMerge` | -| 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:155` | `KycService.getKycFile` | -| 328 | 10 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:129` | `UserService.getUserDto` | | 323 | 10 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1246` | `RealUnitService.forwardRegistrationToAktionariat` | | 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:362` | `BuyFiatService.getBuyFiatByTransactionId` | | 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:367` | `BuyFiatService.getBuyFiatsByTransactionIds` | +| 320 | 15 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:80` | `CustodyJobService.executeStep` | | 319 | 10 | find | `BuyFiat` | `subdomains/core/accounting/services/ledger-cutover.service.ts:308` | `LedgerCutoverService.openBuyFiatReceived` | | 319 | 10 | find | `BuyFiat` | `subdomains/core/accounting/services/ledger-cutover.service.ts:357` | `LedgerCutoverService.openBuyFiatOwed` | +| 309 | 8 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:278` | `TransactionRequestService.findAndComplete` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:60` | `KycService.transferKycData` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:76` | `KycService.transferKycData` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:80` | `UserService.getAllUser` | @@ -248,8 +248,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:742` | `UserService.updateApiFilter` | | 301 | 6 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:89` | `BankTxRepeatService.getAllUserRepeats` | | 299 | 12 | find | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:65` | `TradingOrderService.startNewOrders` | -| 197 | 5 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:113` | `CustodyJobService.onStepComplete` | -| 197 | 5 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:273` | `CustodyOrderService.getCustodyOrderByTx` | | 287 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:746` | `BuyFiatService.getTransactions` | | 284 | 10 | find | `UserData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:173` | `BankDataService.addBankData` | | 276 | 10 | find | `NameCheckLog` | `subdomains/generic/kyc/services/name-check.service.ts:205` | `NameCheckService.createNameCheckLog` | @@ -321,16 +319,13 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 234 | 6 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:199` | `RefRewardService.getAllUserRewards` | | 234 | 6 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:207` | `RefRewardService.getRefRewardsByUserDataId` | | 229 | 11 | find | `CryptoInput` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:527` | `DashboardReconciliationService.getCryptoInputs` | -| 128 | 4 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:94` | `CustodyJobService.checkStep` | -| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:52` | `CustodyJobService.resetExpiredConfirmedOrders` | -| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:308` | `CustodyOrderService.approveOrder` | -| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:257` | `CustodyService.getUserCustodyHistory` | -| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:470` | `CustodyService.calculateAccruedInterest` | | 201 | 5 | find | `BuyFiat` | `subdomains/core/accounting/services/consumers/payout-order.consumer.ts:293` | `PayoutOrderConsumer.owedCompletionChf` | | 201 | 8 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward-dex.service.ts:36` | `RefRewardDexService.secureLiquidity` | | 201 | 5 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:108` | `BuyFiatService.checkAmlResetTx` | | 201 | 5 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:548` | `BuyFiatService.manualPassAmlCheck` | | 201 | 5 | find | `BuyFiat` | `subdomains/supporting/fiat-output/fiat-output.service.ts:98` | `FiatOutputService.create` | +| 197 | 5 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:113` | `CustodyJobService.onStepComplete` | +| 197 | 5 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:273` | `CustodyOrderService.getCustodyOrderByTx` | | 195 | 8 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:612` | `PaymentLinkService.deletePaymentLink` | | 182 | 5 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:222` | `FiatOutputService.delete` | | 179 | 4 | find | `BankTxRepeat` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:553` | `BankTxConsumer.openingBankTxId` | @@ -338,10 +333,10 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 176 | 5 | find | `TradingOrder` | `subdomains/core/accounting/services/consumers/trading-order.consumer.ts:84` | `TradingOrderConsumer.processForward` | | 176 | 5 | find | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:164` | `TradingOrderService.checkRunningOrders` | | 176 | 5 | find | `TradingOrder` | `subdomains/core/trading/services/trading-rule.service.ts:42` | `TradingRuleService.getCurrentTradingOrders` | -| 170 | 12 | find | `Route` | `subdomains/core/route/route.service.ts:19` | `RouteService.updateRoute` | | 174 | 4 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:51` | `RecallService.update` | | 174 | 4 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:63` | `RecallService.getAll` | | 174 | 4 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:75` | `RecallService.getById` | +| 170 | 12 | find | `Route` | `subdomains/core/route/route.service.ts:19` | `RouteService.updateRoute` | | 164 | 9 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:41` | `DepositRouteService.getByLabel` | | 159 | 3 | find | `Transaction` | `subdomains/supporting/payment/services/transaction-notification.service.ts:114` | `TransactionNotificationService.txUnassigned` | | 156 | 4 | find | `LiquidityOrder` | `subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts:104` | `LiquidityOrderDexConsumer.processForward` | @@ -392,6 +387,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:649` | `LiquidityManagementPipelineService.resolveUncertainOrderManually` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:702` | `LiquidityManagementPipelineService.checkRunningOrders` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:510` | `DashboardReconciliationService.getLmOrders` | +| 131 | 3 | find | `StakingRefReward` | `subdomains/core/staking/services/staking.service.ts:37` | `StakingService.getUserStakingRefRewards` | | 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:84` | `BuyService.updateVolume` | | 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:120` | `BuyService.getAllBankUsages` | | 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:131` | `BuyService.get` | @@ -399,8 +395,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:208` | `BuyService.getUserBuys` | | 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:212` | `BuyService.getUserDataBuys` | | 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:250` | `BuyService.getAllUserBuys` | -| 131 | 3 | find | `StakingRefReward` | `subdomains/core/staking/services/staking.service.ts:37` | `StakingService.getUserStakingRefRewards` | | 130 | 9 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:173` | `LiquidityManagementPipelineService.checkRunningPipelines` | +| 128 | 4 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:94` | `CustodyJobService.checkStep` | | 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:223` | `BankTxService.assignTransactions` | | 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:417` | `BankTxService.getBankTxByTransactionId` | | 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:422` | `BankTxService.getBankTxsByTransactionIds` | @@ -424,9 +420,10 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:249` | `PayoutService.prepareNewOrders` | | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:266` | `PayoutService.payoutOrders` | | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:283` | `PayoutService.processFailedOrders` | -| 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody-pdf.service.ts:47` | `CustodyPdfService.getBalancesWithHistoricalPrices` | -| 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:111` | `CustodyService.getUserCustodyBalance` | -| 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:238` | `CustodyService.updateCustodyBalance` | +| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:52` | `CustodyJobService.resetExpiredConfirmedOrders` | +| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:308` | `CustodyOrderService.approveOrder` | +| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:257` | `CustodyService.getUserCustodyHistory` | +| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:470` | `CustodyService.calculateAccruedInterest` | | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:49` | `BankTxRepeatService.update` | | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:62` | `BankTxRepeatService.update` | | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:104` | `BankTxRepeatService.getBankTxRepeat` | @@ -438,9 +435,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:257` | `TransactionRequestService.getTransactionRequestByUid` | | 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:261` | `TransactionRequestService.getOpenBuyQuotes` | | 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:424` | `TransactionRequestService.getByAssetId` | -| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/bank/bank.service.ts:181` | `BankService.getReceiveIbanStatus` | -| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:130` | `VirtualIbanService.getActiveReceivingForUserAndCurrency` | -| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:155` | `VirtualIbanService.getActiveSendingCandidatesForUserAndCurrency` | | 99 | 0 | query-builder (nur-alias) | `UserData` | `subdomains/generic/user/models/user-data/user-data-notification.service.ts:173` | `UserDataNotificationService.blackSquadInvitation` | | 99 | 0 | query-builder (ohne-select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:141` | `UserDataService.getUserDataByUser` | | 99 | 0 | query-builder (nur-alias) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:313` | `UserDataService.getUserDataByKey` | @@ -456,6 +450,9 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:173` | `TransactionService.getTransactionByRequestUid` | | 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:181` | `TransactionService.getTransactionByExternalId` | | 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:185` | `TransactionService.getTransactionByCkoId` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/bank/bank.service.ts:181` | `BankService.getReceiveIbanStatus` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:130` | `VirtualIbanService.getActiveReceivingForUserAndCurrency` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:155` | `VirtualIbanService.getActiveSendingCandidatesForUserAndCurrency` | | 93 | 2 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1281` | `RealUnitService.findRegistration` | | 93 | 2 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1296` | `RealUnitService.findRegistration` | | 91 | 4 | find | `PaymentActivation` | `subdomains/core/payment-link/services/payment-activation.service.ts:64` | `PaymentActivationService.getActivationByTxId` | @@ -508,13 +505,13 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:603` | `FiatOutputJobService.getLastBatchId` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:638` | `FiatOutputJobService.notifyScryptDeposits` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:203` | `FiatOutputService.update` | -| 52 | 2 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:140` | `BuyService.getById` | -| 52 | 2 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:258` | `BuyService.updateBuy` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:89` | `LedgerQueryService.getAccounts` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:157` | `LedgerQueryService.getReconStatus` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:613` | `LedgerQueryService.unverifiedAccountIds` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:146` | `LedgerReconciliationService.reconcileAssets` | | 53 | 1 | find | `PriceRule` | `subdomains/supporting/pricing/services/pricing.service.ts:163` | `PricingService.updatePrices` | +| 52 | 2 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:140` | `BuyService.getById` | +| 52 | 2 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:258` | `BuyService.updateBuy` | | 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:58` | `PaymentLinkPaymentService.processExpiredPayments` | | 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:264` | `PaymentLinkPaymentService.expirePaymentIfPending` | | 50 | 2 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:172` | `PaymentLinkService.createInvoice` | @@ -538,6 +535,9 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:41` | `FaucetRequestService.checkFaucetRequests` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:93` | `FaucetRequestService.resetFaucet` | | 41 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:267` | `UserDataRepository.getProfile` | +| 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody-pdf.service.ts:47` | `CustodyPdfService.getBalancesWithHistoricalPrices` | +| 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:111` | `CustodyService.getUserCustodyBalance` | +| 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:238` | `CustodyService.updateCustodyBalance` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:38` | `LiquidityManagementBalanceService.getAllLiqBalancesForAssets` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:68` | `LiquidityManagementBalanceService.refreshBankBalance` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:82` | `LiquidityManagementBalanceService.getBalances` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 6680180d0f..70f32f368d 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -187,7 +187,8 @@ asserted directly. A missing field does not crash. It is simply absent, getters compute with it anyway, and the endpoint answers 200 with a wrong value. -The concrete case, from the code that was already fixed once: +The concrete case, from `UserData` in +`src/subdomains/generic/user/models/user-data/user-data.entity.ts`: ```typescript get requiredInvoiceFields(): string[] { @@ -203,8 +204,8 @@ load returns `true`. The invoice is refused with "user data is not complete" alt complete. No error, no log entry. This service carries **234 such getters across 50 of its 112 entities**. In an application moving -money, a silent wrong value is worse than a crash: a statement that exceeds the column limit fails -loudly and is found at once, while a wrong value can run for weeks. +money, a silent wrong value is the expensive kind of defect: nothing reports it, so it runs until +someone notices the answer is wrong. ## The guard: making the failure loud diff --git a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts index 701b4b61ff..eb212b9dd1 100644 --- a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts +++ b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts @@ -10,8 +10,8 @@ export const PIPELINE_STATUS_RESPONSE_FIELDS = ['pipeline.status']; /** * `GET /liquidityManagement/pipeline/:id/status` — one column, for one value. * - * The pipeline expands its rule and its current action eagerly, and the rule pulls in its asset and - * its currency — the graph a plain lookup by id reaches, to read a status string. + * A plain lookup by id expands the pipeline's eager graph, and its rule's in turn, to read a status + * string. */ export const PIPELINE_STATUS_PROJECTION = new ReadProjection( 'pipeline', diff --git a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts index b70c8d589c..17138e9c82 100644 --- a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts +++ b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts @@ -223,9 +223,8 @@ export const SUPPORT_ISSUE_LIST_RESPONSE_FIELDS = [ /** * `GET /support/issue/list` and `GET /realunit/support/list` — the ten values the row shows. * - * The six it drops are the five foreign keys and `information`, which is `text` and holds the - * free-form body of the issue. The list shows a row per issue and none of it, so on a page of - * results that column is the bulk of what the query transfers. + * The six it drops are the five foreign keys and `information`, an unbounded `text` column holding + * the free-form body of the issue, which the list does not show. * * Every column `SupportIssueListOrderBy` allows is in the list above, which the query needs: the * paginated form of `getManyAndCount` orders a distinct-id subquery by the sort column. From 47c3b96bf3a89b681cd43d542110256d618ee643 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:59:13 +0200 Subject: [PATCH 37/46] Teach the alias scan the three-element join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A projection declaring ['leg.tx', 'tx', 'inner'] contributed no alias at all: the scan reads the declaration with a pattern for two-element tuples only. Every reference to those relations was then reported as a bare column and the suite failed. The pattern now accepts the optional join kind, and a case beside the existing ones covers a projection that declares one. Three comments also claimed more than the code behind them does. A fixture exemption said each entry asserts another variant covers the field, which nothing tracks. The controller scan promised that adding a controller cannot narrow the closure silently, which name matching cannot give across a type alias — the limit is now stated where the promise was. And the classification section carried a count that belongs to the preceding pull request and cannot be derived from this one; the rule it illustrated stays. --- docs/read-path-projections.md | 10 +++---- .../eager-relations.projection.spec.ts | 7 ++--- .../__tests__/query-builder-alias.spec.ts | 26 ++++++++++++++++++- src/shared/utils/projection-test.util.ts | 4 +-- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 70f32f368d..6a57d98aaf 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -57,11 +57,11 @@ as invoices, receipts, history and exports — which need fields, not objects. Read the first number carefully. The 90 query builders that name columns one at a time are almost entirely counts, maxima and id lookups — `.select('userData.id', 'id')`, `.select('COUNT(*)', 'count')` and the like — and they select **one -column at the median**. They are projections, and they were miscounted as full loads because the -classification recognised only the array form `.select([...])` and read every string argument as the -bare root alias. The rule that holds: a bare identifier is the root alias and loads everything; -anything else — a column or an expression — narrows the query. Correcting it moved 15 endpoints out -of the `whole rows` group. What it does not do is change the picture: a +column at the median**. They are projections. The classification recognised only the array form +`.select([...])` and read every string argument as the bare root alias, which is what put them in +the `whole rows` group. The rule that holds: a bare identifier is the root alias and loads +everything; anything else — a column or an expression — narrows the query. Correcting that does not +change the picture: a `COUNT(*)` that was always narrow is not a read path that was converted, and the response payloads — history, profile, invoices, exports — are still served by `find`. diff --git a/src/shared/models/__tests__/eager-relations.projection.spec.ts b/src/shared/models/__tests__/eager-relations.projection.spec.ts index 7630b5b6d5..84b1176044 100644 --- a/src/shared/models/__tests__/eager-relations.projection.spec.ts +++ b/src/shared/models/__tests__/eager-relations.projection.spec.ts @@ -14,9 +14,10 @@ const SRC = join(__dirname, '../../..'); * The entities that leave through a controller as themselves — for those, the eager relations are * the response rather than a loading detail. * - * Read out of the source rather than listed, so that adding a controller cannot narrow the closure - * silently. Deliberately generous: any method in a controller file whose return type names an - * entity counts. An over-match costs precision in the message, a miss costs the guarantee. + * Read out of the source rather than listed, so that a controller added later is covered without + * anyone remembering a list. Deliberately generous: any method in a controller file whose return + * type names an entity counts. Names are matched as written — a return type reached through an + * alias is not seen — so an over-match costs precision in the message, a miss costs the guarantee. */ function entitiesReturnedWhole(entities: Set): Map { const controllers: string[] = []; diff --git a/src/shared/utils/__tests__/query-builder-alias.spec.ts b/src/shared/utils/__tests__/query-builder-alias.spec.ts index aed6945d17..4fa43d20db 100644 --- a/src/shared/utils/__tests__/query-builder-alias.spec.ts +++ b/src/shared/utils/__tests__/query-builder-alias.spec.ts @@ -162,7 +162,10 @@ describe('Query Builder Alias Enforcement', () => { aliases.add(match[2]); const end = fileContent.indexOf('\n);', match.index); const declaration = fileContent.slice(match.index, end < 0 ? undefined : end); - const joinPattern = /\[\s*['"`][^'"`]+\.[^'"`]+['"`]\s*,\s*['"`](\w+)['"`]\s*\]/g; + // The third element is the optional join kind. Without it in the pattern a projection that + // declares an inner join contributes no alias at all, and every reference to it is reported + // as a bare column. + const joinPattern = /\[\s*['"`][^'"`]+\.[^'"`]+['"`]\s*,\s*['"`](\w+)['"`]\s*(?:,\s*['"`]\w+['"`]\s*)?\]/g; let joinMatch; while ((joinMatch = joinPattern.exec(declaration)) !== null) aliases.add(joinMatch[1]); } @@ -554,6 +557,15 @@ export const SECOND_PROJECTION = new ReadProjection( [['thing.other', 'secondOther']], ['thing.id'], ); + +export const THIRD_PROJECTION = new ReadProjection( + 'thing', + [ + ['thing.inner', 'innerJoined', 'inner'], + ['thing.left', 'leftJoined'], + ], + ['thing.id'], +); `; it('accepts an alias declared by the projection the query applies', () => { @@ -569,6 +581,18 @@ export const SECOND_PROJECTION = new ReadProjection( expect(extractAllAliases(chain, 'thing', twoProjections).has('secondOther')).toBe(false); }); + it('takes the aliases of a projection declaring a join kind', () => { + // A join carrying `'inner'` is a three-element tuple. Read with a two-element pattern it + // matches nothing, and the scan then rejects every reference to the relation it joined. + const chain = `THIRD_PROJECTION.apply(this.createQueryBuilder('thing')).where('innerJoined.id = :id', { id })`; + + const aliases = extractAllAliases(chain, 'thing', twoProjections); + + expect(aliases.has('innerJoined')).toBe(true); + // The plain join beside it still resolves — the optional element must not swallow the pair. + expect(aliases.has('leftJoined')).toBe(true); + }); + it('takes no projection aliases at all when the chain applies none', () => { const chain = `this.createQueryBuilder('thing').where('thing.id = :id', { id })`; diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts index cb6c6d4621..96f19f556d 100644 --- a/src/shared/utils/projection-test.util.ts +++ b/src/shared/utils/projection-test.util.ts @@ -212,8 +212,8 @@ export async function seedEntity( * `undefined`, `null`, `''` and `NaN` count as empty; `0` and `false` do not. `NaN` is on the list * because a field computed as `a + b + c` becomes `NaN` as soon as one column is missing. * - * `optional` lists paths allowed to be empty for the fixture at hand; each entry asserts that - * another variant covers the field. + * `optional` lists paths allowed to be empty for the fixture at hand. Covering those fields is the + * job of another fixture, which this function does not track. */ export function expectNoEmptyFields(value: unknown, optional: string[] = [], path = ''): void { const empty = From 757c613f8e97d6cfa5eb96eb215e8b7cce5abda3 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:11:26 +0200 Subject: [PATCH 38/46] Guard a relation the query joins but selects nothing from A join declared for filtering alone leaves the relation unselected, so the ORM never materialises it and the property is undefined on every row. The guard wrapped that undefined and handed it back: code reading the relation took the absent-relation branch, which reads exactly like a row that genuinely has none. Five projections join this way, each to filter on the owner. Reading such a relation now throws. The condition is structural rather than data-dependent - nothing of it is selected, so no row can carry it - which is why a left join whose row is legitimately absent is unaffected, and why the existing suites are unchanged by it. --- .../__tests__/projection-guard.projection.spec.ts | 14 ++++++++++++++ src/shared/utils/projection-test.util.ts | 14 +++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/shared/utils/__tests__/projection-guard.projection.spec.ts b/src/shared/utils/__tests__/projection-guard.projection.spec.ts index 59b147d496..dc33d95278 100644 --- a/src/shared/utils/__tests__/projection-guard.projection.spec.ts +++ b/src/shared/utils/__tests__/projection-guard.projection.spec.ts @@ -86,6 +86,20 @@ describeProjection('guardProjection', () => { expect(() => row.language.symbol).toThrow("read of 'Language.symbol'"); }, 120000); + it('throws on a relation it joins but selects nothing from', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + // `FIELDS` names no `language.*`, so the join exists for filtering alone. The relation is then + // undefined on the row for every account, and code checking it takes the absent branch — which + // reads exactly like an account that genuinely has no language. + const row = await load(FIELDS, seeded.id); + + expect(() => row.language).toThrow( + "read of 'UserData.language', a relation this query joins but selects nothing from", + ); + }, 120000); + it('throws on a @RelationId, which no field list can select', async () => { // The property is filled from the foreign-key column of the row, which a query naming its // fields does not carry — the defect this suite exists to catch, in the one shape where the diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts index 96f19f556d..6616323c38 100644 --- a/src/shared/utils/projection-test.util.ts +++ b/src/shared/utils/projection-test.util.ts @@ -450,7 +450,19 @@ function guardAgainst( const value = Reflect.get(source, property, receiver); const child = at.children.get(name); - if (child) return wrap(value, child); + if (child) { + // A relation joined for filtering only: with nothing of it selected the ORM never + // materialises it, so the value is undefined whatever the row holds. That is not a row + // without a relation — it is a question the query cannot answer, and reading it takes the + // absent-relation branch silently. + if (!child.asked.size && value == null && !written.has(name)) + throw new Error( + `read of '${at.metadata.name}.${name}', a relation this query joins but selects nothing from — ` + + `select at least its primary key, or stop reading it`, + ); + + return wrap(value, child); + } // A relation the projection does not declare is not guarded: it is `undefined`, and // dereferencing it already throws. That includes the owner-side join column, which TypeORM From ff5ff4a6d81f7f48af62617671181f59e52743af Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:33:46 +0200 Subject: [PATCH 39/46] Hand out one guarded proxy per row instead of a new one per access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapping on every access returned a fresh proxy each time, and two things followed that are not projection defects. `row.relation === row.relation` was false, so code comparing relations by identity or using one as a map key behaved differently under the guard than without it. And each proxy started with an empty record of caller assignments, so writing through a relation and reading it back threw. A guard that invents failures is measuring itself. The controller scan now also reads renamed imports. `import { SupportIssue as Issue }` left the handler naming something the scan did not recognise, which would drop that controller out of the eager closure silently — the one direction the scan must not fail in. The reverse case is handled too: a local name standing for something else no longer counts as the entity it resembles. That branch has no instance in this repository, so the name resolution moved into a function that takes source text and is asserted on it directly, rather than depending on what the tree happens to contain. --- .../eager-relations.projection.spec.ts | 93 ++++++++++++++++--- .../projection-guard.projection.spec.ts | 18 ++++ src/shared/utils/projection-test.util.ts | 20 +++- 3 files changed, 115 insertions(+), 16 deletions(-) diff --git a/src/shared/models/__tests__/eager-relations.projection.spec.ts b/src/shared/models/__tests__/eager-relations.projection.spec.ts index 84b1176044..05d9503d3a 100644 --- a/src/shared/models/__tests__/eager-relations.projection.spec.ts +++ b/src/shared/models/__tests__/eager-relations.projection.spec.ts @@ -16,8 +16,8 @@ const SRC = join(__dirname, '../../..'); * * Read out of the source rather than listed, so that a controller added later is covered without * anyone remembering a list. Deliberately generous: any method in a controller file whose return - * type names an entity counts. Names are matched as written — a return type reached through an - * alias is not seen — so an over-match costs precision in the message, a miss costs the guarantee. + * type names an entity counts, including through a renamed import. An over-match costs precision + * in the message, a miss costs the guarantee. */ function entitiesReturnedWhole(entities: Set): Map { const controllers: string[] = []; @@ -32,25 +32,56 @@ function entitiesReturnedWhole(entities: Set): Map { const found = new Map(); for (const path of controllers) { - const source = readFileSync(path, 'utf8'); - // The whole return type, then every entity name in it: `Promise`, `Promise` - // and any wrapper around them all have to count. Matching the first identifier after the colon - // reads more simply and silently misses the union forms — the expensive direction, because a - // handler it misses is one whose answer the closure below then fails to cover. - for (const match of source.matchAll(/\)\s*:\s*([^;{]+?)\s*\{/g)) { - for (const identifier of match[1].match(/[A-Za-z0-9_]+/g) ?? []) { - if (!entities.has(identifier)) continue; - const file = path.slice(SRC.length + 1); - const where = found.get(identifier) ?? []; - if (!where.includes(file)) where.push(file); - found.set(identifier, where); - } + for (const name of entitiesReturnedBy(readFileSync(path, 'utf8'), entities)) { + const file = path.slice(SRC.length + 1); + const where = found.get(name) ?? []; + if (!where.includes(file)) where.push(file); + found.set(name, where); } } return found; } +/** + * The entities one controller file answers with, by their own names. + * + * Separate from the walk above so it can be exercised on source text rather than on whatever the + * repository happens to contain today — the alias case has no instance here yet, and a guard whose + * hardest branch never runs is the thing this suite exists to argue against. + */ +export function entitiesReturnedBy(source: string, entities: Set): Set { + // `import { SupportIssue as Issue }` — the handler then names `Issue`, which is not an entity + // name and would drop out of the scan. Mapped back, so renaming an import cannot quietly remove + // a controller from the closure. + const renamed = new Map(); + // The other direction of the same clause: a local name that happens to match an entity while + // standing for something else. Counting it would attribute relations to a response that never + // carries them. + const shadowed = new Set(); + for (const clause of source.matchAll(/import\s*\{([^}]*)\}/g)) + for (const part of clause[1].split(',')) { + const [original, alias] = part.split(/\s+as\s+/).map((piece) => piece.trim()); + if (!alias) continue; + if (entities.has(original)) renamed.set(alias, original); + else shadowed.add(alias); + } + + const found = new Set(); + // The whole return type, then every entity name in it: `Promise`, `Promise` + // and any wrapper around them all have to count. Matching the first identifier after the colon + // reads more simply and silently misses the union forms — the expensive direction, because a + // handler it misses is one whose answer the closure below then fails to cover. + for (const match of source.matchAll(/\)\s*:\s*([^;{]+?)\s*\{/g)) + for (const identifier of match[1].match(/[A-Za-z0-9_]+/g) ?? []) { + if (shadowed.has(identifier)) continue; + const name = renamed.get(identifier) ?? identifier; + if (entities.has(name)) found.add(name); + } + + return found; +} + /** * Every eager relation those responses contain, reached recursively. * @@ -171,6 +202,38 @@ describeProjection('eager relations', () => { return found; } + it('reads an entity out of a return type, through a union, an array and a renamed import', () => { + const entities = new Set(['SupportIssue', 'UserData']); + const source = ` + import { SupportIssue as Issue } from './support-issue.entity'; + import { UserData } from './user-data.entity'; + import { Something } from './elsewhere'; + + class C { + async one(id: number): Promise { return null; } + async many(): Promise { return []; } + async neither(): Promise { return null; } + }`; + + // The renamed one is the case with no instance in this repository today, which is why it is + // asserted here rather than left to the walk over the real controllers. + expect(entitiesReturnedBy(source, entities)).toEqual(new Set(['SupportIssue', 'UserData'])); + }); + + it('takes a name that is not an entity for nothing, renamed or not', () => { + const entities = new Set(['SupportIssue']); + const source = ` + import { Helper as SupportIssue } from './helper'; + + class C { + async one(): Promise { return null; } + }`; + + // `SupportIssue` here is a local name for something else entirely. Reading the alias map in the + // other direction would report the entity and put a relation in a closure it is not part of. + expect(entitiesReturnedBy(source, entities)).toEqual(new Set()); + }); + it('finds the controllers that answer with an entity', () => { // If this reads zero, the search above stopped matching and every assertion below would pass // for the wrong reason. diff --git a/src/shared/utils/__tests__/projection-guard.projection.spec.ts b/src/shared/utils/__tests__/projection-guard.projection.spec.ts index dc33d95278..aa07dbb1fc 100644 --- a/src/shared/utils/__tests__/projection-guard.projection.spec.ts +++ b/src/shared/utils/__tests__/projection-guard.projection.spec.ts @@ -100,6 +100,24 @@ describeProjection('guardProjection', () => { ); }, 120000); + it('hands out the same guarded relation on every read', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const fields = [...FIELDS, 'language.id']; + const row = await load(fields, seeded.id); + + // A fresh proxy per access would make this false, and production code comparing relations by + // identity — or using one as a map key — would behave differently under the guard than without + // it. The guard has to be invisible except where it throws. + expect(row.language).toBe(row.language); + + // It also carries the caller's assignments across reads: with a new proxy each time, each one + // starts with an empty set of them and reading back what was just written throws. + row.language.symbol = 'assigned-by-the-caller'; + expect(row.language.symbol).toEqual('assigned-by-the-caller'); + }, 120000); + it('throws on a @RelationId, which no field list can select', async () => { // The property is filled from the foreign-key column of the row, which a query naming its // fields does not carry — the defect this suite exists to catch, in the one shape where the diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts index 6616323c38..6a49081f32 100644 --- a/src/shared/utils/projection-test.util.ts +++ b/src/shared/utils/projection-test.util.ts @@ -431,16 +431,30 @@ function guardAgainst( }, }); + /** + * One proxy per source row and node. + * + * Wrapping on every access would hand out a new proxy each time, and two things break with it: + * `row.relation === row.relation` is false, and each proxy starts with an empty set of caller + * assignments, so writing through a relation and reading it back throws. Neither is a projection + * defect, and a guard that invents them is measuring itself. + */ + const proxies = new WeakMap>(); + const wrap = (row: T | T[] | null, at: Node): T | T[] | null => { if (row == null) return row; if (Array.isArray(row)) return row.map((one) => wrap(one, at)) as T[]; + const known = proxies.get(row) ?? new Map(); + proxies.set(row, known); + if (known.has(at)) return known.get(at) as T; + // Properties the caller assigned itself. A projected read does not carry them, but writing one // and reading it back is what the write paths do — `createApiKey` sets the filter code on the // row before passing it to the update — and that is not a projection defect. const written = new Set(); - return new Proxy(row, { + const proxy = new Proxy(row, { set(source, property, value, receiver) { written.add(String(property)); return Reflect.set(source, property, value, receiver); @@ -499,6 +513,10 @@ function guardAgainst( return value; }, }) as T; + + known.set(at, proxy); + + return proxy; }; return wrap(entity as ObjectLiteral, root); From 15ecb523cd6b691cfc52f12b7b6b6ac3ba34c972 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:45:05 +0200 Subject: [PATCH 40/46] Throw on an eager relation the query does not join Dereferencing an undeclared relation throws on its own, which is why the guard let it pass. But `if (row.relation)` never dereferences: it reads undefined, takes the absent branch and answers - the same silent wrong answer the guard exists to prevent, one level up. Only eager relations count. Those the unprojected query did carry, so a projection failing to join one changes what the endpoint answers. A lazy relation was undefined before the conversion too: reading it may well be a defect, but not one this change introduced, and a guard reporting it would be reporting the code it replaced. `UserData.kycSteps` is that case, and it is why the distinction is drawn from the metadata rather than from a list of exceptions. Also corrects a rule in load-sites.md that contradicted the one this branch measures by: the distinction is not the presence of a dot - `COUNT(*)` has none and narrows - but whether the argument is the bare root alias. --- docs/load-sites.md | 2 +- .../projection-guard.projection.spec.ts | 36 +++++++++++++++++++ src/shared/utils/projection-test.util.ts | 25 ++++++++++--- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/docs/load-sites.md b/docs/load-sites.md index f589cdd35c..702e5c3416 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -25,7 +25,7 @@ Among the query builders, the field list is what decides whether anything is act | `getCount()` or `getExists()` — the select list is discarded, **no row is materialised** | 3 | | projects, but a `leftJoinAndSelect` loads a relation whole | 1 | -`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The distinction is the presence of a dot in the argument, and it matters — the sites that name columns this way select 2 columns at the median, against 957 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. +`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The rule is the one stated in [read-path-projections.md](read-path-projections.md): a bare identifier is the root alias and loads everything, anything else — a column or an expression such as `COUNT(*)` — narrows the query. It matters — the sites that name columns this way select 2 columns at the median, against 957 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. ## Measurements diff --git a/src/shared/utils/__tests__/projection-guard.projection.spec.ts b/src/shared/utils/__tests__/projection-guard.projection.spec.ts index aa07dbb1fc..0392395d14 100644 --- a/src/shared/utils/__tests__/projection-guard.projection.spec.ts +++ b/src/shared/utils/__tests__/projection-guard.projection.spec.ts @@ -100,6 +100,42 @@ describeProjection('guardProjection', () => { ); }, 120000); + it('throws on an eager relation the query does not join, without dereferencing it', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const bare = new ReadProjection('userData', [], ['userData.id']); + const row = await bare + .apply(dataSource.getRepository(UserData).createQueryBuilder('userData'), bare.fields) + .where('userData.id = :id', { id: seeded.id }) + .getOne(); + const guarded = guardProjection(dataSource, UserData, bare, bare.fields, row); + + // `UserData.language` is eager, so the unprojected query carried it. Reading it as a condition + // never dereferences: without the guard this is falsy and the caller answers as if the account + // had no language. + expect(() => (guarded.language ? 'has one' : 'has none')).toThrow( + "read of 'UserData.language', an eager relation this query does not join", + ); + }, 120000); + + it('leaves a lazy relation the query does not join alone', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const bare = new ReadProjection('userData', [], ['userData.id']); + const row = await bare + .apply(dataSource.getRepository(UserData).createQueryBuilder('userData'), bare.fields) + .where('userData.id = :id', { id: seeded.id }) + .getOne(); + const guarded = guardProjection(dataSource, UserData, bare, bare.fields, row); + + // `kycSteps` is a plain one-to-many: the query before the conversion did not carry it either, + // so reading it is not something a projection introduced. The guard reports what this change + // could have broken, not every latent defect it passes. + expect(guarded.kycSteps).toBeUndefined(); + }, 120000); + it('hands out the same guarded relation on every read', async () => { const language = await seedEntity(dataSource, Language); const seeded = await seedEntity(dataSource, UserData, { values: { language } }); diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts index 6a49081f32..45936b293f 100644 --- a/src/shared/utils/projection-test.util.ts +++ b/src/shared/utils/projection-test.util.ts @@ -478,10 +478,27 @@ function guardAgainst( return wrap(value, child); } - // A relation the projection does not declare is not guarded: it is `undefined`, and - // dereferencing it already throws. That includes the owner-side join column, which TypeORM - // reports as a column under the relation's own property name. - if (at.metadata.relations.some((relation) => relation.propertyName === name)) return value; + const relation = at.metadata.relations.find((each) => each.propertyName === name); + if (relation) { + // Dereferencing an undeclared relation throws on its own, but `if (row.relation)` does + // not dereference: it reads undefined, takes the absent branch and answers. + // + // Only eager relations are a projection defect. Those the unprojected query did load, so + // failing to join one changes what the endpoint answers. A lazy relation was undefined + // before the conversion too — reading it may well be a defect, but not one this change + // introduced, and the guard would report the same thing on the code it replaced. + // + // Guarded on the value rather than the declaration, because TypeORM reports the + // owner-side join column under the relation's own property name, and a query selecting + // that column did fill it. + if (relation.isEager && value == null && !at.asked.has(name) && !written.has(name)) + throw new Error( + `read of '${at.metadata.name}.${name}', an eager relation this query does not join — ` + + `the unprojected query carried it, so join it in the projection or stop reading it`, + ); + + return value; + } // A `@RelationId` is filled from the foreign-key column of the row, which a query naming its // fields does not carry, and no field list can select it. Guarded on the value rather than From 10db37394cefcc7f30e885ccaa187e75b860ebf7 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:58:16 +0200 Subject: [PATCH 41/46] Stop guessing what the replaced query loaded The previous commit made the guard throw when an eager relation was read that the projection does not join, on the reasoning that the unprojected query carried the eager ones. That reasoning does not hold. `getIssueData` passed `loadEagerRelations: false` and named its relations explicitly, so `SupportIssue.transactionRequest` - eager, joined by no projection - would fail on a read the replaced query answered the same way. In the other direction a non-eager relation that the old query did load and the projection dropped passes without a word. Both cases exist in this branch. No metadata on the entity records what a particular `find` loaded, so the guard cannot derive it, and this is the third round in which sharpening that rule produced a new edge rather than fewer. The rule is withdrawn instead of narrowed again. What the guard still says is what it can observe about the query in front of it: a column the field list did not select, and a relation this projection joins while selecting nothing from it. Whether the answer as a whole matches the one before the conversion is level 4's question, and it compares two real responses rather than reading a flag. --- .../projection-guard.projection.spec.ts | 30 ++++------------- src/shared/utils/projection-test.util.ts | 33 +++++++------------ 2 files changed, 19 insertions(+), 44 deletions(-) diff --git a/src/shared/utils/__tests__/projection-guard.projection.spec.ts b/src/shared/utils/__tests__/projection-guard.projection.spec.ts index 0392395d14..fdabd23ce5 100644 --- a/src/shared/utils/__tests__/projection-guard.projection.spec.ts +++ b/src/shared/utils/__tests__/projection-guard.projection.spec.ts @@ -100,7 +100,7 @@ describeProjection('guardProjection', () => { ); }, 120000); - it('throws on an eager relation the query does not join, without dereferencing it', async () => { + it('leaves a relation the query does not join alone, eager or not', async () => { const language = await seedEntity(dataSource, Language); const seeded = await seedEntity(dataSource, UserData, { values: { language } }); @@ -111,28 +111,12 @@ describeProjection('guardProjection', () => { .getOne(); const guarded = guardProjection(dataSource, UserData, bare, bare.fields, row); - // `UserData.language` is eager, so the unprojected query carried it. Reading it as a condition - // never dereferences: without the guard this is falsy and the caller answers as if the account - // had no language. - expect(() => (guarded.language ? 'has one' : 'has none')).toThrow( - "read of 'UserData.language', an eager relation this query does not join", - ); - }, 120000); - - it('leaves a lazy relation the query does not join alone', async () => { - const language = await seedEntity(dataSource, Language); - const seeded = await seedEntity(dataSource, UserData, { values: { language } }); - - const bare = new ReadProjection('userData', [], ['userData.id']); - const row = await bare - .apply(dataSource.getRepository(UserData).createQueryBuilder('userData'), bare.fields) - .where('userData.id = :id', { id: seeded.id }) - .getOne(); - const guarded = guardProjection(dataSource, UserData, bare, bare.fields, row); - - // `kycSteps` is a plain one-to-many: the query before the conversion did not carry it either, - // so reading it is not something a projection introduced. The guard reports what this change - // could have broken, not every latent defect it passes. + // Whether an undeclared relation should have been joined depends on what the replaced query + // loaded, which this entity's metadata does not record — `language` is eager and `kycSteps` is + // not, and neither fact settles it, because a `find` can switch eager loading off and name its + // relations instead. Level 4 compares the two answers where that is observable; here both are + // simply passed through. + expect(guarded.language).toBeUndefined(); expect(guarded.kycSteps).toBeUndefined(); }, 120000); diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts index 45936b293f..439841174b 100644 --- a/src/shared/utils/projection-test.util.ts +++ b/src/shared/utils/projection-test.util.ts @@ -478,27 +478,18 @@ function guardAgainst( return wrap(value, child); } - const relation = at.metadata.relations.find((each) => each.propertyName === name); - if (relation) { - // Dereferencing an undeclared relation throws on its own, but `if (row.relation)` does - // not dereference: it reads undefined, takes the absent branch and answers. - // - // Only eager relations are a projection defect. Those the unprojected query did load, so - // failing to join one changes what the endpoint answers. A lazy relation was undefined - // before the conversion too — reading it may well be a defect, but not one this change - // introduced, and the guard would report the same thing on the code it replaced. - // - // Guarded on the value rather than the declaration, because TypeORM reports the - // owner-side join column under the relation's own property name, and a query selecting - // that column did fill it. - if (relation.isEager && value == null && !at.asked.has(name) && !written.has(name)) - throw new Error( - `read of '${at.metadata.name}.${name}', an eager relation this query does not join — ` + - `the unprojected query carried it, so join it in the projection or stop reading it`, - ); - - return value; - } + // A relation the projection does not declare is left alone, and deliberately so. + // + // Reading one answers `undefined`, and `if (row.relation)` then takes the absent branch + // without dereferencing anything — a real hazard. But whether that is a defect depends on + // what the replaced query loaded, and no metadata on this entity says. `isEager` is not it: + // `getIssueData` passed `loadEagerRelations: false` and named its relations explicitly, so + // an eager relation it never carried would fail here, while a non-eager one it did load and + // the projection dropped would pass. Both directions exist in this branch. + // + // What the replaced query loaded is compared where it can actually be observed: level 4 + // runs the endpoint against a full load and compares the two answers. + if (at.metadata.relations.some((relation) => relation.propertyName === name)) return value; // A `@RelationId` is filled from the foreign-key column of the row, which a query naming its // fields does not carry, and no field list can select it. Guarded on the value rather than From a8022cc10bc7a87de24e1ae0f281dd4c201278d6 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:15:41 +0000 Subject: [PATCH 42/46] docs: carry the corrections from the base branch through the conversions The base branch corrected several statements after this branch left it, and the rebase brought some of them back: the load-site total is an upper bound rather than a count, the duplicated paths are not an old handler beside its replacement, and the endpoints with no measured width are the ones the upper bound actually reaches. The mechanism labels were German in an otherwise English table, and this branch added four more of them. All six now read as the definition table above them does. Three statements this branch makes about itself did not hold. Eighteen of the nineteen endpoints that were already projecting read 0/4; the nineteenth is the debug endpoint, whose field list comes from the request, so it stays n/a. The paragraph saying one site carries the projection risk and every other read loads whole rows was true when it was written and is not after these conversions - 113 sites now name their columns. And level 2 was credited with asserting the filter, which its definition does not cover: the endpoint specs do exercise it, no level requires it. --- docs/endpoints.md | 6 +- docs/load-sites.md | 284 +++++++++++++++++----------------- docs/read-path-projections.md | 25 +-- 3 files changed, 161 insertions(+), 154 deletions(-) diff --git a/docs/endpoints.md b/docs/endpoints.md index 884aa4d4cc..a0ced2d1eb 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -34,7 +34,7 @@ Today 36 endpoints read only what they return and 398 do not, so the column read | `projected` | 36 | 7 % | | `caller-defined` | 2 | 0 % | -Of the 36 that read only what they return, 17 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11), `GET /kyc/users` (328 columns to 7), `GET /kyc/:id/documents` (328 columns to 2), `GET /custody/order` (19 columns to 14), `GET /support/issue/list` (16 columns to 10), `GET /realunit/support/list` (16 columns to 10), `GET /dashboard/accounting/ledger/suspense` (11 columns to 10), `GET /liquidityManagement/pipeline/:id/status` (112 columns to 2), `PUT /paymentLink/:id/pos` (513 columns to 7), `POST /user/apiKey/CT` (253 columns to 3), `GET /user` (351 columns to 66). The other 19 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why their `Tests` column reads `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. +Of the 36 that read only what they need, 17 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11), `GET /kyc/users` (328 columns to 7), `GET /kyc/:id/documents` (328 columns to 2), `GET /custody/order` (19 columns to 14), `GET /support/issue/list` (16 columns to 10), `GET /realunit/support/list` (16 columns to 10), `GET /dashboard/accounting/ledger/suspense` (11 columns to 10), `GET /liquidityManagement/pipeline/:id/status` (112 columns to 2), `PUT /paymentLink/:id/pos` (513 columns to 7), `POST /user/apiKey/CT` (253 columns to 3), `GET /user` (351 columns to 66). The other 19 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why 18 of them read `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. The nineteenth is `POST /gs/debug`, which stays `n/a` because its field list comes from the request and there is no fixed projection to test. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. Among the 398 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 306 exceed 100, 74 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. @@ -44,7 +44,7 @@ Among the 398 that fetch whole rows, the widest query they can trigger is **308 ### Deprecation -24 handlers carry `@ApiOperation({ deprecated: true })`: 19 of them fetch whole rows, 2 project, 3 read nothing. They are what the duplicated paths are about — an older handler and its replacement served side by side under different versions. Note that deprecation does not follow the version: `GET /kyc/countries` is marked on **both** the v1 and the v2 handler. +24 handlers carry `@ApiOperation({ deprecated: true })`: 19 of them fetch whole rows, 2 project, 3 read nothing. Deprecation does not follow the version, and the duplicated paths are not simply an old handler beside its replacement: `GET /kyc/countries` is marked on **both** its v1 and its v2 handler, and `GET /user/ref` on neither. ### Limits of this classification @@ -53,7 +53,7 @@ Stated exactly, so the numbers can be checked rather than believed: - **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 398 is a lower bound. - All 98 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. - The 36 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. -- 2 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`. The classification holds; only the width is unknown. +- 2 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`. They are also the ones most exposed to the upper bound described in [load-sites.md](load-sites.md#measurements): with no measured query behind them, nothing here shows that they reach a whole-row read at all. ### Two controller classes may share a name diff --git a/docs/load-sites.md b/docs/load-sites.md index 702e5c3416..db774501b9 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -1,6 +1,6 @@ # Database load sites -Every place in the code that reads from the database: **1105 load sites** across 249 files. +Every place in the code that reads from the database: **at most 1105 load sites** across 249 files — an upper bound, for the reason given under *Measurements*. This is the level at which the statement is unambiguous. An endpoint reaches several load sites — a permission check, a lookup, the actual query — so asking whether *an endpoint* loads efficiently has no single answer. Asking it of a load site does. [endpoints.md](endpoints.md) carries the per-endpoint summary derived from these sites. @@ -35,9 +35,13 @@ Columns were measured against the real entity metadata by building the query and - **452 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. - 315 could not be measured: no resolvable target entity, or raw SQL. +**That last group is also why the total is an upper bound.** The collection matches `find` by name, and `find` on a repository is indistinguishable by name from `find` on an array. Where the target entity resolved, the distinction is settled; where it did not, the group holds both. A sample of 30 of those rows, read in the source, came out at 21 array operations to 9 genuine repository reads — so on the order of 200 rows here are not database reads at all, and the true count is nearer 900. + +What that does and does not affect: the median and the counts below are computed only over the rows that carry a measured width, and an array operation never has one, so those figures stand. Nor does it move the conclusion this table exists for — the sites that name their columns are a small fraction either way. It does reach the per-endpoint summary in [endpoints.md](endpoints.md), where the endpoints with no measured width at all are marked as the exposed cases. + Median across measured sites: **98 columns**. 14 sites exceed 1000, 71 exceed 500, 390 exceed 100. -Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. +Postgres refuses a statement with more than 1664 columns. The widest measured site here sits at 1453, so a little over two hundred columns separate it from a rejected statement — and 452 of these measurements are lower bounds, so the real margin can be smaller. ## Load sites @@ -435,9 +439,9 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:257` | `TransactionRequestService.getTransactionRequestByUid` | | 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:261` | `TransactionRequestService.getOpenBuyQuotes` | | 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:424` | `TransactionRequestService.getByAssetId` | -| 99 | 0 | query-builder (nur-alias) | `UserData` | `subdomains/generic/user/models/user-data/user-data-notification.service.ts:173` | `UserDataNotificationService.blackSquadInvitation` | -| 99 | 0 | query-builder (ohne-select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:141` | `UserDataService.getUserDataByUser` | -| 99 | 0 | query-builder (nur-alias) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:313` | `UserDataService.getUserDataByKey` | +| 99 | 0 | query-builder (alias only) | `UserData` | `subdomains/generic/user/models/user-data/user-data-notification.service.ts:173` | `UserDataNotificationService.blackSquadInvitation` | +| 99 | 0 | query-builder (no select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:141` | `UserDataService.getUserDataByUser` | +| 99 | 0 | query-builder (alias only) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:313` | `UserDataService.getUserDataByKey` | | 98 | 2 | find | `User` | `subdomains/generic/user/models/user-data/user-data.service.ts:1033` | `UserDataService.customIdentMethod` | | 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:274` | `UserService.getRefDtoV2` | | 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:282` | `UserService.updateRef` | @@ -471,15 +475,15 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:110` | `LiquidityManagementRuleService.reactivateRules` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:147` | `LiquidityManagementRuleService.findExistingRuleOnCreation` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:109` | `LiquidityManagementService.findRuleByAssetOrThrow` | -| 81 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:336` | `SupportIssueRepository.findIssueData` | +| 81 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:336` | `SupportIssueRepository.findIssueData` | | 78 | 1 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:97` | `UserService.getUserByAddress` | | 78 | 3 | find | `Mros` | `subdomains/supporting/mros/mros.service.ts:32` | `MrosService.update` | -| 77 | 0 | query-builder (nur-alias) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:712` | `BuyCryptoService.getBuyCryptoByKeys` | +| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:712` | `BuyCryptoService.getBuyCryptoByKeys` | | 75 | 2 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:238` | `BuyCryptoBatchService.filterOutExistingBatches` | -| 71 | 0 | query-builder (nur-alias) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:340` | `BuyFiatService.getBuyFiatByKey` | +| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:340` | `BuyFiatService.getBuyFiatByKey` | | 71 | 1 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:68` | `RecallService.getByBankTxIds` | | 68 | 4 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:204` | `SwapService.getById` | -| 66 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:252` | `UserDataRepository.getUserV2` | +| 66 | 0 | query-builder (field list) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:252` | `UserDataRepository.getUserV2` | | 65 | 2 | find | `Fee` | `subdomains/supporting/payment/services/fee.service.ts:115` | `FeeService.createFee` | | 61 | 0 | find | `BankTx` | `subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts:386` | `ExchangeTxConsumer.hasBankRouteMatch` | | 61 | 0 | find | `BankTx` | `subdomains/core/accounting/services/ledger-cutover.service.ts:710` | `LedgerCutoverService.openUnattributed` | @@ -530,11 +534,11 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 46 | 0 | query-builder (alias only) | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:231` | `FiatOutputService.getFiatOutputByKey` | | 45 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:124` | `PaymentQuoteService.getQuoteByAsset` | | 45 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:143` | `PaymentQuoteService.getQuoteByTxId` | -| 45 | 0 | query-builder (nur-alias) | `User` | `subdomains/generic/user/models/user/user.service.ts:102` | `UserService.getUserByKey` | -| 45 | 0 | query-builder (ohne-select) | `User` | `subdomains/generic/user/models/user/user.service.ts:177` | `UserService.getOpenRefCreditUser` | +| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:102` | `UserService.getUserByKey` | +| 45 | 0 | query-builder (no select) | `User` | `subdomains/generic/user/models/user/user.service.ts:177` | `UserService.getOpenRefCreditUser` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:41` | `FaucetRequestService.checkFaucetRequests` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:93` | `FaucetRequestService.resetFaucet` | -| 41 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:267` | `UserDataRepository.getProfile` | +| 41 | 0 | query-builder (field list) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:267` | `UserDataRepository.getProfile` | | 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody-pdf.service.ts:47` | `CustodyPdfService.getBalancesWithHistoricalPrices` | | 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:111` | `CustodyService.getUserCustodyBalance` | | 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:238` | `CustodyService.updateCustodyBalance` | @@ -596,7 +600,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:387` | `DashboardReconciliationService.getExchangeFlows` | | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:544` | `DashboardReconciliationService.getExchangeWithdrawalsForBlockchain` | | 27 | 2 | find | `LiquidityManagementAction` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:257` | `LiquidityManagementRuleService.findExistingAction` | -| 26 | 0 | query-builder (nur-alias) | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:22` | `BankAccountService.getBankAccountByKey` | +| 26 | 0 | query-builder (alias only) | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:22` | `BankAccountService.getBankAccountByKey` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:40` | `BankAccountService.checkFailedBankAccounts` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:48` | `BankAccountService.reloadErrorBankAccounts` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:56` | `BankAccountService.reloadUncheckedBankAccounts` | @@ -612,19 +616,19 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:28` | `CountryService.getCountriesByKycType` | | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:31` | `CountryService.getCountriesByKycType` | | 23 | 0 | find | `BankTxBatch` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx-batch.service.ts:10` | `BankTxBatchService.getBankTxBatchByIban` | -| 21 | 0 | query-builder (ohne-select) | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:87` | `DepositRouteService.getPaymentRouteForKey` | -| 20 | 0 | query-builder (nur-alias) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:95` | `SellService.getSellByKey` | +| 21 | 0 | query-builder (no select) | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:87` | `DepositRouteService.getPaymentRouteForKey` | +| 20 | 0 | query-builder (alias only) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:95` | `SellService.getSellByKey` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:54` | `WalletRepository.getByAddress` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:19` | `WalletService.updateWallet` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:28` | `WalletService.getByAddress` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:36` | `WalletService.getByIdOrName` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:40` | `WalletService.getKycClients` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:44` | `WalletService.getDefault` | -| 20 | 0 | query-builder (nur-alias) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:221` | `TransactionService.getTransactionList` | -| 20 | 0 | query-builder (nur-alias) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:389` | `TransactionService.getTransactionByKey` | -| 20 | 0 | query-builder (ohne-select) | `PriceRule` | `subdomains/supporting/pricing/services/pricing.service.ts:272` | `PricingService.getRuleFor` | -| 19 | 0 | query-builder (ohne-select) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:79` | `SwapService.getSwapByAddress` | -| 19 | 0 | query-builder (nur-alias) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:152` | `SwapService.getSwapByKey` | +| 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:221` | `TransactionService.getTransactionList` | +| 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:389` | `TransactionService.getTransactionByKey` | +| 20 | 0 | query-builder (no select) | `PriceRule` | `subdomains/supporting/pricing/services/pricing.service.ts:272` | `PricingService.getRuleFor` | +| 19 | 0 | query-builder (no select) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:79` | `SwapService.getSwapByAddress` | +| 19 | 0 | query-builder (alias only) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:152` | `SwapService.getSwapByKey` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:83` | `KycLogService.updateLog` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:90` | `KycLogService.updateLogPdfUrl` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:109` | `KycLogService.getLogsByUserDataId` | @@ -633,18 +637,18 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:27` | `FiatService.getFiat` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:33` | `FiatService.getFiatByName` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:43` | `FiatService.getFiatByCountry` | -| 16 | 0 | query-builder (projektion-mit-vollem-join) | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:148` | `PaymentLinkPaymentService.getMostRecentPayments` | -| 16 | 0 | query-builder (nur-alias) | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1294` | `VirtualIbanService.getVirtualIbanByKey` | +| 16 | 0 | query-builder (projected, full join) | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:148` | `PaymentLinkPaymentService.getMostRecentPayments` | +| 16 | 0 | query-builder (alias only) | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1294` | `VirtualIbanService.getVirtualIbanByKey` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:20` | `WalletAppService.getAllBlockchainWalletApps` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:24` | `WalletAppService.getRecommendedWalletApps` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:28` | `WalletAppService.getWalletAppById` | -| 15 | 0 | query-builder (nur-alias) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:284` | `BankDataService.getBankDataByKey` | +| 15 | 0 | query-builder (alias only) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:284` | `BankDataService.getBankDataByKey` | | 15 | 0 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:2830` | `RealUnitService.getRegisteredWalletAddresses` | | 14 | 0 | find | `ScorechainScreening` | `integration/scorechain/repositories/scorechain-screening.repository.ts:18` | `ScorechainScreeningRepository.getByObjectIds` | | 14 | 0 | find | `ScorechainScreening` | `integration/scorechain/services/scorechain-screening.service.ts:233` | `ScorechainScreeningService.getCached` | -| 14 | 0 | query-builder (feldliste) | `CustodyOrder` | `subdomains/core/custody/repositories/custody-order.repository.ts:59` | `CustodyOrderRepository.findHistoryFor` | -| 14 | 0 | query-builder (feldliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/buy-fiat.repository.ts:57` | `BuyFiatRepository.findSellHistory` | -| 13 | 0 | query-builder (nur-alias) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:228` | `BuyService.getBuyByKey` | +| 14 | 0 | query-builder (field list) | `CustodyOrder` | `subdomains/core/custody/repositories/custody-order.repository.ts:59` | `CustodyOrderRepository.findHistoryFor` | +| 14 | 0 | query-builder (field list) | `BuyFiat` | `subdomains/core/sell-crypto/process/buy-fiat.repository.ts:57` | `BuyFiatRepository.findSellHistory` | +| 13 | 0 | query-builder (alias only) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:228` | `BuyService.getBuyByKey` | | 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:76` | `PaymentQuoteService.processExpiredQuotes` | | 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:109` | `PaymentQuoteService.getActualQuoteByPaymentId` | | 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:157` | `PaymentQuoteService.cancelAllForPayment` | @@ -669,10 +673,10 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 12 | 0 | find | `IpLog` | `shared/models/ip-log/ip-log.service.ts:66` | `IpLogService.getByUserDataId` | | 12 | 0 | find | `IpLog` | `shared/models/ip-log/ip-log.service.ts:75` | `IpLogService.getLoginCountries` | | 12 | 0 | find | `IpLog` | `shared/models/ip-log/ip-log.service.ts:119` | `IpLogService.updateUserIpLogs` | -| 12 | 0 | query-builder (feldliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:77` | `BuyCryptoRepository.findBuyHistory` | -| 12 | 0 | query-builder (feldliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:91` | `BuyCryptoRepository.findSwapHistory` | +| 12 | 0 | query-builder (field list) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:77` | `BuyCryptoRepository.findBuyHistory` | +| 12 | 0 | query-builder (field list) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:91` | `BuyCryptoRepository.findSwapHistory` | | 11 | 0 | find | `OlkyRecipient` | `integration/bank/services/olkypay.service.ts:104` | `OlkypayService.getOrCreateRecipient` | -| 11 | 0 | query-builder (ohne-select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:125` | `LedgerQueryService.getAccountDetail` | +| 11 | 0 | query-builder (no select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:125` | `LedgerQueryService.getAccountDetail` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:119` | `LogRepository.getFinancialLogAt` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:132` | `LogRepository.getLatestFinancialLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:142` | `LogRepository.getLatestValidFinancialLogs` | @@ -681,17 +685,17 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:51` | `LogService.update` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:131` | `LogService.getLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:135` | `LogService.maxEntity` | -| 11 | 0 | query-builder (ohne-select) | `Log` | `subdomains/supporting/log/log.service.ts:191` | `LogService.getBankLog` | -| 11 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:346` | `SupportIssueRepository.findIssuesForAccount` | -| 11 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:364` | `SupportIssueRepository.findIssueBy` | -| 10 | 0 | query-builder (feldliste) | `LedgerLeg` | `subdomains/core/accounting/repositories/ledger-leg.repository.ts:55` | `LedgerLegRepository.findSuspenseLegs` | +| 11 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.service.ts:191` | `LogService.getBankLog` | +| 11 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:346` | `SupportIssueRepository.findIssuesForAccount` | +| 11 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:364` | `SupportIssueRepository.findIssueBy` | +| 10 | 0 | query-builder (field list) | `LedgerLeg` | `subdomains/core/accounting/repositories/ledger-leg.repository.ts:55` | `LedgerLegRepository.findSuspenseLegs` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:61` | `AccountMergeService.sendMergeRequest` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:162` | `AccountMergeService.pendingMergeRequest` | -| 10 | 0 | query-builder (feldliste) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:271` | `SupportIssueRepository.findIssueList` | +| 10 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:271` | `SupportIssueRepository.findIssueList` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:57` | `SupportNoteService.search` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:76` | `SupportNoteService.search` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:152` | `SupportNoteService.delete` | -| 9 | 0 | query-builder (spaltenliste) | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1214` | `UserDataService.updateVolumes` | +| 9 | 0 | query-builder (named columns) | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1214` | `UserDataService.updateVolumes` | | 9 | 0 | find | `TransactionSpecification` | `subdomains/supporting/payment/services/transaction-helper.ts:92` | `TransactionHelper.updateCache` | | 8 | 0 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-account.service.ts:12` | `LedgerAccountService.findByName` | | 8 | 0 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-account.service.ts:16` | `LedgerAccountService.findByAssetId` | @@ -707,17 +711,17 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:15` | `LanguageService.getLanguage` | | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:19` | `LanguageService.getLanguageBySymbol` | | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:24` | `LanguageService.getLanguageByCountry` | -| 7 | 0 | query-builder (feldliste) | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:66` | `PaymentLinkRepository.findForPosLink` | +| 7 | 0 | query-builder (field list) | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:66` | `PaymentLinkRepository.findForPosLink` | | 7 | 0 | find | `UserDataRelation` | `subdomains/generic/user/models/user-data-relation/user-data-relation.service.ts:40` | `UserDataRelationService.updateUserDataRelation` | -| 7 | 0 | query-builder (feldliste) | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:48` | `WalletRepository.findKycData` | +| 7 | 0 | query-builder (field list) | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:48` | `WalletRepository.findKycData` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:12` | `SpecialExternalAccountService.createSpecialExternalAccount` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:24` | `SpecialExternalAccountService.getMultiAccounts` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:48` | `SpecialExternalAccountService.getPhoneCallList` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:58` | `SpecialExternalAccountService.getBlacklist` | | 7 | 0 | find | `AssetPrice` | `subdomains/supporting/pricing/services/asset-prices-job.service.ts:81` | `AssetPricesJobService.saveAssetPrices` | | 7 | 0 | find | `RealUnitLegalAcceptance` | `subdomains/supporting/realunit/realunit-legal.service.ts:57` | `RealUnitLegalService.getLatestAcceptance` | -| 6 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:173` | `LedgerMarkToMarketService.accountBalance` | -| 6 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:517` | `LedgerReconciliationService.nativeBalanceByAccount` | +| 6 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:173` | `LedgerMarkToMarketService.accountBalance` | +| 6 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:517` | `LedgerReconciliationService.nativeBalanceByAccount` | | 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.repository.ts:13` | `RefRepository.getAndRemove` | | 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.service.ts:22` | `RefService.checkRefs` | | 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.service.ts:28` | `RefService.addOrUpdate` | @@ -729,7 +733,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 6 | 0 | find | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:75` | `DepositService.getDepositsByBlockchain` | | 6 | 0 | find | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:79` | `DepositService.getDepositByBlockchainAndIndex` | | 6 | 0 | find | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:83` | `DepositService.getUsedDepositsByBlockchain` | -| 6 | 0 | query-builder (ohne-select) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:89` | `DepositService.getNextDeposit` | +| 6 | 0 | query-builder (no select) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:89` | `DepositService.getNextDeposit` | | 5 | 0 | find | `Setting` | `shared/models/setting/setting.repository.ts:36` | `SettingRepository.getStatusSettings` | | 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:16` | `SettingService.getAll` | | 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:26` | `SettingService.get` | @@ -738,103 +742,103 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:206` | `SettingService.getObjCached` | | 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:210` | `SettingService.setObj` | | 5 | 0 | find | `Sanction` | `subdomains/core/aml/services/sanction.service.ts:54` | `SanctionService.syncList` | -| 5 | 0 | query-builder (spaltenliste) | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:84` | `SupportNoteService.listUsers` | -| 5 | 0 | query-builder (feldliste) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:61` | `SupportMessageRepository.findThread` | -| 4 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:460` | `LedgerQueryService.marginBuckets` | -| 4 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:534` | `LedgerQueryService.cumulativeEquityByDay` | +| 5 | 0 | query-builder (named columns) | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:84` | `SupportNoteService.listUsers` | +| 5 | 0 | query-builder (field list) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:61` | `SupportMessageRepository.findThread` | +| 4 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:460` | `LedgerQueryService.marginBuckets` | +| 4 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:534` | `LedgerQueryService.cumulativeEquityByDay` | | 4 | 0 | find | `SystemStateSnapshot` | `subdomains/core/monitoring/monitoring.service.ts:47` | `MonitoringService.loadState` | -| 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:278` | `LedgerQueryService.balancesByAccount` | -| 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:271` | `LedgerReconciliationService.checkTransitAge` | -| 3 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:347` | `LedgerReconciliationService.openResidualSince` | -| 3 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1051` | `BuyCryptoService.updateBuyVolume` | -| 3 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1079` | `BuyCryptoService.updateCryptoRouteVolume` | -| 3 | 0 | query-builder (spaltenliste) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:101` | `BuyService.getUserVolume` | -| 3 | 0 | query-builder (spaltenliste) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:123` | `SwapService.getUserVolume` | -| 3 | 0 | query-builder (spaltenliste) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:677` | `CustodyService.getHistoricalBalances` | -| 3 | 0 | query-builder (spaltenliste) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:689` | `CustodyService.getHistoricalBalances` | -| 3 | 0 | query-builder (spaltenliste) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:278` | `RefRewardService.getRewardRecipients` | -| 3 | 0 | query-builder (spaltenliste) | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:97` | `BuyFiatRegistrationService.filterSellPayIns` | -| 3 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:678` | `BuyFiatService.updateSellVolume` | -| 3 | 0 | query-builder (spaltenliste) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:261` | `SellService.getUserVolume` | -| 3 | 0 | query-builder (spaltenliste) | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1981` | `KycService.getPendingReviewSummary` | -| 3 | 0 | query-builder (feldliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:240` | `UserDataRepository.getForApiKey` | -| 3 | 0 | query-builder (spaltenliste) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:461` | `BankTxService.getBankTxFee` | -| 3 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:310` | `SupportEscalationService.getLastMessages` | -| 3 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:245` | `SupportIssueService.getSupportIssueStatistics` | -| 2 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:320` | `LedgerQueryService.nativeBalanceByAccount` | -| 2 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:400` | `LedgerReconciliationService.checkSuspense` | -| 2 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1122` | `BuyCryptoService.getRefVolume` | -| 2 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1134` | `BuyCryptoService.getPartnerFeeRefVolume` | -| 2 | 0 | query-builder (feldliste) | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts:43` | `LiquidityManagementPipelineRepository.findForStatus` | -| 2 | 0 | query-builder (spaltenliste) | `—` | `subdomains/core/monitoring/observers/payment.observer.ts:72` | `PaymentObserver.getPayment` | -| 2 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:721` | `BuyFiatService.getRefVolume` | -| 2 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:733` | `BuyFiatService.getPartnerFeeRefVolume` | -| 2 | 0 | query-builder (spaltenliste) | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:53` | `TradingOrderService.getTradingOrderYield` | -| 2 | 0 | query-builder (spaltenliste) | `—` | `subdomains/generic/gs/gs.service.ts:868` | `GsService.getExtendedBankTxData` | -| 2 | 0 | query-builder (spaltenliste) | `—` | `subdomains/generic/gs/gs.service.ts:887` | `GsService.getExtendedBankTxData` | -| 2 | 0 | query-builder (spaltenliste) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:493` | `BankDataService.getPendingReviewSummary` | -| 2 | 0 | query-builder (spaltenliste) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:354` | `RecommendationService.countByRecommenderIds` | -| 2 | 0 | query-builder (spaltenliste) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:369` | `RecommendationService.countByRecommendedIds` | -| 2 | 0 | query-builder (feldliste) | `User` | `subdomains/generic/user/models/user/user.repository.ts:47` | `UserRepository.findAccountIdForAddress` | -| 2 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:226` | `UserService.countRefChildrenByUserDataIds` | -| 2 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:248` | `UserService.countRefReferrersByUserDataIds` | -| 2 | 0 | query-builder (spaltenliste) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:505` | `BankTxService.getBankTxFee` | -| 2 | 0 | query-builder (feldliste) | `Log` | `subdomains/supporting/log/log.repository.ts:699` | `LogRepository.getFinancialLogValidityChangeSet` | -| 2 | 0 | query-builder (spaltenliste) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:324` | `TransactionService.getManualRefVolume` | -| 2 | 0 | query-builder (spaltenliste) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:353` | `TransactionService.getAuditPeriodVolumes` | -| 2 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:96` | `SupportMessageRepository.findStatsFor` | -| 2 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:109` | `SupportIssueService.getSupportIssueCounts` | -| 2 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:133` | `SupportIssueService.getSupportIssueActivity` | -| 2 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:198` | `SupportIssueService.getSupportIssueStatistics` | -| 1 | 0 | query-builder (spaltenliste) | `Asset` | `shared/models/asset/asset.service.ts:140` | `AssetService.getAssetsUsedOn` | -| 1 | 0 | query-builder (spaltenliste) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:79` | `IpLogService.getLoginCountries` | -| 1 | 0 | query-builder (spaltenliste) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:92` | `IpLogService.getUserDataIdsWith` | -| 1 | 0 | query-builder (spaltenliste) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:103` | `IpLogService.getUserDataIdsWith` | -| 1 | 0 | query-builder (spaltenliste) | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:335` | `LedgerBookingService.nextSeqFrom` | -| 1 | 0 | query-builder (spaltenliste) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:958` | `LedgerCutoverService.maxSettledId` | -| 1 | 0 | query-builder (spaltenliste) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:1002` | `LedgerCutoverService.idsUpToBoundary` | -| 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:106` | `LedgerMarkToMarketService.selectCandidates` | -| 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:292` | `LedgerQueryService.nativeBalanceBefore` | -| 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:304` | `LedgerQueryService.nativeBalanceInPeriod` | -| 1 | 0 | query-builder (spaltenliste) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:487` | `LedgerReconciliationService.journalEquity` | -| 1 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:767` | `BuyCryptoService.updateRefVolumes` | -| 1 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:883` | `BuyCryptoService.getUserVolumeForType` | -| 1 | 0 | query-builder (spaltenliste) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:955` | `BuyCryptoService.getPendingLiquidityDemandChf` | -| 1 | 0 | query-builder (spaltenliste) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:111` | `BuyService.getTotalVolume` | -| 1 | 0 | query-builder (spaltenliste) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:133` | `SwapService.getTotalVolume` | -| 1 | 0 | query-builder (spaltenliste) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:221` | `CustodyService.updateCustodyBalance` | -| 1 | 0 | query-builder (spaltenliste) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:229` | `CustodyService.updateCustodyBalance` | -| 1 | 0 | query-builder (spaltenliste) | `—` | `subdomains/core/monitoring/observers/bank.observer.ts:117` | `BankObserver.getDbBalance` | -| 1 | 0 | query-builder (spaltenliste) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:216` | `RefRewardService.getRefRewardVolume` | -| 1 | 0 | query-builder (spaltenliste) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:249` | `RefRewardService.updatePaidRefCredit` | -| 1 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:586` | `BuyFiatService.updateRefVolumes` | -| 1 | 0 | query-builder (spaltenliste) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:603` | `BuyFiatService.getUserVolume` | -| 1 | 0 | query-builder (spaltenliste) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:271` | `SellService.getTotalVolume` | -| 1 | 0 | query-builder (spaltenliste) | `TradingOrder` | `subdomains/core/trading/services/trading-rule.service.ts:35` | `TradingRuleService.getCurrentTradingOrders` | -| 1 | 0 | query-builder (ohne-select) | `—` | `subdomains/generic/gs/gs.service.ts:906` | `GsService.getExtendedBankTxData` | -| 1 | 0 | query-builder (spaltenliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:179` | `UserDataService.getUserDataIdsByServiceProvider` | -| 1 | 0 | query-builder (spaltenliste) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1774` | `UserDataService.getMaxKycFileIdByDateRange` | -| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:140` | `UserService.getAllLinkedUsers` | -| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:195` | `UserService.getOpenRefCreditEur` | -| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:576` | `UserService.getUserVolumes` | -| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:586` | `UserService.getUserVolumes` | -| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:648` | `UserService.getRefInfo` | -| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:661` | `UserService.getRefInfo` | -| 1 | 0 | query-builder (spaltenliste) | `User` | `subdomains/generic/user/models/user/user.service.ts:720` | `UserService.getTotalRefRewards` | -| 1 | 0 | query-builder (spaltenliste) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:189` | `DepositService.getNextDepositIndex` | -| 1 | 0 | query-builder (spaltenliste) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:449` | `BankTxService.getBankTxFee` | -| 1 | 0 | query-builder (spaltenliste) | `Log` | `subdomains/supporting/log/log.repository.ts:97` | `LogRepository.cleanup` | -| 1 | 0 | query-builder (spaltenliste) | `Log` | `subdomains/supporting/log/log.repository.ts:104` | `LogRepository.cleanup` | -| 1 | 0 | query-builder (spaltenliste) | `Log` | `subdomains/supporting/log/log.repository.ts:158` | `LogRepository.getFinancialChangesLogs` | -| 1 | 0 | query-builder (ohne-select) | `Log` | `subdomains/supporting/log/log.repository.ts:165` | `LogRepository.getFinancialChangesLogs` | -| 1 | 0 | query-builder (spaltenliste) | `Log` | `subdomains/supporting/log/log.repository.ts:206` | `LogRepository.getFinancialLogs` | -| 1 | 0 | query-builder (ohne-select) | `Log` | `subdomains/supporting/log/log.repository.ts:214` | `LogRepository.getFinancialLogs` | -| 1 | 0 | query-builder (ohne-select) | `Log` | `subdomains/supporting/log/log.repository.ts:244` | `LogRepository.getFinancialLogs` | -| 1 | 0 | query-builder (spaltenliste) | `—` | `subdomains/supporting/payin/services/payin.service.ts:217` | `PayInService.getPayInFee` | -| 1 | 0 | query-builder (spaltenliste) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:352` | `TransactionRequestService.getLegacySettlementTxIds` | -| 1 | 0 | query-builder (spaltenliste) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:406` | `TransactionRequestService.getActiveDepositAddresses` | -| 1 | 0 | query-builder (spaltenliste) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:177` | `SupportIssueService.getSupportIssueStatistics` | -| 1 | 0 | query-builder (spaltenliste) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:186` | `SupportIssueService.getSupportIssueStatistics` | +| 3 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:278` | `LedgerQueryService.balancesByAccount` | +| 3 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:271` | `LedgerReconciliationService.checkTransitAge` | +| 3 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:347` | `LedgerReconciliationService.openResidualSince` | +| 3 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1051` | `BuyCryptoService.updateBuyVolume` | +| 3 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1079` | `BuyCryptoService.updateCryptoRouteVolume` | +| 3 | 0 | query-builder (named columns) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:101` | `BuyService.getUserVolume` | +| 3 | 0 | query-builder (named columns) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:123` | `SwapService.getUserVolume` | +| 3 | 0 | query-builder (named columns) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:677` | `CustodyService.getHistoricalBalances` | +| 3 | 0 | query-builder (named columns) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:689` | `CustodyService.getHistoricalBalances` | +| 3 | 0 | query-builder (named columns) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:278` | `RefRewardService.getRewardRecipients` | +| 3 | 0 | query-builder (named columns) | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:97` | `BuyFiatRegistrationService.filterSellPayIns` | +| 3 | 0 | query-builder (named columns) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:678` | `BuyFiatService.updateSellVolume` | +| 3 | 0 | query-builder (named columns) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:261` | `SellService.getUserVolume` | +| 3 | 0 | query-builder (named columns) | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1981` | `KycService.getPendingReviewSummary` | +| 3 | 0 | query-builder (field list) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:240` | `UserDataRepository.getForApiKey` | +| 3 | 0 | query-builder (named columns) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:461` | `BankTxService.getBankTxFee` | +| 3 | 0 | query-builder (named columns) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:310` | `SupportEscalationService.getLastMessages` | +| 3 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:245` | `SupportIssueService.getSupportIssueStatistics` | +| 2 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:320` | `LedgerQueryService.nativeBalanceByAccount` | +| 2 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:400` | `LedgerReconciliationService.checkSuspense` | +| 2 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1122` | `BuyCryptoService.getRefVolume` | +| 2 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1134` | `BuyCryptoService.getPartnerFeeRefVolume` | +| 2 | 0 | query-builder (field list) | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts:43` | `LiquidityManagementPipelineRepository.findForStatus` | +| 2 | 0 | query-builder (named columns) | `—` | `subdomains/core/monitoring/observers/payment.observer.ts:72` | `PaymentObserver.getPayment` | +| 2 | 0 | query-builder (named columns) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:721` | `BuyFiatService.getRefVolume` | +| 2 | 0 | query-builder (named columns) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:733` | `BuyFiatService.getPartnerFeeRefVolume` | +| 2 | 0 | query-builder (named columns) | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:53` | `TradingOrderService.getTradingOrderYield` | +| 2 | 0 | query-builder (named columns) | `—` | `subdomains/generic/gs/gs.service.ts:868` | `GsService.getExtendedBankTxData` | +| 2 | 0 | query-builder (named columns) | `—` | `subdomains/generic/gs/gs.service.ts:887` | `GsService.getExtendedBankTxData` | +| 2 | 0 | query-builder (named columns) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:493` | `BankDataService.getPendingReviewSummary` | +| 2 | 0 | query-builder (named columns) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:354` | `RecommendationService.countByRecommenderIds` | +| 2 | 0 | query-builder (named columns) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:369` | `RecommendationService.countByRecommendedIds` | +| 2 | 0 | query-builder (field list) | `User` | `subdomains/generic/user/models/user/user.repository.ts:47` | `UserRepository.findAccountIdForAddress` | +| 2 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:226` | `UserService.countRefChildrenByUserDataIds` | +| 2 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:248` | `UserService.countRefReferrersByUserDataIds` | +| 2 | 0 | query-builder (named columns) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:505` | `BankTxService.getBankTxFee` | +| 2 | 0 | query-builder (field list) | `Log` | `subdomains/supporting/log/log.repository.ts:699` | `LogRepository.getFinancialLogValidityChangeSet` | +| 2 | 0 | query-builder (named columns) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:324` | `TransactionService.getManualRefVolume` | +| 2 | 0 | query-builder (named columns) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:353` | `TransactionService.getAuditPeriodVolumes` | +| 2 | 0 | query-builder (named columns) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:96` | `SupportMessageRepository.findStatsFor` | +| 2 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:109` | `SupportIssueService.getSupportIssueCounts` | +| 2 | 0 | query-builder (named columns) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:133` | `SupportIssueService.getSupportIssueActivity` | +| 2 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:198` | `SupportIssueService.getSupportIssueStatistics` | +| 1 | 0 | query-builder (named columns) | `Asset` | `shared/models/asset/asset.service.ts:140` | `AssetService.getAssetsUsedOn` | +| 1 | 0 | query-builder (named columns) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:79` | `IpLogService.getLoginCountries` | +| 1 | 0 | query-builder (named columns) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:92` | `IpLogService.getUserDataIdsWith` | +| 1 | 0 | query-builder (named columns) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:103` | `IpLogService.getUserDataIdsWith` | +| 1 | 0 | query-builder (named columns) | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:335` | `LedgerBookingService.nextSeqFrom` | +| 1 | 0 | query-builder (named columns) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:958` | `LedgerCutoverService.maxSettledId` | +| 1 | 0 | query-builder (named columns) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:1002` | `LedgerCutoverService.idsUpToBoundary` | +| 1 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:106` | `LedgerMarkToMarketService.selectCandidates` | +| 1 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:292` | `LedgerQueryService.nativeBalanceBefore` | +| 1 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:304` | `LedgerQueryService.nativeBalanceInPeriod` | +| 1 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:487` | `LedgerReconciliationService.journalEquity` | +| 1 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:767` | `BuyCryptoService.updateRefVolumes` | +| 1 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:883` | `BuyCryptoService.getUserVolumeForType` | +| 1 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:955` | `BuyCryptoService.getPendingLiquidityDemandChf` | +| 1 | 0 | query-builder (named columns) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:111` | `BuyService.getTotalVolume` | +| 1 | 0 | query-builder (named columns) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:133` | `SwapService.getTotalVolume` | +| 1 | 0 | query-builder (named columns) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:221` | `CustodyService.updateCustodyBalance` | +| 1 | 0 | query-builder (named columns) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:229` | `CustodyService.updateCustodyBalance` | +| 1 | 0 | query-builder (named columns) | `—` | `subdomains/core/monitoring/observers/bank.observer.ts:117` | `BankObserver.getDbBalance` | +| 1 | 0 | query-builder (named columns) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:216` | `RefRewardService.getRefRewardVolume` | +| 1 | 0 | query-builder (named columns) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:249` | `RefRewardService.updatePaidRefCredit` | +| 1 | 0 | query-builder (named columns) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:586` | `BuyFiatService.updateRefVolumes` | +| 1 | 0 | query-builder (named columns) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:603` | `BuyFiatService.getUserVolume` | +| 1 | 0 | query-builder (named columns) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:271` | `SellService.getTotalVolume` | +| 1 | 0 | query-builder (named columns) | `TradingOrder` | `subdomains/core/trading/services/trading-rule.service.ts:35` | `TradingRuleService.getCurrentTradingOrders` | +| 1 | 0 | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:906` | `GsService.getExtendedBankTxData` | +| 1 | 0 | query-builder (named columns) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:179` | `UserDataService.getUserDataIdsByServiceProvider` | +| 1 | 0 | query-builder (named columns) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1774` | `UserDataService.getMaxKycFileIdByDateRange` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:140` | `UserService.getAllLinkedUsers` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:195` | `UserService.getOpenRefCreditEur` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:576` | `UserService.getUserVolumes` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:586` | `UserService.getUserVolumes` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:648` | `UserService.getRefInfo` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:661` | `UserService.getRefInfo` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:720` | `UserService.getTotalRefRewards` | +| 1 | 0 | query-builder (named columns) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:189` | `DepositService.getNextDepositIndex` | +| 1 | 0 | query-builder (named columns) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:449` | `BankTxService.getBankTxFee` | +| 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:97` | `LogRepository.cleanup` | +| 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:104` | `LogRepository.cleanup` | +| 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:158` | `LogRepository.getFinancialChangesLogs` | +| 1 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:165` | `LogRepository.getFinancialChangesLogs` | +| 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:206` | `LogRepository.getFinancialLogs` | +| 1 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:214` | `LogRepository.getFinancialLogs` | +| 1 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:244` | `LogRepository.getFinancialLogs` | +| 1 | 0 | query-builder (named columns) | `—` | `subdomains/supporting/payin/services/payin.service.ts:217` | `PayInService.getPayInFee` | +| 1 | 0 | query-builder (named columns) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:352` | `TransactionRequestService.getLegacySettlementTxIds` | +| 1 | 0 | query-builder (named columns) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:406` | `TransactionRequestService.getActiveDepositAddresses` | +| 1 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:177` | `SupportIssueService.getSupportIssueStatistics` | +| 1 | 0 | query-builder (named columns) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:186` | `SupportIssueService.getSupportIssueStatistics` | | — | — | find | `—` | `config/config.ts:1347` | `Configuration.isDomesticIban` | | — | — | find | `—` | `integration/binance-pay/services/binance-pay.service.ts:271` | `BinancePayService.verifySignature` | | — | — | find | `—` | `integration/blockchain/api/services/blockchain-balance.service.ts:52` | `BlockchainBalanceService.getSolanaBalances` | @@ -902,7 +906,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:264` | `LedgerBookingService.activeTx` | | — | — | find | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:268` | `LedgerBookingService.activeTx` | | — | — | find | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:274` | `LedgerBookingService.activeTx` | -| — | — | query-builder (zaehlend) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:212` | `LedgerMarkToMarketService.alreadyBooked` | +| — | — | query-builder (count only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:212` | `LedgerMarkToMarketService.alreadyBooked` | | — | — | find | `—` | `subdomains/core/aml/services/aml-helper.service.ts:375` | — | | — | — | find | `—` | `subdomains/core/aml/services/aml-helper.service.ts:684` | — | | — | — | find | `—` | `subdomains/core/aml/services/aml-helper.service.ts:685` | — | @@ -974,7 +978,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | raw-sql | `—` | `subdomains/generic/gs/gs.service.ts:337` | `GsService.executeDebugQuery` | | — | — | find | `—` | `subdomains/generic/gs/gs.service.ts:739` | `GsService.getParsedJsonData` | | — | — | find | `—` | `subdomains/generic/gs/gs.service.ts:742` | `GsService.getParsedJsonData` | -| — | — | query-builder (ohne-select) | `—` | `subdomains/generic/gs/gs.service.ts:805` | `GsService.getRawDbData` | +| — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:805` | `GsService.getRawDbData` | | — | — | find | `—` | `subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts:35` | — | | — | — | find | `—` | `subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts:36` | — | | — | — | find | `—` | `subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts:125` | — | @@ -1035,7 +1039,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1344` | `UserDataService.mergeUserData` | | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1547` | `UserDataService.mergeUserData` | | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1725` | `UserDataService.updateBankTxTime` | -| — | — | query-builder (zaehlend) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1766` | `UserDataService.countByDateRange` | +| — | — | query-builder (count only) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1766` | `UserDataService.countByDateRange` | | — | — | find | `—` | `subdomains/generic/user/models/user/dto/user-dto.mapper.ts:27` | — | | — | — | find | `—` | `subdomains/generic/user/models/user/user.repository.ts:70` | `UserRepository.getNextRef` | | — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:336` | `UserService.createUser` | @@ -1096,7 +1100,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:341` | `LogRepository.getFinancialLogAssetPrices` | | — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:511` | `LogRepository.getFinancialLogSummariesFull` | | — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:664` | `LogRepository.getFinancialLogSummariesChartOnly` | -| — | — | query-builder (zaehlend) | `Log` | `subdomains/supporting/log/log.repository.ts:688` | `LogRepository.assertEmptyResultIsEndOfData` | +| — | — | query-builder (count only) | `Log` | `subdomains/supporting/log/log.repository.ts:688` | `LogRepository.assertEmptyResultIsEndOfData` | | — | — | find | `—` | `subdomains/supporting/notification/services/notification.service.ts:128` | `NotificationService.resolveMailWallet` | | — | — | find | `—` | `subdomains/supporting/payin/services/payin-notification.service.ts:32` | `PayInNotificationService.returnedCryptoInput` | | — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:148` | `PayInService.getCryptoInputsByTransactionIds` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 6a57d98aaf..b5c92a3aa4 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -173,9 +173,10 @@ has nothing to do with the response. Nine of the seventeen conversions recorded [endpoints.md](endpoints.md) were found that way — by reading the endpoint after the filter had rejected it. The counts above are therefore a lower bound on what is possible, not a ceiling. -Two of those seventeen are conversions the first criterion excluded as written. It rejects an -endpoint that writes the entity it loaded, and the hazard it names is saving a partially loaded row -back — the unselected columns are undefined on the entity and would be written as null. +Two of those seventeen would fall foul of a blanket no-write rule, and are the reason the first +criterion does not state one. It rejects an endpoint that writes the entity it loaded, and the hazard +it names is saving a partially loaded row back — the unselected columns are undefined on the entity +and would be written as null. `PUT /paymentLink/:id/pos` and `POST /user/apiKey/CT` write through `update(id, …)`, which sends only the columns named in the call, so a projected read cannot blank anything. What the criterion does have to keep excluding is a value the write *derives* from what was read: the point-of-sale @@ -362,14 +363,16 @@ incomplete result is the caller's doing rather than a defect here. Its 197 specs axis — the table and column allowlist, PII masking, parameter binding — and that is the right axis for it. -So the gap is narrow and specific: **`log.repository.ts:699` is the one site that carries the -projection risk, serves a live endpoint (`PUT /log/financial/validity`), and is not exercised at -all.** What surrounds it is well covered — batching into blocks of 100, the audit trail, rejection -of fabricated audit records, the block on changing validity through the generic update path. +So among the six reads this section is about, the gap is narrow and specific: +**`log.repository.ts:699` carries the projection risk, serves a live endpoint +(`PUT /log/financial/validity`), and is not exercised at all.** What surrounds it is well covered — +batching into blocks of 100, the audit trail, rejection of fabricated audit records, the block on +changing validity through the generic update path. -Every other read in the repository either selects only the root alias or nothing at all and -therefore still loads every column; nothing can be missing from those. Each becomes subject to these -tests the moment it is given a field list. +That was the whole picture when this document was written. It no longer is: 113 sites now name their +columns — the 18 with an explicit field list and the 90 that name them one at a time, plus the five +raw statements — and each of them can drop a field silently. The 17 conversions below carry the four +levels; the rest do not, which is what their `0/4` records. ### How they run @@ -514,7 +517,7 @@ difference can only come from the columns. What the level does not verify is the query around them: each spec restates the filter and the joins, so a spec that restates them wrongly compares two things neither of which is the endpoint. -That part is covered by level 2, which asserts the filter against seeded rows. It is also the only +The endpoint specs do exercise the filter, because they run it against seeded rows, but no level requires that — level 2 is about branches that change the required field set. It is also the only level that catches a projection loading the *wrong* field rather than too few: level 1 sees a field that went empty, level 4 sees any field that changed. From 3c62e9b2717d7b5622dee624c308754aac538b23 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:22:35 +0000 Subject: [PATCH 43/46] docs: separate what is classified from what is measured Three statements presented a classification as if it were evidence. 398 endpoints are classified as fetching whole rows and that stays a lower bound against unresolved call graph edges, but only 396 have a measured query behind them - the other two are the ones with no measured width at all. The load-site total carries the same caveat as the table it comes from, so the count of whole-row reads derived from it is an upper bound; the 116 that load less than a whole row are counted rather than estimated and are stated as such. The note on the already-projecting endpoints said the rest record 0/4. Eighteen do; three stay n/a because their field list comes from the request and there is no fixed projection to test. --- docs/endpoints.md | 2 +- docs/read-path-projections.md | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/endpoints.md b/docs/endpoints.md index a0ced2d1eb..be8edee09b 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -50,7 +50,7 @@ Among the 398 that fetch whole rows, the widest query they can trigger is **308 Stated exactly, so the numbers can be checked rather than believed: -- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 398 is a lower bound. +- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 398 is a lower bound in that direction. In the other direction 396 of them are backed by at least one measured query; the remaining two are the entries discussed below. - All 98 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. - The 36 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. - 2 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`. They are also the ones most exposed to the upper bound described in [load-sites.md](load-sites.md#measurements): with no measured query behind them, nothing here shows that they reach a whole-row read at all. diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index b5c92a3aa4..472b98d514 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -46,9 +46,10 @@ and one on `LimitRequest` **434 across 15** — before any `relations` option is decision what to load therefore lives in the entity definition, not at the call site, and no call site can see what it triggers. -**No read model.** Of the 1,105 load sites in this repository, **116** load less than a whole row: -108 query builders that name their columns, three that end in `getCount()` or `getExists()` and -materialise none, and the five raw statements. The other 989 request whole rows — 957 through the +**No read model.** Of the load sites in this repository — at most 1,105, see +[load-sites.md](load-sites.md#measurements) — **116** load less than a whole row: 108 query builders +that name their columns, three that end in `getCount()` or `getExists()` and materialise none, and +the five raw statements. Those 116 are counted, not estimated. At most 989 request whole rows — 957 through the `find` family, and of the 143 query builders, 17 pass the root alias to `.select(...)`, which reads like a projection but is not, 14 pass no select at all, and one projects its root but pulls a relation in whole. The same entities serve persistence, business logic and pure output paths such @@ -372,7 +373,9 @@ changing validity through the generic update path. That was the whole picture when this document was written. It no longer is: 113 sites now name their columns — the 18 with an explicit field list and the 90 that name them one at a time, plus the five raw statements — and each of them can drop a field silently. The 17 conversions below carry the four -levels; the rest do not, which is what their `0/4` records. +levels. Of the endpoints behind the others, 18 record that they do not, as `0/4`; three stay `n/a` +because their field list comes from the request rather than the code, so there is no fixed +projection to test — `POST /gs/db`, `POST /gs/db/custom` and `POST /gs/debug`. ### How they run From 9d258f3fde8687f39e1665fef7ec7db5192e57f4 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:26:17 +0000 Subject: [PATCH 44/46] docs: state the median of the one-at-a-time projections as measured The 90 sites that name their columns one at a time were given as two columns at the median in one document and one in the other. Counted from the printed table: 45 select a single column, 22 select two, and the median across all 90 is 1.5. Both documents now say that, with the distribution behind it. --- docs/load-sites.md | 2 +- docs/read-path-projections.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/load-sites.md b/docs/load-sites.md index db774501b9..9270fa2824 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -25,7 +25,7 @@ Among the query builders, the field list is what decides whether anything is act | `getCount()` or `getExists()` — the select list is discarded, **no row is materialised** | 3 | | projects, but a `leftJoinAndSelect` loads a relation whole | 1 | -`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The rule is the one stated in [read-path-projections.md](read-path-projections.md): a bare identifier is the root alias and loads everything, anything else — a column or an expression such as `COUNT(*)` — narrows the query. It matters — the sites that name columns this way select 2 columns at the median, against 957 `find` calls that select every one. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. +`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The rule is the one stated in [read-path-projections.md](read-path-projections.md): a bare identifier is the root alias and loads everything, anything else — a column or an expression such as `COUNT(*)` — narrows the query. It matters — the sites that name columns this way select 1.5 columns at the median, and 45 of the 90 select a single one, against 957 `find` calls that select every column there is. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. ## Measurements diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 472b98d514..393a79297d 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -57,8 +57,8 @@ as invoices, receipts, history and exports — which need fields, not objects. Read the first number carefully. The 90 query builders that name columns one at a time are almost entirely counts, maxima and id lookups — -`.select('userData.id', 'id')`, `.select('COUNT(*)', 'count')` and the like — and they select **one -column at the median**. They are projections. The classification recognised only the array form +`.select('userData.id', 'id')`, `.select('COUNT(*)', 'count')` and the like — and they select **1.5 +columns at the median**, half of them exactly one. They are projections. The classification recognised only the array form `.select([...])` and read every string argument as the bare root alias, which is what put them in the `whole rows` group. The rule that holds: a bare identifier is the root alias and loads everything; anything else — a column or an expression — narrows the query. Correcting that does not From 51608f08f65e095434e47dec1d7ed3107597e561 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:32:28 +0000 Subject: [PATCH 45/46] docs: bring the inventories up to the current base, and two fixes the rebase exposed Same method as the base branch: the collection chain was run twice - against this branch before the rebase and against it after - and only the difference between those two runs was transferred. The pre-rebase run reproduces this branch's published figures exactly: 398 whole rows, 36 projected, 1105 load sites, 957 find calls, 143 query builders, 5 raw reads, 18 field lists, 99 tables, 1736 columns. That is what makes the difference trustworthy, and it is why the documents were not regenerated - the generator knows none of the corrections five review rounds put into them. Nine endpoints moved from touching no database to fetching whole rows without their handlers changing: GET /health and its five siblings, GET /monitoring/data, GET /statistic and GET /dashboard/financial/latest. Their state used to live in an in-process subject and now comes from the database, because the process that writes it is no longer the process that serves the request. whole rows 398 to 410, none 97 to 89. Load sites 1105 to 1158 across 251 files, 796 measured. 82 are new, 29 are gone, and 457 line references moved. Two raw reads joined: the cron lease reachability probe and the internal-transfer recovery statement, so 115 sites now name their columns rather than 113. Schema 1742 columns across 100 tables, 113 entities. Two fixes the rebase exposed. The import of txExplorerUrl survived a conflict resolution although its only consumer, toHistoryDto, is deleted here - tsc does not see an unused import, eslint does. And the projection CI job still used retry_on: error, which the base branch changed everywhere else to any, with the reason: the failure this wrapper exists for is npm stalling until the timeout, and a timeout is not an error, so the three attempts never applied. --- .github/workflows/api-pr.yaml | 2 +- docs/endpoints.md | 131 +- docs/load-sites.md | 1065 +++++++++-------- docs/read-path-projections.md | 22 +- .../process/services/buy-crypto.service.ts | 1 - 5 files changed, 638 insertions(+), 583 deletions(-) diff --git a/.github/workflows/api-pr.yaml b/.github/workflows/api-pr.yaml index 52cddb70a6..112f648391 100644 --- a/.github/workflows/api-pr.yaml +++ b/.github/workflows/api-pr.yaml @@ -262,7 +262,7 @@ jobs: with: timeout_minutes: 10 max_attempts: 3 - retry_on: error + retry_on: any command: npm ci # Separate from the sharded run because it compiles with full type information; see diff --git a/docs/endpoints.md b/docs/endpoints.md index be8edee09b..b6ef5e6c9f 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -1,6 +1,6 @@ # HTTP endpoints -Every HTTP endpoint this service exposes: **534 decorated route entries** across 94 controller files, of which **533 are registered at runtime** — one handler carries two `@Post` decorators and only one of them takes effect, see *Known discrepancy*. 296 are marked `@ApiExcludeEndpoint` and do not appear in the public Swagger schema. +Every HTTP endpoint this service exposes: **537 decorated route entries** across 94 controller files, of which **536 are registered at runtime** — one handler carries two `@Post` decorators and only one of them takes effect, see *Known discrepancy*. 299 are marked `@ApiExcludeEndpoint` and do not appear in the public Swagger schema. ## Columns @@ -23,20 +23,20 @@ Two rules follow from that, and both are binding: 1. **An endpoint counts as converted only when its tests reach `4/4`** against the four levels in [read-path-projections.md](read-path-projections.md#test-definition). A projection without them is worse than no projection: a forgotten field does not crash, it returns a wrong value with a 200, and in a service moving money that can run for weeks unnoticed. Anything short of `4/4` is unfinished work, not a partial success. 2. **The state of every endpoint is recorded here**, in the `Tests` column, and kept in sync with the code in the same pull request that changes it. An undocumented conversion is indistinguishable from one that was never tested. -Today 36 endpoints read only what they return and 398 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. +Today 36 endpoints read only what they need and 410 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. ## What the numbers say | Data access | Endpoints | Share | | ----------- | --------: | ----: | -| `whole rows` | 398 | 75 % | -| `none` | 98 | 18 % | +| `whole rows` | 410 | 76 % | +| `none` | 89 | 17 % | | `projected` | 36 | 7 % | | `caller-defined` | 2 | 0 % | Of the 36 that read only what they need, 17 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11), `GET /kyc/users` (328 columns to 7), `GET /kyc/:id/documents` (328 columns to 2), `GET /custody/order` (19 columns to 14), `GET /support/issue/list` (16 columns to 10), `GET /realunit/support/list` (16 columns to 10), `GET /dashboard/accounting/ledger/suspense` (11 columns to 10), `GET /liquidityManagement/pipeline/:id/status` (112 columns to 2), `PUT /paymentLink/:id/pos` (513 columns to 7), `POST /user/apiKey/CT` (253 columns to 3), `GET /user` (351 columns to 66). The other 19 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why 18 of them read `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. The nineteenth is `POST /gs/debug`, which stays `n/a` because its field list comes from the request and there is no fixed projection to test. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. -Among the 398 that fetch whole rows, the widest query they can trigger is **308 columns** at the median; 306 exceed 100, 74 exceed 500 and 19 exceed 1000. Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. +Among the 410 that fetch whole rows, the widest query they can trigger is **308 columns** at the median of the recorded maxima; at least 306 exceed 100, 73 exceed 500 and 21 exceed 1000. Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. ### How to read this column, and how not to @@ -50,10 +50,10 @@ Among the 398 that fetch whole rows, the widest query they can trigger is **308 Stated exactly, so the numbers can be checked rather than believed: -- **436 of 534 endpoints rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 398 is a lower bound in that direction. In the other direction 396 of them are backed by at least one measured query; the remaining two are the entries discussed below. -- All 98 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. +- **448 of the 537 route entries rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 410 is a lower bound in that direction. In the other direction 407 of them are backed by at least one measured query; the remaining three are the entries discussed below. +- All 89 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. - The 36 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. -- 2 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`. They are also the ones most exposed to the upper bound described in [load-sites.md](load-sites.md#measurements): with no measured query behind them, nothing here shows that they reach a whole-row read at all. +- 3 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file` and `PUT /buyCrypto/:id/amlCheck/reviewReset`. They are also the ones most exposed to the upper bound described in [load-sites.md](load-sites.md#measurements): with no measured query behind them, nothing here shows that they reach a whole-row read at all. ### Two controller classes may share a name @@ -97,7 +97,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea ## How the values are produced -- **Endpoints** — from the routing decorators in `src/**/*.controller.ts`, each attributed to the `@Controller` scope preceding it. Decorators between the route and the method are skipped by counting parentheses, so a multi-line `@UseGuards(` cannot be mistaken for the handler. Cross-checked in both directions against the routes the framework registers at startup: all 527 distinct method/path pairs match, with no entry left over on either side. The 533 registered rows exceed that by the six pairs served under two versions. +- **Endpoints** — from the routing decorators in `src/**/*.controller.ts`, each attributed to the `@Controller` scope preceding it. Decorators between the route and the method are skipped by counting parentheses, so a multi-line `@UseGuards(` cannot be mistaken for the handler. Cross-checked in both directions against the routes the framework registers at startup: all 530 distinct method/path pairs match, with no entry left over on either side. The 536 registered rows exceed that by the six pairs served under two versions. - **Ver** — from `@Version` on the handler, otherwise from the `@Controller` scope, otherwise the configured default. Note that the version follows the class, not the folder: the controllers under `generic/kyc/` are not uniformly v2 — `KycAdminController` carries no version decorator and is therefore served under the default. - **Data access** — the union over the call graph, following injected fields, locally constructed repositories and multi-line call chains. `find*` pulls in eager relations, `createQueryBuilder` does not, a bare identifier passed to `.select(...)` is the root alias and loads every column, while anything else — an array, a qualified column such as `.select('userData.id', 'id')`, or an expression such as `COUNT(*)` — narrows it, and `.update()/.delete()/.insert()` are writes that load nothing. - **Max cols** — the query is built from the real entity metadata and its SELECT list counted, so the number is measured rather than estimated. It is still a lower bound wherever the load site takes its `relations` tree as a parameter, or the call graph did not resolve: both can only add sites and widen queries, never the reverse. @@ -152,12 +152,12 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/bankAccount/iban` | hidden | whole rows | 26 | not yet | | `BankAccountController.addBankAccountIban` | `subdomains/supporting/bank/bank-account/bank-account.controller.ts` | | PUT | 1 | | `/bankData/:id` | hidden | whole rows | 31 | not yet | | `BankDataController.updateBankData` | `subdomains/generic/user/models/bank-data/bank-data.controller.ts` | | PUT | 1 | | `/bankData/:id/nameCheck` | hidden | whole rows | 276 | not yet | | `BankDataController.doNameCheck` | `subdomains/generic/user/models/bank-data/bank-data.controller.ts` | -| POST | 1 | | `/bankTx` | hidden | whole rows | 61 | not yet | | `BankTxController.uploadSepaFiles` | `subdomains/supporting/bank-tx/bank-tx/bank-tx.controller.ts` | -| PUT | 1 | | `/bankTx/:id` | hidden | whole rows | 1051 | not yet | | `BankTxController.update` | `subdomains/supporting/bank-tx/bank-tx/bank-tx.controller.ts` | -| DELETE | 1 | | `/bankTx/:id/buyCrypto` | hidden | whole rows | 247 | not yet | | `BankTxController.reset` | `subdomains/supporting/bank-tx/bank-tx/bank-tx.controller.ts` | +| POST | 1 | | `/bankTx` | hidden | whole rows | 62 | not yet | | `BankTxController.uploadSepaFiles` | `subdomains/supporting/bank-tx/bank-tx/bank-tx.controller.ts` | +| PUT | 1 | | `/bankTx/:id` | hidden | whole rows | 1053 | not yet | | `BankTxController.update` | `subdomains/supporting/bank-tx/bank-tx/bank-tx.controller.ts` | +| DELETE | 1 | | `/bankTx/:id/buyCrypto` | hidden | whole rows | 249 | not yet | | `BankTxController.reset` | `subdomains/supporting/bank-tx/bank-tx/bank-tx.controller.ts` | | PUT | 1 | | `/bankTxRepeat/:id` | hidden | whole rows | 308 | not yet | | `BankTxRepeatController.update` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.controller.ts` | -| PUT | 1 | | `/bankTxReturn/:id` | hidden | whole rows | 438 | not yet | | `BankTxReturnController.update` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.controller.ts` | -| POST | 1 | | `/bankTxReturn/:id/refund` | hidden | whole rows | 727 | not yet | | `BankTxReturnController.refundBuyCrypto` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.controller.ts` | +| PUT | 1 | | `/bankTxReturn/:id` | hidden | whole rows | 439 | not yet | | `BankTxReturnController.update` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.controller.ts` | +| POST | 1 | | `/bankTxReturn/:id/refund` | hidden | whole rows | 728 | not yet | | `BankTxReturnController.refundBuyCrypto` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.controller.ts` | | POST | 1 | | `/blockchain/balances` | public | whole rows | 33 | not yet | | `BlockchainApiController.getBalances` | `integration/blockchain/api/controllers/blockchain-api.controller.ts` | | POST | 1 | | `/blockchain/broadcast` | public | none | — | n/a | | `BlockchainApiController.broadcastTransaction` | `integration/blockchain/api/controllers/blockchain-api.controller.ts` | | POST | 1 | | `/blockchain/transaction` | public | whole rows | 33 | not yet | | `BlockchainApiController.createTransaction` | `integration/blockchain/api/controllers/blockchain-api.controller.ts` | @@ -172,20 +172,20 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/buy/personalIban` | public | whole rows | 327 | not yet | | `BuyController.getAllPersonalIbans` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | POST | 1 | | `/buy/personalIban` | public | whole rows | 253 | not yet | | `BuyController.createPersonalIban` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/quote` | public | whole rows | 143 | not yet | | `BuyController.getBuyQuote` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| PUT | 1 | | `/buyCrypto/:id` | hidden | whole rows | 1086 | not yet | | `BuyCryptoController.update` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| DELETE | 1 | | `/buyCrypto/:id/amlCheck` | hidden | whole rows | 422 | not yet | | `BuyCryptoController.resetAmlCheck` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| PUT | 1 | | `/buyCrypto/:id/amlCheck` | hidden | whole rows | 1086 | not yet | | `BuyCryptoController.manualPassAmlCheck` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| POST | 1 | | `/buyCrypto/:id/refund` | hidden | whole rows | 1051 | not yet | | `BuyCryptoController.refundBuyCrypto` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| POST | 1 | | `/buyCrypto/:id/scorechain` | hidden | whole rows | 713 | not yet | | `BuyCryptoController.retriggerScorechain` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| POST | 1 | | `/buyCrypto/:id/webhook` | hidden | whole rows | 844 | not yet | | `BuyCryptoController.triggerWebhook` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyCrypto/:id` | hidden | whole rows | 1088 | not yet | | `BuyCryptoController.update` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyCrypto/:id/amlCheck` | hidden | whole rows | 1088 | not yet | | `BuyCryptoController.manualPassAmlCheck` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyCrypto/:id/amlCheck/reviewReset` | hidden | whole rows | — | not yet | | `BuyCryptoController.resetAmlCheckForReview` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| POST | 1 | | `/buyCrypto/:id/refund` | hidden | whole rows | 1053 | not yet | | `BuyCryptoController.refundBuyCrypto` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| POST | 1 | | `/buyCrypto/:id/scorechain` | hidden | whole rows | 714 | not yet | | `BuyCryptoController.retriggerScorechain` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| POST | 1 | | `/buyCrypto/:id/webhook` | hidden | whole rows | 846 | not yet | | `BuyCryptoController.triggerWebhook` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | PUT | 1 | | `/buyCrypto/refVolumes` | hidden | projected | 2 | 0/4 | | `BuyCryptoController.updateRefVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| PUT | 1 | | `/buyCrypto/volumes` | hidden | whole rows | 483 | not yet | | `BuyCryptoController.updateBuyVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| PUT | 1 | | `/buyFiat/:id` | hidden | whole rows | 1033 | not yet | | `BuyFiatController.update` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | +| PUT | 1 | | `/buyCrypto/volumes` | hidden | whole rows | 484 | not yet | | `BuyCryptoController.updateBuyVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyFiat/:id` | hidden | whole rows | 1034 | not yet | | `BuyFiatController.update` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | DELETE | 1 | | `/buyFiat/:id/amlCheck` | hidden | whole rows | 490 | not yet | | `BuyFiatController.resetAmlCheck` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | -| PUT | 1 | | `/buyFiat/:id/amlCheck` | hidden | whole rows | 1033 | not yet | | `BuyFiatController.manualPassAmlCheck` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | +| PUT | 1 | | `/buyFiat/:id/amlCheck` | hidden | whole rows | 1034 | not yet | | `BuyFiatController.manualPassAmlCheck` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | POST | 1 | | `/buyFiat/:id/refund` | hidden | whole rows | 803 | not yet | | `BuyFiatController.refundBuyFiat` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | POST | 1 | | `/buyFiat/:id/scorechain` | hidden | whole rows | 517 | not yet | | `BuyFiatController.retriggerScorechain` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | -| POST | 1 | | `/buyFiat/:id/webhook` | hidden | whole rows | 644 | not yet | | `BuyFiatController.triggerWebhook` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | +| POST | 1 | | `/buyFiat/:id/webhook` | hidden | whole rows | 645 | not yet | | `BuyFiatController.triggerWebhook` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | PUT | 1 | | `/buyFiat/refVolumes` | hidden | projected | 2 | 0/4 | | `BuyFiatController.updateRefVolumes` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | PUT | 1 | | `/buyFiat/volumes` | hidden | whole rows | 308 | not yet | | `BuyFiatController.updateVolumes` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | GET | 1 | | `/country` | public | whole rows | 23 | not yet | | `CountryController.getAllCountry` | `shared/models/country/country.controller.ts` | @@ -224,11 +224,11 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/dashboard/accounting/ledger/suspense` | hidden | projected | 10 | 4/4 | yes | `LedgerController.getSuspense` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/financial/changes` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getFinancialChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/changes/latest` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getLatestChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | -| GET | 1 | | `/dashboard/financial/latest` | hidden | none | — | n/a | | `DashboardFinancialController.getLatestBalance` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | +| GET | 1 | | `/dashboard/financial/latest` | hidden | whole rows | 33 | n/a | | `DashboardFinancialController.getLatestBalance` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/log` | hidden | whole rows | 33 | not yet | yes | `DashboardFinancialController.getFinancialLog` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/reconciliation` | hidden | whole rows | 229 | not yet | | `DashboardReconciliationController.getReconciliation` | `subdomains/supporting/dashboard/dashboard-reconciliation.controller.ts` | | GET | 1 | | `/dashboard/financial/reconciliation/overview` | hidden | whole rows | 229 | not yet | | `DashboardReconciliationController.getOverview` | `subdomains/supporting/dashboard/dashboard-reconciliation.controller.ts` | -| GET | 1 | | `/dashboard/financial/ref-recipients` | hidden | projected | 3 | 0/4 | | `DashboardFinancialController.getRefRewardRecipients` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | +| GET | 1 | | `/dashboard/financial/ref-recipients` | hidden | projected | 2 | 0/4 | | `DashboardFinancialController.getRefRewardRecipients` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | POST | 1 | | `/deposit` | hidden | whole rows | 6 | not yet | | `DepositController.createDeposits` | `subdomains/supporting/address-pool/deposit/deposit.controller.ts` | | PUT | 1 | | `/deposit/lightningWebhook` | hidden | none | — | n/a | | `DepositController.updateLightningDepositWebhook` | `subdomains/supporting/address-pool/deposit/deposit.controller.ts` | | GET | 1 | | `/deuro/info` | public | whole rows | 11 | not yet | | `DEuroController.getInfo` | `integration/blockchain/deuro/controllers/deuro.controller.ts` | @@ -263,17 +263,17 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/gs/evm/contractTransaction` | hidden | none | — | n/a | | `GsEvmController.sendContractTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | | POST | 1 | | `/gs/evm/rawTransaction` | hidden | whole rows | 6 | not yet | | `GsEvmController.sendRawTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | | POST | 1 | | `/gs/evm/tokenTransaction` | hidden | whole rows | 33 | not yet | | `GsEvmController.sendTokenTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | -| GET | 1 | | `/gs/support` | hidden | whole rows | 903 | not yet | yes | `GsController.getSupportData` | `subdomains/generic/gs/gs.controller.ts` | -| GET | neutral | | `/health` | public | none | — | n/a | | `HealthController.getHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/banking` | public | none | — | n/a | | `HealthController.getBankingHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/external` | public | none | — | n/a | | `HealthController.getExternalHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/liquidity` | public | none | — | n/a | | `HealthController.getLiquidityHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/nodes` | public | none | — | n/a | | `HealthController.getNodeHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/payment` | public | none | — | n/a | | `HealthController.getPaymentHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | 1 | | `/history` | hidden | whole rows | 1359 | not yet | | `HistoryController.getHistory` | `subdomains/core/history/controllers/history.controller.ts` | -| GET | 1 | | `/history/:exportType` | hidden | whole rows | 1359 | not yet | | `HistoryController.getApiHistory` | `subdomains/core/history/controllers/history.controller.ts` | +| GET | 1 | | `/gs/support` | hidden | whole rows | 906 | not yet | yes | `GsController.getSupportData` | `subdomains/generic/gs/gs.controller.ts` | +| GET | neutral | | `/health` | public | whole rows | 4 | n/a | | `HealthController.getHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/banking` | public | whole rows | 4 | n/a | | `HealthController.getBankingHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/external` | public | whole rows | 4 | n/a | | `HealthController.getExternalHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/liquidity` | public | whole rows | 4 | n/a | | `HealthController.getLiquidityHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/nodes` | public | whole rows | 4 | n/a | | `HealthController.getNodeHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/payment` | public | whole rows | 4 | n/a | | `HealthController.getPaymentHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | 1 | | `/history` | hidden | whole rows | 1363 | not yet | | `HistoryController.getHistory` | `subdomains/core/history/controllers/history.controller.ts` | +| GET | 1 | | `/history/:exportType` | hidden | whole rows | 1363 | not yet | | `HistoryController.getApiHistory` | `subdomains/core/history/controllers/history.controller.ts` | | GET | 1 | | `/history/csv` | hidden | none | — | n/a | | `HistoryController.getCsv` | `subdomains/core/history/controllers/history.controller.ts` | -| POST | 1 | | `/history/csv` | hidden | whole rows | 1359 | not yet | | `HistoryController.createCsv` | `subdomains/core/history/controllers/history.controller.ts` | +| POST | 1 | | `/history/csv` | hidden | whole rows | 1363 | not yet | | `HistoryController.createCsv` | `subdomains/core/history/controllers/history.controller.ts` | | GET | 1 | | `/ikna/bfs/:id` | hidden | none | — | n/a | | `IknaController.getBfsResult` | `integration/ikna/controllers/ikna.controller.ts` | | POST | 1 | | `/ikna/bfs/address` | hidden | none | — | n/a | | `IknaController.createBfsAddressRequest` | `integration/ikna/controllers/ikna.controller.ts` | | GET | 1 | | `/ikna/tag` | hidden | none | — | n/a | | `IknaController.getIknaAddressTag` | `integration/ikna/controllers/ikna.controller.ts` | @@ -299,11 +299,11 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/kyc/admin/nameCheck/:id` | hidden | whole rows | 245 | not yet | | `KycAdminController.updateNameCheckLog` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | PUT | 1 | | `/kyc/admin/step/:id` | hidden | whole rows | 385 | not yet | | `KycAdminController.updateKycStep` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | POST | 1 | | `/kyc/admin/webhook` | hidden | whole rows | 364 | not yet | | `KycAdminController.triggerWebhook` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | -| GET | 2 | | `/kyc/client/payments` | public | whole rows | 1088 | not yet | | `KycClientController.getAllPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | +| GET | 2 | | `/kyc/client/payments` | public | whole rows | 1091 | not yet | | `KycClientController.getAllPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 2 | | `/kyc/client/users` | public | whole rows | 20 | not yet | | `KycClientController.getAllKycData` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 2 | | `/kyc/client/users/:id/documents` | public | whole rows | 78 | not yet | | `KycClientController.getKycFiles` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 2 | | `/kyc/client/users/:id/documents/:type` | public | whole rows | 78 | not yet | | `KycClientController.getKycFile` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | -| GET | 2 | | `/kyc/client/users/:id/payments` | public | whole rows | 1088 | not yet | | `KycClientController.getUserPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | +| GET | 2 | | `/kyc/client/users/:id/payments` | public | whole rows | 1091 | not yet | | `KycClientController.getUserPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 1 | yes | `/kyc/countries` | public | whole rows | 351 | not yet | | `KycController.getKycCountriesV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 2 | yes | `/kyc/countries` | public | whole rows | 351 | not yet | | `KycController.getKycCountries` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | DELETE | 2 | | `/kyc/data/:type/:id` | public | whole rows | 351 | not yet | | `KycController.cancelStep` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | @@ -336,7 +336,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | yes | `/kyc/transfer` | public | whole rows | 364 | not yet | | `KycController.transferKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | yes | `/kyc/users` | public | projected | 7 | 4/4 | | `KycClientController.getAllKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | | `/language` | public | whole rows | 7 | not yet | | `LanguageController.getAllLanguage` | `shared/models/language/language.controller.ts` | -| PUT | 1 | | `/limitRequest/:id` | hidden | whole rows | 434 | not yet | | `LimitRequestController.updateUserData` | `subdomains/supporting/support-issue/limit-request.controller.ts` | +| PUT | 1 | | `/limitRequest/:id` | hidden | whole rows | 364 | not yet | | `LimitRequestController.updateUserData` | `subdomains/supporting/support-issue/limit-request.controller.ts` | | GET | 1 | | `/liquidityManagement/balance` | hidden | whole rows | 40 | not yet | | `LiquidityBalanceController.getBalances` | `subdomains/core/liquidity-management/controllers/balance.controller.ts` | | PUT | 1 | | `/liquidityManagement/order/:id/resolveUncertain` | hidden | whole rows | 139 | not yet | | `LiquidityManagementOrderController.resolveUncertainOrder` | `subdomains/core/liquidity-management/controllers/order.controller.ts` | | GET | 1 | | `/liquidityManagement/order/in-progress` | hidden | whole rows | 139 | not yet | | `LiquidityManagementOrderController.getProcessingOrders` | `subdomains/core/liquidity-management/controllers/order.controller.ts` | @@ -360,7 +360,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/lnurlp/:id` | public | whole rows | 545 | not yet | | `LnUrlPForwardController.activatePublicPayment` | `subdomains/generic/forwarding/controllers/lnurlp-forward.controller.ts` | | DELETE | 1 | | `/lnurlp/cancel/:id` | public | whole rows | 545 | not yet | | `LnUrlPForwardController.cancelPayment` | `subdomains/generic/forwarding/controllers/lnurlp-forward.controller.ts` | | GET | 1 | | `/lnurlp/cb/:id` | public | whole rows | 545 | not yet | yes | `LnUrlPForwardController.lnUrlPCallbackForward` | `subdomains/generic/forwarding/controllers/lnurlp-forward.controller.ts` | -| GET | 1 | | `/lnurlp/tx/:id` | public | whole rows | 545 | not yet | | `LnUrlPForwardController.txHexForward` | `subdomains/generic/forwarding/controllers/lnurlp-forward.controller.ts` | +| GET | 1 | | `/lnurlp/tx/:id` | public | whole rows | 558 | not yet | | `LnUrlPForwardController.txHexForward` | `subdomains/generic/forwarding/controllers/lnurlp-forward.controller.ts` | | GET | 1 | | `/lnurlp/wait/:id` | public | whole rows | 545 | not yet | | `LnUrlPForwardController.waitForPayment` | `subdomains/generic/forwarding/controllers/lnurlp-forward.controller.ts` | | GET | 1 | | `/lnurlw/:id` | public | none | — | n/a | yes | `LnUrlWForwardController.lnUrlWForward` | `subdomains/generic/forwarding/controllers/lnurlw-forward.controller.ts` | | GET | 1 | | `/lnurlw/cb/:id` | public | none | — | n/a | yes | `LnUrlWForwardController.lnUrlWCallbackForward` | `subdomains/generic/forwarding/controllers/lnurlw-forward.controller.ts` | @@ -368,7 +368,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/log/:id` | hidden | whole rows | 11 | not yet | | `LogController.update` | `subdomains/supporting/log/log.controller.ts` | | POST | 1 | | `/log/clientError` | public | none | — | n/a | yes | `ClientErrorController.logError` | `subdomains/supporting/log/client-error.controller.ts` | | PUT | 1 | | `/log/financial/validity` | hidden | projected | 2 | 0/4 | | `LogController.setFinancialLogValidity` | `subdomains/supporting/log/log.controller.ts` | -| GET | 1 | | `/monitoring/data` | hidden | none | — | n/a | | `MonitoringController.getSystemState` | `subdomains/core/monitoring/monitoring.controller.ts` | +| GET | 1 | | `/monitoring/data` | hidden | whole rows | 4 | n/a | | `MonitoringController.getSystemState` | `subdomains/core/monitoring/monitoring.controller.ts` | | POST | 1 | | `/monitoring/data` | hidden | none | — | n/a | | `MonitoringController.onWebhook` | `subdomains/core/monitoring/monitoring.controller.ts` | | GET | 1 | | `/mros` | hidden | whole rows | 243 | not yet | | `MrosController.getAll` | `subdomains/supporting/mros/mros.controller.ts` | | POST | 1 | | `/mros` | hidden | whole rows | 253 | not yet | | `MrosController.createMros` | `subdomains/supporting/mros/mros.controller.ts` | @@ -429,7 +429,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/realunit/account/:address` | public | whole rows | 40 | not yet | | `RealUnitController.getAccountSummary` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/account/:address/history` | public | none | — | n/a | | `RealUnitController.getAccountHistory` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/admin/quotes` | hidden | whole rows | 112 | not yet | | `RealUnitController.getAdminQuotes` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/admin/quotes/:id/confirm-payment` | hidden | whole rows | 1051 | not yet | | `RealUnitController.confirmPaymentReceived` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/admin/quotes/:id/confirm-payment` | hidden | whole rows | 62 | not yet | | `RealUnitController.confirmPaymentReceived` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/admin/registration/:id/forward` | hidden | whole rows | 493 | not yet | yes | `RealUnitController.forwardRegistration` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/admin/transactions` | hidden | whole rows | 362 | not yet | | `RealUnitController.getAdminTransactions` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | POST | 1 | | `/realunit/balance/pdf` | public | whole rows | 308 | not yet | yes | `RealUnitController.getBalancePdf` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | @@ -442,7 +442,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/realunit/buy` | public | whole rows | 360 | not yet | yes | `RealUnitController.getPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/buy/:id/confirm` | public | whole rows | 484 | not yet | | `RealUnitController.confirmBuy` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/compliance/customers` | hidden | whole rows | 308 | not yet | | `RealUnitComplianceController.searchCustomers` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | -| GET | 1 | | `/realunit/compliance/customers/:id` | hidden | whole rows | 826 | not yet | | `RealUnitComplianceController.getCustomer` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | +| GET | 1 | | `/realunit/compliance/customers/:id` | hidden | whole rows | 1039 | not yet | | `RealUnitComplianceController.getCustomer` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | | GET | 1 | | `/realunit/compliance/customers/:id/dossier` | hidden | whole rows | 264 | not yet | | `RealUnitComplianceController.downloadCustomerDossier` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | | GET | 1 | | `/realunit/compliance/customers/:id/files` | hidden | whole rows | 264 | not yet | | `RealUnitComplianceController.getCustomerFiles` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | | GET | 1 | | `/realunit/compliance/customers/:id/files/:uid` | hidden | whole rows | 264 | not yet | | `RealUnitComplianceController.downloadCustomerFile` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | @@ -451,7 +451,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/realunit/legal` | public | whole rows | 308 | not yet | yes | `RealUnitLegalController.getLegal` | `subdomains/supporting/realunit/controllers/realunit-legal.controller.ts` | | PUT | 1 | | `/realunit/legal` | public | whole rows | 308 | not yet | yes | `RealUnitLegalController.acceptLegal` | `subdomains/supporting/realunit/controllers/realunit-legal.controller.ts` | | GET | 1 | | `/realunit/pay/:id/status` | public | whole rows | 32 | not yet | yes | `RealUnitController.getOcpPayStatus` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/pay/submit` | public | whole rows | 545 | not yet | yes | `RealUnitController.submitOcpPay` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/pay/submit` | public | whole rows | 558 | not yet | yes | `RealUnitController.submitOcpPay` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/pay/unsigned-transaction` | public | whole rows | 545 | not yet | yes | `RealUnitController.getOcpPayUnsignedTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/price` | public | whole rows | 33 | not yet | | `RealUnitController.getRealUnitPrice` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/price/history` | public | whole rows | 40 | not yet | | `RealUnitController.getHistoricalPrice` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | @@ -490,9 +490,9 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/realunit/transfer` | public | whole rows | 308 | not yet | yes | `RealUnitController.prepareTransfer` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/transfer/:id/confirm` | public | whole rows | 87 | not yet | yes | `RealUnitController.confirmTransfer` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | yes | `/realunit/wallet/status` | public | whole rows | 308 | not yet | yes | `RealUnitController.getWalletStatus` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| GET | 1 | | `/recall` | hidden | whole rows | 174 | not yet | | `RecallController.getAll` | `subdomains/supporting/recall/recall.controller.ts` | +| GET | 1 | | `/recall` | hidden | whole rows | 175 | not yet | | `RecallController.getAll` | `subdomains/supporting/recall/recall.controller.ts` | | POST | 1 | | `/recall` | hidden | whole rows | 308 | not yet | | `RecallController.createRecall` | `subdomains/supporting/recall/recall.controller.ts` | -| GET | 1 | | `/recall/:id` | hidden | whole rows | 174 | not yet | | `RecallController.getById` | `subdomains/supporting/recall/recall.controller.ts` | +| GET | 1 | | `/recall/:id` | hidden | whole rows | 175 | not yet | | `RecallController.getById` | `subdomains/supporting/recall/recall.controller.ts` | | PUT | 1 | | `/recall/:id` | hidden | whole rows | 308 | not yet | | `RecallController.updateRecall` | `subdomains/supporting/recall/recall.controller.ts` | | GET | 1 | | `/recommendation` | hidden | whole rows | 474 | not yet | | `RecommendationController.getAllRecommendation` | `subdomains/generic/user/models/recommendation/recommendation.controller.ts` | | POST | 1 | | `/recommendation` | hidden | whole rows | 364 | not yet | | `RecommendationController.createRecommendation` | `subdomains/generic/user/models/recommendation/recommendation.controller.ts` | @@ -521,17 +521,18 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/setting/disabledProcesses` | hidden | none | — | n/a | | `SettingController.updateProcess` | `shared/models/setting/setting.controller.ts` | | GET | 1 | | `/setting/infoBanner` | public | none | — | n/a | | `SettingController.getInfoBanner` | `shared/models/setting/setting.controller.ts` | | POST | 1 | | `/specialExternalAccount` | hidden | whole rows | 7 | not yet | | `SpecialExternalAccountController.createSpecialExternalAccount` | `subdomains/supporting/payment/controllers/special-external-account.controller.ts` | -| GET | 1 | | `/statistic` | public | none | — | n/a | | `StatisticController.getAll` | `subdomains/core/statistic/statistic.controller.ts` | +| GET | 1 | | `/statistic` | public | whole rows | 5 | n/a | | `StatisticController.getAll` | `subdomains/core/statistic/statistic.controller.ts` | | GET | 1 | | `/statistic/status` | public | whole rows | 5 | not yet | | `StatisticController.getStatus` | `subdomains/core/statistic/statistic.controller.ts` | -| GET | 1 | | `/statistic/transactions` | public | whole rows | 415 | not yet | | `StatisticController.getTransactions` | `subdomains/core/statistic/statistic.controller.ts` | -| GET | 1 | | `/support` | hidden | whole rows | 593 | not yet | | `SupportController.searchUserByKey` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/:id` | hidden | whole rows | 826 | not yet | | `SupportController.getUserData` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/statistic/transactions` | public | whole rows | 416 | not yet | | `StatisticController.getTransactions` | `subdomains/core/statistic/statistic.controller.ts` | +| GET | 1 | | `/support` | hidden | whole rows | 594 | not yet | | `SupportController.searchUserByKey` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/:id` | hidden | whole rows | 1039 | not yet | | `SupportController.getUserData` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/:id/ip-log-pdf` | hidden | whole rows | 12 | not yet | | `SupportController.getIpLogPdf` | `subdomains/generic/support/support.controller.ts` | +| POST | 1 | | `/support/:id/limit-request-pdf` | hidden | whole rows | 253 | not yet | | `SupportController.generateLimitRequestPdf` | `subdomains/generic/support/support.controller.ts` | | POST | 1 | | `/support/:id/onboarding-pdf` | hidden | whole rows | 264 | not yet | | `SupportController.generateOnboardingPdf` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/:id/scorechain` | hidden | whole rows | 14 | not yet | | `SupportController.getScorechainScreenings` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/:id/transaction-pdf` | hidden | whole rows | 826 | not yet | | `SupportController.getTransactionPdf` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/:id/transaction-pdf` | hidden | whole rows | 1039 | not yet | | `SupportController.getTransactionPdf` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues` | hidden | none | — | n/a | | `SupportController.getCallQueues` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/call-queues/:queue/items` | hidden | whole rows | 668 | not yet | | `SupportController.getCallQueueItems` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/call-queues/:queue/items` | hidden | whole rows | 669 | not yet | | `SupportController.getCallQueueItems` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues/clerks` | hidden | none | — | n/a | | `SupportController.getCallQueueClerks` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/issue` | public | projected | 11 | 4/4 | | `SupportIssueController.getIssues` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue` | public | whole rows | 493 | not yet | | `SupportIssueController.createIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | @@ -560,7 +561,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/support/note/users` | hidden | projected | 5 | 0/4 | | `SupportController.listNoteUsers` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/pending-reviews` | hidden | projected | 3 | 0/4 | | `SupportController.getPendingReviews` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/pending-reviews/items` | hidden | whole rows | 261 | not yet | | `SupportController.getPendingReviewItems` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/pending-transactions` | hidden | whole rows | 668 | not yet | | `SupportController.getPendingTransactions` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/pending-transactions` | hidden | whole rows | 669 | not yet | | `SupportController.getPendingTransactions` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/recommendation-graph/:id/neighbors` | hidden | whole rows | 474 | not yet | yes | `SupportController.getRecommendationGraphNeighbors` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/template` | hidden | whole rows | 8 | not yet | | `SupportController.getTemplates` | `subdomains/generic/support/support.controller.ts` | | POST | 1 | | `/support/template` | hidden | whole rows | 253 | not yet | | `SupportController.createTemplate` | `subdomains/generic/support/support.controller.ts` | @@ -580,26 +581,27 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/swap/quote` | public | whole rows | 143 | not yet | | `SwapController.getSwapQuote` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | POST | 1 | | `/tatum/addressWebhook` | hidden | none | — | n/a | | `TatumController.addressWebhook` | `integration/tatum/controllers/tatum.controller.ts` | | PUT | 1 | | `/trading/rule/:id` | hidden | whole rows | 87 | not yet | | `TradingRuleController.update` | `subdomains/core/trading/controllers/trading-rule.controller.ts` | -| GET | 1 | | `/transaction` | public | whole rows | 1359 | not yet | yes | `TransactionController.getTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction` | public | whole rows | 1363 | not yet | yes | `TransactionController.getTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | | PUT | 1 | | `/transaction/:id/invoice` | public | whole rows | 327 | not yet | yes | `TransactionController.generateInvoiceFromTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | | PUT | 1 | | `/transaction/:id/receipt` | public | whole rows | 327 | not yet | yes | `TransactionController.generateReceiptFromTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | | GET | 1 | | `/transaction/:id/refund` | public | whole rows | 327 | not yet | yes | `TransactionController.getTransactionRefund` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/:id/refund` | public | whole rows | 483 | not yet | | `TransactionController.setTransactionRefundTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/:id/target` | hidden | whole rows | 1051 | not yet | | `TransactionController.setTransactionTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/ChainReport` | hidden | whole rows | 1359 | not yet | yes | `TransactionController.getCsvChainReport` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/CoinTracking` | hidden | whole rows | 1359 | not yet | yes | `TransactionController.getCsvCT` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/:id/refund` | public | whole rows | 484 | not yet | | `TransactionController.setTransactionRefundTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/:id/target` | hidden | whole rows | 1053 | not yet | | `TransactionController.setTransactionTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/ChainReport` | hidden | whole rows | 1363 | not yet | yes | `TransactionController.getCsvChainReport` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/CoinTracking` | hidden | whole rows | 1363 | not yet | yes | `TransactionController.getCsvCT` | `subdomains/core/history/controllers/transaction.controller.ts` | | PUT | 1 | | `/transaction/admin/:id` | hidden | whole rows | 276 | not yet | | `TransactionAdminController.updateTransaction` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | +| POST | 1 | | `/transaction/admin/:id/resume` | hidden | whole rows | 98 | not yet | | `TransactionAdminController.resumeTransaction` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | POST | 1 | | `/transaction/admin/:id/stop` | hidden | whole rows | 98 | not yet | | `TransactionAdminController.stopTransaction` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | POST | 1 | | `/transaction/admin/:txId/riskAssessment` | hidden | none | — | n/a | | `TransactionAdminController.createRiskAssessment` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | PUT | 1 | | `/transaction/admin/:txId/riskAssessment/:id` | hidden | whole rows | 13 | not yet | | `TransactionAdminController.updateRiskAssessment` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | GET | 1 | | `/transaction/csv` | public | none | — | n/a | | `TransactionController.getCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/csv` | public | whole rows | 1359 | not yet | yes | `TransactionController.createCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/detail` | public | whole rows | 1359 | not yet | | `TransactionController.getTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/detail/csv` | public | whole rows | 1359 | not yet | | `TransactionController.createDetailCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/detail/single` | public | whole rows | 483 | not yet | yes | `TransactionController.getSingleTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/single` | public | whole rows | 483 | not yet | yes | `TransactionController.getSingleTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/csv` | public | whole rows | 1363 | not yet | yes | `TransactionController.createCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/detail` | public | whole rows | 1363 | not yet | | `TransactionController.getTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/detail/csv` | public | whole rows | 1363 | not yet | | `TransactionController.createDetailCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/detail/single` | public | whole rows | 484 | not yet | yes | `TransactionController.getSingleTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/single` | public | whole rows | 484 | not yet | yes | `TransactionController.getSingleTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | | GET | 1 | | `/transaction/target` | hidden | whole rows | 130 | not yet | | `TransactionController.getTransactionTargets` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/unassigned` | hidden | whole rows | 356 | not yet | | `TransactionController.getUnassignedTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/unassigned` | hidden | whole rows | 357 | not yet | | `TransactionController.getUnassignedTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | | DELETE | 1 | yes | `/user` | public | whole rows | 344 | not yet | | `UserController.deleteUser` | `subdomains/generic/user/models/user/user.controller.ts` | | DELETE | 2 | | `/user` | public | whole rows | 344 | not yet | | `UserV2Controller.deleteAccount` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | yes | `/user` | public | whole rows | 328 | not yet | | `UserController.getUserV1` | `subdomains/generic/user/models/user/user.controller.ts` | @@ -634,6 +636,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | DELETE | 1 | | `/userData/:id/fee` | hidden | whole rows | 253 | not yet | | `UserDataController.removeFee` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | PUT | 1 | | `/userData/:id/fee` | hidden | whole rows | 253 | not yet | | `UserDataController.addFee` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | POST | 1 | | `/userData/:id/kycFile` | hidden | whole rows | 253 | not yet | | `UserDataController.uploadKycFile` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | +| PUT | 1 | | `/userData/:id/kycStatus/check` | hidden | whole rows | 364 | not yet | | `UserDataController.setKycStatusCheck` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | PUT | 1 | | `/userData/:id/merge` | hidden | whole rows | 364 | not yet | | `UserDataController.mergeUserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | PUT | 1 | | `/userData/:id/volumes` | hidden | projected | 9 | 0/4 | | `UserDataController.updateVolumes` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | PUT | 1 | | `/userData/auditPeriodNumbers` | hidden | whole rows | 40 | not yet | | `UserDataController.calculateAuditPeriodNumbers` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | diff --git a/docs/load-sites.md b/docs/load-sites.md index 9270fa2824..e111b2f78b 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -1,6 +1,6 @@ # Database load sites -Every place in the code that reads from the database: **at most 1105 load sites** across 249 files — an upper bound, for the reason given under *Measurements*. +Every place in the code that reads from the database: **at most 1158 load sites** across 251 files — an upper bound, for the reason given under *Measurements*. This is the level at which the statement is unambiguous. An endpoint reaches several load sites — a permission check, a lookup, the actual query — so asking whether *an endpoint* loads efficiently has no single answer. Asking it of a load site does. [endpoints.md](endpoints.md) carries the per-endpoint summary derived from these sites. @@ -8,11 +8,11 @@ This is the level at which the statement is unambiguous. An endpoint reaches sev | Mechanism | Sites | Eager relations | Columns selected | | --------- | ----: | --------------- | ---------------- | -| `find` family | 957 | **applied** — expanded recursively | all columns of the entity plus every eager relation | -| `createQueryBuilder` | 143 | not applied | all columns of the root entity, unless `.select([...])` narrows it | -| raw SQL | 5 | not applied | whatever the statement lists | +| `find` family | 1007 | **applied** — expanded recursively | all columns of the entity plus every eager relation | +| `createQueryBuilder` | 144 | not applied | all columns of the root entity, unless `.select([...])` narrows it | +| raw SQL | 7 | not applied | whatever the statement lists | -Statements that load nothing are excluded from the count: 2 `createQueryBuilder` calls carrying `.update()`, 6 advisory locks (`SELECT pg_advisory_xact_lock(...)`, which return no rows) and 1 raw `INSERT`. Each of the 5 raw reads that remain names its columns. +Statements that load nothing are excluded from the count: 2 `createQueryBuilder` calls carrying `.update()`, 6 advisory locks (`SELECT pg_advisory_xact_lock(...)`, which return no rows) and 4 raw `INSERT`. Each of the 7 raw reads that remain names its columns. Among the query builders, the field list is what decides whether anything is actually saved: @@ -21,27 +21,27 @@ Among the query builders, the field list is what decides whether anything is act | `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **18** | | `.select('alias.column')` — names columns one by one | **90** | | `.select('alias')` — selects the root alias, **loads every column** | 17 | -| no `select` at all — loads every column | 14 | +| no `select` at all — loads every column | 15 | | `getCount()` or `getExists()` — the select list is discarded, **no row is materialised** | 3 | | projects, but a `leftJoinAndSelect` loads a relation whole | 1 | -`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The rule is the one stated in [read-path-projections.md](read-path-projections.md): a bare identifier is the root alias and loads everything, anything else — a column or an expression such as `COUNT(*)` — narrows the query. It matters — the sites that name columns this way select 1.5 columns at the median, and 45 of the 90 select a single one, against 957 `find` calls that select every column there is. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. +`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The rule is the one stated in [read-path-projections.md](read-path-projections.md): a bare identifier is the root alias and loads everything, anything else — a column or an expression such as `COUNT(*)` — narrows the query. It matters — the sites that name columns this way select 1.5 columns at the median, and 45 of the 90 select a single one, against 1,007 `find` calls that select every column there is. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. ## Measurements -Columns were measured against the real entity metadata by building the query and counting its SELECT list — 790 of 1105 sites. +Columns were measured against the real entity metadata by building the query and counting its SELECT list — 796 of 1158 sites. -- **338 are exact**: the `relations` tree is written at the call site. -- **452 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. -- 315 could not be measured: no resolvable target entity, or raw SQL. +- **341 are exact**: the `relations` tree is written at the call site. +- **455 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. +- 362 could not be measured: no resolvable target entity, or raw SQL. -**That last group is also why the total is an upper bound.** The collection matches `find` by name, and `find` on a repository is indistinguishable by name from `find` on an array. Where the target entity resolved, the distinction is settled; where it did not, the group holds both. A sample of 30 of those rows, read in the source, came out at 21 array operations to 9 genuine repository reads — so on the order of 200 rows here are not database reads at all, and the true count is nearer 900. +**That last group is also why the total is an upper bound.** The collection matches `find` by name, and `find` on a repository is indistinguishable by name from `find` on an array. Where the target entity resolved, the distinction is settled; where it did not, the group holds both. A sample of 30 of those rows, read in the source, came out at 21 array operations to 9 genuine repository reads. That group holds 343 rows, so on the order of 240 of them are not database reads at all, and the true count is nearer 900. What that does and does not affect: the median and the counts below are computed only over the rows that carry a measured width, and an array operation never has one, so those figures stand. Nor does it move the conclusion this table exists for — the sites that name their columns are a small fraction either way. It does reach the per-endpoint summary in [endpoints.md](endpoints.md), where the endpoints with no measured width at all are marked as the exposed cases. -Median across measured sites: **98 columns**. 14 sites exceed 1000, 71 exceed 500, 390 exceed 100. +Median across measured sites: **98 columns**. At least 14 sites exceed 1000, 72 exceed 500 and 392 exceed 100 — "at least", because 455 of these measurements are lower bounds, and a resolved relation tree can only widen a query, never narrow it. -Postgres refuses a statement with more than 1664 columns. The widest measured site here sits at 1453, so a little over two hundred columns separate it from a rejected statement — and 452 of these measurements are lower bounds, so the real margin can be smaller. +Postgres refuses a statement with more than 1664 columns. The widest measured site here sits at 1453, so a little over two hundred columns separate it from a rejected statement — and 455 of these measurements are lower bounds, so the real margin can be smaller. ## Load sites @@ -50,38 +50,38 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | Columns | Joins | Mechanism | Entity | Location | Method | | ------: | ----: | --------- | ------ | -------- | ------ | | 1453 | 49 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:229` | `FiatOutputJobService.setReadyDate` | -| 1359 | 46 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:267` | `TransactionService.getTransactionsForAccount` | -| 1282 | 50 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:426` | `BuyCryptoPreparationService.fillPaymentLinkPayments` | +| 1359 | 46 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:368` | `TransactionService.getTransactionsForAccount` | +| 1282 | 50 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:565` | `BuyCryptoPreparationService.fillPaymentLinkPayments` | | 1231 | 38 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:174` | `FiatOutputJobService.assignBankAccount` | -| 1158 | 43 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:65` | `BuyCryptoBatchService.batchAndOptimizeTransactions` | -| 1135 | 40 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:317` | `BuyCryptoPreparationService.In` | -| 1088 | 40 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:299` | `TransactionService.getTransactionsForUsers` | -| 1086 | 39 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:274` | `BuyCryptoService.update` | -| 1059 | 41 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:119` | `BuyCryptoPreparationService.doAmlCheck` | -| 1051 | 36 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:513` | `BuyCryptoService.refundBuyCrypto` | -| 1051 | 32 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:328` | `BankTxService.create` | -| 1051 | 32 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:341` | `BankTxService.update` | +| 1158 | 43 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:68` | `BuyCryptoBatchService.batchAndOptimizeTransactions` | +| 1135 | 40 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:422` | `BuyCryptoPreparationService.In` | +| 1088 | 40 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:400` | `TransactionService.getTransactionsForUsers` | +| 1086 | 39 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:309` | `BuyCryptoService.update` | +| 1059 | 41 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:120` | `BuyCryptoPreparationService.doAmlCheck` | +| 1051 | 36 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:624` | `BuyCryptoService.refundBuyCrypto` | +| 1051 | 32 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:402` | `BankTxService.update` | +| 1051 | 32 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:385` | `BankTxService.create` | | 1033 | 40 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:180` | `BuyFiatService.update` | | 1003 | 36 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:534` | `BuyFiatPreparationService.addFiatOutputs` | -| 903 | 30 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1165` | `BuyCryptoService.getAllUserTransactions` | +| 903 | 30 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1549` | `BuyCryptoService.getAllUserTransactions` | | 891 | 37 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:340` | `BuyFiatPreparationService.fillPaymentLinkPayments` | | 881 | 32 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-out.service.ts:133` | `BuyCryptoOutService.fetchBatchesForPayout` | -| 880 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:626` | `BuyCryptoPreparationService.chargebackFillUp` | +| 880 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:811` | `BuyCryptoPreparationService.chargebackFillUp` | | 844 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-webhook.service.ts:17` | `BuyCryptoWebhookService.triggerWebhookManual` | -| 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:189` | `TransactionService.getTransactionsWithoutUid` | -| 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:198` | `TransactionService.getTransactionsByUserDataId` | +| 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:282` | `TransactionService.getTransactionsWithoutUid` | +| 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:291` | `TransactionService.getTransactionsByUserDataId` | | 813 | 29 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:255` | `BuyFiatPreparationService.refreshFee` | -| 811 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:922` | `BuyCryptoService.getRefTransactions` | -| 811 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1147` | `BuyCryptoService.getAllRefTransactions` | +| 811 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1306` | `BuyCryptoService.getRefTransactions` | +| 811 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1531` | `BuyCryptoService.getAllRefTransactions` | | 803 | 29 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:400` | `BuyFiatService.refundBuyFiat` | | 790 | 26 | find | `Transaction` | `subdomains/supporting/payment/services/transaction-notification.service.ts:37` | `TransactionNotificationService.txAssigned` | -| 785 | 24 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:267` | `BuyCryptoNotificationService.chargebackInitiated` | -| 765 | 23 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:588` | `BuyCryptoPreparationService.chargebackTx` | +| 785 | 24 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:269` | `BuyCryptoNotificationService.chargebackInitiated` | +| 765 | 23 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:760` | `BuyCryptoPreparationService.chargebackTx` | | 737 | 30 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:107` | `BuyFiatPreparationService.doAmlCheck` | | 727 | 23 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:176` | `BankTxReturnService.refundBankTxReturn` | -| 713 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:788` | `BuyCryptoService.retriggerScorechain` | +| 713 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1149` | `BuyCryptoService.retriggerScorechain` | | 703 | 24 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:491` | `BuyFiatPreparationService.complete` | -| 668 | 22 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1203` | `BuyCryptoService.getByAmlReason` | +| 668 | 22 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1587` | `BuyCryptoService.getByAmlReason` | | 644 | 22 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:374` | `BuyFiatService.getBuyFiat` | | 644 | 22 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:378` | `BuyFiatService.triggerWebhookManual` | | 643 | 21 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:196` | `RecommendationService.confirmRecommendation` | @@ -91,47 +91,48 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 630 | 20 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:284` | `RecommendationService.getUserDataRecommendation` | | 623 | 20 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:619` | `BuyFiatService.getAllUserTransactions` | | 613 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:82` | `BuyCryptoNotificationService.paymentCompleted` | -| 613 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:175` | `BuyCryptoNotificationService.pendingBuyCrypto` | -| 613 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:350` | `BuyCryptoNotificationService.chargebackUnconfirmed` | +| 613 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:177` | `BuyCryptoNotificationService.pendingBuyCrypto` | +| 613 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:352` | `BuyCryptoNotificationService.chargebackUnconfirmed` | | 597 | 21 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:432` | `BuyFiatPreparationService.setOutput` | -| 593 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:738` | `BuyCryptoService.getBuyCryptosByChargebackIban` | +| 593 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:951` | `BuyCryptoService.getBuyCryptosByChargebackIban` | | 583 | 21 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:209` | `BuyFiatNotificationService.chargebackInitiated` | | 583 | 21 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:35` | `BuyFiatRegistrationService.syncReturnTxId` | | 567 | 17 | find | `BuyCrypto` | `subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts:114` | `BuyCryptoConsumer.processForward` | -| 558 | 24 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:150` | `PaymentQuoteService.getConfirmingQuotes` | -| 545 | 23 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:99` | `PaymentLinkRepository.getHistoryByStatus` | -| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:97` | `PaymentLinkPaymentService.updatePayment` | -| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:104` | `PaymentLinkPaymentService.getPendingPaymentByUniqueId` | -| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:372` | `PaymentLinkPaymentService.handleBlockchainConfirmed` | -| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:449` | `PaymentLinkPaymentService.sendWebhook` | +| 558 | 24 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:159` | `PaymentQuoteService.getEarliestQuoteClaimingTx` | +| 558 | 24 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:170` | `PaymentQuoteService.getConfirmingQuotes` | +| 545 | 23 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:92` | `PaymentLinkRepository.getHistoryByStatus` | +| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:246` | `PaymentLinkPaymentService.updatePayment` | +| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:253` | `PaymentLinkPaymentService.getPendingPaymentByUniqueId` | +| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:730` | `PaymentLinkPaymentService.handleBlockchainConfirmed` | +| 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:840` | `PaymentLinkPaymentService.sendWebhook` | | 540 | 17 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:763` | `BuyFiatService.getByAmlReason` | | 538 | 21 | find | `CryptoInput` | `subdomains/core/accounting/services/consumers/crypto-input.consumer.ts:89` | `CryptoInputConsumer.processForward` | -| 535 | 15 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:947` | `BuyCryptoService.getPendingTransactions` | +| 535 | 15 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1331` | `BuyCryptoService.getPendingTransactions` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:615` | `BuyFiatPreparationService.chargebackTx` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:486` | `BuyFiatService.retriggerScorechain` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:65` | `PaymentLinkRepository.getAllPaymentLinks` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:72` | `PaymentLinkRepository.getAllPaymentLinks` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:79` | `PaymentLinkRepository.getAllPaymentLinksByExternalLinkId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:86` | `PaymentLinkRepository.getAllPaymentLinksByExternalPaymentId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:116` | `PaymentLinkRepository.getPaymentLinkByLinkId` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:123` | `PaymentLinkRepository.getPaymentLinkByLinkId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:130` | `PaymentLinkRepository.getPaymentLinkByExternalId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:140` | `PaymentLinkRepository.getPaymentLinkByExternalPaymentId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:133` | `PaymentLinkRepository.getPaymentLinkByExternalPaymentId` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:430` | `PaymentLinkService.updatePaymentLinkAdmin` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:485` | `PaymentLinkService.getActivePaymentLink` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:504` | `PaymentLinkService.assignPaymentLink` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:520` | `PaymentLinkService.getLocations` | -| 507 | 18 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:358` | `KycService.reviewRecommendationStep` | +| 507 | 18 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:360` | `KycService.reviewRecommendationStep` | | 499 | 14 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:169` | `BankTxReturnService.getPendingTx` | -| 497 | 18 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:128` | `BuyFiatNotificationService.pendingBuyFiat` | | 497 | 15 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:86` | `BankTxReturnService.setFiatAmounts` | +| 497 | 18 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:128` | `BuyFiatNotificationService.pendingBuyFiat` | | 493 | 19 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:366` | `SupportIssueService.createIssueInternal` | | 490 | 20 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:587` | `PaymentLinkService.getPublicPaymentLinkByUniqueId` | | 490 | 15 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:473` | `BuyFiatService.resetAmlCheck` | -| 484 | 15 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:218` | `TransactionRequestService.getOrThrow` | | 484 | 15 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward-notification.service.ts:27` | `RefRewardNotificationService.refRewardPayouts` | -| 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:745` | `BuyCryptoService.getBuyCryptoByTransactionId` | -| 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:749` | `BuyCryptoService.getBuyCrypto` | -| 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:753` | `BuyCryptoService.updateVolumes` | -| 474 | 14 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:198` | `KycService.reviewIdentSteps` | +| 484 | 15 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:224` | `TransactionRequestService.getOrThrow` | +| 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:958` | `BuyCryptoService.getBuyCryptoByTransactionId` | +| 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:962` | `BuyCryptoService.getBuyCrypto` | +| 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:966` | `BuyCryptoService.updateVolumes` | +| 474 | 14 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:200` | `KycService.reviewIdentSteps` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:274` | `RecommendationService.getAllRecommendationForUserData` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:318` | `RecommendationService.getRecommendationsByKycStepIdsOrUserDataId` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:326` | `RecommendationService.getRecommendationsByKycStepIdsOrUserDataId` | @@ -144,8 +145,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 472 | 19 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:29` | `DepositRouteService.getLatest` | | 472 | 19 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:75` | `DepositRouteService.getPaymentRoutesForPublicName` | | 470 | 16 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:119` | `AccountMergeService.executeMerge` | -| 458 | 14 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return-notification.service.ts:27` | `BankTxReturnNotificationService.chargebackInitiated` | -| 454 | 16 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request-notification.service.ts:31` | `LimitRequestNotificationService.limitRequestAcceptedManual` | +| 458 | 14 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return-notification.service.ts:31` | `BankTxReturnNotificationService.chargebackInitiated` | +| 454 | 16 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request-notification.service.ts:35` | `LimitRequestNotificationService.limitRequestAcceptedManual` | | 451 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:42` | `BuyFiatNotificationService.paymentCompleted` | | 451 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:290` | `BuyFiatNotificationService.chargebackUnconfirmed` | | 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:457` | `SupportIssueService.closeIssue` | @@ -161,152 +162,152 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 438 | 13 | find | `BankTxReturn` | `subdomains/core/accounting/services/ledger-cutover.service.ts:610` | `LedgerCutoverService.openBankTxReturn` | | 438 | 13 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:54` | `BankTxReturnService.chargebackTx` | | 438 | 13 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:137` | `BankTxReturnService.update` | -| 434 | 15 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request.service.ts:61` | `LimitRequestService.updateLimitRequest` | -| 434 | 15 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request.service.ts:82` | `LimitRequestService.getUserLimitRequests` | +| 434 | 15 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request.service.ts:120` | `LimitRequestService.getUserLimitRequests` | | 433 | 12 | find | `BankTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:106` | `BankTxConsumer.processForward` | | 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:478` | `SupportIssueService.closeIssue` | | 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:628` | `SupportIssueService.getIssueMessages` | -| 427 | 13 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:283` | `CustodyOrderService.confirmOrder` | -| 427 | 13 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:299` | `CustodyOrderService.getOrdersForSupport` | -| 422 | 12 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:778` | `BuyCryptoService.resetAmlCheck` | +| 427 | 13 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:281` | `CustodyOrderService.confirmOrder` | +| 427 | 13 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:297` | `CustodyOrderService.getOrdersForSupport` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:214` | `SupportEscalationService.checkEscalations` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:484` | `SupportIssueService.updateIssue` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:618` | `SupportIssueService.getIssueMessages` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:655` | `SupportIssueService.getIssueUserDataId` | | 418 | 11 | find | `User` | `subdomains/generic/user/models/user/user-job.service.ts:19` | `UserJobService.approveUser` | -| 415 | 13 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1186` | `BuyCryptoService.getTransactions` | -| 411 | 15 | find | `Buy` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1024` | `BuyCryptoService.getBuy` | -| 407 | 11 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:67` | `TransactionRequestService.txRequestWaitingExpiryCheck` | -| 407 | 11 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:78` | `TransactionRequestService.deleteOldTxRequests` | -| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:393` | `UserService.updateUserV1` | -| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:447` | `UserService.updateUserData` | +| 415 | 13 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1570` | `BuyCryptoService.getTransactions` | +| 411 | 15 | find | `Buy` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1408` | `BuyCryptoService.getBuy` | +| 407 | 11 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:73` | `TransactionRequestService.txRequestWaitingExpiryCheck` | +| 407 | 11 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:84` | `TransactionRequestService.deleteOldTxRequests` | +| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:394` | `UserService.updateUserV1` | +| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:448` | `UserService.updateUserData` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:69` | `BuyCryptoRegistrationService.filterBuyCryptoPayIns` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:260` | `SwapService.updateSwap` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:268` | `SwapService.confirmSwap` | | 392 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:639` | `BuyFiatService.getPendingTransactions` | -| 386 | 11 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:439` | `UserService.updateUserName` | +| 386 | 11 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:440` | `UserService.updateUserName` | | 385 | 15 | find | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:139` | `BuyFiatRegistrationService.createBuyFiatsAndAckPayIns` | | 385 | 14 | find | `KycStep` | `subdomains/generic/kyc/services/kyc-admin.service.ts:45` | `KycAdminService.updateKycStep` | -| 384 | 13 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:344` | `UserDataService.updateUserData` | -| 380 | 13 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:223` | `BuyService.getByBankUsage` | +| 384 | 13 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:353` | `UserDataService.updateUserData` | +| 380 | 13 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:226` | `BuyService.getByBankUsage` | | 377 | 14 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:80` | `SellService.get` | | 377 | 12 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:118` | `BankTxReturnService.create` | | 377 | 12 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:148` | `BankTxReturnService.updateInternal` | | 377 | 12 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:158` | `BankTxReturnService.getBankTxReturn` | | 377 | 12 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:162` | `BankTxReturnService.getBankTxReturnsByIban` | | 374 | 14 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:287` | `SellService.confirmSell` | -| 370 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:268` | `BankTxService.fillBankTx` | +| 370 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:325` | `BankTxService.fillBankTx` | | 370 | 9 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:554` | `FiatOutputJobService.searchOutgoingBankTx` | | 364 | 12 | find | `Webhook` | `subdomains/generic/user/services/webhook/webhook-notification.service.ts:32` | `WebhookNotificationService.sendOpenWebhooks` | | 364 | 12 | find | `Webhook` | `subdomains/generic/user/services/webhook/webhook.service.ts:146` | `WebhookService.createAndSendWebhook` | | 363 | 10 | find | `BuyCrypto` | `subdomains/core/accounting/services/consumers/payout-order.consumer.ts:297` | `PayoutOrderConsumer.owedCompletionChf` | -| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:554` | `BuyCryptoPreparationService.checkAggregatingTransactions` | -| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:134` | `BuyCryptoService.checkAmlResetTx` | -| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:148` | `BuyCryptoService.createFromBankTx` | -| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:203` | `BuyCryptoService.createFromCheckoutTx` | -| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:833` | `BuyCryptoService.manualPassAmlCheck` | +| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:726` | `BuyCryptoPreparationService.checkAggregatingTransactions` | +| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:167` | `BuyCryptoService.checkAmlResetTx` | +| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:183` | `BuyCryptoService.createFromBankTx` | +| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:238` | `BuyCryptoService.createFromCheckoutTx` | +| 363 | 10 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1217` | `BuyCryptoService.manualPassAmlCheck` | +| 363 | 10 | find | `BuyCrypto` | `subdomains/supporting/fiat-output/fiat-output.service.ts:109` | `FiatOutputService.create` | | 363 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data-job.service.ts:25` | `UserDataJobService.bankTxVerification` | -| 363 | 10 | find | `BuyCrypto` | `subdomains/supporting/fiat-output/fiat-output.service.ts:108` | `FiatOutputService.create` | -| 362 | 11 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:375` | `TransactionService.getByAssetId` | -| 360 | 12 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:162` | `BuyService.createBuy` | -| 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:624` | `BankTxService.getUnassignedBankTx` | -| 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:634` | `BankTxService.getBankTxsByVirtualIban` | +| 362 | 11 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:476` | `TransactionService.getByAssetId` | +| 360 | 12 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:165` | `BuyService.createBuy` | +| 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:761` | `BankTxService.getUnassignedBankTx` | +| 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:771` | `BankTxService.getBankTxsByVirtualIban` | | 354 | 13 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:117` | `SellService.getSellsByIban` | | 352 | 15 | find | `CryptoStaking` | `subdomains/core/staking/services/staking.service.ts:48` | `StakingService.getUserInvests` | | 351 | 11 | find | `UserData` | `subdomains/generic/user/models/kyc/kyc.service.ts:126` | `KycService.getUserByKycCode` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:406` | `UserService.updateUser` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:418` | `UserService.updateUserMail` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:428` | `UserService.verifyMail` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:484` | `UserService.updateAddress` | -| 344 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:499` | `UserService.deactivateUser` | -| 343 | 10 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts:87` | `FiatPayInSyncService.createCheckoutTx` | -| 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1074` | `UserDataService.updateApiFilter` | -| 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1084` | `UserDataService.checkApiKey` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:407` | `UserService.updateUser` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:419` | `UserService.updateUserMail` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:429` | `UserService.verifyMail` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:503` | `UserService.updateAddress` | +| 344 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:518` | `UserService.deactivateUser` | +| 343 | 10 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts:92` | `FiatPayInSyncService.createCheckoutTx` | +| 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1112` | `UserDataService.updateApiFilter` | +| 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1122` | `UserDataService.checkApiKey` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/auth/auth.controller.ts:157` | `AuthController.createAccessTokenAfterMerge` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:155` | `KycService.getKycFile` | -| 328 | 10 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:129` | `UserService.getUserDto` | -| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:169` | `VirtualIbanService.getByIdForUser` | -| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1262` | `VirtualIbanService.getActiveForBuyAndCurrency` | -| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1274` | `VirtualIbanService.getByIban` | -| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1316` | `VirtualIbanService.getVirtualIbansForAccount` | -| 323 | 10 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1246` | `RealUnitService.forwardRegistrationToAktionariat` | +| 328 | 10 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:130` | `UserService.getUserDto` | +| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:176` | `VirtualIbanService.getByIdForUser` | +| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1384` | `VirtualIbanService.getActiveForBuyAndCurrency` | +| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1396` | `VirtualIbanService.getByIban` | +| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1438` | `VirtualIbanService.getVirtualIbansForAccount` | +| 323 | 10 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1247` | `RealUnitService.forwardRegistrationToAktionariat` | | 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:362` | `BuyFiatService.getBuyFiatByTransactionId` | | 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:367` | `BuyFiatService.getBuyFiatsByTransactionIds` | | 320 | 15 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:80` | `CustodyJobService.executeStep` | | 319 | 10 | find | `BuyFiat` | `subdomains/core/accounting/services/ledger-cutover.service.ts:308` | `LedgerCutoverService.openBuyFiatReceived` | | 319 | 10 | find | `BuyFiat` | `subdomains/core/accounting/services/ledger-cutover.service.ts:357` | `LedgerCutoverService.openBuyFiatOwed` | -| 309 | 8 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:278` | `TransactionRequestService.findAndComplete` | +| 309 | 8 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:284` | `TransactionRequestService.findAndComplete` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:60` | `KycService.transferKycData` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:76` | `KycService.transferKycData` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:80` | `UserService.getAllUser` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:84` | `UserService.getUser` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:88` | `UserService.getAllUserDataUsers` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:93` | `UserService.getUsersByUserDataIds` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:122` | `UserService.getUsersByIp` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:204` | `UserService.getRefUser` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:209` | `UserService.getRefUsersByRefs` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:215` | `UserService.getUsersByUsedRefs` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:461` | `UserService.updateUserAdmin` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:566` | `UserService.updateUserDataVolume` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:733` | `UserService.checkApiKey` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:742` | `UserService.updateApiFilter` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/staff-kyc-clearance.service.ts:61` | `StaffKycClearanceService.syncStaffKycClearance` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:81` | `UserService.getAllUser` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:85` | `UserService.getUser` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:89` | `UserService.getAllUserDataUsers` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:94` | `UserService.getUsersByUserDataIds` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:123` | `UserService.getUsersByIp` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:205` | `UserService.getRefUser` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:210` | `UserService.getRefUsersByRefs` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:216` | `UserService.getUsersByUsedRefs` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:462` | `UserService.updateUserAdmin` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:482` | `UserService.updateUserInternal` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:585` | `UserService.updateUserDataVolume` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:752` | `UserService.checkApiKey` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:761` | `UserService.updateApiFilter` | | 301 | 6 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:89` | `BankTxRepeatService.getAllUserRepeats` | | 299 | 12 | find | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:65` | `TradingOrderService.startNewOrders` | | 287 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:746` | `BuyFiatService.getTransactions` | -| 284 | 10 | find | `UserData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:173` | `BankDataService.addBankData` | +| 284 | 10 | find | `UserData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:177` | `BankDataService.addBankData` | | 276 | 10 | find | `NameCheckLog` | `subdomains/generic/kyc/services/name-check.service.ts:205` | `NameCheckService.createNameCheckLog` | -| 274 | 10 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:59` | `BankDataService.checkUnverifiedBankDatas` | +| 274 | 10 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:63` | `BankDataService.checkUnverifiedBankDatas` | | 266 | 9 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data-job.service.ts:39` | `UserDataJobService.setAccountOpener` | | 264 | 9 | find | `KycFile` | `subdomains/generic/kyc/services/kyc-file.service.ts:27` | `KycFileService.getKycFile` | | 264 | 9 | find | `KycFile` | `subdomains/generic/kyc/services/kyc-file.service.ts:34` | `KycFileService.getUserDataKycFiles` | | 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc-notification.service.ts:35` | `KycNotificationService.autoKycStepReminder` | -| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:137` | `KycService.checkIdentSteps` | -| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:174` | `KycService.reviewNationalityStep` | -| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:308` | `KycService.reviewFinancialData` | -| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1850` | `KycService.getUserByTransactionOrThrow` | +| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:139` | `KycService.checkIdentSteps` | +| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:176` | `KycService.reviewNationalityStep` | +| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:310` | `KycService.reviewFinancialData` | +| 263 | 9 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1858` | `KycService.getUserByTransactionOrThrow` | | 261 | 8 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-dex.service.ts:30` | `BuyCryptoDexService.secureLiquidity` | | 261 | 8 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-dex.service.ts:35` | `BuyCryptoDexService.secureLiquidity` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:92` | `BankDataService.verifyBankData` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:279` | `BankDataService.getBankData` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:299` | `BankDataService.getBankDatasByIban` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:308` | `BankDataService.getBankDatasByUserData` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:317` | `BankDataService.getApprovedAlternatives` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:351` | `BankDataService.getValidBankDatasForUser` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:369` | `BankDataService.getIdentBankDataForUser` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:376` | `BankDataService.updateUserBankData` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:399` | `BankDataService.updateUserBankData` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:437` | `BankDataService.createIbanForUserInternal` | -| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:507` | `BankDataService.getPendingReviewList` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:413` | `BankDataService.createIbanForUser` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:96` | `BankDataService.verifyBankData` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:283` | `BankDataService.getBankData` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:303` | `BankDataService.getBankDatasByIban` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:312` | `BankDataService.getBankDatasByUserData` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:321` | `BankDataService.getApprovedAlternatives` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:355` | `BankDataService.getValidBankDatasForUser` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:373` | `BankDataService.getIdentBankDataForUser` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:380` | `BankDataService.updateUserBankData` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:403` | `BankDataService.updateUserBankData` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:441` | `BankDataService.createIbanForUserInternal` | +| 261 | 9 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:511` | `BankDataService.getPendingReviewList` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:417` | `BankDataService.createIbanForUser` | | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/organization/organization.service.ts:26` | `OrganizationService.syncOrganization` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/jwt-revocation-sync.service.ts:30` | `JwtRevocationSyncService.syncDeniedJwtAccounts` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts:54` | `UserDataController.getAllUserData` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts:91` | `UserDataController.getUserData` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:158` | `UserDataService.getUserData` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:159` | `UserDataService.getUserData` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:171` | `UserDataService.getUserDataByIds` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:190` | `UserDataService.getByKycHashOrThrow` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:216` | `UserDataService.getDifferentUserWithSameIdentDoc` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:229` | `UserDataService.getUsersByMail` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:241` | `UserDataService.getUserDataByBirthday` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:281` | `UserDataService.getUsersByName` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:285` | `UserDataService.getUsersByPhone` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:289` | `UserDataService.getUserDatasWithKycFile` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:606` | `UserDataService.assignNextKycFileId` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1128` | `UserDataService.loadRelationsAndVerify` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1139` | `UserDataService.loadRelationsAndVerify` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1146` | `UserDataService.loadRelationsAndVerify` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1783` | `UserDataService.getByPhoneCallStatuses` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/jwt-revocation-sync.service.ts:37` | `JwtRevocationSyncService.syncDeniedJwtAccounts` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts:56` | `UserDataController.getAllUserData` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts:106` | `UserDataController.getUserData` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:165` | `UserDataService.getUserData` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:166` | `UserDataService.getUserData` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:178` | `UserDataService.getUserDataByIds` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:197` | `UserDataService.getByKycHashOrThrow` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:223` | `UserDataService.getDifferentUserWithSameIdentDoc` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:236` | `UserDataService.getUsersByMail` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:248` | `UserDataService.getUserDataByBirthday` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:288` | `UserDataService.getUsersByName` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:292` | `UserDataService.getUsersByPhone` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:296` | `UserDataService.getUserDatasWithKycFile` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:644` | `UserDataService.assignNextKycFileId` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1166` | `UserDataService.loadRelationsAndVerify` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1177` | `UserDataService.loadRelationsAndVerify` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1184` | `UserDataService.loadRelationsAndVerify` | +| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1821` | `UserDataService.getByPhoneCallStatuses` | +| 247 | 6 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:451` | `BankTxService.reset` | | 247 | 9 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:575` | `BuyFiatService.updateVolumes` | -| 247 | 6 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:384` | `BankTxService.reset` | | 246 | 9 | find | `CustodyAccountAccess` | `subdomains/core/custody/services/custody-account.service.ts:61` | `CustodyAccountService.getCustodyAccountsForUser` | | 245 | 8 | find | `NameCheckLog` | `subdomains/generic/kyc/services/name-check.service.ts:40` | `NameCheckService.updateLog` | | 245 | 8 | find | `NameCheckLog` | `subdomains/generic/kyc/services/name-check.service.ts:169` | `NameCheckService.closeAndRefreshRiskStatus` | | 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc-admin.service.ts:144` | `KycAdminService.triggerWebhook` | -| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1493` | `KycService.getKycStepById` | -| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1896` | `KycService.syncIdentFiles` | -| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1967` | `KycService.getDfxApprovalSteps` | -| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1995` | `KycService.getPendingReviewSteps` | +| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1501` | `KycService.getKycStepById` | +| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1904` | `KycService.syncIdentFiles` | +| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1975` | `KycService.getDfxApprovalSteps` | +| 243 | 8 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:2003` | `KycService.getPendingReviewSteps` | | 243 | 8 | find | `Mros` | `subdomains/supporting/mros/mros.service.ts:50` | `MrosService.getAll` | | 243 | 8 | find | `Mros` | `subdomains/supporting/mros/mros.service.ts:54` | `MrosService.getById` | | 240 | 5 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:23` | `BankTxRepeatService.create` | @@ -327,11 +328,11 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 201 | 8 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward-dex.service.ts:36` | `RefRewardDexService.secureLiquidity` | | 201 | 5 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:108` | `BuyFiatService.checkAmlResetTx` | | 201 | 5 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:548` | `BuyFiatService.manualPassAmlCheck` | -| 201 | 5 | find | `BuyFiat` | `subdomains/supporting/fiat-output/fiat-output.service.ts:98` | `FiatOutputService.create` | +| 201 | 5 | find | `BuyFiat` | `subdomains/supporting/fiat-output/fiat-output.service.ts:99` | `FiatOutputService.create` | | 197 | 5 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:113` | `CustodyJobService.onStepComplete` | -| 197 | 5 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:273` | `CustodyOrderService.getCustodyOrderByTx` | +| 197 | 5 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:271` | `CustodyOrderService.getCustodyOrderByTx` | | 195 | 8 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:612` | `PaymentLinkService.deletePaymentLink` | -| 182 | 5 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:222` | `FiatOutputService.delete` | +| 182 | 5 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:232` | `FiatOutputService.delete` | | 179 | 4 | find | `BankTxRepeat` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:553` | `BankTxConsumer.openingBankTxId` | | 179 | 4 | find | `BankTxRepeat` | `subdomains/core/accounting/services/ledger-cutover.service.ts:641` | `LedgerCutoverService.openBankTxRepeat` | | 176 | 5 | find | `TradingOrder` | `subdomains/core/accounting/services/consumers/trading-order.consumer.ts:84` | `TradingOrderConsumer.processForward` | @@ -360,16 +361,16 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex-spark.service.ts:28` | `DexSparkService.getPendingAmount` | | 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex-tron.service.ts:62` | `DexTronService.getPendingAmount` | | 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex-zano.service.ts:58` | `DexZanoService.getPendingAmount` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:162` | `DexService.fetchLiquidityTransactionResult` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:182` | `DexService.checkOrderReady` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:199` | `DexService.checkOrderCompletion` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:211` | `DexService.completeOrders` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:222` | `DexService.cancelOrders` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:234` | `DexService.hasOrder` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:239` | `DexService.getPendingOrders` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:329` | `DexService.finalizePurchaseOrders` | -| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:341` | `DexService.alertStrandedPurchaseOrders` | -| 150 | 10 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:56` | `LiquidityManagementService.getPipelineWithOrders` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:163` | `DexService.fetchLiquidityTransactionResult` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:183` | `DexService.checkOrderReady` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:200` | `DexService.checkOrderCompletion` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:212` | `DexService.completeOrders` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:223` | `DexService.cancelOrders` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:235` | `DexService.hasOrder` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:240` | `DexService.getPendingOrders` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:334` | `DexService.finalizePurchaseOrders` | +| 156 | 4 | find | `LiquidityOrder` | `subdomains/supporting/dex/services/dex.service.ts:346` | `DexService.alertStrandedPurchaseOrders` | +| 150 | 10 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:60` | `LiquidityManagementService.getPipelineWithOrders` | | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:106` | `SwapService.updateVolume` | | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:140` | `SwapService.getSwapWithoutRoute` | | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:145` | `SwapService.get` | @@ -378,33 +379,33 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:214` | `SwapService.createSwap` | | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:253` | `SwapService.getUserSwaps` | | 144 | 6 | find | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:664` | `BuyFiatService.getSell` | -| 143 | 4 | find | `Fee` | `subdomains/supporting/payment/services/fee.service.ts:342` | `FeeService.getAllFees` | +| 143 | 4 | find | `Fee` | `subdomains/supporting/payment/services/fee.service.ts:346` | `FeeService.getAllFees` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts:89` | `LiquidityMgmtConsumer.processForward` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/adapters/actions/liquidity-pipeline.adapter.ts:62` | `LiquidityPipelineAdapter.buy` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/adapters/actions/liquidity-pipeline.adapter.ts:98` | `LiquidityPipelineAdapter.checkBuyCompletion` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:122` | `LiquidityManagementPipelineService.getProcessingOrders` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:133` | `LiquidityManagementPipelineService.getPendingTx` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:181` | `LiquidityManagementPipelineService.checkRunningPipelines` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:243` | `LiquidityManagementPipelineService.startNewOrders` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:334` | `LiquidityManagementPipelineService.resolveUncertainOrders` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:506` | `LiquidityManagementPipelineService.blockConfirmedOrder` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:649` | `LiquidityManagementPipelineService.resolveUncertainOrderManually` | -| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:702` | `LiquidityManagementPipelineService.checkRunningOrders` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:127` | `LiquidityManagementPipelineService.getProcessingOrders` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:138` | `LiquidityManagementPipelineService.getPendingTx` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:186` | `LiquidityManagementPipelineService.checkRunningPipelines` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:248` | `LiquidityManagementPipelineService.startNewOrders` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:357` | `LiquidityManagementPipelineService.resolveUncertainOrders` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:669` | `LiquidityManagementPipelineService.blockConfirmedOrder` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:837` | `LiquidityManagementPipelineService.resolveUncertainOrderManually` | +| 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:890` | `LiquidityManagementPipelineService.checkRunningOrders` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:510` | `DashboardReconciliationService.getLmOrders` | | 131 | 3 | find | `StakingRefReward` | `subdomains/core/staking/services/staking.service.ts:37` | `StakingService.getUserStakingRefRewards` | -| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:84` | `BuyService.updateVolume` | -| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:120` | `BuyService.getAllBankUsages` | -| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:131` | `BuyService.get` | -| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:204` | `BuyService.getBuyWithoutRoute` | -| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:208` | `BuyService.getUserBuys` | -| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:212` | `BuyService.getUserDataBuys` | -| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:250` | `BuyService.getAllUserBuys` | -| 130 | 9 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:173` | `LiquidityManagementPipelineService.checkRunningPipelines` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:87` | `BuyService.updateVolume` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:123` | `BuyService.getAllBankUsages` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:134` | `BuyService.get` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:207` | `BuyService.getBuyWithoutRoute` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:211` | `BuyService.getUserBuys` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:215` | `BuyService.getUserDataBuys` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:253` | `BuyService.getAllUserBuys` | +| 130 | 9 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:178` | `LiquidityManagementPipelineService.checkRunningPipelines` | | 128 | 4 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:94` | `CustodyJobService.checkStep` | -| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:223` | `BankTxService.assignTransactions` | -| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:417` | `BankTxService.getBankTxByTransactionId` | -| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:422` | `BankTxService.getBankTxsByTransactionIds` | -| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:678` | `BankTxService.getBankTxsByName` | +| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:229` | `BankTxService.assignTransactions` | +| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:484` | `BankTxService.getBankTxByTransactionId` | +| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:489` | `BankTxService.getBankTxsByTransactionIds` | +| 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:815` | `BankTxService.getBankTxsByName` | | 124 | 5 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:123` | `SellService.getUserSells` | | 124 | 5 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:136` | `SellService.getSellsByUserDataId` | | 124 | 5 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:167` | `SellService.createSell` | @@ -418,87 +419,89 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:91` | `PayoutService.getRecentPayoutSentCorrelationIds` | | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:117` | `PayoutService.speedupTransaction` | | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:140` | `PayoutService.retryUncertainPayout` | -| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:193` | `PayoutService.getLatestOrderDate` | -| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:206` | `PayoutService.checkPreparationCompletion` | -| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:226` | `PayoutService.checkPayoutCompletion` | -| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:249` | `PayoutService.prepareNewOrders` | -| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:266` | `PayoutService.payoutOrders` | -| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:283` | `PayoutService.processFailedOrders` | +| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:197` | `PayoutService.logUncertainOrdersSnapshot` | +| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:210` | `PayoutService.getLatestOrderDate` | +| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:223` | `PayoutService.checkPreparationCompletion` | +| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:243` | `PayoutService.checkPayoutCompletion` | +| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:266` | `PayoutService.prepareNewOrders` | +| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:283` | `PayoutService.payoutOrders` | +| 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:300` | `PayoutService.processFailedOrders` | | 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:52` | `CustodyJobService.resetExpiredConfirmedOrders` | -| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:308` | `CustodyOrderService.approveOrder` | +| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:306` | `CustodyOrderService.approveOrder` | | 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:257` | `CustodyService.getUserCustodyHistory` | | 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:470` | `CustodyService.calculateAccruedInterest` | | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:49` | `BankTxRepeatService.update` | | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:62` | `BankTxRepeatService.update` | | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:104` | `BankTxRepeatService.getBankTxRepeat` | | 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/adapters/actions/liquidity-pipeline.adapter.ts:86` | `LiquidityPipelineAdapter.checkBuyCompletion` | -| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:110` | `LiquidityManagementPipelineService.getProcessingPipelines` | -| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:116` | `LiquidityManagementPipelineService.getStoppedPipelines` | -| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:155` | `LiquidityManagementPipelineService.startNewPipelines` | -| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:196` | `LiquidityManagementService.findRunningPipeline` | -| 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:257` | `TransactionRequestService.getTransactionRequestByUid` | -| 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:261` | `TransactionRequestService.getOpenBuyQuotes` | -| 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:424` | `TransactionRequestService.getByAssetId` | +| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:115` | `LiquidityManagementPipelineService.getProcessingPipelines` | +| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:121` | `LiquidityManagementPipelineService.getStoppedPipelines` | +| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:160` | `LiquidityManagementPipelineService.startNewPipelines` | +| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:200` | `LiquidityManagementService.findRunningPipeline` | +| 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:263` | `TransactionRequestService.getTransactionRequestByUid` | +| 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:267` | `TransactionRequestService.getOpenBuyQuotes` | +| 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:430` | `TransactionRequestService.getByAssetId` | | 99 | 0 | query-builder (alias only) | `UserData` | `subdomains/generic/user/models/user-data/user-data-notification.service.ts:173` | `UserDataNotificationService.blackSquadInvitation` | -| 99 | 0 | query-builder (no select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:141` | `UserDataService.getUserDataByUser` | -| 99 | 0 | query-builder (alias only) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:313` | `UserDataService.getUserDataByKey` | -| 98 | 2 | find | `User` | `subdomains/generic/user/models/user-data/user-data.service.ts:1033` | `UserDataService.customIdentMethod` | -| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:274` | `UserService.getRefDtoV2` | -| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:282` | `UserService.updateRef` | +| 99 | 0 | query-builder (no select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:148` | `UserDataService.getUserDataByUser` | +| 99 | 0 | query-builder (alias only) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:320` | `UserDataService.getUserDataByKey` | +| 98 | 2 | find | `User` | `subdomains/generic/user/models/user-data/user-data.service.ts:1071` | `UserDataService.customIdentMethod` | +| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:275` | `UserService.getRefDtoV2` | +| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:283` | `UserService.updateRef` | | 98 | 2 | find | `User` | `subdomains/generic/user/services/webhook/webhook.service.ts:107` | `WebhookService.sendWebhooks` | | 98 | 2 | find | `User` | `subdomains/generic/user/services/webhook/webhook.service.ts:176` | `WebhookService.getUsers` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:146` | `TransactionService.getTransactionById` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:151` | `TransactionService.getTransactionsByIds` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:159` | `TransactionService.getTransactionByUid` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:166` | `TransactionService.getTransactionByRequestId` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:173` | `TransactionService.getTransactionByRequestUid` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:181` | `TransactionService.getTransactionByExternalId` | -| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:185` | `TransactionService.getTransactionByCkoId` | -| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/bank/bank.service.ts:181` | `BankService.getReceiveIbanStatus` | -| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:130` | `VirtualIbanService.getActiveReceivingForUserAndCurrency` | -| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:155` | `VirtualIbanService.getActiveSendingCandidatesForUserAndCurrency` | -| 93 | 2 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1281` | `RealUnitService.findRegistration` | -| 93 | 2 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1296` | `RealUnitService.findRegistration` | -| 91 | 4 | find | `PaymentActivation` | `subdomains/core/payment-link/services/payment-activation.service.ts:64` | `PaymentActivationService.getActivationByTxId` | -| 91 | 4 | find | `PaymentActivation` | `subdomains/core/payment-link/services/payment-activation.service.ts:131` | `PaymentActivationService.getExistingActivations` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:239` | `TransactionService.getTransactionById` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:244` | `TransactionService.getTransactionsByIds` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:252` | `TransactionService.getTransactionByUid` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:259` | `TransactionService.getTransactionByRequestId` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:266` | `TransactionService.getTransactionByRequestUid` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:274` | `TransactionService.getTransactionByExternalId` | +| 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:278` | `TransactionService.getTransactionByCkoId` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/bank/bank.service.ts:216` | `BankService.getReceiveIbanStatus` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:137` | `VirtualIbanService.getActiveReceivingForUserAndCurrency` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:162` | `VirtualIbanService.getActiveSendingCandidatesForUserAndCurrency` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:710` | `VirtualIbanService.isIbanProtectedFromReconciliationDeactivation` | +| 93 | 2 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1282` | `RealUnitService.findRegistration` | +| 93 | 2 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1297` | `RealUnitService.findRegistration` | +| 91 | 4 | find | `PaymentActivation` | `subdomains/core/payment-link/services/payment-activation.service.ts:67` | `PaymentActivationService.getActivationByTxId` | +| 91 | 4 | find | `PaymentActivation` | `subdomains/core/payment-link/services/payment-activation.service.ts:134` | `PaymentActivationService.getExistingActivations` | | 87 | 2 | find | `TradingRule` | `subdomains/core/trading/services/trading-rule.service.ts:46` | `TradingRuleService.updateTradingRule` | | 87 | 2 | find | `TradingRule` | `subdomains/core/trading/services/trading-rule.service.ts:53` | `TradingRuleService.processRules` | | 87 | 2 | find | `TradingRule` | `subdomains/core/trading/services/trading-rule.service.ts:63` | `TradingRuleService.reactivateRules` | -| 87 | 2 | find | `RealUnitTransferRequest` | `subdomains/supporting/realunit/realunit.service.ts:3103` | `RealUnitService.confirmTransfer` | -| 87 | 2 | find | `RealUnitTransferRequest` | `subdomains/supporting/realunit/realunit.service.ts:3209` | `RealUnitService.reconcilePendingTransfers` | -| 86 | 2 | find | `Asset` | `shared/models/asset/asset.service.ts:78` | `AssetService.getAssetsByPriceRules` | +| 87 | 2 | find | `RealUnitTransferRequest` | `subdomains/supporting/realunit/realunit.service.ts:3104` | `RealUnitService.confirmTransfer` | +| 87 | 2 | find | `RealUnitTransferRequest` | `subdomains/supporting/realunit/realunit.service.ts:3210` | `RealUnitService.reconcilePendingTransfers` | +| 86 | 2 | find | `Asset` | `shared/models/asset/asset.service.ts:90` | `AssetService.getAssetsByPriceRules` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:56` | `LiquidityManagementRuleService.updateRule` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:64` | `LiquidityManagementRuleService.getRule` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:72` | `LiquidityManagementRuleService.deactivateRule` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:82` | `LiquidityManagementRuleService.reactivateRule` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:95` | `LiquidityManagementRuleService.updateRuleSettings` | -| 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:110` | `LiquidityManagementRuleService.reactivateRules` | -| 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:147` | `LiquidityManagementRuleService.findExistingRuleOnCreation` | -| 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:109` | `LiquidityManagementService.findRuleByAssetOrThrow` | -| 81 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:336` | `SupportIssueRepository.findIssueData` | -| 78 | 1 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:97` | `UserService.getUserByAddress` | +| 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:114` | `LiquidityManagementRuleService.reactivateRules` | +| 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:151` | `LiquidityManagementRuleService.findExistingRuleOnCreation` | +| 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:113` | `LiquidityManagementService.findRuleByAssetOrThrow` | +| 81 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:341` | `SupportIssueRepository.findIssueData` | +| 78 | 1 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:98` | `UserService.getUserByAddress` | | 78 | 3 | find | `Mros` | `subdomains/supporting/mros/mros.service.ts:32` | `MrosService.update` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:712` | `BuyCryptoService.getBuyCryptoByKeys` | -| 75 | 2 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:238` | `BuyCryptoBatchService.filterOutExistingBatches` | +| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:925` | `BuyCryptoService.getBuyCryptoByKeys` | +| 75 | 2 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:284` | `BuyCryptoBatchService.filterOutExistingBatches` | | 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:340` | `BuyFiatService.getBuyFiatByKey` | | 71 | 1 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:68` | `RecallService.getByBankTxIds` | | 68 | 4 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:204` | `SwapService.getById` | | 66 | 0 | query-builder (field list) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:252` | `UserDataRepository.getUserV2` | -| 65 | 2 | find | `Fee` | `subdomains/supporting/payment/services/fee.service.ts:115` | `FeeService.createFee` | +| 65 | 2 | find | `Fee` | `subdomains/supporting/payment/services/fee.service.ts:119` | `FeeService.createFee` | +| 62 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:593` | `BankTxService.getTrackedInternalTransfers` | | 61 | 0 | find | `BankTx` | `subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts:386` | `ExchangeTxConsumer.hasBankRouteMatch` | | 61 | 0 | find | `BankTx` | `subdomains/core/accounting/services/ledger-cutover.service.ts:710` | `LedgerCutoverService.openUnattributed` | | 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:46` | `BankTxRepeatService.update` | | 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:59` | `BankTxRepeatService.update` | | 61 | 0 | query-builder (alias only) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service.ts:36` | `BankTxOutgoingMatchService.getUniqueOutgoingBankTx` | -| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:153` | `BankTxService.enrichYapealTransactions` | -| 61 | 0 | query-builder (alias only) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:395` | `BankTxService.getBankTxByKey` | -| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:429` | `BankTxService.getBankTxById` | -| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:433` | `BankTxService.getPendingTx` | -| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:525` | `BankTxService.getRecentBankToBankTx` | -| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:532` | `BankTxService.getRecentExchangeTx` | -| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:552` | `BankTxService.storeSepaFile` | +| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:159` | `BankTxService.enrichYapealTransactions` | +| 61 | 0 | query-builder (alias only) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:462` | `BankTxService.getBankTxByKey` | +| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:496` | `BankTxService.getBankTxById` | +| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:500` | `BankTxService.getPendingTx` | +| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:659` | `BankTxService.getRecentExchangeTx` | +| 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:679` | `BankTxService.storeSepaFile` | | 61 | 0 | find | `BankTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:464` | `DashboardReconciliationService.getBankFlows` | -| 59 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:94` | `PaymentQuoteService.getActualQuoteByUniqueId` | +| 59 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:97` | `PaymentQuoteService.getActualQuoteByUniqueId` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-frick.service.ts:37` | `FiatOutputFrickService.checkFrickOrderStatus` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-frick.service.ts:101` | `FiatOutputFrickService.transmitPayments` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:85` | `FiatOutputJobService.checkOlkypayOrderStatus` | @@ -506,36 +509,37 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:407` | `FiatOutputJobService.checkTransmission` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:432` | `FiatOutputJobService.transmitYapealPayments` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:495` | `FiatOutputJobService.transmitOlkypayPayments` | -| 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:603` | `FiatOutputJobService.getLastBatchId` | -| 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:638` | `FiatOutputJobService.notifyScryptDeposits` | -| 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:203` | `FiatOutputService.update` | +| 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:610` | `FiatOutputJobService.getLastBatchId` | +| 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:645` | `FiatOutputJobService.notifyScryptDeposits` | +| 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:213` | `FiatOutputService.update` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:89` | `LedgerQueryService.getAccounts` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:157` | `LedgerQueryService.getReconStatus` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:613` | `LedgerQueryService.unverifiedAccountIds` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:146` | `LedgerReconciliationService.reconcileAssets` | | 53 | 1 | find | `PriceRule` | `subdomains/supporting/pricing/services/pricing.service.ts:163` | `PricingService.updatePrices` | -| 52 | 2 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:140` | `BuyService.getById` | -| 52 | 2 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:258` | `BuyService.updateBuy` | -| 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:58` | `PaymentLinkPaymentService.processExpiredPayments` | -| 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:264` | `PaymentLinkPaymentService.expirePaymentIfPending` | +| 52 | 2 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:143` | `BuyService.getById` | +| 52 | 2 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:261` | `BuyService.updateBuy` | +| 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:155` | `PaymentLinkPaymentService.processExpiredPayments` | +| 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:387` | `PaymentLinkPaymentService.deliverToWaitingCallers` | +| 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:615` | `PaymentLinkPaymentService.expirePaymentIfPending` | | 50 | 2 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:172` | `PaymentLinkService.createInvoice` | +| 46 | 0 | query-builder (alias only) | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:241` | `FiatOutputService.getFiatOutputByKey` | | 46 | 1 | find | `Bank` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:839` | `BankTxConsumer.bankContext` | | 46 | 1 | find | `Bank` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:870` | `BankTxConsumer.currencyMarkAssetId` | | 46 | 1 | find | `Bank` | `subdomains/core/accounting/services/ledger-cutover.service.ts:750` | `LedgerCutoverService.bankMaps` | | 46 | 3 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:90` | `SellService.getById` | | 46 | 3 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:143` | `SellService.getSellWithoutRoute` | -| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:40` | `BankService.getAllBanks` | -| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:44` | `BankService.getBanksWithAsset` | -| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:48` | `BankService.getBanksByName` | -| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:222` | `BankService.loadIbanCache` | +| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:41` | `BankService.getAllBanks` | +| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:45` | `BankService.getBanksWithAsset` | +| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:49` | `BankService.getBanksByName` | +| 46 | 1 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:257` | `BankService.loadIbanCache` | | 46 | 1 | find | `Asset` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:74` | `DashboardReconciliationService.getReconciliation` | | 46 | 1 | find | `Asset` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:146` | `DashboardReconciliationService.getOverview` | -| 46 | 3 | find | `Sell` | `subdomains/supporting/fiat-output/fiat-output.service.ts:149` | `FiatOutputService.createInternal` | -| 46 | 0 | query-builder (alias only) | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:231` | `FiatOutputService.getFiatOutputByKey` | -| 45 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:124` | `PaymentQuoteService.getQuoteByAsset` | -| 45 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:143` | `PaymentQuoteService.getQuoteByTxId` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:102` | `UserService.getUserByKey` | -| 45 | 0 | query-builder (no select) | `User` | `subdomains/generic/user/models/user/user.service.ts:177` | `UserService.getOpenRefCreditUser` | +| 46 | 3 | find | `Sell` | `subdomains/supporting/fiat-output/fiat-output.service.ts:158` | `FiatOutputService.createInternal` | +| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:103` | `UserService.getUserByKey` | +| 45 | 0 | query-builder (no select) | `User` | `subdomains/generic/user/models/user/user.service.ts:178` | `UserService.getOpenRefCreditUser` | +| 45 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:127` | `PaymentQuoteService.getQuoteByAsset` | +| 45 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:146` | `PaymentQuoteService.getQuoteByTxId` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:41` | `FaucetRequestService.checkFaucetRequests` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:93` | `FaucetRequestService.resetFaucet` | | 41 | 0 | query-builder (field list) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:267` | `UserDataRepository.getProfile` | @@ -549,41 +553,44 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 40 | 1 | find | `AssetPrice` | `subdomains/supporting/pricing/services/asset-prices.service.ts:12` | `AssetPricesService.getAssetPrices` | | 40 | 1 | find | `AssetPrice` | `subdomains/supporting/pricing/services/asset-prices.service.ts:38` | `AssetPricesService.getAssetPriceEntitiesForDate` | | 39 | 1 | find | `Organization` | `subdomains/generic/user/models/organization/organization.service.ts:86` | `OrganizationService.getOrganizationByName` | -| 38 | 1 | find | `BlockchainFee` | `subdomains/supporting/payment/services/fee.service.ts:93` | `FeeService.updateBlockchainFees` | -| 38 | 1 | find | `BlockchainFee` | `subdomains/supporting/payment/services/fee.service.ts:297` | `FeeService.getBlockchainFeeInChf` | -| 38 | 1 | find | `BlockchainFee` | `subdomains/supporting/payment/services/fee.service.ts:550` | `FeeService.getBlockchainMaxFee` | -| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:96` | `TransactionRequestService.syncStatus` | -| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:233` | `TransactionRequestService.getTransactionRequest` | -| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:244` | `TransactionRequestService.getWaitingTransactionRequest` | -| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:341` | `TransactionRequestService.getConsumedSettlementEventIds` | +| 38 | 1 | find | `BlockchainFee` | `subdomains/supporting/payment/services/fee.service.ts:97` | `FeeService.updateBlockchainFees` | +| 38 | 1 | find | `BlockchainFee` | `subdomains/supporting/payment/services/fee.service.ts:301` | `FeeService.getBlockchainFeeInChf` | +| 38 | 1 | find | `BlockchainFee` | `subdomains/supporting/payment/services/fee.service.ts:554` | `FeeService.getBlockchainMaxFee` | +| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:102` | `TransactionRequestService.syncStatus` | +| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:239` | `TransactionRequestService.getTransactionRequest` | +| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:250` | `TransactionRequestService.getWaitingTransactionRequest` | +| 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:347` | `TransactionRequestService.getConsumedSettlementEventIds` | | 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:21` | `AssetService.updateAsset` | | 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:30` | `AssetService.getAssetsWith` | | 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:42` | `AssetService.getAllBlockchainAssets` | | 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:46` | `AssetService.getPricedAssets` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:53` | `AssetService.getPaymentAssets` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:57` | `AssetService.getAssetById` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:61` | `AssetService.getAssetsById` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:87` | `AssetService.getAssetsByIdWith` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:91` | `AssetService.getAssetByChainId` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:95` | `AssetService.getAssetByUniqueName` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:99` | `AssetService.getAssetByQuery` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:103` | `AssetService.getAssetsByName` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:107` | `AssetService.getNativeAsset` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:121` | `AssetService.getTokens` | -| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:126` | `AssetService.getSellableBlockchains` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:61` | `AssetService.getPayInAssets` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:65` | `AssetService.getPaymentAssets` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:69` | `AssetService.getAssetById` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:73` | `AssetService.getAssetsById` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:99` | `AssetService.getAssetsByIdWith` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:103` | `AssetService.getAssetByChainId` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:107` | `AssetService.getAssetByUniqueName` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:111` | `AssetService.getAssetByQuery` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:115` | `AssetService.getAssetsByName` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:119` | `AssetService.getNativeAsset` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:133` | `AssetService.getTokens` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:138` | `AssetService.getSellableBlockchains` | +| 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:169` | `AssetService.getEvmAssetsWithoutDecimals` | | 33 | 0 | find | `Asset` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:106` | `DashboardReconciliationService.getReconciliation` | | 33 | 0 | find | `Asset` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:377` | `DashboardReconciliationService.getExchangeFlows` | -| 32 | 1 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:125` | `PaymentLinkPaymentService.getPaymentByExternalId` | -| 32 | 1 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:131` | `PaymentLinkPaymentService.getMostRecentPayment` | -| 31 | 1 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:217` | `BankDataService.createBankDataInternal` | -| 31 | 1 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:237` | `BankDataService.updateBankData` | -| 31 | 1 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:332` | `BankDataService.getVerifiedBankDataWithIban` | +| 32 | 1 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:274` | `PaymentLinkPaymentService.getPaymentByExternalId` | +| 32 | 1 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:280` | `PaymentLinkPaymentService.getMostRecentPayment` | +| 32 | 1 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:415` | `PaymentLinkPaymentService.deliverToConnectedDevices` | +| 31 | 1 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:221` | `BankDataService.createBankDataInternal` | +| 31 | 1 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:241` | `BankDataService.updateBankData` | +| 31 | 1 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:336` | `BankDataService.getVerifiedBankDataWithIban` | | 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:76` | `ExchangeTxService.upsertScryptTx` | -| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:114` | `ExchangeTxService.syncExchanges` | -| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:211` | `ExchangeTxService.getExchangeTx` | -| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:215` | `ExchangeTxService.getLastExchangeTx` | -| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:219` | `ExchangeTxService.getRecentExchangeTx` | -| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:269` | `ExchangeTxService.getSyncSinceDate` | +| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:118` | `ExchangeTxService.syncExchanges` | +| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:215` | `ExchangeTxService.getExchangeTx` | +| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:219` | `ExchangeTxService.getLastExchangeTx` | +| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:223` | `ExchangeTxService.getRecentExchangeTx` | +| 30 | 0 | find | `ExchangeTx` | `integration/exchange/services/exchange-tx.service.ts:273` | `ExchangeTxService.getSyncSinceDate` | | 30 | 2 | find | `LedgerTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:335` | `BankTxConsumer.cutoverOwedOpeningChf` | | 30 | 2 | find | `LedgerTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:563` | `BankTxConsumer.openingLiabilityLegChf` | | 30 | 2 | find | `LedgerTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:582` | `BankTxConsumer.cutoverOpeningLiabilityChf` | @@ -599,7 +606,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:286` | `DashboardReconciliationService.getBlockchainFlows` | | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:387` | `DashboardReconciliationService.getExchangeFlows` | | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:544` | `DashboardReconciliationService.getExchangeWithdrawalsForBlockchain` | -| 27 | 2 | find | `LiquidityManagementAction` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:257` | `LiquidityManagementRuleService.findExistingAction` | +| 27 | 2 | find | `LiquidityManagementAction` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:261` | `LiquidityManagementRuleService.findExistingAction` | | 26 | 0 | query-builder (alias only) | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:22` | `BankAccountService.getBankAccountByKey` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:40` | `BankAccountService.checkFailedBankAccounts` | | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:48` | `BankAccountService.reloadErrorBankAccounts` | @@ -608,7 +615,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 26 | 0 | find | `BankAccount` | `subdomains/supporting/bank/bank-account/bank-account.service.ts:73` | `BankAccountService.getOrCreateBicBankAccountInternal` | | 25 | 0 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/checkout-tx.service.ts:53` | `CheckoutTxService.getCheckoutTx` | | 25 | 0 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/checkout-tx.service.ts:60` | `CheckoutTxService.getPendingRefundedList` | -| 25 | 0 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/checkout-tx.service.ts:71` | `CheckoutTxService.getSyncDate` | +| 25 | 0 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/checkout-tx.service.ts:74` | `CheckoutTxService.getSyncDate` | | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:12` | `CountryService.getAllCountry` | | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:16` | `CountryService.getCountry` | | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:21` | `CountryService.getCountryWithSymbol` | @@ -617,6 +624,9 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:31` | `CountryService.getCountriesByKycType` | | 23 | 0 | find | `BankTxBatch` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx-batch.service.ts:10` | `BankTxBatchService.getBankTxBatchByIban` | | 21 | 0 | query-builder (no select) | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:87` | `DepositRouteService.getPaymentRouteForKey` | +| 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:322` | `TransactionService.getTransactionList` | +| 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:490` | `TransactionService.getTransactionByKey` | +| 20 | 0 | query-builder (no select) | `PriceRule` | `subdomains/supporting/pricing/services/pricing.service.ts:272` | `PricingService.getRuleFor` | | 20 | 0 | query-builder (alias only) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:95` | `SellService.getSellByKey` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:54` | `WalletRepository.getByAddress` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:19` | `WalletService.updateWallet` | @@ -624,48 +634,44 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:36` | `WalletService.getByIdOrName` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:40` | `WalletService.getKycClients` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:44` | `WalletService.getDefault` | -| 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:221` | `TransactionService.getTransactionList` | -| 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:389` | `TransactionService.getTransactionByKey` | -| 20 | 0 | query-builder (no select) | `PriceRule` | `subdomains/supporting/pricing/services/pricing.service.ts:272` | `PricingService.getRuleFor` | | 19 | 0 | query-builder (no select) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:79` | `SwapService.getSwapByAddress` | | 19 | 0 | query-builder (alias only) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:152` | `SwapService.getSwapByKey` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:83` | `KycLogService.updateLog` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:90` | `KycLogService.updateLogPdfUrl` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:109` | `KycLogService.getLogsByUserDataId` | +| 16 | 0 | query-builder (alias only) | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1416` | `VirtualIbanService.getVirtualIbanByKey` | +| 16 | 0 | query-builder (projected, full join) | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:297` | `PaymentLinkPaymentService.getMostRecentPayments` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:12` | `FiatService.getAllFiat` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:16` | `FiatService.getActiveFiat` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:27` | `FiatService.getFiat` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:33` | `FiatService.getFiatByName` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:43` | `FiatService.getFiatByCountry` | -| 16 | 0 | query-builder (projected, full join) | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:148` | `PaymentLinkPaymentService.getMostRecentPayments` | -| 16 | 0 | query-builder (alias only) | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1294` | `VirtualIbanService.getVirtualIbanByKey` | +| 15 | 0 | query-builder (alias only) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:288` | `BankDataService.getBankDataByKey` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:20` | `WalletAppService.getAllBlockchainWalletApps` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:24` | `WalletAppService.getRecommendedWalletApps` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:28` | `WalletAppService.getWalletAppById` | -| 15 | 0 | query-builder (alias only) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:284` | `BankDataService.getBankDataByKey` | -| 15 | 0 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:2830` | `RealUnitService.getRegisteredWalletAddresses` | +| 15 | 0 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:2831` | `RealUnitService.getRegisteredWalletAddresses` | | 14 | 0 | find | `ScorechainScreening` | `integration/scorechain/repositories/scorechain-screening.repository.ts:18` | `ScorechainScreeningRepository.getByObjectIds` | | 14 | 0 | find | `ScorechainScreening` | `integration/scorechain/services/scorechain-screening.service.ts:233` | `ScorechainScreeningService.getCached` | -| 14 | 0 | query-builder (field list) | `CustodyOrder` | `subdomains/core/custody/repositories/custody-order.repository.ts:59` | `CustodyOrderRepository.findHistoryFor` | +| 14 | 0 | query-builder (field list) | `CustodyOrder` | `subdomains/core/custody/repositories/custody-order.repository.ts:61` | `CustodyOrderRepository.findHistoryFor` | | 14 | 0 | query-builder (field list) | `BuyFiat` | `subdomains/core/sell-crypto/process/buy-fiat.repository.ts:57` | `BuyFiatRepository.findSellHistory` | -| 13 | 0 | query-builder (alias only) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:228` | `BuyService.getBuyByKey` | -| 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:76` | `PaymentQuoteService.processExpiredQuotes` | -| 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:109` | `PaymentQuoteService.getActualQuoteByPaymentId` | -| 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:157` | `PaymentQuoteService.cancelAllForPayment` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:147` | `KycService.checkIdentSteps` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:184` | `KycService.reviewNationalityStep` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:209` | `KycService.reviewIdentSteps` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:319` | `KycService.reviewFinancialData` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:371` | `KycService.reviewRecommendationStep` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:441` | `KycService.checkDfxApproval` | -| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1497` | `KycService.getStepsByUserData` | +| 13 | 0 | query-builder (alias only) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:231` | `BuyService.getBuyByKey` | +| 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:79` | `PaymentQuoteService.processExpiredQuotes` | +| 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:112` | `PaymentQuoteService.getActualQuoteByPaymentId` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:149` | `KycService.checkIdentSteps` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:186` | `KycService.reviewNationalityStep` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:211` | `KycService.reviewIdentSteps` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:321` | `KycService.reviewFinancialData` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:373` | `KycService.reviewRecommendationStep` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:443` | `KycService.checkDfxApproval` | +| 13 | 0 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1505` | `KycService.getStepsByUserData` | | 13 | 0 | find | `TfaLog` | `subdomains/generic/kyc/services/tfa.service.ts:195` | `TfaService.checkVerification` | -| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:63` | `BankService.getBankInternal` | -| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:68` | `BankService.getBankById` | -| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:72` | `BankService.getBankByIdUncached` | -| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:76` | `BankService.getBankByIban` | -| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:80` | `BankService.getReceiveBanks` | -| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:84` | `BankService.getSenderBanks` | +| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:64` | `BankService.getBankInternal` | +| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:69` | `BankService.getBankById` | +| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:73` | `BankService.getBankByIdUncached` | +| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:77` | `BankService.getBankByIban` | +| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:91` | `BankService.getReceiveBanks` | +| 13 | 0 | find | `Bank` | `subdomains/supporting/bank/bank/bank.service.ts:95` | `BankService.getSenderBanks` | | 13 | 0 | find | `Notification` | `subdomains/supporting/notification/services/notification-job.service.ts:38` | `NotificationJobService.resendUncompletedMails` | | 13 | 0 | find | `Notification` | `subdomains/supporting/notification/services/notification.service.ts:49` | `NotificationService.getMails` | | 13 | 0 | find | `Notification` | `subdomains/supporting/notification/services/notification.service.ts:107` | `NotificationService.isSuppressed` | @@ -686,16 +692,16 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:131` | `LogService.getLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:135` | `LogService.maxEntity` | | 11 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.service.ts:191` | `LogService.getBankLog` | -| 11 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:346` | `SupportIssueRepository.findIssuesForAccount` | -| 11 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:364` | `SupportIssueRepository.findIssueBy` | -| 10 | 0 | query-builder (field list) | `LedgerLeg` | `subdomains/core/accounting/repositories/ledger-leg.repository.ts:55` | `LedgerLegRepository.findSuspenseLegs` | +| 11 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:351` | `SupportIssueRepository.findIssuesForAccount` | +| 11 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:369` | `SupportIssueRepository.findIssueBy` | +| 10 | 0 | query-builder (field list) | `LedgerLeg` | `subdomains/core/accounting/repositories/ledger-leg.repository.ts:54` | `LedgerLegRepository.findSuspenseLegs` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:61` | `AccountMergeService.sendMergeRequest` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:162` | `AccountMergeService.pendingMergeRequest` | -| 10 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:271` | `SupportIssueRepository.findIssueList` | +| 10 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:276` | `SupportIssueRepository.findIssueList` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:57` | `SupportNoteService.search` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:76` | `SupportNoteService.search` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:152` | `SupportNoteService.delete` | -| 9 | 0 | query-builder (named columns) | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1214` | `UserDataService.updateVolumes` | +| 9 | 0 | query-builder (named columns) | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1252` | `UserDataService.updateVolumes` | | 9 | 0 | find | `TransactionSpecification` | `subdomains/supporting/payment/services/transaction-helper.ts:92` | `TransactionHelper.updateCache` | | 8 | 0 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-account.service.ts:12` | `LedgerAccountService.findByName` | | 8 | 0 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-account.service.ts:16` | `LedgerAccountService.findByAssetId` | @@ -711,7 +717,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:15` | `LanguageService.getLanguage` | | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:19` | `LanguageService.getLanguageBySymbol` | | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:24` | `LanguageService.getLanguageByCountry` | -| 7 | 0 | query-builder (field list) | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:66` | `PaymentLinkRepository.findForPosLink` | +| 7 | 0 | query-builder (field list) | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:59` | `PaymentLinkRepository.findForPosLink` | | 7 | 0 | find | `UserDataRelation` | `subdomains/generic/user/models/user-data-relation/user-data-relation.service.ts:40` | `UserDataRelationService.updateUserDataRelation` | | 7 | 0 | query-builder (field list) | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:48` | `WalletRepository.findKycData` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:12` | `SpecialExternalAccountService.createSpecialExternalAccount` | @@ -723,8 +729,8 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 6 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:173` | `LedgerMarkToMarketService.accountBalance` | | 6 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:517` | `LedgerReconciliationService.nativeBalanceByAccount` | | 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.repository.ts:13` | `RefRepository.getAndRemove` | -| 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.service.ts:22` | `RefService.checkRefs` | -| 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.service.ts:28` | `RefService.addOrUpdate` | +| 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.service.ts:23` | `RefService.checkRefs` | +| 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.service.ts:29` | `RefService.addOrUpdate` | | 6 | 0 | find | `CustodyProvider` | `subdomains/generic/user/models/custody-provider/custody-provider.service.ts:17` | `CustodyProviderService.updateCustodyProvider` | | 6 | 0 | find | `CustodyProvider` | `subdomains/generic/user/models/custody-provider/custody-provider.service.ts:26` | `CustodyProviderService.getWithMasterKey` | | 6 | 0 | find | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:63` | `DepositService.getDeposit` | @@ -735,40 +741,40 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 6 | 0 | find | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:83` | `DepositService.getUsedDepositsByBlockchain` | | 6 | 0 | query-builder (no select) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:89` | `DepositService.getNextDeposit` | | 5 | 0 | find | `Setting` | `shared/models/setting/setting.repository.ts:36` | `SettingRepository.getStatusSettings` | -| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:16` | `SettingService.getAll` | -| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:26` | `SettingService.get` | -| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:32` | `SettingService.set` | -| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:202` | `SettingService.getObj` | -| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:206` | `SettingService.getObjCached` | -| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:210` | `SettingService.setObj` | +| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:22` | `SettingService.getAll` | +| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:32` | `SettingService.get` | +| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:43` | `SettingService.set` | +| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:222` | `SettingService.getObj` | +| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:226` | `SettingService.getObjCached` | +| 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:230` | `SettingService.setObj` | | 5 | 0 | find | `Sanction` | `subdomains/core/aml/services/sanction.service.ts:54` | `SanctionService.syncList` | | 5 | 0 | query-builder (named columns) | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:84` | `SupportNoteService.listUsers` | -| 5 | 0 | query-builder (field list) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:61` | `SupportMessageRepository.findThread` | +| 5 | 0 | query-builder (field list) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:60` | `SupportMessageRepository.findThread` | | 4 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:460` | `LedgerQueryService.marginBuckets` | | 4 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:534` | `LedgerQueryService.cumulativeEquityByDay` | -| 4 | 0 | find | `SystemStateSnapshot` | `subdomains/core/monitoring/monitoring.service.ts:47` | `MonitoringService.loadState` | +| 4 | 0 | find | `SystemStateSnapshot` | `subdomains/core/monitoring/monitoring.service.ts:237` | `MonitoringService.readState` | | 3 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:278` | `LedgerQueryService.balancesByAccount` | | 3 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:271` | `LedgerReconciliationService.checkTransitAge` | | 3 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:347` | `LedgerReconciliationService.openResidualSince` | -| 3 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1051` | `BuyCryptoService.updateBuyVolume` | -| 3 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1079` | `BuyCryptoService.updateCryptoRouteVolume` | -| 3 | 0 | query-builder (named columns) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:101` | `BuyService.getUserVolume` | +| 3 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1435` | `BuyCryptoService.updateBuyVolume` | +| 3 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1463` | `BuyCryptoService.updateCryptoRouteVolume` | +| 3 | 0 | query-builder (named columns) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:104` | `BuyService.getUserVolume` | | 3 | 0 | query-builder (named columns) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:123` | `SwapService.getUserVolume` | | 3 | 0 | query-builder (named columns) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:677` | `CustodyService.getHistoricalBalances` | | 3 | 0 | query-builder (named columns) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:689` | `CustodyService.getHistoricalBalances` | -| 3 | 0 | query-builder (named columns) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:278` | `RefRewardService.getRewardRecipients` | | 3 | 0 | query-builder (named columns) | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:97` | `BuyFiatRegistrationService.filterSellPayIns` | | 3 | 0 | query-builder (named columns) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:678` | `BuyFiatService.updateSellVolume` | | 3 | 0 | query-builder (named columns) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:261` | `SellService.getUserVolume` | -| 3 | 0 | query-builder (named columns) | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1981` | `KycService.getPendingReviewSummary` | +| 3 | 0 | query-builder (named columns) | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1989` | `KycService.getPendingReviewSummary` | | 3 | 0 | query-builder (field list) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:240` | `UserDataRepository.getForApiKey` | -| 3 | 0 | query-builder (named columns) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:461` | `BankTxService.getBankTxFee` | | 3 | 0 | query-builder (named columns) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:310` | `SupportEscalationService.getLastMessages` | -| 3 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:245` | `SupportIssueService.getSupportIssueStatistics` | +| 3 | 0 | query-builder (named columns) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:278` | `RefRewardService.getRewardRecipients` | +| 3 | 0 | query-builder (named columns) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:572` | `BankTxService.getBankTxFee` | +| 3 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:177` | `SupportIssueService.getSupportIssueStatistics` | | 2 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:320` | `LedgerQueryService.nativeBalanceByAccount` | | 2 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:400` | `LedgerReconciliationService.checkSuspense` | -| 2 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1122` | `BuyCryptoService.getRefVolume` | -| 2 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1134` | `BuyCryptoService.getPartnerFeeRefVolume` | +| 2 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1506` | `BuyCryptoService.getRefVolume` | +| 2 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1518` | `BuyCryptoService.getPartnerFeeRefVolume` | | 2 | 0 | query-builder (field list) | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts:43` | `LiquidityManagementPipelineRepository.findForStatus` | | 2 | 0 | query-builder (named columns) | `—` | `subdomains/core/monitoring/observers/payment.observer.ts:72` | `PaymentObserver.getPayment` | | 2 | 0 | query-builder (named columns) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:721` | `BuyFiatService.getRefVolume` | @@ -776,21 +782,26 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 2 | 0 | query-builder (named columns) | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:53` | `TradingOrderService.getTradingOrderYield` | | 2 | 0 | query-builder (named columns) | `—` | `subdomains/generic/gs/gs.service.ts:868` | `GsService.getExtendedBankTxData` | | 2 | 0 | query-builder (named columns) | `—` | `subdomains/generic/gs/gs.service.ts:887` | `GsService.getExtendedBankTxData` | -| 2 | 0 | query-builder (named columns) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:493` | `BankDataService.getPendingReviewSummary` | +| 2 | 0 | query-builder (named columns) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:497` | `BankDataService.getPendingReviewSummary` | | 2 | 0 | query-builder (named columns) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:354` | `RecommendationService.countByRecommenderIds` | | 2 | 0 | query-builder (named columns) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:369` | `RecommendationService.countByRecommendedIds` | | 2 | 0 | query-builder (field list) | `User` | `subdomains/generic/user/models/user/user.repository.ts:47` | `UserRepository.findAccountIdForAddress` | -| 2 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:226` | `UserService.countRefChildrenByUserDataIds` | -| 2 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:248` | `UserService.countRefReferrersByUserDataIds` | -| 2 | 0 | query-builder (named columns) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:505` | `BankTxService.getBankTxFee` | +| 2 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:227` | `UserService.countRefChildrenByUserDataIds` | +| 2 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:249` | `UserService.countRefReferrersByUserDataIds` | | 2 | 0 | query-builder (field list) | `Log` | `subdomains/supporting/log/log.repository.ts:699` | `LogRepository.getFinancialLogValidityChangeSet` | -| 2 | 0 | query-builder (named columns) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:324` | `TransactionService.getManualRefVolume` | -| 2 | 0 | query-builder (named columns) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:353` | `TransactionService.getAuditPeriodVolumes` | -| 2 | 0 | query-builder (named columns) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:96` | `SupportMessageRepository.findStatsFor` | +| 2 | 0 | query-builder (named columns) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:425` | `TransactionService.getManualRefVolume` | +| 2 | 0 | query-builder (named columns) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:454` | `TransactionService.getAuditPeriodVolumes` | +| 2 | 0 | query-builder (named columns) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:97` | `SupportMessageRepository.findStatsFor` | | 2 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:109` | `SupportIssueService.getSupportIssueCounts` | | 2 | 0 | query-builder (named columns) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:133` | `SupportIssueService.getSupportIssueActivity` | | 2 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:198` | `SupportIssueService.getSupportIssueStatistics` | -| 1 | 0 | query-builder (named columns) | `Asset` | `shared/models/asset/asset.service.ts:140` | `AssetService.getAssetsUsedOn` | +| 2 | 0 | query-builder (named columns) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:516` | `BankTxService.getBankTxFee` | +| 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:165` | `LogRepository.getFinancialChangesLogs` | +| 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:214` | `LogRepository.getFinancialLogs` | +| 1 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:244` | `LogRepository.getFinancialLogs` | +| 1 | 0 | query-builder (named columns) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:528` | `BankTxService.getBankTxFee` | +| 1 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:245` | `SupportIssueService.getSupportIssueStatistics` | +| 1 | 0 | query-builder (named columns) | `Asset` | `shared/models/asset/asset.service.ts:179` | `AssetService.getAssetsUsedOn` | | 1 | 0 | query-builder (named columns) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:79` | `IpLogService.getLoginCountries` | | 1 | 0 | query-builder (named columns) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:92` | `IpLogService.getUserDataIdsWith` | | 1 | 0 | query-builder (named columns) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:103` | `IpLogService.getUserDataIdsWith` | @@ -801,10 +812,10 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 1 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:292` | `LedgerQueryService.nativeBalanceBefore` | | 1 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:304` | `LedgerQueryService.nativeBalanceInPeriod` | | 1 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:487` | `LedgerReconciliationService.journalEquity` | -| 1 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:767` | `BuyCryptoService.updateRefVolumes` | -| 1 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:883` | `BuyCryptoService.getUserVolumeForType` | -| 1 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:955` | `BuyCryptoService.getPendingLiquidityDemandChf` | -| 1 | 0 | query-builder (named columns) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:111` | `BuyService.getTotalVolume` | +| 1 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:980` | `BuyCryptoService.updateRefVolumes` | +| 1 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1267` | `BuyCryptoService.getUserVolumeForType` | +| 1 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1339` | `BuyCryptoService.getPendingLiquidityDemandChf` | +| 1 | 0 | query-builder (named columns) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:114` | `BuyService.getTotalVolume` | | 1 | 0 | query-builder (named columns) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:133` | `SwapService.getTotalVolume` | | 1 | 0 | query-builder (named columns) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:221` | `CustodyService.updateCustodyBalance` | | 1 | 0 | query-builder (named columns) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:229` | `CustodyService.updateCustodyBalance` | @@ -815,31 +826,29 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 1 | 0 | query-builder (named columns) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:603` | `BuyFiatService.getUserVolume` | | 1 | 0 | query-builder (named columns) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:271` | `SellService.getTotalVolume` | | 1 | 0 | query-builder (named columns) | `TradingOrder` | `subdomains/core/trading/services/trading-rule.service.ts:35` | `TradingRuleService.getCurrentTradingOrders` | -| 1 | 0 | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:906` | `GsService.getExtendedBankTxData` | -| 1 | 0 | query-builder (named columns) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:179` | `UserDataService.getUserDataIdsByServiceProvider` | -| 1 | 0 | query-builder (named columns) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1774` | `UserDataService.getMaxKycFileIdByDateRange` | -| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:140` | `UserService.getAllLinkedUsers` | -| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:195` | `UserService.getOpenRefCreditEur` | -| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:576` | `UserService.getUserVolumes` | -| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:586` | `UserService.getUserVolumes` | -| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:648` | `UserService.getRefInfo` | -| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:661` | `UserService.getRefInfo` | -| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:720` | `UserService.getTotalRefRewards` | +| 1 | 0 | query-builder (named columns) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:186` | `UserDataService.getUserDataIdsByServiceProvider` | +| 1 | 0 | query-builder (named columns) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1812` | `UserDataService.getMaxKycFileIdByDateRange` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:141` | `UserService.getAllLinkedUsers` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:196` | `UserService.getOpenRefCreditEur` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:595` | `UserService.getUserVolumes` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:605` | `UserService.getUserVolumes` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:667` | `UserService.getRefInfo` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:680` | `UserService.getRefInfo` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:739` | `UserService.getTotalRefRewards` | | 1 | 0 | query-builder (named columns) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:189` | `DepositService.getNextDepositIndex` | -| 1 | 0 | query-builder (named columns) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:449` | `BankTxService.getBankTxFee` | | 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:97` | `LogRepository.cleanup` | | 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:104` | `LogRepository.cleanup` | -| 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:158` | `LogRepository.getFinancialChangesLogs` | -| 1 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:165` | `LogRepository.getFinancialChangesLogs` | -| 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:206` | `LogRepository.getFinancialLogs` | -| 1 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:214` | `LogRepository.getFinancialLogs` | -| 1 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:244` | `LogRepository.getFinancialLogs` | -| 1 | 0 | query-builder (named columns) | `—` | `subdomains/supporting/payin/services/payin.service.ts:217` | `PayInService.getPayInFee` | -| 1 | 0 | query-builder (named columns) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:352` | `TransactionRequestService.getLegacySettlementTxIds` | -| 1 | 0 | query-builder (named columns) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:406` | `TransactionRequestService.getActiveDepositAddresses` | -| 1 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:177` | `SupportIssueService.getSupportIssueStatistics` | +| 1 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:158` | `LogRepository.getFinancialChangesLogs` | +| 1 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:206` | `LogRepository.getFinancialLogs` | +| 1 | 0 | query-builder (named columns) | `—` | `subdomains/supporting/payin/services/payin.service.ts:246` | `PayInService.getPayInFee` | +| 1 | 0 | query-builder (named columns) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:358` | `TransactionRequestService.getLegacySettlementTxIds` | +| 1 | 0 | query-builder (named columns) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:412` | `TransactionRequestService.getActiveDepositAddresses` | | 1 | 0 | query-builder (named columns) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:186` | `SupportIssueService.getSupportIssueStatistics` | -| — | — | find | `—` | `config/config.ts:1347` | `Configuration.isDomesticIban` | +| 1 | 0 | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:906` | `GsService.getExtendedBankTxData` | +| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:341` | `LogRepository.getFinancialLogAssetPrices` | +| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:511` | `LogRepository.getFinancialLogSummariesFull` | +| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:664` | `LogRepository.getFinancialLogSummariesChartOnly` | +| — | — | find | `—` | `config/config.ts:1365` | `Configuration.isDomesticIban` | | — | — | find | `—` | `integration/binance-pay/services/binance-pay.service.ts:271` | `BinancePayService.verifySignature` | | — | — | find | `—` | `integration/blockchain/api/services/blockchain-balance.service.ts:52` | `BlockchainBalanceService.getSolanaBalances` | | — | — | find | `—` | `integration/blockchain/api/services/blockchain-balance.service.ts:79` | `BlockchainBalanceService.getTronBalances` | @@ -849,49 +858,51 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `integration/blockchain/cardano/cardano-client.ts:100` | `CardanoClient.getTokenBalances` | | — | — | find | `—` | `integration/blockchain/icp/icp-client.ts:231` | `InternetComputerClient.getNativeTransfersForAddress` | | — | — | find | `—` | `integration/blockchain/icp/icp-client.ts:232` | `InternetComputerClient.getNativeTransfersForAddress` | +| — | — | find | `—` | `integration/blockchain/icp/icp-client.ts:369` | `InternetComputerClient.getTransferByTxId` | +| — | — | find | `—` | `integration/blockchain/icp/icp-client.ts:370` | `InternetComputerClient.getTransferByTxId` | | — | — | find | `—` | `integration/blockchain/shared/evm/citrea-base-client.ts:248` | `CitreaBaseClient.swapViaGateway` | | — | — | find | `—` | `integration/blockchain/shared/evm/citrea-base-client.ts:353` | `CitreaBaseClient.getTokenPairByAddresses` | | — | — | find | `—` | `integration/blockchain/shared/evm/evm-client.ts:681` | `EvmClient.poolQuote` | | — | — | find | `—` | `integration/blockchain/shared/evm/evm-client.ts:704` | `EvmClient.getSwapResultBaseUnits` | -| — | — | find | `—` | `integration/blockchain/shared/evm/evm-decimals.service.ts:24` | `EvmDecimalsService.setDecimals` | -| — | — | find | `—` | `integration/blockchain/solana/solana-client.ts:530` | `SolanaClient.updateTokenInstruction` | -| — | — | find | `—` | `integration/blockchain/solana/solana-client.ts:531` | `SolanaClient.updateTokenInstruction` | +| — | — | find | `—` | `integration/blockchain/shared/services/tx-validation.service.ts:104` | `TxValidationService.validateSolanaTransaction` | +| — | — | find | `—` | `integration/blockchain/solana/solana-client.ts:489` | `SolanaClient.getTokenTransactionDestinations` | | — | — | find | `—` | `integration/blockchain/tron/tron-client.ts:51` | `TronClient.getNativeCoinBalanceForAddress` | | — | — | find | `—` | `integration/blockchain/zano/services/zano.service.ts:97` | `ZanoService.addAssetsToWhitelist` | | — | — | find | `—` | `integration/blockchain/zano/services/zano.service.ts:98` | `ZanoService.addAssetsToWhitelist` | | — | — | find | `—` | `integration/blockchain/zano/zano-client.ts:185` | `ZanoClient.getTokenBalances` | | — | — | find | `—` | `integration/exchange/controllers/exchange.controller.ts:93` | `ExchangeController.syncExchange` | -| — | — | find | `—` | `integration/exchange/services/exchange-tx.service.ts:304` | `ExchangeTxService.getTransactionsFor` | +| — | — | find | `—` | `integration/exchange/services/exchange-tx.service.ts:308` | `ExchangeTxService.getTransactionsFor` | | — | — | find | `—` | `integration/exchange/services/exchange.service.ts:228` | `ExchangeService.getWithdraw` | | — | — | find | `—` | `integration/exchange/services/exchange.service.ts:284` | `ExchangeService.getMarket` | | — | — | find | `—` | `integration/exchange/services/exchange.service.ts:297` | `ExchangeService.getTradePair` | | — | — | find | `—` | `integration/exchange/services/exchange.service.ts:343` | `ExchangeService.getBestBidLiquidity` | | — | — | find | `—` | `integration/exchange/services/exchange.service.ts:368` | `ExchangeService.trade` | | — | — | find | `—` | `integration/exchange/services/mexc.service.ts:159` | `MexcService.getWithdraw` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:402` | `ScryptService.withdrawFunds` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:577` | `ScryptService.findWithdrawal` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:600` | `ScryptService.getOrderStatus` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:832` | `ScryptService.placeOrder` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:862` | `ScryptService.cancelOrder` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:895` | `ScryptService.editOrder` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:914` | `ScryptService.getTradePair` | -| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:930` | `ScryptService.getSecurity` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:499` | `ScryptService.withdrawFunds` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:674` | `ScryptService.findWithdrawal` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:766` | `ScryptService.getOrderStatus` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:1084` | `ScryptService.placeOrder` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:1264` | `ScryptService.cancelOrderBySymbol` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:1307` | `ScryptService.editOrder` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:1326` | `ScryptService.getTradePair` | +| — | — | find | `—` | `integration/exchange/services/scrypt.service.ts:1342` | `ScryptService.getSecurity` | | — | — | find | `—` | `integration/lightning/lightning-helper.ts:134` | — | | — | — | find | `—` | `integration/lightning/services/lightning.service.ts:196` | `LightningService.findPayment` | | — | — | find | `—` | `shared/models/asset/asset.controller.ts:39` | `AssetController.getAllAsset` | -| — | — | find | `—` | `shared/models/asset/asset.service.ts:153` | `AssetService.getByQuerySync` | -| — | — | find | `—` | `shared/models/asset/asset.service.ts:157` | `AssetService.getByChainIdSync` | +| — | — | find | `—` | `shared/models/asset/asset.service.ts:192` | `AssetService.getByQuerySync` | +| — | — | find | `—` | `shared/models/asset/asset.service.ts:196` | `AssetService.getByChainIdSync` | | — | — | find | `—` | `shared/models/fiat/fiat.controller.ts:27` | `FiatController.getAllFiat` | | — | — | find | `—` | `shared/models/fiat/fiat.service.ts:32` | `FiatService.getFiatByName` | | — | — | find | `—` | `shared/models/ip-log/ip-log.service.ts:143` | `IpLogService.checkIpCountry` | | — | — | find | `—` | `shared/models/setting/setting.repository.ts:20` | `SettingRepository.setDateMax` | -| — | — | find | `—` | `shared/models/setting/setting.service.ts:169` | `SettingService.updateCustomSignUpFees` | -| — | — | find | `—` | `shared/models/setting/setting.service.ts:198` | `SettingService.getCustomSignUpFees` | +| — | — | find | `—` | `shared/models/setting/setting.service.ts:189` | `SettingService.updateCustomSignUpFees` | +| — | — | find | `—` | `shared/models/setting/setting.service.ts:218` | `SettingService.getCustomSignUpFees` | | — | — | find | `—` | `shared/repositories/cached.repository.ts:16` | `CachedRepository.findOneCached` | | — | — | find | `—` | `shared/repositories/cached.repository.ts:20` | `CachedRepository.findOneCachedBy` | | — | — | find | `—` | `shared/repositories/cached.repository.ts:24` | `CachedRepository.findCached` | | — | — | find | `—` | `shared/repositories/cached.repository.ts:28` | `CachedRepository.findCachedBy` | -| — | — | find | `—` | `shared/services/http.service.ts:84` | `HttpService.getMockResponse` | +| — | — | raw-sql | `—` | `shared/services/cron-lease.service.ts:186` | `CronLeaseService.onModuleInit` | +| — | — | find | `—` | `shared/services/http.service.ts:87` | `HttpService.getMockResponse` | | — | — | find | `—` | `shared/utils/util.ts:786` | `Util.clearTimeout` | | — | — | find | `—` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:339` | `BankTxConsumer.cutoverOwedOpeningChf` | | — | — | find | `—` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:567` | `BankTxConsumer.openingLiabilityLegChf` | @@ -914,24 +925,49 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/core/aml/services/aml-helper.service.ts:700` | — | | — | — | find | `—` | `subdomains/core/aml/services/aml.service.ts:132` | `AmlService.getAmlCheckInput` | | — | — | find | `—` | `subdomains/core/aml/services/aml.service.ts:278` | `AmlService.getBankData` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts:370` | `BuyCrypto.calculateOutputReferenceAmount` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts:662` | `BuyCrypto.setFeeAndFiatReference` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:242` | `BuyCryptoBatchService.filterOutExistingBatches` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts:376` | `BuyCrypto.calculateOutputReferenceAmount` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts:678` | `BuyCrypto.setFeeAndFiatReference` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:152` | `BuyCryptoBatchService.saveBatchIfTransactionsUnchanged` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:288` | `BuyCryptoBatchService.filterOutExistingBatches` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:449` | `BuyCryptoBatchService.handleMissingBuyCryptoLiquidityException` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:292` | `BuyCryptoPreparationService.postProcessAmlVerdict` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:498` | `BuyCryptoPreparationService.isFiat` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:506` | `BuyCryptoPreparationService.isFiat` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:667` | `BuyCryptoPreparationService.In` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:675` | `BuyCryptoPreparationService.In` | | — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:93` | `BuyCryptoRegistrationService.findMatchingRoute` | | — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:95` | `BuyCryptoRegistrationService.findMatchingRoute` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:297` | `BuyCryptoService.update` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1037` | `BuyCryptoService.getCryptoRoute` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:332` | `BuyCryptoService.update` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:512` | `BuyCryptoService.runWithVersionLock` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:529` | `BuyCryptoService.runIfAmlStateCurrent` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:669` | `BuyCryptoService.refundCheckoutTx` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:677` | `BuyCryptoService.refundCheckoutTx` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:684` | `BuyCryptoService.refundCheckoutTx` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:757` | `BuyCryptoService.refundCryptoInput` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:765` | `BuyCryptoService.refundCryptoInput` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:771` | `BuyCryptoService.refundCryptoInput` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:779` | `BuyCryptoService.refundCryptoInput` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:857` | `BuyCryptoService.refundBankTx` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:865` | `BuyCryptoService.refundBankTx` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:992` | `BuyCryptoService.resetAmlCheckForReview` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1000` | `BuyCryptoService.resetAmlCheckForReview` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1014` | `BuyCryptoService.resetAmlCheckForReview` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1021` | `BuyCryptoService.resetAmlCheckForReview` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1057` | `BuyCryptoService.resetAmlCheckForReview` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1088` | `BuyCryptoService.resetAmlCheckForReview` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1094` | `BuyCryptoService.resetAmlCheckForReview` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1421` | `BuyCryptoService.getCryptoRoute` | | — | — | find | `—` | `subdomains/core/custody/controllers/custody-account.controller.ts:66` | `CustodyAccountController.getCustodyAccount` | | — | — | find | `—` | `subdomains/core/custody/controllers/custody-account.controller.ts:67` | `CustodyAccountController.getCustodyAccount` | | — | — | query-builder (no select) | `—` | `subdomains/core/custody/services/custody-account.service.ts:482` | `CustodyAccountService.lockActiveAccessGrant` | | — | — | find | `—` | `subdomains/core/custody/services/custody-account.service.ts:504` | `CustodyAccountService.createGrant` | | — | — | find | `—` | `subdomains/core/custody/services/custody-account.service.ts:598` | `CustodyAccountService.grantAccessForLegacy` | | — | — | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:65` | `CustodyJobService.executeOrder` | -| — | — | find | `—` | `subdomains/core/custody/services/custody-order.service.ts:388` | `CustodyOrderService.checkBalance` | +| — | — | find | `—` | `subdomains/core/custody/services/custody-order.service.ts:386` | `CustodyOrderService.checkBalance` | | — | — | find | `—` | `subdomains/core/custody/services/custody.service.ts:307` | `CustodyService.getUserCustodyHistory` | | — | — | find | `—` | `subdomains/core/history/mappers/transaction-dto.mapper.ts:211` | `TransactionDtoMapper.feeAmountType` | -| — | — | find | `—` | `subdomains/core/history/services/history-access.service.ts:122` | `HistoryAccessService.resolveFromApiKey` | -| — | — | find | `—` | `subdomains/core/history/services/history-access.service.ts:130` | `HistoryAccessService.findOwnedUser` | +| — | — | find | `—` | `subdomains/core/history/services/history-access.service.ts:126` | `HistoryAccessService.resolveFromApiKey` | +| — | — | find | `—` | `subdomains/core/history/services/history-access.service.ts:134` | `HistoryAccessService.findOwnedUser` | | — | — | find | `—` | `subdomains/core/history/services/history.service.ts:169` | `HistoryService.getHistoryTransactions` | | — | — | find | `—` | `subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts:451` | `CcxtExchangeAdapter.checkTransferCompletion` | | — | — | find | `—` | `subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts:228` | `DfxDexAdapter.checkWithdrawCompletion` | @@ -943,10 +979,11 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/core/liquidity-management/adapters/balances/bank.adapter.ts:134` | `BankAdapter.getForBank` | | — | — | find | `—` | `subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts:40` | `ExchangeAdapter.hasPendingOrders` | | — | — | find | `—` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:78` | `LiquidityManagementBalanceService.findRelevantBalance` | -| — | — | find | `—` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:197` | `LiquidityManagementRuleService.confirmOrCreateActionTree` | -| — | — | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:42` | `LiquidityManagementService.checkLiquidityBalances` | +| — | — | find | `—` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:201` | `LiquidityManagementRuleService.confirmOrCreateActionTree` | +| — | — | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:46` | `LiquidityManagementService.checkLiquidityBalances` | | — | — | find | `—` | `subdomains/core/liquidity-management/validators/liquidity-actions-all-steps-match.validator.ts:11` | `LiquidityActionsAllStepsMatchValidator.validate` | | — | — | find | `—` | `subdomains/core/liquidity-management/validators/liquidity-actions-all-steps-match.validator.ts:12` | `LiquidityActionsAllStepsMatchValidator.validate` | +| — | — | find | `—` | `subdomains/core/monitoring/monitoring.service.ts:192` | `MonitoringService.mergeIntoStoredState` | | — | — | find | `—` | `subdomains/core/monitoring/observers/node-health.observer.ts:131` | `NodeHealthObserver.getPoolState` | | — | — | find | `—` | `subdomains/core/monitoring/observers/node-health.observer.ts:139` | `NodeHealthObserver.getNodeStateInPool` | | — | — | find | `—` | `subdomains/core/monitoring/observers/payment.observer.ts:157` | `PaymentObserver.getLastOutputDates` | @@ -954,23 +991,25 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/core/payment-link/entities/payment-quote.entity.ts:116` | `PaymentQuote.getTransferAmount` | | — | — | find | `—` | `subdomains/core/payment-link/entities/payment-quote.entity.ts:123` | `PaymentQuote.getTransferAmountFor` | | — | — | find | `—` | `subdomains/core/payment-link/services/ocp-sticker.service.ts:206` | `OCPStickerService.generateBitcoinFocusStickersPdf` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-activation.service.ts:118` | `PaymentActivationService.doCreateRequest` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-activation.service.ts:121` | `PaymentActivationService.doCreateRequest` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-balance.service.ts:93` | `PaymentBalanceService.getPaymentBalances` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-balance.service.ts:108` | `PaymentBalanceService.getPaymentBalances` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:179` | `PaymentLinkPaymentService.handleBinanceWaiting` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:284` | `PaymentLinkPaymentService.cancelByLink` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:490` | `PaymentLinkPaymentService.deliverToDevice` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:524` | `PaymentLinkPaymentService.handleBinanceWaiting` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:635` | `PaymentLinkPaymentService.cancelByLink` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-link.service.ts:181` | `PaymentLinkService.createInvoice` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-link.service.ts:633` | `PaymentLinkService.getPaymentLinkByAccessKey` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-link.service.ts:643` | `PaymentLinkService.getPaymentLinkByAccessKey` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-link.service.ts:663` | `PaymentLinkService.waitForPayment` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:118` | `PaymentQuoteService.getActualQuoteByPaymentId` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:503` | `PaymentQuoteService.validateEvmTx` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:504` | `PaymentQuoteService.validateEvmTx` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:552` | `PaymentQuoteService.async` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:560` | `PaymentQuoteService.async` | -| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:704` | `PaymentQuoteService.doIcpPayment` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:121` | `PaymentQuoteService.getActualQuoteByPaymentId` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:184` | `PaymentQuoteService.cancelAllForPayment` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:530` | `PaymentQuoteService.validateEvmTx` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:531` | `PaymentQuoteService.validateEvmTx` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:579` | `PaymentQuoteService.async` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:587` | `PaymentQuoteService.async` | +| — | — | find | `—` | `subdomains/core/payment-link/services/payment-quote.service.ts:767` | `PaymentQuoteService.doIcpPayment` | | — | — | find | `—` | `subdomains/core/payment-link/services/payment-standard.service.ts:13` | `PaymentStandardService.getById` | -| — | — | find | `—` | `subdomains/core/sell-crypto/process/buy-fiat.entity.ts:367` | `BuyFiat.setFeeAndFiatReference` | +| — | — | find | `—` | `subdomains/core/sell-crypto/process/buy-fiat.entity.ts:369` | `BuyFiat.setFeeAndFiatReference` | | — | — | find | `—` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:124` | `BuyFiatRegistrationService.findMatchingRoute` | | — | — | find | `—` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:126` | `BuyFiatRegistrationService.findMatchingRoute` | | — | — | find | `—` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:128` | `BuyFiatRegistrationService.findMatchingRoute` | @@ -989,35 +1028,35 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/generic/kyc/services/kyc-admin.service.ts:41` | `KycAdminService.getKycSteps` | | — | — | find | `—` | `subdomains/generic/kyc/services/kyc-client.service.ts:55` | `KycClientService.getAllUserPayments` | | — | — | find | `—` | `subdomains/generic/kyc/services/kyc-client.service.ts:111` | `KycClientService.getFileFor` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:218` | `KycService.reviewIdentSteps` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:433` | `KycService.checkDfxApproval` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:436` | `KycService.checkDfxApproval` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:459` | `KycService.isKycStepUniqueViolation` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:515` | `KycService.initializeProcess` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:531` | `KycService.failContactStepForMail` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1174` | `KycService.getOrCreateStepInternal` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1200` | `KycService.getOrCreateStep` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1201` | `KycService.getOrCreateStep` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1295` | `KycService.getNext` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1358` | `KycService.initiateStep` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1465` | `KycService.completeReferencedSteps` | -| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1777` | `KycService.getIdentCheckErrors` | -| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:332` | `SupportPdfService.createOnboardingPdf` | -| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:358` | `SupportPdfService.createOnboardingPdf` | -| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:370` | `SupportPdfService.createOnboardingPdf` | -| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:386` | `SupportPdfService.createOnboardingPdf` | -| — | — | find | `—` | `subdomains/generic/support/support.service.ts:1327` | `SupportService.getUniqueUserDataByKey` | -| — | — | find | `—` | `subdomains/generic/support/support.service.ts:1336` | `SupportService.getUniqueUserDataByKey` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:220` | `KycService.reviewIdentSteps` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:435` | `KycService.checkDfxApproval` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:438` | `KycService.checkDfxApproval` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:461` | `KycService.isKycStepUniqueViolation` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:523` | `KycService.initializeProcess` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:539` | `KycService.failContactStepForMail` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1182` | `KycService.getOrCreateStepInternal` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1208` | `KycService.getOrCreateStep` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1209` | `KycService.getOrCreateStep` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1303` | `KycService.getNext` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1366` | `KycService.initiateStep` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1473` | `KycService.completeReferencedSteps` | +| — | — | find | `—` | `subdomains/generic/kyc/services/kyc.service.ts:1785` | `KycService.getIdentCheckErrors` | +| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:334` | `SupportPdfService.createOnboardingPdf` | +| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:360` | `SupportPdfService.createOnboardingPdf` | +| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:372` | `SupportPdfService.createOnboardingPdf` | +| — | — | find | `—` | `subdomains/generic/support/support-pdf.service.ts:388` | `SupportPdfService.createOnboardingPdf` | +| — | — | find | `—` | `subdomains/generic/support/support.service.ts:1443` | `SupportService.getUniqueUserDataByKey` | +| — | — | find | `—` | `subdomains/generic/support/support.service.ts:1452` | `SupportService.getUniqueUserDataByKey` | | — | — | find | `—` | `subdomains/generic/user/models/auth/auth.service.ts:497` | `AuthService.checkPendingRecommendation` | | — | — | find | `—` | `subdomains/generic/user/models/auth/auth.service.ts:515` | `AuthService.confirmRecommendationCode` | | — | — | find | `—` | `subdomains/generic/user/models/auth/auth.service.ts:530` | `AuthService.getLinkedUser` | | — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.entity.ts:155` | `BankData.internalReview` | -| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:246` | `BankDataService.updateBankDataInternal` | -| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:338` | `BankDataService.getVerifiedBankDataWithIban` | -| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:339` | `BankDataService.getVerifiedBankDataWithIban` | -| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:340` | `BankDataService.getVerifiedBankDataWithIban` | -| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:362` | `BankDataService.getAllBankDatasForUser` | -| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:444` | `BankDataService.createIbanForUserInternal` | +| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:250` | `BankDataService.updateBankDataInternal` | +| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:342` | `BankDataService.getVerifiedBankDataWithIban` | +| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:343` | `BankDataService.getVerifiedBankDataWithIban` | +| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:344` | `BankDataService.getVerifiedBankDataWithIban` | +| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:366` | `BankDataService.getAllBankDatasForUser` | +| — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:448` | `BankDataService.createIbanForUserInternal` | | — | — | find | `Wallet` | `subdomains/generic/user/models/kyc/kyc.service.ts:57` | `KycService.transferKycData` | | — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:178` | `KycService.getFileFor` | | — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:185` | `KycService.getFileFor` | @@ -1032,54 +1071,64 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.entity.ts:785` | `UserData.getCompletedStepWith` | | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.entity.ts:789` | `UserData.getNonFailedStepWith` | | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.enum.ts:75` | — | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:867` | `UserDataService.checkMail` | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1312` | `UserDataService.mergeUserData` | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1324` | `UserDataService.mergeUserData` | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1332` | `UserDataService.mergeUserData` | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1344` | `UserDataService.mergeUserData` | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1547` | `UserDataService.mergeUserData` | -| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1725` | `UserDataService.updateBankTxTime` | -| — | — | query-builder (count only) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1766` | `UserDataService.countByDateRange` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:444` | `UserDataService.setKycStatusCheck` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:905` | `UserDataService.checkMail` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1350` | `UserDataService.mergeUserData` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1362` | `UserDataService.mergeUserData` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1370` | `UserDataService.mergeUserData` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1382` | `UserDataService.mergeUserData` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1585` | `UserDataService.mergeUserData` | +| — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1763` | `UserDataService.updateBankTxTime` | +| — | — | query-builder (count only) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1804` | `UserDataService.countByDateRange` | | — | — | find | `—` | `subdomains/generic/user/models/user/dto/user-dto.mapper.ts:27` | — | | — | — | find | `—` | `subdomains/generic/user/models/user/user.repository.ts:70` | `UserRepository.getNextRef` | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:336` | `UserService.createUser` | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:490` | `UserService.updateAddress` | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:506` | `UserService.deactivateUser` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:337` | `UserService.createUser` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:509` | `UserService.updateAddress` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:525` | `UserService.deactivateUser` | | — | — | find | `—` | `subdomains/supporting/balance/services/balance-pdf.service.ts:105` | `BalancePdfService.getBalancesForAddress` | -| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts:323` | `BankTx.bankDataName` | -| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts:335` | `BankTx.getSenderAccount` | -| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:172` | `BankTxService.enrichYapealTransactions` | -| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:708` | `BankTxService.findMatchingBuy` | -| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:62` | `BankService.getBankInternal` | -| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:142` | `BankService.getMatchingBank` | -| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:143` | `BankService.getMatchingBank` | -| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:216` | `BankService.getReceiveIbanStatus` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts:150` | `VirtualIbanFrickIssuanceReconciliationService.runPhase1StuckIntents` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts:471` | `VirtualIbanFrickIssuanceReconciliationService.loadAbandonedReferences` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:118` | `VirtualIbanService.getAccountHolder` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:295` | `VirtualIbanService.String` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:385` | `VirtualIbanService.lockUserLevelIssuanceForMerge` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:709` | `VirtualIbanService.async` | -| — | — | raw-sql | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:769` | `VirtualIbanService.hasOrderedOwnershipPath` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1030` | `VirtualIbanService.resolveVirtualIbanId` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1124` | `VirtualIbanService.getFrickIntentForUpdate` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1139` | `VirtualIbanService.getFrickIntentByIdForUpdate` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1157` | `VirtualIbanService.persistUserLevelIfMissing` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1246` | `VirtualIbanService.findActiveForUserCurrencyAndBank` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1312` | `VirtualIbanService.getVirtualIbansForAccount` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1320` | `VirtualIbanService.getFrickVirtualIbansForAccount` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1339` | `VirtualIbanService.deactivateVirtualIbanLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1379` | `VirtualIbanService.deactivateVirtualIbanLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1446` | `VirtualIbanService.resolveIssuanceIntentsForMergeLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1451` | `VirtualIbanService.resolveIssuanceIntentsForMergeLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1501` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1554` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1557` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1565` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1591` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1638` | `VirtualIbanService.mergeUserLevelVirtualIbans` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1669` | `VirtualIbanService.mergeUserLevelVirtualIbans` | -| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1715` | `VirtualIbanService.getProvider` | +| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts:337` | `BankTx.bankDataName` | +| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts:349` | `BankTx.getSenderAccount` | +| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:178` | `BankTxService.enrichYapealTransactions` | +| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:281` | `BankTxService.classifyKnownTypeIfAssignable` | +| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:294` | `BankTxService.classifyKnownTypeIfAssignable` | +| — | — | raw-sql | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:597` | `BankTxService.recoverRollingInternalTransfers` | +| — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:845` | `BankTxService.findMatchingBuy` | +| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:63` | `BankService.getBankInternal` | +| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:153` | `BankService.getMatchingBank` | +| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:154` | `BankService.getMatchingBank` | +| — | — | find | `—` | `subdomains/supporting/bank/bank/bank.service.ts:251` | `BankService.getReceiveIbanStatus` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts:131` | `VirtualIbanFrickIssuanceReconciliationService.runPhase1StuckIntents` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts:344` | `VirtualIbanFrickIssuanceReconciliationService.runCompletedIntentDuplicateCleanup` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts:721` | `VirtualIbanFrickIssuanceReconciliationService.loadAbandonedReferences` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:125` | `VirtualIbanService.getAccountHolder` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:302` | `VirtualIbanService.String` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:392` | `VirtualIbanService.lockUserLevelIssuanceForMerge` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:637` | `VirtualIbanService.recoverFrickIntentForReconciliation` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:655` | `VirtualIbanService.recoverFrickIntentForReconciliation` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:656` | `VirtualIbanService.recoverFrickIntentForReconciliation` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:657` | `VirtualIbanService.recoverFrickIntentForReconciliation` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:720` | `VirtualIbanService.isIbanProtectedFromReconciliationDeactivation` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:831` | `VirtualIbanService.async` | +| — | — | raw-sql | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:891` | `VirtualIbanService.hasOrderedOwnershipPath` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1152` | `VirtualIbanService.resolveVirtualIbanId` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1246` | `VirtualIbanService.getFrickIntentForUpdate` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1261` | `VirtualIbanService.getFrickIntentByIdForUpdate` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1279` | `VirtualIbanService.persistUserLevelIfMissing` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1368` | `VirtualIbanService.findActiveForUserCurrencyAndBank` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1434` | `VirtualIbanService.getVirtualIbansForAccount` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1442` | `VirtualIbanService.getFrickVirtualIbansForAccount` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1461` | `VirtualIbanService.deactivateVirtualIbanLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1501` | `VirtualIbanService.deactivateVirtualIbanLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1568` | `VirtualIbanService.resolveIssuanceIntentsForMergeLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1573` | `VirtualIbanService.resolveIssuanceIntentsForMergeLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1625` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1678` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1681` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1689` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1716` | `VirtualIbanService.resolveMergedVirtualIbanPairLocked` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1763` | `VirtualIbanService.mergeUserLevelVirtualIbans` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1794` | `VirtualIbanService.mergeUserLevelVirtualIbans` | +| — | — | find | `—` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1840` | `VirtualIbanService.getProvider` | | — | — | find | `—` | `subdomains/supporting/dex/strategies/supplementary/impl/bitcoin-testnet4.strategy.ts:35` | `BitcoinTestnet4Strategy.findTransaction` | | — | — | find | `—` | `subdomains/supporting/dex/strategies/supplementary/impl/bitcoin.strategy.ts:35` | `BitcoinStrategy.findTransaction` | | — | — | find | `—` | `subdomains/supporting/dex/strategies/supplementary/impl/cardano.strategy.ts:36` | `CardanoStrategy.findTransaction` | @@ -1091,34 +1140,33 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/supporting/dex/strategies/supplementary/impl/tron.strategy.ts:37` | `TronStrategy.findTransaction` | | — | — | find | `—` | `subdomains/supporting/dex/strategies/supplementary/impl/zano.strategy.ts:37` | `ZanoStrategy.findTransaction` | | — | — | find | `—` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:300` | `FiatOutputJobService.setReadyDate` | -| — | — | find | `—` | `subdomains/supporting/fiat-output/fiat-output.service.ts:55` | `FiatOutputService.selectPayoutBank` | -| — | — | find | `—` | `subdomains/supporting/fiat-output/fiat-output.service.ts:207` | `FiatOutputService.update` | -| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:618` | `LogJobService.getAssetLog` | -| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:625` | `LogJobService.getAssetLog` | -| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:997` | `LogJobService.getAssetLog` | -| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:1607` | `LogJobService.findSenderReceiverPair` | -| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:341` | `LogRepository.getFinancialLogAssetPrices` | -| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:511` | `LogRepository.getFinancialLogSummariesFull` | -| — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:664` | `LogRepository.getFinancialLogSummariesChartOnly` | +| — | — | find | `—` | `subdomains/supporting/fiat-output/fiat-output.service.ts:56` | `FiatOutputService.selectPayoutBank` | +| — | — | find | `—` | `subdomains/supporting/fiat-output/fiat-output.service.ts:217` | `FiatOutputService.update` | +| — | — | find | `—` | `subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts:106` | `FiatPayInSyncService.createCheckoutTx` | +| — | — | find | `—` | `subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts:114` | `FiatPayInSyncService.createCheckoutTx` | +| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:609` | `LogJobService.getAssetLog` | +| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:616` | `LogJobService.getAssetLog` | +| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:982` | `LogJobService.getAssetLog` | +| — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:1833` | `LogJobService.findSenderReceiverPair` | | — | — | query-builder (count only) | `Log` | `subdomains/supporting/log/log.repository.ts:688` | `LogRepository.assertEmptyResultIsEndOfData` | | — | — | find | `—` | `subdomains/supporting/notification/services/notification.service.ts:128` | `NotificationService.resolveMailWallet` | | — | — | find | `—` | `subdomains/supporting/payin/services/payin-notification.service.ts:32` | `PayInNotificationService.returnedCryptoInput` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:148` | `PayInService.getCryptoInputsByTransactionIds` | -| — | — | query-builder (alias only) | `—` | `subdomains/supporting/payin/services/payin.service.ts:156` | `PayInService.getCryptoInputByKeys` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:179` | `PayInService.getNewPayIns` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:192` | `PayInService.getAllUserTransactions` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:201` | `PayInService.getPendingPayIns` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:226` | `PayInService.acknowledgePayIn` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:261` | `PayInService.ignorePayIn` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:269` | `PayInService.retryUncertainSend` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:323` | `PayInService.updateFailedPayments` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:351` | `PayInService.forwardPayIns` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:397` | `PayInService.getUnconfirmedNextBlockPayIns` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:434` | `PayInService.checkOutputConfirmations` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:457` | `PayInService.checkReturnConfirmations` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:480` | `PayInService.returnPayIns` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:509` | `PayInService.processStrandedSendingPayIns` | -| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:553` | `PayInService.checkInputConfirmations` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:177` | `PayInService.getCryptoInputsByTransactionIds` | +| — | — | query-builder (alias only) | `—` | `subdomains/supporting/payin/services/payin.service.ts:185` | `PayInService.getCryptoInputByKeys` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:208` | `PayInService.getNewPayIns` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:221` | `PayInService.getAllUserTransactions` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:230` | `PayInService.getPendingPayIns` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:255` | `PayInService.acknowledgePayIn` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:300` | `PayInService.ignorePayIn` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:308` | `PayInService.retryUncertainSend` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:362` | `PayInService.updateFailedPayments` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:402` | `PayInService.forwardPayIns` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:448` | `PayInService.getUnconfirmedNextBlockPayIns` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:485` | `PayInService.checkOutputConfirmations` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:508` | `PayInService.checkReturnConfirmations` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:531` | `PayInService.returnPayIns` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:560` | `PayInService.processStrandedSendingPayIns` | +| — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:604` | `PayInService.checkInputConfirmations` | | — | — | find | `—` | `subdomains/supporting/payin/strategies/register/impl/base/alchemy.strategy.ts:33` | `AlchemyStrategy.pollAddress` | | — | — | find | `—` | `subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts:81` | `CitreaBaseStrategy.getLastCheckedBlockHeight` | | — | — | find | `—` | `subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts:120` | `CitreaBaseStrategy.mapCoinTransactionsToEntries` | @@ -1136,20 +1184,25 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/supporting/payin/strategies/register/impl/zano.strategy.ts:57` | `ZanoStrategy.getLastCheckedBlockHeight` | | — | — | find | `—` | `subdomains/supporting/payin/strategies/register/impl/zano.strategy.ts:105` | `ZanoStrategy.doMapToPayInEntries` | | — | — | find | `—` | `subdomains/supporting/payment/repositories/transaction-specification.repository.ts:49` | `TransactionSpecificationRepository.findSpec` | -| — | — | find | `—` | `subdomains/supporting/payment/services/fee.service.ts:233` | `FeeService.getFeeBySpecialCode` | -| — | — | find | `—` | `subdomains/supporting/payment/services/fee.service.ts:338` | `FeeService.getFee` | +| — | — | find | `—` | `subdomains/supporting/payment/services/fee.service.ts:237` | `FeeService.getFeeBySpecialCode` | +| — | — | find | `—` | `subdomains/supporting/payment/services/fee.service.ts:342` | `FeeService.getFee` | | — | — | find | `—` | `subdomains/supporting/payment/services/swiss-qr.service.ts:391` | `SwissQRService.formatChDate` | | — | — | find | `—` | `subdomains/supporting/payment/services/swiss-qr.service.ts:409` | `SwissQRService.formatChDate` | -| — | — | find | `—` | `subdomains/supporting/payment/services/transaction-request.service.ts:295` | `TransactionRequestService.findAndComplete` | -| — | — | find | `—` | `subdomains/supporting/payment/services/transaction-request.service.ts:297` | `TransactionRequestService.findAndComplete` | -| — | — | find | `—` | `subdomains/supporting/payment/services/transaction.service.ts:341` | `TransactionService.getAllTransactionsForUserData` | +| — | — | find | `—` | `subdomains/supporting/payment/services/transaction-request.service.ts:301` | `TransactionRequestService.findAndComplete` | +| — | — | find | `—` | `subdomains/supporting/payment/services/transaction-request.service.ts:303` | `TransactionRequestService.findAndComplete` | +| — | — | find | `—` | `subdomains/supporting/payment/services/transaction.service.ts:162` | `TransactionService.resume` | +| — | — | find | `—` | `subdomains/supporting/payment/services/transaction.service.ts:169` | `TransactionService.resume` | +| — | — | find | `—` | `subdomains/supporting/payment/services/transaction.service.ts:179` | `TransactionService.resume` | +| — | — | find | `—` | `subdomains/supporting/payment/services/transaction.service.ts:186` | `TransactionService.resume` | +| — | — | find | `—` | `subdomains/supporting/payment/services/transaction.service.ts:442` | `TransactionService.getAllTransactionsForUserData` | | — | — | find | `—` | `subdomains/supporting/pricing/services/integration/coin-gecko.service.ts:155` | `CoinGeckoService.getCurrency` | | — | — | find | `—` | `subdomains/supporting/pricing/services/integration/pricing-deuro.service.ts:60` | `PricingDeuroService.getPrice` | -| — | — | find | `—` | `subdomains/supporting/realunit/realunit-job.service.ts:132` | `RealUnitJobService.findUnconsumedSettlement` | +| — | — | find | `—` | `subdomains/supporting/realunit/realunit-job.service.ts:140` | `RealUnitJobService.findUnconsumedSettlement` | | — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:339` | `RealUnitService.getHistoryEventByTxHash` | -| — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:1442` | `RealUnitService.toUserDataDtoFromUserData` | -| — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:1606` | `RealUnitService.forwardRegistration` | -| — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:2927` | `RealUnitService.applyRegistrationConfirmation` | +| — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:1443` | `RealUnitService.toUserDataDtoFromUserData` | +| — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:1607` | `RealUnitService.forwardRegistration` | +| — | — | find | `—` | `subdomains/supporting/realunit/realunit.service.ts:2928` | `RealUnitService.applyRegistrationConfirmation` | +| — | — | query-builder (no select) | `—` | `subdomains/supporting/support-issue/services/limit-request.service.ts:71` | `LimitRequestService.updateLimitRequest` | | — | — | find | `—` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:166` | `SupportEscalationService.bindGroupChat` | | — | — | find | `—` | `subdomains/supporting/support-issue/services/support-issue.service.ts:92` | `SupportIssueService.getSupportIssueClerkForAccount` | | — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:633` | `SupportIssueService.getIssueFile` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 393a79297d..a0554ca9b4 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -25,11 +25,11 @@ is treated as the latter. This service loads far more data than it returns. Measured against the real entity metadata: -- The whole database schema has **1,736 columns across 99 tables**. +- The whole database schema has **1,742 columns across 100 tables**. - `PUT /v1/transaction/:id/invoice` selected **1,664 of them** — 96% of the entire schema — to render a PDF containing a handful of values. That is exactly Postgres' limit of 1,664 columns per statement, so one further column in that query would have made it fail outright. -- Of the 534 endpoints, **398 reach at least one load site that fetches whole rows**; 98 read +- Of the 537 route entries, **410 reach at least one load site that fetches whole rows**; 89 read nothing at all, and **36 read only the fields they return**. The widest query a fetching endpoint can trigger is 308 columns at the median, and 19 of them exceed 1,000. @@ -46,12 +46,12 @@ and one on `LimitRequest` **434 across 15** — before any `relations` option is decision what to load therefore lives in the entity definition, not at the call site, and no call site can see what it triggers. -**No read model.** Of the load sites in this repository — at most 1,105, see -[load-sites.md](load-sites.md#measurements) — **116** load less than a whole row: 108 query builders +**No read model.** Of the load sites in this repository — at most 1,158, see +[load-sites.md](load-sites.md#measurements) — **118** load less than a whole row: 108 query builders that name their columns, three that end in `getCount()` or `getExists()` and materialise none, and -the five raw statements. Those 116 are counted, not estimated. At most 989 request whole rows — 957 through the -`find` family, and of the 143 query builders, 17 pass the root alias to `.select(...)`, which reads -like a projection but is not, 14 pass no select at all, and one projects its root but pulls a +the seven raw statements. Those 118 are counted, not estimated. At most 1,040 request whole rows — 1,007 through the +`find` family, and of the 144 query builders, 17 pass the root alias to `.select(...)`, which reads +like a projection but is not, 15 pass no select at all, and one projects its root but pulls a relation in whole. The same entities serve persistence, business logic and pure output paths such as invoices, receipts, history and exports — which need fields, not objects. @@ -205,7 +205,7 @@ Leave `surname` out of the projection and `isInvoiceDataComplete` returns `false load returns `true`. The invoice is refused with "user data is not complete" although the data is complete. No error, no log entry. -This service carries **234 such getters across 50 of its 112 entities**. In an application moving +This service carries **238 such getters across 50 of its 113 entities**. In an application moving money, a silent wrong value is the expensive kind of defect: nothing reports it, so it runs until someone notices the answer is wrong. @@ -370,8 +370,8 @@ So among the six reads this section is about, the gap is narrow and specific: batching into blocks of 100, the audit trail, rejection of fabricated audit records, the block on changing validity through the generic update path. -That was the whole picture when this document was written. It no longer is: 113 sites now name their -columns — the 18 with an explicit field list and the 90 that name them one at a time, plus the five +That was the whole picture when this document was written. It no longer is: 115 sites now name their +columns — the 18 with an explicit field list and the 90 that name them one at a time, plus the seven raw statements — and each of them can drop a field silently. The 17 conversions below carry the four levels. Of the endpoints behind the others, 18 record that they do not, as `0/4`; three stay `n/a` because their field list comes from the request rather than the code, so there is no fixed @@ -401,7 +401,7 @@ The projection tests use that same gate. What they add lives in - **The schema.** Each spec file creates its own Postgres schema (`const SCHEMA = '…'`) so parallel specs cannot collide, and fills it from the entity metadata via `synchronize` rather than from replayed migrations — the reference for a projection is the entity definition, not the migration - history. Measured: 112 entities, 99 tables, 1,736 columns, about half a minute per spec file. + history. Measured: 113 entities, 100 tables, 1,742 columns, about half a minute per spec file. - **The fixtures**, generated from the same metadata. Every scalar column gets a non-empty value and required relations are created recursively, so an empty field in a response proves the query failed to load something. Three kinds of value have to be pinned by hand, and all three are the diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index eb565ef94d..c3d38dc62b 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -8,7 +8,6 @@ import { OnModuleInit, } from '@nestjs/common'; import { Config } from 'src/config/config'; -import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; import { CheckoutPaymentStatus } from 'src/integration/checkout/dto/checkout.dto'; import { CheckoutService } from 'src/integration/checkout/services/checkout.service'; import { toScorechainBlockchain } from 'src/integration/scorechain/dto/scorechain.dto'; From c1a7b5bd12f1b3aecb43f735821afcc8c0151bca Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:45:35 +0000 Subject: [PATCH 46/46] docs: match the test state to the load classification, and two stale counts The nine endpoints whose state moved into the database still carried Tests n/a, which the column reserves for endpoints that read nothing or take their field list from the caller. They now read not yet. POST /gs/debug keeps n/a, which is what that value is for. The count of endpoints above a thousand columns disagreed between the two documents after the transfer - 19 against 21 - and the projection jest config still pinned 112 entities in a comment where the schema now builds 113. --- docs/endpoints.md | 18 +++++++++--------- docs/read-path-projections.md | 2 +- jest.projection.config.js | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/endpoints.md b/docs/endpoints.md index b6ef5e6c9f..ac526e2fa9 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -224,7 +224,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/dashboard/accounting/ledger/suspense` | hidden | projected | 10 | 4/4 | yes | `LedgerController.getSuspense` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/financial/changes` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getFinancialChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/changes/latest` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getLatestChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | -| GET | 1 | | `/dashboard/financial/latest` | hidden | whole rows | 33 | n/a | | `DashboardFinancialController.getLatestBalance` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | +| GET | 1 | | `/dashboard/financial/latest` | hidden | whole rows | 33 | not yet | | `DashboardFinancialController.getLatestBalance` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/log` | hidden | whole rows | 33 | not yet | yes | `DashboardFinancialController.getFinancialLog` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/reconciliation` | hidden | whole rows | 229 | not yet | | `DashboardReconciliationController.getReconciliation` | `subdomains/supporting/dashboard/dashboard-reconciliation.controller.ts` | | GET | 1 | | `/dashboard/financial/reconciliation/overview` | hidden | whole rows | 229 | not yet | | `DashboardReconciliationController.getOverview` | `subdomains/supporting/dashboard/dashboard-reconciliation.controller.ts` | @@ -264,12 +264,12 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/gs/evm/rawTransaction` | hidden | whole rows | 6 | not yet | | `GsEvmController.sendRawTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | | POST | 1 | | `/gs/evm/tokenTransaction` | hidden | whole rows | 33 | not yet | | `GsEvmController.sendTokenTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | | GET | 1 | | `/gs/support` | hidden | whole rows | 906 | not yet | yes | `GsController.getSupportData` | `subdomains/generic/gs/gs.controller.ts` | -| GET | neutral | | `/health` | public | whole rows | 4 | n/a | | `HealthController.getHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/banking` | public | whole rows | 4 | n/a | | `HealthController.getBankingHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/external` | public | whole rows | 4 | n/a | | `HealthController.getExternalHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/liquidity` | public | whole rows | 4 | n/a | | `HealthController.getLiquidityHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/nodes` | public | whole rows | 4 | n/a | | `HealthController.getNodeHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | neutral | | `/health/payment` | public | whole rows | 4 | n/a | | `HealthController.getPaymentHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health` | public | whole rows | 4 | not yet | | `HealthController.getHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/banking` | public | whole rows | 4 | not yet | | `HealthController.getBankingHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/external` | public | whole rows | 4 | not yet | | `HealthController.getExternalHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/liquidity` | public | whole rows | 4 | not yet | | `HealthController.getLiquidityHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/nodes` | public | whole rows | 4 | not yet | | `HealthController.getNodeHealth` | `subdomains/core/monitoring/health.controller.ts` | +| GET | neutral | | `/health/payment` | public | whole rows | 4 | not yet | | `HealthController.getPaymentHealth` | `subdomains/core/monitoring/health.controller.ts` | | GET | 1 | | `/history` | hidden | whole rows | 1363 | not yet | | `HistoryController.getHistory` | `subdomains/core/history/controllers/history.controller.ts` | | GET | 1 | | `/history/:exportType` | hidden | whole rows | 1363 | not yet | | `HistoryController.getApiHistory` | `subdomains/core/history/controllers/history.controller.ts` | | GET | 1 | | `/history/csv` | hidden | none | — | n/a | | `HistoryController.getCsv` | `subdomains/core/history/controllers/history.controller.ts` | @@ -368,7 +368,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/log/:id` | hidden | whole rows | 11 | not yet | | `LogController.update` | `subdomains/supporting/log/log.controller.ts` | | POST | 1 | | `/log/clientError` | public | none | — | n/a | yes | `ClientErrorController.logError` | `subdomains/supporting/log/client-error.controller.ts` | | PUT | 1 | | `/log/financial/validity` | hidden | projected | 2 | 0/4 | | `LogController.setFinancialLogValidity` | `subdomains/supporting/log/log.controller.ts` | -| GET | 1 | | `/monitoring/data` | hidden | whole rows | 4 | n/a | | `MonitoringController.getSystemState` | `subdomains/core/monitoring/monitoring.controller.ts` | +| GET | 1 | | `/monitoring/data` | hidden | whole rows | 4 | not yet | | `MonitoringController.getSystemState` | `subdomains/core/monitoring/monitoring.controller.ts` | | POST | 1 | | `/monitoring/data` | hidden | none | — | n/a | | `MonitoringController.onWebhook` | `subdomains/core/monitoring/monitoring.controller.ts` | | GET | 1 | | `/mros` | hidden | whole rows | 243 | not yet | | `MrosController.getAll` | `subdomains/supporting/mros/mros.controller.ts` | | POST | 1 | | `/mros` | hidden | whole rows | 253 | not yet | | `MrosController.createMros` | `subdomains/supporting/mros/mros.controller.ts` | @@ -521,7 +521,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/setting/disabledProcesses` | hidden | none | — | n/a | | `SettingController.updateProcess` | `shared/models/setting/setting.controller.ts` | | GET | 1 | | `/setting/infoBanner` | public | none | — | n/a | | `SettingController.getInfoBanner` | `shared/models/setting/setting.controller.ts` | | POST | 1 | | `/specialExternalAccount` | hidden | whole rows | 7 | not yet | | `SpecialExternalAccountController.createSpecialExternalAccount` | `subdomains/supporting/payment/controllers/special-external-account.controller.ts` | -| GET | 1 | | `/statistic` | public | whole rows | 5 | n/a | | `StatisticController.getAll` | `subdomains/core/statistic/statistic.controller.ts` | +| GET | 1 | | `/statistic` | public | whole rows | 5 | not yet | | `StatisticController.getAll` | `subdomains/core/statistic/statistic.controller.ts` | | GET | 1 | | `/statistic/status` | public | whole rows | 5 | not yet | | `StatisticController.getStatus` | `subdomains/core/statistic/statistic.controller.ts` | | GET | 1 | | `/statistic/transactions` | public | whole rows | 416 | not yet | | `StatisticController.getTransactions` | `subdomains/core/statistic/statistic.controller.ts` | | GET | 1 | | `/support` | hidden | whole rows | 594 | not yet | | `SupportController.searchUserByKey` | `subdomains/generic/support/support.controller.ts` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index a0554ca9b4..92d5c286a4 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -31,7 +31,7 @@ This service loads far more data than it returns. Measured against the real enti statement, so one further column in that query would have made it fail outright. - Of the 537 route entries, **410 reach at least one load site that fetches whole rows**; 89 read nothing at all, and **36 read only the fields they return**. The widest query a fetching endpoint - can trigger is 308 columns at the median, and 19 of them exceed 1,000. + can trigger is 308 columns at the median, and at least 21 of them exceed 1,000. The column limit was the symptom, not the cause. Loading a thousand columns to return one is equally wasteful under a limit of 4,096 — it simply would not have failed yet. diff --git a/jest.projection.config.js b/jest.projection.config.js index 1f9be983a2..08f93f14cb 100644 --- a/jest.projection.config.js +++ b/jest.projection.config.js @@ -21,7 +21,7 @@ module.exports = { // would exclude them here too — this run is the one that must find them. testPathIgnorePatterns: ['/node_modules/'], // Each spec file creates and drops its own Postgres schema, which is what allows them to run in - // parallel — but the schema is built by `synchronize` over 112 entities and costs about half a + // parallel — but the schema is built by `synchronize` over 113 entities and costs about half a // minute, so the wall clock is dominated by how many files there are, not by how many assertions. testTimeout: 300000, };