diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e148dfee0c..f082313353 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -940,6 +940,42 @@ Keep old endpoints for backward compatibility but annotate: @ApiOperation({ deprecated: true }) ``` +### RealUnit: `/quote/*` vs `/brokerbot/*` + +The RealUnit purchase and sale flows historically lived under `/v1/realunit/brokerbot/*`. That naming is misleading: most of those endpoints never touch the on-chain Brokerbot smart contract. Treat them as two distinct subsystems: + +| Path | What it does | On-chain? | +|---|---|---| +| `GET /v1/realunit/quote/price` | Spot price per share | No — Aktionariat REST (`/directinvestment/getPrice`, 30 s cache) | +| `GET /v1/realunit/quote/buyPrice?shares=N` | `N × price` (buy direction) | No | +| `GET /v1/realunit/quote/buyShares?amount=N` | `floor(N / price)` (buy direction) | No | +| `GET /v1/realunit/quote/sellPrice?shares=N` | Estimated payout after user-specific fees | No — REST price + local fee math | +| `GET /v1/realunit/quote/sellShares?amount=N` | Reverse of the above | No | +| `GET /v1/realunit/quote/info` | Spot price + Brokerbot contract addresses (for clients that need them) | No | +| `PUT /v1/realunit/buy` + `/buy/:id/confirm` | Fiat IBAN flow — Aktionariat allocates shares off-chain via `directinvestment/payAndAllocate` | No | +| `PUT /v1/realunit/sell` | Anchors the quote against the live on-chain sell price before returning payment-info | **Yes** — `RealUnitBlockchainService.getBrokerbotSellPrice` (viem `readContract`) | +| `PUT /v1/realunit/sell/:id/unsigned-transactions` | Reads the on-chain sell price and builds the EIP-7702 batch the user has to sign | **Yes** — `RealUnitBlockchainService.getBrokerbotSellPrice` | +| `PUT /v1/realunit/sell/:id/confirm` | Verifies the user-signed batch against the live on-chain sell price | **Yes** — `RealUnitBlockchainService.getBrokerbotSellPrice` | +| `PUT /v1/realunit/sell/:id/broadcast` | Submits the user-signed EIP-1559 transaction to the network | No — broadcast only, no `readContract` | + +Operational consequences: + +- Treat `/quote/*` as a thin pricing API. It can be public, cached, and oracle-style. Don't add transactional side effects there. +- The actual on-chain Brokerbot interaction is `RealUnitBlockchainService.getBrokerbotSellPrice(brokerbotAddress, shares)` (viem `readContract`). It is invoked by `getSellPaymentInfo`, `createSellUnsignedTransactions` and `confirmSell` — i.e. every `PUT /sell*` route except `/broadcast`. Anything that names the smart contract directly (`getBrokerbot…`, `brokerbotAddress`) should stay scoped to that on-chain path. +- The legacy `/brokerbot/*` endpoints are `deprecated: true` mirrors of the `/quote/*` ones. Don't add new functionality there. + +### RealUnit: `/registration` vs `/wallet/status` + +The endpoint that tells the client what to do to RealUnit-register the connected wallet historically lived under `/v1/realunit/wallet/status`. That naming is misleading: the resource being described is the user's Aktionariat registration, not a generic wallet status — and clients never ask "what is the wallet's status?", they ask "what do I need to do to be RealUnit-registered?". The canonical path is now `/v1/realunit/registration`; the legacy path is kept as a `deprecated: true` mirror. + +| Old | New | +|---|---| +| `GET /v1/realunit/wallet/status` | `GET /v1/realunit/registration` | +| `RealUnitWalletStatusDto` | `RealUnitRegistrationInfoDto` | +| `RealUnitService.getAddressWalletStatus(...)` | `RealUnitService.getRegistrationInfo(...)` | + +Operational consequence: treat `/wallet/status` as deprecated; consume `state` from the new `/registration` endpoint; the legacy path is kept for backwards compatibility on existing clients only. + ### `undefined` vs Empty Array ```typescript diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index fca60c498e..ab690a0564 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -26,7 +26,7 @@ import { TransactionRequestService } from 'src/subdomains/supporting/payment/ser import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { AssetPricesService } from '../../pricing/services/asset-prices.service'; import { PricingService } from '../../pricing/services/pricing.service'; -import { RealUnitRegistrationStatus } from '../dto/realunit-registration.dto'; +import { RealUnitRegistrationState, RealUnitRegistrationStatus } from '../dto/realunit-registration.dto'; import { RealUnitDevService } from '../realunit-dev.service'; import { RealUnitService } from '../realunit.service'; @@ -89,6 +89,7 @@ jest.mock('src/shared/utils/util', () => ({ Util: { createUid: jest.fn().mockReturnValue('MOCK-UID'), equalsIgnoreCase: (a?: string, b?: string) => a?.toLowerCase() === b?.toLowerCase(), + isoDate: (date: Date) => date.toISOString().split('T')[0], }, })); @@ -464,4 +465,149 @@ describe('RealUnitService', () => { expect(kycService.createCustomKycStep).not.toHaveBeenCalled(); }); }); + + describe('getRegistrationInfo', () => { + const walletAddress = '0x2222222222222222222222222222222222222222'; + const otherWalletAddress = '0x3333333333333333333333333333333333333333'; + + function buildVerifiedUserData(): any { + return { + firstname: 'Max', + surname: 'Mustermann', + mail: 'max@example.com', + phone: '+41791234567', + birthday: new Date('1990-05-21T00:00:00.000Z'), + nationality: { id: 1, symbol: 'CH' }, + country: { id: 1, symbol: 'CH' }, + street: 'Bahnhofstrasse', + houseNumber: '1', + location: 'Zürich', + zip: '8001', + language: { symbol: 'DE' }, + accountType: 'Personal', + tin: null, + organizationName: null, + organizationStreet: null, + organizationHouseNumber: null, + organizationLocation: null, + organizationZip: null, + organizationCountry: null, + get naturalPersonName() { + return [this.firstname, this.surname].filter((n) => n).join(' '); + }, + getStepsWith: jest.fn().mockReturnValue([]), + }; + } + + function buildStepForWallet(stepWalletAddress: string, opts: { isCompleted?: boolean } = {}): any { + return { + getResult: () => ({ + email: 'signed@example.com', + name: 'Signed Name', + type: 'HUMAN', + phoneNumber: '+41790000000', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'Signed Street 1', + addressPostalCode: '8000', + addressCity: 'Zürich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'DE', + signature: '0xSig', + walletAddress: stepWalletAddress, + registrationDate: '2026-05-21', + }), + isFailed: false, + isCanceled: false, + isCompleted: opts.isCompleted ?? true, + result: 'non-empty', + }; + } + + it('returns state=ALREADY_REGISTERED when a non-failed step for the current wallet exists', () => { + const userData = buildVerifiedUserData(); + userData.getStepsWith.mockReturnValue([buildStepForWallet(walletAddress)]); + + const status = service.getRegistrationInfo(userData, walletAddress); + + expect(status.state).toBe(RealUnitRegistrationState.ALREADY_REGISTERED); + expect(status.isRegistered).toBe(true); + expect(status.userData).toBeDefined(); + expect(status.userData!.email).toBe('signed@example.com'); + 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', () => { + const userData = buildVerifiedUserData(); + userData.getStepsWith.mockReturnValue([buildStepForWallet(otherWalletAddress, { isCompleted: true })]); + + const status = service.getRegistrationInfo(userData, walletAddress); + + expect(status.state).toBe(RealUnitRegistrationState.ADD_WALLET); + expect(status.isRegistered).toBe(false); + expect(status.userData).toBeDefined(); + // userData comes from the existing signed step, not from KYC fallback + expect(status.userData!.email).toBe('signed@example.com'); + expect(status.userData!.name).toBe('Signed Name'); + }); + + it('returns state=NEW_REGISTRATION when no step exists but userData has firstname/surname', () => { + const userData = buildVerifiedUserData(); + + const status = service.getRegistrationInfo(userData, walletAddress); + + expect(status.state).toBe(RealUnitRegistrationState.NEW_REGISTRATION); + expect(status.isRegistered).toBe(false); + expect(status.userData).toBeDefined(); + expect(status.userData!.email).toBe('max@example.com'); + expect(status.userData!.name).toBe('Max Mustermann'); + expect(status.userData!.phoneNumber).toBe('+41791234567'); + expect(status.userData!.birthday).toBe('1990-05-21'); + expect(status.userData!.nationality).toBe('CH'); + expect(status.userData!.addressStreet).toBe('Bahnhofstrasse 1'); + expect(status.userData!.addressPostalCode).toBe('8001'); + expect(status.userData!.addressCity).toBe('Zürich'); + expect(status.userData!.addressCountry).toBe('CH'); + expect(status.userData!.swissTaxResidence).toBe(true); + expect(status.userData!.lang).toBe('DE'); + expect(status.userData!.kycData.firstName).toBe('Max'); + expect(status.userData!.kycData.lastName).toBe('Mustermann'); + }); + + it('returns state=KYC_REQUIRED when no step exists and no KYC data is present', () => { + const userData = { + firstname: null, + surname: null, + getStepsWith: jest.fn().mockReturnValue([]), + } as any; + + const status = service.getRegistrationInfo(userData, walletAddress); + + expect(status.state).toBe(RealUnitRegistrationState.KYC_REQUIRED); + expect(status.isRegistered).toBe(false); + expect(status.userData).toBeUndefined(); + }); + + it('defaults swissTaxResidence to false in NEW_REGISTRATION when the residence country is not CH', () => { + const userData = buildVerifiedUserData(); + userData.country = { id: 2, symbol: 'DE' }; + + const status = 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', () => { + const userData = buildVerifiedUserData(); + userData.language = { symbol: 'ES' }; + + const status = service.getRegistrationInfo(userData, walletAddress); + + expect(status.state).toBe(RealUnitRegistrationState.NEW_REGISTRATION); + expect(status.userData!.lang).toBe('EN'); + }); + }); }); diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index 3718733899..ea66d6c19b 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -50,9 +50,9 @@ import { RealUnitEmailRegistrationResponseDto, RealUnitRegisterWalletDto, RealUnitRegistrationDto, + RealUnitRegistrationInfoDto, RealUnitRegistrationResponseDto, RealUnitRegistrationStatus, - RealUnitWalletStatusDto, } from '../dto/realunit-registration.dto'; import { RealUnitSellBroadcastDto, @@ -246,12 +246,144 @@ export class RealUnitController { return { pdfData }; } - // --- Brokerbot Endpoints --- + // --- Quote Endpoints --- + // Backed by the off-chain Aktionariat REST API (`/directinvestment/getPrice`, 30 s cache). + // The on-chain Brokerbot smart contract is read by the sell-flow routes that anchor a quote + // against live chain state — `PUT /sell`, `PUT /sell/:id/unsigned-transactions`, and + // `PUT /sell/:id/confirm` — see the CONTRIBUTING.md "RealUnit: /quote/* vs /brokerbot/*" + // section for the full table. The legacy `/brokerbot/*` mirror endpoints below are deprecated. + + @Get('quote/info') + @ApiOperation({ + summary: 'Get RealUnit quote info', + description: + 'Returns the REALU spot price together with the on-chain Brokerbot contract addresses (token / base currency / brokerbot). The price values come from the Aktionariat REST API, not from an on-chain read.', + }) + @ApiQuery({ + name: 'currency', + enum: BrokerbotCurrency, + required: false, + description: 'Currency for prices (CHF or EUR)', + }) + @ApiOkResponse({ type: BrokerbotInfoDto }) + async getQuoteInfo(@Query() { currency }: BrokerbotCurrencyQueryDto): Promise { + return this.realunitService.getBrokerbotInfo(currency); + } + + @Get('quote/price') + @ApiOperation({ + summary: 'Get current REALU spot price', + description: + 'Returns the current price per REALU share. Sourced from the Aktionariat REST API (30 s cache); not an on-chain read.', + }) + @ApiQuery({ + name: 'currency', + enum: BrokerbotCurrency, + required: false, + description: 'Currency for prices (CHF or EUR)', + }) + @ApiOkResponse({ type: BrokerbotPriceDto }) + async getQuotePrice(@Query() { currency }: BrokerbotCurrencyQueryDto): Promise { + return this.realunitService.getBrokerbotPrice(currency); + } + + @Get('quote/buyPrice') + @ApiOperation({ + summary: 'Get total fiat cost for a number of shares (buy quote)', + description: 'Returns the total fiat amount needed to buy a specific number of REALU shares.', + }) + @ApiQuery({ name: 'shares', type: Number, description: 'Number of shares to buy' }) + @ApiQuery({ + name: 'currency', + enum: BrokerbotCurrency, + required: false, + description: 'Currency for prices (CHF or EUR)', + }) + @ApiOkResponse({ type: BrokerbotBuyPriceDto }) + async getQuoteBuyPrice( + @Query('shares') shares: number, + @Query() { currency }: BrokerbotCurrencyQueryDto, + ): Promise { + return this.realunitService.getBrokerbotBuyPrice(Number(shares), currency); + } + + @Get('quote/buyShares') + @ApiOperation({ + summary: 'Get shares purchasable for a fiat amount (buy quote)', + description: 'Returns how many REALU shares can be purchased for a given fiat amount.', + }) + @ApiQuery({ name: 'amount', type: String, description: 'Amount in specified currency (e.g., "1000.50")' }) + @ApiQuery({ + name: 'currency', + enum: BrokerbotCurrency, + required: false, + description: 'Currency for prices (CHF or EUR)', + }) + @ApiOkResponse({ type: BrokerbotBuySharesDto }) + async getQuoteBuyShares( + @Query('amount') amount: number, + @Query() { currency }: BrokerbotCurrencyQueryDto, + ): Promise { + return this.realunitService.getBrokerbotBuyShares(amount, currency); + } + + @Get('quote/sellPrice') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Get estimated sell payout for a number of shares (after fees)', + description: + 'Returns the estimated fiat payout when selling a specific number of REALU shares, including user-specific fees.', + }) + @ApiQuery({ name: 'shares', type: Number, description: 'Number of shares to sell' }) + @ApiQuery({ + name: 'currency', + enum: BrokerbotCurrency, + required: false, + description: 'Currency for prices (CHF or EUR)', + }) + @ApiOkResponse({ type: BrokerbotSellPriceDto }) + async getQuoteSellPrice( + @GetJwt() jwt: JwtPayload, + @Query('shares') shares: number, + @Query() { currency }: BrokerbotCurrencyQueryDto, + ): Promise { + const user = await this.userService.getUser(jwt.user, { userData: true }); + return this.realunitService.getBrokerbotSellPrice(user, Number(shares), currency); + } + + @Get('quote/sellShares') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Get shares needed for a target sell payout (after fees)', + description: + 'Returns how many REALU shares need to be sold to receive a target fiat amount after user-specific fees.', + }) + @ApiQuery({ name: 'amount', type: Number, description: 'Target amount to receive after fees (e.g., 1000.50)' }) + @ApiQuery({ + name: 'currency', + enum: BrokerbotCurrency, + required: false, + description: 'Currency for prices (CHF or EUR)', + }) + @ApiOkResponse({ type: BrokerbotSellSharesDto }) + async getQuoteSellShares( + @GetJwt() jwt: JwtPayload, + @Query('amount') amount: number, + @Query() { currency }: BrokerbotCurrencyQueryDto, + ): Promise { + const user = await this.userService.getUser(jwt.user, { userData: true }); + return this.realunitService.getBrokerbotSellShares(user, Number(amount), currency); + } + + // --- Brokerbot Endpoints (deprecated — use the /quote/* mirrors above) --- @Get('brokerbot/info') @ApiOperation({ summary: 'Get Brokerbot info', - description: 'Retrieves general information about the REALU Brokerbot (addresses, settings)', + description: 'Deprecated mirror of `/quote/info`. See that endpoint for the canonical description.', + deprecated: true, }) @ApiQuery({ name: 'currency', @@ -267,7 +399,8 @@ export class RealUnitController { @Get('brokerbot/price') @ApiOperation({ summary: 'Get current Brokerbot price', - description: 'Retrieves the current price per REALU share from the Brokerbot smart contract', + description: 'Deprecated mirror of `/quote/price`. See that endpoint for the canonical description.', + deprecated: true, }) @ApiQuery({ name: 'currency', @@ -283,7 +416,8 @@ export class RealUnitController { @Get('brokerbot/buyPrice') @ApiOperation({ summary: 'Get buy price for shares', - description: 'Calculates the total cost to buy a specific number of REALU shares (includes price increment)', + description: 'Deprecated mirror of `/quote/buyPrice`. See that endpoint for the canonical description.', + deprecated: true, }) @ApiQuery({ name: 'shares', type: Number, description: 'Number of shares to buy' }) @ApiQuery({ @@ -303,7 +437,8 @@ export class RealUnitController { @Get('brokerbot/buyShares') @ApiOperation({ summary: 'Get shares for amount', - description: 'Calculates how many REALU shares can be purchased for a given amount', + description: 'Deprecated mirror of `/quote/buyShares`. See that endpoint for the canonical description.', + deprecated: true, }) @ApiQuery({ name: 'amount', type: String, description: 'Amount in specified currency (e.g., "1000.50")' }) @ApiQuery({ @@ -325,8 +460,8 @@ export class RealUnitController { @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) @ApiOperation({ summary: 'Get sell price for shares including fees', - description: - 'Calculates the estimated payout when selling a specific number of REALU shares, including user-specific fees', + description: 'Deprecated mirror of `/quote/sellPrice`. See that endpoint for the canonical description.', + deprecated: true, }) @ApiQuery({ name: 'shares', type: Number, description: 'Number of shares to sell' }) @ApiQuery({ @@ -350,7 +485,8 @@ export class RealUnitController { @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) @ApiOperation({ summary: 'Get shares needed to receive target amount including fees', - description: 'Calculates how many REALU shares need to be sold to receive a target amount after user-specific fees', + description: 'Deprecated mirror of `/quote/sellShares`. See that endpoint for the canonical description.', + deprecated: true, }) @ApiQuery({ name: 'amount', type: Number, description: 'Target amount to receive after fees (e.g., 1000.50)' }) @ApiQuery({ @@ -468,7 +604,23 @@ export class RealUnitController { return this.realunitService.broadcastSellTransaction(jwt.user, +id, dto); } - // --- Wallet Status Endpoint --- + // --- Registration Info Endpoint --- + + @Get('registration') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Get RealUnit registration info for the connected wallet', + description: + 'Returns the action the client should take to RealUnit-register the connected wallet (`state`), the registration data to pre-fill or display (`userData`), and a legacy `isRegistered` flag. Drives the registration UX: client routes on `state` (AlreadyRegistered / AddWallet / NewRegistration / KycRequired) without inferring it locally.', + }) + @ApiOkResponse({ type: RealUnitRegistrationInfoDto }) + async getRegistrationInfo(@GetJwt() jwt: JwtPayload): Promise { + const user = await this.userService.getUser(jwt.user, { + userData: { kycSteps: true, country: true, nationality: true, organizationCountry: true, language: true }, + }); + return this.realunitService.getRegistrationInfo(user.userData, jwt.address); + } @Get('wallet/status') @ApiBearerAuth() @@ -476,12 +628,15 @@ export class RealUnitController { @ApiOperation({ summary: 'Get wallet status and user data', description: - 'Returns registration status for the connected wallet and user data if available. Can be used to check registration, get data for account merge, or display user profile.', - }) - @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); + 'Deprecated mirror of `GET /v1/realunit/registration`. See that endpoint for the canonical description.', + deprecated: true, + }) + @ApiOkResponse({ type: RealUnitRegistrationInfoDto }) + async getWalletStatus(@GetJwt() jwt: JwtPayload): Promise { + const user = await this.userService.getUser(jwt.user, { + userData: { kycSteps: true, country: true, nationality: true, organizationCountry: true, language: true }, + }); + return this.realunitService.getRegistrationInfo(user.userData, jwt.address); } // --- Registration Endpoints --- diff --git a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts index c6e3feafdc..4e4d5e5522 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts @@ -236,10 +236,28 @@ export class RealUnitUserDataDto { kycData: KycPersonalData; } -export class RealUnitWalletStatusDto { - @ApiProperty({ description: 'Whether the wallet is registered for RealUnit' }) +export enum RealUnitRegistrationState { + ALREADY_REGISTERED = 'AlreadyRegistered', + ADD_WALLET = 'AddWallet', + NEW_REGISTRATION = 'NewRegistration', + KYC_REQUIRED = 'KycRequired', +} + +export class RealUnitRegistrationInfoDto { + @ApiProperty({ + deprecated: true, + description: + 'Whether the wallet is registered for RealUnit. Semantically equivalent to `state === AlreadyRegistered`; kept for backwards compatibility. Prefer `state` for new clients.', + }) isRegistered: boolean; + @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`. `KycRequired`: user must complete DFX KYC first (`userData` not set; edge case).', + }) + state: RealUnitRegistrationState; + @ApiPropertyOptional({ type: RealUnitUserDataDto, description: 'User data if available' }) userData?: RealUnitUserDataDto; } diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index da7d6a161d..583760e8d5 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -75,12 +75,14 @@ import { AktionariatRegistrationDto, RealUnitEmailRegistrationDto, RealUnitEmailRegistrationStatus, + RealUnitLanguage, RealUnitRegisterWalletDto, RealUnitRegistrationDto, + RealUnitRegistrationInfoDto, + RealUnitRegistrationState, RealUnitRegistrationStatus, RealUnitUserDataDto, RealUnitUserType, - RealUnitWalletStatusDto, } from './dto/realunit-registration.dto'; import { RealUnitSellBroadcastDto, @@ -643,12 +645,41 @@ export class RealUnitService { // --- Wallet Methods --- - getAddressWalletStatus(userData: UserData, walletAddress: string): RealUnitWalletStatusDto { + getRegistrationInfo(userData: UserData, walletAddress: string): RealUnitRegistrationInfoDto { const { step, isForCurrentWallet } = this.findRegistrationStep(userData, walletAddress); + // Dispatch to one of four states so the client can route to the right UX without inferring + // it locally. Order matters: a registration step for the current wallet (ALREADY_REGISTERED) + // wins over any other signal; a step for a different wallet drives the one-tap Add-Wallet + // flow (ADD_WALLET); otherwise we pre-fill the full form from existing KYC data when + // available (NEW_REGISTRATION), falling back to KYC_REQUIRED when no usable data exists. + if (step) { + const stepUserData = this.toUserDataDto(step); + const state = isForCurrentWallet + ? RealUnitRegistrationState.ALREADY_REGISTERED + : RealUnitRegistrationState.ADD_WALLET; + return { + isRegistered: state === RealUnitRegistrationState.ALREADY_REGISTERED, + state, + userData: stepUserData, + }; + } + + // No step exists. Pre-fill from DFX KYC data (firstname/surname guarded by + // toUserDataDtoFromUserData) and fall through to KYC_REQUIRED when that returns undefined. + const prefill = this.toUserDataDtoFromUserData(userData); + if (prefill) { + return { + isRegistered: false, + state: RealUnitRegistrationState.NEW_REGISTRATION, + userData: prefill, + }; + } + return { - isRegistered: isForCurrentWallet, - userData: this.toUserDataDto(step), + isRegistered: false, + state: RealUnitRegistrationState.KYC_REQUIRED, + userData: undefined, }; } @@ -940,6 +971,61 @@ export class RealUnitService { return userDataDto as RealUnitUserDataDto; } + // Pre-fill source for first-time RealUnit registrations: maps the user's existing DFX KYC data into + // the Aktionariat-shaped DTO. The corresponding `completeRegistration` validation + // (`isPersonalDataMatching`) compares the submitted KycPersonalData/address against the same + // user_data fields, so the values returned here are guaranteed to pass that check. + private toUserDataDtoFromUserData(userData: UserData): RealUnitUserDataDto | undefined { + // Without verified personal data there is nothing useful to pre-fill — the app will continue to + // collect every field manually. + if (!userData.firstname && !userData.surname) return undefined; + + const lang = Object.values(RealUnitLanguage).find((l) => l === userData.language?.symbol?.toUpperCase()); + const addressStreet = [userData.street, userData.houseNumber].filter((s) => s).join(' '); + const tinEntries: { country: string; tin: string }[] = userData.tin ? JSON.parse(userData.tin) : []; + + return { + email: userData.mail ?? '', + name: userData.naturalPersonName ?? '', + type: RealUnitUserType.HUMAN, + phoneNumber: userData.phone ?? '', + birthday: userData.birthday ? Util.isoDate(userData.birthday) : '', + nationality: userData.nationality?.symbol ?? '', + addressStreet, + addressPostalCode: userData.zip ?? '', + addressCity: userData.location ?? '', + addressCountry: userData.country?.symbol ?? '', + // Swiss tax residence cannot be derived from KYC data alone; default to the country-of-residence + // signal so a CH-resident pre-fills the common case. The user can still override before signing. + swissTaxResidence: userData.country?.symbol === 'CH', + lang: lang ?? RealUnitLanguage.EN, + countryAndTINs: tinEntries.length ? tinEntries : undefined, + kycData: { + accountType: userData.accountType ?? AccountType.PERSONAL, + firstName: userData.firstname ?? '', + lastName: userData.surname ?? '', + phone: userData.phone ?? '', + address: { + street: userData.street ?? '', + houseNumber: userData.houseNumber, + city: userData.location ?? '', + zip: userData.zip ?? '', + country: userData.country!, + }, + organizationName: userData.organizationName ?? undefined, + organizationAddress: userData.organizationCountry + ? { + street: userData.organizationStreet ?? '', + houseNumber: userData.organizationHouseNumber, + city: userData.organizationLocation ?? '', + zip: userData.organizationZip ?? '', + country: userData.organizationCountry, + } + : undefined, + }, + }; + } + private isPersonalDataMatching(userData: UserData, dto: RealUnitRegistrationDto): boolean { const kycData = dto.kycData; // Transliterate both sides: legacy rows still hold ASCII (pre-fix), new rows hold UTF-8.