Skip to content

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

Closed
Danswar wants to merge 4 commits into
developfrom
perf/buy-payment-info-duplicate-lookups
Closed

perf(buy): resolve the user and the active vIBAN once per payment-info request#4543
Danswar wants to merge 4 commits into
developfrom
perf/buy-payment-info-duplicate-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, which is what makes it the first to suffer whenever the database is under load. Measured against production traces of typical requests (60–250 ms band): ~106 ms, 13.3 DB queries, 74% of the request spent waiting on the database. Of those queries, User accounted for 4.0 and VirtualIban for 3.5 — both because the same lookup ran twice. For comparison, PUT /v1/buy/quote runs the same fee and price calculation without these lookups and sits at a 2 ms median.

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, 6214 tests. 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.

Danswar added 4 commits July 31, 2026 11:01
…o request

PUT /v1/buy/paymentInfos issued the same two lookups twice. Measured against production
traces of typical requests (~106 ms, 13 DB queries, 74% of the request spent in the
database): the user accounted for 4 of those queries and the vIBAN for 3.5.

- 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
  callers keep their previous behaviour through the optional parameter.

- 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. null distinguishes "looked up, none found" from undefined
  ("no lookup ran"), so a caller that never triggered one still resolves it itself. The
  lookup still happens inside getTxDetails, so the ordering that keeps a failed quote
  from creating an external account is unchanged.

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 tradeApprovalDate through rather
than failing it with RecommendationRequired. The equivalent check in getTxDetails does have
the wallet loaded and only emits a soft QuoteError, so the two disagreed. The resulting
behaviour change is gated by DisabledProcess(TRADE_APPROVAL_DATE).

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.
Review follow-up on the payment-info deduplication.

Reusing the vIBAN lookup was wrong for the negative case. A "none found" answer is a whole
getTxDetails (pricing, fees, limits) old by the time resolveBankInfo sees it, and the branch
that follows issues an IBAN. If a concurrent request issued one in that window, the stale
negative caused createForUser to hit a duplicate, swallow the ConflictException and fall into
the fail-closed throw — a 400 PersonalIbanIssuanceFailed where the previous fresh read returned
the IBAN. Bank 15 issued 460 user-level vIBANs in the last 90 days and several accounts hold
multiple active ones, so concurrent requests demonstrably reach this path. Only a positive
result is reused now; the negative path re-reads, which costs one SELECT before an external
issuance call anyway.

toPaymentInfoDto now rejects a preloadedUser whose id does not match userId — the transaction
request is attributed to userId, so a mismatch would book it against another account — and
documents that the user must carry PAYMENT_INFO_USER_RELATIONS, since getTxErrors dereferences
user.wallet without optional chaining.

Test gaps that let the change revert unnoticed are closed: getTxDetails actually returning the
resolved vIBAN, createBuyPaymentInfo handing its user down, and the relation set itself (getUser
is mocked everywhere, so deleting wallet: true was invisible). The trade-approval gate that the
shared relation set restores now has direct coverage in a new payment-info.service spec.
…f documented

Review follow-up. The previous commit fixed the stale-negative race with a runtime check, but
kept a null/undefined tri-state that nothing consumed and whose doc still told callers a
negative result was reusable — the exact reasoning that produces the fail-closed
PersonalIbanIssuanceFailed. getBankIn now normalises "none found" to undefined and the type is
narrowed to VirtualIban | undefined, so a negative cannot be represented at all and reuse is
only ever a positive hit. The doc says why, rather than describing a distinction that no longer
exists.

Also from review: the negative-path test asserted only a call count, so a mutation that re-read
and then discarded the result still passed. Its fresh read now returns a vIBAN — standing in for
one issued concurrently in that window — and the test asserts that IBAN reaches the response.

Smaller conformance fixes: sorted and absolutised the new spec's imports, added the missing
return type on its user() helper and used the KycLevel enum rather than a bare 50, and gave the
preloaded-user guard a capitalised message without the methodName prefix, which appears nowhere
else in src.
Review follow-up, no behaviour change.

The negative-path test moved from CARD to BANK when its fresh read started returning a vIBAN,
but kept the comment explaining why CARD was necessary. That rationale belongs to the following
test, which is still CARD, and was actively wrong where it stood.

The preloaded-user JSDoc pointed at a module-private const via {@link}, so a cross-module caller
could not reach the contract it was told to satisfy. The relation set is spelled out instead,
which keeps the const private and the guidance usable.

Also sorts the new spec's process.service import into its alphabetical position, which the
previous commit claimed to have done.
@Danswar

Danswar commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #4545, which carries the same reviewed change on a fresh branch off the current develop.

@Danswar Danswar closed this Jul 31, 2026
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