From a6d204fdfa55c92d6f399ab3e7735d25749015b2 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:40:27 +0200 Subject: [PATCH 01/25] 01a0234b - fix(buy): show and send exact CHF with rappen (#926) EN: The buy flow now shows and quotes exact CHF with rappen instead of rounding to whole francs. DE: Der Kauf zeigt und quotet exakte CHF mit Rappen, statt auf ganze Franken zu runden.
Details Typing 10000 CHF for REALU at 1.37 yields 7299 shares. The field now snaps to 9999.63 (shares times list in Rappen), the quote request keeps that amount, and payment details render two decimal places. `chargedFiatAmount` snaps to Rappen only. Sell still sends an integer share count.
--- .../models/payment/buy/buy_payment_info.dart | 2 +- .../real_unit_buy_payment_info_service.dart | 2 +- lib/packages/utils/fiat_amount.dart | 8 +++---- .../buy_converter/buy_converter_cubit.dart | 6 ++++++ .../buy/widgets/buy_confirm_button.dart | 2 +- .../sell_payment_info_cubit.dart | 2 +- test/packages/utils/fiat_amount_test.dart | 21 +++++++++++-------- .../buy/cubits/buy_converter_cubit_test.dart | 19 ++++++++++++++++- .../cubits/buy_payment_info_cubit_test.dart | 3 +-- .../buy/widgets/buy_confirm_button_test.dart | 8 +++---- 10 files changed, 49 insertions(+), 24 deletions(-) diff --git a/lib/packages/service/dfx/models/payment/buy/buy_payment_info.dart b/lib/packages/service/dfx/models/payment/buy/buy_payment_info.dart index cbbeb3616..0472678a1 100644 --- a/lib/packages/service/dfx/models/payment/buy/buy_payment_info.dart +++ b/lib/packages/service/dfx/models/payment/buy/buy_payment_info.dart @@ -18,7 +18,7 @@ class BuyPaymentInfo extends Equatable { // authority on whether the quote is valid for trading and what the // current min/max limits are for the user+currency combination. final bool isValid; - // The whole-currency amount this quote charges, echoed by the API — the + // The amount this quote charges (Rappen-exact), echoed by the API — the // Details page must render this, never re-derive it from keystrokes. final double amount; final double? minVolume; diff --git a/lib/packages/service/dfx/real_unit_buy_payment_info_service.dart b/lib/packages/service/dfx/real_unit_buy_payment_info_service.dart index 3c67cb137..2c4bb8ed4 100644 --- a/lib/packages/service/dfx/real_unit_buy_payment_info_service.dart +++ b/lib/packages/service/dfx/real_unit_buy_payment_info_service.dart @@ -15,7 +15,7 @@ class RealUnitBuyPaymentInfoService extends DFXAuthService { RealUnitBuyPaymentInfoService(super.appStore, super.walletService); - Future getPaymentInfo(int amount, {Currency currency = Currency.chf}) async { + Future getPaymentInfo(num amount, {Currency currency = Currency.chf}) async { final buyDto = RealUnitBuyDto(amount: amount, currency: currency); final uri = buildUri(host, _buyPaymentInfoPath); diff --git a/lib/packages/utils/fiat_amount.dart b/lib/packages/utils/fiat_amount.dart index cf732a473..bdb554116 100644 --- a/lib/packages/utils/fiat_amount.dart +++ b/lib/packages/utils/fiat_amount.dart @@ -7,10 +7,10 @@ double? tryParseFiatAmount(String input) { return double.tryParse(input.replaceAll(',', '.')); } -/// The whole-currency integer the backend charges for the raw [input] the user -/// typed (e.g. `300,75` → `301`); empty input counts as zero. -int chargedFiatAmount(String input) { +/// Rappen-snapped major units the backend is asked to quote (e.g. `300,75` → +/// `300.75`). Never rounds to whole currency. Empty input counts as zero. +double chargedFiatAmount(String input) { final amount = tryParseFiatAmount(input.isEmpty ? '0' : input); if (amount == null) throw FormatException('Invalid fiat amount', input); - return amount.round(); + return (amount * 100).round() / 100; } diff --git a/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart b/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart index 8fa7ac9a8..0f574bc0c 100644 --- a/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart +++ b/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart @@ -39,9 +39,12 @@ class BuyConverterCubit extends Cubit { try { final result = await _brokerbotService.getBuyShares(value, state.currency); if (isClosed || mySeq != _seq) return; + final priceMinor = (result.pricePerShare * 100).round(); + final payable = result.shares * priceMinor / 100; emit( state.copyWith( sharesText: result.shares.toString(), + fiatText: payable.toStringAsFixed(2), loading: false, ), ); @@ -90,9 +93,12 @@ class BuyConverterCubit extends Cubit { try { final result = await _brokerbotService.getBuyShares(state.fiatText, currency); if (isClosed || mySeq != _seq) return; + final priceMinor = (result.pricePerShare * 100).round(); + final payable = result.shares * priceMinor / 100; emit( state.copyWith( sharesText: result.shares.toString(), + fiatText: payable.toStringAsFixed(2), loading: false, ), ); diff --git a/lib/screens/buy/widgets/buy_confirm_button.dart b/lib/screens/buy/widgets/buy_confirm_button.dart index e0cf2f25f..d37017e5f 100644 --- a/lib/screens/buy/widgets/buy_confirm_button.dart +++ b/lib/screens/buy/widgets/buy_confirm_button.dart @@ -54,7 +54,7 @@ class BuyConfirmButtonView extends StatelessWidget { extra: BuyPaymentDetailsParams( buyPaymentInfo: buyPaymentInfo, // The charged amount comes from the quote itself, never keystrokes. - amount: '${buyPaymentInfo.amount.round()}', + amount: buyPaymentInfo.amount.toStringAsFixed(2), // Backward compatible: prefer the API-designated purpose once it // ships; until then `reference` (always returned) is the value. purposeOfPayment: state.remittanceInfo ?? state.reference, diff --git a/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart b/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart index 2c4ba5d82..f0cde3796 100644 --- a/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart +++ b/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart @@ -42,7 +42,7 @@ class SellPaymentInfoCubit extends Cubit { emit(const SellPaymentInfoLoading()); final paymentInfo = await _sellPaymentInfoService.getPaymentInfo( - chargedFiatAmount(amount), + chargedFiatAmount(amount).round(), iban, currency: currency, ); diff --git a/test/packages/utils/fiat_amount_test.dart b/test/packages/utils/fiat_amount_test.dart index dcc2215f8..e09face41 100644 --- a/test/packages/utils/fiat_amount_test.dart +++ b/test/packages/utils/fiat_amount_test.dart @@ -3,14 +3,13 @@ import 'package:realunit_wallet/packages/utils/fiat_amount.dart'; void main() { group('chargedFiatAmount', () { - // The quote is always requested with this rounded integer, so the SEPA - // transfer / QR the API builds encodes it. - test('rounds a dot decimal to the charged integer (300.75 → 301)', () { - expect(chargedFiatAmount('300.75'), 301); + // Quote requests snap to Rappen, never to whole francs. + test('keeps a dot decimal at Rappen precision (300.75 → 300.75)', () { + expect(chargedFiatAmount('300.75'), 300.75); }); - test('normalises a comma decimal (300,75 → 301)', () { - expect(chargedFiatAmount('300,75'), 301); + test('normalises a comma decimal (300,75 → 300.75)', () { + expect(chargedFiatAmount('300,75'), 300.75); }); test('leaves a whole amount unchanged (300 → 300)', () { @@ -21,9 +20,13 @@ void main() { expect(chargedFiatAmount(''), 0); }); - test('rounds half away from zero and down below the half (0.5 → 1, 1.49 → 1)', () { - expect(chargedFiatAmount('0.5'), 1); - expect(chargedFiatAmount('1.49'), 1); + test('keeps half-franc and sub-franc amounts (0.5 → 0.5, 1.49 → 1.49)', () { + expect(chargedFiatAmount('0.5'), 0.5); + expect(chargedFiatAmount('1.49'), 1.49); + }); + + test('does not round leftover Rappen up to the next franc (9999.63 → 9999.63)', () { + expect(chargedFiatAmount('9999.63'), 9999.63); }); test('throws on structurally invalid input instead of guessing', () { diff --git a/test/screens/buy/cubits/buy_converter_cubit_test.dart b/test/screens/buy/cubits/buy_converter_cubit_test.dart index 53ecc7186..7f04c0bb9 100644 --- a/test/screens/buy/cubits/buy_converter_cubit_test.dart +++ b/test/screens/buy/cubits/buy_converter_cubit_test.dart @@ -45,12 +45,29 @@ void main() { // Past the 100ms debounce. await Future.delayed(const Duration(milliseconds: 250)); - expect(cubit.state.fiatText, '100'); + expect(cubit.state.fiatText, '87.50'); expect(cubit.state.sharesText, '7'); expect(cubit.state.loading, isFalse); verify(() => service.getBuyShares('100', Currency.chf)).called(1); }); + test('onFiatChanged snaps CHF to shares × list in Rappen (10000 → 7299 × 1.37 = 9999.63)', () async { + when(() => service.getBuyShares(any(), any())).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 7299, + pricePerShare: 1.37, + availableShares: 50000, + ), + ); + + final cubit = BuyConverterCubit(service); + await cubit.onFiatChanged('10000'); + await Future.delayed(const Duration(milliseconds: 250)); + + expect(cubit.state.sharesText, '7299'); + expect(cubit.state.fiatText, '9999.63'); + }); + test('onFiatChanged debounces — only the latest value reaches the service', () async { when(() => service.getBuyShares(any(), any())).thenAnswer( (_) async => BrokerbotBuySharesDto( diff --git a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart index 486ba9242..df6c8119f 100644 --- a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart +++ b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart @@ -175,8 +175,7 @@ void main() { final cubit = build(); await cubit.getPaymentInfo(amount: '300,75'); - // 300.75 rounded to 301. - verify(() => service.getPaymentInfo(301, currency: Currency.chf)).called(1); + verify(() => service.getPaymentInfo(300.75, currency: Currency.chf)).called(1); }); test('KycLevelRequiredException → Failure(kycRequired, requiredLevel)', () async { diff --git a/test/screens/buy/widgets/buy_confirm_button_test.dart b/test/screens/buy/widgets/buy_confirm_button_test.dart index 87cbdabe3..d02e3c976 100644 --- a/test/screens/buy/widgets/buy_confirm_button_test.dart +++ b/test/screens/buy/widgets/buy_confirm_button_test.dart @@ -33,7 +33,7 @@ const _info = BuyPaymentInfo( currency: Currency.chf, ); -// The quote echoes the charged amount; a fractional echo must render rounded. +// The quote echoes the charged amount; rappen must render, not round to francs. const _quotedFractional = BuyPaymentInfo( amount: 300.75, id: 42, @@ -200,7 +200,7 @@ void main() { }); testWidgets('shows the charged amount echoed by the quote on the details ' - 'page, rounded (300.75 → 301) — never derived from keystrokes', (tester) async { + 'page with rappen (300.75, not 301) — never derived from keystrokes', (tester) async { whenListen( cubit, Stream.fromIterable([ @@ -218,8 +218,8 @@ void main() { // The details amount is the quote's own echoed charge, so it can never // disagree with the SEPA transfer / QR the backend built for the quote. - expect(find.text('301'), findsOneWidget); - expect(find.text('300.75'), findsNothing); + expect(find.text('300.75'), findsOneWidget); + expect(find.text('301'), findsNothing); }); testWidgets('forward path: remittanceInfo + paymentRequest drive the ' From 568f6a4b70f871f96ce316baf09ff9ea5c62c234 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:17:24 +0200 Subject: [PATCH 02/25] 01a02915 - Live RealUnit geo-filter table in the handbook (#930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EN: Adds a live RealUnit share-token geo-filter table to the handbook. handbook.realunit.app loads GET /v1/country at runtime and does not copy country rows into the repo. Until DFXswiss/backend is deployed, the realunit columns stay empty and the page says so. DE: Ergänzt die Live-Tabelle des RealUnit-Aktientoken-Geo-Filters im Handbook. handbook.realunit.app lädt GET /v1/country zur Laufzeit und kopiert keine Länderzeilen ins Repo. Solange DFXswiss/backend nicht deployed ist, bleiben die realunit-Spalten leer; die Seite weist darauf hin.
Details Section `#spec-geo` in `docs/handbook/de/index.html`. Client `docs/handbook/de/geo-filter.js` tries same-origin `/v1/country` (nginx proxy in `handbook.nginx.conf`) then `https://api.dfx.swiss/v1/country`. Screenshot count is unchanged. No Dart/UI change, no goldens. The geo-filter columns only appear after the merged backend `CountryDto.realunit` is live on that API.
--- docs/handbook/README.md | 7 ++ docs/handbook/de/geo-filter.js | 157 +++++++++++++++++++++++++++++++++ docs/handbook/de/index.html | 143 ++++++++++++++++++++++++++++++ handbook.nginx.conf | 21 +++++ 4 files changed, 328 insertions(+) create mode 100644 docs/handbook/de/geo-filter.js diff --git a/docs/handbook/README.md b/docs/handbook/README.md index 88fe69b5a..2fa5ab8d3 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -64,6 +64,13 @@ 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. 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 diff --git a/docs/handbook/de/geo-filter.js b/docs/handbook/de/geo-filter.js new file mode 100644 index 000000000..0dede72de --- /dev/null +++ b/docs/handbook/de/geo-filter.js @@ -0,0 +1,157 @@ +/** + * Live RealUnit share-token geo-filter table. + * + * Source of truth is GET /v1/country (same payload the wallet uses). Rows are + * never copied into this repo. Same-origin `/v1/country` is the handbook + * nginx proxy; the public API is the fallback for a local file preview. + */ +(function () { + var URLS = ['/v1/country', 'https://api.dfx.swiss/v1/country']; + var COLUMNS = [ + { key: 'symbol', label: 'ISO' }, + { key: 'name', label: 'Land' }, + { 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' }, + ]; + + function $(id) { + return document.getElementById(id); + } + + 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 (field === 'residence') { + if (value === 'Allowed') return 'ok'; + if (value === 'Sanction') return 'bad'; + if (value === 'Restricted') return 'warn'; + return 'muted'; + } + if (field === 'nationality') { + if (value === 'Ok') return 'ok'; + if (value === 'Blocked') return 'bad'; + if (value === 'Exception') return 'warn'; + return 'muted'; + } + if (value === true) return 'ok'; + if (value === false) return 'bad'; + return 'muted'; + } + + function rowView(country) { + var geo = country.realunit || {}; + return { + symbol: country.symbol, + name: country.foreignName || country.name, + residence: geo.residence, + nationality: geo.nationality, + residenceExisting: geo.residenceExisting, + nationalityExisting: geo.nationalityExisting, + ipEnable: geo.ipEnable, + ipExisting: geo.ipExisting, + taxEnable: geo.taxEnable, + taxExisting: 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 render(state) { + var tbody = $('geo-filter-body'); + var status = $('geo-filter-status'); + var missing = $('geo-filter-missing'); + if (!tbody) return; + + var q = ($('geo-filter-search') || {}).value || ''; + q = q.trim().toLowerCase(); + var residence = ($('geo-filter-residence') || {}).value || ''; + var nationality = ($('geo-filter-nationality') || {}).value || ''; + + var rows = state.rows.filter(function (row) { + if (q && (row.symbol || '').toLowerCase().indexOf(q) < 0 && (row.name || '').toLowerCase().indexOf(q) < 0) { + return false; + } + if (residence && row.residence !== residence) return false; + if (nationality && row.nationality !== nationality) return false; + return true; + }); + + 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; + } + } + + 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 }; + ['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); }); + }); + 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..5014908e9 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -724,6 +724,70 @@ border-radius: 8px; padding: 14px 16px; } + #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-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 +837,7 @@ word-break: break-all; } +
@@ -1033,6 +1098,9 @@
  • 79Insider-Freischaltung — Bezahlen & Senden
  • +
  • + GAktientoken — Geo-Filter +
  • WWeb · realunit.app
  • @@ -8060,6 +8128,81 @@

    79Insider-Freischaltung — Bezahlen & Senden +
    + +
    +
    +

    GAktientoken — Geo-Filter

    +
    GET /v1/country · realunit.*
    +
    +
    + Live aus der API + +
    +
    +
    +

    + Wohnsitz- und Nationalitäts-Filter für den RealUnit-Aktientoken, direkt + aus GET /v1/country. Die JSON-Felder sind + realunit.residence und realunit.nationality + (plus nullable Existing/IP/Tax unter demselben realunit-Objekt); + auf der country-Tabelle heissen die Spalten + realunitResidence / realunitNationality. + Die Tabelle kopiert keine Zeilen ins Repo. Onboarding prüft nur Residence + und Nationality; IP, Steueransässigkeit und Grandfathering bleiben leer, + bis Legal sie füllt. +

    +
    +
    + + + +
    +

    Lade GET /v1/country …

    + +
    + + + + + + + + + + + + + + + + +
    ISOLandWohnsitzNationalitätWohnsitz BestandNationalität BestandIP neuIP BestandSteuer neuSteuer Bestand
    +
    +
    +
    + +
    +
    diff --git a/handbook.nginx.conf b/handbook.nginx.conf index 0f9fde6ed..89619c236 100644 --- a/handbook.nginx.conf +++ b/handbook.nginx.conf @@ -64,6 +64,27 @@ server { # here; otherwise defining add_header inside the block would silently # drop them. Cache-Control is "no-store" so a healthcheck answer is # never cached by intermediaries while the container is actually down. + # Public country list used by the live geo-filter table. Same payload as + # api.dfx.swiss/v1/country; kept behind Basic Auth with the rest of the + # handbook. Variable proxy_pass needs a resolver so nginx looks the + # host up at request time (the image has no baked IP). + resolver 1.1.1.1 ipv6=off valid=300s; + location = /v1/country { + set $dfx_country_upstream https://api.dfx.swiss/v1/country; + proxy_pass $dfx_country_upstream; + proxy_ssl_server_name on; + proxy_ssl_name api.dfx.swiss; + proxy_set_header Host api.dfx.swiss; + # Country list is public. Do not forward handbook Basic Auth to the API. + proxy_set_header Authorization ""; + proxy_connect_timeout 10s; + proxy_read_timeout 20s; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Cache-Control "private, no-store" always; + } + location = /healthz { auth_basic off; add_header X-Content-Type-Options "nosniff" always; From f491ac0a824aacc5362dcc467e82b7d3bc7fcd35 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:35:27 +0200 Subject: [PATCH 03/25] 01a02915 - Geo-filter DE/EN names and downloads (#932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EN: The geo-filter table now shows German and English country names. CSV, Excel, and PDF downloads export the currently visible rows. Native-script API foreignName is no longer used. DE: Die Geo-Filter-Tabelle zeigt Ländernamen auf Deutsch und Englisch. CSV, Excel und PDF laden die aktuell angezeigten Zeilen herunter. Die native foreignName der API wird nicht mehr verwendet.
    Details Names come from `Intl.DisplayNames` (ISO 3166-1) so Arabic/Japanese/Chinese script in `foreignName` is not shown. Downloads are generated in the browser from the live `GET /v1/country` payload; no country rows are copied into the repo.
    --- docs/handbook/README.md | 8 +- docs/handbook/de/geo-filter.js | 287 +++++++++++++++++++++++++++++++-- docs/handbook/de/index.html | 31 +++- 3 files changed, 305 insertions(+), 21 deletions(-) diff --git a/docs/handbook/README.md b/docs/handbook/README.md index 2fa5ab8d3..18bcaba94 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -67,9 +67,11 @@ 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. 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. +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 diff --git a/docs/handbook/de/geo-filter.js b/docs/handbook/de/geo-filter.js index 0dede72de..a96223bd5 100644 --- a/docs/handbook/de/geo-filter.js +++ b/docs/handbook/de/geo-filter.js @@ -1,15 +1,16 @@ /** * Live RealUnit share-token geo-filter table. * - * Source of truth is GET /v1/country (same payload the wallet uses). Rows are - * never copied into this repo. Same-origin `/v1/country` is the handbook - * nginx proxy; the public API is the fallback for a local file preview. + * 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: 'name', label: 'Land' }, + { key: 'nameDe', label: 'Land (DE)' }, + { key: 'nameEn', label: 'Country (EN)' }, { key: 'residence', label: 'Wohnsitz' }, { key: 'nationality', label: 'Nationalität' }, { key: 'residenceExisting', label: 'Wohnsitz Bestand' }, @@ -20,10 +21,37 @@ { 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 cell(value) { if (value === true) return 'ja'; if (value === false) return 'nein'; @@ -52,9 +80,11 @@ function rowView(country) { var geo = country.realunit || {}; + var english = country.name || country.symbol; return { symbol: country.symbol, - name: country.foreignName || country.name, + nameDe: regionName(displayDe, country.symbol, english), + nameEn: regionName(displayEn, country.symbol, english), residence: geo.residence, nationality: geo.nationality, residenceExisting: geo.residenceExisting, @@ -80,26 +110,237 @@ }); } - function render(state) { - var tbody = $('geo-filter-body'); - var status = $('geo-filter-status'); - var missing = $('geo-filter-missing'); - if (!tbody) return; - + 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 || ''; - - var rows = state.rows.filter(function (row) { - if (q && (row.symbol || '').toLowerCase().indexOf(q) < 0 && (row.name || '').toLowerCase().indexOf(q) < 0) { + 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 && row.residence !== residence) return false; if (nationality && row.nationality !== nationality) 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 += '' + 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'); @@ -119,6 +360,21 @@ 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() { @@ -133,12 +389,13 @@ var hasRealunit = result.body.some(function (country) { return country && country.realunit != null; }); - var state = { rows: rows, url: result.url, hasRealunit: hasRealunit }; + 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) { diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index 5014908e9..ec2c13c95 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); } @@ -744,6 +746,20 @@ 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; @@ -8150,6 +8166,9 @@

    GAktientoken — Geo-Filter

    (plus nullable Existing/IP/Tax unter demselben realunit-Objekt); auf der country-Tabelle heissen die Spalten realunitResidence / realunitNationality. + Ländernamen sind Deutsch und Englisch (ISO 3166 über + Intl.DisplayNames), nicht die native foreignName + der API. CSV, Excel und PDF laden die aktuell angezeigte Liste. Die Tabelle kopiert keine Zeilen ins Repo. Onboarding prüft nur Residence und Nationality; IP, Steueransässigkeit und Grandfathering bleiben leer, bis Legal sie füllt. @@ -8171,6 +8190,11 @@

    GAktientoken — Geo-Filter

    +
    + + + +

    Lade GET /v1/country …