diff --git a/src/subdomains/generic/kyc/entities/kyc-step.entity.ts b/src/subdomains/generic/kyc/entities/kyc-step.entity.ts index 8f96031946..75cb6d727a 100644 --- a/src/subdomains/generic/kyc/entities/kyc-step.entity.ts +++ b/src/subdomains/generic/kyc/entities/kyc-step.entity.ts @@ -6,6 +6,7 @@ import { UserData } from '../../user/models/user-data/user-data.entity'; import { KycLevel, KycType, UserDataStatus } from '../../user/models/user-data/user-data.enum'; import { IdentDocumentType, IdentResultData, IdentType } from '../dto/ident-result-data.dto'; import { IdNowResult } from '../dto/ident-result.dto'; +import { KycError } from '../dto/kyc-error.enum'; import { ManualIdentResult } from '../dto/manual-ident-result.dto'; import { KycSessionInfoDto } from '../dto/output/kyc-info.dto'; import { IdDocTypeMap, ReviewAnswer, SumsubResult } from '../dto/sum-sub.dto'; @@ -213,6 +214,23 @@ export class KycStep extends IEntity { return this.isInReview || this.isCompleted; } + // Whether this row records an outcome. A cancellation only does so if the step had completed first: + // `complete()` writes `result` and `cancel()` leaves it untouched, so a cancelled step without one was + // never satisfied — it was merely superseded, and says nothing about the attempt it replaced. + // + get hasSettledVerdict(): boolean { + return !this.isInProgress && !(this.isCanceled && !this.result); + } + + // Whether this step's outcome was rejected rather than accepted. `restartStep` revokes an outcome without + // erasing it — it calls `fail(undefined, …)` and `setResult(undefined)` keeps the existing value — so a + // completed-then-restarted row still carries a stale `result` and, once cancelled, would otherwise read as a + // clean completion. The RESTARTED_STEP marker survives both writes, so it stays authoritative after the + // status has moved on. + get isRejected(): boolean { + return this.isFailed || (this.comment?.split(';').includes(KycError.RESTARTED_STEP) ?? false); + } + update( status: ReviewStatus, result?: KycStepResult, diff --git a/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts b/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts index 73d6fdacdc..6209abcfa0 100644 --- a/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts +++ b/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts @@ -9,6 +9,7 @@ import { Country } from 'src/shared/models/country/country.entity'; import { CountryService } from 'src/shared/models/country/country.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import * as processServiceModule from 'src/shared/services/process.service'; +import { AccountType } from '../../../user/models/user-data/account-type.enum'; import { createCustomUserData } from '../../../user/models/user-data/__mocks__/user-data.entity.mock'; import { UserData } from '../../../user/models/user-data/user-data.entity'; import { RiskStatus, UserDataStatus } from '../../../user/models/user-data/user-data.enum'; @@ -795,3 +796,189 @@ describe('KycService checkDfxApproval step promotion', () => { expect(kycStepRepo.update).not.toHaveBeenCalled(); }); }); + +// A flow that writes personal data outside the step machinery (RealUnit registration) leaves the +// PERSONAL_DATA step IN_PROGRESS: initiateStep's auto-completion is gated on `!preventDirectEvaluation`, +// which any prior step row sets, so an account that once abandoned the step could never satisfy it again +// and KycInfoMapper kept handing that stale step back as `currentStep`. +describe('KycService completeSatisfiedPersonalDataStep', () => { + let service: KycService; + let kycStepRepo: jest.Mocked; + let userDataService: jest.Mocked; + + const personalStep = (status: ReviewStatus, sequenceNumber = 0, result?: string, comment?: string): KycStep => + Object.assign(new KycStep(), { + id: 2 + sequenceNumber, + name: KycStepName.PERSONAL_DATA, + status, + sequenceNumber, + result, + comment, + }); + + // Every field in `requiredKycFields` for a personal account, so `isDataComplete` is true. + const completeUser = (kycSteps: KycStep[], overrides: Partial = {}): UserData => + createCustomUserData({ + id: 1, + accountType: AccountType.PERSONAL, + mail: 'test@test.com', + phone: '+41790000000', + firstname: 'Erika', + surname: 'Mueller', + street: 'Bahnhofstrasse 1', + location: 'Zurich', + zip: '8001', + kycSteps, + ...overrides, + }); + + beforeEach(() => { + kycStepRepo = createMock(); + userDataService = createMock(); + + service = Object.create(KycService.prototype); + (service as any).kycStepRepo = kycStepRepo; + (service as any).userDataService = userDataService; + jest.spyOn(service as any, 'createStepLog').mockResolvedValue(undefined); + jest.spyOn(service as any, 'updateProgress').mockResolvedValue(undefined); + }); + + const run = async (user: UserData): Promise => { + userDataService.getUserData.mockResolvedValue(user); + // the caller's UserData need not carry `kycSteps`; the method reloads it itself + await service.completeSatisfiedPersonalDataStep(createCustomUserData({ id: user.id })); + }; + + it('completes a pending step and advances the process', async () => { + const step = personalStep(ReviewStatus.IN_PROGRESS); + await run(completeUser([step])); + + expect(userDataService.getUserData).toHaveBeenCalledWith(1, { kycSteps: true }); + expect(kycStepRepo.update).toHaveBeenCalledTimes(1); + expect(step.status).toBe(ReviewStatus.COMPLETED); + expect(step.getResult()).toMatchObject({ firstname: 'Erika', surname: 'Mueller', zip: '8001' }); + expect((service as any).updateProgress).toHaveBeenCalled(); + }); + + // preventDirectEvaluation exists so a retry does not paper over a prior rejection. A FAILED step must + // keep going through the normal flow rather than being silently resurrected by a registration. + it('leaves a FAILED step untouched', async () => { + const step = personalStep(ReviewStatus.FAILED); + await run(completeUser([step])); + + expect(kycStepRepo.update).not.toHaveBeenCalled(); + expect(step.status).toBe(ReviewStatus.FAILED); + expect((service as any).updateProgress).not.toHaveBeenCalled(); + }); + + // When Sumsub reports PROBLEMATIC_APPLICANT_DATA, restartStep FAILS the completed step and opens a fresh + // IN_PROGRESS one so the user can correct data that is present but wrong. isDataComplete is a non-null + // check and cannot see that, so without the failed-step guard the retry would be auto-completed with the + // same rejected data and the user stranded on IDENT instead of the correction step. + it('leaves a step re-opened by a rejection alone (FAILED + fresh IN_PROGRESS chain)', async () => { + const failed = personalStep(ReviewStatus.FAILED, 0); + const reopened = personalStep(ReviewStatus.IN_PROGRESS, 1); + await run(completeUser([failed, reopened])); + + expect(kycStepRepo.update).not.toHaveBeenCalled(); + expect(reopened.status).toBe(ReviewStatus.IN_PROGRESS); + expect((service as any).updateProgress).not.toHaveBeenCalled(); + }); + + // A rejection the user has since remedied ends in a step that COMPLETED and was later cancelled by + // initiateStep. cancel() leaves `result` in place, so that row still carries the proof it was satisfied. + it('still completes when an older rejection was remedied before the step was re-opened', async () => { + const failed = personalStep(ReviewStatus.FAILED, 0); + const remedied = personalStep(ReviewStatus.CANCELED, 1, '{"firstname":"Erika"}'); + const pending = personalStep(ReviewStatus.IN_PROGRESS, 2); + await run(completeUser([failed, remedied, pending])); + + expect(kycStepRepo.update).toHaveBeenCalledTimes(1); + expect(pending.status).toBe(ReviewStatus.COMPLETED); + }); + + // The negative twin. initiateStep also cancels a merely PENDING step, so a CANCELED row with no result is + // an untouched retry, not a remediation — the FAILED step behind it must still block. + it('leaves the chain alone when the cancelled step never completed (no result)', async () => { + const failed = personalStep(ReviewStatus.FAILED, 0); + const untouched = personalStep(ReviewStatus.CANCELED, 1); + const pending = personalStep(ReviewStatus.IN_PROGRESS, 2); + await run(completeUser([failed, untouched, pending])); + + expect(kycStepRepo.update).not.toHaveBeenCalled(); + expect(pending.status).toBe(ReviewStatus.IN_PROGRESS); + }); + + // Legacy merged-in accounts can carry two IN_PROGRESS steps, the merged-in one at a negative sequence. + // Closing that dead step would leave the live one open and the account still wedged. + it('closes the live pending step and ignores a merged-in one', async () => { + const merged = personalStep(ReviewStatus.IN_PROGRESS, -102); + const live = personalStep(ReviewStatus.IN_PROGRESS, 0); + await run(completeUser([merged, live])); + + expect(live.status).toBe(ReviewStatus.COMPLETED); + expect(merged.status).toBe(ReviewStatus.IN_PROGRESS); + }); + + // Merged-in rows are history, not the account's own chain: a merge seeds them 100 below the floor, and + // batches are ordered chronologically only WITHIN a batch. Left in scope they would both vote on the + // verdict and be eligible for closing — completing a dead row while the account has no live step at all. + it('does nothing when the only pending step is merged-in', async () => { + const mergedPending = personalStep(ReviewStatus.IN_PROGRESS, -100); + await run(completeUser([mergedPending])); + + expect(kycStepRepo.update).not.toHaveBeenCalled(); + expect(mergedPending.status).toBe(ReviewStatus.IN_PROGRESS); + }); + + // The shape carried by a number of merged prod accounts: the live chain is already COMPLETED at sequence 0 + // and only a merged-in leftover is still pending. Closing that dead row would stamp a COMPLETED verdict and + // a step log onto history inherited from a merged-away account, and leave two COMPLETED rows behind. + it('does nothing when only a merged-in step is pending and the live step is already completed', async () => { + const mergedPending = personalStep(ReviewStatus.IN_PROGRESS, -102); + const live = personalStep(ReviewStatus.COMPLETED, 0, '{"firstname":"Erika"}'); + await run(completeUser([mergedPending, live])); + + expect(kycStepRepo.update).not.toHaveBeenCalled(); + expect(mergedPending.status).toBe(ReviewStatus.IN_PROGRESS); + }); + + // restartStep calls fail(undefined, …) and setResult(undefined) keeps the existing value, so a + // completed-then-restarted row still carries a stale result. Cancelling it afterwards must not read as a + // clean completion — the RESTARTED_STEP marker survives both writes and says the outcome was withdrawn. + it('leaves the chain alone when a restarted step was later cancelled but kept its stale result', async () => { + const withdrawn = personalStep( + ReviewStatus.CANCELED, + 0, + '{"firstname":"Erika"}', + 'PersonalDataNotMatching;RestartedStep', + ); + const pending = personalStep(ReviewStatus.IN_PROGRESS, 1); + await run(completeUser([withdrawn, pending])); + + expect(kycStepRepo.update).not.toHaveBeenCalled(); + expect(pending.status).toBe(ReviewStatus.IN_PROGRESS); + }); + + it('leaves an already completed step untouched', async () => { + const step = personalStep(ReviewStatus.COMPLETED); + await run(completeUser([step])); + + expect(kycStepRepo.update).not.toHaveBeenCalled(); + }); + + it('does nothing when the account data is incomplete', async () => { + const step = personalStep(ReviewStatus.IN_PROGRESS); + await run(completeUser([step], { surname: undefined })); + + expect(kycStepRepo.update).not.toHaveBeenCalled(); + expect(step.status).toBe(ReviewStatus.IN_PROGRESS); + }); + + it('does nothing when there is no PersonalData step', async () => { + await run(completeUser([])); + + expect(kycStepRepo.update).not.toHaveBeenCalled(); + expect((service as any).updateProgress).not.toHaveBeenCalled(); + }); +}); diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 5f6cb2dda9..6ee643b547 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -644,6 +644,58 @@ export class KycService { return KycStepMapper.toStepBase(kycStep); } + /** + * Closes a PERSONAL_DATA step whose data the account already carries, for flows that write personal data + * outside the KYC step machinery (RealUnit registration). Without this the step stays IN_PROGRESS forever: + * the auto-completion in `initiateStep` is gated on `!preventDirectEvaluation`, which any prior step row sets, + * so an account that once abandoned the step can never satisfy it again — and `KycInfoMapper` keeps handing + * that stale step back as `currentStep`. + * + * Only ever closes a step that was abandoned, never one that was RE-OPENED. `isDataComplete` is a non-null + * check, not a validity check, so it cannot tell the two apart on its own: when Sumsub reports + * PROBLEMATIC_APPLICANT_DATA, `restartStep` FAILS the completed step and opens a fresh IN_PROGRESS one so the + * user can correct data that is present but wrong. Auto-completing that retry with the same unchanged data + * would skip the correction step and strand the user on IDENT instead. The rejection marker lives on the + * failed row, not on the pending one, so the pending lookup alone is no protection. + * + * The verdict comes from the most recent SETTLED step, not from the whole history: a rejection the user has + * since remedied ends in a completed step, and that account must stay eligible. + * + * CANCELED alone cannot stand for "remedied": `initiateStep` cancels the previous COMPLETED step in its + * PERSONAL_DATA branch, but its generic pending-step cancel also fires on a merely IN_PROGRESS one, so both a + * remediation and an untouched retry end up CANCELED. `KycStep.hasSettledVerdict` separates them — a + * cancellation only counts once the step had completed, which `result` records durably — and + * `KycStep.isRejected` reads the verdict itself, so a completion later revoked by a restart still blocks + * even after its status has moved past FAILED. + */ + async completeSatisfiedPersonalDataStep(userData: UserData): Promise { + // The caller's UserData is loaded for its own flow and need not carry `kycSteps`; reload so the step + // lookup never reads an undefined relation (same pattern as checkDfxApproval). + const user = await this.userDataService.getUserData(userData.id, { kycSteps: true }); + + // The account's OWN chain only. A merge seeds the slave's rows 100 below the floor, so merged-in history + // is always negative while own rows start at 0 — and merge batches are ordered chronologically only within + // a batch, so ranking across them would let an older batch outrank a newer rejection. + const steps = user.getStepsWith(KycStepName.PERSONAL_DATA).filter((s) => s.sequenceNumber >= 0); + + const lastSettled = Util.maxObj( + steps.filter((s) => s.hasSettledVerdict), + 'sequenceNumber', + ); + if (lastSettled?.isRejected) return; + + const kycStep = Util.maxObj( + steps.filter((s) => s.isInProgress), + 'sequenceNumber', + ); + if (!kycStep || !user.isDataComplete) return; + + await this.kycStepRepo.update(...kycStep.complete(user.kycFieldData)); + await this.createStepLog(user, kycStep); + + await this.updateProgress(user, false); + } + async updateKycStep( kycHash: string, stepId: number, @@ -1358,8 +1410,7 @@ export class KycService { const completedStep = user.getStepsWith(KycStepName.PERSONAL_DATA).find((s) => s.isCompleted); if (completedStep) await this.kycStepRepo.update(...completedStep.cancel()); - const result = user.requiredKycFields.reduce((prev, curr) => ({ ...prev, [curr]: user[curr] }), {}); - if (user.isDataComplete && !preventDirectEvaluation) kycStep.complete(result); + if (user.isDataComplete && !preventDirectEvaluation) kycStep.complete(user.kycFieldData); break; } diff --git a/src/subdomains/generic/user/models/user-data/user-data.entity.ts b/src/subdomains/generic/user/models/user-data/user-data.entity.ts index 822cfc2740..527ff66a6b 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.entity.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.entity.ts @@ -868,6 +868,11 @@ export class UserData extends IEntity { return this.requiredKycFields.every((f) => this[f]); } + // The requiredKycFields projection a completed PERSONAL_DATA step stores as its result. + get kycFieldData(): Record { + return this.requiredKycFields.reduce((prev, curr) => ({ ...prev, [curr]: this[curr] }), {}); + } + get requiredInvoiceFields(): string[] { return ['accountType'].concat(this.isPersonalAccount ? ['firstname', 'surname'] : ['organizationName']); } diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 892fa047ce..b77151ed7c 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -288,7 +288,7 @@ describe('RealUnitService', () => { getUserByAddress: jest.fn(), }, }, - { provide: KycService, useValue: {} }, + { provide: KycService, useValue: { completeSatisfiedPersonalDataStep: jest.fn() } }, { provide: CountryService, useValue: { getCountryWithSymbol: jest.fn() } }, { provide: LanguageService, useValue: { getLanguageBySymbol: jest.fn() } }, { provide: HttpService, useValue: { post: jest.fn(), getRaw: jest.fn() } }, @@ -3051,6 +3051,21 @@ describe('RealUnitService', () => { mockEnvironment = 'loc'; }); + // Pins the forwardRegistration call site: the pre-existing spies target ensureRegistrationKycLevel, + // which ensureRegistrationKycState still calls transitively, so reverting the call site would otherwise + // keep the suite green and silently undo the fix. + it('reconciles the PersonalData step after a completed forward', async () => { + const wallet = softwareWallet.address; + const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet)); + const dto = buildDto(utf8Fields(wallet), signature); + httpService.post.mockResolvedValue({} as any); + + const ok = await (service as any).forwardRegistration(fakeUserData(), dto); + + expect(ok).toBe(true); + expect((service as any).kycService.completeSatisfiedPersonalDataStep).toHaveBeenCalled(); + }); + // REGRESSION GUARD: a legacy software wallet that signed the raw UTF-8 fields // (still accepted by verifyRealUnitRegistrationSignature) must keep working — // the forward must stay UTF-8, not be transliterated, or Aktionariat rejects it. @@ -3536,8 +3551,13 @@ describe('RealUnitService', () => { expect(ok).toBe(true); // the collision is NOT recorded as a failure expect(logService.create).not.toHaveBeenCalledWith(expect.objectContaining({ severity: LogSeverity.ERROR })); - // the idempotent-collision path still (best-effort) lifts the KYC level + // the idempotent-collision path still (best-effort) reconciles the KYC state. Both halves are asserted: + // the level spy alone passes even if the call site is reverted, because ensureRegistrationKycState calls + // ensureRegistrationKycLevel transitively. expect(ensureSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 1 })); + expect((service as any).kycService.completeSatisfiedPersonalDataStep).toHaveBeenCalledWith( + expect.objectContaining({ id: 1 }), + ); }); it('writes the full Aktionariat error body to the DB log but keeps the Loki line redacted', async () => { @@ -3730,7 +3750,7 @@ describe('RealUnitService', () => { // the COMPLETED persist already committed, so a failed lift must not fail the registration expect(ok).toBe(true); expect(aktionariatTxManager.save).toHaveBeenCalledTimes(1); - expect((service as any).logger.error).toHaveBeenCalledWith(expect.stringContaining('will self-heal on retry')); + expect((service as any).logger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to lift KYC level')); }); it('keeps the registration when the KYC lift rejects without a message', async () => { @@ -3771,6 +3791,48 @@ describe('RealUnitService', () => { }); }); + // The stuck account this fixes is BY DEFINITION already at LEVEL_20 (a prior registration lifted it), + // so the step reconciliation must not sit behind ensureRegistrationKycLevel's `>= LEVEL_20` early return. + // Folding the two together would skip the reconciliation for exactly the population that needs it. + describe('ensureRegistrationKycState (reconciles the PersonalData step, not only the level)', () => { + it('closes the PersonalData step even when the account is already at LEVEL_20', async () => { + const completeStep = jest.fn().mockResolvedValue(undefined); + (service as any).kycService.completeSatisfiedPersonalDataStep = completeStep; + const updateUserDataInternal = jest.fn(); + (service as any).userDataService.updateUserDataInternal = updateUserDataInternal; + const userData = { id: 1, kycLevel: KycLevel.LEVEL_20 } as any; + + await (service as any).ensureRegistrationKycState(userData); + + // the level lift correctly no-ops (already there) ... + expect(updateUserDataInternal).not.toHaveBeenCalled(); + // ... but the step reconciliation still runs + expect(completeStep).toHaveBeenCalledWith(userData); + }); + + // Pins the idempotent call site. The pre-existing spies target ensureRegistrationKycLevel, which + // ensureRegistrationKycState still calls transitively, so reverting the call sites would otherwise keep + // the suite green and silently undo the fix. The forwardRegistration site is pinned in its own describe. + it('is reached from idempotentRegistrationResult on a COMPLETED registration', async () => { + const userData = { id: 1, kycLevel: KycLevel.LEVEL_20 } as any; + const registration = { id: 2, signature: '0xsig', status: ReviewStatus.COMPLETED } as any; + + await (service as any).idempotentRegistrationResult(userData, registration, '0xsig'); + + expect((service as any).kycService.completeSatisfiedPersonalDataStep).toHaveBeenCalledWith(userData); + }); + + it('is best-effort: a step-reconciliation failure never breaks a durable registration', async () => { + (service as any).kycService.completeSatisfiedPersonalDataStep = jest + .fn() + .mockRejectedValue(new Error('step repo down')); + const userData = { id: 1, kycLevel: KycLevel.LEVEL_20 } as any; + + await expect((service as any).ensureRegistrationKycState(userData)).resolves.toBeUndefined(); + expect((service as any).logger.error).toHaveBeenCalledWith(expect.stringContaining('step repo down')); + }); + }); + describe('describeError (full error body for the PII audit DB log)', () => { it('returns the Aktionariat HTTP error body verbatim when present (the useful, complete part)', () => { const body = { message: 'E-Mail erika.mueller@example.com already registered' }; diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 91ec287551..a87618df57 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -1358,10 +1358,10 @@ export class RealUnitService { : RealUnitRegistrationStatus.FORWARDING_FAILED; // Self-heal the best-effort KYC level-20 lift on the idempotent COMPLETED retry (see - // ensureRegistrationKycLevel): the prior forward completed the registration, so re-assert the lift + // ensureRegistrationKycState): the prior forward completed the registration, so re-assert the KYC state // here in case it did not land the first time. Monotonic and best-effort — never lowers the level, // never fails the retry. - if (registration.status === ReviewStatus.COMPLETED) await this.ensureRegistrationKycLevel(userData); + if (registration.status === ReviewStatus.COMPLETED) await this.ensureRegistrationKycState(userData); this.logger.info( `RealUnit registration idempotent retry for userData ${userData.id}, registration ${registration.id} → ${status}`, @@ -1644,7 +1644,7 @@ export class RealUnitService { this.logger.info( `RealUnit registration concurrency collision resolved as idempotent for wallet ${dto.walletAddress}`, ); - await this.ensureRegistrationKycLevel(userData); + await this.ensureRegistrationKycState(userData); return true; } // Signature mismatch on an already COMPLETED row must surface as 400, not as a soft forward failure. @@ -1682,8 +1682,8 @@ export class RealUnitService { return false; } - // completed or idempotent: lift KYC level (best-effort, self-healing) and write the INFO audit log. - await this.ensureRegistrationKycLevel(userData); + // completed or idempotent: reconcile KYC state (best-effort) and write the INFO audit log. + await this.ensureRegistrationKycState(userData); await this.logAktionariatRegistration( LogSeverity.INFO, dto.walletAddress, @@ -1710,6 +1710,38 @@ export class RealUnitService { } } + // A completed registration means the account's personal data is on file and verified equal to the signed + // envelope (validateRegistrationDto rejects a mismatch with a 400 before we get here), and the level lift + // above grants LEVEL_20 — which the KycLevel enum defines as "personal data". An open PERSONAL_DATA step + // therefore contradicts a decision the API has already made, and the RealUnit client cannot render that step, + // so it dead-ends onboarding. Reconcile the step with the level. + // + // Separate from ensureRegistrationKycLevel on purpose: that one returns early once the account is at + // LEVEL_20, which is exactly the state a stuck account is already in — folding this in would skip it for + // every account that needs it. Best-effort like the lift, so a failure here never fails the registration. + private async ensureRegistrationPersonalDataStep(userData: UserData): Promise { + try { + await this.kycService.completeSatisfiedPersonalDataStep(userData); + } catch (e) { + this.logger.error( + `Failed to close the PersonalData KYC step for RealUnit registration (userData ${userData.id}); the step stays open and needs manual reconciliation: ${e?.message || e}`, + ); + } + } + + // The KYC state a durably COMPLETED registration implies. Called from every point that concludes the + // registration is in place, including the idempotent retry paths. + // + // Scope: this reconciles registrations as they happen, so it prevents the wedge rather than curing it. An + // account that is ALREADY wedged does not come back through here on its own — for a wallet with a COMPLETED + // registration `getRegistrationInfo` answers AlreadyRegistered, and the client then goes straight to the KYC + // step flow without re-posting register/complete. Such an account is only reached if it registers a further + // wallet. The pre-existing backlog is handled out of band. + private async ensureRegistrationKycState(userData: UserData): Promise { + await this.ensureRegistrationKycLevel(userData); + await this.ensureRegistrationPersonalDataStep(userData); + } + // Aktionariat's registerUser answers "Existing user found, updated your address." when the signed email // already belongs to a share-register shareholder: it updates that shareholder's wallet in place and sends // NO confirmation email (a newly registered email instead gets "Confirmation email sent to ..."). Such a