perf(buy): resolve the user and the active vIBAN once per payment-info request - #4545
Draft
Danswar wants to merge 1 commit into
Draft
perf(buy): resolve the user and the active vIBAN once per payment-info request#4545Danswar wants to merge 1 commit into
Danswar wants to merge 1 commit into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/quoteperforms 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.
createBuyPaymentInfoloaded it, thentoPaymentInfoDtoloaded the same user again by id. Each load costs two queries, because TypeORM emits itsDISTINCT-id pattern forfindOnewith relations.toPaymentInfoDtonow 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.
getTxDetailsalready resolves it to pick the receiving bank for the fee, andresolveBankInforesolved it again for the deposit destination.getTxDetailsnow 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
resolveBankInfosees it, it is a wholegetTxDetails(pricing, fees, limits) old, and the branch that follows issues an IBAN — so a vIBAN issued concurrently in that window would be missed,createForUserwould hit a duplicate, swallow theConflictException, and the request would fall into the fail-closed throw. That is a 400PersonalIbanIssuanceFailedwhere a fresh read returns the customer's IBAN. To keep that from being re-introduced as a future "optimisation", a negative result is normalised toundefinedat the single point that produces it (getBankIn), and the type isVirtualIban | undefined. Note this is enforced by tests, not by the compiler — the repo sets neitherstrictnorstrictNullChecks, sonullstays assignable. TwogetBankInspecs 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.
createBuyPaymentInforequesteduserData.wallet, butbuyCheckreadsuser.wallet— a different relation, never loaded, so it was alwaysundefined. Both escape hatches guarded by it were inert:SKIP_AML_CHECKautoTradeApproval, which is meant to let a wallet without atradeApprovalDatethrough rather than rejecting it withRecommendationRequiredThe equivalent check in
getTxDetailsdoes have the wallet loaded and only emits a softQuoteError, so the two disagreed on the same condition.autoTradeApprovalis set on roughly half of the configured partner wallets — this is not a marginal case. Their users without atradeApprovalDatewill 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.usersis dropped — nothing reachable from this endpoint reads it (checked the service call tree, the entity getters that dereferencethis.users, and the absence of any@AfterLoad/subscriber), and it fanned the user query out by the number of users on the account.userData.organizationis kept, and looks equally removable but is not:UserData.addressreadsorganization.country, and TypeORM joins the eager relations of a requested relation one level only — soorganization.countryis joined only whenorganizationis requested explicitly. Dropping it would silently blank the recipient country on CHF personal IBANs and drop the QR debtor for organization accounts.Verification
tsc --noEmit,prettier --checkandeslintclean.getTxDetailsreturning the resolved vIBAN,createBuyPaymentInfohanding its user down, the relation set itself (getUseris mocked everywhere, so a droppedwallet: truewould otherwise be invisible), and the id guard.payment-info.servicespec.