Problem
The RealUnit-Aktionariat registration is per-wallet, not per-account: one user_data can hold multiple user rows (one per wallet), and the entry into the Aktionariat share registry has to happen per wallet. Today this per-wallet relation is squeezed into a generic kyc_step row whose result column holds the full RealUnitRegistrationDto JSON, with the wallet address as a string field inside that blob.
Concretely, RealUnitService.findRegistrationStep(userData, walletAddress) (realunit.service.ts:865) implements the lookup like this:
```ts
const allSteps = userData.getStepsWith(KycStepName.REALUNIT_REGISTRATION);
const currentWalletStep = allSteps
.filter((s) => !(s.isFailed || s.isCanceled))
.find((s) => {
const result = s.getResult();
return result?.walletAddress && Util.equalsIgnoreCase(result.walletAddress, walletAddress);
});
```
Why this is wrong
- No referential integrity. If a `user` row is deleted or migrated, the JSON-encoded `walletAddress` inside the blob becomes a dangling pointer. The DB has no idea it ever pointed at anything.
- No DB-level index. Every wallet-scoped lookup loads all `kyc_step` rows of a `user_data`, deserialises the JSON, and string-compares in application code. O(n) instead of indexed equality.
- Case-insensitive string match. `Util.equalsIgnoreCase(result.walletAddress, walletAddress)` only exists because Ethereum addresses arrive in mixed-case checksum form and the JSON keeps the string the registration was originally submitted with. A numeric FK to `user.id` would eliminate this entire class of bug.
- Schema-blind. Nothing in the DB schema reveals that this step subtype is bound to a specific wallet. Migration tooling, ORM relations, query planners are all blind to it.
- Joins impossible. "All RealUnit registrations grouped by wallet" or "all wallets of user X that are registered" cannot be expressed as SQL — they require application-side iteration.
- Lifecycle mismatch. RealUnit registrations are forwarded to an external party (Aktionariat) and have a different status lifecycle (`COMPLETED` / `PENDING_REVIEW` / `FORWARDING_FAILED` / `ALREADY_REGISTERED`) than the generic kyc_step ReviewStatus enum. Squeezing the two into one column produces ad-hoc state translations everywhere.
Proposed direction
Promote RealUnit registration to a first-class entity with a proper relational shape. Sketch:
```
realunit_registration
id PK
userId FK → user.id NOT NULL (= the registered wallet)
userDataId FK → user_data.id NOT NULL (= the natural person / account)
signature text NOT NULL
registrationDate date NOT NULL
status enum (COMPLETED, PENDING_REVIEW, FORWARDING_FAILED, …)
aktionariatRequestId / externalRef -- for forwarding-failed retry, audit
signedPayload jsonb -- the kycData/Aktionariat fields, for compliance audit
created / updated
```
`findRegistrationStep` becomes a single `SELECT … WHERE userId = ?`. The case-insensitive string compare disappears. The KycStep-side of things either:
- (a) keeps a generic `RealUnitRegistration` KycStep row whose `result` only carries a foreign key `{ realunitRegistrationId: number }` — for backward compatibility with the existing KYC step-iteration plumbing, or
- (b) removes the per-wallet `RealUnitRegistration` KycStep entirely and lets the `user_data` declare "fully KYC'd" without it; the realunit-buy gate (`hasRegistrationForWallet`) reads from the new table directly.
Either way, the `AktionariatRegistrationDto` JSON inside `kyc_step.result` goes away as the source of truth for "is this wallet registered".
Why this is its own issue / PR
The pre-fill work currently in #3782 (server) + RealUnitCH/app#600 (client) is a tactical UX fix on top of the existing structure. The structural change proposed here is a separate concern — it would interact with the migration backlog, with the realunit-app's `KycRegistrationView` flow, and with how the `/v1/realunit/wallet/status` and `/v1/realunit/register/status` endpoints answer "is THIS wallet registered". Doing both in one PR muddies both reviews.
Out of scope
- Renaming `KycStepName.REALUNIT_REGISTRATION` (the user-facing step concept can stay)
- Aktionariat forwarding semantics (no change to the external API contract)
- Other KycStep subtypes that legitimately are per-userData (ContactData, PersonalData, NationalityData, Ident, FinancialData, DfxApproval). Those stay JSON-result-based — they are per-account by nature.
Acceptance criteria
- New `realunit_registration` entity + table + migration
- `findRegistrationStep` / `hasRegistrationForWallet` / `getAddressWalletStatus` rewired to use the new table; no JSON parsing in the lookup path
- Backfill migration that converts existing `kyc_step` rows with `name = 'RealUnitRegistration'` into rows in the new table
- All four idempotency / merge / re-sign flows verified (`completeRegistration`, `completeRegistrationForWalletAddress`, `idempotentRegistrationResult`, the `ALREADY_REGISTERED` path)
- Client-facing endpoints (`/v1/realunit/wallet/status`, `/v1/realunit/register/status`) return identical JSON shapes — no breaking change for realunit-app
Problem
The RealUnit-Aktionariat registration is per-wallet, not per-account: one user_data can hold multiple
userrows (one per wallet), and the entry into the Aktionariat share registry has to happen per wallet. Today this per-wallet relation is squeezed into a generickyc_steprow whoseresultcolumn holds the fullRealUnitRegistrationDtoJSON, with the wallet address as a string field inside that blob.Concretely,
RealUnitService.findRegistrationStep(userData, walletAddress)(realunit.service.ts:865) implements the lookup like this:```ts
const allSteps = userData.getStepsWith(KycStepName.REALUNIT_REGISTRATION);
const currentWalletStep = allSteps
.filter((s) => !(s.isFailed || s.isCanceled))
.find((s) => {
const result = s.getResult();
return result?.walletAddress && Util.equalsIgnoreCase(result.walletAddress, walletAddress);
});
```
Why this is wrong
Proposed direction
Promote RealUnit registration to a first-class entity with a proper relational shape. Sketch:
```
realunit_registration
id PK
userId FK → user.id NOT NULL (= the registered wallet)
userDataId FK → user_data.id NOT NULL (= the natural person / account)
signature text NOT NULL
registrationDate date NOT NULL
status enum (COMPLETED, PENDING_REVIEW, FORWARDING_FAILED, …)
aktionariatRequestId / externalRef -- for forwarding-failed retry, audit
signedPayload jsonb -- the kycData/Aktionariat fields, for compliance audit
created / updated
```
`findRegistrationStep` becomes a single `SELECT … WHERE userId = ?`. The case-insensitive string compare disappears. The KycStep-side of things either:
Either way, the `AktionariatRegistrationDto` JSON inside `kyc_step.result` goes away as the source of truth for "is this wallet registered".
Why this is its own issue / PR
The pre-fill work currently in #3782 (server) + RealUnitCH/app#600 (client) is a tactical UX fix on top of the existing structure. The structural change proposed here is a separate concern — it would interact with the migration backlog, with the realunit-app's `KycRegistrationView` flow, and with how the `/v1/realunit/wallet/status` and `/v1/realunit/register/status` endpoints answer "is THIS wallet registered". Doing both in one PR muddies both reviews.
Out of scope
Acceptance criteria