diff --git a/migration/1779967217063-AddAktionariatRegistration.js b/migration/1779967217063-AddAktionariatRegistration.js new file mode 100644 index 0000000000..28888233ab --- /dev/null +++ b/migration/1779967217063-AddAktionariatRegistration.js @@ -0,0 +1,61 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddAktionariatRegistration1779967217063 { + name = 'AddAktionariatRegistration1779967217063' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "aktionariat_registration" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "walletAddress" character varying(256) NOT NULL, "status" character varying(256) NOT NULL, "signature" text NOT NULL, "registrationDate" character varying(256) NOT NULL, "externalRef" character varying(256), "comment" text, "result" text, "userId" integer NOT NULL, "userDataId" integer NOT NULL, "kycStepId" integer, CONSTRAINT "PK_af158ecdcaff6229223fe33f2ee" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_21d1d4854aa5b13f2038752af0" ON "aktionariat_registration" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_071503c6951cd10097327edffc" ON "aktionariat_registration" ("userDataId") `); + await queryRunner.query(`CREATE INDEX "IDX_63e3c8146435a6c91532b45e0e" ON "aktionariat_registration" ("kycStepId") `); + await queryRunner.query(`CREATE INDEX "IDX_754aadd3add69f81ce36ecfe33" ON "aktionariat_registration" ("walletAddress") `); + await queryRunner.query(`ALTER TABLE "aktionariat_registration" ADD CONSTRAINT "FK_21d1d4854aa5b13f2038752af00" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "aktionariat_registration" ADD CONSTRAINT "FK_071503c6951cd10097327edffc6" FOREIGN KEY ("userDataId") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "aktionariat_registration" ADD CONSTRAINT "FK_63e3c8146435a6c91532b45e0e9" FOREIGN KEY ("kycStepId") REFERENCES "kyc_step"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + + // Backfill from existing kyc_step rows (latest non-failed step per wallet) + await queryRunner.query(` + INSERT INTO "aktionariat_registration" ("walletAddress", "status", "signature", "registrationDate", "result", "userId", "userDataId", "kycStepId") + SELECT DISTINCT ON (LOWER(ks."result"::json->>'walletAddress')) + ks."result"::json->>'walletAddress', + ks."status", + ks."result"::json->>'signature', + COALESCE(ks."result"::json->>'registrationDate', ''), + ks."result", + u."id", + ks."userDataId", + ks."id" + FROM "kyc_step" ks + JOIN "user" u ON LOWER(u."address") = LOWER(ks."result"::json->>'walletAddress') + WHERE ks."name" = 'RealUnitRegistration' + AND ks."status" NOT IN ('Failed', 'Canceled') + AND ks."result" IS NOT NULL + AND ks."result"::json->>'walletAddress' IS NOT NULL + ORDER BY LOWER(ks."result"::json->>'walletAddress'), ks."id" DESC + `); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "aktionariat_registration" DROP CONSTRAINT "FK_63e3c8146435a6c91532b45e0e9"`); + await queryRunner.query(`ALTER TABLE "aktionariat_registration" DROP CONSTRAINT "FK_071503c6951cd10097327edffc6"`); + await queryRunner.query(`ALTER TABLE "aktionariat_registration" DROP CONSTRAINT "FK_21d1d4854aa5b13f2038752af00"`); + await queryRunner.query(`DROP INDEX "public"."IDX_754aadd3add69f81ce36ecfe33"`); + await queryRunner.query(`DROP INDEX "public"."IDX_63e3c8146435a6c91532b45e0e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_071503c6951cd10097327edffc"`); + await queryRunner.query(`DROP INDEX "public"."IDX_21d1d4854aa5b13f2038752af0"`); + await queryRunner.query(`DROP TABLE "aktionariat_registration"`); + } +} diff --git a/migration/generate.sh b/migration/generate.sh index 22be22e0af..e3f4a5de3d 100755 --- a/migration/generate.sh +++ b/migration/generate.sh @@ -1 +1 @@ -typeorm migration:generate migration/$1 -o -d migration/dev-data-source.ts +typeorm migration:generate migration/$1 -o -d migration/dev-data-source.psql.ts diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index fca60c498e..a15162ab78 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -1,3 +1,4 @@ +import { createMock } from '@golevelup/ts-jest'; import { BadRequestException, ConflictException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service'; @@ -29,6 +30,7 @@ import { PricingService } from '../../pricing/services/pricing.service'; import { RealUnitRegistrationStatus } from '../dto/realunit-registration.dto'; import { RealUnitDevService } from '../realunit-dev.service'; import { RealUnitService } from '../realunit.service'; +import { AktionariatRegistrationRepository } from '../repositories/aktionariat-registration.repository'; jest.mock('src/config/config', () => ({ get Config() { @@ -101,6 +103,7 @@ describe('RealUnitService', () => { let sellService: jest.Mocked; let userService: jest.Mocked; let kycService: jest.Mocked; + let module: TestingModule; const realuAsset = createCustomAsset({ id: 1, @@ -121,7 +124,7 @@ describe('RealUnitService', () => { }); beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + module = await Test.createTestingModule({ providers: [ RealUnitService, { provide: AssetPricesService, useValue: {} }, @@ -184,6 +187,7 @@ describe('RealUnitService', () => { { provide: FaucetRequestService, useValue: {} }, { provide: EthereumService, useValue: {} }, { provide: SepoliaService, useValue: {} }, + { provide: AktionariatRegistrationRepository, useValue: createMock() }, ], }).compile(); @@ -395,26 +399,39 @@ describe('RealUnitService', () => { const matchingSignature = '0xSIGNATURE_MATCHING'; const registrationDate = '2026-05-21'; - function buildExistingStep(opts: { signature: string; isCompleted: boolean }): any { + let registrationRepo: AktionariatRegistrationRepository; + + beforeEach(() => { + registrationRepo = module.get(AktionariatRegistrationRepository); + }); + + function buildExistingRegistration(opts: { signature: string; isCompleted: boolean }): any { return { - getResult: () => ({ - signature: opts.signature, - walletAddress, - registrationDate, - }), - isCompleted: opts.isCompleted, - isFailed: false, - isCanceled: false, - result: 'non-empty', + id: 1, + signature: opts.signature, + walletAddress, + registrationDate, + status: opts.isCompleted ? 'Completed' : 'InternalReview', + get isCompleted() { + return this.status === 'Completed'; + }, + get isFailed() { + return this.status === 'Failed'; + }, + get isCanceled() { + return this.status === 'Canceled'; + }, + result: JSON.stringify({ signature: opts.signature, walletAddress, registrationDate }), + getResult() { + return JSON.parse(this.result); + }, }; } - function mockUserWithSteps(steps: any[]): void { - const userData = { - id: userDataId, - getStepsWith: jest.fn().mockReturnValue(steps), - }; + function mockUserWithRegistration(registration: any): void { + const userData = { id: userDataId }; userService.getUserByAddress.mockResolvedValue({ userData } as any); + jest.spyOn(registrationRepo, 'findOneBy').mockResolvedValue(registration); } const dto = { @@ -424,8 +441,8 @@ describe('RealUnitService', () => { }; it('returns ALREADY_REGISTERED without creating a new KycStep when signature matches a completed registration', async () => { - const existingStep = buildExistingStep({ signature: matchingSignature, isCompleted: true }); - mockUserWithSteps([existingStep]); + const existing = buildExistingRegistration({ signature: matchingSignature, isCompleted: true }); + mockUserWithRegistration(existing); const status = await service.completeRegistrationForWalletAddress(userDataId, dto); @@ -434,8 +451,8 @@ describe('RealUnitService', () => { }); it('returns FORWARDING_FAILED when signature matches but the existing registration is not completed', async () => { - const existingStep = buildExistingStep({ signature: matchingSignature, isCompleted: false }); - mockUserWithSteps([existingStep]); + const existing = buildExistingRegistration({ signature: matchingSignature, isCompleted: false }); + mockUserWithRegistration(existing); const status = await service.completeRegistrationForWalletAddress(userDataId, dto); @@ -444,8 +461,8 @@ describe('RealUnitService', () => { }); it('matches signatures case-insensitively (stored upper-case, incoming lower-case)', async () => { - const existingStep = buildExistingStep({ signature: matchingSignature.toUpperCase(), isCompleted: true }); - mockUserWithSteps([existingStep]); + const existing = buildExistingRegistration({ signature: matchingSignature.toUpperCase(), isCompleted: true }); + mockUserWithRegistration(existing); const status = await service.completeRegistrationForWalletAddress(userDataId, { ...dto, @@ -457,8 +474,8 @@ describe('RealUnitService', () => { }); it('throws BadRequestException when an existing registration for the same wallet has a different signature', async () => { - const existingStep = buildExistingStep({ signature: '0xDIFFERENT_SIGNATURE', isCompleted: true }); - mockUserWithSteps([existingStep]); + const existing = buildExistingRegistration({ signature: '0xDIFFERENT_SIGNATURE', isCompleted: true }); + mockUserWithRegistration(existing); await expect(service.completeRegistrationForWalletAddress(userDataId, dto)).rejects.toThrow(BadRequestException); expect(kycService.createCustomKycStep).not.toHaveBeenCalled(); diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index e280ea2578..ac5ab741ed 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -518,7 +518,7 @@ export class RealUnitController { @ApiOkResponse({ type: RealUnitPaymentInfoDto }) @ApiBadRequestResponse({ description: 'KYC Level 30 required, registration missing, or address not on allowlist' }) async getPaymentInfo(@GetJwt() jwt: JwtPayload, @Body() dto: RealUnitBuyDto): Promise { - const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true, country: true } }); + const user = await this.userService.getUser(jwt.user, { userData: { country: true } }); return this.realunitService.getPaymentInfo(user, dto); } @@ -546,7 +546,7 @@ export class RealUnitController { @GetJwt() jwt: JwtPayload, @Body() dto: RealUnitSellDto, ): Promise { - const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true, country: true } }); + const user = await this.userService.getUser(jwt.user, { userData: { country: true } }); return this.realunitService.getSellPaymentInfo(user, dto); } @@ -616,8 +616,8 @@ export class RealUnitController { }) @ApiOkResponse({ type: RealUnitWalletStatusDto }) async getWalletStatus(@GetJwt() jwt: JwtPayload): Promise { - const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true } }); - return this.realunitService.getAddressWalletStatus(user.userData, jwt.address); + const user = await this.userService.getUser(jwt.user, { userData: true }); + return this.realunitService.getAddressWalletStatus(user.userData.id, jwt.address); } // --- Registration Endpoints --- @@ -631,8 +631,8 @@ export class RealUnitController { }) @ApiOkResponse({ type: Boolean }) async isRegistered(@GetJwt() jwt: JwtPayload): Promise { - const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true } }); - return this.realunitService.hasRegistrationForWallet(user.userData, jwt.address); + const user = await this.userService.getUser(jwt.user, { userData: true }); + return this.realunitService.hasRegistrationForWallet(user.userData.id, jwt.address); } @Post('register/email') diff --git a/src/subdomains/supporting/realunit/entities/aktionariat-registration.entity.ts b/src/subdomains/supporting/realunit/entities/aktionariat-registration.entity.ts new file mode 100644 index 0000000000..20f3e83486 --- /dev/null +++ b/src/subdomains/supporting/realunit/entities/aktionariat-registration.entity.ts @@ -0,0 +1,78 @@ +import { IEntity, UpdateResult } from 'src/shared/models/entity'; +import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { Column, Entity, Index, ManyToOne } from 'typeorm'; +import { KycStep } from 'src/subdomains/generic/kyc/entities/kyc-step.entity'; + +@Entity('aktionariat_registration') +export class AktionariatRegistration extends IEntity { + @Index({ unique: true }) + @ManyToOne(() => User, { nullable: false }) + user: User; + + @Index() + @ManyToOne(() => UserData, { nullable: false }) + userData: UserData; + + @Index() + @ManyToOne(() => KycStep, { nullable: true }) + kycStep?: KycStep; + + @Column({ length: 256 }) + @Index() + walletAddress: string; + + @Column({ length: 256 }) + status: ReviewStatus; + + @Column({ type: 'text' }) + signature: string; + + @Column({ length: 256 }) + registrationDate: string; + + @Column({ length: 256, nullable: true }) + externalRef?: string; + + @Column({ type: 'text', nullable: true }) + comment?: string; + + @Column({ type: 'text', nullable: true }) + result?: string; + + // --- GETTERS --- // + + get isCompleted(): boolean { + return this.status === ReviewStatus.COMPLETED; + } + + get isFailed(): boolean { + return this.status === ReviewStatus.FAILED; + } + + get isCanceled(): boolean { + return this.status === ReviewStatus.CANCELED; + } + + getResult(): T | undefined { + if (!this.result) return undefined; + try { + return JSON.parse(this.result); + } catch { + return undefined; + } + } + + // --- UPDATE METHODS --- // + + complete(): UpdateResult { + const update: Partial = { status: ReviewStatus.COMPLETED }; + return [this.id, update]; + } + + manualReview(comment?: string): UpdateResult { + const update: Partial = { status: ReviewStatus.MANUAL_REVIEW, comment }; + return [this.id, update]; + } +} diff --git a/src/subdomains/supporting/realunit/realunit.module.ts b/src/subdomains/supporting/realunit/realunit.module.ts index a7d7a20cd6..7501e6ca8a 100644 --- a/src/subdomains/supporting/realunit/realunit.module.ts +++ b/src/subdomains/supporting/realunit/realunit.module.ts @@ -1,4 +1,5 @@ import { forwardRef, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { EthereumModule } from 'src/integration/blockchain/ethereum/ethereum.module'; import { RealUnitBlockchainModule } from 'src/integration/blockchain/realunit/realunit-blockchain.module'; import { SepoliaModule } from 'src/integration/blockchain/sepolia/sepolia.module'; @@ -16,11 +17,14 @@ import { PaymentModule } from '../payment/payment.module'; import { TransactionModule } from '../payment/transaction.module'; import { PricingModule } from '../pricing/pricing.module'; import { RealUnitController } from './controllers/realunit.controller'; +import { AktionariatRegistration } from './entities/aktionariat-registration.entity'; import { RealUnitDevService } from './realunit-dev.service'; import { RealUnitService } from './realunit.service'; +import { AktionariatRegistrationRepository } from './repositories/aktionariat-registration.repository'; @Module({ imports: [ + TypeOrmModule.forFeature([AktionariatRegistration]), SharedModule, PricingModule, BalanceModule, @@ -39,7 +43,7 @@ import { RealUnitService } from './realunit.service'; FaucetRequestModule, ], controllers: [RealUnitController], - providers: [RealUnitService, RealUnitDevService], + providers: [RealUnitService, RealUnitDevService, AktionariatRegistrationRepository], exports: [RealUnitService], }) export class RealUnitModule {} diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index da7d6a161d..37f13c9b5f 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -45,6 +45,9 @@ import { KycStep } from 'src/subdomains/generic/kyc/entities/kyc-step.entity'; import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; +import { ILike } from 'typeorm'; +import { AktionariatRegistration } from './entities/aktionariat-registration.entity'; +import { AktionariatRegistrationRepository } from './repositories/aktionariat-registration.repository'; import { AccountMergeService } from 'src/subdomains/generic/user/models/account-merge/account-merge.service'; import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; @@ -152,6 +155,7 @@ export class RealUnitService { private readonly swissQrService: SwissQRService, private readonly feeService: FeeService, private readonly faucetRequestService: FaucetRequestService, + private readonly registrationRepo: AktionariatRegistrationRepository, ) { this.ponderUrl = GetConfig().blockchain.realunit.graphUrl; } @@ -410,7 +414,7 @@ export class RealUnitService { const currencyName = dto.currency ?? 'CHF'; // 1. Registration required - if (!this.hasRegistrationForWallet(userData, user.address)) { + if (!(await this.hasRegistrationForWallet(userData.id, user.address))) { throw new RegistrationRequiredException(); } @@ -555,8 +559,9 @@ export class RealUnitService { // --- Registration Methods --- - hasRegistrationForWallet(userData: UserData, walletAddress: string): boolean { - return this.findRegistrationStep(userData, walletAddress).isForCurrentWallet; + async hasRegistrationForWallet(userDataId: number, walletAddress: string): Promise { + const { isForCurrentWallet } = await this.findRegistration(userDataId, walletAddress); + return isForCurrentWallet; } async registerEmail(userDataId: number, dto: RealUnitEmailRegistrationDto): Promise { @@ -588,11 +593,10 @@ export class RealUnitService { await this.validateRegistrationDto(dto); // get and validate user - const userData = await this.userService - .getUserByAddress(dto.walletAddress, { - userData: { kycSteps: true, users: true, country: true, organizationCountry: true }, - }) - .then((u) => u?.userData); + const user = await this.userService.getUserByAddress(dto.walletAddress, { + userData: { users: true, country: true, organizationCountry: true }, + }); + const userData = user?.userData; if (!userData) throw new NotFoundException('User not found'); if (userData.id !== userDataId) throw new BadRequestException('Wallet address does not belong to user'); @@ -604,9 +608,9 @@ export class RealUnitService { throw new BadRequestException('Email does not match registered email'); } - const { step: existingStep, isForCurrentWallet } = this.findRegistrationStep(userData, dto.walletAddress); + const { registration: existing, isForCurrentWallet } = await this.findRegistration(userData.id, dto.walletAddress); if (isForCurrentWallet) { - return this.idempotentRegistrationResult(userData, existingStep!, dto.signature); + return this.idempotentRegistrationResult(userData, existing!, dto.signature); } // validate personal data @@ -626,7 +630,7 @@ export class RealUnitService { }); } - // store data with internal review + // store data (dual-write: KycStep for audit + AktionariatRegistration for lookups) const kycStep = await this.kycService.createCustomKycStep( userData, KycStepName.REALUNIT_REGISTRATION, @@ -634,8 +638,21 @@ export class RealUnitService { dto, ); + const registration = await this.registrationRepo.save( + this.registrationRepo.create({ + user: user, + userData, + kycStep, + walletAddress: dto.walletAddress, + status: ReviewStatus.INTERNAL_REVIEW, + signature: dto.signature, + registrationDate: dto.registrationDate, + result: JSON.stringify(dto), + }), + ); + // forward to Aktionariat - const success = await this.forwardRegistration(kycStep, dto); + const success = await this.forwardRegistration(registration, kycStep, dto); if (!success) return RealUnitRegistrationStatus.FORWARDING_FAILED; return RealUnitRegistrationStatus.COMPLETED; @@ -643,12 +660,12 @@ export class RealUnitService { // --- Wallet Methods --- - getAddressWalletStatus(userData: UserData, walletAddress: string): RealUnitWalletStatusDto { - const { step, isForCurrentWallet } = this.findRegistrationStep(userData, walletAddress); + async getAddressWalletStatus(userDataId: number, walletAddress: string): Promise { + const { registration, isForCurrentWallet } = await this.findRegistration(userDataId, walletAddress); return { isRegistered: isForCurrentWallet, - userData: this.toUserDataDto(step), + userData: this.toUserDataDto(registration), }; } @@ -656,26 +673,28 @@ export class RealUnitService { userDataId: number, dto: RealUnitRegisterWalletDto, ): Promise { - const userData = await this.userService - .getUserByAddress(dto.walletAddress, { - userData: { kycSteps: true, users: true, country: true }, - }) - .then((u) => u?.userData); + const user = await this.userService.getUserByAddress(dto.walletAddress, { + userData: { users: true, country: true }, + }); + const userData = user?.userData; if (!userData) throw new NotFoundException('User not found'); if (userData.id !== userDataId) throw new BadRequestException('Wallet address does not belong to user'); - const { step: registrationStep, isForCurrentWallet } = this.findRegistrationStep(userData, dto.walletAddress); + const { registration: existingRegistration, isForCurrentWallet } = await this.findRegistration( + userData.id, + dto.walletAddress, + ); if (isForCurrentWallet) { - return this.idempotentRegistrationResult(userData, registrationStep!, dto.signature); + return this.idempotentRegistrationResult(userData, existingRegistration!, dto.signature); } - if (!registrationStep) { + if (!existingRegistration) { throw new BadRequestException('No RealUnit registration found'); } - const registrationData = registrationStep.getResult(); + const registrationData = existingRegistration.getResult(); if (!registrationData) { throw new BadRequestException('Invalid registration data'); } @@ -693,6 +712,7 @@ export class RealUnitService { throw new BadRequestException('Invalid signature'); } + // dual-write: KycStep for audit + AktionariatRegistration for lookups const kycStep = await this.kycService.createCustomKycStep( userData, KycStepName.REALUNIT_REGISTRATION, @@ -700,7 +720,20 @@ export class RealUnitService { fullDto, ); - const success = await this.forwardRegistration(kycStep, fullDto); + const registration = await this.registrationRepo.save( + this.registrationRepo.create({ + user: user, + userData, + kycStep, + walletAddress: dto.walletAddress, + status: ReviewStatus.INTERNAL_REVIEW, + signature: dto.signature, + registrationDate: dto.registrationDate, + result: JSON.stringify(fullDto), + }), + ); + + const success = await this.forwardRegistration(registration, kycStep, fullDto); return success ? RealUnitRegistrationStatus.COMPLETED : RealUnitRegistrationStatus.FORWARDING_FAILED; } @@ -850,45 +883,45 @@ export class RealUnitService { throw new BadRequestException('KYC step is not in MANUAL_REVIEW status'); } - const dto = kycStep.getResult(); + const registration = await this.registrationRepo.findOneBy({ kycStep: { id: kycStepId } }); + if (!registration) throw new NotFoundException('Registration not found for KYC step'); + + const dto = registration.getResult(); if (!dto) throw new BadRequestException('No registration data found'); - const success = await this.forwardRegistration(kycStep, dto); + const success = await this.forwardRegistration(registration, kycStep, dto); if (!success) throw new BadRequestException('Failed to forward registration to Aktionariat'); } /** - * Finds a registration step for the user. + * Finds a registration for the user. * First tries to find a registration for the current wallet. * If not found, falls back to finding a registration from another wallet (for account merge scenarios). */ - private findRegistrationStep( - userData: UserData, + private async findRegistration( + userDataId: number, walletAddress: string, - ): { step: KycStep | undefined; isForCurrentWallet: boolean } { - const allSteps = userData.getStepsWith(KycStepName.REALUNIT_REGISTRATION); - - // First: look for registration for the current wallet (non-failed, non-canceled) - const currentWalletStep = allSteps - .filter((s) => !(s.isFailed || s.isCanceled)) - .find((s) => { - const result = s.getResult(); - return result?.walletAddress && Util.equalsIgnoreCase(result.walletAddress, walletAddress); - }); + ): Promise<{ registration: AktionariatRegistration | undefined; isForCurrentWallet: boolean }> { + // First: indexed lookup for current wallet + const currentWallet = await this.registrationRepo.findOneBy({ + userData: { id: userDataId }, + walletAddress: ILike(walletAddress), + }); - if (currentWalletStep) { - return { step: currentWalletStep, isForCurrentWallet: true }; + if (currentWallet && !currentWallet.isFailed && !currentWallet.isCanceled) { + return { registration: currentWallet, isForCurrentWallet: true }; } // Second: look for registration from another wallet (for account merge) - const otherWalletStep = allSteps - .filter((s) => (s.isCompleted || s.isCanceled) && s.result) - .find((s) => { - const result = s.getResult(); - return result?.walletAddress && !Util.equalsIgnoreCase(result.walletAddress, walletAddress); - }); + const otherWallet = await this.registrationRepo + .find({ where: { userData: { id: userDataId } } }) + .then((all) => + all.find( + (r) => (r.isCompleted || r.isCanceled) && r.result && !Util.equalsIgnoreCase(r.walletAddress, walletAddress), + ), + ); - return { step: otherWalletStep, isForCurrentWallet: false }; + return { registration: otherWallet, isForCurrentWallet: false }; } /** @@ -899,40 +932,28 @@ export class RealUnitService { */ private idempotentRegistrationResult( userData: UserData, - step: KycStep, + registration: AktionariatRegistration, incomingSignature: string, ): RealUnitRegistrationStatus { - const existingData = step.getResult(); - if (!Util.equalsIgnoreCase(existingData?.signature, incomingSignature)) { + if (!Util.equalsIgnoreCase(registration.signature, incomingSignature)) { throw new BadRequestException('RealUnit registration already exists for this wallet with a different signature'); } - // Under the normal REALUNIT_REGISTRATION flow the step is in INTERNAL_REVIEW (created, - // forward not run yet), MANUAL_REVIEW (forward failed, awaiting admin retry), or COMPLETED - // (forward succeeded). findRegistrationStep filters out FAILED and CANCELED, but admin - // overrides via kyc-admin.updateKycStep can leave other non-failed/non-canceled statuses - // (e.g. ON_HOLD, OUTDATED) reachable here. Only COMPLETED is a terminal success; every - // other reachable status falls through to FORWARDING_FAILED, which surfaces the same retry - // path the client would have seen on the original call. - // Surface ALREADY_REGISTERED (not COMPLETED) on the idempotent path so - // clients can distinguish "registration just completed in this call" - // from "registration was already in place". The wallet-app uses this - // to skip the post-registration onboarding screens on retry. - const status = step.isCompleted + const status = registration.isCompleted ? RealUnitRegistrationStatus.ALREADY_REGISTERED : RealUnitRegistrationStatus.FORWARDING_FAILED; this.logger.info( - `RealUnit registration idempotent retry for userData ${userData.id}, kycStep ${step.id} → ${status}`, + `RealUnit registration idempotent retry for userData ${userData.id}, registration ${registration.id} → ${status}`, ); return status; } - private toUserDataDto(step: KycStep | undefined): RealUnitUserDataDto | undefined { - if (!step) return undefined; + private toUserDataDto(registration: AktionariatRegistration | undefined): RealUnitUserDataDto | undefined { + if (!registration) return undefined; - const registrationData = step.getResult(); + const registrationData = registration.getResult(); if (!registrationData) return undefined; const { signature: _sig, walletAddress: _wallet, registrationDate: _date, ...userDataDto } = registrationData; @@ -973,7 +994,11 @@ export class RealUnitService { return true; } - private async forwardRegistration(kycStep: KycStep, dto: RealUnitRegistrationDto): Promise { + private async forwardRegistration( + registration: AktionariatRegistration, + kycStep: KycStep, + dto: RealUnitRegistrationDto, + ): Promise { const { api } = Config.blockchain.realunit; try { @@ -1004,6 +1029,7 @@ export class RealUnitService { } await this.kycService.saveKycStepUpdate(kycStep.complete()); + await this.registrationRepo.update(...registration.complete()); // Set KYC Level 20 if not already higher (same as NATIONALITY_DATA step) if (kycStep.userData.kycLevel < KycLevel.LEVEL_20) { @@ -1015,9 +1041,10 @@ export class RealUnitService { const message = error?.response?.data ? JSON.stringify(error.response.data) : error?.message || error; this.logger.error( - `Failed to forward RealUnit registration to Aktionariat for KYC step ${kycStep.id}: ${message}`, + `Failed to forward RealUnit registration to Aktionariat for registration ${registration.id}: ${message}`, ); await this.kycService.saveKycStepUpdate(kycStep.manualReview(message)); + await this.registrationRepo.update(...registration.manualReview(message)); return false; } } @@ -1029,7 +1056,7 @@ export class RealUnitService { const currencyName = dto.currency ?? 'CHF'; // 1. Registration required - if (!this.hasRegistrationForWallet(userData, user.address)) { + if (!(await this.hasRegistrationForWallet(userData.id, user.address))) { throw new RegistrationRequiredException(); } diff --git a/src/subdomains/supporting/realunit/repositories/aktionariat-registration.repository.ts b/src/subdomains/supporting/realunit/repositories/aktionariat-registration.repository.ts new file mode 100644 index 0000000000..c1560b9979 --- /dev/null +++ b/src/subdomains/supporting/realunit/repositories/aktionariat-registration.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { EntityManager } from 'typeorm'; +import { AktionariatRegistration } from '../entities/aktionariat-registration.entity'; + +@Injectable() +export class AktionariatRegistrationRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(AktionariatRegistration, manager); + } +}