diff --git a/migration/1784560000000-BackfillExistingShareholderConfirmation.js b/migration/1784560000000-BackfillExistingShareholderConfirmation.js new file mode 100644 index 0000000000..480bc970a0 --- /dev/null +++ b/migration/1784560000000-BackfillExistingShareholderConfirmation.js @@ -0,0 +1,73 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Backfill: un-gate the RealUnit registrations that Aktionariat answered with "Existing user found, updated + * your address." (existing share-register shareholders). + * + * WHY: PR #4215 gates buy quotes on a confirmed registration email. When Aktionariat matches an EXISTING + * shareholder it updates the wallet in place and sends NO confirmation email (only newly registered emails + * get "Confirmation email sent to ..."). Those registrations were persisted COMPLETED with + * requiresEmailConfirmation = true and therefore hang forever on a confirmation link that never arrives. + * The service fix persists new existing-shareholder registrations with requiresEmailConfirmation = false; + * this migration heals the rows already stuck before the fix shipped. + * + * The existing-shareholder evidence is the durable Aktionariat/Registration audit row in the `log` table + * (the designated PII audit store), whose `response.message` is "Existing user found ...". We only touch rows + * that are (a) the active COMPLETED registration, (b) still gated (requiresEmailConfirmation = true), (c) not + * yet confirmed (confirmedDate IS NULL — a genuinely email-confirmed row is left untouched), and (d) proven + * existing-shareholder by such a log row for the same wallet. LOWER() both sides: the log keeps the mixed-case + * (EIP-55) wallet, the registration column is canonically lowercased. + * + * Idempotent: re-running is a no-op (the requiresEmailConfirmation = true predicate no longer matches). The + * reconciliation count is emitted via RAISE NOTICE — nothing changes silently. + * + * down(): intentional no-op. This is a forward-only data correction; re-gating these customers would lock + * them back out of buy/sell, and after the service fix ships the same predicate can no longer distinguish a + * backfilled row from a natively un-gated new existing-shareholder registration. The prior state stays + * reconstructible from the append-only `log` rows this migration reads (never mutates). + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class BackfillExistingShareholderConfirmation1784560000000 { + name = 'BackfillExistingShareholderConfirmation1784560000000'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(` + DO $$ + DECLARE + backfilled_count integer; + BEGIN + UPDATE "aktionariat_registration" r + SET "requiresEmailConfirmation" = false + WHERE r."active" = true + AND r."status" = 'Completed' + AND r."requiresEmailConfirmation" = true + AND r."confirmedDate" IS NULL + AND EXISTS ( + SELECT 1 FROM "log" l + WHERE l."system" = 'Aktionariat' + AND l."subsystem" = 'Registration' + AND l."message" LIKE '%Existing user found%' + AND LOWER(l."message") LIKE '%' || LOWER(r."walletAddress") || '%' + ); + GET DIAGNOSTICS backfilled_count = ROW_COUNT; + + RAISE NOTICE 'BackfillExistingShareholderConfirmation: un-gated existing-shareholder registrations=%', backfilled_count; + END $$; + `); + } + + /** + * @param {QueryRunner} _queryRunner + */ + async down(_queryRunner) { + // No-op: forward-only data correction (see class doc). + } +}; diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index bcd91e4439..9bf218d980 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -1614,6 +1614,56 @@ describe('RealUnitService', () => { expect(postConfig.timeout).toBe(30000); }); + it('does not gate the completed registration on a confirmation mail when Aktionariat matched an existing shareholder', async () => { + const wallet = softwareWallet.address; + const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet)); + const dto = buildDto(utf8Fields(wallet), signature); + // Aktionariat updates an existing share-register shareholder in place and sends NO confirmation mail. + httpService.post.mockResolvedValue({ message: 'Existing user found, updated your address.' } as any); + + const ok = await (service as any).forwardRegistration(fakeUserData(), dto); + + expect(ok).toBe(true); + const created = (aktionariatRegistrationRepo.create as jest.Mock).mock.calls[0][0]; + expect(created.status).toBe(ReviewStatus.COMPLETED); + // no confirmation mail will ever arrive for an existing shareholder → must not be gated (else buy dead-ends) + expect(created.requiresEmailConfirmation).toBe(false); + }); + + it('keeps the completed registration gated when Aktionariat sent a confirmation mail to a newly registered email', async () => { + const wallet = softwareWallet.address; + const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet)); + const dto = buildDto(utf8Fields(wallet), signature); + httpService.post.mockResolvedValue({ message: 'Confirmation email sent to erika.example@example.com' } as any); + + const ok = await (service as any).forwardRegistration(fakeUserData(), dto); + + expect(ok).toBe(true); + const created = (aktionariatRegistrationRepo.create as jest.Mock).mock.calls[0][0]; + expect(created.status).toBe(ReviewStatus.COMPLETED); + // a confirmation mail was sent → the row stays gated until the customer confirms + expect(created.requiresEmailConfirmation).toBe(true); + }); + + it('keeps the registration gated when a confirmation mail echoes an address that merely embeds the marker words', async () => { + const wallet = softwareWallet.address; + const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet)); + const dto = buildDto(utf8Fields(wallet), signature); + // Aktionariat echoes the raw registrant email into the message; a quoted local part can embed the + // marker words. The marker is matched start-anchored, so this stays a "Confirmation email sent" reply + // and the row must stay gated — an embedded substring must not spoof an existing-shareholder match. + httpService.post.mockResolvedValue({ + message: 'Confirmation email sent to "existing user found"@example.com', + } as any); + + const ok = await (service as any).forwardRegistration(fakeUserData(), dto); + + expect(ok).toBe(true); + const created = (aktionariatRegistrationRepo.create as jest.Mock).mock.calls[0][0]; + expect(created.status).toBe(ReviewStatus.COMPLETED); + expect(created.requiresEmailConfirmation).toBe(true); + }); + it('persists the registration in DEV/LOC without calling Aktionariat and logs the response as skipped', async () => { mockEnvironment = 'loc'; const wallet = softwareWallet.address; diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index c193c33a5e..0d11b62586 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -192,6 +192,12 @@ export class RealUnitService { private readonly ponderUrl: string; private readonly genesisDate = new Date('2022-04-12 07:46:41.000'); private readonly tokenName = 'REALU'; + // Lower-cased prefix of Aktionariat's registerUser response that marks a matched existing shareholder + // (full message: "Existing user found, updated your address."). Matched as a start-anchored prefix, not a + // free substring: the alternative "Confirmation email sent to " reply echoes the raw registrant + // email, so a substring test would let an address embedding these words (e.g. a quoted local part) spoof + // the marker and wrongly un-gate a registration whose confirmation email WAS sent and never confirmed. + private static readonly existingShareholderMarker = 'existing user found'; // Getter, not a field: Config is undefined until ConfigService is constructed, so reading it // in a field initializer can crash bootstrap depending on provider-instantiation order. private get tokenBlockchain(): Blockchain { @@ -1492,6 +1498,11 @@ export class RealUnitService { } } + // An existing share-register shareholder gets no confirmation email from Aktionariat, so the completed + // registration must not be gated on one (see isExistingShareholderResponse). Skip-forward (DEV/LOC) and + // failed forwards have no such response and stay gated by the normal rules. + const existingShareholder = this.isExistingShareholderResponse(registerResponse); + // 2) Persist the outcome in a short advisory-locked transaction (no external I/O inside it). let outcome: 'completed' | 'forward-failed' | 'idempotent'; try { @@ -1524,7 +1535,15 @@ export class RealUnitService { return 'forward-failed'; } - await this.persistAktionariatRegistration(manager, user, dto, payload, ReviewStatus.COMPLETED, new Date()); + await this.persistAktionariatRegistration( + manager, + user, + dto, + payload, + ReviewStatus.COMPLETED, + new Date(), + existingShareholder, + ); return 'completed'; }); } catch (error) { @@ -1600,6 +1619,16 @@ export class RealUnitService { } } + // Aktionariat's registerUser answers "Existing user found, updated your address." when the signed email + // already belongs to a share-register shareholder: it updates that shareholder's wallet in place and sends + // NO confirmation email (a newly registered email instead gets "Confirmation email sent to ..."). Such a + // registration must not be gated on a confirmation email that never arrives — Aktionariat has + // authoritatively identified an existing shareholder, which is itself the confirmation. + private isExistingShareholderResponse(response: Record | undefined): boolean { + const message = typeof response?.message === 'string' ? response.message : undefined; + return message?.toLowerCase().startsWith(RealUnitService.existingShareholderMarker) ?? false; + } + // Persist the queryable, per-wallet Aktionariat registration record for the resolved wallet-user within // the caller's transaction (the caller holds the per-wallet-user advisory lock). Deactivate any prior // active registration for this wallet-user, then insert the new one — so the partial unique index @@ -1614,6 +1643,7 @@ export class RealUnitService { payload: AktionariatRegistrationDto, status: ReviewStatus, forwardedToAktionariatDate: Date | null, + existingShareholder = false, ): Promise { await manager.update(AktionariatRegistration, { user: { id: user.id }, active: true }, { active: false }); @@ -1626,11 +1656,11 @@ export class RealUnitService { status, forwardedToAktionariatDate: forwardedToAktionariatDate ?? undefined, active: true, - // Only a COMPLETED registration is gated on the Aktionariat confirmation email (sent on a successful - // forward). A MANUAL_REVIEW row's forward failed, so no confirmation mail was ever sent — gating it - // would dead-end the flow on a mail that never arrives. (The completion migration clears this for rows - // that predate the gate.) - requiresEmailConfirmation: status === ReviewStatus.COMPLETED, + // A COMPLETED registration is gated on the Aktionariat confirmation email (sent on a successful forward) + // UNLESS Aktionariat matched an existing shareholder — it then sends no confirmation email, so gating + // would dead-end the flow on a mail that never arrives. A MANUAL_REVIEW row's forward failed, so no mail + // was sent either. (The completion migration clears this for rows that predate the gate.) + requiresEmailConfirmation: status === ReviewStatus.COMPLETED && !existingShareholder, }); registration.signedPayloadData = payload; registration.kycDataObj = dto.kycData;