Skip to content

1962fdea - Let legacy Yapeal CHF holders switch between their old and new personal IBAN - #1320

Merged
TaprootFreak merged 11 commits into
developfrom
feat/legacy-viban-toggle
Aug 12, 2026
Merged

1962fdea - Let legacy Yapeal CHF holders switch between their old and new personal IBAN#1320
TaprootFreak merged 11 commits into
developfrom
feat/legacy-viban-toggle

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Follow-up to the CHF deposit cutover (#1305) and the API half DFXswiss/api#4813: customers who still hold an active legacy Yapeal CHF personal IBAN see their new Bank Frick IBAN by default and can flip back and forth with a provider toggle — with the guarantee that every PDF names exactly the IBAN that was on screen.

Open, and not closable from this branch

  • DFXswiss/api#4813 merged and deployed first. Not a hard ordering: without the new bank field on GET /buy/personalIban the switch offer never appears and this frontend behaves exactly as today (feature inert by construction). But the feature only exists once the API is live.
  • feat(core): add the Yapeal personal IBAN provider member packages#211 merged and released, then the dependency bump lands here. Done: the SDK is published as @dfx.swiss/react 1.8.0-beta.2 (pulling in @dfx.swiss/core 0.7.0-beta.1) and consumed from the registry — the local tarball override is gone and CI is green.

What

  1. Frick by default for grandfathered holders. The screens load the customer's personal-IBAN rows once (GET /buy/personalIban, now naming the issuing bank per row). An explicit decision tree — no fallback cascade — picks the provider: a toggle click always wins; an explicit URL/widget selector stays untouched; otherwise an existing still-payable Yapeal holder with KYC ≥ 50 gets personalIbanProvider=Frick; otherwise no selector is sent and the server default (the existing Yapeal row) applies. Below KYC 50 nothing changes at all.
  2. The provider toggle. A second button next to the collection-IBAN toggle (distinct icon on purpose — two adjacent switches need distinct affordances) requests a fresh quote pinned to the other provider. Both directions are covered: a displayed Frick response offers the Yapeal row, a displayed Yapeal response offers Frick (KYC 50+ only).
  3. Display ↔ PDF consistency by construction. Every flip is a new payment-info request with an explicit selector, and the API persists bankId/virtualIbanId on the transaction request (api#4813). The PDF button hangs off that request's id, so invoice and receipt rebuild from the stored IDs — the document can never name a different account than the screen did, in either toggle state.
  4. Visible, never silent, KYC fallback. If the automatically chosen Frick default bounces with KycRequired (stale local KYC knowledge), the screen re-quotes without a selector and keeps a permanent hint ("we are showing your existing IBAN instead") — no blocking KYC screen for a choice the customer never made. An explicit toggle click or URL selector keeps the ordinary KYC error path.
  5. Selector plumbing generalized. normalizePersonalIban/toPersonalIbanProviderRequest/isUnrecognizedPersonalIbanSelector now recognize every SDK enum member instead of hardcoding Frick, so personal-iban=Yapeal in the URL is an explicit (fail-closed, server-validated) selector instead of being silently dropped; the new PersonalIbanProviderNotAvailable token maps to customer-readable copy.
  6. The row list flows through the SDK, once, for both screens. GET /buy/personalIban is consumed via useBuy().getPersonalIbans (feat(core): add the Yapeal personal IBAN provider member packages#211) inside a shared usePersonalIbanRows hook; the provider decision tree itself is a pure, unit-tested helper. The list is identity-tagged: an in-place account swap resets rows, override and fallback state (all of them — the KYC-fallback flag included), and neither a late list response nor a late quote response can commit for an account that is no longer signed in — the quote signature carries the customer identity, the committed payment info is stored together with the identity, method and the provider that was actually sent, and every commit re-checks the identity. KYC itself is evaluated only against a user object whose accountId matches the session identity; a foreign user counts as still loading. Completion and in-flight confirmation state reset on an identity change too, completion commits only on a successful confirmation (a rejected confirm surfaces as an error instead of a "Done!" screen), the confirm callback guards against unmount, and confirming is only possible once the final (exact-price) quote of a generation is displayed — each quote request persists its own transaction request, so this closes the window where a confirmation could target the approximate request while the screen moves on to the exact one. The displayed verification follows the sent provider (an explicit Yapeal answer after a URL-Frick entry is valid), the request provider derives from the same debounced snapshot as the request currency, the first quote additionally waits while the user context itself is loading, and both waits fail open after ten seconds so the core buy flow can never hang on the optional switch offer.
  7. The completion screen shows what was actually completed. Both flows snapshot the completed payment information under the customer identity the moment the completion state is entered (after a successful confirmation on /buy, on the explicit "I have issued the transfer" action on /buy/info) and render only from that snapshot. Late-arriving context — a user reload, the personal-IBAN row list, a KYC re-derivation — can still re-quote in the background, but it can no longer swap the information a customer already acted on, neither on screen nor in the close payload handed to the host application. The snapshot is cleared together with the completion flag on an identity change. The user prop stays the raw context value on purpose: the completion component treats a missing user as "still loading" and then renders only a spinner while the screen suppresses the back button, so an identity-filtered undefined would strand a customer who has already paid — and that value gates nothing but the spinner and a copy variant, never account data.
  8. No dead ends. If an explicitly selected provider is rejected by the server (PersonalIbanProviderNotAvailable), the error offers a "Show available IBAN" action that clears the selection and re-quotes selector-free — matching what the error copy promises, instead of a retry-only loop.
  9. Both buy flows. buy.screen and buy-info.screen share the row hook and the derive helper and keep only their flow-specific wiring; payment-info-buy only renders the toggle.

Tests

  • Unit: the generalized selector utils, getYapealAlternative (bank/currency/active/acceptsPayments/status filters), isVerifiedYapealPersonalIbanResponse, and the new error-token mapping — full branch coverage on personal-iban.ts.
  • The four screen suites mock the virtual-iban hook with a reference-stable, reset-proof interface: CRA sets resetMocks: true, which strips jest.fn() implementations before every test, and an unstable factory reference re-fires the load effect every render. Plain functions pinned inside the factory survive both; createPersonalIban rejects loudly if a suite ever reaches it.
  • The KYC fallback renders as its own visual variant with committed baselines on both screens (first Frick request answered KycRequired, selector-less retry, permanent hint). The /buy/info suite carries the full scenario set (default Frick, toggle, fallback, hint clear, account swap, load rejection), and dedicated tests pin the same-snapshot, identity and combined user-timeout races with deferred promises and fake timers, plus completion-leak regressions on both screens.
  • The close payload is pinned in the completion component's own suite: closing hands the host application exactly the payment information it was rendered with, so the chain screen -> snapshot -> host payload is covered end to end.
  • e2e (static route mocks, deterministic): a legacy Yapeal holder sees the Frick IBAN first (request-body assertion on the sent selector), flips to the Yapeal IBAN (real production BIC YAPECHZ2 in the fixture) and back; the provider-unavailable rejections run against fully static /v2/user fixtures (with the accountId decoded from the live session JWT, keeping the fixture coherent with the identity check) and the complete NestJS error shape, assert the new recovery action and click it, pinning the selector-free follow-up request in the request body. Eight committed baselines, each proven by a second run against the committed file, all visually verified.

Per-file coverage and declared deviations

Measured over the full suite on this head (statements / branches / functions / lines):

File Stmts Branch Funcs Lines
src/util/personal-iban.ts 100 100 100 100
src/hooks/personal-iban.hook.ts 100 100 100 100
src/hooks/personal-iban-rows.hook.ts 100 96 100 100
src/hooks/virtual-iban.hook.ts 100 100 100 100
src/components/payment/payment-info-buy.tsx 57.14 92.59 40 55
src/components/payment/buy-completion.tsx 92.30 70 75 91.66
src/screens/buy.screen.tsx 85.60 81.22 69.01 87.33
src/screens/buy-info.screen.tsx 93.06 87.32 80.76 93.90

Declared deviation (CONTRIBUTING § Coverage, reviewer's call): the two screens and the payment-info component do not reach 100% on the whole file — the gap is dominated by pre-existing screen scaffolding this PR does not touch (the same state these files carried into #1305). The new decision tree, both toggle directions, the KYC fallback, the account-swap resets and the race guards are pinned by dedicated tests; a small remainder of defensive guard branches inside the new code is exercised only through the scenario suites rather than each by a dedicated test. Bringing an ~1100-line screen to 100% belongs to a preparatory coverage PR, not under this title.

Declared deviation (§ full-stack test): no e2e-stack/ scenario is added here — the same declared deviation as in #1270, which is itself still open. The scenario needs a grandfathered Yapeal row and DFXswiss/api#4813 inside the stack image — until that API lands in the harness, the case is untestable there; the PR's own static Playwright suite pins both toggle states deterministically on /buy and /buy/info. It follows once the API half is merged into the stack.

Pre-existing, reported not fixed

  • buy.screen.tsx carries an empty onSubmit handler with a 2023 TODO ("fix broken form validation and onSubmit"); the actual submit path runs through the button handlers, so the dead handler has no runtime effect. It predates this PR (git blame 2023) and is reported here per the review rule rather than silently skipped; fixing the form wiring belongs to its own change.
  • useVirtualIban still fires POST buy/personalIban through a raw useApi().call instead of the SDK, which CONTRIBUTING asks to avoid. It is pre-existing and identical on develop; this PR only narrows the file (the GET moved to useBuy().getPersonalIbans, and the response type now comes from the SDK). The remaining POST belongs to personal-iban.screen, not to the provider toggle, and migrating it means adding the endpoint to the SDK first — that would reopen the already-reviewed packages PR this branch is waiting on for its release. Reported here rather than silently skipped; it follows in its own change once the SDK release has landed.
  • The sell and swap screens carry the same pre-existing two-phase quote commit (approximate, then exact price) with an unguarded confirm window; this PR closes that window for the buy screen it touches, the twin flows are untouched and keep their existing behavior.
  • Three older cases in the two e2e files fail on a fresh local API stack independently of this branch (verified: identical failures on the develop merge-base against the same stack): the real-quote Frick issuance test needs a KYC ≥ 50 seed account and currently valid price rows, and the two Wallet-2 screenshots compare against baselines committed long before this branch that encode a different seeded account state. Fixing the seed or regenerating those baselines is environment work outside this diff.

Evidence

Local chain on this head (Node 20, SDK tarball override, own API stack for the e2e auth): lint, CI=true npm test (84 suites / 1,052 tests), build:dev, widget:dev, index-swap check — all green. The eight provider e2e cases (/buy and /buy/info: both toggle directions, the KYC fallback and the provider-unavailable rejection) run deterministically against their eight committed, visually verified baselines; the two full spec files pass twice in a row with the identical result set (16 of 19, remainder = the three pre-existing environment cases above).

One test-only correctness find along the way: the static quote mocks omitted asset.uniqueName, which the buy screen's exact-price echo suppression compares — every static quote therefore triggered a redundant reverse quote cycle that could replace the payment info mid-interaction and reset the collection-IBAN toggle. All eleven static quote mocks now carry the field the real API always returns.

…al IBAN

Grandfathered customers with an active Yapeal CHF personal IBAN now see
their new Bank Frick IBAN by default (KYC 50+) and can flip back and forth
with a provider toggle next to the payment details. Every flip requests a
fresh quote pinned to the explicit personalIbanProvider, so the stored
transaction request - and with it every later invoice or receipt PDF -
always names exactly the IBAN that was on screen.

The screens read GET buy/personalIban (which now names the issuing bank
per row) to decide whether a switch target exists; without the new API
field the feature stays inert and the flow behaves as before. An automatic
Frick default that the server rejects with KycRequired falls back to a
selector-less quote with a permanently visible hint instead of a blocking
KYC screen - the customer never chose Frick themselves.

The four screen-test suites mock the virtual-iban hook with a reference-
stable, reset-proof interface (CRA sets resetMocks true), and the new e2e
scenario pins both toggle states with static route mocks and baselines.
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

🤖 PR Review Bot

❌ TypeScript: 14 errors


This is an automated review. Please address the issues above.

…nt hygiene, gated first quote, full test coverage of the new paths

The request provider is now derived from the same debounced snapshot as
the request currency (the live derivation keeps feeding only the
invalidation signature), the row list and every provider choice reset on
an in-place account swap and commit only for the identity that started
the load, the automatic-Frick KYC fallback classifies on the actually
sent selector and clears on an explicit toggle click, and the first quote
of an eligible legacy holder waits for the row list exactly when it could
change the default - a failed load resolves the gate immediately.

New tests pin the toggle button in both directions, the real
getPersonalIbans hook, the five screen scenarios (default Frick, toggle
to Yapeal, visible KYC fallback, hint cleared on explicit choice,
account-swap reset) and the non-Yapeal-bank branch of the alternative
lookup; the /buy/info flow carries the same provider-toggle e2e scenario
with two committed baselines.
GET buy/personalIban now flows through useBuy().getPersonalIbans (SDK rule;
the local hook keeps only the pre-existing POST), and the row list carries
an identity tag so neither the list nor a quote response can ever commit
for an account that is no longer signed in - the quote signature includes
the customer identity and every commit re-checks it. The request provider
derives from the same debounced snapshot as the request currency, the
displayed verification follows the provider that was actually sent (an
explicit Yapeal answer after a URL-Frick entry is valid, not blocked), the
first quote of an eligible legacy holder additionally waits while the user
context itself is still loading, and the row-list load fails open to an
empty list after ten seconds so the core buy flow can never hang on the
optional switch offer.

The KYC fallback now renders as its own visual variant with committed
baselines on both screens, the /buy/info suite carries the full scenario
set (default Frick, toggle, fallback, hint clear, account swap, load
rejection), the same-snapshot and identity races are pinned by dedicated
tests, and the suites' console traps now name the offending component.
…ntext wait

The mismatch acknowledgement and selector suppression now carry the
customer identity like the provider override already did - an account
swapped in place can no longer inherit the previous account's dismissal
or suppression. The user-context part of the first-quote gate gets the
same ten-second fail-open cap as the row list, so a hanging user reload
can never block the core buy flow; after the cap the quote proceeds
selector-less and a late load triggers the ordinary corrective quote.

The provider-unavailable rejection renders as its own visual variant with
committed baselines on both screens, the handbook descriptions name the
KYC fallback and the new error case, and the suites pin the row-list
timeout, the stale-rejection guard, the verification-failed branch and
the toggle/fallback rendering with fake timers and deferred promises -
resolved inside act, which also settled a test-order-dependent act
warning for good.
…ng races

- Gate explicit personal-IBAN requests provider-neutrally per wallet
- Tag payment info state with the customer identity and reset completion
  and confirmation state on identity changes; guard the confirm callback
  against stale identities
- Wait for user data before deriving the live Frick default and clear
  payment info synchronously before requesting new payment details
- Use fully static user and error fixtures in the provider-unavailable
  e2e scenarios and add identity-race and completion-leak regression tests
The real payment-info response always carries asset.uniqueName, and the
buy screen's exact-price echo suppression compares exactly this field.
Without it every static quote triggered a redundant reverse quote cycle
~500ms later that replaced the payment info object and reset the
collection-IBAN display toggle mid-interaction.
…entity gaps

- Extract the row-loading/timeout state machine into usePersonalIbanRows
  and the provider decision tree into a pure derive helper consumed by
  both buy screens
- Evaluate KYC only against a user whose accountId matches the session
  identity; a foreign user counts as still loading and joins the existing
  ten-second fail-open
- Tag the automatic-Frick KYC fallback flag with the customer identity
- Offer a 'Show available IBAN' recovery action on the provider-
  unavailable error instead of a retry-only dead end, and reflect it in
  the e2e scenarios and baselines
- Commit completion only on a successful confirmation; surface errors
  and guard against unmount and identity changes
- Bundle the provider-switch props, replace enum casts with a typed
  parser, type the deferred test helpers and drop the now-unused
  Frick-only request predicate
…e KYC hint

- Hold the quote generation across the confirm round trip and drop any
  resolution for a quote the form has since replaced; invalidation also
  clears the confirming state
- Split the automatic-Frick suppression flag from the KYC hint so
  recovering from an unavailable provider no longer shows a misleading
  KYC message; only a genuine KycRequired fallback renders the hint
- Localize the recovery action, pass the switch target through the
  toggle callback, drop the duplicate provider derivation on the info
  screen and refresh stale comments
- Cover the identity round trip in a dedicated row-hook suite, click the
  recovery action in both unavailable e2e scenarios asserting the
  selector-free follow-up request, and pin the stale-generation
  confirmation race
Each quote request persists its own transaction request, so the
approximate quote and the exact-price refinement of the same generation
carry different ids. Confirmation is now gated on the final quote commit
and the confirm callback additionally re-checks the displayed request id
before committing completion, so a confirmation can never target a
request the screen has since replaced. Also aligns test-mock indentation
and moves the JSX-free row-hook suite to a .ts extension.
…pletion screen

The completion screen read the live quote state, so a re-quote triggered
by late-loading context could swap the payment information a customer
had already acted on - both on screen and in the close payload handed to
the host application. Both buy flows now snapshot the completed
information under the customer identity and render only from that
snapshot, cleared alongside the completion flag on an identity change.

Also pins the close payload to the passed payment information and makes
the quote fixtures disjoint so freezing the approximate instead of the
final quote can no longer pass unnoticed.
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

10 review passes (conformance and logic, each pass covering the full diff) until both dimensions came back clean. Summary of what the later passes actually changed, since most of it is behaviour rather than polish:

Cross-account and timing correctness

  • KYC is now evaluated only against a user object whose accountId matches the session identity; a foreign user counts as still loading and joins the existing ten-second fail-open. Previously an in-place session swap could let one account be judged with another account's KYC level.
  • The automatic-Frick suppression flag was split from the KYC hint. Recovering from an unavailable provider no longer shows a misleading "requires KYC level 50" message; only a genuine KycRequired fallback renders it.
  • Confirmation is pinned to the quote generation and to the displayed request id, and it is only possible once the final (exact-price) quote is committed. Each quote request persists its own transaction request, so this closes the window where a confirmation could target the approximate request while the screen moved on to the exact one. A rejected confirmation now surfaces as an error instead of a "Done!" screen.
  • The completion screen renders from an identity-tagged snapshot of the completed payment information instead of the live quote state. Late-arriving context can still re-quote in the background, but it can no longer swap the information a customer already acted on — on screen or in the close payload handed to the host application.

Dead ends removed

  • PersonalIbanProviderNotAvailable used to offer only a retry of the very provider that just failed. There is now a "Show available IBAN" action that clears the selection and re-quotes selector-free, matching what the error copy promises. Localized in all supported languages.

Structure

  • The row-loading state machine moved into a shared hook and the provider decision tree into a pure, unit-tested helper, so both buy flows derive the provider from one place instead of two near-identical copies.

Test findings worth naming

  • The static quote mocks omitted asset.uniqueName, which the exact-price echo suppression compares — every static quote fired a redundant reverse quote that could reset the collection-IBAN toggle mid-interaction.
  • Two quote fixtures had IBAN values where one was a substring of the other; since the assertion checks containment, freezing the approximate instead of the final quote would have passed unnoticed. They are disjoint now.

Deliberately declared, not fixed here (see the PR description): the raw POST buy/personalIban in useVirtualIban (pre-existing, belongs to a different screen, needs an additive SDK change first), the pre-existing empty onSubmit handler, and the identical two-phase quote commit in the sell and swap screens.

One reported point was declined with reasons: an additional test combining the real completion component, a close click and an in-flight re-quote. Both ends of that chain are already pinned — the screen passing the frozen information (asserted during and after the background re-quote) and the component handing exactly its prop to the close channel (reference identity) — with nothing but prop forwarding in between.

This PR stays in draft until the SDK release it depends on has landed; the dependency bump follows, and CI is red by design until then.

Deutsch

10 Review-Durchläufe (Konformität und Logik, jeder über den vollständigen Diff), bis beide Dimensionen sauber waren. Was die späteren Durchläufe geändert haben, ist überwiegend Verhalten, nicht Kosmetik:

Konto- und Timing-Korrektheit

  • KYC wird nur noch gegen ein Benutzerobjekt bewertet, dessen accountId zur Sitzungsidentität passt; ein fremdes Objekt gilt als „lädt noch" und läuft in das bestehende Zehn-Sekunden-Fail-open. Zuvor konnte ein Sitzungswechsel im laufenden Betrieb dazu führen, dass ein Konto mit dem KYC-Stand eines anderen bewertet wird.
  • Die Unterdrückung des automatischen Frick-Defaults ist vom KYC-Hinweis getrennt. Nach einer Erholung aus einem nicht verfügbaren Provider erscheint kein irreführendes „erfordert KYC-Stufe 50" mehr; der Hinweis kommt nur noch beim echten KycRequired-Fallback.
  • Die Bestätigung ist an die Quote-Generation und die angezeigte Request-Id gebunden und erst möglich, wenn die finale Quote (exakter Preis) steht. Jede Quote-Anfrage persistiert eine eigene Transaktionsanfrage — damit ist das Fenster geschlossen, in dem eine Bestätigung die approximative Anfrage treffen konnte, während die Anzeige längst auf der exakten stand. Eine fehlgeschlagene Bestätigung zeigt jetzt einen Fehler statt „Fertig".
  • Der Abschlussbildschirm rendert aus einem identitätsgebundenen Abbild der abgeschlossenen Zahlung statt aus dem lebenden Quote-Zustand. Nachladender Kontext kann weiterhin im Hintergrund neu anfragen, aber nicht mehr die Information austauschen, auf die der Kunde bereits gehandelt hat — weder auf dem Bildschirm noch im Payload an die einbettende Anwendung.

Sackgassen beseitigt

  • PersonalIbanProviderNotAvailable bot bisher nur einen erneuten Versuch mit genau dem Provider, der gerade fehlgeschlagen war. Jetzt gibt es „Verfügbare IBAN anzeigen": Auswahl zurücksetzen und selektorfrei neu anfragen — genau das, was der Fehlertext verspricht. In allen Sprachen übersetzt.

Struktur

  • Die Zustandsmaschine für das Laden der Zeilen liegt jetzt in einem gemeinsamen Hook, der Entscheidungsbaum in einer reinen, unit-getesteten Funktion. Beide Kaufwege leiten den Provider aus einer Quelle ab statt aus zwei fast gleichen Kopien.

Nennenswerte Testbefunde

  • Den statischen Quote-Mocks fehlte asset.uniqueName, das die Echo-Unterdrückung des exakten Preises vergleicht — dadurch löste jede statische Quote eine überflüssige Gegenanfrage aus, die den Sammel-IBAN-Umschalter mitten in der Bedienung zurücksetzen konnte.
  • Zwei Quote-Fixtures trugen IBAN-Werte, bei denen der eine Teilstring des anderen war; da die Prüfung auf Enthaltensein testet, wäre das Einfrieren der approximativen statt der finalen Quote unbemerkt geblieben. Die Werte sind jetzt disjunkt.

Bewusst deklariert, hier nicht behoben (siehe PR-Beschreibung): der rohe POST buy/personalIban in useVirtualIban (Bestand, gehört zu einem anderen Screen, braucht zuerst eine additive SDK-Ergänzung), der vorbestehende leere onSubmit-Handler sowie der identische zweistufige Quote-Commit in den Verkaufs- und Tausch-Bildschirmen.

Ein gemeldeter Punkt wurde begründet abgelehnt: ein zusätzlicher Test, der die echte Abschlusskomponente, einen Klick auf Schliessen und eine laufende Neuanfrage kombiniert. Beide Enden dieser Kette sind bereits abgesichert — der Bildschirm übergibt das eingefrorene Abbild (geprüft während und nach der Hintergrund-Neuanfrage), und die Komponente reicht exakt ihr Prop an den Schliessen-Kanal weiter (Referenzidentität) — dazwischen liegt nichts als Prop-Durchreichung.

Der PR bleibt im Entwurf, bis das benötigte SDK-Release vorliegt; danach folgt die Abhängigkeitsanhebung. Bis dahin ist die CI absichtlich rot.

The personal-IBAN provider types and the row-list hook this branch needs
are published as @dfx.swiss/react 1.8.0-beta.2 (pulling in
@dfx.swiss/core 0.7.0-beta.1), so the local tarball override is no
longer required.
@TaprootFreak
TaprootFreak marked this pull request as ready for review August 12, 2026 12:46
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

Ready for review. The SDK dependency this branch was waiting on is published, so the last blocker is closed: @dfx.swiss/react 1.8.0-beta.2 (pulling in @dfx.swiss/core 0.7.0-beta.1) is now consumed from the registry in the pin style this repository uses, and the local tarball override is gone. Verified before bumping that the published packages actually carry the symbols this branch needs.

All checks are green on the final head, including the handbook image build that is skipped while a PR is a draft, and the PR reports mergeable. Local chain on Node 20 (the version this repository's CI uses): lint, 84 suites / 1,052 tests, build:dev, widget:dev, index-swap check — all clean; both Playwright spec files pass twice in a row with only the three pre-existing environment cases described in the PR body.

One remaining item in the checklist above is not closable from here: DFXswiss/api#4813 has to be deployed for the feature to exist at all. Until then this frontend behaves exactly as it does today — without the bank field on the row list there is no switch offer and no Frick default, so the change is inert by construction rather than by a flag.

Deutsch

Bereit zum Review. Die SDK-Abhängigkeit, auf die dieser Branch gewartet hat, ist veröffentlicht — damit ist der letzte Blocker erledigt: @dfx.swiss/react 1.8.0-beta.2 (zieht @dfx.swiss/core 0.7.0-beta.1 nach) wird jetzt in der hier üblichen Pin-Schreibweise aus der Registry bezogen, der lokale Tarball-Override ist weg. Vor dem Anheben wurde geprüft, dass die veröffentlichten Pakete die benötigten Symbole tatsächlich enthalten.

Alle Checks auf dem finalen Stand sind grün, inklusive des Handbook-Image-Builds, der bei Entwürfen übersprungen wird; der PR ist mergefähig. Lokale Kette auf Node 20 (der Version, die die CI dieses Repositories nutzt): lint, 84 Suiten / 1'052 Tests, build:dev, widget:dev, Index-Swap-Prüfung — alles sauber; beide Playwright-Dateien laufen zweimal hintereinander durch, mit ausschliesslich den drei vorbestehenden Umgebungsfällen aus der PR-Beschreibung.

Ein Punkt der Liste oben lässt sich von hier aus nicht schliessen: DFXswiss/api#4813 muss deployt sein, damit das Feature überhaupt existiert. Bis dahin verhält sich dieses Frontend exakt wie heute — ohne das bank-Feld in der Zeilenliste gibt es kein Umschaltangebot und keinen Frick-Default, die Änderung ist also konstruktionsbedingt wirkungslos statt über einen Schalter.

@TaprootFreak
TaprootFreak merged commit 9d11400 into develop Aug 12, 2026
9 checks passed
@TaprootFreak
TaprootFreak deleted the feat/legacy-viban-toggle branch August 12, 2026 13:04
TaprootFreak added a commit to joshuakrueger-dfx/services that referenced this pull request Aug 12, 2026
DFXswiss#1320 rewrote the same five files while this branch waited, so the
rebase left them holding one side or the other. They now carry both:
develop's provider generalization untouched — the Yapeal parsing, the
precedence helper, the provider switch and every test around them — and
this branch's work lifted back on top of it.

Where the two genuinely met, the collection toggle keeps its own state
in the content component so the QR branch can see it, and the provider
switch stays wired exactly as develop passes it down; both buttons sit
in the same IBAN row. The dependency bump is gone: develop already
carries a newer SDK than this branch pinned.
TaprootFreak added a commit that referenced this pull request Aug 13, 2026
#1270)

* Make the collection-IBAN toggle readable and let the QR code follow it

The toggle added in #1258 works, but it is hard to read: it uses a colour
emoji next to the monochrome DFX icons, it never says which of the two
accounts is currently shown, and it never states the reason it exists, so
the customers it was built for do not find it.

- Replace the emoji with IconVariant.SWAP, without size and colour props so
  it matches the neighbouring CopyButton exactly. Drop the extra ml-1, which
  broke the row's gap-3 rhythm, and add a focus ring and aria-pressed.
- Name the state and the reason in the row's existing infoText slot, the same
  pattern the remittance-info row already uses. On the collection account the
  hint also states that the remittance info is mandatory: a transfer there is
  attributable through the reference only.
- Carry the switch into the QR tab. It encoded the personal IBAN even after
  switching, so it handed the customer the very IBAN their bank rejects.
  toCollectionIbanGiroCode rewrites line 7 of the EPC payload and nothing
  else; holder, amount and remittance stay identical. Both the text and the
  QR branch are gated on canOfferCollectionIban, so display, copy and QR can
  never diverge. It fails closed - if the payload is not a well-formed SCT
  GiroCode carrying the displayed IBAN, no QR code is rendered at all and the
  screen asks for manual entry.

Behaviour and gating are unchanged; canOfferCollectionIban is untouched. The
accessible names stay verbatim so the tests from #1258 remain valid.

* Let the PDF invoice follow the switch as well

The invoice button sits inside the QR tab, and the document it opens is
rebuilt server-side from the stored request - so it kept naming the personal
vIBAN while the screen, the copy button and the QR code already showed the
collection account. The customer ended up holding two documents naming
different receiving accounts.

DFXswiss/backend#4686 adds an optional collectionAccount switch to
PUT /buy/paymentInfos/:id/invoice. This sends it while the collection account
is displayed, using the SDK's own BuyUrl.invoice builder through useApi so the
request stays identical apart from the query. The parameter is omitted, never
sent as false, because the backend maps any present value to true.

The three rejection tokens the endpoint can answer with all mean the same
thing to a customer - the invoice cannot be issued for the collection account
right now, the details on screen still apply - so they share one message while
staying separate tokens for the logs.

* Drop the error token the endpoint no longer emits

Review of the api side found that the reference guard behind
CollectionAccountInvoiceReferenceMissing could not be reached: buy.bankUsage
is NOT NULL in the schema and set on every route creation, so the guard and
its token were removed there. Mapping a token no consumer can receive would
be dead code on this side too.

* Match the renamed collection-account error token

The api renames CollectionAccountInvoiceRequiresPersonalIban to
CollectionAccountInvoicePersonalIbanMissing, so the token says what the guard found
instead of what it wanted. The customer-facing message is unchanged; only the string
it matches on moves.

* Call the buy invoice through the SDK instead of a hand-built URL

* Never open the invoice PDF for a mode or session the UI has left

* Refuse to rewrite a GiroCode that does not carry the quote's remittance info

* Cover the three files this pull request touches

CONTRIBUTING requires every touched file to reach 100 percent on all four
metrics and the per-file numbers to be stated in the description. Measured on
the previous head they were 70.96/94.59/40/68.96 for payment-info-buy.tsx,
90.32/88.23/100/92.85 for payment-qr-code.tsx and 98.82/98.76/100/100 for
personal-iban.ts.

The gaps were the render branches and copy callbacks of the payment rows, the
early return that sends a customer with incomplete KYC to their profile instead
of issuing an invoice, and the generation guard in the catch block - the error
half of the stale-response protection, which until now had only its success half
pinned. That last one is the reason this is more than bookkeeping: a late
rejection arriving after the customer switched modes must not surface an error
for a mode they have left, and nothing held that property in place.

Tests only.

* Present the IBAN toggle as a plain action button and cover its last guard branch

* Capture the fail-closed QR state as a committed handbook baseline

The buy spec asserted twice that the manual-entry hint is not visible, but
nothing ever captured it when it is: a GiroCode whose remittance line does
not carry the quote's reference must render no QR at all. The new case
mirrors the neighbouring QR test with one changed payload line and pins
the state to a baseline like the other four.

* Refuse to rewrite a GiroCode that populates both remittance carriers

EPC069-12 allows exactly one remittance carrier — structured line 9 or
unstructured line 10. The mismatch check accepted a payload carrying both
as long as one of them matched the quote's reference; such a payload is
non-compliant and is now not rewritten at all. The rebuild semantics are
pinned alongside: CRLF input normalizes to LF and a trailing blank line
is dropped, with the IBAN line remaining the only changed field.

* Pin both halves of the collection-QR guard, not just the IBAN one

The case added in the previous commit reaches the guard's false arm only
through a missing IBAN, although its title names the remittance info as
well. Branch coverage cannot see the difference - Istanbul counts the
ternary once, and the IBAN case already covers it - so the file read 100
percent while dropping the second operand changed nothing: 21 of 21 tests
stayed green.

The case is now an it.each over both fields. Each half of the condition
fails its own case when removed, and the parameter is in the title so a
failure says which one.

Worth knowing how these die: not through the assertions but through a
TypeError, because without the guard the payload builder is handed
undefined and calls trim or replace on it. That is what the guard is for -
it keeps undefined away from a function that assumes strings - so the
crash is the property being pinned rather than a weak proof. An empty
string is a different matter and stays green under the same mutation:
there the builder's own check rejects it and the screen looks identical.

Tests only.

* Keep the PDF invoice available when only the QR image is refused

The fail-closed rewrite gates the QR image, but the invoice is built
server-side and does not depend on it — replacing the whole component
with a hint also took the button away. The hint now renders inside
PaymentQrCode with the button below it, pinned by a regenerated baseline.

Three hardenings alongside: the generation guard bumps in a layout
effect so a response cannot land between commit and the passive-effect
flush; the GiroCode parser validates the EPC character-set line (1-8)
and no longer repairs leading whitespace by trimming the whole payload;
and the collection-invoice error state gets its own e2e case and
baseline.

* Accept the remittance reference on the unstructured line only

The api emits the reference exclusively on unstructured line 10, and a
quote reference is never a valid ISO 11649 structured reference — this
function never validated one, so a payload carrying the reference on
line 9 is not the api's output and is now refused instead of rewritten.
This subsumes the dual-carrier refusal.

Two test gaps closed alongside: the offer-gate rerender case now pins
the mock attributes, and a txId change during an in-flight request is
pinned to discard the response.

* Delete the dead guard branch by making the rewrite its own gate

The caller guarded toCollectionIbanGiroCode with a ternary whose false
arm is unreachable — canOfferCollectionIban already requires iban and
remittanceInfo — and covering it meant forcing the gate mock into a
state the app cannot reach. The function now accepts both fields as
possibly undefined and refuses first thing when either is missing, the
caller passes the quote fields straight through, and the refusal is
pinned by two direct unit tests. The module mock in the toggle spec is
gone with it: every remaining case tests through the real gate.

* Re-arm the invoice guard when the signed-in account changes

A deep-link param push replaces the session in place — wallet.context
applies updateSession without unmounting the screen — so an invoice
requested under the previous session could still open after the swap.
The generation guard now also keys on user.accountId, which changes
exactly when the signed-in identity changes and not on periodic context
refreshes, so legitimate in-flight downloads are not discarded.

* Key the invoice guard on the session, not the lagging user context

An in-place token swap changes the session synchronously, but the SDK's
user context reloads only when the logged-in boolean flips — which it
does not across a swap — so user.accountId can keep serving the previous
account and the guard added for this scenario never fired. The guard now
keys on session.account from useApiSession, and the test models reality:
the session changes while the mocked user stays stale.

The GiroCode docstring also stops asserting backend behaviour it cannot
prove; the structured-carrier refusal is now justified locally — no
ISO 11649 validation exists here, so that carrier is outside the
validated shape, and a wrong refusal costs only the QR image.

* Pin the two combinatorial states the baselines missed

The toggle baselines are shot against a quote without a paymentRequest
and therefore show the tabless presentation — the Text tab with the tab
bar visible in the collection state had no baseline. And the fail-closed
QR state and a rejected invoice can occur together, but neither test
ever produced them at once. Both states are pinned now, and the toggle
test carries the same lang=en pin as its siblings so the locale of the
test account cannot decide what the baselines show.

* Track both session identity fields and align the gate with the rewrite

The guard now keys on session.account and session.user, so a token swap
keeping one but changing the other still re-arms, and a concurrency test
pins that a stale response cannot clear a newer request's loading state.
The e2e invoice-error case asserts the request actually carries
collectionAccount=true instead of trusting a mock that answers anything.

canOfferCollectionIban refuses whitespace-only fields, matching the trim
semantics of the rewrite it gates, and the rewrite's length guard states
its real minimum: eleven lines, since the unstructured reference on
index 10 is required — which also let the now-dead length ternary go.
The Text tab with the tab bar visible is pinned in both toggle states.

* Validate the amount line before rewriting a GiroCode

The rewrite validated every field it depends on except the amount: a
payload whose line 7 disagrees with the quote would have been rewritten,
and the collection QR would offer a different amount than the Text tab
beside it. Line 7 must now carry EUR plus exactly the quote's amount —
numeric equality, so EUR100 and EUR100.00 both match 100 — and a missing
amount refuses like the other quote fields.

* Mock the tab container the way it actually behaves

The toggle spec's StyledTabContainer mock rendered every tab at once —
a state the real component never produces, since it renders only the
active tab. The mock is stateful now, tabs switch by click like in
production, and the QR assertions activate their tab first.

* Hold the amount line to strict EPC grammar before comparing

Number() coerces more than EPC069-12 allows: EUR1e2, EUR0x64 or a bare
EUR would have compared equal to a clean amount. The numeric part must
now be plain digits with at most two decimals, and the comparison runs
in cents, so EUR100 and EUR100.00 still both match 100 while exponent,
hex and signed spellings refuse.

* Cap the amount grammar at the EPC ceiling in canonical form

Nine integer digits is where EPC069-12 stops (999999999.99), and a
canonical amount carries no leading zeros — EUR000000000100 numerically
equals 100 but is not the format the standard permits, so both now
refuse rather than compare.

* Lift the collection hardenings onto the per-currency world of #1305

The rebase onto develop kept this branch's versions of five files, which
carried the pull request's hardenings but predated #1305's EUR+CHF
generalization. This commit restores that architecture — the per-currency
collection map, FRICK_CURRENCIES, getFrickCollectionIban and
getOfferableCollectionIban — and lifts the hardenings onto it: the
whitespace gate lives in getOfferableCollectionIban now, the display
follows the per-currency IBAN, and the GiroCode rewrite keeps every gate
while stating why it stays EUR-bound: EPC069-12 exists for EUR only, a
CHF quote carries a QR-Bill the first check refuses, so CHF fails closed
into the hint with the PDF button.

The #1305 test coverage travels along — per-currency unit cases, the CHF
toggle and the two non-Frick-currency e2e cases — and the three CHF
baselines are regenerated on this branch, which changes the toggle's
look the same way it does for EUR.

* Gate the rewrite on the quote currency and the EPC field count

The EUR binding rested on the backend's payload contract — a CHF quote
carries a QR-Bill the svg check refuses. Now the quote's own currency
must say EUR before anything else is read, and a payload longer than
EPC069-12's twelve fields is not reassembled. The three currency e2e
cases inherit the file's lang=en pin, and the CHF fail-closed QR state
gets its own baseline: hint and PDF button, no code.

* Keep the collection QR when the quote carries no source amount

The amount line is optional in EPC069-12, and the api leaves it empty
whenever the customer quotes by target amount — the two amount fields
are mutually exclusive, so buying 0.05 ETH sends no source amount at
all. The gate demanded EUR plus a figure and refused those payloads, so
the collection QR silently disappeared for a perfectly ordinary flow
while the personal QR still rendered.

An absent amount contradicts nothing: it is the same absence the
personal QR carries, and the customer types the figure in their banking
app. A populated amount must still match the quote to the cent.

* Re-apply the collection work on top of the Yapeal generalization

#1320 rewrote the same five files while this branch waited, so the
rebase left them holding one side or the other. They now carry both:
develop's provider generalization untouched — the Yapeal parsing, the
precedence helper, the provider switch and every test around them — and
this branch's work lifted back on top of it.

Where the two genuinely met, the collection toggle keeps its own state
in the content component so the QR branch can see it, and the provider
switch stays wired exactly as develop passes it down; both buttons sit
in the same IBAN row. The dependency bump is gone: develop already
carries a newer SDK than this branch pinned.

* Refuse a malformed amount line and compare the amount exactly

A line of spaces is not an absent amount: absent states nothing, spaces
state nonsense, and everywhere else this function refuses malformed
input rather than passing it through. Only a genuinely empty line takes
the no-amount path now.

The comparison also stops rounding to cents, which let a quote of
100.004 pass against a payload reading EUR100.00 while the screen showed
and copied the unrounded figure. Plain equality accepts every canonical
spelling — EUR100 and EUR100.00 both parse to 100 — and rejects the
divergence.

The e2e case that pinned the empty-amount payload is gone with them: it
carried no screenshot, so it proved function rather than appearance, and
the same property is already pinned where it belongs, in the unit test.

* Let the payload decide its own decimals, and declare the fakes

Two decimals is the standard's limit for what a payment carries, not a
limit on what a customer may enter: the amount field takes any figure
and the api writes it into the payload unformatted, so a purchase of
100.004 produced a line the grammar rejected and the collection QR
disappeared for it. The character guard stays — plain decimal digits, so
nothing can be read as a number other than what stands there — while the
equality against the displayed amount does the deciding, which is the
only property that ever mattered here.

The e2e cases this branch adds fake two things the suite never faked
before: a customer's KYC completeness and the invoice endpoint's
rejection. CONTRIBUTING requires each fake to be declared with what a
green run does not prove, and this repository had nowhere to put such an
entry — the section exists now, with all three fakes of these specs.

* Compare the amount line against what the api writes

Three rounds tuned a decimal bound that was arbitrary in both
directions: too tight and a legitimate purchase lost its QR, too loose
and two different amounts collided onto the same floating-point value
near a hundred million, so a payload could carry an amount the screen
never showed.

The bound is gone. The api renders that line as the currency prefix
followed by the quote amount, so the exact string is known here and
comparing against it needs no parsing, cannot be read as a number other
than what stands there, and leaves nothing left to tune. A payload
spelled differently is not this api's output and is refused.

* Count both specs in the reality declaration

Both collection-invoice cases install the KYC and invoice-400 fakes, not one.

* Refuse a payload that names another creditor

The rewrite kept line 5 as it found it and trusted the toggle's own gate for the
holder. Both accounts are held by DFX AG, so a payload naming anyone else is not
one this function may retarget.

* End the creditor name at its boundary

A bare prefix test also accepts a company whose name merely begins with the
holder's, so the name must end at the address separator or the line's end.

---------

Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant