From a933a11ad906e8a111277e5d04ee0b4342754c1d Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 2 Jun 2026 17:49:04 +0200 Subject: [PATCH 1/8] fix(kyc): never surface DfxApproval as a user-actionable step (#3794) * fix(kyc): never surface DfxApproval as a user-actionable step DfxApproval is a DFX-side decision the user can never act on. KycStep.create defaults a freshly initiated step to IN_PROGRESS, and a DfxApproval step in an actionable status (NOT_STARTED/IN_PROGRESS/...) made KycInfoMapper compute processStatus=InProgress and pick it as currentStep. The client then routed it into the user-actionable lane with no UI for it -> blank/dead-end screen (DFXswiss/realunit-app#618). Introduce KycStepNonUserActionable = [DfxApproval] and, in KycInfoMapper: - never select a non-user-actionable step as currentStep; - classify it as PendingReview (awaiting DFX) even when its raw status is in the actionable set, so it can never drive processStatus into IN_PROGRESS. Adds focused KycInfoMapper specs (red->green): DfxApproval-only InProgress now reads PendingReview and is never currentStep; a real user-actionable step still yields InProgress; OnHold behaviour unchanged. * fix(kyc): also drop an explicitly-passed DfxApproval currentStep The Fixer<->Tester loop (Tester-Inspector) found the prior change only guarded the derived (`??=`) currentStep; a caller passing `currentStep: dfxApproval` explicitly (4th arg of toDto) would still emit it. Drop a non-user-actionable step regardless of how it arrives, and cover it with a durable session-DTO regression: toDto(userData, true, [], dfxApprovalStep) -> currentStep undefined. --- .../mapper/__tests__/kyc-info.mapper.spec.ts | 84 ++++++++++++++++++- .../generic/kyc/dto/mapper/kyc-info.mapper.ts | 23 +++-- .../generic/kyc/enums/kyc-step-name.enum.ts | 5 ++ 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts b/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts index 719a998712..2b7aa47292 100644 --- a/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts +++ b/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts @@ -6,7 +6,7 @@ import { KycStep } from '../../../entities/kyc-step.entity'; import { KycStepName } from '../../../enums/kyc-step-name.enum'; import { requiredKycSteps } from '../../../enums/kyc.enum'; import { ReviewStatus } from '../../../enums/review-status.enum'; -import { KycLevelDto, KycProcessStatus } from '../../output/kyc-info.dto'; +import { KycLevelDto, KycProcessStatus, KycSessionDto } from '../../output/kyc-info.dto'; import { KycInfoMapper } from '../kyc-info.mapper'; jest.mock('../../../enums/kyc.enum', () => ({ @@ -164,6 +164,88 @@ describe('KycInfoMapper', () => { }); }); + // DfxApproval is a DFX-side decision: the user can never act on it. A freshly + // initiated approval step can momentarily carry an actionable status + // (KycStep.create defaults to IN_PROGRESS); it must still read as + // PendingReview and never be surfaced as the user's currentStep — otherwise + // the client routes it into the actionable lane with no UI (blank screen). + describe('DfxApproval (non-user-actionable backend step)', () => { + it('returns PendingReview when the only open required step is DfxApproval InProgress (awaiting DFX)', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.DFX_APPROVAL); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.DFX_APPROVAL, ReviewStatus.IN_PROGRESS), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.PENDING_REVIEW); + }); + + it('still returns InProgress when a real user-actionable step is open alongside DfxApproval', () => { + setRequiredSteps(KycStepName.IDENT, KycStepName.DFX_APPROVAL); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.IDENT, ReviewStatus.IN_PROGRESS), + buildStep(KycStepName.DFX_APPROVAL, ReviewStatus.IN_PROGRESS), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.IN_PROGRESS); + }); + + it('never surfaces DfxApproval as the current step, even when it is the only open step', () => { + // The incident shape: every other required step done, DfxApproval the only + // open one in an actionable status. Without the guard it becomes + // `currentStep` and the client gets a step it has no UI for (blank screen). + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.DFX_APPROVAL); + const contact = buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED); + contact.id = 1; + const approval = buildStep(KycStepName.DFX_APPROVAL, ReviewStatus.IN_PROGRESS); + approval.id = 3; + const userData = buildUserData({ kycSteps: [contact, approval] }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.kycSteps.find((s) => s.name === KycStepName.DFX_APPROVAL)?.isCurrent).toBeFalsy(); + }); + + it('drops an explicitly-passed DfxApproval currentStep (never emitted as the session currentStep)', () => { + // A caller can pass `currentStep` explicitly (4th arg), bypassing the + // internal `??=` derivation. A DfxApproval passed this way must still be + // dropped — otherwise it is emitted as the session currentStep (the + // blank-screen path). + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.DFX_APPROVAL); + const approval = buildStep(KycStepName.DFX_APPROVAL, ReviewStatus.IN_PROGRESS); + approval.id = 3; + const userData = buildUserData({ + kycSteps: [buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), approval], + }); + + const result = KycInfoMapper.toDto(userData, true, [], approval) as KycSessionDto; + + expect(result.currentStep).toBeUndefined(); + }); + + it('keeps returning PendingReview for an OnHold DfxApproval (unchanged behaviour)', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.DFX_APPROVAL); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.DFX_APPROVAL, ReviewStatus.ON_HOLD), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.PENDING_REVIEW); + }); + }); + describe('isRequired flag', () => { it('marks a step whose name is in requiredStepNames as required', () => { setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT); diff --git a/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts b/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts index 21508f2845..6f33318797 100644 --- a/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts +++ b/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts @@ -3,7 +3,7 @@ import { Util } from 'src/shared/utils/util'; import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; import { UserData } from '../../../user/models/user-data/user-data.entity'; import { KycStep } from '../../entities/kyc-step.entity'; -import { KycStepName, KycStepRepeatable } from '../../enums/kyc-step-name.enum'; +import { KycStepName, KycStepNonUserActionable, KycStepRepeatable } from '../../enums/kyc-step-name.enum'; import { KycStepType, getKycStepIndex, getKycTypeIndex, requiredKycSteps } from '../../enums/kyc.enum'; import { ReviewStatus } from '../../enums/review-status.enum'; import { KycLevelDto, KycProcessStatus, KycSessionDto } from '../output/kyc-info.dto'; @@ -17,9 +17,14 @@ export class KycInfoMapper { currentStep?: KycStep, ): KycLevelDto | KycSessionDto { const kycSteps = KycInfoMapper.getUiSteps(userData); + // A non-user-actionable step (e.g. DfxApproval, a DFX-side decision) is + // never the user's `currentStep` — the user has no action to take on it, so + // surfacing it as current produced a dead-end (blank) screen on the client. + if (currentStep && KycStepNonUserActionable.includes(currentStep.name)) currentStep = undefined; + currentStep ??= - kycSteps.find((s) => s.status === ReviewStatus.IN_PROGRESS) ?? - kycSteps.find((s) => s.status === ReviewStatus.FAILED); + kycSteps.find((s) => s.status === ReviewStatus.IN_PROGRESS && !KycStepNonUserActionable.includes(s.name)) ?? + kycSteps.find((s) => s.status === ReviewStatus.FAILED && !KycStepNonUserActionable.includes(s.name)); const userKycClients = kycClients.filter((kc) => userData.kycClientList.includes(kc.id)); @@ -68,8 +73,16 @@ export class KycInfoMapper { ReviewStatus.ON_HOLD, ]); - if (requiredSteps.some((s) => actionable.has(s.status))) return KycProcessStatus.IN_PROGRESS; - if (requiredSteps.some((s) => pending.has(s.status))) return KycProcessStatus.PENDING_REVIEW; + // A non-user-actionable step (e.g. DfxApproval) is a backend/DFX-side + // decision: while open it is "awaiting DFX", never a user action. Treat it + // as pending-review even when its raw status is in the actionable set, so it + // can never push the client into the user-actionable IN_PROGRESS lane. + const isUserActionable = (s: KycStep) => actionable.has(s.status) && !KycStepNonUserActionable.includes(s.name); + const isPendingReview = (s: KycStep) => + pending.has(s.status) || (KycStepNonUserActionable.includes(s.name) && actionable.has(s.status)); + + if (requiredSteps.some(isUserActionable)) return KycProcessStatus.IN_PROGRESS; + if (requiredSteps.some(isPendingReview)) return KycProcessStatus.PENDING_REVIEW; return KycProcessStatus.COMPLETED; } diff --git a/src/subdomains/generic/kyc/enums/kyc-step-name.enum.ts b/src/subdomains/generic/kyc/enums/kyc-step-name.enum.ts index b73949613d..41be4dcbf6 100644 --- a/src/subdomains/generic/kyc/enums/kyc-step-name.enum.ts +++ b/src/subdomains/generic/kyc/enums/kyc-step-name.enum.ts @@ -40,6 +40,11 @@ export const KycStepIdentRequiredForReview = [ KycStepName.BENEFICIAL_OWNER, KycStepName.OPERATIONAL_ACTIVITY, ]; +// Steps the user can never action because they are a backend/DFX-side decision. +// While such a step is open it is "awaiting DFX", so it must never surface to +// the client as the actionable `currentStep` / `InProgress` — it reads as +// PendingReview instead. +export const KycStepNonUserActionable = [KycStepName.DFX_APPROVAL]; export const KycStepRepeatable = [ KycStepName.ADDRESS_CHANGE, KycStepName.PHONE_CHANGE, From 3c085b902ebecc0738f95eb930cd339937566d6b Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:12:03 +0200 Subject: [PATCH 2/8] feat(kyc): add context query param to filter required steps per flow (#3814) * feat(kyc): add context query param to KYC status endpoint Allow the KYC endpoint to return only the steps relevant to the caller's flow. RealUnit buy-only users see LEVEL_30 steps as required and get processStatus: Completed after Ident, skipping FINANCIAL_DATA. When they later sell, the sell context surfaces the full step set. * refactor(kyc): use DTO with class-validator for context query param Replace manual validateContext in service with KycQueryDto using @IsEnum + @IsOptional decorators. Framework-level validation via the global ValidationPipe. * test(kyc): add PendingReview test for RealunitBuy with IDENT in review --- .../generic/kyc/controllers/kyc.controller.ts | 9 +- .../generic/kyc/dto/input/kyc-query.dto.ts | 16 +++ .../mapper/__tests__/kyc-info.mapper.spec.ts | 99 ++++++++++++++++++- .../generic/kyc/dto/mapper/kyc-info.mapper.ts | 16 ++- src/subdomains/generic/kyc/enums/kyc.enum.ts | 21 ++++ .../generic/kyc/services/kyc.service.ts | 22 +++-- .../realunit/exceptions/buy-exceptions.ts | 5 +- .../supporting/realunit/realunit.service.ts | 14 ++- 8 files changed, 183 insertions(+), 19 deletions(-) create mode 100644 src/subdomains/generic/kyc/dto/input/kyc-query.dto.ts diff --git a/src/subdomains/generic/kyc/controllers/kyc.controller.ts b/src/subdomains/generic/kyc/controllers/kyc.controller.ts index f1f2ce9022..19a84d24ee 100644 --- a/src/subdomains/generic/kyc/controllers/kyc.controller.ts +++ b/src/subdomains/generic/kyc/controllers/kyc.controller.ts @@ -65,6 +65,7 @@ import { KycLevelDto, KycSessionDto, KycStepBase } from '../dto/output/kyc-info. import { MergedDto } from '../dto/output/kyc-merged.dto'; import { Setup2faDto } from '../dto/output/setup-2fa.dto'; import { SumSubWebhookResult } from '../dto/sum-sub.dto'; +import { KycContinueQueryDto, KycQueryDto } from '../dto/input/kyc-query.dto'; import { KycStepName } from '../enums/kyc-step-name.enum'; import { ReviewStatus } from '../enums/review-status.enum'; import { SumsubService } from '../services/integration/sum-sub.service'; @@ -119,8 +120,8 @@ export class KycController { @Get() @ApiOkResponse({ type: KycLevelDto }) @ApiUnauthorizedResponse(MergedResponse) - async getKycLevel(@Headers(CodeHeaderName) code: string): Promise { - return this.kycService.getInfo(code); + async getKycLevel(@Headers(CodeHeaderName) code: string, @Query() query: KycQueryDto): Promise { + return this.kycService.getInfo(code, query.context); } @Put() @@ -131,9 +132,9 @@ export class KycController { async continueKyc( @Headers(CodeHeaderName) code: string, @RealIP() ip: string, - @Query('autoStep') autoStep?: string, + @Query() query: KycContinueQueryDto, ): Promise { - return this.kycService.continue(code, ip, autoStep !== 'false'); + return this.kycService.continue(code, ip, query.autoStep !== 'false', query.context); } @Get('countries') diff --git a/src/subdomains/generic/kyc/dto/input/kyc-query.dto.ts b/src/subdomains/generic/kyc/dto/input/kyc-query.dto.ts new file mode 100644 index 0000000000..6896775c71 --- /dev/null +++ b/src/subdomains/generic/kyc/dto/input/kyc-query.dto.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; +import { KycContext } from '../../enums/kyc.enum'; + +export class KycQueryDto { + @ApiPropertyOptional({ enum: KycContext, description: 'KYC context to filter required steps per flow' }) + @IsOptional() + @IsEnum(KycContext) + context?: KycContext; +} + +export class KycContinueQueryDto extends KycQueryDto { + @ApiPropertyOptional() + @IsOptional() + autoStep?: string; +} diff --git a/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts b/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts index 2b7aa47292..d6e98ed7a1 100644 --- a/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts +++ b/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts @@ -4,7 +4,7 @@ import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data import { KycLevel } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; import { KycStep } from '../../../entities/kyc-step.entity'; import { KycStepName } from '../../../enums/kyc-step-name.enum'; -import { requiredKycSteps } from '../../../enums/kyc.enum'; +import { KycContext, requiredKycSteps } from '../../../enums/kyc.enum'; import { ReviewStatus } from '../../../enums/review-status.enum'; import { KycLevelDto, KycProcessStatus, KycSessionDto } from '../../output/kyc-info.dto'; import { KycInfoMapper } from '../kyc-info.mapper'; @@ -274,4 +274,101 @@ describe('KycInfoMapper', () => { expect(financialStep?.isRequired).toBe(false); }); }); + + describe('KYC context filtering', () => { + it('returns Completed for RealunitBuy when LEVEL_30 steps are done, even with FINANCIAL_DATA incomplete', () => { + setRequiredSteps( + KycStepName.CONTACT_DATA, + KycStepName.PERSONAL_DATA, + KycStepName.NATIONALITY_DATA, + KycStepName.IDENT, + KycStepName.FINANCIAL_DATA, + KycStepName.DFX_APPROVAL, + ); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.PERSONAL_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.NATIONALITY_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.IDENT, ReviewStatus.COMPLETED), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, [], undefined, KycContext.REALUNIT_BUY) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.COMPLETED); + expect(result.kycSteps.find((s) => s.name === KycStepName.FINANCIAL_DATA)?.isRequired).toBe(false); + expect(result.kycSteps.find((s) => s.name === KycStepName.DFX_APPROVAL)?.isRequired).toBe(false); + }); + + it('returns InProgress for RealunitSell when FINANCIAL_DATA is incomplete (no filtering)', () => { + setRequiredSteps( + KycStepName.CONTACT_DATA, + KycStepName.IDENT, + KycStepName.FINANCIAL_DATA, + KycStepName.DFX_APPROVAL, + ); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.IDENT, ReviewStatus.COMPLETED), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, [], undefined, KycContext.REALUNIT_SELL) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.IN_PROGRESS); + expect(result.kycSteps.find((s) => s.name === KycStepName.FINANCIAL_DATA)?.isRequired).toBe(true); + }); + + it('behaves unchanged without a context (backwards compatible)', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.FINANCIAL_DATA); + const userData = buildUserData({ + kycSteps: [buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED)], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.IN_PROGRESS); + expect(result.kycSteps.find((s) => s.name === KycStepName.FINANCIAL_DATA)?.isRequired).toBe(true); + }); + + it('returns PendingReview for RealunitBuy when IDENT is still in ManualReview', () => { + setRequiredSteps( + KycStepName.CONTACT_DATA, + KycStepName.PERSONAL_DATA, + KycStepName.NATIONALITY_DATA, + KycStepName.IDENT, + KycStepName.FINANCIAL_DATA, + KycStepName.DFX_APPROVAL, + ); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.PERSONAL_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.NATIONALITY_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.IDENT, ReviewStatus.MANUAL_REVIEW), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, [], undefined, KycContext.REALUNIT_BUY) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.PENDING_REVIEW); + }); + + it('intersects context whitelist with user-specific required steps (RECOMMENDATION only when both agree)', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT, KycStepName.RECOMMENDATION); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.IDENT, ReviewStatus.COMPLETED), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, [], undefined, KycContext.REALUNIT_BUY) as KycLevelDto; + + expect(result.kycSteps.find((s) => s.name === KycStepName.RECOMMENDATION)?.isRequired).toBe(true); + expect(result.processStatus).toBe(KycProcessStatus.IN_PROGRESS); + }); + }); }); diff --git a/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts b/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts index 6f33318797..58e97675d4 100644 --- a/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts +++ b/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts @@ -4,7 +4,14 @@ import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity' import { UserData } from '../../../user/models/user-data/user-data.entity'; import { KycStep } from '../../entities/kyc-step.entity'; import { KycStepName, KycStepNonUserActionable, KycStepRepeatable } from '../../enums/kyc-step-name.enum'; -import { KycStepType, getKycStepIndex, getKycTypeIndex, requiredKycSteps } from '../../enums/kyc.enum'; +import { + KycContext, + KycStepType, + contextRequiredSteps, + getKycStepIndex, + getKycTypeIndex, + requiredKycSteps, +} from '../../enums/kyc.enum'; import { ReviewStatus } from '../../enums/review-status.enum'; import { KycLevelDto, KycProcessStatus, KycSessionDto } from '../output/kyc-info.dto'; import { KycStepMapper } from './kyc-step.mapper'; @@ -15,6 +22,7 @@ export class KycInfoMapper { withSession: boolean, kycClients: Wallet[], currentStep?: KycStep, + context?: KycContext, ): KycLevelDto | KycSessionDto { const kycSteps = KycInfoMapper.getUiSteps(userData); // A non-user-actionable step (e.g. DfxApproval, a DFX-side decision) is @@ -28,7 +36,11 @@ export class KycInfoMapper { const userKycClients = kycClients.filter((kc) => userData.kycClientList.includes(kc.id)); - const requiredStepNames = new Set(requiredKycSteps(userData)); + const allRequiredSteps = requiredKycSteps(userData); + const contextSteps = context ? contextRequiredSteps(context) : undefined; + const requiredStepNames = new Set( + contextSteps ? allRequiredSteps.filter((s) => contextSteps.has(s)) : allRequiredSteps, + ); const dto: KycLevelDto | KycSessionDto = { kycLevel: userData.kycLevelDisplay, diff --git a/src/subdomains/generic/kyc/enums/kyc.enum.ts b/src/subdomains/generic/kyc/enums/kyc.enum.ts index 2be3f3b779..b25d793632 100644 --- a/src/subdomains/generic/kyc/enums/kyc.enum.ts +++ b/src/subdomains/generic/kyc/enums/kyc.enum.ts @@ -106,6 +106,27 @@ export function getIdentificationType(type: IdentType, companyId: string): KycId } } +export enum KycContext { + REALUNIT_BUY = 'RealunitBuy', + REALUNIT_SELL = 'RealunitSell', +} + +export function contextRequiredSteps(context: KycContext): Set | undefined { + switch (context) { + case KycContext.REALUNIT_BUY: + return new Set([ + KycStepName.CONTACT_DATA, + KycStepName.PERSONAL_DATA, + KycStepName.NATIONALITY_DATA, + KycStepName.RECOMMENDATION, + KycStepName.RESIDENCE_PERMIT, + KycStepName.IDENT, + ]); + case KycContext.REALUNIT_SELL: + return undefined; + } +} + export enum UrlType { BROWSER = 'Browser', API = 'API', diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 416440611d..8ff76d2232 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -82,7 +82,7 @@ import { KycStep, KycStepResult } from '../entities/kyc-step.entity'; import { ContentType } from '../enums/content-type.enum'; import { FileCategory } from '../enums/file-category.enum'; import { KycStepCancelable, KycStepIdentRequiredForReview, KycStepName } from '../enums/kyc-step-name.enum'; -import { KycLogType, KycStepType, getIdentificationType, requiredKycSteps } from '../enums/kyc.enum'; +import { KycContext, KycLogType, KycStepType, getIdentificationType, requiredKycSteps } from '../enums/kyc.enum'; import { ReviewStatus } from '../enums/review-status.enum'; import { KycStepRepository } from '../repositories/kyc-step.repository'; import { StepLogRepository } from '../repositories/step-log.repository'; @@ -440,11 +440,11 @@ export class KycService { } } - async getInfo(kycHash: string): Promise { + async getInfo(kycHash: string, context?: KycContext): Promise { const user = await this.getUser(kycHash); await this.verifyUserDuplication(user); - return this.toDto(user, false); + return this.toDto(user, false, undefined, context); } async getFileByUid(uid: string, userDataId?: number, role?: UserRole): Promise { @@ -469,9 +469,9 @@ export class KycService { return KycFileMapper.mapKycFile(kycFile, blob); } - async continue(kycHash: string, ip: string, autoStep: boolean): Promise { + async continue(kycHash: string, ip: string, autoStep: boolean, context?: KycContext): Promise { return Util.retry( - () => this.tryContinue(kycHash, ip, autoStep), + () => this.tryContinue(kycHash, ip, autoStep, context), 2, 0, undefined, @@ -534,7 +534,12 @@ export class KycService { ); } - private async tryContinue(kycHash: string, ip: string, autoStep: boolean): Promise { + private async tryContinue( + kycHash: string, + ip: string, + autoStep: boolean, + context?: KycContext, + ): Promise { let user = await this.getUser(kycHash); if (user.hasRole(UserRole.COMPLIANCE)) throw new BadRequestException('KYC not allowed for compliance accounts'); @@ -545,7 +550,7 @@ export class KycService { await this.verify2faIfRequired(user, ip); - return this.toDto(user, true); + return this.toDto(user, true, undefined, context); } private async verifyUserDuplication(user: UserData) { @@ -1759,10 +1764,11 @@ export class KycService { user: UserData, withSession: boolean, currentStep?: KycStep, + context?: KycContext, ): Promise { const kycClients = await this.walletService.getKycClients(); - return KycInfoMapper.toDto(user, withSession, kycClients, currentStep); + return KycInfoMapper.toDto(user, withSession, kycClients, currentStep, context); } private async getUser(kycHash: string): Promise { diff --git a/src/subdomains/supporting/realunit/exceptions/buy-exceptions.ts b/src/subdomains/supporting/realunit/exceptions/buy-exceptions.ts index fd26dc16e0..c22d5e634e 100644 --- a/src/subdomains/supporting/realunit/exceptions/buy-exceptions.ts +++ b/src/subdomains/supporting/realunit/exceptions/buy-exceptions.ts @@ -1,10 +1,11 @@ import { ForbiddenException } from '@nestjs/common'; export class RegistrationRequiredException extends ForbiddenException { - constructor(message = 'RealUnit registration required') { + constructor(message = 'RealUnit registration required', context?: string) { super({ code: 'REGISTRATION_REQUIRED', message, + ...(context && { context }), }); } } @@ -14,12 +15,14 @@ export class KycLevelRequiredException extends ForbiddenException { public readonly requiredLevel: number, public readonly currentLevel: number, message: string, + context?: string, ) { super({ code: 'KYC_LEVEL_REQUIRED', message, requiredLevel, currentLevel, + ...(context && { context }), }); } } diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 583760e8d5..1fa582aa37 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -43,6 +43,7 @@ import { FaucetRequestService } from 'src/subdomains/core/faucet-request/service import { SellService } from 'src/subdomains/core/sell-crypto/route/sell.service'; import { KycStep } from 'src/subdomains/generic/kyc/entities/kyc-step.entity'; import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum'; +import { KycContext } from 'src/subdomains/generic/kyc/enums/kyc.enum'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; import { AccountMergeService } from 'src/subdomains/generic/user/models/account-merge/account-merge.service'; @@ -413,14 +414,19 @@ export class RealUnitService { // 1. Registration required if (!this.hasRegistrationForWallet(userData, user.address)) { - throw new RegistrationRequiredException(); + throw new RegistrationRequiredException(undefined, KycContext.REALUNIT_BUY); } // 2. KYC Level check - Level 30 required for all RealUnit purchases const currency = await this.fiatService.getFiatByName(currencyName); if (userData.kycLevel < KycLevel.LEVEL_30) { - throw new KycLevelRequiredException(KycLevel.LEVEL_30, userData.kycLevel, 'KYC Level 30 required for RealUnit'); + throw new KycLevelRequiredException( + KycLevel.LEVEL_30, + userData.kycLevel, + 'KYC Level 30 required for RealUnit', + KycContext.REALUNIT_BUY, + ); } // 3. Get or create Buy route for REALU @@ -1116,7 +1122,7 @@ export class RealUnitService { // 1. Registration required if (!this.hasRegistrationForWallet(userData, user.address)) { - throw new RegistrationRequiredException(); + throw new RegistrationRequiredException(undefined, KycContext.REALUNIT_SELL); } // 2. KYC Level check - Level 30 minimum @@ -1125,6 +1131,7 @@ export class RealUnitService { KycLevel.LEVEL_30, userData.kycLevel, 'KYC Level 30 required for RealUnit sell', + KycContext.REALUNIT_SELL, ); } @@ -1163,6 +1170,7 @@ export class RealUnitService { KycLevel.LEVEL_50, userData.kycLevel, 'KYC Level 50 required for RealUnit sell exceeding trading limit', + KycContext.REALUNIT_SELL, ); } From 1969a273a41f69e0ab9f711b40180d33e0e38647 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:37:35 +0200 Subject: [PATCH 3/8] feat: Docker + SSH deploy for DFX server migration (#3810) * feat: add Docker + SSH deploy for DFX server migration * chore: add .dockerignore for Docker build --- .dockerignore | 11 ++++++ .github/workflows/api-dev.yaml | 2 ++ .github/workflows/api-prd.yaml | 2 ++ .github/workflows/dfx-api-dev.yaml | 57 ++++++++++++++++++++++++++++++ .github/workflows/dfx-api-prd.yaml | 54 ++++++++++++++++++++++++++++ Dockerfile | 45 +++++++++++++++++------ 6 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/dfx-api-dev.yaml create mode 100644 .github/workflows/dfx-api-prd.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..c253228ca5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.git +.github +infrastructure +*.log +.env +.env.* +test +coverage +README.md diff --git a/.github/workflows/api-dev.yaml b/.github/workflows/api-dev.yaml index ceba1a5c3a..57b52ad49a 100644 --- a/.github/workflows/api-dev.yaml +++ b/.github/workflows/api-dev.yaml @@ -1,3 +1,5 @@ +# Legacy Azure deploy — will be removed after DFX server migration cutover. +# New deploy pipeline: dfx-api-dev.yaml (Docker + SSH to dfxdev). name: API DEV CI/CD on: diff --git a/.github/workflows/api-prd.yaml b/.github/workflows/api-prd.yaml index ae2f1e2c9a..3b151ee017 100644 --- a/.github/workflows/api-prd.yaml +++ b/.github/workflows/api-prd.yaml @@ -1,3 +1,5 @@ +# Legacy Azure deploy — will be removed after DFX server migration cutover. +# New deploy pipeline: dfx-api-prd.yaml (Docker + SSH to dfxprd). name: API PRD CI/CD on: diff --git a/.github/workflows/dfx-api-dev.yaml b/.github/workflows/dfx-api-dev.yaml new file mode 100644 index 0000000000..dfa16be97b --- /dev/null +++ b/.github/workflows/dfx-api-dev.yaml @@ -0,0 +1,57 @@ +name: DFX API DEV CI/CD + +on: + push: + branches: [develop] + paths-ignore: + - '**.md' + - 'infrastructure/**' + workflow_dispatch: + +env: + DOCKER_TAGS: dfxswiss/dfx-api:beta + +permissions: + contents: read + +jobs: + build-and-deploy: + name: Build and deploy to DEV + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ env.DOCKER_TAGS }} + platforms: linux/arm64 + + - name: Install cloudflared + run: | + curl -fsSL https://github.com/cloudflare/cloudflared/releases/download/2025.4.0/cloudflared-linux-arm64 -o /usr/local/bin/cloudflared + chmod +x /usr/local/bin/cloudflared + + - name: Deploy + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_DEV_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "${{ secrets.DEPLOY_DEV_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + ssh -i ~/.ssh/deploy_key \ + -o ProxyCommand="cloudflared access ssh --hostname ${{ secrets.DEPLOY_DEV_HOST }}" \ + ${{ secrets.DEPLOY_DEV_USER }}@${{ secrets.DEPLOY_DEV_HOST }} \ + "dfx-api" diff --git a/.github/workflows/dfx-api-prd.yaml b/.github/workflows/dfx-api-prd.yaml new file mode 100644 index 0000000000..3a091d9714 --- /dev/null +++ b/.github/workflows/dfx-api-prd.yaml @@ -0,0 +1,54 @@ +name: DFX API PRD CI/CD + +on: + push: + branches: [main] + workflow_dispatch: + +env: + DOCKER_TAGS: dfxswiss/dfx-api:latest + +permissions: + contents: read + +jobs: + build-and-deploy: + name: Build and deploy to PRD + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ env.DOCKER_TAGS }} + platforms: linux/arm64 + + - name: Install cloudflared + run: | + curl -fsSL https://github.com/cloudflare/cloudflared/releases/download/2025.4.0/cloudflared-linux-arm64 -o /usr/local/bin/cloudflared + chmod +x /usr/local/bin/cloudflared + + - name: Deploy + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_PRD_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "${{ secrets.DEPLOY_PRD_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + ssh -i ~/.ssh/deploy_key \ + -o ProxyCommand="cloudflared access ssh --hostname ${{ secrets.DEPLOY_PRD_HOST }}" \ + ${{ secrets.DEPLOY_PRD_USER }}@${{ secrets.DEPLOY_PRD_HOST }} \ + "dfx-api" diff --git a/Dockerfile b/Dockerfile index 264af30470..908e17d2dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,37 @@ -FROM node:latest AS builder -WORKDIR /app -COPY ./package.json ./ -RUN npm install -COPY . . +# Multi-stage build for the dfx-api NestJS service. +# +# Built and pushed by .github/workflows/dfx-api-{dev,prd}.yaml as +# dfxswiss/dfx-api:{beta,latest} (linux/arm64). + +FROM node:20-alpine AS builder + +# node-gyp needs Python + a C/C++ toolchain to build native modules +RUN apk add --no-cache python3 make g++ + +USER node +WORKDIR /home/node + +ADD --chown=node:node package.json . +ADD --chown=node:node package-lock.json . +RUN npm ci + +ADD --chown=node:node . . RUN npm run build +# Drop dev deps in-place after the build so the runtime stage can copy +# the already-compiled native modules without needing python3 + g++. +RUN npm prune --omit=dev + + +FROM node:20-alpine + +USER node +WORKDIR /home/node + +COPY --from=builder /home/node/package.json /home/node/package-lock.json ./ +COPY --from=builder /home/node/node_modules ./node_modules +COPY --from=builder /home/node/dist ./dist +COPY --from=builder /home/node/migration ./migration + +EXPOSE 3000 -FROM node:latest -WORKDIR /app -COPY --from=builder /app ./ -EXPOSE 3000:3000 -CMD [ "npm", "run", "start" ] \ No newline at end of file +CMD ["node", "dist/main.js"] From cdaf356e0c17da8fad2abfbeb0338c6708914837 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 3 Jun 2026 15:05:42 +0200 Subject: [PATCH 4/8] fix(account-merge): dedup ACCOUNT_MERGE_REQUEST mail per merge event (#3804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(account-merge): dedup merge-request mail per merge event Multiple code paths (ident verify + re-check, IBAN conflict) request the same logical merge. Each minted a new AccountMerge row with a fresh correlationId, so the 60s mail-layer debounce never matched and the user received several identical merge-request mails. Dedup at the sendMergeRequest entry: if an open (!isCompleted, !isExpired) merge for the {master, slave} pair already exists, return early and reuse it instead of sending another mail. Closes #3799 * test(account-merge): cover dedup branches + log reused merges Address review follow-ups on #3799: - Log a merge-reuse entry on the dedup early-return so support keeps the audit trail of which trigger reasons hit an already-open merge. - Add tests for the not-mergeable early-return, the isCompleted filter, and the sendToSlave receiver. * fix(account-merge): scope dedup reuse to a recency window Per review: an open merge request lives until expiration (30d for IDENT/IBAN), so the dedup would also swallow a deliberate re-initiation days later — the user would never get a fresh mail. Bound the reuse to requests touched within the last few minutes so only the near-simultaneous trigger burst is deduped. --------- Co-authored-by: Joshua Krüger --- .../__tests__/account-merge.service.spec.ts | 114 ++++++++++++++++++ .../account-merge/account-merge.service.ts | 43 +++++-- 2 files changed, 146 insertions(+), 11 deletions(-) create mode 100644 src/subdomains/generic/user/models/account-merge/__tests__/account-merge.service.spec.ts diff --git a/src/subdomains/generic/user/models/account-merge/__tests__/account-merge.service.spec.ts b/src/subdomains/generic/user/models/account-merge/__tests__/account-merge.service.spec.ts new file mode 100644 index 0000000000..95918133c4 --- /dev/null +++ b/src/subdomains/generic/user/models/account-merge/__tests__/account-merge.service.spec.ts @@ -0,0 +1,114 @@ +import { ConfigService } from 'src/config/config'; +import { KycLogService } from 'src/subdomains/generic/kyc/services/kyc-log.service'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { UserData } from '../../user-data/user-data.entity'; +import { UserDataService } from '../../user-data/user-data.service'; +import { AccountMerge, MergeReason } from '../account-merge.entity'; +import { AccountMergeRepository } from '../account-merge.repository'; +import { AccountMergeService } from '../account-merge.service'; + +describe('AccountMergeService', () => { + let service: AccountMergeService; + + let accountMergeRepo: jest.Mocked>; + let notificationService: jest.Mocked>; + let kycLogService: jest.Mocked>; + + const buildUserData = (id: number, mail?: string): UserData => { + const userData = Object.assign(new UserData(), { id, mail, firstname: `user${id}` }); + jest.spyOn(userData, 'isMergePossibleWith').mockReturnValue(true); + return userData; + }; + + beforeAll(() => { + new ConfigService(); + }); + + beforeEach(() => { + accountMergeRepo = { findOneBy: jest.fn(), save: jest.fn() }; + notificationService = { sendMail: jest.fn() }; + kycLogService = { createMergeLog: jest.fn() }; + + service = new AccountMergeService( + accountMergeRepo as unknown as AccountMergeRepository, + notificationService as unknown as NotificationService, + kycLogService as unknown as KycLogService, + {} as unknown as UserDataService, + ); + }); + + describe('sendMergeRequest', () => { + it('creates a request and sends exactly one mail when no open merge exists', async () => { + const master = buildUserData(1, 'master@test.com'); + const slave = buildUserData(2, 'slave@test.com'); + accountMergeRepo.findOneBy.mockResolvedValue(null); + accountMergeRepo.save.mockResolvedValue(Object.assign(new AccountMerge(), { id: 10, code: 'code-10' })); + + const result = await service.sendMergeRequest(master, slave, MergeReason.IDENT_DOCUMENT); + + expect(result).toBe(true); + expect(accountMergeRepo.save).toHaveBeenCalledTimes(1); + expect(notificationService.sendMail).toHaveBeenCalledTimes(1); + }); + + it('reuses an open merge and sends no further mail (dedup across triggers), but keeps the audit log', async () => { + const master = buildUserData(1, 'master@test.com'); + const slave = buildUserData(2, 'slave@test.com'); + accountMergeRepo.findOneBy.mockResolvedValue( + Object.assign(new AccountMerge(), { id: 10, code: 'code-10', isCompleted: false }), + ); + + const result = await service.sendMergeRequest(master, slave, MergeReason.IBAN); + + expect(result).toBe(true); + expect(accountMergeRepo.save).not.toHaveBeenCalled(); + expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(kycLogService.createMergeLog).toHaveBeenCalledTimes(2); + // the open-merge lookup only matches non-completed requests + expect(accountMergeRepo.findOneBy).toHaveBeenCalledWith(expect.objectContaining({ isCompleted: false })); + }); + + it('scopes the dedup lookup to recently-touched open requests (re-trigger after the window gets a fresh mail)', async () => { + const master = buildUserData(1, 'master@test.com'); + const slave = buildUserData(2, 'slave@test.com'); + // a long-open request that the user re-triggers days later is outside the recency window, so the + // DB lookup (filtered on `updated`) returns nothing and a fresh request + mail is minted. + accountMergeRepo.findOneBy.mockResolvedValue(null); + accountMergeRepo.save.mockResolvedValue(Object.assign(new AccountMerge(), { id: 12, code: 'code-12' })); + + const result = await service.sendMergeRequest(master, slave, MergeReason.IDENT_DOCUMENT); + + expect(result).toBe(true); + // the dedup lookup carries the recency clause, not just isCompleted/expiration + expect(accountMergeRepo.findOneBy).toHaveBeenCalledWith(expect.objectContaining({ updated: expect.anything() })); + expect(accountMergeRepo.save).toHaveBeenCalledTimes(1); + expect(notificationService.sendMail).toHaveBeenCalledTimes(1); + }); + + it('returns false without touching the repo when the merge is not possible', async () => { + const master = buildUserData(1, 'master@test.com'); + const slave = buildUserData(2, 'slave@test.com'); + jest.spyOn(master, 'isMergePossibleWith').mockReturnValue(false); + + const result = await service.sendMergeRequest(master, slave, MergeReason.IDENT_DOCUMENT); + + expect(result).toBe(false); + expect(accountMergeRepo.findOneBy).not.toHaveBeenCalled(); + expect(accountMergeRepo.save).not.toHaveBeenCalled(); + expect(notificationService.sendMail).not.toHaveBeenCalled(); + }); + + it('sends the mail to the slave when sendToSlave is set', async () => { + const master = buildUserData(1, 'master@test.com'); + const slave = buildUserData(2, 'slave@test.com'); + accountMergeRepo.findOneBy.mockResolvedValue(null); + accountMergeRepo.save.mockResolvedValue(Object.assign(new AccountMerge(), { id: 11, code: 'code-11' })); + + await service.sendMergeRequest(master, slave, MergeReason.IDENT_DOCUMENT, true); + + expect(notificationService.sendMail).toHaveBeenCalledTimes(1); + const [mail] = notificationService.sendMail.mock.calls[0]; + expect((mail.input as { userData: UserData }).userData).toBe(slave); + }); + }); +}); diff --git a/src/subdomains/generic/user/models/account-merge/account-merge.service.ts b/src/subdomains/generic/user/models/account-merge/account-merge.service.ts index 51bfc2076e..6fe6280809 100644 --- a/src/subdomains/generic/user/models/account-merge/account-merge.service.ts +++ b/src/subdomains/generic/user/models/account-merge/account-merge.service.ts @@ -7,6 +7,7 @@ import { NotFoundException, } from '@nestjs/common'; import { Config } from 'src/config/config'; +import { Util } from 'src/shared/utils/util'; import { KycLogService } from 'src/subdomains/generic/kyc/services/kyc-log.service'; import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; import { MailKey, MailTranslationKey } from 'src/subdomains/supporting/notification/factories/mail.factory'; @@ -19,6 +20,10 @@ import { AccountMergeRepository } from './account-merge.repository'; @Injectable() export class AccountMergeService { + // Only an open merge request touched within this window is reused; older open requests are left + // alone so a deliberate re-initiation gets a fresh mail instead of being silently swallowed. + private static readonly mergeRequestDedupWindowMinutes = 5; + constructor( private readonly accountMergeRepo: AccountMergeRepository, private readonly notificationService: NotificationService, @@ -44,17 +49,33 @@ export class AccountMergeService { ): Promise { if (!master.isMergePossibleWith(slave)) return false; - const request = - (await this.accountMergeRepo.findOne({ - where: { - master: { id: master.id }, - slave: { id: slave.id }, - expiration: MoreThan(new Date()), - }, - relations: { master: true, slave: true }, - })) ?? (await this.accountMergeRepo.save(AccountMerge.create(master, slave, reason))); - - const [receiver, mentioned] = sendToSlave ? [request.slave, request.master] : [request.master, request.slave]; + // Dedup at the entry: several code paths request the same logical merge (ident verification + + // re-check, IBAN conflict, mail change). If an open merge for this pair already exists, the + // original mail already pointed the user to the confirmation URL — reuse it instead of minting + // a new row (which would get a fresh correlationId and slip past the mail-layer debounce). + // + // The reuse window is bounded to recent requests only (`updated` within the last few minutes): + // an open request lives until its expiration (30d for IDENT/IBAN), but dedup should suppress the + // near-simultaneous trigger burst — not a user who deliberately re-initiates the flow hours or + // days later and legitimately expects a fresh mail. + const openRequest = await this.accountMergeRepo.findOneBy({ + master: { id: master.id }, + slave: { id: slave.id }, + isCompleted: false, + expiration: MoreThan(new Date()), + updated: MoreThan(Util.minutesBefore(AccountMergeService.mergeRequestDedupWindowMinutes)), + }); + if (openRequest) { + // keep the audit trail of which trigger reasons hit an already-open merge + const reuseMessage = `Merge request ${openRequest.id} reused (reason ${reason}): master ${master.id}, slave ${slave.id}`; + await this.kycLogService.createMergeLog(master, reuseMessage); + await this.kycLogService.createMergeLog(slave, reuseMessage); + return true; + } + + const request = await this.accountMergeRepo.save(AccountMerge.create(master, slave, reason)); + + const [receiver, mentioned] = sendToSlave ? [slave, master] : [master, slave]; if (!receiver.mail) return false; const name = mentioned.organizationName ?? mentioned.firstname ?? receiver.organizationName ?? receiver.firstname; From 6a1473d71208b0d7b82b8f04cc814e0b1025e13a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:06:30 +0200 Subject: [PATCH 5/8] test(kyc): harden DfxApproval mapper guards (FAILED/ManualReview/terminated locks) (#3811) Lock the DfxApproval mapper invariants with regression tests, no production behaviour change: - KYC-terminated user + FAILED DfxApproval -> processStatus = Failed (terminated wins). - non-terminated + FAILED DfxApproval -> PendingReview (never user-actionable IN_PROGRESS). - ManualReview / OnHold DfxApproval -> PendingReview (unchanged behaviour, now guarded). - explicitly-passed non-user-actionable currentStep is dropped (no blank-screen leak). Drops the logger.warn on the currentStep guard (per review: the guard working as designed is not a bug worth logging on every DfxApproval status hit). --- .../mapper/__tests__/kyc-info.mapper.spec.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts b/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts index d6e98ed7a1..452d275ea4 100644 --- a/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts +++ b/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts @@ -244,6 +244,49 @@ describe('KycInfoMapper', () => { expect(result.processStatus).toBe(KycProcessStatus.PENDING_REVIEW); }); + + it('keeps returning PendingReview for a ManualReview DfxApproval (unchanged behaviour)', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.DFX_APPROVAL); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.DFX_APPROVAL, ReviewStatus.MANUAL_REVIEW), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.PENDING_REVIEW); + }); + + it('returns Failed for a KYC-terminated user even when DfxApproval is Failed', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.DFX_APPROVAL); + const userData = buildUserData({ + kycLevel: KycLevel.REJECTED, + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.DFX_APPROVAL, ReviewStatus.FAILED), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.FAILED); + }); + + it('returns PendingReview when DfxApproval is Failed but the user is not KYC-terminated', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.DFX_APPROVAL); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.DFX_APPROVAL, ReviewStatus.FAILED), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.PENDING_REVIEW); + }); }); describe('isRequired flag', () => { From 4a1e16f1f8d64d1a29e14056569252d1d86f2a75 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:18:06 +0200 Subject: [PATCH 6/8] ci: disable premature DFX server deploy workflows until migration (#3815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dfx-api-dev/prd workflows SSH-deploy to dfxdev/dfxprd, but the migration is not live yet — the API still runs on Azure. Their push triggers made every develop/main merge fail at the SSH deploy step (server-side prerequisites not in place). Restrict both to workflow_dispatch until the cutover. --- .github/workflows/dfx-api-dev.yaml | 10 +++++----- .github/workflows/dfx-api-prd.yaml | 7 +++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/dfx-api-dev.yaml b/.github/workflows/dfx-api-dev.yaml index dfa16be97b..422f4878cb 100644 --- a/.github/workflows/dfx-api-dev.yaml +++ b/.github/workflows/dfx-api-dev.yaml @@ -1,11 +1,11 @@ name: DFX API DEV CI/CD on: - push: - branches: [develop] - paths-ignore: - - '**.md' - - 'infrastructure/**' + # Auto-deploy is disabled until the DFX server migration cutover. + # The API still runs on Azure (see api-dev.yaml). Server-side prerequisites + # (deploy.sh dfx-api case, authorized_keys, compose) are not yet in place, + # so every develop push failed at the SSH deploy step. Re-add the push + # trigger when the migration goes live. workflow_dispatch: env: diff --git a/.github/workflows/dfx-api-prd.yaml b/.github/workflows/dfx-api-prd.yaml index 3a091d9714..9b2b4f7fc0 100644 --- a/.github/workflows/dfx-api-prd.yaml +++ b/.github/workflows/dfx-api-prd.yaml @@ -1,8 +1,11 @@ name: DFX API PRD CI/CD on: - push: - branches: [main] + # Auto-deploy is disabled until the DFX server migration cutover. + # The API still runs on Azure (see api-prd.yaml). Server-side prerequisites + # (deploy.sh dfx-api case, authorized_keys, compose) are not yet in place, + # so every main push failed at the SSH deploy step. Re-add the push + # trigger when the migration goes live. workflow_dispatch: env: From d5d02c140dacb38230a529f49742ab281e93950f Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 3 Jun 2026 15:27:04 +0200 Subject: [PATCH 7/8] feat(account-merge): surface MergeProcessing state on the KYC status endpoint (#3807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(account-merge): surface MergeProcessing state on KYC status endpoint After a user confirms a merge the backend keeps processing (re-parenting, KYC follow-up, level transition, mail), but the status endpoint could not distinguish 'merge in progress' from 'idle', so the app's polling timeout surfaced as 'Fehler beim Laden'. Add a nullable processingStartedAt marker to AccountMerge set when executeMerge begins; expose it as a new KycProcessStatus.MergeProcessing value (additive — old clients treat it as a no-op) computed by KycInfoMapper from a single accountMergeRepo lookup. Closes #3802 * fix(account-merge): clear processing marker when merge fails If mergeUserData threw, processingStartedAt stayed set with isCompleted=false, so isProcessing returned true until expiration (up to 30 days) and the status endpoint was stuck on MergeProcessing. Reset the marker on failure via stopProcessing() and rethrow. * fix(account-merge): bound processing state to a timeout so a crash can't strand the client isProcessing now also requires processingStartedAt within MERGE_PROCESSING_TIMEOUT_MINUTES (10 min), and hasProcessingMerge mirrors it. A merge runs in seconds; if a pod crashes between startProcessing and complete/stopProcessing, the marker self-heals instead of pinning the status endpoint on a waiting state until the merge request expires. Addresses review follow-ups on #3802 (stale-marker cleanup / reentrancy). --------- Co-authored-by: Joshua Krüger --- ...2017-AddAccountMergeProcessingStartedAt.js | 26 ++++++ .../mapper/__tests__/kyc-info.mapper.spec.ts | 18 ++++ .../generic/kyc/dto/mapper/kyc-info.mapper.ts | 5 +- .../generic/kyc/dto/output/kyc-info.dto.ts | 3 +- .../generic/kyc/services/kyc.service.ts | 3 +- .../account-merge.processing.spec.ts | 91 +++++++++++++++++++ .../account-merge/account-merge.entity.ts | 34 +++++++ .../account-merge/account-merge.service.ts | 27 +++++- 8 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 migration/1780349782017-AddAccountMergeProcessingStartedAt.js create mode 100644 src/subdomains/generic/user/models/account-merge/__tests__/account-merge.processing.spec.ts diff --git a/migration/1780349782017-AddAccountMergeProcessingStartedAt.js b/migration/1780349782017-AddAccountMergeProcessingStartedAt.js new file mode 100644 index 0000000000..5f29d89992 --- /dev/null +++ b/migration/1780349782017-AddAccountMergeProcessingStartedAt.js @@ -0,0 +1,26 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddAccountMergeProcessingStartedAt1780349782017 { + name = 'AddAccountMergeProcessingStartedAt1780349782017' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "account_merge" ADD "processingStartedAt" TIMESTAMP`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "account_merge" DROP COLUMN "processingStartedAt"`); + } +} diff --git a/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts b/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts index 452d275ea4..a535850181 100644 --- a/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts +++ b/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts @@ -60,6 +60,24 @@ describe('KycInfoMapper', () => { expect(result.processStatus).toBe(KycProcessStatus.FAILED); }); + it('returns MergeProcessing when a merge is in progress, overriding actionable steps', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT); + const userData = buildUserData({ kycSteps: [] }); + + const result = KycInfoMapper.toDto(userData, false, [], undefined, undefined, true) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.MERGE_PROCESSING); + }); + + it('does not return MergeProcessing for a KYC-terminated user (Failed wins)', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT); + const userData = buildUserData({ kycLevel: KycLevel.REJECTED }); + + const result = KycInfoMapper.toDto(userData, false, [], undefined, undefined, true) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.FAILED); + }); + it('returns InProgress when there are no real steps and required steps exist (synthetic NotStarted steps)', () => { setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT); const userData = buildUserData({ kycSteps: [] }); diff --git a/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts b/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts index 58e97675d4..34e6337517 100644 --- a/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts +++ b/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts @@ -23,6 +23,7 @@ export class KycInfoMapper { kycClients: Wallet[], currentStep?: KycStep, context?: KycContext, + isMergeProcessing = false, ): KycLevelDto | KycSessionDto { const kycSteps = KycInfoMapper.getUiSteps(userData); // A non-user-actionable step (e.g. DfxApproval, a DFX-side decision) is @@ -48,7 +49,7 @@ export class KycInfoMapper { kycClients: userKycClients.map((kc) => kc.name), language: LanguageDtoMapper.entityToDto(userData.language), kycSteps: kycSteps.map((s) => KycStepMapper.toStep(s, currentStep, requiredStepNames)), - processStatus: KycInfoMapper.computeProcessStatus(userData, kycSteps, requiredStepNames), + processStatus: KycInfoMapper.computeProcessStatus(userData, kycSteps, requiredStepNames, isMergeProcessing), currentStep: withSession && currentStep ? KycStepMapper.toStepSession(currentStep) : undefined, }; @@ -63,8 +64,10 @@ export class KycInfoMapper { userData: UserData, kycSteps: KycStep[], requiredStepNames: Set, + isMergeProcessing: boolean, ): KycProcessStatus { if (userData.isKycTerminated) return KycProcessStatus.FAILED; + if (isMergeProcessing) return KycProcessStatus.MERGE_PROCESSING; const requiredSteps = kycSteps.filter((s) => requiredStepNames.has(s.name)); diff --git a/src/subdomains/generic/kyc/dto/output/kyc-info.dto.ts b/src/subdomains/generic/kyc/dto/output/kyc-info.dto.ts index 49e9ab39b1..593d87f83c 100644 --- a/src/subdomains/generic/kyc/dto/output/kyc-info.dto.ts +++ b/src/subdomains/generic/kyc/dto/output/kyc-info.dto.ts @@ -26,6 +26,7 @@ export enum KycProcessStatus { PENDING_REVIEW = 'PendingReview', COMPLETED = 'Completed', FAILED = 'Failed', + MERGE_PROCESSING = 'MergeProcessing', } // step @@ -100,7 +101,7 @@ export class KycLevelDto { @ApiProperty({ enum: KycProcessStatus, description: - 'High-level KYC process status. `Completed` ⇒ all required steps completed; `PendingReview` ⇒ at least one required step is in backend review; `InProgress` ⇒ at least one required step is actionable by the user; `Failed` ⇒ KYC terminated. Clients render this verbatim instead of inferring it from `kycSteps`.', + 'High-level KYC process status. `MergeProcessing` ⇒ a user-confirmed account merge is still being processed in the backend (render a waiting state, do not interpret a polling timeout as failure); `Completed` ⇒ all required steps completed; `PendingReview` ⇒ at least one required step is in backend review; `InProgress` ⇒ at least one required step is actionable by the user; `Failed` ⇒ KYC terminated. Clients render this verbatim instead of inferring it from `kycSteps`.', }) processStatus: KycProcessStatus; } diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 8ff76d2232..086b80fd0c 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -1767,8 +1767,9 @@ export class KycService { context?: KycContext, ): Promise { const kycClients = await this.walletService.getKycClients(); + const isMergeProcessing = await this.accountMergeService.hasProcessingMerge(user.id); - return KycInfoMapper.toDto(user, withSession, kycClients, currentStep, context); + return KycInfoMapper.toDto(user, withSession, kycClients, currentStep, context, isMergeProcessing); } private async getUser(kycHash: string): Promise { diff --git a/src/subdomains/generic/user/models/account-merge/__tests__/account-merge.processing.spec.ts b/src/subdomains/generic/user/models/account-merge/__tests__/account-merge.processing.spec.ts new file mode 100644 index 0000000000..e5cd420f8b --- /dev/null +++ b/src/subdomains/generic/user/models/account-merge/__tests__/account-merge.processing.spec.ts @@ -0,0 +1,91 @@ +import { ConfigService } from 'src/config/config'; +import { KycLogService } from 'src/subdomains/generic/kyc/services/kyc-log.service'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { Util } from 'src/shared/utils/util'; +import { UserData } from '../../user-data/user-data.entity'; +import { UserDataService } from '../../user-data/user-data.service'; +import { AccountMerge } from '../account-merge.entity'; +import { AccountMergeRepository } from '../account-merge.repository'; +import { AccountMergeService } from '../account-merge.service'; + +describe('AccountMerge processing state', () => { + beforeAll(() => { + new ConfigService(); + }); + + describe('isProcessing getter', () => { + const build = (overrides: Partial): AccountMerge => + Object.assign(new AccountMerge(), { isCompleted: false, expiration: Util.daysAfter(1) }, overrides); + + it('is false before processing starts', () => { + expect(build({ processingStartedAt: undefined }).isProcessing).toBe(false); + }); + + it('is true once processing started and the merge is neither completed nor expired', () => { + expect(build({ processingStartedAt: new Date() }).isProcessing).toBe(true); + }); + + it('is false once the merge is completed', () => { + expect(build({ processingStartedAt: new Date(), isCompleted: true }).isProcessing).toBe(false); + }); + + it('is false once the merge is expired', () => { + expect(build({ processingStartedAt: new Date(), expiration: Util.daysBefore(1) }).isProcessing).toBe(false); + }); + + it('is false once the processing marker is stale (crash-orphaned beyond the timeout)', () => { + expect(build({ processingStartedAt: Util.minutesBefore(30) }).isProcessing).toBe(false); + }); + }); + + describe('hasProcessingMerge', () => { + it('queries open, processing, non-expired merges for the userData as master or slave', async () => { + const existsBy = jest.fn().mockResolvedValue(true); + const service = new AccountMergeService( + { existsBy } as unknown as AccountMergeRepository, + {} as unknown as NotificationService, + {} as unknown as KycLogService, + {} as unknown as UserDataService, + ); + + const result = await service.hasProcessingMerge(7); + + expect(result).toBe(true); + const [where] = existsBy.mock.calls[0]; + expect(where).toHaveLength(2); + expect(where[0]).toMatchObject({ isCompleted: false, master: { id: 7 } }); + expect(where[1]).toMatchObject({ isCompleted: false, slave: { id: 7 } }); + }); + }); + + describe('executeMerge cleanup on failure', () => { + it('clears processingStartedAt when mergeUserData throws, so the client is not stuck processing', async () => { + const master = Object.assign(new UserData(), { id: 1, kycLevel: 20, mail: 'm@test.com' }); + const slave = Object.assign(new UserData(), { id: 2, kycLevel: 10, mail: 's@test.com' }); + const request = Object.assign(new AccountMerge(), { + id: 5, + code: 'code-5', + master, + slave, + isCompleted: false, + expiration: Util.daysAfter(1), + }); + + const update = jest.fn(); + const service = new AccountMergeService( + { findOne: jest.fn().mockResolvedValue(request), update } as unknown as AccountMergeRepository, + {} as unknown as NotificationService, + { createMergeLog: jest.fn() } as unknown as KycLogService, + { mergeUserData: jest.fn().mockRejectedValue(new Error('merge failed')) } as unknown as UserDataService, + ); + + await expect(service.executeMerge('code-5')).rejects.toThrow('merge failed'); + + // last persisted update must be the cleanup (processingStartedAt = null), and complete() never ran + const updates = update.mock.calls.map((c) => c[1]); + expect(updates).toContainEqual({ processingStartedAt: null }); + expect(updates).not.toContainEqual(expect.objectContaining({ isCompleted: true })); + expect(request.isProcessing).toBe(false); + }); + }); +}); diff --git a/src/subdomains/generic/user/models/account-merge/account-merge.entity.ts b/src/subdomains/generic/user/models/account-merge/account-merge.entity.ts index 7c2bea2040..c487125759 100644 --- a/src/subdomains/generic/user/models/account-merge/account-merge.entity.ts +++ b/src/subdomains/generic/user/models/account-merge/account-merge.entity.ts @@ -10,6 +10,11 @@ export enum MergeReason { IBAN = 'Iban', } +// Upper bound on how long a confirmed merge is considered "processing". A merge runs in seconds; +// bounding it means a marker left behind by a pod crash between start and complete self-heals +// instead of pinning the client on a waiting state until the merge request expires (days). +export const MERGE_PROCESSING_TIMEOUT_MINUTES = 10; + @Entity() export class AccountMerge extends IEntity { @Index() @@ -30,6 +35,9 @@ export class AccountMerge extends IEntity { @Column({ type: 'timestamp' }) expiration: Date; + @Column({ type: 'timestamp', nullable: true }) + processingStartedAt?: Date; + @Column({ length: 256, nullable: true }) reason?: MergeReason; @@ -47,6 +55,22 @@ export class AccountMerge extends IEntity { return entity; } + startProcessing(): UpdateResult { + const update: Partial = { processingStartedAt: new Date() }; + + Object.assign(this, update); + + return [this.id, update]; + } + + stopProcessing(): UpdateResult { + const update: Partial = { processingStartedAt: null }; + + Object.assign(this, update); + + return [this.id, update]; + } + complete(master: UserData, slave: UserData): UpdateResult { const update: Partial = { isCompleted: true, @@ -62,4 +86,14 @@ export class AccountMerge extends IEntity { get isExpired(): boolean { return this.expiration < new Date(); } + + // user has confirmed the merge and the backend is still processing it (re-parenting, KYC follow-up) + get isProcessing(): boolean { + return ( + !this.isCompleted && + this.processingStartedAt != null && + this.processingStartedAt > Util.minutesBefore(MERGE_PROCESSING_TIMEOUT_MINUTES) && + !this.isExpired + ); + } } diff --git a/src/subdomains/generic/user/models/account-merge/account-merge.service.ts b/src/subdomains/generic/user/models/account-merge/account-merge.service.ts index 6fe6280809..564dee8db4 100644 --- a/src/subdomains/generic/user/models/account-merge/account-merge.service.ts +++ b/src/subdomains/generic/user/models/account-merge/account-merge.service.ts @@ -15,7 +15,7 @@ import { NotificationService } from 'src/subdomains/supporting/notification/serv import { MoreThan } from 'typeorm'; import { UserData } from '../user-data/user-data.entity'; import { UserDataService } from '../user-data/user-data.service'; -import { AccountMerge, MergeReason } from './account-merge.entity'; +import { AccountMerge, MERGE_PROCESSING_TIMEOUT_MINUTES, MergeReason } from './account-merge.entity'; import { AccountMergeRepository } from './account-merge.repository'; @Injectable() @@ -128,13 +128,36 @@ export class AccountMergeService { await this.kycLogService.createMergeLog(master, logMessage); await this.kycLogService.createMergeLog(slave, logMessage); - await this.userDataService.mergeUserData(master.id, slave.id, request.slave.mail); + // mark the merge as processing so the status endpoint can surface a waiting state to the client + await this.accountMergeRepo.update(...request.startProcessing()); + + try { + await this.userDataService.mergeUserData(master.id, slave.id, request.slave.mail); + } catch (e) { + // clear the processing marker so a failed merge does not leave the client stuck on a + // never-ending waiting state (isProcessing would otherwise stay true until expiration) + await this.accountMergeRepo.update(...request.stopProcessing()); + throw e; + } await this.accountMergeRepo.update(...request.complete(master, slave)); return request; } + async hasProcessingMerge(userDataId: number): Promise { + // mirrors AccountMerge.isProcessing: open, recently started (so a crash-orphaned marker self-heals), not expired + const where = { + isCompleted: false, + processingStartedAt: MoreThan(Util.minutesBefore(MERGE_PROCESSING_TIMEOUT_MINUTES)), + expiration: MoreThan(new Date()), + }; + return this.accountMergeRepo.existsBy([ + { ...where, master: { id: userDataId } }, + { ...where, slave: { id: userDataId } }, + ]); + } + async pendingMergeRequest(userDataId: number, referenceUserDataId: number): Promise { return this.accountMergeRepo.findOneBy([ { master: { id: userDataId }, slave: { id: referenceUserDataId }, isCompleted: false }, From 1f573f1c5f8d243f69daa065742178de348204c0 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 3 Jun 2026 16:28:55 +0200 Subject: [PATCH 8/8] fix(account-merge): stop stale-collection save from orphaning re-parented KYC steps (#3805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(account-merge): re-parent slave KYC steps to master at DB level mergeUserData only concatenated the slave's kycSteps onto the master in-memory. The slave rows kept userData_id = slave.id, so on the next reload of the master they vanished and already-completed steps (e.g. FINANCIAL_DATA) were re-flagged as missing. Add KycAdminService.reassignKycSteps and call it after the slave-step update loop so the FK is persisted. Closes #3800 * fix(account-merge): backfill historical merged KYC steps + log reassignment Address review on #3800: - Add a data migration that re-parents KYC steps still pointing at merged-away (slave) accounts onto their surviving master, using the completed account_merge rows as the slave->master mapping (user_data has no master FK). Looped to resolve chained merges; idempotent. - Log the affected step count in reassignKycSteps for audit trails. * fix(account-merge): fix stale-collection save that orphans re-parented KYC steps Address David May's review: drop the redundant reassignKycSteps and fix the real root cause. updateUserData loads kycSteps+users and `save(userData)` makes TypeORM's OneToManySubjectBuilder reconcile those collections — so a master loaded with a pre-merge snapshot nulls out the slave steps a merge just re-parented (2083 orphaned kyc_step rows in prod). Persist scalars/FKs only by excluding the loaded OneToMany collections from the saved object; the returned entity keeps them for the HTTP response. - remove KycAdminService.reassignKycSteps (the concat + save(master) in mergeUserData already persists the re-parent) + its call + its spec - keep the backfill migration (pairs with the fix so no new orphans appear) - add regression test: updateUserData's save() excludes kycSteps/users * chore: remove backfill migration (executed manually) Backfill of 1510 orphaned kyc_step rows was run manually against prod. 573 remain from pre-account_merge era (Nov 2023) with no resolvable master — harmless on dead Merged accounts. --------- Co-authored-by: Joshua Krüger Co-authored-by: David May --- .../__tests__/user-data.service.spec.ts | 90 +++++++++++++++++++ .../models/user-data/user-data.service.ts | 9 +- 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts diff --git a/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts new file mode 100644 index 0000000000..ad7edb0767 --- /dev/null +++ b/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts @@ -0,0 +1,90 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { RepositoryFactory } from 'src/shared/repositories/repository.factory'; +import { CountryService } from 'src/shared/models/country/country.service'; +import { FiatService } from 'src/shared/models/fiat/fiat.service'; +import { LanguageService } from 'src/shared/models/language/language.service'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { KycAdminService } from 'src/subdomains/generic/kyc/services/kyc-admin.service'; +import { KycLogService } from 'src/subdomains/generic/kyc/services/kyc-log.service'; +import { KycNotificationService } from 'src/subdomains/generic/kyc/services/kyc-notification.service'; +import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; +import { AccountMergeService } from 'src/subdomains/generic/user/models/account-merge/account-merge.service'; +import { SpecialExternalAccountService } from 'src/subdomains/supporting/payment/services/special-external-account.service'; +import { UserDataNotificationService } from 'src/subdomains/generic/user/models/user-data/user-data-notification.service'; +import { WebhookService } from 'src/subdomains/generic/user/services/webhook/webhook.service'; +import { IpLogService } from 'src/shared/models/ip-log/ip-log.service'; +import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { KycDocumentService } from 'src/subdomains/generic/kyc/services/integration/kyc-document.service'; +import { SiftService } from 'src/integration/sift/services/sift.service'; +import { OrganizationService } from 'src/subdomains/generic/user/models/organization/organization.service'; +import { TfaService } from 'src/subdomains/generic/kyc/services/tfa.service'; +import { CustodyService } from 'src/subdomains/core/custody/services/custody.service'; +import { UserData } from '../user-data.entity'; +import { UserDataRepository } from '../user-data.repository'; +import { UserDataService } from '../user-data.service'; +import { UserRepository } from '../../user/user.repository'; + +describe('UserDataService', () => { + let service: UserDataService; + let userDataRepo: jest.Mocked; + + beforeEach(async () => { + userDataRepo = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserDataService, + { provide: RepositoryFactory, useValue: createMock() }, + { provide: UserDataRepository, useValue: userDataRepo }, + { provide: UserRepository, useValue: createMock() }, + { provide: CountryService, useValue: createMock() }, + { provide: LanguageService, useValue: createMock() }, + { provide: FiatService, useValue: createMock() }, + { provide: SettingService, useValue: createMock() }, + { provide: KycNotificationService, useValue: createMock() }, + { provide: KycLogService, useValue: createMock() }, + { provide: UserDataNotificationService, useValue: createMock() }, + { provide: AccountMergeService, useValue: createMock() }, + { provide: SpecialExternalAccountService, useValue: createMock() }, + { provide: SiftService, useValue: createMock() }, + { provide: WebhookService, useValue: createMock() }, + { provide: KycDocumentService, useValue: createMock() }, + { provide: KycAdminService, useValue: createMock() }, + { provide: OrganizationService, useValue: createMock() }, + { provide: TfaService, useValue: createMock() }, + { provide: TransactionService, useValue: createMock() }, + { provide: BankDataService, useValue: createMock() }, + { provide: KycService, useValue: createMock() }, + { provide: IpLogService, useValue: createMock() }, + { provide: CustodyService, useValue: createMock() }, + ], + }).compile(); + + service = module.get(UserDataService); + }); + + describe('updateUserData', () => { + it('does not pass kycSteps or users to save() to prevent stale-collection FK clobber', async () => { + const fakeKycSteps = [{ id: 10 }] as UserData['kycSteps']; + const fakeUsers = [{ id: 20 }] as UserData['users']; + const fakeUserData = Object.assign(new UserData(), { + id: 1, + kycSteps: fakeKycSteps, + users: fakeUsers, + kycLevel: 0, + }); + + userDataRepo.findOne.mockResolvedValue(fakeUserData); + userDataRepo.save.mockImplementation(async (e) => e as UserData); + + await service.updateUserData(1, {}); + + const savedArg = userDataRepo.save.mock.calls[0][0] as Partial; + expect(savedArg.kycSteps).toBeUndefined(); + expect(savedArg.users).toBeUndefined(); + expect(savedArg.id).toBe(1); + }); + }); +}); diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index e5226431cb..8035b831ee 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -383,7 +383,14 @@ export class UserDataService { } } - await this.userDataRepo.save(userData); + // Persist scalars/FKs only. Excluding the loaded OneToMany collections (kycSteps, users) stops a + // stale snapshot from orphaning rows a concurrent account merge just re-parented onto this master + // (root cause of #3800; 2083 orphaned kyc_step rows in prod). save() reconciles loaded OneToMany + // relations, so a master loaded before the merge would null out the slave's re-parented steps. + // The returned `userData` keeps the collections for the response; user changes are persisted + // separately via userRepo.setUserRef. + const { kycSteps: _kycSteps, users: _users, ...persistable } = userData; + await this.userDataRepo.save(persistable as UserData); if (kycChanged) await this.kycNotificationService.kycChanged(userData, userData.kycLevel);