Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ describe('RealUnitService', () => {
let sellService: jest.Mocked<SellService>;
let userService: jest.Mocked<UserService>;
let kycService: jest.Mocked<KycService>;
let accountMergeService: jest.Mocked<AccountMergeService>;

const realuAsset = createCustomAsset({
id: 1,
Expand Down Expand Up @@ -190,7 +191,7 @@ describe('RealUnitService', () => {
},
},
{ provide: TransactionService, useValue: {} },
{ provide: AccountMergeService, useValue: {} },
{ provide: AccountMergeService, useValue: { hasProcessingMerge: jest.fn().mockResolvedValue(false) } },
{ provide: RealUnitDevService, useValue: {} },
{ provide: SwissQRService, useValue: {} },
{ provide: FeeService, useValue: {} },
Expand All @@ -208,6 +209,7 @@ describe('RealUnitService', () => {
sellService = module.get(SellService);
userService = module.get(UserService);
kycService = module.get(KycService);
accountMergeService = module.get(AccountMergeService);
});

afterEach(() => {
Expand Down Expand Up @@ -537,11 +539,25 @@ describe('RealUnitService', () => {
};
}

it('returns state=ALREADY_REGISTERED when a non-failed step for the current wallet exists', () => {
it('returns state=MERGE_PROCESSING with no userData while an account merge for the user is still propagating', async () => {
accountMergeService.hasProcessingMerge.mockResolvedValue(true);
const userData = buildVerifiedUserData();
userData.getStepsWith.mockReturnValue([buildStepForWallet(walletAddress)]);

const status = service.getRegistrationInfo(userData, walletAddress);
const status = await service.getRegistrationInfo(userData, walletAddress);

expect(status.state).toBe(RealUnitRegistrationState.MERGE_PROCESSING);
expect(status.isRegistered).toBe(false);
expect(status.userData).toBeUndefined();
// the merge guard short-circuits before any registration-step inspection
expect(userData.getStepsWith).not.toHaveBeenCalled();
});

it('returns state=ALREADY_REGISTERED when a non-failed step for the current wallet exists', async () => {
const userData = buildVerifiedUserData();
userData.getStepsWith.mockReturnValue([buildStepForWallet(walletAddress)]);

const status = await service.getRegistrationInfo(userData, walletAddress);

expect(status.state).toBe(RealUnitRegistrationState.ALREADY_REGISTERED);
expect(status.isRegistered).toBe(true);
Expand All @@ -550,11 +566,11 @@ describe('RealUnitService', () => {
expect(status.userData!.name).toBe('Signed Name');
});

it('returns state=ADD_WALLET when a step exists for a different wallet but not the current one', () => {
it('returns state=ADD_WALLET when a step exists for a different wallet but not the current one', async () => {
const userData = buildVerifiedUserData();
userData.getStepsWith.mockReturnValue([buildStepForWallet(otherWalletAddress, { isCompleted: true })]);

const status = service.getRegistrationInfo(userData, walletAddress);
const status = await service.getRegistrationInfo(userData, walletAddress);

expect(status.state).toBe(RealUnitRegistrationState.ADD_WALLET);
expect(status.isRegistered).toBe(false);
Expand All @@ -564,10 +580,10 @@ describe('RealUnitService', () => {
expect(status.userData!.name).toBe('Signed Name');
});

it('returns state=NEW_REGISTRATION when no step exists but userData has firstname/surname', () => {
it('returns state=NEW_REGISTRATION when no step exists but userData has firstname/surname', async () => {
const userData = buildVerifiedUserData();

const status = service.getRegistrationInfo(userData, walletAddress);
const status = await service.getRegistrationInfo(userData, walletAddress);

expect(status.state).toBe(RealUnitRegistrationState.NEW_REGISTRATION);
expect(status.isRegistered).toBe(false);
Expand All @@ -587,36 +603,36 @@ describe('RealUnitService', () => {
expect(status.userData!.kycData.lastName).toBe('Mustermann');
});

it('returns state=NEW_REGISTRATION with no userData when no step exists and no KYC data is present (first-time user gets an empty form)', () => {
it('returns state=NEW_REGISTRATION with no userData when no step exists and no KYC data is present (first-time user gets an empty form)', async () => {
const userData = {
firstname: null,
surname: null,
getStepsWith: jest.fn().mockReturnValue([]),
} as any;

const status = service.getRegistrationInfo(userData, walletAddress);
const status = await service.getRegistrationInfo(userData, walletAddress);

expect(status.state).toBe(RealUnitRegistrationState.NEW_REGISTRATION);
expect(status.isRegistered).toBe(false);
expect(status.userData).toBeUndefined();
});

it('defaults swissTaxResidence to false in NEW_REGISTRATION when the residence country is not CH', () => {
it('defaults swissTaxResidence to false in NEW_REGISTRATION when the residence country is not CH', async () => {
const userData = buildVerifiedUserData();
userData.country = { id: 2, symbol: 'DE' };

const status = service.getRegistrationInfo(userData, walletAddress);
const status = await service.getRegistrationInfo(userData, walletAddress);

expect(status.state).toBe(RealUnitRegistrationState.NEW_REGISTRATION);
expect(status.userData!.swissTaxResidence).toBe(false);
expect(status.userData!.addressCountry).toBe('DE');
});

it('falls back to EN in NEW_REGISTRATION when the user language is not one of the RealUnit-supported codes', () => {
it('falls back to EN in NEW_REGISTRATION when the user language is not one of the RealUnit-supported codes', async () => {
const userData = buildVerifiedUserData();
userData.language = { symbol: 'ES' };

const status = service.getRegistrationInfo(userData, walletAddress);
const status = await service.getRegistrationInfo(userData, walletAddress);

expect(status.state).toBe(RealUnitRegistrationState.NEW_REGISTRATION);
expect(status.userData!.lang).toBe('EN');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ export enum RealUnitRegistrationState {
ALREADY_REGISTERED = 'AlreadyRegistered',
ADD_WALLET = 'AddWallet',
NEW_REGISTRATION = 'NewRegistration',
MERGE_PROCESSING = 'MergeProcessing',
}

export class RealUnitRegistrationInfoDto {
Expand All @@ -253,7 +254,7 @@ export class RealUnitRegistrationInfoDto {
@ApiProperty({
enum: RealUnitRegistrationState,
description:
'Action the client should take for this wallet. `AlreadyRegistered`: no UX needed. `AddWallet`: render a one-tap Add-Wallet flow that submits to POST /register/wallet using the prior signed payload (`userData` is set). `NewRegistration`: render the full registration form — pre-filled with `userData` when present, otherwise empty for the client to collect every field manually.',
'Action the client should take for this wallet. `AlreadyRegistered`: no UX needed. `AddWallet`: render a one-tap Add-Wallet flow that submits to POST /register/wallet using the prior signed payload (`userData` is set). `NewRegistration`: render the full registration form — pre-filled with `userData` when present, otherwise empty for the client to collect every field manually. `MergeProcessing`: an account merge for this user is still propagating — render a waiting state and poll; do not treat the absent `userData` as a failure.',
})
state: RealUnitRegistrationState;

Expand Down
9 changes: 8 additions & 1 deletion src/subdomains/supporting/realunit/realunit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,14 @@ export class RealUnitService {

// --- Wallet Methods ---

getRegistrationInfo(userData: UserData, walletAddress: string): RealUnitRegistrationInfoDto {
async getRegistrationInfo(userData: UserData, walletAddress: string): Promise<RealUnitRegistrationInfoDto> {
// While an account merge for this user is still propagating, the merged-in KYC steps and personal
// data are not yet re-parented, so the branches below would misreport NEW_REGISTRATION with an
// empty `userData`. Signal MERGE_PROCESSING instead so the client renders a waiting state and polls
// until propagation completes — mirrors the KYC path (KycService #toDto via hasProcessingMerge).
if (await this.accountMergeService.hasProcessingMerge(userData.id))
return { isRegistered: false, state: RealUnitRegistrationState.MERGE_PROCESSING };

const { step, isForCurrentWallet } = this.findRegistrationStep(userData, walletAddress);

// Dispatch to one of three states so the client can route to the right UX without inferring
Expand Down