diff --git a/.coverage-visibility-allowlist b/.coverage-visibility-allowlist
index e183db1b5..0b54e96ad 100644
--- a/.coverage-visibility-allowlist
+++ b/.coverage-visibility-allowlist
@@ -14,6 +14,7 @@
# Abstract interfaces / ports (implementations live elsewhere and are tested):
lib/packages/io/backup_exclusion_port.dart
lib/packages/io/documents_directory_port.dart
+lib/packages/io/install_referrer_port.dart
lib/packages/service/biometric/biometric_port.dart
lib/packages/service/price_service.dart
lib/screens/kyc/steps/ident/cubits/kyc_ident/sumsub_ident_port.dart
diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml
index 797ed36fd..89c0547d7 100644
--- a/.github/workflows/handbook-build-check.yaml
+++ b/.github/workflows/handbook-build-check.yaml
@@ -33,6 +33,10 @@ name: Handbook Build Check
# - scripts/assemble-handbook-screenshots.sh handbook→Golden mapping
# - test/goldens/** Golden baselines (source of every
# screenshot the handbook serves)
+# - scripts/assemble-handbook-store-listing.py, scripts/assemble-handbook-legal.py
+# store-listing / legal-downloads generators
+# - ios/fastlane/**, android/fastlane/metadata/**, assets/legal/**, assets/languages/**
+# store texts, screenshots and legal documents
# - Dockerfile.handbook multi-stage build
# - handbook.nginx.conf nginx config
# - handbook.htpasswd access gate
@@ -92,8 +96,8 @@ jobs:
set -euo pipefail
bash scripts/assemble-handbook-screenshots.sh /tmp/handbook-shots
count=$(ls -1 /tmp/handbook-shots/*.png | wc -l | tr -d ' ')
- if [ "$count" != "278" ]; then
- echo "expected 278 screenshots, got $count" >&2
+ if [ "$count" != "301" ]; then
+ echo "expected 301 screenshots, got $count" >&2
exit 1
fi
@@ -173,15 +177,13 @@ jobs:
exit 1
fi
- # Screenshots dir must contain all 278 PNGs assembled from Goldens.
- # Hit one of them through the auth gate to verify wiring end-to-end.
- # Mix of the original 01-61 range and the 62-269 batch (every Golden
- # baseline) so a regression in either half surfaces here.
- for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked; do
- code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/screenshots/${name}.png")
- # 200 (auth happens to match) or 401 (auth fails but file exists)
- # both prove the file is on disk. 404 means it was not assembled.
- if [ "$code" = "404" ]; then
+ # Spot-check handbook screenshots assembled from Goldens (301 PNGs).
+ # Mix of original 01-61 slots, later Golden batches, and referral
+ # baselines so a regression in any range surfaces here.
+ for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked 270-handbook-persona-dca 275-handbook-persona-exit-1j 276-referral-overview 282-referral-payout-row 283-referral-terms-readonly 287-kyc-registration-referral-recognized 288-transaction-history-referral-payout 289-dashboard-referral-entry-and-payout 290-kyc-registration-referral-promo 291-referral-bind-error-invalid 292-referral-bind-error-already-registered; do
+ # Auth is checked before the file exists: a missing PNG also
+ # returns 401, so HTTP status cannot prove assembly. Look on disk.
+ if ! docker exec handbook test -f "/usr/share/nginx/html/screenshots/${name}.png"; then
echo "screenshot ${name}.png missing from /usr/share/nginx/html/screenshots/" >&2
docker logs handbook
exit 1
@@ -191,9 +193,8 @@ jobs:
# Legal downloads must be present (built by the legal-docs-builder
# stage via pandoc). Same 200/401-vs-404 logic: 404 means the PDF/DOCX
# was not produced. Covers PDF + DOCX and both languages.
- for f in legal/privacy_policy_de.pdf legal/privacy_policy_de.docx legal/terms_of_use_en.pdf legal/registration_agreement_de.docx; do
- code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/${f}")
- if [ "$code" = "404" ]; then
+ for f in legal/privacy_policy_de.pdf legal/privacy_policy_de.docx legal/terms_of_use_en.pdf legal/registration_agreement_de.docx legal/referral_terms_de.pdf legal/referral_terms_en.pdf legal/referral_terms_de.docx legal/referral_terms_en.docx; do
+ if ! docker exec handbook test -f "/usr/share/nginx/html/${f}"; then
echo "legal download ${f} missing from /usr/share/nginx/html/legal/" >&2
docker logs handbook
exit 1
diff --git a/.github/workflows/handbook.yaml b/.github/workflows/handbook.yaml
index f45eb014e..ae67c62cc 100644
--- a/.github/workflows/handbook.yaml
+++ b/.github/workflows/handbook.yaml
@@ -121,19 +121,20 @@ jobs:
env:
API_DIR: _api-checkout
HANDLEBARS_PREFIX: _handlebars-only
- # Exact expected mail-preview file count: 24 mails + 1 index = 25.
- # `-ne` below makes ANY drift (drop OR addition) fail the build —
- # adding a new mail upstream therefore requires an explicit edit
- # here, which is the whole point: a silently-added (or silently-
- # removed) mail must never reach the handbook image undetected.
- # When you legitimately add/remove a mail in
- # DFXswiss/api/scripts/generate-realunit-previews.js, bump this
- # number in the SAME PR as the api-side change-PR (or its
- # follow-up). The exact count is computed as:
- # N(add() calls in the generator)
+ # Exact expected mail-preview file count: 22 standalone add() mails
+ # + 3 pendingTypes expansions + 1 index = 26. `-ne` below makes ANY
+ # drift (drop OR addition) fail the build — adding a new mail
+ # upstream therefore requires an explicit edit here, which is the
+ # whole point: a silently-added (or silently-removed) mail must
+ # never reach the handbook image undetected. When you legitimately
+ # add/remove a mail in DFXswiss/api/scripts/generate-realunit-previews.js,
+ # bump this number in the SAME PR as the api-side change-PR (or
+ # its follow-up). The exact count is computed as:
+ # N(standalone add() calls in the generator; the loop template
+ # is not counted here)
# + N(pendingTypes entries the for-loop expands)
# + 1 (00_index.html)
- EXPECTED_HTML_COUNT: 25
+ EXPECTED_HTML_COUNT: 26
run: |
set -euo pipefail
@@ -278,7 +279,7 @@ jobs:
# Like the receipt examples, these PDFs are already committed in the api
# repo (rendered by BalancePdfService via the api-side spec
- # realunit-balance-example.spec.ts), so no generator / npm run is needed
+ # realunit-statement-example.spec.ts), so no generator / npm run is needed
# here — we only copy them into the build context. Reuses the same
# _api-checkout/ from the mail step above. NOTE the SEPARATE dest dir
# (docs/handbook/balance/, not receipts/) so the receipt step's
@@ -328,13 +329,13 @@ jobs:
- name: Stage web e2e baselines from web repo
env:
WEB_DIR: _web-checkout
- # RealUnitCH/web commits 36 Playwright visual baselines under
+ # RealUnitCH/web commits 146 Playwright visual baselines under
# tests/__screenshots__/{desktop-chromium,tablet-chromium,mobile-safari}/.
# `-ne` below fails the build on ANY drift (add OR remove) so a change
# to the committed set upstream requires an explicit bump here AND a
# matching card edit in docs/handbook/de/index.html (#spec-web) in the
# SAME change — same guard rationale as the api steps above.
- EXPECTED_WEB_BASELINE_COUNT: 36
+ EXPECTED_WEB_BASELINE_COUNT: 146
run: |
set -euo pipefail
diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml
index d596f6ce8..ec6e3d453 100644
--- a/.github/workflows/pull-request.yaml
+++ b/.github/workflows/pull-request.yaml
@@ -63,6 +63,7 @@ jobs:
- run: dart run tool/generate_localization.dart
- run: dart run tool/generate_release_info.dart
- run: flutter pub run build_runner build
+ - run: bash scripts/run-handbook-flows.sh --matcher-self-test
- run: flutter analyze
# Excludes the `golden` tag: visual-regression tests live under
# `test/goldens/` and are validated on the self-hosted runner
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 7fdbe341a..9cd8b4d01 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -108,6 +108,8 @@ jobs:
java-version: "17"
- uses: android-actions/setup-android@v4
+ with:
+ packages: platform-tools
- name: Set up Flutter
uses: subosito/flutter-action@v2
diff --git a/.github/workflows/tier3-handbook.yaml b/.github/workflows/tier3-handbook.yaml
index d2d40832d..00f36fb28 100644
--- a/.github/workflows/tier3-handbook.yaml
+++ b/.github/workflows/tier3-handbook.yaml
@@ -14,7 +14,12 @@ name: Tier 3 — Handbook flows
# the existing Analyze & Test job. Reviewers opt-in by labelling
# the PR. The label gate is the cost control, not the branch
# filter — stacked PRs against integration branches need the same
-# opt-in option.
+# opt-in option. Types are `opened` / `synchronize` / `reopened` /
+# `labeled` / `unlabeled`. `ready_for_review` is omitted on purpose:
+# a labelled draft that is later marked ready must not start a
+# second macOS run — the labelled run already covers that SHA.
+# (Contrast `pull-request.yaml`, where draft-skip means becoming
+# ready is the first real run and `ready_for_review` belongs there.)
# * `push: develop` always runs — post-merge verification is the
# authoritative source of truth for "did the handbook flows survive
# this merge", independent of any PR state. Same reasoning as
@@ -22,9 +27,11 @@ name: Tier 3 — Handbook flows
# * `workflow_dispatch` for manual catch-up runs and a re-run path when
# the macOS image flips an Xcode default and we need to verify the fix
# before re-arming the gate. Always runs (label gate doesn't apply).
-# * `labeled` / `unlabeled` keep the trigger surface in sync with the
-# `tier3:full` opt-in: adding the label after the PR opened starts a
-# run; removing it cancels via the concurrency block below.
+# * `labeled` / `unlabeled` stay in `types` so GitHub delivers the
+# opt-in events. Adding `tier3:full` starts a run (job `if` matches
+# that label name). Removing it does not start a run and does not
+# cancel an in-flight one — labeled events use a unique concurrency
+# group, so an unlabeled skip cannot stop the flow already running.
#
# Concurrency:
# Same pattern as `pull-request.yaml`: group by `pull_request.number`
@@ -36,7 +43,10 @@ name: Tier 3 — Handbook flows
# get their own `ci--label-` group so a label toggle
# does NOT kill an already-running ~15-minute flow execution — the user
# experience of "I added the label, the previous run died" is worse
-# than the cost of one extra runner-minute.
+# than the cost of one extra runner-minute. That unique group is also
+# why omitting `ready_for_review` matters: a later ready event would
+# join the PR-number group and would not cancel the in-flight labeled
+# run, so both would race on macos-latest.
#
# Locale: scripts/run-handbook-flows.sh pins the booted simulator to de_CH
# so German handbook assertions pass on `macos-latest` runners (default
@@ -66,8 +76,11 @@ name: Tier 3 — Handbook flows
# as a safety net for that residual crash class, retrying the
# driver hang/death class against both the CLI tee-log and
# `--debug-output` maestro.log (`IOSDriverTimeoutException`,
-# ConnectException on :7001, Connection refused/reset) —
-# assertion failures without those patterns are never retried.
+# ConnectException on :7001, Connection refused/reset,
+# UnknownFailure HTTP 500 on :7001/deviceInfo). A hung JVM after
+# that 500 is killed per attempt (`MAESTRO_ATTEMPT_TIMEOUT_SEC`)
+# so the 60-minute job does not sit until GitHub cancels it.
+# Assertion failures without those patterns are never retried.
# The post-mortem block (lsof / ps / simctl log + Maestro
# `--debug-output`) captures failure state so future regressions
# can be diagnosed forensically. The CI-hardening track that landed
@@ -110,7 +123,7 @@ on:
# covered by `push: develop` on the same SHA — see the longer
# rationale in `pull-request.yaml`).
branches-ignore: [main]
- types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
+ types: [opened, synchronize, reopened, labeled, unlabeled]
concurrency:
group: >-
@@ -131,9 +144,19 @@ jobs:
# Drafts pass through unconditionally on push (they cannot push, so
# the only way here is via a labelled PR) — no extra `draft == false`
# guard needed.
+ # `labeled` must match the opt-in label itself: with only `contains(...)`,
+ # adding any other label on an already-labelled PR would start a second
+ # macos-latest run (labeled events use a unique concurrency group and
+ # would not cancel the in-flight one). `unlabeled` is skipped here —
+ # the unique group also means it cannot cancel an in-flight flow.
if: >-
github.event_name != 'pull_request'
- || contains(github.event.pull_request.labels.*.name, 'tier3:full')
+ || (github.event.action == 'labeled' && github.event.label.name == 'tier3:full')
+ || (
+ github.event.action != 'labeled'
+ && github.event.action != 'unlabeled'
+ && contains(github.event.pull_request.labels.*.name, 'tier3:full')
+ )
runs-on: macos-latest
# ~12 min setup (Flutter + tooling, build, sim erase/boot/install) plus
# ~26-33 min for the 26 handbook flows — each flow restarts the XCUITest
diff --git a/.maestro/handbook/11-dashboard.yaml b/.maestro/handbook/11-dashboard.yaml
index a24a2bd08..85b04f5b7 100644
--- a/.maestro/handbook/11-dashboard.yaml
+++ b/.maestro/handbook/11-dashboard.yaml
@@ -35,3 +35,9 @@ appId: swiss.realunit.app
- extendedWaitUntil:
visible: 'RealUnit kaufen'
timeout: 30000
+# Fresh handbook wallet is not a verified shareholder with 70 REALU
+# (TB Ziff. 2, analog Verkaufen). The dashboard card must stay off this
+# screen; 289-dashboard-referral-entry-and-payout is golden-only.
+- extendedWaitUntil:
+ notVisible: 'Erhalte 20 REALU pro Weiterempfehlung'
+ timeout: 10000
diff --git a/.maestro/handbook/12-settings.yaml b/.maestro/handbook/12-settings.yaml
index 2491e4850..f33254f54 100644
--- a/.maestro/handbook/12-settings.yaml
+++ b/.maestro/handbook/12-settings.yaml
@@ -24,3 +24,13 @@ appId: swiss.realunit.app
- extendedWaitUntil:
visible: 'Einstellungen'
timeout: 30000
+# Same gate as the dashboard card. Wait for a settings tile that does not
+# depend on the referral API, then assert Empfehlungen is still absent so
+# a late summary 200 cannot have opened the tile on a fresh wallet.
+- extendedWaitUntil:
+ visible:
+ text: '.*Rechtsdokumente.*'
+ timeout: 15000
+- extendedWaitUntil:
+ notVisible: 'Empfehlungen'
+ timeout: 8000
diff --git a/.maestro/handbook/20-settings-legal-documents.yaml b/.maestro/handbook/20-settings-legal-documents.yaml
index d75298723..e11b27811 100644
--- a/.maestro/handbook/20-settings-legal-documents.yaml
+++ b/.maestro/handbook/20-settings-legal-documents.yaml
@@ -39,3 +39,20 @@ appId: swiss.realunit.app
visible:
text: '.*Dokumente der Aktionariat AG.*'
timeout: 30000
+# Last tile is the 14.08 Teilnahmebedingungen (Ziff. 2–11). On a phone it can
+# sit below the fold, so scroll it into view, then restore Aktionariat so
+# flow 21 can tap that entry.
+- scrollUntilVisible:
+ element:
+ text: '.*Teilnahmebedingungen Referral-Programm.*'
+ direction: DOWN
+ timeout: 15000
+- extendedWaitUntil:
+ visible:
+ text: '.*Teilnahmebedingungen Referral-Programm.*'
+ timeout: 10000
+- scrollUntilVisible:
+ element:
+ text: '.*Dokumente der Aktionariat AG.*'
+ direction: UP
+ timeout: 15000
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index da7f04aac..58765c8ca 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -33,6 +33,25 @@ The app must stay fully usable on every **standard phone** we support (smallest
New sticky-CTA UI without `ScrollableActionsLayout` + matrix entry is a **blocking** review finding.
+## Scanner navigation — CRITICAL
+
+[`QrScannerView`](lib/widgets/scanner/qr_scanner_view.dart) / `MobileScanner` forwards **every camera frame** while a barcode is in view — it is not a one-shot capture.
+
+**Navigation rule**
+
+- Navigating from a scan result in the same turn as `cubit.reset()` is **forbidden**: it drops the cubit's "already decoded" / Valid guard, so the next frame pushes a second copy of the next route. The user ends up on two stacked amount (or quote) screens and cannot leave cleanly.
+- Production contract: every `QrScannerView` consumer uses [`pushThenRearm`](lib/widgets/scanner/push_then_rearm.dart) to push the next route. Rearm / `reset` runs after `Route.completed` (the outgoing animation is gone) or immediately if the push throws — **never** in the same listener turn. In this app a scanner that does not push a route is not a valid consumer — add it to the catalog with `pushThenRearm` or do not construct `QrScannerView`.
+- Cubit `onCodeDetected` must ignore further detections while the decoded/valid state is held. That guard is **necessary and not sufficient** — without `pushThenRearm`, an early `reset()` makes it a no-op.
+
+**Test rule (gate for this bug class)**
+
+- Catalog: [`test/helper/scanner_navigation_catalog.dart`](test/helper/scanner_navigation_catalog.dart).
+- Self-test: [`scanner_navigation_catalog_test.dart`](test/helper/scanner_navigation_catalog_test.dart) — every catalogued production + regression path exists; production files contain `QrScannerView(` **and** `pushThenRearm(`; regression files fire `BarcodeCapture` and assert the destination `findsOne`; **discovery** walks `lib/` and fails if any `QrScannerView(` consumer is missing from the catalog (skipping the widget definition file itself).
+- Each surface has a real-cubit widget test that fires **two** `BarcodeCapture`s and expects the destination `findsOne`, then pop, then a third capture is accepted again. Mocking the cubit / `whenListen` of a single Valid/Decoded state cannot catch this bug class.
+- Details and PR checklist: [`docs/testing.md`](docs/testing.md) § *Scanner navigation*.
+
+New `QrScannerView` consumer without catalog entry + `pushThenRearm` + double-capture test is a **blocking** review finding.
+
## Branch Flow
Three branches participate in the release lane:
@@ -229,7 +248,7 @@ The app supports three wallet modes (`software`, `bitbox`, `debug`) with differe
```bash
rg "^//\s*@no-integration-test:" lib/
```
-- Visual-regression Goldens under `test/goldens/screens/` are also the source of the 26 screenshots served at `handbook.realunit.app`. When you add a handbook page, you MUST add a matching Golden test AND a row in the mapping table at `scripts/assemble-handbook-screenshots.sh` — the handbook will not pick up a Maestro-captured PNG anymore. The `Handbook Build Check` workflow on every PR runs the assembly script and fails loudly if a mapped Golden is missing.
+- Visual-regression Goldens under `test/goldens/screens/` and `test/goldens/widgets/` are also the source of the 301 screenshots served at `handbook.realunit.app`. When you add a handbook page, you MUST add a matching Golden test AND a row in the mapping table at `scripts/assemble-handbook-screenshots.sh` — the handbook will not pick up a Maestro-captured PNG anymore. The `Handbook Build Check` workflow on every PR runs the assembly script and fails loudly if a mapped Golden is missing.
- Why: single source of truth — a UI regression that breaks a Golden also breaks the handbook image before either ships; eliminates the previous "two pipelines, two truths" problem.
- See: [`docs/visual-regression-tests.md`](docs/visual-regression-tests.md) section "Handbook screenshots are sourced from Goldens".
diff --git a/Dockerfile.handbook b/Dockerfile.handbook
index a312bc852..b66ea0514 100644
--- a/Dockerfile.handbook
+++ b/Dockerfile.handbook
@@ -1,14 +1,15 @@
# syntax=docker/dockerfile:1
#
# Static nginx host for docs/handbook/ — served at handbook.realunit.app (PRD)
-# and dev-handbook.realunit.app (DEV) via Cloudflare Tunnel.
+# via Cloudflare Tunnel; the former DEV instance is retired.
#
# Built independently of the Flutter app: no Flutter toolchain, no app code.
-# Build context is the repo root; only docs/handbook/, scripts/, and
-# test/goldens/ are copied in.
+# Build context is the repo root; docs/handbook/, scripts/, test/goldens/,
+# ios/fastlane/{metadata,screenshots}, android/fastlane/metadata, assets/legal,
+# assets/languages, handbook.nginx.conf and handbook.htpasswd are copied in.
#
# The handbook screenshots (`screenshots/NN-name.png`) are assembled from the
-# visual-regression Golden baselines under `test/goldens/screens/` via
+# visual-regression Golden baselines under `test/goldens/screens/` and `test/goldens/widgets/` via
# scripts/assemble-handbook-screenshots.sh — one Golden per handbook
# entry, see the mapping in that script. `docs/handbook/screenshots/` is
# git-ignored (the directory holds the assembly output for local previews
@@ -57,7 +58,7 @@ COPY docs/handbook/de/index.html ./docs/handbook/de/index.html
RUN python3 ./scripts/assemble-handbook-store-listing.py /out && cp ./docs/handbook/de/index.html /out/index.html
# Legal-downloads section: derived export of the in-app legal Markdown
-# (assets/legal/*.md). Two separate concerns, by design:
+# (assets/legal/*.md, including referral_terms). Two separate concerns, by design:
# - assemble-handbook-legal.py rewrites the deterministic block in index.html (committed + sync-gated upstream).
# - build-legal-downloads.sh renders the NON-deterministic PDF/DOCX via pandoc
diff --git a/README.md b/README.md
index c00569002..ed86394f6 100644
--- a/README.md
+++ b/README.md
@@ -158,7 +158,7 @@ Non-BitBox code only needs Tier 0 + widget tests; Tier 1+ are reserved for hardw
| Coverage | `flutter test --coverage` | Writes `coverage/lcov.info`. CI narrows it to the activated surface and hard-fails when scoped coverage drops below the floor in `.coverage-floor-lines` / `.coverage-floor-functions`. See "Coverage infrastructure roadmap" above for the ratchet protocol. |
| Analyzer | `flutter analyze` | Dart static analysis per `analysis_options.yaml` |
-Tier 1 specs live under `test/integration/**` and run inside the same `flutter test --coverage` invocation as Tier 0 — no separate `integration_test/` harness today (that Flutter-convention directory is reserved for on-device runs that are not yet wired up). Tier 3 handbook flows (iOS Simulator) are wired via [`tier3-handbook.yaml`](.github/workflows/tier3-handbook.yaml); the BitBox02 hardware variant remains deferred.
+Tier 1 specs live under `test/integration/**` and run inside the same `flutter test --coverage` invocation as Tier 0 — no separate `integration_test/` harness today (that Flutter-convention directory is reserved for on-device runs that are not yet wired up). Referral widget flows live under `test/screens/referral/` (including the former `integration_test/referral_*_e2e_test.dart` specs, now at `test/screens/referral/flows/`) and run in that same coverage invocation. Tier 3 handbook flows (iOS Simulator) are wired via [`tier3-handbook.yaml`](.github/workflows/tier3-handbook.yaml); the BitBox02 hardware variant remains deferred.
## CI/CD
diff --git a/analysis_options.yaml b/analysis_options.yaml
index e9380ff99..85661df66 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -10,3 +10,9 @@ linter:
avoid_print: true
prefer_const_constructors: true
prefer_single_quotes: true
+analyzer:
+ exclude:
+ - build/**
+ - android/**
+ - ios/**
+ - macos/**
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 0a1b23b87..d6e33b5cb 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -14,7 +14,7 @@ if (keystorePropertiesFile.exists()) {
android {
namespace = "swiss.realunit.app"
compileSdk = 36
- ndkVersion = "29.0.13113456 rc1"
+ ndkVersion = "29.0.13113456"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
@@ -64,3 +64,7 @@ android {
flutter {
source = "../.."
}
+
+dependencies {
+ implementation "com.android.installreferrer:installreferrer:2.2"
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index 53960b220..9e594a142 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -17,5 +17,8 @@
-keep class com.google.android.gms.tasks.** { *; }
# SumSub SDK general keep rules
--keep class com.sumsub.** { *; }
--dontwarn com.sumsub.**
+-keep class com.sumsub.** { *; }
+-dontwarn com.sumsub.**
+
+# Play Install Referrer (invite code across a fresh Android install)
+-keep class com.android.installreferrer.** { *; }
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
index 399f6981d..2aa4793da 100644
--- a/android/app/src/debug/AndroidManifest.xml
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -4,4 +4,32 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a07818b21..ad11633a0 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -47,13 +47,70 @@
realunit-wallet://open). Opening the app via the scheme only
foregrounds it; go_router redirects any scheme URL to the normal
entry. A custom scheme needs no autoVerify / Digital Asset
- Links. -->
+ Links. Multiple in one filter are sets (scheme × host ×
+ path), not tuples: a pathPrefix here would require a path and
+ drop realunit-wallet://open. Opaque URIs (lightning, invite/code
+ without //) have no host and need a scheme-only filter. -->
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/swiss/dfx/realunit/MainActivity.kt b/android/app/src/main/kotlin/swiss/dfx/realunit/MainActivity.kt
index 5f60b31ab..6d0a79578 100644
--- a/android/app/src/main/kotlin/swiss/dfx/realunit/MainActivity.kt
+++ b/android/app/src/main/kotlin/swiss/dfx/realunit/MainActivity.kt
@@ -1,5 +1,64 @@
package swiss.realunit.app
+import android.os.Handler
+import android.os.Looper
+import com.android.installreferrer.api.InstallReferrerClient
+import com.android.installreferrer.api.InstallReferrerStateListener
import io.flutter.embedding.android.FlutterFragmentActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodChannel
-class MainActivity: FlutterFragmentActivity()
+class MainActivity : FlutterFragmentActivity() {
+ private val installReferrerChannel = "swiss.realunit.app/install_referrer"
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, installReferrerChannel)
+ .setMethodCallHandler { call, result ->
+ if (call.method != "readInstallReferrer") {
+ result.notImplemented()
+ return@setMethodCallHandler
+ }
+ readInstallReferrer(result)
+ }
+ }
+
+ private fun readInstallReferrer(result: MethodChannel.Result) {
+ val client = InstallReferrerClient.newBuilder(this).build()
+ val main = Handler(Looper.getMainLooper())
+ var replied = false
+
+ fun reply(value: String?) {
+ if (replied) return
+ replied = true
+ result.success(value)
+ try {
+ client.endConnection()
+ } catch (_: Exception) {
+ }
+ }
+
+ main.postDelayed({ reply(null) }, 3000)
+ try {
+ client.startConnection(
+ object : InstallReferrerStateListener {
+ override fun onInstallReferrerSetupFinished(responseCode: Int) {
+ if (responseCode != InstallReferrerClient.InstallReferrerResponse.OK) {
+ reply(null)
+ return
+ }
+ try {
+ reply(client.installReferrer.installReferrer)
+ } catch (_: Exception) {
+ reply(null)
+ }
+ }
+
+ override fun onInstallReferrerServiceDisconnected() {}
+ },
+ )
+ } catch (_: Exception) {
+ reply(null)
+ }
+ }
+}
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
index 399f6981d..edc280f12 100644
--- a/android/app/src/profile/AndroidManifest.xml
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -4,4 +4,29 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile
index eb2a22c02..10048e55c 100644
--- a/android/fastlane/Fastfile
+++ b/android/fastlane/Fastfile
@@ -51,10 +51,33 @@ def upload_sentry_symbols(marketing_version:, version_code:, symbols_path:, dart
UI.user_error!("Sentry upload: Dart obfuscation map not found at #{dart_symbol_map_path}")
end
- sh("dart", "run", "sentry_dart_plugin",
- "--sentry-define=symbols_path=#{symbols_path}",
- "--sentry-define=dart_symbol_map_path=#{dart_symbol_map_path}"
- )
+ # Self-hosted Sentry can return "Not all chunks available for
+ # assembling" after "Nothing to upload" (files already on the
+ # server, assemble stuck). That pair continues. Other plugin
+ # errors retry up to max_attempts, then fail the lane.
+ max_attempts = 3
+ attempt = 0
+ begin
+ attempt += 1
+ sh("dart", "run", "sentry_dart_plugin",
+ "--sentry-define=symbols_path=#{symbols_path}",
+ "--sentry-define=dart_symbol_map_path=#{dart_symbol_map_path}"
+ )
+ rescue FastlaneCore::Interface::FastlaneShellError => e
+ stuck = e.message.include?("Not all chunks available for assembling") &&
+ e.message.include?("Nothing to upload")
+ if stuck
+ # Files are already on the server; waiting for assemble loops on
+ # incomplete chunks. Do not fail the lane after that.
+ UI.important("Sentry already has these files but assemble is stuck; continuing")
+ elsif attempt >= max_attempts
+ raise
+ else
+ UI.important("Sentry symbol upload failed (#{attempt}/#{max_attempts}), retrying in 20s")
+ sleep 20
+ retry
+ end
+ end
end
end
@@ -133,23 +156,11 @@ platform :android do
end
end
- upload_sentry_symbols(
- marketing_version: marketing_version,
- version_code: version_code,
- symbols_path: "build/debug_info/appbundle",
- dart_symbol_map_path: "build/appbundle-obfuscation-map.json"
- )
- upload_sentry_symbols(
- marketing_version: marketing_version,
- version_code: version_code,
- symbols_path: "build/debug_info/apk",
- dart_symbol_map_path: "build/apk-obfuscation-map.json"
- )
-
# Upload the binary + changelog only. The changelog is tied to this
# build's version code (taken from the AAB); the listing metadata and
# images are pushed by the dedicated call below, keeping the two
# concerns separate and avoiding a duplicate metadata upload.
+ # Play Store first: a stuck Sentry assemble must not block testers.
upload_to_play_store(
track: release_track,
aab: aab_path,
@@ -173,6 +184,19 @@ platform :android do
skip_upload_screenshots: false,
track: release_track
)
+
+ upload_sentry_symbols(
+ marketing_version: marketing_version,
+ version_code: version_code,
+ symbols_path: "build/debug_info/appbundle",
+ dart_symbol_map_path: "build/appbundle-obfuscation-map.json"
+ )
+ upload_sentry_symbols(
+ marketing_version: marketing_version,
+ version_code: version_code,
+ symbols_path: "build/debug_info/apk",
+ dart_symbol_map_path: "build/apk-obfuscation-map.json"
+ )
end
desc "Upload Play Store listing (metadata + screenshots) without a binary"
diff --git a/android/gradle.properties b/android/gradle.properties
index 259717082..4147ba381 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
+# This builtInKotlin flag was added automatically by Flutter migrator
+android.builtInKotlin=false
+# This newDsl flag was added automatically by Flutter migrator
+android.newDsl=false
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index 02767eb1c..e4ef43fb9 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb
index b69627dd7..36da5d176 100644
--- a/assets/languages/strings_de.arb
+++ b/assets/languages/strings_de.arb
@@ -26,6 +26,8 @@
"blockchain": "Blockchain",
"buy": "Kaufen",
"buyBackToMain": "Zurück zum Hauptbereich",
+ "buyChargedAmount": "Genau belastet",
+ "buyMaxAmount": "Maximalbetrag: ${amount} ${currency}",
"buyMinAmount": "Mindestbetrag: ${amount} ${currency}",
"buyPaymentConfirm": "Jetzt verbindlich kaufen",
"buyPaymentDetailsTitle": "Zahlungsdetails",
@@ -100,6 +102,7 @@
"firstName": "Vorname",
"from": "von",
"hardwareWalletSubtitle": "Ich besitze eine Bitbox02 Nova und möchte RealUnit Aktientoken darauf verwahren.",
+ "historySyncFailed": "Die Umsatzhistorie konnte nicht aktualisiert werden.",
"iban": "IBAN",
"ibanInvalid": "IBAN ist ungültig",
"ibanRequired": "IBAN ist erforderlich",
@@ -183,6 +186,8 @@
"payFailureQuoteExpired": "Das Zahlungsangebot ist abgelaufen. Bitte scannen Sie den Code erneut.",
"payFailureSignatureUnsupported": "Diese Wallet kann keine Transaktionen signieren. Wechseln Sie zu einer Software- oder BitBox-Wallet.",
"payFailureTitle": "Zahlung fehlgeschlagen",
+ "payInfoBody": "Die Bezahlfunktion kann an allen OpenCryptoPay-Standorten genutzt werden.\n\nDer Tausch erfolgt in zwei Schritten. Zuerst wird REALU in ZCHF getauscht. ZCHF ist ein CHF-Stablecoin, der anschliessend an den Zahlungsempfänger übertragen wird.\n\nREALU ist ein Wertpapier und wird nicht für die Bezahlung selbst verwendet.",
+ "payInfoTitle": "Bevor Sie bezahlen",
"paymentInformationFailed": "Beim Abrufen der Zahlungsinformationen ist ein Fehler aufgetreten.",
"paymentInformationFailedDescription": "Bitte versuchen Sie es später erneut. Wenn der Fehler weiterhin besteht, wenden Sie sich an unseren Support.",
"payoutAccountAdd": "Auszahlungskonto hinzufügen",
@@ -263,6 +268,58 @@
"receiver": "Empfänger",
"recoveryWords": "Wiederherstellungs-Wörter",
"recoveryWordsInvalid": "Wiederherstellungs-Wörter ungültig",
+ "referralAlreadyBound": "Sie haben bereits einen Einladungs- oder Promo-Code eingelöst.",
+ "referralAlreadyBoundTitle": "Bereits ein Code eingelöst",
+ "referralAlreadyRegistered": "Einladungs- und Promo-Codes müssen vor dem ersten REALU-Kauf eingelöst werden.",
+ "referralAlreadyRegisteredTitle": "Nur für neue Kundinnen und Kunden",
+ "referralChfAtSharePrice": "≈ ${chf} zum ${label}",
+ "referralCodeChecking": "Code wird geprüft…",
+ "referralCodeDescription": "Optional. Über den Einladungslink wird der Code automatisch übernommen; hier können Sie ihn auch manuell eingeben.",
+ "referralCodeHeading": "Haben Sie einen Empfehlungscode?",
+ "referralCodeHint": "Einladungs- oder Promo-Code",
+ "referralCodeInvalid": "Dieser Code ist ungültig oder abgelaufen.",
+ "referralCodeInvalidTitle": "Link ungültig oder abgelaufen",
+ "referralCodeOptional": "Einladungs- oder Promo-Code (optional)",
+ "referralCodeSpent": "Dieser Einladungs- oder Promo-Code wurde bereits verwendet.",
+ "referralCodeSpentTitle": "Code bereits eingelöst",
+ "referralCodeUnavailable": "Wir konnten den Code gerade nicht prüfen. Bitte versuchen Sie es später erneut.",
+ "referralCopied": "Kopiert",
+ "referralCopyInviteLink": "Einladungslink kopieren",
+ "referralCreateInvite": "Einladungslink erstellen",
+ "referralCreateInviteDescription": "Geben Sie den Vornamen der Person ein, die Sie einladen möchten.",
+ "referralCreating": "Einladung wird erstellt…",
+ "referralGuestName": "Name der eingeladenen Person",
+ "referralGuestNameRequired": "Bitte den Vornamen der eingeladenen Person eingeben.",
+ "referralInviteRecognized": "Einladung von ${inviter} erkannt. ${inviter} erhält seine Prämie automatisch, sobald Sie verifiziert sind und Ihren ersten Kauf abgeschlossen haben.",
+ "referralInvitesLoading": "Offene Einladungen werden geladen…",
+ "referralInviteUrlLabel": "Persönlicher Einladungslink",
+ "referralLoading": "Empfehlungen werden geladen…",
+ "referralNotEligible": "Das Empfehlungsprogramm steht verifizierten Aktionären mit dem erforderlichen Bestand zur Verfügung.",
+ "referralOpenInvitesExpire": "Offene Einladungen verfallen nach 3 Monaten.",
+ "referralOverviewHint": "20 REALU pro Weiterempfehlung, sobald der erste Kauf erfolgreich abgewickelt ist.",
+ "referralOverviewTitle": "Ihre Empfehlungen",
+ "referralPayout": "Empfehlungsprämie",
+ "referralPayoutChf": "CHF ${chf} zum Zeitpunkt der Gutschrift",
+ "referralPromoTitle": "Aktion",
+ "referralQuarterCap": "In diesem Quartal sind keine weiteren Prämien möglich.",
+ "referrals": "Empfehlungen",
+ "referralSelfReferral": "Sie können Ihre eigene Einladung nicht einlösen.",
+ "referralSelfReferralTitle": "Eigene Einladung",
+ "referralShareInviteLink": "Einladungslink versenden",
+ "referralSharePrice": "Aktienkurs",
+ "referralShareText": "Hey ${guestName}, ${hostName} lädt dich ein zu RealUnit: ${url}",
+ "referralShareTextNoName": "${hostName} lädt dich ein zu RealUnit: ${url}",
+ "referralsSubtitle": "Erhalten Sie 20 REALU pro Weiterempfehlung",
+ "referralStatusCredited": "Gutgeschrieben",
+ "referralStatusOpen": "Offen",
+ "referralTermsAccepting": "Teilnahmebedingungen werden akzeptiert…",
+ "referralTermsCheckbox": "Ich habe die Teilnahmebedingungen gelesen und akzeptiert",
+ "referralTermsLoading": "Teilnahmebedingungen werden geladen…",
+ "referralTermsRequired": "Bitte zuerst die Teilnahmebedingungen akzeptieren.",
+ "referralTermsTitle": "Teilnahmebedingungen Referral-Programm",
+ "referralTotalReceived": "Insgesamt erhalten",
+ "referralYourInvite": "Ihre Einladung",
+ "referralYourInviteFor": "Ihre Einladung für ${guestName}",
"refresh": "Aktualisieren",
"registerAccountType": "Kontotyp",
"registerCitizenship": "Staatsangehörigkeit",
@@ -350,6 +407,8 @@
"sendFailureSignatureCancelled": "Die Signatur wurde abgebrochen. Ihre REALU bleiben unangetastet.",
"sendFailureSignatureUnsupported": "Diese Wallet kann keine gaslosen Überweisungen signieren. Wechseln Sie zu einer Software-Wallet, um REALU zu senden.",
"sendFailureTitle": "Überweisung fehlgeschlagen",
+ "sendInfoBody": "Sie können REALU nur an Wallet-Adressen senden, die bei RealUnit als Aktionärin oder Aktionär registriert sind. Nur so kann der Empfänger sein Stimmrecht ausüben.\n\nDurch die Ausführung dieser Transaktion wird das Aktienbuch aktualisiert.",
+ "sendInfoTitle": "Bevor Sie senden",
"sending": "Wird gesendet",
"sendPaste": "Einfügen",
"sendPreparing": "Überweisung wird vorbereitet",
@@ -359,7 +418,7 @@
"sendRecipientManualHint": "Scannen Sie einen Wallet-QR-Code oder fügen Sie die Empfängeradresse unten ein.",
"sendRecipientTitle": "Empfänger scannen",
"sendShares": "${shares} REALU",
- "sendSigning": "Bestätigen Sie die Überweisung in Ihrer Wallet",
+ "sendSigning": "Überweisung wird gesendet",
"sendSuccess": "Überweisung gesendet",
"sendSuccessDescription": "Ihre REALU sind auf dem Weg zum Empfänger.",
"setNationalityFailed": "Ihre Staatsangehörigkeit konnte nicht gesetzt werden:\n${message}",
@@ -438,8 +497,10 @@
"transactionBuy": "Kauf",
"transactionHistory": "Transaktionshistorie",
"transactionPending": "In Bearbeitung",
+ "transactionReceived": "Empfangen",
"transactions": "Transaktionen",
"transactionSell": "Verkauf",
+ "transactionSent": "Gesendet",
"transactionWaitingForPayment": "Warte auf Zahlung",
"twoFa": "2-Faktor Authentifizierung",
"twoFaCodeRequired": "Code ist erforderlich",
@@ -465,4 +526,4 @@
"youPay": "Sie bezahlen",
"youReceive": "Sie erhalten",
"youSell": "Sie verkaufen"
-}
\ No newline at end of file
+}
diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb
index 58a7d2b84..4d5578e31 100644
--- a/assets/languages/strings_en.arb
+++ b/assets/languages/strings_en.arb
@@ -26,6 +26,8 @@
"blockchain": "Blockchain",
"buy": "Buy",
"buyBackToMain": "Back to main area",
+ "buyChargedAmount": "Exact charge",
+ "buyMaxAmount": "Maximum amount: ${amount} ${currency}",
"buyMinAmount": "Minimum amount: ${amount} ${currency}",
"buyPaymentConfirm": "Buy now bindingly",
"buyPaymentDetailsTitle": "Payment details",
@@ -100,6 +102,7 @@
"firstName": "First name",
"from": "from",
"hardwareWalletSubtitle": "I own a Bitbox02 Nova and want to store RealUnit stock tokens on it.",
+ "historySyncFailed": "Transaction history could not be updated.",
"iban": "IBAN",
"ibanInvalid": "IBAN is invalid",
"ibanRequired": "IBAN is required",
@@ -183,6 +186,8 @@
"payFailureQuoteExpired": "The payment quote expired. Please scan the code again.",
"payFailureSignatureUnsupported": "This wallet cannot sign transactions. Switch to a software or BitBox wallet.",
"payFailureTitle": "Payment failed",
+ "payInfoBody": "You can use the payment function at all OpenCryptoPay locations.\n\nThe exchange happens in two steps. First, REALU is swapped for ZCHF. ZCHF is a Swiss franc stablecoin, which is then transferred to the payment recipient.\n\nREALU is a security and is not used for the payment itself.",
+ "payInfoTitle": "Before you pay",
"paymentInformationFailed": "An error occurred while getting the payment information.",
"paymentInformationFailedDescription": "Please try again later. If the error persists, contact our support team.",
"payoutAccountAdd": "Add payout account",
@@ -263,6 +268,58 @@
"receiver": "Receiver",
"recoveryWords": "recovery words",
"recoveryWordsInvalid": "Recovery words invalid",
+ "referralAlreadyBound": "You have already applied an invite or promo code.",
+ "referralAlreadyBoundTitle": "Code already applied",
+ "referralAlreadyRegistered": "Invite and promo codes must be applied before the first REALU purchase.",
+ "referralAlreadyRegisteredTitle": "Only for new customers",
+ "referralChfAtSharePrice": "≈ ${chf} at ${label}",
+ "referralCodeChecking": "Checking code…",
+ "referralCodeDescription": "Optional. The invite link fills this automatically; you can also enter the code here.",
+ "referralCodeHeading": "Do you have a referral code?",
+ "referralCodeHint": "Invite or promo code",
+ "referralCodeInvalid": "This code is invalid or has expired.",
+ "referralCodeInvalidTitle": "Link invalid or expired",
+ "referralCodeOptional": "Invite or promo code (optional)",
+ "referralCodeSpent": "This invite or promo code has already been used.",
+ "referralCodeSpentTitle": "Code already used",
+ "referralCodeUnavailable": "We could not look up this code right now. Please try again later.",
+ "referralCopied": "Copied",
+ "referralCopyInviteLink": "Copy invite link",
+ "referralCreateInvite": "Create invite link",
+ "referralCreateInviteDescription": "Enter the first name of the person you want to invite.",
+ "referralCreating": "Creating invitation…",
+ "referralGuestName": "Name of the invited person",
+ "referralGuestNameRequired": "Please enter the first name of the person you are inviting.",
+ "referralInviteRecognized": "Invitation from ${inviter} recognised. ${inviter} receives the prize automatically once you are verified and have completed your first purchase.",
+ "referralInvitesLoading": "Loading open invitations…",
+ "referralInviteUrlLabel": "Personal invite link",
+ "referralLoading": "Loading referrals…",
+ "referralNotEligible": "The referral programme is available once you are a verified shareholder with the required holding.",
+ "referralOpenInvitesExpire": "Open invitations expire after 3 months.",
+ "referralOverviewHint": "20 REALU per referral once the first purchase completes successfully.",
+ "referralOverviewTitle": "Your referrals",
+ "referralPayout": "Referral prize",
+ "referralPayoutChf": "CHF ${chf} at credit",
+ "referralPromoTitle": "Promotion",
+ "referralQuarterCap": "No further prizes can be credited this quarter.",
+ "referrals": "Referrals",
+ "referralSelfReferral": "You cannot apply your own invitation.",
+ "referralSelfReferralTitle": "Your own invitation",
+ "referralShareInviteLink": "Share invite link",
+ "referralSharePrice": "Share price",
+ "referralShareText": "Hey ${guestName}, ${hostName} is inviting you to RealUnit: ${url}",
+ "referralShareTextNoName": "${hostName} is inviting you to RealUnit: ${url}",
+ "referralsSubtitle": "Receive 20 REALU per referral",
+ "referralStatusCredited": "Credited",
+ "referralStatusOpen": "Open",
+ "referralTermsAccepting": "Accepting terms of participation…",
+ "referralTermsCheckbox": "I have read and accepted the terms of participation",
+ "referralTermsLoading": "Loading terms of participation…",
+ "referralTermsRequired": "Please accept the terms of participation first.",
+ "referralTermsTitle": "Referral programme terms of participation",
+ "referralTotalReceived": "Total received",
+ "referralYourInvite": "Your invitation",
+ "referralYourInviteFor": "Your invitation for ${guestName}",
"refresh": "Refresh",
"registerAccountType": "Account type",
"registerCitizenship": "Citizenship",
@@ -350,6 +407,8 @@
"sendFailureSignatureCancelled": "The signature was cancelled. Your REALU is untouched.",
"sendFailureSignatureUnsupported": "This wallet cannot sign gasless transfers. Switch to a software wallet to send REALU.",
"sendFailureTitle": "Transfer failed",
+ "sendInfoBody": "You can only send REALU to wallet addresses that are registered as RealUnit shareholders. This is required so the recipient can exercise their voting rights.\n\nCompleting this transfer updates the share register.",
+ "sendInfoTitle": "Before you send",
"sending": "Sending",
"sendPaste": "Paste",
"sendPreparing": "Preparing transfer",
@@ -359,7 +418,7 @@
"sendRecipientManualHint": "Scan a wallet QR code, or paste the recipient address below.",
"sendRecipientTitle": "Scan recipient",
"sendShares": "${shares} REALU",
- "sendSigning": "Confirm the transfer in your wallet",
+ "sendSigning": "Sending the transfer",
"sendSuccess": "Transfer sent",
"sendSuccessDescription": "Your REALU is on its way to the recipient.",
"setNationalityFailed": "Could not set your nationality:\n${message}",
@@ -438,8 +497,10 @@
"transactionBuy": "Buy",
"transactionHistory": "Transaction history",
"transactionPending": "Processing",
+ "transactionReceived": "Received",
"transactions": "Transactions",
"transactionSell": "Sell",
+ "transactionSent": "Sent",
"transactionWaitingForPayment": "Waiting for payment",
"twoFa": "Two-factor authentication",
"twoFaCodeRequired": "Code is required",
@@ -465,4 +526,4 @@
"youPay": "You pay",
"youReceive": "You receive",
"youSell": "You sell"
-}
\ No newline at end of file
+}
diff --git a/assets/legal/referral_terms_de.md b/assets/legal/referral_terms_de.md
new file mode 100644
index 000000000..98d0e4f59
--- /dev/null
+++ b/assets/legal/referral_terms_de.md
@@ -0,0 +1,47 @@
+# Teilnahmebedingungen Referral-Programm RealUnit App
+
+*Stand: 26.08.2026*
+
+## 1. Veranstalterin
+
+RealUnit Schweiz AG, Schochenmühlestrasse 6, 6340 Baar. Das Referral-Programm wird in der RealUnit App angeboten.
+
+## 2. Teilnahmeberechtigung
+
+Teilnahmeberechtigt sind natürliche Personen, die in der RealUnit App registriert und verifiziert sind und mindestens 70 RealUnit-Aktientoken im eigenen Wallet halten. Der Mindestbestand muss im Zeitpunkt der Erstellung der Einladung und im Zeitpunkt der Qualifikation nach Ziff. 4 erfüllt sein. Von der Teilnahme ausgeschlossen sind Mitarbeitende und Organe der Veranstalterin.
+
+## 3. Ablauf
+
+Der Empfehler erstellt in der App eine Einladung und teilt den Einladungslink. Die eingeladene Person darf noch nicht in der App registriert sein.
+
+## 4. Prämie und Qualifikation
+
+Der Empfehler erhält 20 RealUnit-Aktientoken, sobald die eingeladene Person registriert und verifiziert ist und ihren ersten Kauf über mindestens 200 RealUnit-Aktientoken erfolgreich abgeschlossen hat. Die Prämie wird dem Wallet des Empfehlers gutgeschrieben und in der Transaktionshistorie mit dem im Zeitpunkt der Gutschrift fixierten Frankenwert ausgewiesen. Die Prämie ist Entgelt für die Vermittlung der eingeladenen Person. Ein Anspruch entsteht erst mit vollständiger Erfüllung dieser Voraussetzungen.
+
+## 5. Obergrenze
+
+Je Empfehler werden höchstens einhundert erfolgreiche Empfehlungen innerhalb eines Quartals prämiert.
+
+## 6. Steuern
+
+Die Prämie stellt beim Empfehler steuerbares Einkommen dar und ist von ihm zu deklarieren. Die Veranstalterin weist Anzahl, Frankenwert und Datum der Gutschrift aus, erteilt jedoch keine Steuerauskunft.
+
+## 7. Datenschutz
+
+Für die Teilnahme bearbeitet die Veranstalterin den Einladungscode, die Zuordnung zwischen Empfehler und eingeladener Person sowie den Qualifikationsstatus. Verantwortlich ist die Veranstalterin; die technische Bearbeitung erfolgt durch ihren Dienstleister als Auftragsbearbeiter. Angaben zur Registrierung, Verifizierung oder zu Käufen der eingeladenen Person werden dem Empfehler nicht bekanntgegeben; angezeigt wird nur der Status des eigenen Prämienanspruchs. Einladungen, die nicht zu einer Qualifikation führen, werden nach drei Monaten gelöscht. Im Übrigen gilt die Datenschutzerklärung der RealUnit App.
+
+## 8. Missbrauch
+
+Nicht prämiert werden Empfehlungen, die auf Selbstempfehlung, mehrfache Identitäten, koordinierte Anwerbung oder sonstige Umgehung dieser Bedingungen zurückgehen. Die Veranstalterin kann Prämien vor der Ausschüttung zurückstellen und manuell prüfen sowie bereits ausgeschüttete Prämien bei nachträglich festgestelltem Missbrauch zurückfordern.
+
+## 9. Werbung
+
+Für die Empfehlung ist ausschliesslich der in der App bereitgestellte Einladungslink zu verwenden. Eigene Aussagen über die Veranstalterin, ihre Aktien oder deren Wertentwicklung sind nicht Bestandteil des Programms und dürfen nicht im Namen der Veranstalterin gemacht werden.
+
+## 10. Dauer, Änderung und Beendigung
+
+Das Programm läuft auf unbestimmte Dauer und kann von der Veranstalterin jederzeit geändert oder ohne Vorankündigung beendet werden. Bereits entstandene Prämienansprüche bleiben unberührt; für ausgesprochene, aber noch nicht qualifizierte Einladungen besteht kein Anspruch.
+
+## 11. Anwendbares Recht
+
+Es gilt Schweizer Recht. Gerichtsstand ist Baar.
diff --git a/assets/legal/referral_terms_en.md b/assets/legal/referral_terms_en.md
new file mode 100644
index 000000000..c2c4c54df
--- /dev/null
+++ b/assets/legal/referral_terms_en.md
@@ -0,0 +1,47 @@
+# Terms of Participation — Referral Programme RealUnit App
+
+*As of: 26 August 2026*
+
+## 1. Organiser
+
+RealUnit Schweiz AG, Schochenmühlestrasse 6, 6340 Baar. The referral programme is offered in the RealUnit app.
+
+## 2. Eligibility
+
+Eligible are natural persons who are registered and verified in the RealUnit app and who hold at least 70 RealUnit share tokens in their own wallet. The minimum holding must be met at the time the invitation is created and at the time of qualification under section 4. Employees and officers of the organiser are excluded from participation.
+
+## 3. Process
+
+The referrer creates an invitation in the app and shares the invitation link. The invited person must not already be registered in the app.
+
+## 4. Reward and qualification
+
+The referrer receives 20 RealUnit share tokens once the invited person is registered and verified and has successfully completed their first purchase of at least 200 RealUnit share tokens. The reward is credited to the referrer’s wallet and shown in the transaction history with the Swiss-franc value fixed at the time of crediting. The reward is remuneration for introducing the invited person. A claim arises only once these conditions are fully met.
+
+## 5. Cap
+
+At most one hundred successful referrals per referrer are remunerated within a calendar quarter.
+
+## 6. Taxes
+
+The reward constitutes taxable income of the referrer and must be declared by them. The organiser states the quantity, Swiss-franc value and date of the credit, but does not provide tax advice.
+
+## 7. Data protection
+
+For participation the organiser processes the invitation code, the assignment between referrer and invited person, and the qualification status. The organiser is the controller; technical processing is carried out by its service provider as processor. Information about the invited person’s registration, verification or purchases is not disclosed to the referrer; only the status of the referrer’s own reward claim is shown. Invitations that do not lead to qualification are deleted after three months. In all other respects the RealUnit app privacy policy applies.
+
+## 8. Abuse
+
+Referrals based on self-referral, multiple identities, coordinated solicitation or any other circumvention of these terms are not remunerated. The organiser may hold rewards before payout and review them manually, and may claw back rewards already paid if abuse is established later.
+
+## 9. Advertising
+
+Only the invitation link provided in the app may be used for referrals. Statements of one’s own about the organiser, its shares or their performance are not part of the programme and may not be made in the organiser’s name.
+
+## 10. Duration, changes and termination
+
+The programme runs for an indefinite period and may be changed or ended by the organiser at any time without prior notice. Reward claims that have already arisen remain unaffected; there is no claim for invitations that have been issued but have not yet qualified.
+
+## 11. Applicable law
+
+Swiss law applies. Place of jurisdiction is Baar.
diff --git a/docs/handbook/README.md b/docs/handbook/README.md
index 88fe69b5a..2bc7b928b 100644
--- a/docs/handbook/README.md
+++ b/docs/handbook/README.md
@@ -21,7 +21,7 @@ deployten Image (`handbook.realunit.app`).
## Screenshots regenerieren
-Es gibt keinen separaten Regeneration-Schritt: Die 278 Handbook-Screenshots
+Es gibt keinen separaten Regeneration-Schritt: Die 301 Handbook-Screenshots
sind direkt die Golden-Baselines unter `test/goldens/` (gemappt in
`scripts/assemble-handbook-screenshots.sh`). Eine UI-Änderung an einer der
gemappten Pages produziert beim `flutter test test/goldens` einen Diff —
@@ -64,14 +64,26 @@ Auch der Tier-3-GitHub-Workflow hat dafür einen `flows`-`workflow_dispatch`-Inp
neu laufen lassen. (Die Screenshots zieht das Handbook aus den Goldens, nicht
mehr aus diesen Maestro-Läufen.)
+## Live Geo-Filter-Tabelle
+
+Die Sektion **Aktientoken — Geo-Filter** (`#spec-geo`) lädt `GET /v1/country`
+zur Laufzeit. Länderzeilen gehören nicht ins Repo. Namen kommen als Deutsch und
+Englisch aus `Intl.DisplayNames` (ISO 3166), nicht aus `foreignName`. CSV, Excel
+und PDF exportieren die angezeigte Liste. Im Image proxied nginx `/v1/country`
+auf `https://api.dfx.swiss/v1/country`; eine lokale HTML-Vorschau fällt auf die
+öffentliche API zurück.
+
## Einen neuen Handbook-Eintrag hinzufügen
1. **Page + Golden-Test**: `lib/screens//_page.dart` + zugehörigen
- Golden-Test unter `test/goldens/screens//`. Pattern siehe
+ Golden-Test unter `test/goldens/screens//`. Widget-Goldens liegen unter
+ `test/goldens/widgets//` (siehe
+ [`../visual-regression-tests.md`](../visual-regression-tests.md)). Pattern siehe
[`../visual-regression-tests.md`](../visual-regression-tests.md) und bestehende
Tests in `test/goldens/screens/`.
2. **Screenshot-Mapping**: in `scripts/assemble-handbook-screenshots.sh` eine neue
- Zeile in der `MAPPING`-Tabelle ergänzen — `"NN-=/goldens/macos/.png"`.
+ Zeile in der `MAPPING`-Tabelle ergänzen — `"NN-=screens//goldens/macos/.png"`
+ oder `"NN-=widgets//goldens/macos/.png"`.
Die Nummer NN ist der Sortierschlüssel im Handbook (keine direkte Bindung mehr
an einen Maestro-Flow). Damit ändert sich die Screenshot-Anzahl: den
Count-Guard in `.github/workflows/handbook-build-check.yaml` (der
@@ -102,13 +114,18 @@ mehr aus diesen Maestro-Läufen.)
## E-Mail Previews
Die HTML-Vorschauen aller vom Backend an Endkunden versendeten Mails liegen
-**nicht in diesem Repo**. Quelle ist `DFXswiss/api`:
+**nicht in diesem Repo**. Quelle für KYC-, Kauf- und Verkaufs-Mails ist
+`DFXswiss/api`:
- Generator: `scripts/generate-realunit-previews.js`
-- Vorlage: `src/subdomains/supporting/notification/templates/realunit.hbs`
+- Vorlage: `realunit.hbs`
- Übersetzungen: `src/shared/i18n/de/mail-realunit.json` (RealUnit-Texte) mit
Fallback auf `src/shared/i18n/de/mail.json` (DFX-Defaults)
+Die Bestätigungs-E-Mail der Empfehlungsprämie (Anzahl, Datum D.M.YYYY, fixierter
+Frankenwert, TB Ziff. 6) kommt zur Sendezeit von der DFX-API als `html` /
+`htmlEn` — nicht aus `realunit.hbs`. Die Überwachung bleibt der Klartext.
+
Der Handbook-CI-Build (`.github/workflows/handbook.yaml`) checkt das api-Repo
zur Build-Zeit aus, führt den Generator aus und kopiert das Ergebnis nach
`docs/handbook/mails/`, bevor das Docker-Image gebaut wird. `docs/handbook/mails/`
@@ -205,7 +222,7 @@ open docs/handbook/de/index.html # Sektion "B — Transaktionsbelege"
```
Zum Regenerieren der Muster-PDFs selbst siehe das api-Repo
-(`GENERATE_RECEIPT_EXAMPLES=true npx jest realunit-receipt-example`).
+(`GENERATE_RECEIPT_EXAMPLES=true npm test -- realunit-receipt-example`).
## Vermögensübersicht
@@ -214,7 +231,8 @@ Muster-PDFs (DE + EN): die Vermögensübersicht weist den REALU-Bestand mit dem
massgeblichen Steuerwert aus. Wie die Transaktionsbelege werden diese PDFs
**nicht** hier generiert — sie liegen bereits committet im api-Repo unter
`docs/examples/realunit-statement/` (gerendert vom `BalancePdfService` via
-`realunit-balance-example.spec.ts`) und werden beim Handbook-Build nur ins Image
+`realunit-statement-example.spec.ts`, Regeneration mit
+`GENERATE_STATEMENT_EXAMPLE=true npm test -- realunit-statement-example`) und werden beim Handbook-Build nur ins Image
kopiert (Step "Stage RealUnit balance examples from api repo" in `handbook.yaml`;
Zielverzeichnis `docs/handbook/balance/` ist gitignored). Single Source of Truth
ist das api-Repo. Kommt upstream ein Beispiel hinzu oder weg, failt der Build am
diff --git a/docs/handbook/assets/realunit-mail-logo.png b/docs/handbook/assets/realunit-mail-logo.png
new file mode 100644
index 000000000..098e85e5f
Binary files /dev/null and b/docs/handbook/assets/realunit-mail-logo.png differ
diff --git a/docs/handbook/de/geo-filter.js b/docs/handbook/de/geo-filter.js
new file mode 100644
index 000000000..f0cf0018b
--- /dev/null
+++ b/docs/handbook/de/geo-filter.js
@@ -0,0 +1,419 @@
+/**
+ * Live RealUnit share-token geo-filter table.
+ *
+ * Source of truth is GET /v1/country. Rows are never copied into this repo.
+ * Country labels are German and English via Intl.DisplayNames (ISO 3166-1),
+ * not API foreignName (often native script).
+ */
+(function () {
+ var URLS = ['/v1/country', 'https://api.dfx.swiss/v1/country'];
+ var COLUMNS = [
+ { key: 'symbol', label: 'ISO' },
+ { key: 'nameDe', label: 'Land (DE)' },
+ { key: 'nameEn', label: 'Country (EN)' },
+ { key: 'residence', label: 'Wohnsitz' },
+ { key: 'nationality', label: 'Nationalität' },
+ { key: 'residenceExisting', label: 'Wohnsitz Bestand' },
+ { key: 'nationalityExisting', label: 'Nationalität Bestand' },
+ { key: 'ipEnable', label: 'IP neu' },
+ { key: 'ipExisting', label: 'IP Bestand' },
+ { key: 'taxEnable', label: 'Steuer neu' },
+ { key: 'taxExisting', label: 'Steuer Bestand' },
+ ];
+
+ var displayDe;
+ var displayEn;
+ try {
+ displayDe = new Intl.DisplayNames(['de'], { type: 'region', fallback: 'none' });
+ displayEn = new Intl.DisplayNames(['en'], { type: 'region', fallback: 'none' });
+ } catch (e) {
+ try {
+ displayDe = new Intl.DisplayNames(['de'], { type: 'region' });
+ displayEn = new Intl.DisplayNames(['en'], { type: 'region' });
+ } catch (e2) {
+ displayDe = null;
+ displayEn = null;
+ }
+ }
+
+ function $(id) {
+ return document.getElementById(id);
+ }
+
+ function regionName(display, symbol, fallback) {
+ if (display && symbol) {
+ try {
+ var label = display.of(symbol);
+ if (label && String(label).toUpperCase() !== String(symbol).toUpperCase()) {
+ return label;
+ }
+ } catch (e) {}
+ }
+ return fallback || symbol || '';
+ }
+
+ function asBool(value) {
+ if (value === true || value === false) return value;
+ return null;
+ }
+
+ function residenceAllowed(value) {
+ if (value == null || value === '') return null;
+ return value === 'Allowed';
+ }
+
+ function nationalityAllowed(value) {
+ if (value == null || value === '') return null;
+ return value !== 'Blocked';
+ }
+
+ function cell(value) {
+ if (value === true) return 'ja';
+ if (value === false) return 'nein';
+ if (value == null || value === '') return '—';
+ return String(value);
+ }
+
+ function tone(field, value) {
+ if (value == null || value === '') return 'muted';
+ if (value === true) return 'ok';
+ if (value === false) return 'bad';
+ return 'muted';
+ }
+
+ function rowView(country) {
+ var geo = country.realunit || {};
+ var english = country.name || country.symbol;
+ return {
+ symbol: country.symbol,
+ nameDe: regionName(displayDe, country.symbol, english),
+ nameEn: regionName(displayEn, country.symbol, english),
+ residence: residenceAllowed(geo.residence),
+ nationality: nationalityAllowed(geo.nationality),
+ residenceExisting: asBool(geo.residenceExisting),
+ nationalityExisting: asBool(geo.nationalityExisting),
+ ipEnable: asBool(geo.ipEnable),
+ ipExisting: asBool(geo.ipExisting),
+ taxEnable: asBool(geo.taxEnable),
+ taxExisting: asBool(geo.taxExisting),
+ };
+ }
+
+ function load(urls) {
+ var url = urls[0];
+ return fetch(url, { credentials: 'same-origin' }).then(function (res) {
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ return res.json().then(function (body) {
+ if (!Array.isArray(body)) throw new Error('unexpected payload');
+ return { body: body, url: url };
+ });
+ }).catch(function (err) {
+ if (urls.length > 1) return load(urls.slice(1));
+ throw err;
+ });
+ }
+
+ function filteredRows(state) {
+ var q = ($('geo-filter-search') || {}).value || '';
+ q = q.trim().toLowerCase();
+ var residence = ($('geo-filter-residence') || {}).value || '';
+ var nationality = ($('geo-filter-nationality') || {}).value || '';
+ return state.rows.filter(function (row) {
+ if (
+ q &&
+ (row.symbol || '').toLowerCase().indexOf(q) < 0 &&
+ (row.nameDe || '').toLowerCase().indexOf(q) < 0 &&
+ (row.nameEn || '').toLowerCase().indexOf(q) < 0
+ ) {
+ return false;
+ }
+ if (residence === 'true' && row.residence !== true) return false;
+ if (residence === 'false' && row.residence !== false) return false;
+ if (nationality === 'true' && row.nationality !== true) return false;
+ if (nationality === 'false' && row.nationality !== false) return false;
+ return true;
+ });
+ }
+
+ function stamp() {
+ var d = new Date();
+ var p = function (n) {
+ return n < 10 ? '0' + n : String(n);
+ };
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
+ }
+
+ function fileBase() {
+ return 'realunit-geo-filter-' + stamp();
+ }
+
+ function saveBlob(blob, filename) {
+ var url = URL.createObjectURL(blob);
+ var a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(function () {
+ URL.revokeObjectURL(url);
+ }, 1000);
+ }
+
+ function xmlEscape(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function csvField(value) {
+ var s = cell(value);
+ if (/[;"\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
+ return s;
+ }
+
+ function downloadCsv(rows) {
+ var lines = [COLUMNS.map(function (c) { return csvField(c.label); }).join(';')];
+ rows.forEach(function (row) {
+ lines.push(COLUMNS.map(function (c) { return csvField(row[c.key]); }).join(';'));
+ });
+ var blob = new Blob(['\uFEFF' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
+ saveBlob(blob, fileBase() + '.csv');
+ }
+
+ function downloadExcel(rows) {
+ var xml = '\r\n';
+ xml += '\r\n';
+ xml += '\r\n';
+ xml += '\r\n';
+ xml += '' + COLUMNS.map(function (c) {
+ return '| ' + xmlEscape(c.label) + ' | ';
+ }).join('') + '
\r\n';
+ rows.forEach(function (row) {
+ xml += '' + COLUMNS.map(function (c) {
+ return '| ' + xmlEscape(cell(row[c.key])) + ' | ';
+ }).join('') + '
\r\n';
+ });
+ xml += '
';
+ var blob = new Blob([xml], { type: 'application/vnd.ms-excel' });
+ saveBlob(blob, fileBase() + '.xls');
+ }
+
+ var WINANSI = {
+ 0x20ac: 128,
+ 0x201a: 130,
+ 0x0192: 131,
+ 0x201e: 132,
+ 0x2026: 133,
+ 0x2020: 134,
+ 0x2021: 135,
+ 0x02c6: 136,
+ 0x2030: 137,
+ 0x0160: 138,
+ 0x2039: 139,
+ 0x0152: 140,
+ 0x017d: 142,
+ 0x2018: 145,
+ 0x2019: 146,
+ 0x201c: 147,
+ 0x201d: 148,
+ 0x2022: 149,
+ 0x2013: 150,
+ 0x2014: 151,
+ 0x02dc: 152,
+ 0x2122: 153,
+ 0x0161: 154,
+ 0x203a: 155,
+ 0x0153: 156,
+ 0x017e: 158,
+ 0x0178: 159,
+ };
+
+ function pdfEscape(text) {
+ var s = '';
+ var raw = String(text);
+ for (var i = 0; i < raw.length; i++) {
+ var code = raw.charCodeAt(i);
+ if (WINANSI[code] != null) code = WINANSI[code];
+ if (code === 92) s += '\\\\';
+ else if (code === 40) s += '\\(';
+ else if (code === 41) s += '\\)';
+ else if (code === 13 || code === 10) s += ' ';
+ else if (code >= 32 && code < 128) s += String.fromCharCode(code);
+ else if (code >= 128 && code <= 255) s += '\\' + ('00' + code.toString(8)).slice(-3);
+ else s += '?';
+ }
+ return s;
+ }
+
+ function downloadPdf(rows) {
+ var pageW = 842;
+ var pageH = 595;
+ var margin = 32;
+ var fontSize = 8;
+ var lineH = 11;
+ var headerH = 36;
+ var usable = pageH - margin * 2 - headerH;
+ var rowsPerPage = Math.max(1, Math.floor(usable / lineH) - 1);
+ var pages = [];
+ for (var i = 0; i < rows.length || (rows.length === 0 && pages.length === 0); i += rowsPerPage) {
+ pages.push(rows.slice(i, i + rowsPerPage));
+ if (rows.length === 0) break;
+ }
+ var colX = [margin, 62, 168, 274, 348, 422, 496, 546, 596, 646, 700];
+
+ function pageStream(pageRows, pageIndex, pageCount) {
+ var y = pageH - margin - 14;
+ var out = 'BT\n/F1 11 Tf\n';
+ out += margin + ' ' + y + ' Td\n(' + pdfEscape('RealUnit Aktientoken — Geo-Filter') + ') Tj\n';
+ out += '/F1 8 Tf\n0 -12 Td\n(' + pdfEscape(fileBase() + ' · Seite ' + (pageIndex + 1) + '/' + pageCount) + ') Tj\n';
+ y -= headerH;
+ out += '/F1 8 Tf\n';
+ COLUMNS.forEach(function (col, idx) {
+ out += '1 0 0 1 ' + colX[idx] + ' ' + y + ' Tm\n(' + pdfEscape(col.label) + ') Tj\n';
+ });
+ y -= lineH;
+ pageRows.forEach(function (row) {
+ COLUMNS.forEach(function (col, idx) {
+ var value = cell(row[col.key]);
+ if (value.length > 22 && (col.key === 'nameDe' || col.key === 'nameEn')) value = value.slice(0, 21) + '...';
+ out += '1 0 0 1 ' + colX[idx] + ' ' + y + ' Tm\n(' + pdfEscape(value) + ') Tj\n';
+ });
+ y -= lineH;
+ });
+ out += 'ET\n';
+ return out;
+ }
+
+ var objects = [];
+ objects.push('<< /Type /Catalog /Pages 2 0 R >>');
+ var pageIds = [];
+ var contentIds = [];
+ var fontId;
+ var startId = 3;
+ for (var p = 0; p < pages.length; p++) {
+ pageIds.push(startId + p);
+ contentIds.push(startId + pages.length + p);
+ }
+ fontId = startId + pages.length * 2;
+ var kids = pageIds.map(function (id) { return id + ' 0 R'; }).join(' ');
+ objects.push('<< /Type /Pages /Kids [' + kids + '] /Count ' + pages.length + ' >>');
+
+ var contents = [];
+ pages.forEach(function (pageRows, idx) {
+ objects.push(
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ' +
+ pageW +
+ ' ' +
+ pageH +
+ '] /Contents ' +
+ contentIds[idx] +
+ ' 0 R /Resources << /Font << /F1 ' +
+ fontId +
+ ' 0 R >> >> >>'
+ );
+ contents[idx] = pageStream(pageRows, idx, pages.length);
+ });
+ contents.forEach(function (stream) {
+ objects.push('<< /Length ' + stream.length + ' >>\nstream\n' + stream + 'endstream');
+ });
+ objects.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>');
+
+ var xref = [0];
+ var pdf = '%PDF-1.4\n';
+ objects.forEach(function (body, idx) {
+ xref.push(pdf.length);
+ pdf += idx + 1 + ' 0 obj\n' + body + '\nendobj\n';
+ });
+ var xrefPos = pdf.length;
+ pdf += 'xref\n0 ' + (objects.length + 1) + '\n';
+ pdf += '0000000000 65535 f \n';
+ xref.slice(1).forEach(function (offset) {
+ var n = String(offset);
+ pdf += ('0000000000' + n).slice(-10) + ' 00000 n \n';
+ });
+ pdf += 'trailer\n<< /Size ' + (objects.length + 1) + ' /Root 1 0 R >>\nstartxref\n' + xrefPos + '\n%%EOF';
+ saveBlob(new Blob([pdf], { type: 'application/pdf' }), fileBase() + '.pdf');
+ }
+
+ function render(state) {
+ var tbody = $('geo-filter-body');
+ var status = $('geo-filter-status');
+ var missing = $('geo-filter-missing');
+ if (!tbody) return;
+
+ var rows = filteredRows(state);
+ tbody.textContent = '';
+ rows.forEach(function (row) {
+ var tr = document.createElement('tr');
+ COLUMNS.forEach(function (col) {
+ var td = document.createElement('td');
+ td.textContent = cell(row[col.key]);
+ td.className = 'geo-tone-' + tone(col.key, row[col.key]);
+ tr.appendChild(td);
+ });
+ tbody.appendChild(tr);
+ });
+
+ if (status) {
+ status.textContent =
+ rows.length + ' von ' + state.rows.length + ' Ländern · Quelle ' + state.url;
+ }
+ if (missing) {
+ missing.hidden = state.hasRealunit;
+ }
+ state.visible = rows;
+ var ready = rows.length > 0;
+ ['geo-filter-csv', 'geo-filter-xls', 'geo-filter-pdf'].forEach(function (id) {
+ var btn = $(id);
+ if (btn) btn.disabled = !ready;
+ });
+ }
+
+ function bindDownloads(state) {
+ var csv = $('geo-filter-csv');
+ var xls = $('geo-filter-xls');
+ var pdf = $('geo-filter-pdf');
+ if (csv) csv.addEventListener('click', function () { downloadCsv(state.visible || []); });
+ if (xls) xls.addEventListener('click', function () { downloadExcel(state.visible || []); });
+ if (pdf) pdf.addEventListener('click', function () { downloadPdf(state.visible || []); });
+ }
+
+ function init() {
+ var root = $('geo-filter-live');
+ if (!root) return;
+ var status = $('geo-filter-status');
+ if (status) status.textContent = 'Lade GET /v1/country …';
+
+ load(URLS)
+ .then(function (result) {
+ var rows = result.body.map(rowView);
+ var hasRealunit = result.body.some(function (country) {
+ return country && country.realunit != null;
+ });
+ var state = { rows: rows, url: result.url, hasRealunit: hasRealunit, visible: rows };
+ ['geo-filter-search', 'geo-filter-residence', 'geo-filter-nationality'].forEach(function (id) {
+ var el = $(id);
+ if (el) el.addEventListener('input', function () { render(state); });
+ if (el) el.addEventListener('change', function () { render(state); });
+ });
+ bindDownloads(state);
+ render(state);
+ })
+ .catch(function (err) {
+ if (status) {
+ status.textContent =
+ 'Tabelle konnte nicht geladen werden (' + (err && err.message ? err.message : err) + ').';
+ }
+ });
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html
index 5c52b111b..e65845eb1 100644
--- a/docs/handbook/de/index.html
+++ b/docs/handbook/de/index.html
@@ -656,7 +656,8 @@
font-size: 13px;
color: var(--ink-2);
}
- a.dl-btn {
+ a.dl-btn,
+ button.dl-btn {
text-decoration: none;
font-size: 12.5px;
font-weight: 600;
@@ -670,7 +671,8 @@
border-color 0.15s,
color 0.15s;
}
- a.dl-btn:hover {
+ a.dl-btn:hover,
+ button.dl-btn:hover:not(:disabled) {
background: var(--surface);
border-color: var(--brand);
}
@@ -724,6 +726,104 @@
border-radius: 8px;
padding: 14px 16px;
}
+ #spec-80 .persona-trades {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+ margin: 8px 0 0;
+ }
+ #spec-80 .persona-trades th,
+ #spec-80 .persona-trades td {
+ border-bottom: 1px solid var(--line-2);
+ padding: 4px 8px;
+ text-align: left;
+ }
+ #spec-80 .persona-trades th {
+ color: var(--ink-3);
+ font-weight: 600;
+ }
+ #spec-80 .persona-trades td.num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ }
+ #geo-filter-live .geo-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 0 0 12px 0;
+ align-items: center;
+ }
+ #geo-filter-live .geo-toolbar input,
+ #geo-filter-live .geo-toolbar select {
+ font: inherit;
+ padding: 6px 8px;
+ border: 1px solid var(--line-2);
+ border-radius: 4px;
+ background: var(--surface);
+ color: var(--ink);
+ }
+ #geo-filter-live .geo-toolbar input {
+ min-width: 180px;
+ flex: 1;
+ }
+ #geo-filter-live .geo-downloads {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 0 0 12px 0;
+ }
+ #geo-filter-live .geo-downloads button.dl-btn {
+ cursor: pointer;
+ font: inherit;
+ }
+ #geo-filter-live .geo-downloads button.dl-btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ }
+ #geo-filter-status {
+ margin: 0 0 10px 0;
+ font-size: 12.5px;
+ color: var(--ink-3);
+ }
+ #geo-filter-scroller {
+ overflow-x: auto;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--surface);
+ }
+ #geo-filter-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12.5px;
+ }
+ #geo-filter-table th,
+ #geo-filter-table td {
+ padding: 6px 8px;
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+ white-space: nowrap;
+ }
+ #geo-filter-table thead th {
+ background: var(--surface-2);
+ font-weight: 600;
+ position: sticky;
+ top: 0;
+ }
+ #geo-filter-table tbody tr:hover {
+ background: var(--surface-2);
+ }
+ .geo-tone-ok {
+ color: #0f7a3d;
+ }
+ .geo-tone-warn {
+ color: #9a6b12;
+ }
+ .geo-tone-bad {
+ color: #b42318;
+ }
+ .geo-tone-muted {
+ color: var(--ink-3);
+ }
/* Downloads section (#spec-downloads) — static, hand-maintained list of
the three public tester/download links. Reuses the .test card chrome
(border + :target highlight) and the .copy-link button; the body adds a
@@ -773,6 +873,7 @@
word-break: break-all;
}
+
@@ -1031,11 +1132,20 @@
78Telefonnummer-Eingabe
- 79Insider-Freischaltung — Bezahlen & Senden
+ 79Insider-Freischaltung — Bezahlen
+
+
+ 80Testkunden — Bestandsentwicklung
+
+
+ GAktientoken — Geo-Filter
WWeb · realunit.app
+
+ REmpfehlungen & Promo-Code
+
ME-Mails
@@ -1046,16 +1156,16 @@
VVermögensübersicht
- SStore-Listing
+ SApp Store / Play Store Listing
- LRechtsdokumente
+ LRechtsdokumente — Downloads
- DDownload / Test
+ DDownload / Test-Zugang
- AAufgaben
+ AAufgaben — Projektübersicht
@@ -1065,8 +1175,9 @@
RealUnit Wallet — Handbook
- User-Doku und Test-Doku in einem. Jeder Screenshot auf dieser Seite ist eine
- Visual-Regression-Golden-Baseline aus test/goldens/screens/ — eine
+ User-Doku und Test-Doku in einem. Jeder App-Screenshot auf dieser Seite ist eine
+ Visual-Regression-Golden-Baseline aus test/goldens/screens/ bzw.
+ test/goldens/widgets/ — eine
pixel-genaue Aufnahme des Page-Widgets, validiert vor jedem Merge auf dem
self-hosted Hardware-Runner. Wenn das Handbook aktuell aussieht, ist die App es auch —
und umgekehrt.
@@ -1075,15 +1186,15 @@
RealUnit Wallet — Handbook
📱 App: RealUnit Wallet (Flutter)
📂 Code:
RealUnitCH/app
- 🧪 Tests: ~1100 (Tier 0/1) · 94 Visual-Regression Goldens · 26 Tier-3 Smoke-Flows
+ 🧪 Tests: 355 Visual-Regression Goldens (301 davon im Handbook) · 26 Tier-3 Smoke-Flows
Single Source of Truth
- Die 52 Bilder dieser Seite sind ausgewählte Goldens aus
- test/goldens/screens/<feature>/goldens/macos/<file>.png.
+ Die 301 App-Screenshots dieser Seite sind Goldens aus
+ test/goldens/{screens,widgets}/<feature>/goldens/macos/<file>.png.
Welcher Golden welcher Handbook-Slot ist, definiert die Mapping-Tabelle in
scripts/assemble-handbook-screenshots.sh. Der Docker-Build
Dockerfile.handbook assembliert sie in einem Multi-Stage-Build in
@@ -1119,10 +1230,10 @@
Single Source of Truth
- Pipeline: Page-Edit → flutter test --update-goldens test/goldens/screens/<feature>
+ Pipeline: Page-Edit → flutter test --update-goldens test/goldens/
auf dem self-hosted Runner (Hardware-Determinismus) → neue Baseline committen →
handbook-deploy.yaml baut das Docker-Image aus dem gepushten Branch
- und assembliert die 52 Slots aus test/goldens/screens/ → Push auf
+ und assembliert die 301 Slots aus test/goldens/ → Push auf
staging deployt das Handbook-Image nach Produktion, via
Cloudflare Tunnel.
@@ -1172,7 +1283,7 @@
01Welcome & Onboarding
Der Splash-Screen mit RealUnit-Logo, dem Claim
- „Bankenunabhängig. Sicher.", einem Bergpanorama im Hintergrund und dem
+ «Sicher. Einfach. Bankenunabhängig.», einem Bergpanorama im Hintergrund und dem
Start-CTA. Unter dem Button steht der i18n-Disclaimer, dass die Nutzung
der App die Annahme der Nutzungsbedingungen impliziert.
@@ -1246,8 +1357,9 @@ 02Wallet erstellen
BIP-39-Seed-Backup-Flow. Drei Schritte: Seed-Phrase wird zuerst verschleiert
angezeigt mit einer Aufforderung sie aufzuschreiben, dann durch Tap enthüllt
(12 Wörter), und schliesslich verifiziert (4 zufällige Wörter müssen wieder
- eingegeben werden). Im Debug-Build sind die Verify-Felder vorausgefüllt, damit
- E2E-Tests nicht den BIP-39-Wortlisten-Match laufen müssen.
+ eingegeben werden). Im Debug-Build trägt der VerifySeedCubit die
+ Verify-Wörter selbst ein, damit E2E-Tests nicht den BIP-39-Wortlisten-Match
+ laufen müssen; der Golden 06 zeigt die Felder leer, der Golden 34 gefüllt.
@@ -1274,11 +1386,11 @@
02Wallet erstellen
/>
- Die Wallet-Sicherung-Seite mit dem Hinweistext „Notieren Sie Ihre
- Wiederherstellungs-Wörter auf einem Blatt Papier..." und der verschleierten
+ Die Wallet-Sicherung-Seite mit dem Hinweistext «Notieren Sie Ihre
+ Wiederherstellungs-Wörter auf einem Blatt Papier...» und der verschleierten
SeedBlurCard. Die Wörter sind als unscharfe Lozenges sichtbar, in der Mitte
steht Hier tippen, um anzuzeigen. Der CTA Ich habe es gesichert
- ist disabled, bis der User mindestens einmal die Phrase aufgedeckt hat.
+ ist immer aktiv; die App erzwingt kein Aufdecken vor dem Weitergehen.
@@ -1296,8 +1408,8 @@
02Wallet erstellen
Nach dem Tap zeigt die Karte alle 12 BIP-39-Wörter in einem
2-Spalten-Grid mit Index. Der Hintergrund ist klar lesbar, der CTA ist
- aktiviert. Im echten Release-Build wechselt die Karte nach einigen Sekunden
- automatisch zurück in den verschleierten Zustand.
+ aktiviert. Ein erneuter Tap auf die Karte verschleiert die Wörter wieder;
+ einen automatischen Rückfall gibt es nicht.
@@ -1314,10 +1426,10 @@
02Wallet erstellen
Sicherung überprüfen: vier zufällig gewählte Indizes (im Screenshot:
- Wort 4, 5, 10, 12) müssen vom User wieder eingetippt werden. In
- kDebugMode trägt der VerifySeedCubit die korrekten
- Wörter aus der gerade generierten Seed-Phrase direkt ein — daher die
- ausgefüllten Felder im Screenshot. Im Release-Build sind die Felder leer.
+ Wort 2, 4, 6, 8) müssen vom User wieder eingetippt werden. Die Felder sind
+ leer, der CTA Bestätigen ist disabled, bis alle vier Wörter
+ eingetragen sind. In kDebugMode trägt der
+ VerifySeedCubit die korrekten Wörter selbst ein (siehe 34).
@@ -1364,7 +1476,7 @@ 03Onboarding abschliessen
Vollbild-Bestätigung mit RealU-Token-Hexagon, Headline
- „Ihre Wallet ist bereit" und dem Hinweis, dass im nächsten Schritt
+ «Ihre Wallet ist bereit» und dem Hinweis, dass im nächsten Schritt
der Zugriff auf die Mobile-App gesichert wird (PIN). Weiter-CTA
triggert HomeBloc.CompleteOnboardingEvent, was die Navigation
in die PIN-Setup-Route auflöst.
@@ -1436,8 +1548,9 @@
04PIN einrichten
Nach 6 Ziffern wechselt der Cubit in confirm, die Dots werden
zurückgesetzt, Titel und Hilfstext ändern sich auf
Bestätigen Sie Ihre PIN. Die Seite ist dieselbe — keine Navigation.
- Eingabe einer abweichenden PIN würde pinConfirmFailed auslösen
- und zurück in create springen.
+ Eine abweichende PIN zeigt die Meldung Die PINs stimmen nicht überein.
+ Versuchen Sie es erneut. mit roten Dots; die Eingabe wird geleert und die App bleibt im Bestätigungsschritt
+ (siehe 74).
@@ -1454,10 +1567,10 @@
04PIN einrichten
Modal-Sheet mit Fingerprint-Icon, Headline
- „Biometrische Authentifizierung aktivieren", und zwei Aktionen:
+ «Biometrische Authentifizierung aktivieren», und zwei Aktionen:
Aktivieren (Face ID / Touch ID einrichten) oder
- Überspringen. Der Hintergrund bleibt sichtbar — der PIN-Screen mit
- gefüllten Dots ist abgedunkelt. Auf dem Simulator gibt es kein echtes
+ Überspringen. Der Golden rendert das Sheet ohne die dahinterliegende
+ PIN-Seite. Auf dem Simulator gibt es kein echtes
Face ID, deshalb verwendet die E2E-Pipeline Überspringen.
@@ -1558,8 +1671,9 @@ 06Einstellungen
/>
- Vollständige Settings-Liste: Sprachen, Währung, Netzwerk, Steuerbericht,
- Nutzerdaten, Rechtsdokumente, Kontakt, Wallet-Adresse, Wallet-Sicherung,
+ Vollständige Settings-Liste: Sprachen, Währung, Netzwerk (nur in
+ Entwickler-Builds sichtbar, siehe Spec 58), Steuerbericht, Nutzerdaten,
+ Rechtsdokumente, Kontakt, Sicherheit, Wallet-Adresse, Wallet-Sicherung,
Wallet zurücksetzen. Aktive Auswahlen (Sprache, Währung, Netzwerk)
werden inline als trailing Text rechts angezeigt.
Die Kachel Wallet-Sicherung erscheint nur bei einer Software-Wallet
@@ -1636,11 +1750,11 @@
06Einstellungen
/>
- EVM-Adresse der Wallet (0x…) mit QR-Code und Copy-Button —
- der „Receive"-Punkt der App, auch wenn er nicht so heisst. Beim Buy-Flow
- kommt die gleiche Information in einem Bottom-Sheet zum Vorschein. QR ist
- der vom qr_flutter-Package gerenderte Standard-EIP-3770-Frame
- ohne EIP-681-Payload.
+ Seite RealUnit Wallet Adresse: EVM-Adresse der Wallet
+ (0x…) mit QR-Code, Copy-Button, Hinweistext und dem CTA
+ Senden. Der QR-Code enthält die Adresse als EIP-681-URI
+ (ethereum:0x…). Die eigentliche Empfangen-Seite mit QR-Code
+ ist in Spec 35 beschrieben.
@@ -1656,9 +1770,12 @@
06Einstellungen
/>
- Die Rechtsdokumente-Übersichtsseite in den Einstellungen. Bündelt
- die rechtlich relevanten Dokumente in zwei Gruppen — Aktionariat und
- DFX — die jeweils auf eine eigene Unterseite führen.
+ Die Rechtsdokumente-Übersichtsseite in den Einstellungen listet
+ Nutzungsbedingungen, Datenschutzbestimmungen, Registrierungsvereinbarung,
+ EU-Wertpapierprospekt (Inhaber-/Namensaktie), CH-Börsenprospekt, RealUnit
+ Statuten, Anlagereglement, die Untergruppen Dokumente der Aktionariat
+ AG und Dokumente der DFX AG sowie als letzten Eintrag die
+ Teilnahmebedingungen Referral-Programm (siehe 286).
@@ -1711,10 +1828,9 @@
06Einstellungen
Die Kontakt-Seite in den Einstellungen bündelt die Support- und
- Kontaktwege: ein Link zum Support (FAQ, Tickets & Chat) sowie
- Telefonnummer, E-Mail und Website des Anbieters. Darunter steht das
- Impressum mit Firmenname und Adresse. Die Firmendaten kommen vom
- DfxCompanyInfoService.
+ Kontaktwege: der Eintrag Support kontaktieren (FAQ, Tickets & Chat)
+ sowie Telefonnummer, E-Mail und Website des Anbieters. Darunter steht das
+ Impressum mit Firmenname und Adresse.
@@ -1730,7 +1846,7 @@
06Einstellungen
/>
- Das Bestätigungs-Sheet „REALU Wallet zurücksetzen", ausgelöst über
+ Das Bestätigungs-Sheet «RealUnit Wallet zurücksetzen», ausgelöst über
den Einstellungs-Eintrag Wallet zurücksetzen. Der User muss
per Checkbox bestätigen, dass er seine Wiederherstellungsphrase gesichert
hat — erst dann wird der Zurücksetzen-Button aktiv. Das Zurücksetzen löscht die
@@ -1784,8 +1900,8 @@
07Wallet-Sicherung
Selber Numpad wie SetupPinPage, aber im
VerifyPinPage-Modus. Titel Geben Sie Ihre PIN ein,
Subtitle erläutert, dass dieser Schritt die Wiederherstellungs-Phrase
- schützt. PIN vergessen? als sekundäre Aktion am unteren Rand —
- führt aktuell zurück in den Restore-Flow.
+ schützt. Einen Ausweg PIN vergessen? gibt es hier nicht; den bietet
+ nur die App-Sperre (siehe Spec 28).
@@ -1822,8 +1938,8 @@
07Wallet-Sicherung
Wörter werden nach Tap angezeigt. Im Gegensatz zum Onboarding-Flow gibt es
hier keinen Verify-Schritt — der User hat die Phrase bereits
- einmal verifiziert. Nach kurzer Zeit kehrt die Karte automatisch in den
- Blur-Zustand zurück (Code: SettingsSeedBloc).
+ einmal verifiziert. Ein erneuter Tap verschleiert die Karte wieder
+ (Code: SettingsSeedCubit).
@@ -1851,7 +1967,7 @@ 08Wiederherstellung & Rechtliches
Zwei eigenständige Screens ausserhalb des linearen Onboarding-Pfads: die
- Wiederherstellung einer bestehenden Wallet aus dem „restore"-Zweig des
+ Wiederherstellung einer bestehenden Wallet aus dem «restore»-Zweig des
Onboardings, und die Nutzungsbedingungen, die direkt vom Splash-Screen aus
geöffnet werden können.
@@ -1870,7 +1986,7 @@ 08Wiederherstellung & Rechtliches
/>
- Der
Wallet wiederherstellen-Screen aus dem „restore"-Zweig des
+ Der
Wallet wiederherstellen-Screen aus dem «restore»-Zweig des
Onboardings. Hier gibt der User seine bestehende
12-Wort-Recovery-Phrase ein, um eine bereits vorhandene Wallet auf
dem Gerät zu reaktivieren — etwa nach einem Geräte-Wechsel oder einer
@@ -1915,8 +2031,10 @@
09Software-Wallet Disclaimer & Rechtsdokumente<
Innerhalb des App-Wallet-Zweigs durchläuft der User mehrere rechtliche Schritte:
zuerst einen zweistufigen Disclaimer, dann die Zustimmung zu den
- Rechtsdokumenten in zwei Gruppen (Aktionariat und DFX). Erst nach
- Bestätigung aller Schritte geht es weiter in die Seed-Generierung.
+ Rechtsdokumenten in drei Schritten (RealUnit Schweiz AG, Aktionariat,
+ DFX); jeder Schritt wird mit Zustimmen bestätigt oder mit
+ Ablehnen abgebrochen. Erst nach Bestätigung aller fünf Schritte (zwei Hinweisseiten und drei Dokumentschritte) geht es
+ weiter in die Seed-Generierung.
@@ -1933,9 +2051,9 @@
09Software-Wallet Disclaimer & Rechtsdokumente<
/>
- Erste Stufe des Software-Wallet-Disclaimers. Macht klar, dass der User
- seine Wiederherstellungs-Wörter selbst verwahren muss und im Verlust-Fall
- kein Zugriff mehr auf die Wallet möglich ist.
+ Erste Stufe des Software-Wallet-Disclaimers: Wichtige rechtliche
+ Hinweise für Investoren & Bestätigung des Wohnsitzes (kein Angebot,
+ eingeschränkte Jurisdiktionen, Bestätigung des Wohnsitzes).
@@ -1951,9 +2069,9 @@
09Software-Wallet Disclaimer & Rechtsdokumente<
/>
- Zweite Stufe des Disclaimers. Zusätzlicher Hinweis auf das Risiko bei
- Verlust des Geräts und die Verantwortung des Users für die sichere
- Aufbewahrung der Recovery-Phrase.
+ Zweite Stufe des Disclaimers: Weitere rechtliche Hinweise
+ (Registrierungsvereinbarung, Steuerdomizil, automatischer
+ Informationsaustausch).
@@ -1969,9 +2087,10 @@
09Software-Wallet Disclaimer & Rechtsdokumente<
/>
- Übersichtsschritt im Onboarding mit beiden Dokument-Gruppen
- (Aktionariat und DFX). Der User muss beide einzeln öffnen
- und akzeptieren, bevor der Weiter-CTA aktiv wird.
+ Erster Dokumentschritt Informationen der RealUnit Schweiz AG
+ (Datenschutzbestimmungen, Registrierungsvereinbarung, Weitere
+ Informationen). Zustimmen führt zum nächsten Schritt,
+ Ablehnen bricht ab.
@@ -1988,8 +2107,8 @@
09Software-Wallet Disclaimer & Rechtsdokumente<
Onboarding-Schritt mit den Aktionariat-Aktionärsdokumenten. Liste
- der zu akzeptierenden Unterlagen rund um die RealUnit-Aktie. Checkbox
- aktiviert den Weiter-CTA.
+ der Unterlagen rund um die RealUnit-Aktie; Zustimmen führt zum
+ nächsten Schritt.
@@ -2006,8 +2125,8 @@
09Software-Wallet Disclaimer & Rechtsdokumente<
Onboarding-Schritt mit den DFX-Rechtsdokumenten (AGB und
- Datenschutz für Buy/Sell und KYC). Checkbox aktiviert den Weiter-CTA und
- schliesst den Legal-Block des Onboardings ab.
+ Datenschutz für Buy/Sell und KYC). Zustimmen schliesst den
+ Legal-Block des Onboardings ab.
@@ -2031,7 +2150,8 @@ 10Wallet-Wiederherstellung — States
Zustände der Seed-Eingabe auf der RestoreWalletPage. Der
Validierungs-Cubit prüft die eingegebenen 12 Wörter gegen die BIP-39-Wortliste
und die Checksum — der Submit-Button ist erst aktiv, wenn die Eingabe gültig
- ist. Bei ungültiger Eingabe wird der Fehler inline angezeigt.
+ ist. Bei ungültiger Eingabe wird der Rahmen rot und der Button selbst zeigt
+ die Fehlermeldung.
@@ -2048,9 +2168,11 @@
10Wallet-Wiederherstellung — States
/>
- Alle 12 Wörter sind eingegeben, BIP-39-Validierung erfolgreich. Der
- Wiederherstellen-CTA ist aktiv. Im Tap-Anschluss generiert die App
- das Wallet aus der Phrase und navigiert in den PIN-Setup-Schritt.
+ Zustand valid nach erfolgreicher BIP-39-Validierung: grüner
+ Rahmen, der Weiter-Button zeigt den Ladeindikator, während die App
+ das Wallet aus der Phrase generiert und in den PIN-Setup-Schritt
+ navigiert. Der Golden rendert die Wortfelder dabei leer; die ausgefüllte
+ Eingabe mit aktivem Button zeigt 69.
@@ -2067,8 +2189,8 @@
10Wallet-Wiederherstellung — States
Eingabe enthält ein Wort ausserhalb der BIP-39-Liste oder die Checksum
- ist falsch. Der CTA bleibt disabled, der Inline-Fehler weist den User
- darauf hin, seine Eingabe zu korrigieren.
+ ist falsch. Der Rahmen wird rot und der Button zeigt
+ Wiederherstellungs-Wörter ungültig, bis die Eingabe korrigiert ist.
@@ -2110,7 +2232,7 @@ 11Seed-Verifizierung — Details
Alle vier zufällig ausgewählten Verifikations-Wörter sind eingetragen.
- Der Weiter-CTA ist aktiv. Tap löst die Validierung im
+ Der Bestätigen-CTA ist aktiv. Tap löst die Validierung im
VerifySeedCubit aus — bei Übereinstimmung mit der
ursprünglichen Phrase folgt der Onboarding-Abschluss-Screen.
@@ -2152,9 +2274,11 @@ 12Dashboard mit Bestand
/>
- Aktienkurs und Performance-Chart oben, darunter die eigene RealUnit-Position
- mit Anzahl Aktien, Gegenwert in der Anzeigewährung und Performance seit
- Kauf. Verlinkt auf Buy/Sell sowie die Transaktionshistorie.
+ Aktienkurs und Bestandsentwicklung oben, darunter die eigene
+ RealUnit-Position mit Anzahl Aktientoken und Gegenwert in der
+ Anzeigewährung sowie die Aktionen Kaufen und Verkaufen. Der Golden
+ ist mit Kurs 0 aufgenommen und zeigt deshalb --.-- und die
+ Roh-Stückzahl; ein Dashboard mit echten Werten zeigt 93.
@@ -2175,9 +2299,9 @@ 13Transaktionshistorie
- Liste der bisherigen Käufe und Verkäufe der RealUnit-Aktie. Erreichbar aus
- dem Dashboard und aus den Einstellungen. Jeder Eintrag zeigt Datum,
- Richtung (Kauf/Verkauf), Aktien-Menge, Preis und Status.
+ Liste der bisherigen Käufe, Verkäufe und Referral-Auszahlungen der RealUnit-Aktie. Erreichbar aus
+ dem Dashboard. Kauf-/Verkaufszeilen zeigen Datum, Richtung und
+ Aktien-Menge; Referral-Auszahlungen zusätzlich den gespeicherten Frankenwert.
@@ -2194,10 +2318,10 @@
13Transaktionshistorie
/>
- Die Transaktionsliste mit mehreren Einträgen. Jede Zeile bündelt Richtung,
- Betrag in Aktien, Gegenwert in der Anzeigewährung sowie den Status (z.B.
- Abgeschlossen, In Bearbeitung). Tap auf eine Zeile öffnet
- die Detailansicht der jeweiligen Transaktion.
+ Transaktionshistorie mit Datumsfilter und PDF-Export. Jede Zeile bündelt
+ Richtung und Betrag in Aktien; Referral-Auszahlungen zeigen darunter den
+ gespeicherten Frankenwert. Einen Status oder eine Detailansicht gibt es nicht. Der Golden zeigt die Liste
+ leer (Test-Fixture ohne gerenderte Einträge); eine gefüllte Liste zeigt 96.
@@ -2213,18 +2337,21 @@ 14KYC — Registrierung & E-Mail
test/goldens/screens/kyc/
- 13 Screens
+ 23 Screens
KYC-Onboarding für Buy/Sell — Pflicht ab definierten Volumen-Schwellen. Der
- Registrierungs-Wizard umfasst zwei Stammdaten-Schritte (persönlich + Adresse)
- und als finalen Schritt die Steueransässigkeit; der Submit der KYC-Anfrage an
- das DFX-Backend erfolgt erst auf diesem Steuer-Schritt. Dazu kommt der
- E-Mail-Verifizierungs-Schritt mit den Fehler-States Loading,
- Mismatch (eingegebener Code stimmt nicht) und Unknown Error
- (Backend-Fehler).
+ Registrierungs-Wizard umfasst vier Schritte: Empfehlungscode (optional, siehe
+ 277), persönliche Daten, Adresse und als finalen Schritt die
+ Steueransässigkeit; der Submit der KYC-Anfrage an das DFX-Backend erfolgt
+ erst auf diesem Steuer-Schritt. Davor liegt die E-Mail-Registrierung mit den
+ States Loading, Mismatch (eingegebene E-Mail-Adresse
+ stimmt nicht mit der bereits verifizierten überein) und Unknown
+ Error (Backend-Fehler). Die Einzelzustände von E-Mail, 2FA und
+ Registrierung sind in Spec 52 und 53 beschrieben; dieser Abschnitt konzentriert
+ sich auf die Steuer-Szenarien S1 bis S5.
@@ -2241,9 +2368,9 @@
14KYC — Registrierung & E-Mail
/>
- Ladezustand der E-Mail-Verifizierung. Der vom User eingegebene Code wird
- gegen das DFX-Backend geprüft, die Eingabefelder sind während des Calls
- disabled.
+ Ladezustand der E-Mail-Registrierung. Die eingegebene E-Mail-Adresse
+ wird beim DFX-Backend registriert; der Weiter-Button zeigt den
+ Spinner, das Feld bleibt editierbar.
@@ -2259,9 +2386,10 @@
14KYC — Registrierung & E-Mail
/>
- Der eingegebene Bestätigungs-Code stimmt nicht mit dem per E-Mail
- gesendeten überein. Inline-Fehler unter dem Eingabefeld, der User kann
- den Code erneut eingeben oder eine neue Mail anfordern.
+ Die eingegebene E-Mail-Adresse stimmt nicht mit der bereits verifizierten
+ überein. Die App zeigt die Meldung als rote SnackBar (siehe 149); der
+ Golden hält nur den Formularzustand fest und sieht deshalb aus wie der
+ Ausgangszustand.
@@ -2278,8 +2406,8 @@
14KYC — Registrierung & E-Mail
Generischer Fehler-State, wenn das Backend keinen verwertbaren Statuscode
- liefert (z.B. Netzwerk-Timeout, 5xx). Der User wird gebeten, es später
- noch einmal zu probieren.
+ liefert (z.B. Netzwerk-Timeout, 5xx). Die Meldung erscheint als rote
+ SnackBar (siehe 150); der Golden hält nur den Formularzustand fest.
@@ -2295,9 +2423,10 @@
14KYC — Registrierung & E-Mail
/>
- Eingabe-Schritt für die persönlichen Stammdaten (Anrede, Vorname,
- Nachname, Geburtsdatum, Nationalität). Pflichtfelder werden inline
- validiert, der Weiter-CTA ist disabled bis die Eingabe vollständig ist.
+ Eingabe-Schritt für die persönlichen Stammdaten (Kontotyp, Vorname,
+ Nachname, Geburtstag, Telefonnummer, Staatsangehörigkeit). Der
+ Weiter-CTA ist immer aktiv; Pflichtfelder werden beim Antippen von
+ Weiter validiert (siehe 165).
@@ -2309,12 +2438,12 @@
14KYC — Registrierung & E-Mail
Persönliche-Daten-Schritt mit geöffnetem Länder-Picker: Der User wählt
- seine Nationalität (Staatsbürgerschaft) aus der scrollbaren Länderliste.
+ seine Staatsangehörigkeit aus der scrollbaren Länderliste.
@@ -2330,8 +2459,8 @@
14KYC — Registrierung & E-Mail
/>
- Adress-Schritt der KYC-Registrierung (Strasse, Nummer, PLZ, Ort, Land).
- Der Schritt ist nicht mehr final: „Weiter" führt den User zum
+ Adress-Schritt der KYC-Registrierung (Strasse, Nummer, PLZ, Stadt, Land).
+ Der Schritt ist nicht mehr final: «Weiter» führt den User zum
abschliessenden Steueransässigkeits-Schritt — die KYC-Anfrage wird erst
dort an das DFX-Backend übermittelt.
@@ -2371,7 +2500,7 @@ 14KYC — Registrierung & E-Mail
Weitere Steuerwohnsitze können hinzugefügt werden; bei Nicht-CH ist die
TIN Pflicht. Die Abbildung zeigt den unbelegten Fallback-Zustand (kein
Wohnsitzland vorhanden), in dem der User mindestens ein Land wählen muss.
- Der „Abschliessen"-CTA übermittelt die vollständige KYC-Anfrage an das
+ Der «Abschliessen»-CTA übermittelt die vollständige KYC-Anfrage an das
DFX-Backend.
@@ -2426,7 +2555,7 @@ 14KYC — Registrierung & E-Mail
Bei Nicht-CH-Wohnsitz (z. B. DE) ist dieses Land als Steuerwohnsitz
- locked und die TIN ist Pflicht. Über „Weiteren Steuerwohnsitz hinzufügen"
+ locked und die TIN ist Pflicht. Über «Weiteren Steuerwohnsitz hinzufügen»
können weitere Länder ergänzt werden (jeweils mit TIN, ausser CH).
@@ -2480,7 +2609,7 @@ 14KYC — Registrierung & E-Mail
Wohnsitz DE ist locked inkl. TIN-Feld; ein zweiter Steuerwohnsitz
- (z. B. CH) wurde über „Weiteren Steuerwohnsitz hinzufügen"
+ (z. B. CH) wurde über «Weiteren Steuerwohnsitz hinzufügen»
ergänzt. Beliebig viele zusätzliche Länder sind möglich.
@@ -2508,7 +2637,7 @@ Kundenszenarien Steuerwohnsitz (S1–S5)
S1: Wohnsitz CH, nur CH als Steuerwohnsitz.
Keine TIN. Submit: swissTaxResidence=true,
- countryAndTINs=null.
+ countryAndTINs=null. Gleicher Golden wie 58.
@@ -2526,6 +2655,7 @@ Kundenszenarien Steuerwohnsitz (S1–S5)
S2 (leer): Wohnsitz DE locked, TIN-Feld sichtbar und noch leer.
+ Gleicher Golden wie 59.
@@ -2562,6 +2692,7 @@ Kundenszenarien Steuerwohnsitz (S1–S5)
S2 (Fehler): Abschliessen ohne TIN — Validierungsfehler am TIN-Feld.
+ Gleicher Golden wie 61.
@@ -2620,39 +2751,39 @@ Kundenszenarien Steuerwohnsitz (S1–S5)
-
+
- S5: Wohnsitz DE locked + FR + US, alle TINs ausgefüllt.
+ S5: Wohnsitz DE locked + FR + IT, alle TINs ausgefüllt.
Submit: swissTaxResidence=false,
- countryAndTINs=[{DE},{FR},{US}].
+ countryAndTINs=[{DE},{FR},{IT}].
-
+
- S5 (Fehler): DE + FR + US, eine TIN fehlt — Validierungsfehler.
+ S5 (Fehler): DE + FR + IT, eine TIN fehlt — Validierungsfehler.
@@ -2693,9 +2824,9 @@
15Steuerbericht — States
/>
- Ladezustand während das Backend den Steuerbericht generiert. Spinner
- und Hinweistext informieren den User, dass die Erstellung einige
- Sekunden dauern kann.
+ Ladezustand während das Backend den Steuerbericht generiert. Der
+ PDF-Button zeigt einen Spinner und ist gesperrt, bis der Bericht
+ vorliegt.
@@ -2711,9 +2842,11 @@
15Steuerbericht — States
/>
- Fehler-State, wenn die Generierung scheitert. Der User erhält eine
- erklärende Meldung und einen Retry-CTA. Häufigste Ursachen sind
- Backend-Timeouts oder noch nicht abgeschlossene KYC.
+ Fehler-State, wenn die Generierung scheitert. Die Backend-Meldung
+ erscheint als rote Snackbar (siehe 226), der PDF-Button bleibt für einen
+ neuen Versuch aktiv; der Golden hält nur den Formularzustand fest.
+ Häufigste Ursachen sind Backend-Timeouts oder noch nicht abgeschlossene
+ KYC.
@@ -2758,8 +2891,8 @@ 16Kaufen — States
Loading-State während die Zahlungsinformationen (Bank-Verbindung,
- Referenz, IBAN) vom DFX-Backend geholt werden. CTA disabled, Spinner
- zentral.
+ Referenz, IBAN) vom DFX-Backend geholt werden. Spinner unter dem
+ Umrechner, noch keine Schaltfläche.
@@ -2811,8 +2944,8 @@
16Kaufen — States
Validierungs-Fehler, wenn der eingegebene Betrag unter der vom Backend
- vorgegebenen Mindest-Kaufsumme liegt. Inline-Fehler unter dem
- Eingabefeld.
+ vorgegebenen Mindest-Kaufsumme liegt. Hinweis Mindestbetrag: 10 CHF
+ über dem deaktivierten Weiter-Button (Betrag aus den Testdaten).
@@ -2829,8 +2962,8 @@
16Kaufen — States
Generischer Fehler-State, wenn das Backend keinen spezifischen Statuscode
- liefert. Retry-CTA und kurze Erklärung — keine technischen Details für
- den User.
+ liefert. Info-Block mit der unveränderten API-Meldung (im Golden
+ englisch), darunter Wiederholen (siehe 109).
@@ -2849,8 +2982,9 @@
16Kaufen — States
Zahlungsdetails-Seite nach dem verbindlichen Kauf. Sie weist auf die
zusätzlich per E-Mail zugestellte Zahlungsanweisung hin, betont den
wichtigen Verwendungszweck und zeigt Referenz sowie die vollständige
- Bankverbindung (IBAN/BIC/Empfänger) mit optionalem QR-Code. Abschluss
- über »Zurück zum Hauptbereich«.
+ Bankverbindung (IBAN/BIC/Empfänger). Der Golden endet beim
+ Adressblock; QR-Code-Reiter und der Abschluss über Zurück zum
+ Hauptbereich sind in Spec 39 vollständig dokumentiert.
@@ -2871,9 +3005,9 @@ 17Verkaufen — States
- Zustände des Verkauf-Flows. Spiegelbildlich zu Spec 16, ohne separaten
- Registration Required-State — der Verkauf ist nur für bereits
- registrierte User aufrufbar.
+ Zustände des Verkauf-Flows. Spiegelbildlich zu Spec 16; Registration
+ Required und KYC Required werden ohne eigenen Hinweis-State
+ behandelt, die App springt direkt in den KYC-Flow.
@@ -2891,8 +3025,8 @@
17Verkaufen — States
Loading-State während die Auszahlungs-Informationen (Bank-Verbindung
- des Users) vom DFX-Backend geholt werden. CTA disabled, Spinner
- zentral.
+ des Users) vom DFX-Backend geholt werden. Der Button REALU
+ verkaufen zeigt den Spinner und ist gesperrt.
@@ -2908,8 +3042,9 @@
17Verkaufen — States
/>
- Hinweis-State, wenn die geplante Verkauf-Summe das Volumen ohne KYC
- überschreitet. CTA verlinkt in den KYC-Flow (siehe Spec 14).
+ Der Server meldet kycRequired: die App zeigt keinen
+ Hinweis, sondern springt direkt in den KYC-Flow (siehe Spec 14). Der
+ Golden zeigt deshalb nur die leere Maske mit deaktiviertem Button.
@@ -2926,8 +3061,8 @@
17Verkaufen — States
Validierungs-Fehler, wenn die eingegebene Verkauf-Menge unter der vom
- Backend vorgegebenen Mindest-Summe liegt. Inline-Fehler unter dem
- Eingabefeld.
+ Backend vorgegebenen Mindest-Summe liegt. Hinweis Mindestbetrag
+ über dem deaktivierten Verkaufsbutton.
@@ -2944,8 +3079,8 @@
17Verkaufen — States
Generischer Fehler-State, wenn das Backend keinen spezifischen Statuscode
- liefert. Retry-CTA und kurze Erklärung — keine technischen Details für
- den User.
+ liefert. Die Backend-Meldung erscheint als rote Snackbar, einen eigenen
+ Retry-Button gibt es nicht; der Golden hält nur die Maske fest.
@@ -3016,7 +3151,7 @@ 19Wallet erstellen — Ladezustand
- Der Bildschirm „Wallet erstellen" generiert beim Öffnen im Hintergrund eine neue Seed-Phrase. Bis diese vorliegt, zeigt die App einen kurzen Ladezustand, bevor die Wiederherstellungswörter erscheinen.
+ Der Bildschirm «Wallet erstellen» generiert beim Öffnen im Hintergrund eine neue Seed-Phrase. Bis diese vorliegt, zeigt die App einen kurzen Ladezustand, bevor die Wiederherstellungswörter erscheinen.
@@ -3033,7 +3168,7 @@
19Wallet erstellen — Ladezustand
/>
- Dieser Zustand zeigt einen zentrierten Ladeindikator auf leerer, hellblauer Fläche mit oberer App-Leiste, solange noch keine Wallet generiert wurde. Der Nutzer sieht ihn kurz direkt nach dem Öffnen des Bildschirms „Wallet erstellen", während im Hintergrund die neue Seed-Phrase erzeugt wird. Sobald das Wallet bereitsteht, wird der Spinner durch Titel, Erklärtext und die verdeckte Seed-Karte ersetzt.
+ Dieser Zustand zeigt einen zentrierten Ladeindikator auf leerer, hellblauer Fläche (die App-Leiste ist leer), solange noch keine Wallet generiert wurde. Der Nutzer sieht ihn kurz direkt nach dem Öffnen des Bildschirms «Wallet erstellen», während im Hintergrund die neue Seed-Phrase erzeugt wird. Sobald das Wallet bereitsteht, wird der Spinner durch Titel, Erklärtext und die verdeckte Seed-Karte ersetzt.
@@ -3577,7 +3712,7 @@ 27PIN eingeben — Fehler und Sperren
/>
- Nach zu vielen Fehlversuchen ist der Ziffernblock gesperrt und die Meldung Zu viele Fehlversuche. Versuchen Sie es in 1h 5m erneut. zeigt die verbleibende Wartezeit. Der Countdown zählt herunter, bis die Eingabe wieder freigegeben wird.
+ Nach zu vielen Fehlversuchen ist der Ziffernblock gesperrt und die Meldung Zu viele Fehlversuche. Versuchen Sie es in 1h 5m erneut. zeigt die verbleibende Wartezeit (Beispielwert aus dem Golden; die reale Sperre beträgt 1, 2, 5 oder 10 Minuten je nach Anzahl Fehlversuche). Der Countdown zählt herunter, bis die Eingabe wieder freigegeben wird.
@@ -3593,7 +3728,7 @@
27PIN eingeben — Fehler und Sperren
/>
- Der Ziffernblock ist gesperrt und die Meldung lautet Zu viele Fehlversuche. Nutzen Sie 'PIN vergessen?', um zurückzusetzen. Dieser Endzustand nach dem Ausschöpfen aller Versuche lässt nur noch das Zurücksetzen der Wallet zu.
+ Der Ziffernblock ist gesperrt und die Meldung lautet Zu viele Fehlversuche. Schließen Sie die App und starten Sie sie neu; auf dem Sperrbildschirm setzen Sie die Wallet über 'PIN vergessen?' zurück. Dieser Endzustand nach dem Ausschöpfen aller Versuche lässt nur noch das Zurücksetzen der Wallet zu.
@@ -3609,7 +3744,7 @@
27PIN eingeben — Fehler und Sperren
/>
- Der Ziffernblock ist gesperrt und die Meldung lautet Ihre PIN kann nicht überprüft werden (Speicherfehler). Sperren Sie die App und setzen Sie die Wallet über den Sperrbildschirm zurücksetzen. Diese Variante ohne 'PIN vergessen?'-Taste erscheint, wenn eine geschützte Funktion die PIN nicht aus dem Speicher lesen kann.
+ Der Ziffernblock ist gesperrt und die Meldung lautet Ihre PIN kann nicht überprüft werden (Speicherfehler). Schließen Sie die App und starten Sie sie neu; auf dem Sperrbildschirm setzen Sie die Wallet über 'PIN vergessen?' zurück. Diese Variante ohne 'PIN vergessen?'-Taste erscheint, wenn eine geschützte Funktion die PIN nicht aus dem Speicher lesen kann.
@@ -3669,7 +3804,7 @@ 28App-Sperre und PIN zurücksetzen
/>
- Die App-Sperre bei einem Speicherfehler: der Ziffernblock ist gesperrt und die Meldung Ihre PIN kann nicht überprüft werden (Speicherfehler). Setzen Sie die Wallet über 'PIN vergessen?' zurücksetzen und stellen Sie sie mit Ihrer Seed-Phrase wieder her. verweist auf die darunter angebotene Aktion PIN vergessen?
+ Die App-Sperre bei einem Speicherfehler: der Ziffernblock ist gesperrt und die Meldung Ihre PIN kann nicht überprüft werden (Speicherfehler). Setzen Sie die Wallet über 'PIN vergessen?' zurück und stellen Sie sie mit Ihrer Seed-Phrase wieder her. verweist auf die darunter angebotene Aktion PIN vergessen?
@@ -3685,7 +3820,7 @@
28App-Sperre und PIN zurücksetzen
/>
- Das Bestätigungs-Bottom-Sheet nach Tippen auf PIN vergessen? mit dem Titel Wallet wird zurückgesetzt und dem Warnhinweis, dass Wallet und Daten gelöscht werden und die Wiederherstellungsphrase gesichert sein muss. Zwei Tasten stehen zur Wahl: Schliessen und Zurücksetzen.
+ Das Bestätigungs-Bottom-Sheet nach Tippen auf PIN vergessen? mit dem Titel Wallet wird zurückgesetzt und dem Warnhinweis, dass Wallet und Daten gelöscht werden und die Wiederherstellungsphrase gesichert sein muss. Zwei Tasten stehen zur Wahl: Schließen und Zurücksetzen.
@@ -3849,7 +3984,7 @@ 31Dashboard — Transaktionen
/>
- Ansicht mit vorhandenem Guthaben: Oben die Aktionen Kaufen und Verkaufen, darunter der Abschnitt Bestand mit REALU 153.00 CHF und dem Detail 100 x 1.53 CHF. Die Karte Letzte Transaktionen listet die jüngsten Bewegungen (+ 50 REALU, - 20 REALU, + 100 REALU) mit Datum und schliesst mit dem Link Transaktionshistorie.
+ Ansicht mit vorhandenem Guthaben: Oben die Aktionen Kaufen und Verkaufen, darunter der Abschnitt Bestand mit REALU 153.00 CHF und dem Detail 100 × 1.53 CHF. Die Karte Letzte Transaktionen listet die jüngsten Bewegungen (+ 50 REALU, - 20 REALU, + 100 REALU) mit Datum und schliesst mit dem Link Transaktionshistorie.
@@ -3876,7 +4011,7 @@ 32Dashboard — Beträge ausblenden
- Ist der Datenschutz-Schalter aktiv, maskiert das Dashboard alle Geldbeträge, lässt die übrige Struktur aber unverändert.
+ Ist hideAmounts im SettingsBloc gesetzt, maskiert das Dashboard Bestandswert und Transaktionsbeträge, lässt Stückzahl × Stückpreis und die übrige Struktur aber unverändert. Ein Schalter in den Einstellungen existiert dafür noch nicht.
@@ -3893,7 +4028,7 @@
32Dashboard — Beträge ausblenden
/>
- Dieselbe Bestandsansicht bei aktivem Datenschutz-Schalter Beträge ausblenden: Der Bestandswert und alle Transaktionsbeträge sind durch \*\*\*.\*\* ersetzt, während Struktur, Anzahl 100 x 1.53 CHF und Beschriftungen sichtbar bleiben. Der Nutzer aktiviert diesen Modus in den Einstellungen, um Geldbeträge vor fremden Blicken zu verbergen.
+ Dieselbe Bestandsansicht mit maskierten Beträgen (Zustand hideAmounts im SettingsBloc): Der Bestandswert und alle Transaktionsbeträge sind durch ***.** ersetzt, während Struktur, Anzahl 100 × 1.53 CHF und Beschriftungen sichtbar bleiben. Einen Schalter dafür gibt es in der App derzeit nicht; der Zustand ist nur über den Bloc erreichbar.
@@ -3969,7 +4104,7 @@ 33Transaktionsverlauf — Übersicht und Filter
- Nach Antippen des Feldes Anfangsdatum öffnet sich der Kalender-Dialog (hier auf Mai 2025) über der abgedunkelten Seite. Mit OK wird das gewählte Datum übernommen und der Verlauf neu gefiltert, Abbrechen schliesst den Dialog ohne Änderung.
+ Nach Antippen des Feldes Anfangsdatum öffnet sich der Kalender-Dialog (hier auf Mai 2025) über der abgedunkelten Seite. Mit OK wird das gewählte Datum übernommen und der Verlauf neu gefiltert, Cancel schliesst den Dialog ohne Änderung. Der Golden ist ohne deutsche Locale aufgenommen, daher englische Beschriftungen und ein überlagertes Kalenderraster.
@@ -4013,7 +4148,7 @@ 34Transaktionsverlauf — Beleg-Download (PDF)
/>
- Eine einzelne Zeile (Kauf, + 50 REALU) während der Erzeugung des Einzelbelegs: das Download-Symbol am rechten Rand ist durch einen kleinen Ladekreis ersetzt. Der Nutzer sieht dies unmittelbar nach dem Tippen auf das Beleg-Symbol der Zeile.
+ Eine einzelne Zeile (Kauf, + 50 REALU) während der Erzeugung des Einzelbelegs: das Download-Symbol am rechten Rand ist durch einen kleinen Ladekreis ersetzt (im Golden nur als Punkt sichtbar). Der Nutzer sieht dies unmittelbar nach dem Tippen auf das Beleg-Symbol der Zeile.
@@ -4029,7 +4164,7 @@
34Transaktionsverlauf — Beleg-Download (PDF)
/>
- Konnte der Beleg nicht erstellt werden, erscheint am unteren Rand eine rote Fehlermeldung 'Beleg konnte nicht erstellt werden.'. Das Download-Symbol der Zeile ist wieder sichtbar, sodass ein erneuter Versuch möglich ist.
+ Konnte der Beleg nicht erstellt werden, erscheint am unteren Rand eine rote Fehlermeldung mit der Servermeldung (im Golden 'Beleg konnte nicht erstellt werden.'). Das Download-Symbol der Zeile ist wieder sichtbar, sodass ein erneuter Versuch möglich ist.
@@ -4045,7 +4180,7 @@
34Transaktionsverlauf — Beleg-Download (PDF)
/>
- Der grosse blaue PDF-Sammel-Button zeigt während der Erzeugung des Sammelbelegs über alle gefilterten Buchungen einen Ladekreis statt des Download-Symbols. Dieser Zustand erscheint nach Antippen des PDF-Buttons oben rechts.
+ Der grosse blaue PDF-Sammel-Button zeigt während der Erzeugung des Sammelbelegs über alle gefilterten Buchungen einen Ladekreis statt des Download-Symbols (im Golden nur als Punkt sichtbar). Dieser Zustand erscheint nach Antippen des PDF-Buttons oben rechts.
@@ -4089,7 +4224,7 @@ 35Empfangen — QR-Code und Adresse
/>
- Die Bottom-Sheet-Variante der Empfangen-Seite: oben der horizontale Ziehbalken (Handlebar), darunter der QR-Code der eigenen Wallet-Adresse und die zweizeilig formatierte Adresse mit fett hervorgehobenem Anfang und Ende sowie dem Kopieren-Symbol. Der Nutzer sieht diese Variante, wenn die Seite als aufgezogenes Blatt von unten geöffnet wird; ein Tippen auf die Adresse kopiert sie in die Zwischenablage.
+ Die Bottom-Sheet-Variante der Empfangen-Seite: oben der horizontale Ziehbalken (Handlebar), darunter der QR-Code der eigenen Wallet-Adresse und die zweizeilig formatierte Adresse mit fett hervorgehobenem Anfang und Ende, dem Kopieren-Symbol und unten dem CTA Senden. Der Nutzer sieht diese Variante, wenn die Seite als aufgezogenes Blatt von unten geöffnet wird; ein Tippen auf die Adresse kopiert sie in die Zwischenablage.
@@ -4105,7 +4240,7 @@
35Empfangen — QR-Code und Adresse
/>
- Die vollflächige Variante derselben Empfangen-Seite: oben eine AppBar mit Zurück-Pfeil statt Ziehbalken, darunter identisch der QR-Code, die zweizeilige Adresse und das Kopieren-Symbol. Diese Variante erscheint, wenn die Seite über die Navigation als eigene Route geöffnet wird; über den Pfeil gelangt der Nutzer zurück.
+ Die vollflächige Variante derselben Empfangen-Seite: oben eine AppBar mit Zurück-Pfeil statt Ziehbalken, darunter identisch der QR-Code, die zweizeilige Adresse, das Kopieren-Symbol und der CTA Senden. Diese Variante erscheint, wenn die Seite über die Navigation als eigene Route geöffnet wird; über den Pfeil gelangt der Nutzer zurück.
@@ -4132,7 +4267,7 @@ 36Kauf — Umrechner und Währungswahl
- Die Kaufseite (RealUnit Aktientoken kaufen) beginnt mit dem Umrechner: oben Sie bezahlen (Fiat-Betrag und Währung), darunter Sie erhalten (RealU-Anteile). Diese Ansichten zeigen den Normalzustand, ein geladenes Angebot sowie die Währungsauswahl und deren Fehlerfall.
+ Die Kaufseite (RealUnit Aktientoken kaufen) beginnt mit dem Umrechner: oben Sie bezahlen (Fiat-Betrag und Währung), darunter Sie erhalten (REALU). Diese Ansichten zeigen den Normalzustand, ein geladenes Angebot sowie die Währungsauswahl und deren Fehlerfall.
@@ -4149,7 +4284,7 @@
36Kauf — Umrechner und Währungswahl
/>
- Der Startzustand der Kaufseite mit leerem Umrechner: die Felder Sie bezahlen (Währung CHF) und Sie erhalten (RealU) sind noch ohne Betrag. Es ist noch kein Angebot abgerufen, deshalb erscheint unten keine Kauf-Schaltfläche.
+ Der Startzustand der Kaufseite mit leerem Umrechner: die Felder Sie bezahlen (Währung CHF) und Sie erhalten (REALU) sind noch ohne Betrag. Es ist noch kein Angebot abgerufen, deshalb erscheint unten keine Kauf-Schaltfläche.
@@ -4165,7 +4300,7 @@
36Kauf — Umrechner und Währungswahl
/>
- Ein gültiges Angebot ist geladen: der Umrechner zeigt 100 CHF gegen 1.00 RealU. Unten erscheint die verbindliche Kauf-Schaltfläche Jetzt verbindlich kaufen, mit der der Nutzer den Kauf abschliessen kann.
+ Ein gültiges Angebot ist geladen: der Umrechner zeigt 100 CHF gegen 1.00 REALU. Unten erscheint die verbindliche Kauf-Schaltfläche Jetzt verbindlich kaufen, mit der der Nutzer den Kauf abschliessen kann.
@@ -4273,7 +4408,7 @@
37Kauf — Freigabe-Hürden vor dem Angebot
/>
- Die Kursabfrage ist fehlgeschlagen. Der Info-Block zeigt die API-Fehlermeldung unverändert. Die App wählt keinen eigenen Text.
+ Die Kursabfrage ist fehlgeschlagen. Der Info-Block zeigt die API-Fehlermeldung unverändert, darunter die Schaltfläche Wiederholen, die das Angebot neu abruft. Die App wählt keinen eigenen Text.
@@ -4300,7 +4435,7 @@ 38Kauf — Verbindliche Bestätigung
- Sobald ein gültiges Angebot vorliegt, erscheint unten die Schaltfläche Jetzt verbindlich kaufen. Diese Zustände zeigen den laufenden Bestätigungsvorgang und die drei möglichen Fehlermeldungen, die als Snackbar erscheinen.
+ Sobald ein gültiges Angebot vorliegt, erscheint unten die Schaltfläche Jetzt verbindlich kaufen. Diese Zustände zeigen den laufenden Bestätigungsvorgang und drei Beispiele von API-Fehlermeldungen, die als Snackbar erscheinen. Beträge und Meldungen in den Goldens sind Testdaten.
@@ -4317,7 +4452,7 @@
38Kauf — Verbindliche Bestätigung
/>
- Die Bestätigung läuft gerade: die Schaltfläche Jetzt verbindlich kaufen zeigt einen Ladespinner. Der Nutzer sieht das unmittelbar nach dem Tippen, während die App den verbindlichen Kauf beim Server bestätigt.
+ Die Bestätigung läuft gerade: die Schaltfläche Jetzt verbindlich kaufen zeigt einen Ladespinner. Der Nutzer sieht das unmittelbar nach dem Tippen, während die App den verbindlichen Kauf beim Server bestätigt. Der Golden rendert nur die Schaltfläche.
@@ -4333,7 +4468,7 @@
38Kauf — Verbindliche Bestätigung
/>
- Die Bestätigung ist fehlgeschlagen. Die Snackbar zeigt die API-Fehlermeldung unverändert (Beispiel: Kauf konnte nicht bestätigt werden, bitte später erneut versuchen). Die App wählt keinen eigenen Text. Die Kauf-Schaltfläche darunter ist wieder bedienbar.
+ Die Bestätigung ist fehlgeschlagen. Die Snackbar zeigt die API-Fehlermeldung unverändert (im Golden The purchase could not be confirmed. Please try again later.). Die App wählt keinen eigenen Text. Die Kauf-Schaltfläche darunter ist wieder bedienbar.
@@ -4441,7 +4576,7 @@
39Zahlungsdetails nach dem Kauf
/>
- Ebenfalls der Reiter QR-Code, hier jedoch die Variante, bei der der Server den QR-Code als fertige SVG-Grafik liefert und die App diese direkt anzeigt. Inhaltlich derselbe Zweck wie der generierte QR-Code.
+ Ebenfalls der Reiter QR-Code, hier jedoch die Variante, bei der der Server den QR-Code als fertige SVG-Grafik liefert und die App diese direkt anzeigt. Inhaltlich derselbe Zweck wie der generierte QR-Code; der Golden verwendet ein Platzhalter-SVG.
@@ -4593,7 +4728,7 @@
41REALU verkaufen — Bankkonto verwalten
/>
- Das Bottom-Sheet Zahlungskonto hinzufügen im leeren Ausgangszustand mit dem Pflichtfeld IBAN und dem optionalen Feld Bezeichnung (optional). Mit Weiter wird die IBAN geprüft und das Konto gespeichert.
+ Das Bottom-Sheet Auszahlungskonto hinzufügen im leeren Ausgangszustand mit dem Pflichtfeld IBAN und dem optionalen Feld Bezeichnung (optional). Mit Weiter wird die IBAN geprüft und das Konto gespeichert.
@@ -4637,7 +4772,7 @@ 42REALU verkaufen — Bestätigen und Abschluss
- Das Bestätigungs-Sheet Verkauf prüfen und bestätigen mit der Zusammenfassung: Menge REALU 100, Betrag in CHF 100.0 und die Empfänger-IBAN. Der Button Bestätigen ist aktiv und löst den Verkauf aus.
+ Das Bestätigungs-Sheet Verkauf prüfen & bestätigen mit der Zusammenfassung: REALU 100, Betrag in CHF 100.0 und die Empfänger-IBAN. Der Button Bestätigen ist aktiv und löst den Verkauf aus.
@@ -4669,7 +4804,7 @@
42REALU verkaufen — Bestätigen und Abschluss
- Die Erfolgsmeldung nach einem abgeschlossenen Verkauf: ein blaues Häkchen, der Titel Verkauf erfolgreich und der Hinweis, dass der Betrag auf das angegebene Bankkonto ausgezahlt wird. Mit Schliessen kehrt der Nutzer zurück.
+ Die Erfolgsmeldung nach einem abgeschlossenen Verkauf: ein blaues Häkchen, der Titel Verkauf erfolgreich und der Hinweis, dass der Betrag auf das angegebene Bankkonto ausgezahlt wird. Mit Schließen kehrt der Nutzer zurück.
@@ -4805,7 +4940,7 @@ 44Schritt 2: Tausch REALU → ZCHF
/>
- Die Prüfkarte für den Tausch mit dem Titel REALU → ZCHF tauschen: Sie zeigt Sie senden 100 REALU und Sie erhalten ca. 100.00 ZCHF, den Hinweis zur Bestätigung über den BrokerBot sowie einen Bestätigen-Button. Nach dem Tippen muss der Tausch auf der BitBox freigegeben werden.
+ Die Prüfkarte für den Tausch mit dem Titel REALU → ZCHF tauschen: Sie zeigt Sie senden 100 REALU und Sie erhalten ≈ 100.00 ZCHF, den Hinweis zur Bestätigung über den BrokerBot sowie einen Bestätigen-Button. Nach dem Tippen muss der Tausch auf der BitBox freigegeben werden.
@@ -4881,7 +5016,7 @@
45Schritt 3: ZCHF an DFX senden
/>
- Nach dem Bestätigen der Einzahlung erscheint in Schritt 3 ein Ladeindikator mit dem Text ZCHF wird gesendet. Bestätigen Sie auf der Bitbox. Die Einzahlungs-Transaktion muss jetzt auf dem Gerät freigegeben werden.
+ Nach dem Bestätigen der Einzahlung erscheint in Schritt 3 ein Ladeindikator mit dem Text ZCHF wird gesendet. Bestätigen Sie auf der Bitbox. Die Einzahlungs-Transaktion muss jetzt auf dem Gerät freigegeben werden.
@@ -4941,7 +5076,7 @@
46Schritt 3: Einzahlung wiederholen (Fehlerfälle)<
/>
- Fehlerzustand, wenn die Einzahlung gar nicht auf die Blockchain gelangt ist: ein rotes Fehler-Symbol mit der Überschrift Einzahlung fehlgeschlagen und dem Hinweis, dass der Tausch abgeschlossen ist, die ZCHF-Einzahlung aber nicht gesendet werden konnte und die Mittel sicher sind. Über den Wiederholen-Button wird der Versuch erneut gestartet.
+ Fehlerzustand, wenn die Einzahlung gar nicht auf die Blockchain gelangt ist: ein rotes Fehler-Symbol mit der Überschrift Einzahlung fehlgeschlagen und darunter der Fehlermeldung (im Golden network error); ohne Meldung erscheint der Standardtext, dass der Tausch abgeschlossen ist, die ZCHF-Einzahlung aber nicht gesendet werden konnte und die Mittel sicher sind. Über den Wiederholen-Button wird der Versuch erneut gestartet.
@@ -4957,7 +5092,7 @@
46Schritt 3: Einzahlung wiederholen (Fehlerfälle)<
/>
- Fehler-Variante, wenn die Einzahlung bereits on-chain ist und nur die Bestätigung fehlt: rotes Fehler-Symbol mit der Überschrift Bestätigung ausstehend und dem Hinweis, dass die ZCHF-Einzahlung gesendet, aber noch nicht bei DFX bestätigt wurde. Der Wiederholen-Button setzt am Bestätigungsschritt wieder an.
+ Fehler-Variante, wenn die Einzahlung bereits on-chain ist und nur die Bestätigung fehlt: rotes Fehler-Symbol mit der Überschrift Bestätigung ausstehend und darunter der Fehlermeldung (im Golden confirm failed); ohne Meldung erscheint der Standardtext, dass die ZCHF-Einzahlung gesendet, aber noch nicht bei DFX bestätigt wurde. Der Wiederholen-Button setzt am Bestätigungsschritt wieder an.
@@ -5273,7 +5408,7 @@ 51BitBox — Adresswiederherstellung
/>
- Die Adresswiederherstellungs-Seite, die als Vollbild erscheint, wenn eine bereits gespeicherte BitBox-Wallet ihre Adresse verloren hat. Optisch identisch zum normalen Startzustand (BitBox verbinden, nur Abbrechen), aber erneutes Koppeln füllt die fehlende Adresse in die bestehende Wallet nach, statt eine zweite anzulegen.
+ Die Adresswiederherstellungs-Seite, die als Vollbild erscheint, wenn eine bereits gespeicherte BitBox-Wallet ihre Adresse verloren hat. Optisch identisch zum normalen Startzustand (BitBox verbinden, nur Abbrechen), aber erneutes Koppeln füllt die fehlende Adresse in die bestehende Wallet nach, statt eine zweite anzulegen. Abbrechen entfernt die defekte lokale Wallet und startet das Onboarding neu.
@@ -5365,7 +5500,7 @@ 52KYC — E-Mail und 2-Faktor-Authentifizierung
- Die Seite E-Mail-Bestätigung im Ausgangszustand. Sie fordert den Nutzer auf, die zugesandte E-Mail zu öffnen, auf den Bestätigungslink zu klicken und anschliessend zurückzukehren.
+ Die Seite E-Mail-Bestätigung im Ausgangszustand. Sie fordert den Nutzer auf, die zugesandte E-Mail zu öffnen, auf den Bestätigungslink zu klicken und anschliessend hierher zurückzukehren.
@@ -5381,7 +5516,7 @@
52KYC — E-Mail und 2-Faktor-Authentifizierung
- Die Seite Willkommen zurück für wiederkehrende Nutzer, deren Konto bereits existiert. Sie bittet darum, die soeben zugesandte E-Mail über den Link zu bestätigen, um mit dem bestehenden Konto fortzufahren.
+ Die Seite Willkommen zurück! für wiederkehrende Nutzer, deren Konto bereits existiert. Sie bittet darum, die soeben zugesandte E-Mail über den Link zu bestätigen, um mit dem bestehenden Konto fortzufahren.
@@ -5477,7 +5612,7 @@
52KYC — E-Mail und 2-Faktor-Authentifizierung
- Der Nutzer hat Code erneut senden angetippt: der Resend-Button ist deaktiviert und zeigt die Sende-Beschriftung. Der Weiter-Button bleibt unverändert.
+ Der Nutzer hat Code erneut senden angetippt: der Resend-Button ist deaktiviert und zeigt Wird gesendet.... Der Weiter-Button bleibt unverändert.
@@ -5493,7 +5628,7 @@
52KYC — E-Mail und 2-Faktor-Authentifizierung
- Rote SnackBar mit der Meldung Der Code ist falsch, wenn ein ungültiger 2FA-Code eingegeben wurde. Der Nutzer kann den Code korrigieren und erneut absenden.
+ Rote SnackBar mit der Server-Fehlermeldung (im Golden invalid code), wenn ein ungültiger 2FA-Code eingegeben wurde. Der Nutzer kann den Code korrigieren und erneut absenden.
@@ -5509,7 +5644,7 @@
52KYC — E-Mail und 2-Faktor-Authentifizierung
- Rote SnackBar, wenn das Versenden des Codes per E-Mail fehlschlägt (etwa bei einer Ratenbegrenzung). Die Meldung weist auf ein Problem beim Senden der Mail hin.
+ Rote SnackBar, wenn das Versenden des Codes per E-Mail fehlschlägt (etwa bei einer Ratenbegrenzung). Die SnackBar zeigt die Server-Meldung (im Golden rate limited).
@@ -5525,7 +5660,7 @@ 53KYC — Registrierung
test/goldens/screens/kyc/
-
12 Screens
+
11 Screens
- Die Registrierung erfasst persönliche Daten, Adresse und Steueransässigkeit in einem dreistufigen Formular. Diese Einträge zeigen die einzelnen Schritte, vorausgefüllte Daten, geöffnete Dropdowns, Validierungsfehler sowie die Zustände beim Absenden inklusive BitBox-Signatur.
+ Die Registrierung erfasst Empfehlungscode (optional, siehe 277), persönliche Daten, Adresse und Steueransässigkeit in einem vierstufigen Formular. Diese Einträge zeigen die einzelnen Schritte, vorausgefüllte Daten, geöffnete Dropdowns, Validierungsfehler sowie die Zustände beim Absenden inklusive BitBox-Signatur.
@@ -5553,7 +5688,7 @@
53KYC — Registrierung
/>
- Der Einstieg in die Registrierung auf dem ersten Schritt Persönliche Daten, mit leerem Formular. Hier werden Kontotyp, Vor- und Nachname, Geburtsdatum, Telefonnummer und Staatsangehörigkeit erfasst.
+ Der Schritt Persönliche Daten der Registrierung (zweiter Schritt nach dem Empfehlungscode), mit leerem Formular. Hier werden Kontotyp, Vor- und Nachname, Geburtstag, Telefonnummer und Staatsangehörigkeit erfasst.
@@ -5585,7 +5720,7 @@
53KYC — Registrierung
/>
- Das geöffnete Kontotyp-Auswahlmenü im persönlichen Schritt. Das Overlay zeigt die auswählbaren Kontotypen.
+ Das geöffnete Kontotyp-Auswahlmenü im persönlichen Schritt. Das Overlay zeigt im Golden nur Natürliche Person.
@@ -5617,7 +5752,7 @@
53KYC — Registrierung
/>
- Der zweite Registrierungsschritt Residenz mit den Feldern für Strasse, Hausnummer, PLZ, Ort und Land. Der Seiten-Pager steht auf Seite 2 von 3.
+ Der Registrierungsschritt Residenz mit den Feldern Strasse, Nummer, PLZ, Stadt und Land. Einen Seitenzähler zeigt die App nicht.
@@ -5649,7 +5784,7 @@
53KYC — Registrierung
/>
- Der dritte Registrierungsschritt Steueransässigkeit zur Angabe des Landes des steuerlichen Wohnsitzes. Der Pager steht auf Seite 3 von 3.
+ Der letzte Registrierungsschritt Steueransässigkeit zur Angabe des Landes des steuerlichen Wohnsitzes.
@@ -5681,7 +5816,7 @@
53KYC — Registrierung
/>
- Rote SnackBar Registrierung fehlgeschlagen, wenn die Übermittlung abgelehnt wird (hier durch eine abgebrochene Signatur ausgelöst). Die Formulareingaben bleiben erhalten.
+ Rote SnackBar, wenn die Übermittlung fehlschlägt: bei abgebrochener BitBox-Signatur (hier) Signatur abgebrochen — bitte BitBox erneut bestätigen, sonst die generische bzw. Server-Meldung. Die Formulareingaben bleiben erhalten.
@@ -5805,7 +5940,7 @@
54KYC — Staatsangehörigkeit
/>
- Konnte die Länderliste nicht geladen werden, erscheint ein roter Hinweistext mit einem Erneut-versuchen-Button anstelle des Dropdowns.
+ Konnte die Länderliste nicht geladen werden, erscheint im Feldrahmen der Hinweis Die Länderliste konnte nicht geladen werden. Bitte versuchen Sie es erneut. mit dem Button Wiederholen.
@@ -5837,7 +5972,7 @@
54KYC — Staatsangehörigkeit
/>
- Rote SnackBar, wenn das Setzen der Staatsangehörigkeit fehlschlägt. Der darunterliegende Formularkörper bleibt im Ruhezustand.
+ Rote SnackBar mit der Server-Meldung (im Golden network error), wenn das Setzen der Staatsangehörigkeit fehlschlägt. Der darunterliegende Formularkörper bleibt im Ruhezustand.
@@ -5913,7 +6048,7 @@
55KYC — Finanzdaten
/>
- Die eigenständige Ladeseite Finanzdaten mit Titelleiste und zentriertem Spinner. Zwischenzustand, während die Finanzdaten-Abfrage vorbereitet wird.
+ Die eigenständige Ladeseite Finanzdaten mit Titelleiste und zentriertem Spinner. Zwischenzustand, während die Finanzdaten-Abfrage vorbereitet wird. Gleicher Golden wie 180.
@@ -6261,7 +6396,7 @@
56KYC — Identitätsprüfung und Wallet-Verknüpfu
/>
- Beim Fehlschlag der Verknüpfung erscheint eine rote Registrierung-fehlgeschlagen-SnackBar über einem zentrierten Spinner.
+ Beim Fehlschlag der Verknüpfung erscheint eine rote SnackBar mit der Fehlermeldung (im Golden network error) über einem zentrierten Spinner.
@@ -6353,7 +6488,7 @@
57KYC — Status- und Ergebnisseiten
/>
- Die Seite Daten werden geprüft: der zuletzt abgeschlossene Schritt (hier die Identitätsprüfung) befindet sich noch in Prüfung. Über den Aktualisieren-Button kann der Nutzer den Status neu abrufen.
+ Die Seite Daten werden geprüft: der zuletzt abgeschlossene Schritt (hier IDENT, die Identitätsprüfung) befindet sich noch in Prüfung. Über den Aktualisieren-Button kann der Nutzer den Status neu abrufen.
@@ -6369,7 +6504,7 @@
57KYC — Status- und Ergebnisseiten
/>
- Die Abschlussseite Verifikation abgeschlossen: der Nutzer hat nun genug Rechte, um die gewünschte Aktion auszuführen, und kann die Seite über Schliessen verlassen.
+ Die Abschlussseite Verifikation abgeschlossen: der Nutzer hat nun genug Rechte, um die gewünschte Aktion auszuführen, und kann die Seite über Schließen verlassen.
@@ -6385,7 +6520,7 @@
57KYC — Status- und Ergebnisseiten
/>
- Die allgemeine Fehlerseite Fehler beim Laden mit der konkreten Fehlermeldung. Sie rät, es später erneut zu versuchen oder den Support zu kontaktieren.
+ Die allgemeine Fehlerseite Fehler beim Laden mit der konkreten Fehlermeldung (im Golden Something went wrong).
@@ -6657,7 +6792,7 @@
61Netzwerk — Umschalten
/>
- Die Netzwerk-Einstellung listet Mainnet (mit blauem Haken als aktuell aktives Netz) und Testnet. Der Nutzer hat gerade auf Testnet getippt, um das Netzwerk zu wechseln: Statt eines Hakens zeigt Testnet einen kleinen Ladekreis, solange der Wechsel im Gange ist. Diesen Zwischenzustand sieht man nur für den Moment, in dem der Netzwerkwechsel noch verarbeitet wird.
+ Die Netzwerk-Einstellung listet Mainnet (mit blauem Haken als aktuell aktives Netz) und Testnet. Der Nutzer hat gerade auf Testnet getippt, um das Netzwerk zu wechseln: Statt eines Hakens zeigt Testnet einen kleinen Ladekreis (im Golden nur als Punkt sichtbar), solange der Wechsel im Gange ist. Diesen Zwischenzustand sieht man nur für den Moment, in dem der Netzwerkwechsel noch verarbeitet wird.
@@ -6684,7 +6819,7 @@ 62Seed-Anzeige — Ladezustand
- Der Bildschirm „Wallet-Sicherung" zeigt die 12 Wiederherstellungs-Wörter, mit denen die Wallet gesichert und wiederhergestellt werden kann. Bevor die Wörter im Speicher liegen, wird anstelle der Seed-Karte ein Ladeindikator eingeblendet.
+ Der Bildschirm «Wallet-Sicherung» zeigt die 12 Wiederherstellungs-Wörter, mit denen die Wallet gesichert und wiederhergestellt werden kann. Bevor die Wörter im Speicher liegen, wird anstelle der Seed-Karte ein Ladeindikator eingeblendet.
@@ -6837,7 +6972,7 @@
64Sicherheit — Umschaltvorgang und Fehler
/>
- Schlägt das Aktivieren oder Deaktivieren der Biometrie fehl (etwa durch Sperre oder fehlende Registrierung), erscheint am unteren Rand eine rote Meldung: „Die Einstellung für die biometrische Entsperrung konnte nicht geändert werden." Ein reiner Abbruch des Betriebssystem-Dialogs löst diese Meldung nicht aus.
+ Schlägt das Aktivieren oder Deaktivieren der Biometrie fehl (etwa durch Sperre oder fehlende Registrierung), erscheint am unteren Rand eine rote Meldung: «Die Einstellung für die biometrische Entsperrung konnte nicht geändert werden.» Ein reiner Abbruch des Betriebssystem-Dialogs löst diese Meldung nicht aus.
@@ -6881,7 +7016,7 @@ 65Steuerbericht
/>
- Der Startzustand des Steuerbericht-Screens: der Hinweis „Hier können Sie Ihren Steuerbericht für ein spezifisches Datum generieren.", darunter das Datumsfeld (vorbelegt mit dem letzten Jahresende, hier 31.12.2025) und der blaue Knopf PDF. Diesen Zustand sehen Sie beim Öffnen des Screens; ein Tippen auf PDF startet die Erstellung für das gewählte Datum.
+ Der Startzustand des Steuerbericht-Screens: der Hinweis «Hier können Sie Ihren Steuerbericht für ein spezifisches Datum generieren.», darunter das Datumsfeld (vorbelegt mit dem letzten Jahresende, hier 31.12.2025) und der blaue Knopf PDF. Diesen Zustand sehen Sie beim Öffnen des Screens; ein Tippen auf PDF startet die Erstellung für das gewählte Datum.
@@ -6897,7 +7032,7 @@
65Steuerbericht
/>
- Nach dem Tippen auf das Datumsfeld öffnet sich der Material-Datumsdialog (Select date), hier auf den vorbelegten Stichtag Mittwoch, 31. Dez. 2025 gesetzt. Hier wählen Sie das Stichdatum des Berichts und bestätigen mit OK oder brechen mit Cancel ab; der Screen im Hintergrund ist während der Auswahl abgedunkelt.
+ Nach dem Tippen auf das Datumsfeld öffnet sich der Material-Datumsdialog (Select date), hier auf den vorbelegten Stichtag Wed, Dec 31 (31.12.2025) gesetzt. Hier wählen Sie das Stichdatum des Berichts und bestätigen mit OK oder brechen mit Cancel ab; der Screen im Hintergrund ist während der Auswahl abgedunkelt. Der Golden ist ohne deutsche Locale aufgenommen, daher englische Beschriftungen und ein überlagertes Kalenderraster.
@@ -6913,7 +7048,7 @@
65Steuerbericht
/>
- Schlägt die Erstellung fehl, erscheint am unteren Rand eine rote Fehler-Meldung: „Konnte Steuerausweis nicht generieren.". Der Screen bleibt unverändert bedienbar, sodass Sie den Vorgang direkt erneut anstossen können.
+ Schlägt die Erstellung fehl, erscheint am unteren Rand eine rote Fehler-Meldung mit der Servermeldung (im Golden «Konnte Steuerausweis nicht generieren.»). Der Screen bleibt unverändert bedienbar, sodass Sie den Vorgang direkt erneut anstossen können.
@@ -7097,7 +7232,7 @@ 67Nutzerdaten — Laden und Fehler
/>
- Der Fehlerzustand beim Laden. Mittig steht der Text Beim Laden der Nutzerdaten ist ein Fehler aufgetreten. Der Nutzer sieht das, wenn die Nutzerdaten nicht vom Server geladen werden konnten.
+ Der Fehlerzustand beim Laden. In der Bildmitte steht linksbündig der Text Beim Laden der Nutzerdaten ist ein Fehler aufgetreten. Der Nutzer sieht das, wenn die Nutzerdaten nicht vom Server geladen werden konnten.
@@ -7265,7 +7400,7 @@
69Daten bearbeiten — Verarbeitung und Ergebnis
- Der Fehlerzustand eines Bearbeiten-Formulars: Titel Fehler beim Laden, eine Beschreibung mit der konkreten Fehlermeldung und dem Hinweis, bei anhaltendem Fehler den Support zu kontaktieren, sowie der Button Aktualisieren.
+ Der Fehlerzustand eines Bearbeiten-Formulars: Titel Fehler beim Laden, darunter die Fehlermeldung des Servers (im Golden Something went wrong) sowie der Button Aktualisieren.
@@ -7292,7 +7427,7 @@ 70Rechtliche Hinweise — Disclaimer-Ablauf
- Der Disclaimer wird als mehrstufiger Ablauf vor der ersten Nutzung angezeigt. Ein Fortschrittsbalken oben zeigt, an welcher Stelle der Schritte man sich befindet; unten führen die Buttons Ablehnen und Zustimmen weiter.
+ Der Disclaimer wird als fünfstufiger Ablauf im Software-Wallet-Onboarding angezeigt (zwei Hinweisseiten, Rechtsdokumente RealUnit, Aktionariat, DFX; siehe Spec 09). Ein Fortschrittsbalken oben zeigt, an welcher Stelle der Schritte man sich befindet; unten führen die Buttons Ablehnen und Zustimmen weiter.
@@ -7629,7 +7764,7 @@
74Support — Meine Tickets
/>
- Die geladene Ticket-Liste mit zwei Einträgen: ein offenes Ticket mit grünem Statuspunkt und ein geschlossenes mit grauem Punkt, jeweils mit Titel und Erstellungsdatum. Der Nutzer sieht diese Ansicht, wenn Tickets vorhanden sind, und kann einen Eintrag antippen, um den Chat zu öffnen.
+ Die geladene Ticket-Liste mit zwei Einträgen: ein offenes Ticket mit grünem Statuspunkt und ein geschlossenes mit blaugrauem Punkt, jeweils mit Titel und Erstellungsdatum. Der Nutzer sieht diese Ansicht, wenn Tickets vorhanden sind, und kann einen Eintrag antippen, um den Chat zu öffnen.
@@ -7645,7 +7780,7 @@
74Support — Meine Tickets
/>
- Die Ticket-Übersicht im Fehlerzustand mit dem zentrierten Text Tickets konnten nicht geladen werden. Diese Meldung erscheint, wenn die Ticket-Liste nicht vom Server abgerufen werden konnte.
+ Die Ticket-Übersicht im Fehlerzustand mit dem zentrierten Fehlertext (im Golden Tickets konnten nicht geladen werden.). Diese Meldung erscheint, wenn die Ticket-Liste nicht vom Server abgerufen werden konnte.
@@ -7753,7 +7888,7 @@ 75Support — Chat
/>
- Der Ticket-Chat im Fehlerzustand mit dem zentrierten Text Chat konnte nicht geladen werden. Diese Meldung erscheint, wenn der Chatverlauf eines Tickets nicht abgerufen werden konnte.
+ Der Ticket-Chat im Fehlerzustand mit dem zentrierten Fehlertext (im Golden Chat konnte nicht geladen werden.). Diese Meldung erscheint, wenn der Chatverlauf eines Tickets nicht abgerufen werden konnte.
@@ -7797,7 +7932,7 @@ 76Debug-Auth — Adresse und Nachricht abrufen
/>
- Der Startzustand des Debug-Auth-Tools: sichtbar sind nur das leere Feld „Adresse" mit dem Platzhalter 0x... und der Textbutton „Signierte Nachricht abrufen". So sieht der Entwickler den Screen beim Öffnen, wenn keine Adresse gespeichert ist.
+ Der Startzustand des Debug-Auth-Tools: sichtbar sind nur das leere Feld «Adresse» mit dem Platzhalter 0x... und der Textbutton «Signierte Nachricht abrufen». So sieht der Entwickler den Screen beim Öffnen, wenn keine Adresse gespeichert ist.
@@ -7813,7 +7948,7 @@
76Debug-Auth — Adresse und Nachricht abrufen
/>
- Während die signierte Nachricht vom Server geladen wird (isLoading), ist der Button „Signierte Nachricht abrufen" ausgegraut und deaktiviert. Der Signatur-Block fehlt noch, weil bislang keine Nachricht vorliegt.
+ Während die signierte Nachricht vom Server geladen wird (isLoading), ist der Button «Signierte Nachricht abrufen» ausgegraut und deaktiviert. Der Signatur-Block fehlt noch, weil bislang keine Nachricht vorliegt.
@@ -7829,7 +7964,7 @@
76Debug-Auth — Adresse und Nachricht abrufen
/>
- Schlägt der Abruf oder die Authentifizierung fehl, erscheint unter dem Button eine rote Fehlerbox mit dem Fehlertext, hier „Authentifizierung fehlgeschlagen (401).". Der Signatur-Block bleibt aus, da keine gültige Nachricht geladen wurde.
+ Schlägt der Abruf oder die Authentifizierung fehl, erscheint unter dem Button eine rote Fehlerbox mit dem Fehlertext, hier «Authentifizierung fehlgeschlagen (401).». Der Signatur-Block bleibt aus, da keine gültige Nachricht geladen wurde.
@@ -7873,7 +8008,7 @@ 77Debug-Auth — Signieren und Authentifizieren
- Sobald die Nachricht geladen ist, erscheinen ein Trenner, die Überschrift „Signierte Nachricht:", die Challenge in einer grauen Box, das Feld „Signatur" und der blaue Button „Authentifizieren". Der Entwickler signiert die Nachricht extern und trägt die Signatur hier ein.
+ Sobald die Nachricht geladen ist, erscheinen ein Trenner, die Überschrift «Signierte Nachricht:», die Challenge in einer grauen Box, das Feld «Signatur» und der blaue Button «Authentifizieren». Der Entwickler signiert die Nachricht extern und trägt die Signatur hier ein.
@@ -7889,7 +8024,7 @@
77Debug-Auth — Signieren und Authentifizieren
- Ein Tippen auf die graue Nachrichtenbox kopiert den Text in die Zwischenablage und zeigt unten die grüne Snackbar „In die Zwischenablage kopiert". So lässt sich die Nachricht bequem in ein externes Signier-Tool übernehmen.
+ Ein Tippen auf die graue Nachrichtenbox kopiert den Text in die Zwischenablage und zeigt unten die grüne Snackbar «In die Zwischenablage kopiert». So lässt sich die Nachricht bequem in ein externes Signier-Tool übernehmen.
@@ -7905,7 +8040,7 @@
77Debug-Auth — Signieren und Authentifizieren
- Nach dem Tippen auf „Authentifizieren" wechselt der Button in den Ladezustand (hellblau mit Spinner), während die eingetragene Signatur gegen den Server geprüft wird. Adress- und Signatur-Feld bleiben dabei sichtbar.
+ Nach dem Tippen auf «Authentifizieren» wechselt der Button in den Ladezustand (hellblau mit Spinner), während die eingetragene Signatur gegen den Server geprüft wird. Adress- und Signatur-Feld bleiben dabei sichtbar.
@@ -7949,7 +8084,7 @@ 78Telefonnummer-Eingabe
/>
- Zeigt den Ausgangszustand des Telefonnummer-Felds: die Überschrift Telefonnummer, links das Vorwahl-Dropdown mit der Standardvorwahl +41 (Schweiz, alternativ +49 für Deutschland) und rechts das noch leere Rufnummernfeld mit dem Platzhalter 1231234567. So sehen Nutzer das Feld, bevor sie eine Nummer eingeben; die Länge der Nummer wird erst serverseitig geprüft.
+ Zeigt den Ausgangszustand des Telefonnummer-Felds: die Überschrift Telefonnummer, links das frei editierbare Vorwahlfeld mit der Standardvorwahl +41 und rechts das noch leere Rufnummernfeld mit dem Platzhalter 1231234567. So sehen Nutzer das Feld, bevor sie eine Nummer eingeben; die Länge der Nummer wird erst serverseitig geprüft.
@@ -7961,7 +8096,7 @@ 78Telefonnummer-Eingabe
-
79Insider-Freischaltung — Bezahlen & Senden
+
79Insider-Freischaltung — Bezahlen
test/goldens/screens/dashboard/
@@ -7976,9 +8111,10 @@
79Insider-Freischaltung — Bezahlen & Senden
- Die Funktionen Bezahlen (OpenCryptoPay) und Senden (REALU-Transfer
- an eine andere Wallet) sind bewusst versteckt (Soft-Launch) und erscheinen erst
- nach einer einmaligen Freischaltung auf dem Gerät.
+ Die Funktion Bezahlen (OpenCryptoPay) ist bewusst versteckt (Soft-Launch)
+ und erscheint erst nach einer einmaligen Freischaltung auf dem Gerät.
+ Senden (REALU-Transfer an eine andere Wallet) ist davon unabhängig über
+ Einstellungen → Wallet-Adresse und über Empfangen erreichbar.
Apple App Store (de-DE)
- - App-Name (max 30)
RealUnit ↗
+ - App-Name (max 30)
RealUnit Wallet ↗
- Untertitel (max 30)
Sicher. Einfach. Unabhängig. ↗
- Werbetext (max 170)
- Kaufe, halte und verkaufe RealUnit Tokens sicher mit der RealUnit App ↗
- Beschreibung (max 4000) ↗
Die offizielle App der RealUnit Schweiz AG – für den einfachen, sicheren Kauf und die bankenunabhängige Verwahrung der RealUnit Aktientoken. Direkt in Ihrer Hand.
@@ -9067,7 +11828,7 @@ Screenshots — iPad Pro 13" Display (2752×2064)
Google Play Store (de-DE)
- - Titel (max 50)
RealUnit Wallet ↗
+ - Titel (max 30)
RealUnit Wallet ↗
- Kurzbeschreibung (max 80)
- RealUnit Aktientoken kaufen, verwahren & verwalten – bankenunabhängig. ↗
- Lang-Beschreibung (max 4000, HTML) ↗
Die offizielle App der RealUnit Schweiz AG – für den einfachen, sicheren Kauf und die bankenunabhängige Verwahrung der RealUnit Aktientoken. Direkt in Ihrer Hand.
@@ -9249,6 +12010,35 @@
Registrierungsvereinbarung ↗
+
diff --git a/docs/referral-programme.md b/docs/referral-programme.md
new file mode 100644
index 000000000..0c3447533
--- /dev/null
+++ b/docs/referral-programme.md
@@ -0,0 +1,554 @@
+# Referral programme and promo code
+
+Implementation spec for this repository and `realunit.app`. The DFX API is
+the decision authority. This app is a rendering layer.
+
+## API contract (`api.dfx.swiss` / `dev.api.dfx.swiss`)
+
+Authenticated routes use the existing Bearer session.
+
+Live contract: `GET`/`POST /v1/realunit/referral/*` on `api.dfx.swiss` and `dev.api.dfx.swiss`
+(70 REALU gate re-checked at credit,
+quarterly cap 100, 3-month expiry, promo `redemptionCap` required, promo
+`minBuyRealu` default 200, referral first-buy floor 200, KYC + late bind, CORS for `realunit.app`, NestJS drop-in
+`RealUnitReferralController`). Credit is evaluated on Aktionariat
+**settlement** (whole REALU shares) and again when KYC Level 30 is
+reached — not on `PUT /v1/realunit/buy/{id}/confirm` (payment
+instructions only). On mount, `ReferralAuthHydrateInterceptor` rewrites `req.user.id` to the
+JWT wallet address so a numeric RealUnitLegal user id cannot miss
+holdings or prize send.
+
+Authenticated calls hydrate mail, firstName, live
+`kyc.level` and Aktionariat `confirmedDate` from `GET /v2/user`,
+`GET /v2/kyc` (`x-kyc-code` from `kyc.hash`, same as this app),
+`GET /v2/user/profile` and `GET /v1/realunit/registration` with the same
+Bearer this app already sends. First purchase (if the settlement hook
+is not yet wired) is the earliest inbound mint from `0x0` on
+`GET /v1/realunit/account/{address}/history`, paginated with `first=200`
+and `after=pageInfo.endCursor`. A successful authenticated GET may
+credit from that mint, and also when KYC Level 30 arrives on a wallet
+that already has a recorded first purchase. The server persists before
+the 200 so a retry cannot double-pay. A failed live holding lookup
+does not credit from a stale snapshot (TB Ziff. 2). GET summary and
+POST invites then return `503 { "code": "UNAVAILABLE" }` so this app
+retries instead of showing «not eligible». Live `GET /v1/realunit/account`
+404 `Account not found` is a known-zero holding (tile stays hidden), not
+unknown. The `/v1/realunit/referral/*` routes are mounted on
+`api.dfx.swiss`; should a deployment ever lack them,
+public lookup returns NestJS `Cannot GET`, and the app maps that body
+(and `503 UNAVAILABLE`) to the unavailable retry copy, not Nest internals. `onAccountMerge` /
+`mergeWallets` is idempotent so a DFX `register/wallet` retry after
+the dropped key is gone does not 404.
+
+### `GET /v1/realunit/referral/summary`
+
+```json
+{
+ "eligible": true,
+ "termsAccepted": true,
+ "minHolding": 70,
+ "openCount": 1,
+ "creditedCount": 2,
+ "realuSum": 40,
+ "chfSum": 55.2,
+ "sharePriceLabel": "Aktienkurs",
+ "sharePrice": 1.38
+}
+```
+
+`eligible` is the dashboard/settings gate (KYC-verified, holding ≥ 70
+REALU, not an employee). The app must not recompute
+shareholder status or the 70 REALU holding locally.
+A failed first load hides the tile; returning to the foreground or to
+this route (pop back from Settings) retries the summary without a
+loading flash so a later mount can open it, and a blip does not hide a
+tile that is already shown. While that GET failed (unmounted Nest
+`Cannot GET`, 503, timeout) the gate is also polled so the tile can
+open without backgrounding the app. Polling runs only while this
+route is current and the app is in the foreground. A 200 with
+`eligible: false` is not polled.
+`sharePrice` is the live Aktienkurs (`GET /v1/realunit/price` `chf`);
+the total tile uses `realuSum * sharePrice` (running value). `chfSum`
+is the sum of frozen payout CHF for history. `sharePriceLabel` is the
+API token `Aktienkurs` (never «aktueller NAV»). The tile localizes it
+(DE «Aktienkurs», EN «Share price»).
+
+### `GET /v1/realunit/referral/terms`
+
+```json
+{
+ "version": "2026-08-26",
+ "markdown": "…",
+ "markdownEn": "…"
+}
+```
+
+The displayed text is `assets/legal/referral_terms_*.md`. The app renders
+that in-app (`rootBundle`, like `LegalDocumentPage`). PUT accept version
+`2026-08-26` matching the files.
+
+### `PUT /v1/realunit/referral/terms/accept`
+
+Body: `{ "accepted": true, "version": "2026-08-26" }`. `version` is the
+bundled terms version matching the files (`2026-08-26`).
+
+### `POST /v1/realunit/referral/invites`
+
+Body: `{ "guestName": "Alice" }`. Empty, whitespace-only, or
+format-character-only names are **400** (`guestName required`). The
+server applies the same folds as the app (ZWSP/bidi stripped, Unicode
+spaces collapsed); the app caps the name at 80 characters, the API
+accepts up to 256.
+
+Response:
+
+```json
+{
+ "code": "AB12CD",
+ "url": "https://realunit.app/invite/AB12CD",
+ "guestName": "Alice",
+ "copyText": "Hey Alice, Björn lädt dich ein zu RealUnit: https://realunit.app/invite/AB12CD",
+ "copyTextEn": "Hey Alice, Björn invites you to RealUnit: https://realunit.app/invite/AB12CD",
+ "inviterName": "Björn"
+}
+```
+
+The server generates code, URL, and share text. `inviterName` is the
+Empfehler display name so a missing `copyText` still shares
+«Hey Alice, Björn …» instead of «Hey Alice, RealUnit …». A later
+`GET /v2/user/profile` firstName (or Kontozusammenführung) rewrites
+open-invite `copyText` / `inviterName` so a first invite created
+while the name was still the wallet address is not stuck as
+«Hey Alice, tritt RealUnit bei». Wallet and numeric ids stay out of
+the prize-mail greeting too; unsent mail waits for a real `mail`
+address instead of sending to `0x…`. The prize confirmation is HTML at
+send (`html` / `htmlEn` from the plaintext Anzahl / Datum / frozen CHF);
+Überwachung stays the compact plaintext body. The app renders
+`copyText` / `copyTextEn` 1:1 except `http://`, protocol-relative
+`//realunit.app`, `www.realunit.app`, and scheme-less
+`realunit.app/…` are folded onto
+`https://realunit.app` (`dev.realunit.app` is left unchanged). A scheme-less or `//` invite/promo URL keeps
+its path (`/promo/…` is not rewritten to `/invite/`). Empty EN falls
+back to DE. Guest names are a single line (newlines/tabs become spaces)
+and capped at 80 characters in the app. Submit writes the sanitized
+name back into the field before POST. The name field autofocuses and
+accepts given-name autofill.
+
+### `GET /v1/realunit/referral/invites`
+
+List of the current user's invites (bare array or `{ "invites": [...] }`).
+Each row includes `copyText` / `copyTextEn` / `inviterName` so copy/share
+on overview can name the Empfehler when the server omits share text.
+The Empfehler list is **Open** or **Credited** only. Bound and Review
+are folded to Open server-side so the Empfehler cannot see the invitee’s
+registration or purchase progress (TB Ziff. 7). Admin relationships keep
+the true status. `Deleted`, `Expired` and `Rejected` rows are omitted.
+Counts of `Open` and `Credited` come from the summary. Open invites can be
+copied and shared again (including a missing/blank
+guestName as a nameless invite, and including a missing created
+timestamp, and including a missing/blank status treated as Open).
+If a backend still returns `Bound` or `Review`, the app treats them as
+open so copy/share stay until Credited. An unknown non-terminal status
+is treated as open so a new API spelling cannot hide copy/share;
+credited/paid and deleted/expired/rejected are not open. Pending
+Open/Bound/Review rows are **Deleted** after 90 days.
+Invitee names stay hidden — never registration, verification, or purchases of
+the invited person. If this list call fails, the app still shows the
+summary tiles (open/credited/total) and omits the copy/share rows on
+first load; a later reload keeps previous rows. Retry is shown for a
+list error even when those rows stay on screen, and when the summary
+open count has no matching rows. List Retry is keyboard-focused when
+no open-invite rows are on screen. Retry reloads only the list so a
+summary outage cannot hide the counts. List Retry stays on the
+list-error copy in the loading state so a second tap is ignored
+(the rows are not replaced with only the loading spinner). List Retry is ignored while an
+overview refresh is in flight; a slower list GET is discarded so it
+cannot roll back counts after create.
+
+### `POST /v1/realunit/referral/bind`
+
+Body: `{ "code": "AB12CD" }`. Binds an invite or promo code.
+
+```json
+{
+ "kind": "Promo",
+ "campaignText": "…",
+ "campaignTextEn": "…",
+ "minBuyRealu": 200,
+ "validUntil": "2026-09-07T00:00:00Z",
+ "redemptionCap": 100
+}
+```
+
+Invite binds return `inviterName` / `inviteeName` (same fields as lookup)
+so a late Universal-Link bind can show the registration recognition
+copy. Promo `campaignText` is shown 1:1 in a dialog.
+
+`kind` is `Invite` or `Promo`. If `kind` is omitted, campaign/action text
+without an inviter name is treated as promo so the confirmation dialog
+still appears. The API rejects self-referral, double-bind, and promo+invite
+stacking. The inviter's referral prize is due only when the invitee's first
+completed REALU buy is at least 200, checked on the server. Promo credit
+uses the promo code's own `minBuyRealu` (default 200). A first buy below
+the applicable floor creates no later claim.
+`redemptionCap` is required — no unlimited option.
+
+### `GET /v1/realunit/referral/code/:code` (public)
+
+Landing payload for `realunit.app/invite/…` and `/promo/…`.
+The server strips ZWSP/bidi, unwraps `/invite|promo/{code}` (including a
+pasted `https://realunit.app/invite/…`), and upper-cases before lookup.
+Empty after that fold is **400**. Bound, credited, rejected and
+cap-spent codes are **410** `{ code: SPENT }` so the landing does not
+greet with names after the invite is used. 90-day delete and disabled
+promo are **410** `{ code: EXPIRED }`. Unknown is **404**. HTTP 400/404/409/410/422 mean the code is invalid or spent. 5xx, 401, 408
+and 429 are transport failures — the registration field must not show
+«invalid», and the landing shows «unavailable». Persist failure is
+`503 { "code": "UNAVAILABLE" }` (retry, not expired). A NestJS 404 whose
+message starts with `Cannot GET` / `Cannot POST` means the route is not
+mounted yet: treat it as unavailable and keep a stashed code for retry.
+
+### `GET /v1/realunit/referral/payouts`
+
+Bare array or `{ "payouts": [...] }`. Each row carries `amount` (whole REALU;
+a fractional API value is truncated, never rounded up),
+`created`, and `chfValue` frozen at credit. The app never recomputes that
+CHF amount from the live share
+price. The API returns **Settled** rows only (Offerte Punkt 4) with public
+fields (`id`, `amount`, `chfValue`, `created`, `kind`, `status`,
+`txHash`). `userId` and `inviteId` stay off this list. Pending
+and failed payouts stay out of history until the transfer
+is confirmed. The server persists the broadcast `txHash` while the
+row is still Pending and confirms that receipt on retry, so a
+restart does not send a second 20 REALU. Non-settled rows are dropped if a payload includes them. Missing
+status is treated as not settled (this list is Settled-only). Duplicate payout rows (same id or tx hash) are shown once, including
+when history sync writes a payload that repeats an id or hash casing.
+A settled row with neither id nor tx hash is dropped so it cannot
+collide as `referral-payout-0`.
+Frozen CHF
+is stored as two decimals (`246.50`) when history
+sync writes the row (amount is truncated, never rounded up). A locale-formatted
+`chfValue` (`246,5`, `1'246.50`, `CHF 246.50`) still parses as a payout
+row.
+
+## App surfaces
+
+- Dashboard card and settings entry only when `summary.eligible`.
+ A second eligibility load while the summary GET is in flight is
+ ignored, so a slower retry cannot hide the card after a later success.
+ A timed-out eligibility GET is not retried, so a hung summary cannot
+ occupy the dashboard for two 15s budgets.
+ Gate and create-invite not-eligible screens offer Close
+ (keyboard-focused). Gate, overview, and create-invite summary
+ failures put keyboard focus on Retry, as does a terms-markdown
+ load failure. A timed-out summary, create, or list GET shows the
+ unavailable copy, not the TimeoutException string. Retry on a summary
+ failure stays on the error copy in the loading state so a second tap
+ is ignored (the screen is not replaced with a blank spinner). Create-invite
+ Retry from needs-terms stays on that copy the same way.
+- Terms page (markdown selectable, only http(s) links open in the in-app
+ browser; root-relative `/…` paths open as `https://realunit.ch/…`;
+ protocol-relative `//host/…` opens as https; mailto and other schemes stay in the markdown); the accepted-terms checkbox is shown only after the
+ markdown has loaded; a later load (language change or Retry)
+ discards an earlier in-flight result. A missing or empty locale asset
+ shows Retry. Retry stays on the load-failed copy in
+ the loading state so a second tap is ignored (the screen is not
+ replaced with a blank spinner). create-invite button after checkbox
+ «Ich habe die Teilnahmebedingungen gelesen und akzeptiert».
+ Accepting terms opens the name-entry screen. A second accept while
+ the PUT is in flight is ignored. A failed accept
+ focuses Create so it can be retried. A second accept stays on that
+ error copy in the loading state so the page is not replaced with only
+ the accepting spinner. After create, the
+ personalised share text is shown under «Persönlicher Einladungslink»
+ for copy and share. Share is keyboard-focused. A failed create
+ POST focuses Create (the name field autofocus is cleared). A second
+ create stays on that error copy in the loading state so the form is
+ not replaced with only «Einladung wird erstellt…». Guest-name
+ paste collapses Unicode spaces (nbsp, em/thin/ideographic) to ASCII
+ spaces as they are typed and on submit so a messenger name stays one line. A second
+ submit while create is in flight is ignored. Copy confirms with «Kopiert» for two seconds
+ only after a successful clipboard write (announced only then,
+ including the share text). A failed clipboard write keeps «Einladungslink
+ kopieren» in the error state for two seconds and stays tappable. A
+ second tap while the clipboard write is in flight is ignored — Copy
+ is in the loading state and not tappable. A hung
+ write is treated as a failure after two seconds so copy is not stuck.
+ A second tap copies again and restarts the
+ two-second timer. A new share text clears the Kopiert state and does
+ not confirm or error a write that started on a previous invite. Share uses that label as the share-sheet
+ title and email subject, and anchors the iOS/Mac popover on the share
+ button (screen origin if the button has no size). A failed share
+ (throw or unavailable) keeps «Einladungslink versenden» in the error
+ state for two seconds and stays tappable. A second tap while the
+ sheet is open is ignored. If the share sheet never returns, resuming
+ the app clears loading so Versenden is tappable again. A new share text while the sheet is open
+ does not show that error on the new invite. Dismissing the sheet does not. Open invite rows are
+ keyed by invite id and code so two nameless rows with a missing id
+ cannot share a key, and copy/share state cannot jump to another guest.
+- Overview: open / credited counts, total REALU (truncated to a whole
+ token, never rounded up), CHF, label «Aktienkurs»
+ (empty or «NAV» API labels fall back to the localized Aktienkurs copy).
+ Count tiles are announced as «3 Offen» / «2 Gutgeschrieben»; the total
+ tile is one name (REALU, CHF at the current share price, Aktienkurs).
+ Open invites show the personalised share text (API copyText 1:1,
+ otherwise the localised template) and can be copied and shared again,
+ including when the guest name is blank («Ihre Einladung», share text
+ without «Hey ,»);
+ credited names stay hidden. Copy and share behave as after create.
+ With no open-invite rows and no list error, Create is keyboard-focused.
+ A second overview refresh while the summary GET is in flight is ignored,
+ so a slower failure after create cannot hide the tiles.
+ A second tap while create is already open is ignored. The create-route
+ lock is released when that screen pops, and overview refresh after
+ create is not awaited, so a hung summary GET cannot block Create.
+ The overview title and Settings → Legal documents (last tile) open
+ the Teilnahmebedingungen read-only (in-app Markdown from assets) so
+ Ziff. 2–11 stay reachable after the checkbox.
+- Registration: dedicated optional step (skip allowed) with the same
+ field for invite and promo. A pasted `realunit.app/invite|promo/…`
+ URL (or the landing copy button) is reduced to the code before lookup
+ and bind, including a query-only origin URL (`https://realunit.app?invite=`),
+ a hash-only path (`/invite#CODE`, `invite#CODE`, `realunit-wallet://invite#CODE`,
+ `https://realunit.app/invite#CODE`, `https://realunit.app#CODE`,
+ `realunit.app#CODE`), including App Links that carry the code only in
+ the fragment,
+ a nested landing URL in the path (`/invite/https://realunit.app/invite/{code}`,
+ `intent://`, or `realunit-wallet://`),
+ `?code=` / `?invite=` wrapping a landing URL (including an App Link
+ `https://realunit.app/invite?code=https://realunit.app/invite/{code}`),
+ Facebook `l.php?u=` / Google `url?q=` wrappers around a landing URL,
+ WhatsApp/Telegram/SMS/mailto/Messenger/Threema/Signal/Viber/LINE
+ share-sheet schemes (`whatsapp://`, `tg://`, `sms:`, `smsto:`,
+ `mailto:`, `fb-messenger://`, `threema://`, `sgnl://`, `viber://`,
+ `line://`, `wa.me`, `t.me/share`) with a percent-encoded landing URL in
+ `text` / `body` / `url` / `link` / the LINE path, including share copy
+ around the URL,
+ Chrome Android `intent://send?text=…#Intent;scheme=whatsapp;end` and
+ `#Intent;S.text=` / `S.browser_fallback_url=` extras,
+ Outlook Safe Links `url=`, Proofpoint URL Defense v2 (`-3A` / `_`)
+ and v3 (`__/https://realunit.app/…__`),
+ key-less wrappers (`href.li/?https://realunit.app/invite/{code}`),
+ path-nested landing URLs (`https://example.com/r/https://realunit.app/invite/{code}`,
+ Yahoo `RU=https://realunit.app/invite/{code}`),
+ and AMP/CDN paths (`https://www.google.com/amp/s/realunit.app/invite/{code}`),
+ `app-argument` and ads/Play `utm_content` /
+ `referrer` wrapping `invite=` or a landing URL, also when that URL
+ is embedded in a share message without a path code, including Chrome
+ `intent://…/invite?code=` and android-app / ios-app
+ `…/invite?code=` alternate links. Format characters that messengers inject around a copied
+ code or URL (zero-width, LRM/RLM, bidi) are stripped so lookup is not
+ sent a tainted token. Extracted codes are uppercased, stripped of
+ messenger zero-width/fullwidth characters and trailing sentence punct
+ (`!`, `?`, `/`, …), and capped at 32 (app/web limit; the API accepts named
+ promo codes up to 256 characters and issues batch codes as
+ `PREFIX-XXXXXXXX`), including a nested `invite|promo/{code}` inside a pasted token,
+ as are typographic quotes wrapping a copied URL
+ (`“…”`, `«…»`), wrapping parentheses, and a trailing `)` from a
+ markdown link, a trailing `"` / `'` from an HTML `href`, or HTML
+ entities (`"…"`, `https://`, `/`, `:`, `/`),
+ JSON-escaped slashes (`https:\\/\\/…`) and `\u003a` / `\u002f`,
+ fullwidth `:` / `/` and fullwidth digits/letters from a mobile keyboard,
+ Word/Pages fraction slash `⁄`, division slash `∕`, and ratio colon `∶`,
+ CJK ideographic full stop `。` as the domain dot (`realunit。app`),
+ Word/PDF typesetting spaces (nbsp, thin space, narrow nbsp) in a URL
+ or code,
+ and line breaks that email clients insert
+ in a wrapped URL (`/invite/\nCODE`) or spaces inserted after `/` or
+ `://` (`https://realunit.app/ invite/CODE`), or `>` quote prefixes on
+ wrapped reply lines, or a backslash before the line break
+ (`/invite/\\\nCODE`), a hyphen before the line break from PDF wrapping
+ (`/in-\\nvite/CODE`) or a Unicode hyphen (`/in‐\\nvite/CODE`),
+ a quoted-printable soft break (`/inv=\\nite/CODE`)
+ or quoted-printable URL hex (`https=3A=2F=2Frealunit.app=2Finvite=2FCODE`),
+ or an RFC 2047 encoded-word from an email subject
+ (`=?UTF-8?Q?https=3A=2F=2F…?=`, `=?UTF-8?B?…?=`),
+ markdown/WhatsApp emphasis (`*url*`, `_url_`),
+ or inline-code backticks (`` `url` ``) and fenced triple-backtick
+ blocks, or table pipes (`| url |`). A trailing slash or sentence
+ punctuation on a pasted code (`AB12CD/`, `AB12CD.`) is dropped.
+ A fullwidth path segment (`/invite/AB12CD`) or App Link query
+ code is folded onto ASCII before lookup. The landing Pages Function
+ applies the same fold to the HTML bytes Safari snapshots for the
+ Smart App Banner, including ads/Play `utm_content` / `referrer`
+ wrapping `invite=` or a landing URL (including `realunit.app/invite/…`
+ without a scheme) on a bare `/invite` path, and Facebook `u=` / Google
+ `q=` / Outlook `url=` / email `link=` wrapping a landing URL (`u=hello` is not a code;
+ a campaign name in `utm_content` does not hide a later wrapper key;
+ a tracking URL that only mentions `realunit.app` does not hide them either;
+ paste and App Links use the same try-each keys;
+ a foreign `https://` URL is not a code; Proofpoint URL Defense and
+ Outlook Safe Links wrapping a RealUnit landing are unwrapped; short
+ `ios-app://` alternate links too). The registration field strips those characters as
+ they are entered, including ASCII/Unicode spaces and the paste control (a clipboard of only
+ format characters is ignored, as is a RealUnit invite/promo URL with
+ no code, or a clipboard read failure). An empty field auto-pastes a
+ landing clipboard code after the deeplink stash (iOS has no Play
+ referrer); a typed or stashed code is not overwritten. A second paste while the
+ clipboard read is in flight is ignored — the paste control is not
+ tappable until that read finishes. A hung clipboard read is
+ ignored after two seconds so paste and Next are not stuck. Paste of the same code joins
+ the in-flight lookup. Paste after Skip (field locked, or an in-flight
+ clipboard read that finishes after Überspringen) is discarded. Next
+ awaits that in-flight paste before commitLookup. The same strip runs on the Play install referrer
+ before `invite=` / `promo=` / `code=` keys are read (an empty or foreign
+ `invite=` does not hide a later key), including fullwidth / CJK
+ / HTML folds so `realunit。app` still matches; a double-encoded
+ referrer (`invite%253D…`) is decoded again, and `utm_content`
+ or a nested `referrer=` / Facebook `u=` / Google `q=` / Outlook `url=` /
+ email `link=` wrapping `invite=` or a landing URL is unwrapped
+ (a campaign name in `utm_content` does not hide a later wrapper key;
+ a tracking URL that only mentions `realunit.app` does not hide them either),
+ as is a Facebook `l.php?u=` / Google `url?q=` wrapper as the whole
+ referrer, an Outlook Safe Links `url=`, a Proofpoint URL Defense
+ v2/v3 encoded landing URL, or a path-nested landing URL (Yahoo `RU=https://…`). A hung
+ Play Install Referrer read is timed out after 4s so app setup cannot
+ stall; the consumed flag is left unset. While lookup is in flight the field shows «Code wird
+ geprüft…» and hides the previous result. A later lookup of the same
+ code (Done, paste, Retry) discards an earlier in-flight result so a
+ slower 4xx cannot overwrite a later 200. Next/Skip lock the field
+ until they finish and cancel a pending lookup debounce (Next still
+ awaits commitLookup and joins an in-flight lookup of the same code,
+ or skips a second GET when that code already has a terminal result;
+ Skip does not put Next into loading). Skip stays tappable while Next
+ is awaiting lookup so a hung GET cannot trap the user; Skip then
+ discards that in-flight lookup and does not let Next also advance.
+ A 5xx /
+ 401 / 408 / 429 lookup or an unmounted NestJS route is not «invalid»:
+ the field keeps the code, shows the unavailable copy, and offers
+ Retry (secondary, so Next stays the primary action). A hung lookup
+ GET is unavailable after 15s so Next is not stuck. Retry stays on the
+ unavailable copy in the loading state so a second tap is ignored
+ (the field is not replaced with only the checking spinner). Done still looks up a newly typed
+ code while a previous lookup is in flight. Skip drops a typed code immediately and leaves a
+ deeplink stash in place. Skip also discards an in-flight paste or
+ lookup so a late clipboard write or GET cannot stash after
+ Überspringen. After lookup the invite recognition copy or
+ the promo campaign dialog is shown. The campaign text is informational,
+ not a consent gate: a barrier tap or the system back button leaves it, and
+ Close is keyboard-focused. The boot and deeplink bind dialogs stay modal.
+ A 4xx bind (stacking, self-referral,
+ spent) shows the matching copy once (spent: «Code bereits eingelöst»;
+ already-registered: «Nur für neue Kundinnen und Kunden»;
+ already-bound: «Bereits ein Code eingelöst»;
+ self-referral: «Eigene Einladung»;
+ unknown/expired stay «Link ungültig oder abgelaufen»).
+ Handbook slots `291` (expired/unknown) and `292` (already-registered)
+ are the dashboard overlay goldens. Close
+ is focused, not barrier-dismissible.
+ If the navigator is not attached yet (dashboard boot bind), the promo,
+ invalid, or unavailable dialog is shown on the next frame instead of
+ dropped.
+ A second bind while the POST is in flight only stashes. A retryable
+ bind failure does not overwrite a newer stashed code. A retryable bind
+ (Dart timeout, transport error, 5xx, 401, 408, 429, 504, or an unmounted
+ NestJS route) restashes the code and shows the unavailable copy once
+ (Retry focused, Close beside it, not barrier-dismissible). Retry stays
+ on that dialog in the loading state and binds the stash again without
+ waiting for the next dashboard landing; Close is not tappable while
+ that POST is in flight. After the POST
+ finishes, a leftover stash is bound next.
+- History: referral payouts with amount, date, frozen CHF, announced as
+ one name (title, date, frozen CHF, +20 REALU). Dashboard prize rows
+ use the same name. A frozen CHF string with a decimal comma
+ (`246,5`), Swiss thousands apostrophes (`1'246.50`), or a `CHF`
+ prefix formats as two decimals (`246.50`).
+- Deeplinks: `realunit-wallet://invite|promo/{code}` and
+ `https://realunit.app/invite|promo/{code}`. Release Universal Links
+ associate `realunit.app` and `www.realunit.app` only; Debug entitlements
+ add `applinks:dev.realunit.app?mode=developer` (and the same query on
+ apex/www) so Universal Links can be tested while production AASA is
+ still 404. iOS Profile/Release use App Store signing without
+ `?mode=developer`.
+ Invite bind is silent on
+ success; promo bind shows the campaign dialog (Close only,
+ keyboard-focused), including when bind returns before the navigator
+ has a context. Registration auto-pastes a copied landing code when the
+ field is empty; a deeplink stash wins over the clipboard.
+
+## Website
+
+`realunit.app/invite/{code}` and `/promo/{code}` look up the public code
+route, greet by name or show the promo action text, open the app via the
+custom scheme, and show App Store / Play Store badges (the matching store leads on iOS/Android).
+A nested invite
+URL in the path or query is unwrapped to the code, including a
+path-nested landing URL on a messenger redirect and an AMP/CDN path
+with `realunit.app` as a segment. `/invite#CODE` is
+the hash fallback when the path has no segment and the query has no
+code. Path and query codes
+drop ASCII/Unicode spaces the same way as the registration field. Ads/Play
+`utm_content` or `referrer` wrapping `invite=` / a landing URL is
+unwrapped when the path has no code, as is Facebook `u=` / Google `q=` /
+Outlook `url=` / email `link=`; a bare campaign name is not, a campaign
+name in `utm_content` does not hide a later wrapper key, an empty or
+foreign `code=` does not hide a later `invite=` / `promo=`, and a
+foreign `https://` URL is not a code. Proofpoint URL Defense, Outlook
+Safe Links, and short `ios-app://` alternate links wrapping a RealUnit
+landing are unwrapped. The landing shows the
+code with copy (tap / long-press) during lookup so iOS users can enter
+it during registration after a fresh install. `format-detection` is
+`telephone=no, date=no` so Safari does not turn that code or the
+Aktionstext date into a phone or calendar link. `#ok-code` and `#ok-body`
+also set `x-apple-data-detectors="false"` because lookup JS writes those
+strings after load. Desktop can copy the
+canonical link while lookup runs; the in-app CTA is mobile-only and
+stays hidden until lookup finishes. iOS Smart App Banner `app-argument`,
+`og:url`, `rel=canonical`, `twitter:url`, `og:title`, `twitter:title`, `og:description`,
+`twitter:description`, `og:image:alt`, `twitter:image:alt`, `og:locale`, `og:site_name`, Play install
+referrer, android-app / ios-app
+alternate links, Facebook App Links (`al:ios:url` / `al:android:url`
+are `realunit-wallet://…`; `al:android:class` is
+`swiss.realunit.app.MainActivity`; `al:web:url` is the HTTPS landing), and Twitter
+App Card `twitter:app:url:iphone` / `twitter:app:url:ipad` /
+`twitter:app:url:googleplay` (same custom scheme; `twitter:app:country` is CH)
+are injected into the HTML bytes from the request URL so
+Safari, Play, WhatsApp, X, and share crawlers can snapshot them before JS. `?lang=en`
+sets English title/description/alt and `og:locale=en_GB`.
+(`/js/invite-banner.js` in `` is the CSP-safe fallback because
+Cloudflare Pages CSP blocks inline `