fix(realunit): close the PersonalData KYC step a completed registration satisfies - #4559
fix(realunit): close the PersonalData KYC step a completed registration satisfies#4559Danswar wants to merge 6 commits into
Conversation
…on satisfies A completed RealUnit registration lifts the account to LEVEL_20, which the KycLevel enum defines as personal data, but never closes the PERSONAL_DATA step that level represents. For an account carrying an open step from an earlier abandoned attempt the step stays IN_PROGRESS forever: createStep's auto-completion is gated on !preventDirectEvaluation, which any prior step row sets, and KycInfoMapper then keeps returning that stale step as currentStep. The RealUnit client cannot render it and dead-ends onboarding. Reconcile the step with the level at every point that concludes the registration is durably in place, including the idempotent retry paths, so an account stuck before this fix repairs itself on its next registration. Scoped to a PENDING step only: preventDirectEvaluation exists so a retry does not paper over a prior rejection, so a FAILED step keeps going through the normal flow. Kept separate from ensureRegistrationKycLevel because that returns early at LEVEL_20 - exactly the state a stuck account is in.
Review follow-ups on the registration reconciliation. isDataComplete is a non-null check, not a validity check, so the pending lookup alone could not tell an abandoned step from one restartStep deliberately re-opened after Sumsub reported PROBLEMATIC_APPLICANT_DATA. The rejection marker lives on the failed row, not the pending one, so the retry was matched and would have been re-completed with the same rejected data - skipping the correction step and stranding the user on Ident. Bail whenever the PersonalData chain carries a failed step. The RealUnit spec provided KycService as a bare object, so the new call was an undefined-function TypeError swallowed by the best-effort catch: the three call sites were unpinned and an existing lift-failure assertion had stopped discriminating. Provide the method, pin the call sites, and tighten that assertion to the lift-specific message. Move the requiredKycFields projection onto UserData as kycFieldData and use it at both call sites, correct the stale createStep reference to initiateStep, drop production identifiers from the fixtures, and remove the self-heal claim: an already-wedged account answers AlreadyRegistered and never re-posts register/complete, so this prevents the wedge rather than curing it.
Second review pass. The failed-chain guard judged the whole history, so a rejection the user had already remedied would disable the reconciliation for that account forever. Take the verdict from the most recent settled step instead: an unremedied re-open is still blocked, a remedied one stays eligible. Pin the concurrency-collision call site. The existing spy targets ensureRegistrationKycLevel, which ensureRegistrationKycState calls transitively, so reverting that site kept the suite green - the same trap the other two pinning tests were written to close. All three sites now fail a test individually when reverted. Drop the remaining self-heal wording from the log message and the two comments this PR added. It is contradicted by the scope note eight lines below: when the reconciliation throws, the account is left wedged and the log would have told the on-call it recovers by itself, suppressing the manual follow-up that is actually needed. Re-pair the new forwardRegistration test with its own comment so the pre-existing REGRESSION GUARD rationale sits with the test it documents.
…atus Third review pass. Both reviews independently found that the previous pass reopened the regression the guard exists to prevent. CANCELED cannot stand for remediation: initiateStep cancels the previous COMPLETED step, but it also cancels a merely PENDING one, so an untouched rejection retry and a genuine remediation end in the same status. Taking CANCELED as proof let a Sumsub-rejected retry be auto-completed with the same data - and the test added last pass asserted that wrong outcome, because its fixture was the unremedied shape. result separates them durably: complete() writes it, cancel() leaves it alone, and a step cancelled while still pending never had one. A result-less cancellation is skipped so the failed step behind it still decides. The fixture now carries a result and gains its negative twin. Pick the pending step by highest sequence rather than by first match: legacy merged-in accounts carry a second IN_PROGRESS step at a negative sequence, and find() would close that dead step while leaving the live one open - reporting success on an account that stayed wedged.
Fourth review pass. Merged-in rows were still in scope for both the verdict and the pick. A merge seeds the slave's steps 100 below the floor and orders them chronologically only within a batch, so a later merge sits below an earlier one: ranking across batches could let a stale cancellation outrank a newer rejection, and an account whose only pending step was merged-in would have had that dead row closed while its live chain stayed untouched. Own rows always start at 0 and merged-in rows are always negative, so the split is exact. Move the settled-verdict predicate onto KycStep as hasSettledVerdict, where the rest of the status getters live, and drop the hardcoded line references from the docstring - this PR's own insertions had already shifted them onto unrelated code.
Fourth review pass, second finding. restartStep revokes an outcome without erasing it: it calls fail(undefined, ...) and setResult(undefined) keeps the existing value, so a completed-then-restarted step still carries a stale result. Cancelling that row afterwards - a plausible support cleanup - made it read as a clean completion, and the retry was auto-completed with the rejected data. Excluding such a row from the settled set does not fix it: with no settled row left nothing blocks either. The verdict itself has to carry the signal, so KycStep.isRejected reads the durable RESTARTED_STEP marker alongside the status and stays authoritative after the status has moved past FAILED.
|
Four review passes were needed to reach zero findings. Pass 1 — the guard matched a Pass 2 — the "self-heal on retry" claim was wrong and was removed from the log message, the comments and the PR body: an already-wedged account answers Pass 3 — Pass 4 — merged-in rows (negative sequence) were still voting on the verdict and eligible for closing; the chain is now scoped to the account's own steps. And a completion revoked by Every guard is mutation-checked in both directions: too broad and too narrow each turn the suite red. |
Closes item 1 of #4556.
Problem
A completed RealUnit registration lifts the account to
LEVEL_20— which theKycLevelenum defines as personal data — but never closes thePERSONAL_DATAstep that level represents.For an account carrying an open step from an earlier, abandoned KYC attempt the step stays
IN_PROGRESSpermanently: the auto-completion ininitiateStepis gated on!preventDirectEvaluation, which the presence of any prior step row sets, so such an account can never satisfy the step again.KycInfoMapperthen keeps handing that stale step back ascurrentStep, the RealUnit client cannot render it, and onboarding dead-ends with no path forward.Change
Reconcile the step with the level at every point that concludes the registration is durably in place.
KycService.completeSatisfiedPersonalDataStepmirrors whatupdatePersonalDataalready does (complete→createStepLog→updateProgress) and reloads the account withkycStepsso the lookup never reads an undefined relation.Deliberate constraints
isDataCompleteis a non-null check, not a validity check. When Sumsub reportsPROBLEMATIC_APPLICANT_DATA,restartStepfails the completed step and opens a fresh one so the user can correct data that is present but wrong — and the rejection marker lands on the failed row, so a pending-only lookup is no protection. The verdict comes from the most recent step carrying a settled verdict (KycStep.hasSettledVerdict), judged over the account's own chain only. Remediation is read from the step'sresultand the durableRESTARTED_STEPmarker (KycStep.isRejected), not from its status alone:initiateStepcancels a merely pending step as well as a completed one, soCANCELEDalone cannot tell an untouched retry from a remediation, whereasresultsurvivescancel()and a step cancelled while pending never had one. A completion later revoked byrestartStepkeeps its staleresult, so the marker — not the status — is what keeps it blocking. Merged-in rows are excluded — a merge seeds them below the floor and orders them chronologically only within a batch, so ranking across batches could let a stale cancellation outrank a newer rejection. Without this guard the retry would be re-completed with the same rejected data, skipping the correction step and stranding the user onIdent.ensureRegistrationKycLevel. That method returns early atLEVEL_20, which is exactly the state a stuck account is already in, so folding the reconciliation into it would skip precisely the population that needs it.Scope: prevents the wedge, does not cure it
This runs when a registration request arrives. An account that is already wedged does not come back through here on its own — for a wallet with a
CompletedregistrationgetRegistrationInfoanswersAlreadyRegistered, and the client then goes straight to the KYC step flow without re-postingregister/complete. Such an account is only reached if it registers a further wallet.The pre-existing backlog is small enough to handle out of band rather than with a data migration, and one of the affected accounts is the rejection shape above, which must not be auto-completed.
Not a behaviour change
The API already asserts the personal data is satisfied by granting
LEVEL_20; the open step contradicts a decision the API itself made. No level is granted, no verification requirement changes —Identand everything above it are untouched. The code path is reachable only throughPOST /v1/realunit/register/complete.Verification
isDataCompleteguard, dropping the failed-chain guard, widening it back to “any failed step”, dropping theresultdiscrimination, picking the pending step by first match instead of highest sequence, dropping the own-chain scoping, reading the verdict asisFailedinstead ofisRejected, and reverting each of the three call sites individually — every mutation turns the suite red.kyc+user+realunitsubdomains green (785 passed),tsc --noEmitclean, eslint and prettier clean.