Skip to content

perf(buy): resolve the user and the active vIBAN once per payment-info request - #4545

Draft
Danswar wants to merge 1 commit into
developfrom
perf/buy-payment-info-single-lookups
Draft

perf(buy): resolve the user and the active vIBAN once per payment-info request#4545
Danswar wants to merge 1 commit into
developfrom
perf/buy-payment-info-single-lookups

Conversation

@Danswar

@Danswar Danswar commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Removes duplicated work from PUT /v1/buy/paymentInfos, and fixes a latent defect that falls out of it.

Why

This endpoint is the heaviest database consumer among the customer-facing routes, and the large majority of a request is spent waiting on the database rather than computing. It is therefore the first route to suffer whenever the database is under load. Two of its lookups — the user and the active vIBAN — each ran twice per request, together accounting for a substantial share of its queries. PUT /v1/buy/quote performs the same fee and price calculation without them and is an order of magnitude faster, which is the comparison that isolates the cost to these lookups rather than to the pricing work.

What changed

The user was loaded twice. createBuyPaymentInfo loaded it, then toPaymentInfoDto loaded the same user again by id. Each load costs two queries, because TypeORM emits its DISTINCT-id pattern for findOne with relations. toPaymentInfoDto now takes the already-loaded user; the standalone caller (realunit.service) keeps its previous behaviour through the optional parameter, and a mismatched (userId, preloadedUser) pair is rejected rather than booking the transaction request against another account.

The active vIBAN was looked up twice. getTxDetails already resolves it to pick the receiving bank for the fee, and resolveBankInfo resolved it again for the deposit destination. getTxDetails now returns what it found so the second step reuses it.

Only a positive result is reused. A "none found" answer is deliberately re-read. By the time resolveBankInfo sees it, it is a whole getTxDetails (pricing, fees, limits) old, and the branch that follows issues an IBAN — so a vIBAN issued concurrently in that window would be missed, createForUser would hit a duplicate, swallow the ConflictException, and the request would fall into the fail-closed throw. That is a 400 PersonalIbanIssuanceFailed where a fresh read returns the customer's IBAN. To keep that from being re-introduced as a future "optimisation", a negative result is normalised to undefined at the single point that produces it (getBankIn), and the type is VirtualIban | undefined. Note this is enforced by tests, not by the compiler — the repo sets neither strict nor strictNullChecks, so null stays assignable. Two getBankIn specs fail if the normalisation is dropped.

Net query count: BANK/INSTANT with a personal IBAN 1 (was 2); BANK/INSTANT without 2 (was 2); CARD 1 (was 1); Frick selector 0 (was 0). Never more than before.

The lookup still happens inside getTxDetails, in the original order. Resolving it earlier would run vIBAN machinery before the quote succeeds, which the existing tests correctly reject.

Latent defect fixed

Unifying the two loads onto one relation set fixes a real bug. createBuyPaymentInfo requested userData.wallet, but buyCheck reads user.wallet — a different relation, never loaded, so it was always undefined. Both escape hatches guarded by it were inert:

  • SKIP_AML_CHECK
  • autoTradeApproval, which is meant to let a wallet without a tradeApprovalDate through rather than rejecting it with RecommendationRequired

The equivalent check in getTxDetails does have the wallet loaded and only emits a soft QuoteError, so the two disagreed on the same condition.

⚠️ This is a behaviour change and needs a decision before merge. The process gating it is currently enabled, and autoTradeApproval is set on roughly half of the configured partner wallets — this is not a marginal case. Their users without a tradeApprovalDate will now be let through where they previously received a 400. That is what the flag is for, but it should be confirmed rather than shipped silently — the affected wallet list and the live flag state are in the internal review thread. The change is strictly loosening: in production the AML term of the condition is always true, so loading the wallet can only admit more, never reject more.

Relations

userData.users is dropped — nothing reachable from this endpoint reads it (checked the service call tree, the entity getters that dereference this.users, and the absence of any @AfterLoad/subscriber), and it fanned the user query out by the number of users on the account.

userData.organization is kept, and looks equally removable but is not: UserData.address reads organization.country, and TypeORM joins the eager relations of a requested relation one level only — so organization.country is joined only when organization is requested explicitly. Dropping it would silently blank the recipient country on CHF personal IBANs and drop the QR debtor for organization accounts.

Verification

  • Full suite green: 340 suites, 6223 tests on the current develop. tsc --noEmit, prettier --check and eslint clean.
  • Every new test was checked by mutation — reverting the corresponding production change makes it fail. That covers: the positive reuse, the re-read of a negative, getTxDetails returning the resolved vIBAN, createBuyPaymentInfo handing its user down, the relation set itself (getUser is mocked everywhere, so a dropped wallet: true would otherwise be invisible), and the id guard.
  • The trade-approval gate this restores has direct coverage in a new payment-info.service spec.

…o request

PUT /v1/buy/paymentInfos issued the same two lookups twice per request, which matters because
this route is the heaviest database consumer among the customer-facing endpoints and therefore
the first to suffer whenever the database is under load.

- The user was loaded in createBuyPaymentInfo and again in toPaymentInfoDto. Each load costs two
  queries, because TypeORM emits its DISTINCT-id pattern for findOne with relations.
  toPaymentInfoDto now accepts the already-loaded user; the standalone caller keeps its previous
  behaviour through the optional parameter, and a mismatched (userId, preloadedUser) pair is
  rejected rather than attributing the transaction request to another account.

- getTxDetails already resolves the user's active vIBAN to pick the receiving bank for the fee.
  It now returns that result so the deposit-destination step reuses it instead of repeating the
  query. The lookup still happens inside getTxDetails, in the original order, so the ordering
  that keeps a failed quote from creating an external account is unchanged.

Only a positive result is reused. A negative one is deliberately re-read: by the time
resolveBankInfo sees it, it is a whole getTxDetails old and the branch that follows issues an
IBAN, so a vIBAN issued concurrently in that window would be missed — createForUser would hit a
duplicate, swallow the ConflictException, and the request would fail closed with
PersonalIbanIssuanceFailed where a fresh read returns the customer's IBAN. To stop that being
re-introduced as a later optimisation, getBankIn normalises "none found" to undefined at the
single point that produces it. That is enforced by tests rather than by the compiler, since the
repo sets neither strict nor strictNullChecks.

Sharing one relation set between both loads also fixes a latent defect: createBuyPaymentInfo
requested userData.wallet, while buyCheck reads user.wallet — which was therefore always
undefined there. Both escape hatches guarded by it were inert: SKIP_AML_CHECK, and
autoTradeApproval, which is meant to let a wallet without a tradeApprovalDate through rather than
rejecting it with RecommendationRequired. The equivalent check in getTxDetails does have the
wallet loaded and only emits a soft QuoteError, so the two disagreed on identical input. The
resulting behaviour change is gated by DisabledProcess(TRADE_APPROVAL_DATE) and is strictly
loosening; see the pull request for the rollout consideration.

userData.users is dropped: nothing reachable from this endpoint reads it, and it fanned the user
query out by the number of users on the account. userData.organization is kept, because
UserData.address reads organization.country and TypeORM joins the eager relations of a requested
relation one level only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant