Skip to content

c638b50e - Label the invoice screen for the payer when opened with ?pay - #1274

Draft
joshuakrueger-dfx wants to merge 22 commits into
DFXswiss:developfrom
joshuakrueger-dfx:feat/invoice-payer-wording
Draft

c638b50e - Label the invoice screen for the payer when opened with ?pay#1274
joshuakrueger-dfx wants to merge 22 commits into
DFXswiss:developfrom
joshuakrueger-dfx:feat/invoice-payer-wording

Conversation

@joshuakrueger-dfx

@joshuakrueger-dfx joshuakrueger-dfx commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

EN:
A pay query parameter switches /invoice to payer wording, keeps that mode across a reload, and renders a printed payee as text instead of an editable field; it also closes a path that let a printed QR carry a foreign lightning value into the payment page. The branch is rebased onto current develop (b3052bd) instead of carrying a merge commit, and the eighteen reviewed commits are unchanged in content. Four further commits answer a second review round: a superseded payment callback can no longer reach the payer, the displayed payee announces its error state instead of only colouring it, and a fifth handbook baseline covers the typed and verified payee. The full-stack stage still records mode=none because a develop pull request needs the ci:full label for it, and applying that label needs write access this fork author does not have — the earlier description blamed a missing repository secret for it, which was wrong.

DE:
Ein pay-Query-Parameter stellt /invoice auf die Sprache des Zahlers um, hält diesen Modus über einen Reload und zeigt einen gedruckten Empfänger als Text statt als editierbares Feld; ausserdem wird ein Pfad geschlossen, über den ein gedruckter QR-Code einen fremden lightning-Wert in die Zahlungsseite tragen konnte. Der Branch ist auf das aktuelle develop (b3052bd) rebased statt über einen Merge-Commit integriert, die achtzehn reviewten Commits sind inhaltlich unverändert. Vier weitere Commits beantworten eine zweite Review-Runde: Ein überholter Zahlungs-Callback erreicht den Zahler nicht mehr, der angezeigte Empfänger meldet seinen Fehlerzustand nicht mehr nur über die Farbe, und eine fünfte Handbuch-Baseline deckt den getippten, bestätigten Empfänger ab. Die Full-Stack-Stufe meldet weiterhin mode=none, weil ein develop-Pull-Request dafür das Label ci:full braucht und das Setzen dieses Labels Schreibrechte verlangt, die dieser Fork-Autor nicht hat — die frühere Beschreibung machte ein fehlendes Repository-Secret dafür verantwortlich, das war falsch.

Details

Not symptom-driven: No incident and no error report behind this. It came out of reading the screen together with the product owner while setting up printed-invoice QR codes for a merchant.
Scale: The screen is the payment entry point for every OpenCryptoPay payer; the first merchant printing this QR bills more than 100 invoices a month, each one sending a customer here.
Smaller fix considered: Reusing the existing recipient parameter as the payer signal, which would add no new parameter at all — insufficient because the documentation defines recipient as a convenience for the merchant to pre-fill the form, so it does not identify who is looking at the screen and would relabel the merchant flow as well.

Open items — none of these can be closed from this branch

What has to happen Why not from here Who can do it
Grant or refuse the written exception for the SDK rule. Granted by marassteiner on this pull request. marassteiner
Merge DFXswiss/packages#201 (feat/payment-link-pay-contract). Different repository; it also carries the response type #206 depends on. packages maintainers
Merge DFXswiss/packages#206 — the SDK method for the hand-built call, open as a draft on top of #201. Different repository, and it cannot land before #201. packages maintainers
Publish @dfx.swiss/react with that method. Release process, not a branch. packages maintainers
Decide on the pre-existing defects listed below — deferral granted; tracked as #1345#1349. marassteiner
Apply the ci:full label so the full-stack stage actually brings the stack up on this head instead of recording mode=none. The label needs write access; this branch comes from a fork whose author has read access only (AddLabelsToLabelable is refused). anyone with write access to this repository
Open the consumer pull requestrefactor: use the SDK invoice payment call. Needs the published version first; this repository keeps consumer work in its own pull request (#1239#1243 are the open examples). me, once the row above is done

Declared deviation

Per Deviating from these guidelines, stated explicitly rather than left to be discovered:

This pull request does not meet "API access goes through the SDK." invoice.screen.tsx:147
still builds the URL for GET /v1/paymentLink/payment by hand and fires it with fetchJson.

Reason: the guideline's own remedy — add it to the SDK, release it, then consume it — has a
step no branch in this repository can perform. @dfx.swiss/react has no method for this endpoint;
the SDK side is written and open as DFXswiss/packages#206, stacked on #201. Until both land
and a version is published, there is nothing here to consume, and working around it at the call
site is the specific thing the guideline rules out. The exception was granted in writing by
marassteiner (review of 2026-08-12, reaffirmed in three later reviews).

Context

A merchant wants one static QR code printed on every paper invoice: the customer scans it, types
the invoice number and the amount, and pays in crypto. app.dfx.swiss/invoice?recipient=<route label> already supports that. What it was never built for is a payer standing in front of it —
every label on that path was written for the merchant creating a link.

Change

pay switches the screen to payer wording ("Pay invoice", "Invoice number", "Invoice amount",
"Continue to payment") plus a line asking the payer to copy both values from the invoice. Without
the parameter every existing string stays byte-identical. The printed QR then carries
…/invoice?recipient=<label>&pay=1.

The screen used to clear the query on mount, so a reload, a tab restore or a back navigation
dropped pay and fell back to merchant wording while autofill restored the values — it looked as
if only the labels were wrong. The query is now kept in payer mode and still cleared in merchant
mode.

Keeping it made every other parameter of the opened URL travel onwards, which is a real hijack
path: payment-link.context.tsx resolves lightning through Lnurl.decode before it looks at
the invoice at all. "Continue to payment" therefore navigates with the object form of navigate
and the new replaceParams: true, so the target query is an allowlist of routeId/route,
amount, message and expiryDate and inherits nothing. The option is additive and off by
default; every other caller merges exactly as before.

Review rounds

The first round raised eight findings: first painted frame, retained query in the payment request,
incomplete wording switch, German form of address, pay parsed as a blocklist instead of an
allowlist, a test that tested neither reload nor back, a freely editable printed recipient, and the
SDK rule. Seven are fixed here; the eighth is the declared deviation above. A later round asked for
the payer flow in the full-stack harness (e2e-stack/specs/payment-links.spec.ts) and for the
reality declaration of the stubbed recipient lookup (docs/test-architecture.md).

The most recent round found three further defects, fixed in this branch:

  1. A superseded payment callback could reach the payer. The screen validates on debounced form
    values while the button is gated on live validity, so editing an already validated amount left
    the button active with the previous callback: the payer could send an amount the screen no
    longer showed. The live values now clear the callback, and validatePayment ignores the answer
    of a request that has been superseded.
  2. The displayed payee carried its error state in the text colour alone — the StyledInput it
    replaced at least exposed forceError. It now sets aria-invalid and points at the explanation
    through aria-describedby, and its label id comes from useId() so two mounted instances (the
    app is also mounted as a widget) cannot collide on a fixed DOM id.
  3. The handbook missed a visual variant this change introduces — payer mode without a printed
    recipient, the payee typed and confirmed, invoice fields unlocked with the currency prefix. It
    has its own baseline now.

Reported defects that stay open

Per Report every bug you find, including pre-existing ones. None is fixed here; the deferral for
the first five was granted in writing.

  1. invoice.screen.tsx:115-130 — no guard against superseded recipient responses.
    validateRecipient fires per debounced keystroke with no request token. An earlier answer
    arriving last unlocks the invoice fields and shows the tick for a name the user has already
    replaced, while the payment call stays blocked — a form that stalls with no error. invoice.screen: guard validateRecipient against superseded responses #1345.
  2. invoice.screen.tsx:147 with util/utils.ts — HTTP status discarded. fetchJson drops
    ok and status; the only failure criterion is an error field, so a 4xx body without it
    takes the success path. payment-link.context.tsx checks statusCode for the same endpoint —
    the two consumers disagree. invoice.screen / fetchJson: HTTP status discarded on paymentLink/payment GET #1346.
  3. navigation.hook.ts:36-45 with navigation-link.tsx:18clearParams is silently ignored
    in the string form.
    NavigationLink passes clearParams: ['lightning', 'key'] and is
    therefore a no-op. navigation.hook: clearParams is a no-op for string navigate (NavigationLink) #1347. Newly relevant here: because payer mode keeps the query, those values
    now survive a menu navigation instead of dying at the mount clear. No menu target consumes
    them today (PaymentLinkProvider is mounted only under /pl), so this is exposure in the URL
    and in browser history, not a proven takeover.
  4. util/utils.ts:122redirect-uri accepts any https target. isSafeRedirectUri returns
    true for every https: URL with no host allowlist, and the parameter is stripped from the
    address bar afterwards, so the payer sees a clean URL. isSafeRedirectUri: any https target accepted (no host allowlist) #1348.
  5. invoice.screen.tsx:294-300 — markup injection into the error text. The Trans block
    interpolates the recipient name with escapeValue: false, so <strong>, <br>, <i> and
    <p> become real elements. Not XSS — unknown tags are escaped and injected attributes dropped
    — but an attacker can place a bold sentence inside what reads as a DFX error message. invoice.screen: Trans interpolates recipient with escapeValue false (markup in error) #1349.
  6. New: the confirmation tick proves the route exists, not who owns it. validateRecipient
    uses only currency from the answer (invoice.screen.tsx:122-127); the payee shown is the
    string from the URL. With a numeric value the screen sends routeId (:141) and displays
    "Payee: 42 ✓" — a payer cannot tell whom they are paying, and the tick carries an accessible
    name that says "verified". Fixing this needs a display name from the API, so it is not a change
    this repository can make alone.
  7. New: in payer mode a foreign query survives for the rest of the session. Before this branch
    the mount clear removed every unknown parameter unconditionally. replaceParams: true protects
    the continue button only; see point 3.

Handbook

The screen had no representation at all. It now has its own spec, its own group key and five
committed baselines: merchant, payer with an empty payee, payer with the payee printed and
rejected, payer with the payee printed and confirmed, and payer with the payee typed and
confirmed. All five were checked for scannable codes and readable secrets with zbarimg and with
Apple Vision (barcodes plus OCR over the whole image): no barcode in any of them, no credential in
the recognised text.

Named so the gap is a number rather than a silence: four further states of this screen exist that
this change does not introduce and that no baseline covers — the merchant side with an accepted
recipient, a validated payment, the payment error hint, and the German, French and Italian
renderings.

Coverage

Per file, for every file this pull request touches that Jest instruments (src/**/*.{ts,tsx,js,jsx}
minus *.d.ts). Translation JSON, the e2e specs and the baselines are not measured. Measured on
this head with the full suite:

File Stmts Branch Funcs Lines
src/screens/invoice.screen.tsx 100 100 100 100
src/hooks/navigation.hook.ts 100 100 100 100
src/components/payment/qr-code.tsx 100 100 100 100

Before this branch: invoice.screen.tsx 78.57 / 69.38 / 56.25 / 76.92, navigation.hook.ts
70.83 / 46.15 / 66.66 / 70, qr-code.tsx 0 / 0 / 0 / 0.

Not verified

  • The path beyond "Continue to payment" through to a settled transaction, and the QR printed on
    paper and scanned with a real camera.
  • Whether the dev API keeps rejecting the placeholder name used in the rejected-payee baseline; if
    a route with that label ever exists there, that image changes.
  • One test outside this diff (src/__tests__/realunit.screen.test.tsx, number formatting) fails
    locally on macOS; it fails identically on unmodified develop, so it is not this branch.

Note for later

The app2 rewrite carries its own invoice screen on feat/app2-preview; it will need the same
wording so the two do not drift apart.

@joshuakrueger-dfx
joshuakrueger-dfx marked this pull request as ready for review August 6, 2026 10:31
@TaprootFreak

Copy link
Copy Markdown
Contributor

Review — full run

Two independent review lanes (conformity + logic), every finding re-verified against the source. Measured locally on 4fb0e52 with Node 20: ESLint exit 0, 73 suites / 794 tests green. Reverting invoice.screen.tsx to the pre-PR state turns exactly the two payer cases red (2 of 6) — the new tests really do hang on the change.

Bugs introduced by this PR

1. The first painted frame shows merchant wording in payer mode.
isPayerMode is state plus a passive useEffect (src/screens/invoice.screen.tsx:54,68-76), so the initial render commits with false. Measured title sequence for /invoice?recipient=Foo&pay=1: ["Create Invoice", "Pay invoice", "Pay invoice"]. The value is derivable from urlParams on every render — in payer mode the query is deliberately not cleared — so state plus effect is unnecessary. Repo precedent for the direct derivation: src/screens/mail-login.screen.tsx:19. The new tests cannot see this: useLayoutOptions is mocked and every assertion sits behind waitFor, which is a no-op here because RTL flushes passive effects inside render().

2. The retained query string is carried into the payment request.
navigate(string) merges the current location.search into the target (src/hooks/navigation.hook.ts:32relativeUrl, src/util/utils.ts:64-81). In payer mode location.search is no longer cleared, so "Continue to payment" navigates to /pl?routeId=…&amount=…&message=…&expiryDate=…&recipient=Foo&pay=1, and src/contexts/payment-link.context.tsx:200-206 builds the API URL from the complete urlParamsrecipient and pay are sent to GET /v1/paymentLink/payment. The API drops them (global whitelist: true without forbidNonWhitelisted, and CreateInvoicePaymentDto knows neither field), so nothing breaks today, but the coupling is unintended, it was impossible before this PR, and the merchant's route label now appears in the request log.

3. The wording switch is incomplete.
The QR code and the "Copy Link" button (invoice.screen.tsx:151-160) are merchant tools and are not tied to isPayerMode. In payer mode they sit above the form as a loading placeholder until the payer has filled in both fields — the most prominent element on a screen that is supposed to read as "pay this bill". The recipient field (:172-182) also keeps the merchant label "Recipient" and the placeholder "John Doe".

4. The new German string breaks the file's form of address.
src/translations/languages/de.json renders the hint as an impersonal infinitive ("Rechnungsnummer und Rechnungsbetrag genau so eingeben, wie sie auf der Rechnung stehen"), while every other instruction in that file uses the capitalised Du form plus imperative (e.g. "Bitte gib ihn hier ein.", "Um zu handeln, gib bitte Deine E-Mail-Adresse ein."). fr.json and it.json are consistent with their neighbours.

5. pay is parsed as a blocklist instead of an allowlist.
pay !== null && pay !== '0' && pay !== 'false' (invoice.screen.tsx:70-71) enables payer mode for ?pay=no, ?pay=False, ?pay=off and the empty ?pay=. Exact string comparison, no toLowerCase(). An allowlist (['1','true','yes']) matches what the documented pay=1 promises.

6. The test named "survives reload / back" tests neither.
src/__tests__/invoice-screen-payer-wording.test.tsx:243-253 renders once and reads router.state.location.search — no router.navigate(-1), no remount, no reload. It asserts the initial state it was handed.

Pre-existing bugs in the files this PR touches

These predate the diff. Reporting them as bugs, not as observations — a bug does not get less severe for being older.

7. The prefilled recipient field is freely editable.
invoice.screen.tsx:172-182: the recipient comes from the printed QR via setValue and has neither disabled nor readOnly. A payer standing at the counter can overwrite Foo and pay a different recipient than the one the merchant printed on the invoice. With ?pay this screen is now explicitly aimed at that payer, which turns a latent problem into a live one.

8. The pay-request call bypasses the SDK.
invoice.screen.tsx:36,127 builds an API URL by hand from Api.url and fires it with a raw fetch (fetchJson, src/util/utils.ts:291-294), while the same screen already uses usePaymentRoutes() from @dfx.swiss/react for getPaymentRecipient. Endpoint knowledge — verb, query shape, response type — lives in this repo and will silently go stale. PaymentRoutesInterface (SDK 1.7.0) has no method for the unauthenticated GET /v1/paymentLink/payment, so this belongs in DFXswiss/packages (packages/react/src/hooks/payment-routes.hook.ts) as an additive, optional-parameter change, released, then consumed here. Same pattern in src/contexts/payment-link.context.tsx:354,517.

Hard requirements for this PR

  • Self-contained. Every point above is fixed in this PR. No follow-up PR unless it is explicitly granted as an exception.
  • Handbook coverage must be 100 %. The handbook renders committed Playwright baselines plus scripts/handbook/metadata.json. e2e/payment-and-special.spec.ts covers /invoice but contains zero toHaveScreenshot assertions, and none of the 34 metadata entries describes the invoice screen — the screen this PR changes does not appear in the handbook at all. handbook-check.yaml does not even trigger, because its path filter matches none of the changed files. Both modes (merchant and payer) need a baseline and a metadata entry.
  • Test coverage must be 100 % on every file this PR touches. Currently src/screens/invoice.screen.tsx sits at 78.57 % statements, 69.38 % branches, 56.25 % functions, 76.92 % lines (--collectCoverageFrom='src/screens/invoice.screen.tsx'). The uncovered part is exactly where findings 2, 5 and 7 live: validatePayment, the callback navigation, and the error branches. useNavigation is fully mocked and mockNavigate is never asserted, so no test can currently observe what URL the payer is actually sent to.
  • Anything that belongs in the SDK goes into the SDK. See finding 8: the fix is a PR in DFXswiss/packages first, then consumption here — not a workaround at the call site.
  • Rebase. The branch is one commit behind develop (Render a missing KYC file date as a gap, not as an invalid date #1273).

Checked and dismissed

The missing Playwright baseline is not a CONTRIBUTING violation on its own: the rule says regenerate the screenshots your change affects, and no baseline covers this screen — there is nothing to regenerate. The gap is the handbook requirement above, which asks for baselines to be created. Two further lane findings did not survive verification: the empty dependency array does not freeze the mode on a query change (no code path navigates to /invoice with a changed query while the screen is mounted — the only internal caller is payment-routes.screen.tsx:790 without parameters), and the deep Form mock is not a foreign body (the same enrich pattern already exists in src/__tests__/buy-personal-iban-mismatch.test.tsx:88-104).

@joshuakrueger-dfx

Copy link
Copy Markdown
Contributor Author

@marassteiner please review

This is a PR, not an issue: 14 files against develop, head 3197f06, 6/6 checks green.

@joshuakrueger-dfx

Copy link
Copy Markdown
Contributor Author

@marassteiner please review

This is a PR, not an issue: 14 files against develop, head 3197f066, 6/6 checks green.

@marassteiner

Copy link
Copy Markdown
Collaborator

Sorry — I've reached my hourly job limit and can't take this on right now. I'll pick it up automatically once the limit resets.

@marassteiner

Copy link
Copy Markdown
Collaborator

EN: Working on this now — job dfxswiss__services__1274__pr-review.
DE: Ich arbeite jetzt daran — Auftrag dfxswiss__services__1274__pr-review.

@marassteiner marassteiner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — request changes

Verdict: Request changes (one merge-blocker). No approval until coverage on every touched instrumented file meets CONTRIBUTING.

Local run: LOCAL_RUN_OKnpm install (after npm ci lock/host mismatch unrelated to this diff), npm run lint exit 0, CI=true npm test -- --watchAll=false76 suites / 824 tests green, dev server (PORT=3001 BROWSER=none npm run start) listens and returns HTTP 200 for /, /invoice, /invoice?pay=1&recipient=test. Compile: “No issues found” (pre-existing source-map warnings only). CI on the PR is 6/6 green.

Diff review: Compliance and logic lanes both completed on 3197f066 vs develop. Logic: 0 defects. Compliance/product scope is sound aside from the coverage gate below.


Merge-blocker

1. CONTRIBUTING 100% coverage not met on two touched instrumented files

Severity: merge-blocker (mandatory CONTRIBUTING criterion)

CONTRIBUTING requires every file a PR touches that Jest instruments to reach 100% statements, branches, functions and lines. Measured on this head with the full suite:

File Stmts Branch Funcs Lines Uncovered
src/screens/invoice.screen.tsx 100 100 100 100
src/hooks/navigation.hook.ts 70.83 46.15 66.66 70 34, 55–62, 78 (navigate(number), goBack, setParams, clearParams())
src/components/payment/qr-code.tsx 0 0 0 0 whole module (mocked as QrBasic in invoice tests; real component never executed)

The PR description only reports numbers for invoice.screen.tsx and treats it as the sole instrumented touch. That understates the rule: both navigation.hook.ts and qr-code.tsx are under src/**/*.{ts,tsx} and were modified in this branch (including the loading skeleton that replaces the scannable placeholder).

Required before merge: Bring navigation.hook.ts and qr-code.tsx to 100% on all four metrics (or drop those files from the diff if the change can live without them). State the per-file table in the PR description. Suggested checks:

CI=true npm run test -- --watchAll=false --coverage   --collectCoverageFrom='src/screens/invoice.screen.tsx'   --collectCoverageFrom='src/hooks/navigation.hook.ts'   --collectCoverageFrom='src/components/payment/qr-code.tsx'

Written exceptions / deferrals (this review)

Per CONTRIBUTING, a declared deviation and deferred pre-existing defects need an explicit grant on the PR. Granted by this review:

  1. SDK deviation (finding 8 / declared deviation)exception granted.
    Hand-built GET /v1/paymentLink/payment via fetchJson in invoice.screen.tsx:136 may remain for this PR. Reason accepted: @dfx.swiss/react has no method yet; packages#206 is the proper path; consumer follow-up after publish is the established pattern (#1239#1243). Landing a local workaround is what the guideline forbids; keeping the call until the SDK ships is acceptable here. Please still land the consumer PR once packages#201/#206 publish.

  2. Pre-existing defects reported in the PR bodydeferral granted (do not block this PR). Tracked as:

    # Topic Issue
    1 validateRecipient race / superseded responses #1345
    2 fetchJson drops HTTP status on payment GET #1346
    3 string-form clearParams no-op (NavigationLink) #1347
    4 isSafeRedirectUri accepts any https host #1348
    5 Trans recipient markup with escapeValue: false #1349

What looks good (not blocking)

  • Payer mode derivation from the query on every render; first-paint payee fallback; query kept in payer mode / cleared in merchant mode.
  • Navigation allowlist via replaceParams: true (no query inheritance / hijack path) with real hook tests, not only mocks.
  • QR loading skeleton (no scannable placeholder URL); merchant QR/copy hidden in payer mode; payee as display text when from URL.
  • Handbook: e2e baselines + scripts/handbook/metadata.json entry for all four variants.
  • de/fr/it keys present for the new strings.
  • Logic review: 0 Mängel. Mutation-style evidence in the PR body is thorough for the invoice screen suite.

Follow-up only (already tracked / not grounds to block once coverage is fixed)

See issues #1345#1349 above. No additional style nits.


Summary for the author: Fix coverage on navigation.hook.ts and qr-code.tsx to 100%, update the PR body table, push. SDK exception and pre-existing deferrals are granted above. Re-request review after that.

No merge from this account.

joshuakrueger-dfx added a commit to joshuakrueger-dfx/services that referenced this pull request Aug 12, 2026
Review on DFXswiss#1274 blocked on CONTRIBUTING: every instrumented file the
branch touches must hit 100% on statements, branches, functions and
lines. invoice.screen.tsx already did; navigation.hook.ts and qr-code.tsx
did not. Cover the remaining hook surface (history navigate, goBack,
setParams, clearParams, setRedirect, string merge) and the real QrBasic /
QrCopy paths, including the loading skeleton that must not encode a
scannable URL.
@joshuakrueger-dfx

Copy link
Copy Markdown
Contributor Author

@marassteiner please re-review

Coverage merge-blocker from your previous review is addressed on head bba6b9d0:

File Stmts Branch Funcs Lines
src/screens/invoice.screen.tsx 100 100 100 100
src/hooks/navigation.hook.ts 100 100 100 100
src/components/payment/qr-code.tsx 100 100 100 100

Commit: test(invoice): bring navigation and QR coverage to 100%
CI on this head: 7/7 green (Build and test, Full-stack E2E, CodeQL, review, handbook, both Analyze jobs).

@marassteiner

Copy link
Copy Markdown
Collaborator

EN: Working on this now — job dfxswiss__services__1274__pr-review__r2.
DE: Ich arbeite jetzt daran — Auftrag dfxswiss__services__1274__pr-review__r2.

@marassteiner marassteiner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EN: Coverage merge-blocker is fixed on bba6b9d0; logic is clean, CI is green, and this review approves with two follow-up issues only.
DE: Der Coverage-Merge-Blocker ist auf bba6b9d0 behoben; Logik ist sauber, CI grün — Approve, nur zwei Follow-up-Issues.

Details

Head / gates

Item Result
Head bba6b9d0 (feat/invoice-payer-wording)
Base develop (GitHub: MERGEABLE; branch is ~18 commits behind)
Gate A — Konformität (Codex) complete; see findings assessment below
Gate A — Logik (Codex) complete — 0 Mängel
Gate B — local LOCAL_RUN_OK
CI on head 7/7 green (Build and test, Full-stack E2E, CodeQL, review, handbook, both Analyze jobs)

Previous merge-blocker — fixed

Measured locally on this head (Node 20, full suite also green: 77 suites / 837 tests):

CI=true npm run test -- --watchAll=false --coverage \
  --collectCoverageFrom='src/screens/invoice.screen.tsx' \
  --collectCoverageFrom='src/hooks/navigation.hook.ts' \
  --collectCoverageFrom='src/components/payment/qr-code.tsx' \
  --testPathPattern='invoice-screen-payer-wording|navigation-hook-clear-params|payment-qr-code-basic'
File Stmts Branch Funcs Lines
src/screens/invoice.screen.tsx 100 100 100 100
src/hooks/navigation.hook.ts 100 100 100 100
src/components/payment/qr-code.tsx 100 100 100 100

Also: npm run lint exit 0; npm start on port 43174 compiled ("No issues found") and responded HTTP 200.

Merge-blockers on this head

None. Recommendation: approve.

Written exceptions from the previous review remain in force (SDK hand-built GET /v1/paymentLink/payment; pre-existing defects #1345#1349).

Compliance findings assessed (not blocking)

Codex Konformität raised four potential blockers against current origin/develop CONTRIBUTING. Assessment:

  1. Rebase hygiene (~18 commits behind; overlapping de/fr/it.json + handbook metadata) — GitHub reports mergeable: MERGEABLE. Recommend rebasing onto current develop before merge for a clean translation/metadata integration, but this is not held as a merge-blocker while the PR remains mergeable without conflict.
  2. Full-stack E2E payer pathe2e-stack covers merchant /invoice only; payer mode is unit+visual covered. CONTRIBUTING uses "should" here. Follow-up: #1364
  3. Reality declaration for installRecipientRoute (e2e/invoice-screen.spec.ts) — develop CONTRIBUTING requires a declaration when introducing a fake. Uncertainty (left to human, not blocked as precaution): the declaration taxonomy/format lives in DFXswiss/api and is not fully specified in this repo; develop already contains many e2e/** page.route mocks without per-entry declarations; visual e2e does not run in CI and the handbook already notes a stubbed API. Tracked as: #1365
  4. Handbook baseline for the payee loading spinner — transient spinner during recipient validation is not treated as a separate product "mode" variant (merchant / payer / prefilled / verified already have baselines). Not a merge-blocker; not filed as an issue.

Logic (summary)

Payer mode derived from the query each render; first-paint payee fallback; query retained in payer mode / cleared in merchant mode; payment navigation uses object-form navigate with clearParams + replaceParams allowlist; merchant QR/copy hidden in payer mode; loading skeleton avoids a scannable placeholder. No logic defects found.

What looks good

  • Coverage suite for the previously under-covered navigation.hook and real qr-code component
  • Handbook baselines for all four intentional screen states + metadata entry
  • de/fr/it keys for the new strings
  • Thorough mutation-style evidence retained in the PR description

Local run evidence (Gate B)

node v20.19.4
npm ci          # OK under Node 20 (npm 10); Node 24 host rejected lockfile
npm run lint    # exit 0
CI=true npm test -- --watchAll=false   # 77 passed / 837 tests
BROWSER=none PORT=43174 REACT_APP_API_URL=https://dev.api.dfx.swiss npm start
  → webpack compiled, "No issues found", HTTP 200 on :43174

Recommendation

Approve. Please still rebase onto current develop before merge when convenient (translations + handbook metadata overlap). No merge from this account.

@TaprootFreak TaprootFreak left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EN: Changes required: the full-stack E2E suite still only covers merchant /invoice and does not open the new ?pay payer flow.
DE: Änderung zwingend erforderlich: Die Full-Stack-E2E-Suite deckt weiterhin nur Händler-/invoice ab und öffnet den neuen ?pay-Zahlerfluss nicht.

Details

Visual E2E and handbook look complete (spec + four baselines + scripts/handbook/metadata.json key invoice-screen).

The remaining gap is the full-stack layer.

E2E (e2e-stack) — not met

CONTRIBUTING.md § Full-stack E2E: a PR that changes a screen must bring or update the matching full-stack test. /invoice is claimed by e2e-stack/specs/registry/payment-links.tspayment-links.spec.ts.

That spec still only opens /invoice in merchant mode (Create Invoice, editable recipient). It never navigates to /invoice?pay=1 and never asserts payer copy, display-only payee, or the verified/unknown recipient states this PR introduces.

Required: extend e2e-stack/specs/payment-links.spec.ts (or add a dedicated spec and keep a single registry claim) so a test actually opens the payer flow and checks the new wording/states.

Unit 100% on invoice.screen.tsx, qr-code.tsx and navigation.hook.ts is not proven by a coverage run (CI has no gate). The PR does add tests for the new branches; if any of those three files is not yet at 100% on all four metrics, that is a second blocker — state the per-file numbers in the description.

joshuakrueger-dfx added a commit to joshuakrueger-dfx/services that referenced this pull request Aug 13, 2026
Review on DFXswiss#1274 blocked on CONTRIBUTING: every instrumented file the
branch touches must hit 100% on statements, branches, functions and
lines. invoice.screen.tsx already did; navigation.hook.ts and qr-code.tsx
did not. Cover the remaining hook surface (history navigate, goBack,
setParams, clearParams, setRedirect, string merge) and the real QrBasic /
QrCopy paths, including the loading skeleton that must not encode a
scannable URL.
@joshuakrueger-dfx
joshuakrueger-dfx force-pushed the feat/invoice-payer-wording branch from bba6b9d to 888ca5b Compare August 13, 2026 08:49

@marassteiner marassteiner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EN:
Quality gate rejected: inserting the invoice-screen reality declaration split a pair of collection-invoice bullets, so "the same two specs" now points at the wrong tests.

DE:
Qualitäts-Gate abgelehnt: Die Reality-Declaration zum Invoice-Screen steht zwischen zwei zusammengehörigen Collection-Invoice-Bullets, sodass „the same two specs“ auf die falschen Tests zeigt.

Details

Head 30d47b89 against develop (merge-base 0bba3597). GitHub: MERGEABLE. CI on this head: 7/7 success.

Quality

One defect on this head.

On develop, Two specs force KYC completeness is immediately followed by The same two specs fabricate the invoice rejection. This pull request inserts the new invoice-screen recipient-lookup bullet between them. After that insertion, "the same two specs" reads as the invoice-screen specs, which only stub GET /v1/paymentLink/recipient — not **/v1/buy/paymentInfos/*/invoice* / CollectionAccountInvoicePersonalIbanMissing.

CONTRIBUTING requires each reality-declaration entry to say in one plain sentence what a green run does not prove. The collection-invoice rejection entry is no longer that sentence.

Fix: restore a unique antecedent, for example Both collection-invoice specs also fabricate the invoice rejection, or name the spec files. File: docs/test-architecture.md:120.

The new invoice-screen declaration itself is otherwise complete (names the stub, the two answers, and which full-stack tests hit the real lookup).

Logic

No defects. Payer mode is an allowlist (1/true/yes). Query is kept in payer mode and cleared in merchant mode. Continue-to-payment uses object navigate plus replaceParams: true and only the validated payment search — lightning / recipient / pay from the invoice URL do not inherit into /pl. Printed payee is display-only. First-paint wording is derived from the query on every render.

Coverage / handbook (not re-measured in this pass; no local test run)

Touched instrumented files: invoice.screen.tsx, navigation.hook.ts, qr-code.tsx. Description claims 100/100/100/100; those three files did not change after the earlier local measurement on 554e583. Handbook: four committed baselines plus scripts/handbook/metadata.json invoice-screen.

Written exceptions — still in force, not re-opened

  • SDK rule: hand-built GET /v1/paymentLink/payment in invoice.screen.tsx (declared; SDK side still unpublished).
  • Pre-existing defects deferred as issues #1345#1349.

Codex gates were not started: quality on this head is not approved.

No merge from this account.

Comment thread docs/test-architecture.md Outdated
the unknown-payee error for those two answers — not that the API resolves a printed recipient. The
real lookup runs in `e2e-stack/specs/payment-links.spec.ts` — in the recipient test and in the two
payer tests that carry a printed recipient; the payer test with an editable payee triggers none.
- **The same two specs fabricate the invoice rejection.** Each answers

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EN:
This bullet used to mean the two collection-invoice specs; the new invoice-screen entry above it now reads as the antecedent.

DE:
Dieser Bullet meinte die beiden Collection-Invoice-Specs; der neue Invoice-Screen-Eintrag darüber liest sich jetzt als Antezedens.

Details

On develop this line sat directly under Two specs force KYC completeness. Restore a unique antecedent (name the collection-invoice specs, or say Both collection-invoice specs also…). Invoice-screen only stubs GET /v1/paymentLink/recipient.

Copy link
Copy Markdown
Contributor Author

@marassteiner please re-review

Your sole finding is fixed on head 7f1fb5ff: docs/test-architecture.md now says Both collection-invoice specs also fabricate the invoice rejection, restoring the unique antecedent. Current develop (50f398de) is integrated by a signed merge commit without rewriting the published branch history.

All 7/7 reported checks on this exact head are green. The Full-stack E2E check remains the documented mode=none on this fork, not a live stack run; the three payer cases last ran on f472ef15 (232 passed).

@marassteiner

Copy link
Copy Markdown
Collaborator

EN: Working on this now — job dfxswiss__app__1274__pr-review__r2.
DE: Ich arbeite jetzt daran — Auftrag dfxswiss__app__1274__pr-review__r2.

@marassteiner

Copy link
Copy Markdown
Collaborator

codex-pr / logic (codex) rejected at 7f1fb5ffa6ea417ec7b3a77301b198b41ca27819

Codex vendor cannot run on this instance. Not a code defect on 7f1fb5f. agent run --spec-file launched the official codex lane: STATUS=unavailable rc=1. Direct codex exec failed with HTTP 401 Unauthorized (Missing bearer or basic authentication; codex login status: Not logged in; no CODEX_HOME/auth.json). Grok quality and grok logic are approved on this head. Codex stages were not substituted. Previous unique-antecedent finding in docs/test-architecture.md is fixed on this head; the four-lane contract is incomplete until Codex can run.

@marassteiner

Copy link
Copy Markdown
Collaborator

codex-pr / quality (codex) rejected at 7f1fb5ffa6ea417ec7b3a77301b198b41ca27819

Codex vendor cannot run on this instance. Not a code defect on 7f1fb5f. agent run --spec-file launched the official codex lane: STATUS=unavailable rc=1. Direct codex exec failed with HTTP 401 Unauthorized (Missing bearer or basic authentication; codex login status: Not logged in; no CODEX_HOME/auth.json). Grok quality and grok logic are approved on this head. Codex stages were not substituted. Previous unique-antecedent finding in docs/test-architecture.md is fixed on this head; the four-lane contract is incomplete until Codex can run.

…h ?pay

The /invoice screen serves two roles: a merchant creating a link to send, and a
payer who scanned a QR printed on an invoice. It was labelled for the merchant
only - "Create Invoice", "Invoice ID", "Amount", "Open invoice" - which reads
wrong for someone who just wants to pay a bill.

With a "pay" query parameter the screen now reads "Pay invoice", "Invoice
number", "Invoice amount" and "Continue to payment", plus a hint to copy both
values from the printed invoice. Without the parameter every existing string
stays untouched, so the merchant flow is unaffected.

The existing "recipient" parameter cannot serve as the trigger: it is documented
as a convenience for the merchant to pre-fill the form, so it does not identify
the payer.
Renders the screen through a real MemoryRouter and asserts which wording each
value of the query parameter produces: pay=1 shows the payer copy and the hint,
no parameter keeps the merchant copy, and pay=0 and pay=false are treated as off.

Two mutations were run against the condition to confirm the test discriminates:
inverting it fails all four cases, weakening it to a plain null check fails
exactly the two falsy-string cases.
…preserve it

The screen clears the query string on mount, which is fine for the merchant
tool but breaks the payer: after a reload, a browser tab restore or a back
navigation from the payment page, the pay parameter is gone and the screen
falls back to the merchant wording, while autofill restores the field values -
so it looks like nothing was lost except the labels.

The parameters are now only cleared in merchant mode, leaving that flow
byte-identical, while a payer keeps both recipient and pay in the URL.

Two cases cover it: payer mode keeps the query string, merchant mode still
clears it. Swapping the condition fails exactly those two.
Seven findings from the review round, each the smallest change that removes
the finding.

- Derive the payer mode from the query on every render instead of holding it
  in state and setting it from a passive effect, so the first committed frame
  already shows payer wording (precedent: mail-login.screen.tsx).
- Read `pay` as an allowlist (1/true/yes, trimmed, case-insensitive) instead
  of a blocklist that also accepted ?pay=no, ?pay=off and the empty ?pay=.
- Stop carrying the retained payer query into the payment request: the
  continue button now navigates with the object form and clears recipient and
  pay, so only routeId, amount, message and expiryDate reach the API.
- Hide the QR code and the copy-link button in payer mode; they are merchant
  tools and sat above the form as a loading placeholder.
- Give the recipient field payer wording (Payee, no John Doe placeholder) and
  lock it when the value came from the printed QR, so a payer cannot silently
  redirect the payment. It stays editable without a recipient parameter, so
  ?pay=1 alone does not produce an empty locked field.
- Put the German hint into the form of address the rest of the file uses.
- Make the reload/back test do what its title claims: remount with the URL the
  router holds, and navigate away and back again.

Coverage on the screen goes from 78.57/69.38/56.25/76.92 to 100 in all four
columns. A second test drives the real useNavigation hook and asserts the
location that is actually reached, because the screen test mocks the hook and
can only observe the intent.
The screen did not appear in the handbook at all: payment-and-special.spec.ts
opens /invoice but takes no screenshot, and none of the 35 metadata entries
described it.

A dedicated spec gives the group its own key (the handbook derives it from the
file name), so the baselines are findable as the invoice screen instead of
sitting between payment link, POS and result.

Three states, because the wording switch alone does not show what this pull
request changes: merchant with empty fields, payer with an empty payee field,
and payer with the payee prefilled and locked from the printed QR. The third
one carries the unknown-recipient error on purpose — it is what support sees
when a merchant prints a wrong route name, and it is the only image that shows
the locked field.

Still uncovered, and named rather than passed off as a selection: merchant and
payer with a recipient the API accepts, a payment that validates, the payment
error hint, and the German, French and Italian variants of all of it. Those
need a payment route the dev API knows.

All three images decode to no barcode and carry no token, key or session URL —
only the placeholder name, the interface copy and the error text.
Findings from the pre-review gate on the previous commit.

The continue button now goes through the object form of navigate in both
modes, not only in payer mode. That the merchant target stays the same was an
argument — the query is already cleared on mount, so nothing is left to merge —
and no test covered it: the only click test ran with ?pay=1. Coverage sat at
100 %, which proves the line executes, not that it is right in the other mode.
The merchant click is now measured: same path, exactly routeId, amount,
message and expiryDate, same navigate options. The hook test gained the
matching case with an empty starting query.

expiryDate was asserted with toBeTruthy(), which would have passed on any
string. It is now pinned to an ISO timestamp roughly a year ahead; changing
addYears(..., 1) to 2 turns two tests red.

The payer-mode derivation no longer needs a default value — a missing pay
parameter reads as null and falls to merchant mode without a ?? in between.
The previous round fixed the wrong half of the problem. Locking the prefilled
recipient stopped a payer from redirecting the payment, but StyledInput passes
disabled straight through to the input element, and a disabled input is not
focusable and gets skipped by screen readers. That is the one field on this
screen a payer has to be able to check before paying: who the money goes to.
Unmanipulable but unreadable is a trade, not a fix.

The real question was whether a form field belongs there at all. When the
recipient comes from a printed QR code it is information, not input, so the
screen now renders it as text with its label. Nothing to lock, nothing to hide
from assistive technology. The Controller stays registered, so the value keeps
flowing into validation and the button still enables.

The confirmation tick moves with it: it used to be positioned against the
height of the input and now sits inline next to the value, carrying an
accessible name rather than being decoration - it tells the payer that DFX
knows this recipient.

The merchant flow and payer mode without a recipient parameter keep the input
field unchanged. The prefilled baseline shows the unknown-recipient case, where
no tick appears, so it needed no regeneration - verified by a run without
--update-snapshots.
Keeping the query in payer mode - needed so a reload does not lose the mode -
made every other parameter of the opened URL travel to the payment page. The
object form of navigate builds its target from the current location.search and
only removes what clearParams names, so listing recipient and pay covered the
two parameters anyone thought of and nothing else. The payment page reads
lightning before it looks at the invoice at all, and resolves it to whatever
server the parameter points at; merchant lands even earlier. Neither is in the
global strip list.

A printed code carrying the real merchant plus a foreign lightning parameter
therefore showed the payer the genuine payee with its confirmation tick, took
the invoice number and amount, and then fetched the payment from somewhere
else. Before this branch it could not happen: the query was cleared
unconditionally and the string form ran on an empty search.

clearParams cannot fix this. It is a blocklist, and it runs after the values
are set, so filling it with the current keys would delete the real amount along
with the attacker's. The target now builds its query from the parameters it is
given, and inherits nothing - an allowlist, the same shape the review asked for
one level down where pay was parsed as a blocklist.

The option is additive and off by default, so every other caller keeps merging
exactly as before. The hook test now starts from a query carrying lightning,
merchant and a second routeId and asserts the exact key set that arrives.
…than it was

The component mock never passed rules through, while the real Form injects
rules[name]. No unit test had a validation rule registered, so isValid was
constant true: removing Required from the recipient, or dropping !isValid from
the button, left all tests green. The question this branch raises - does the
required rule survive when the field becomes text - was the one thing the suite
could not see. The mock now mirrors the real Form, and both mutations turn it
red.

The e2e assertion for "no payee input" queried a role that StyledInput never
exposes: it renders the label without htmlFor and the input without id, and in
payer mode the placeholder is undefined, so there is no accessible name to
match. It returned zero against the previous revision too. It now asserts on
the element itself, which the component gives a name attribute from its
autocomplete value.

The merchant baseline carried a scannable QR code of the loading placeholder -
the public URL of whatever environment generated it. zbarimg missed it because
the placeholder is drawn at near-zero contrast, so the check that was supposed
to clear the image could not see what a phone camera reads. The placeholder is
now a plain skeleton, and the same scan that found the code confirms all four
baselines are clear.

The verified recipient finally has a baseline: stubbing the endpoint the way
support-issue-receiver-iban.spec.ts already does in this repository shows the
tick and its translations, and takes the dev API out of the third test as well.
The payee display also shows a spinner while the check runs, which the replaced
input branch did and its successor had dropped.
The hook test file is the only one in this repository that runs the real
useNavigation - the other sixteen mock it. Moving all three of its tests onto
the new option left clearParams covered by nothing at all: turning its delete
loop into a no-op kept thirty tests across three suites green, where the same
mutation against the previous revision of this file turned one of two red. A
fourth test now drives the probe without the option and asserts that the two
named parameters are the ones missing from the target.

Two of the tests added with the fix claimed more than they checked. The one
titled after the hijacked query lived in the file that mocks useNavigation, so
its target search never carried the location query and its assertions held
without the fix too - only the shape of the mock call differed, which the
neighbouring tests already assert. It is gone; the hook test covers that case
for real. The other named the display branch in its title but ran its
assertions against a second render where that branch is inactive, and deleting
the setup left everything green. It now says what it tests.

The option also did nothing in the string form of navigate, which builds its
target through relativeUrl and never reaches addParams - so a future caller
would have kept merging without noticing. It applies in both forms now.
clearParams in the string form is deliberately untouched: it is a pre-existing
no-op, and fixing it would change what NavigationLink does, which belongs in
its own branch.
The value arrives through setValue in the mount effect, which runs after the
first render, so the payer briefly saw the label above an empty line while the
name sat in the query the whole time. The mode was already derived directly
from the query after the review asked for it; the displayed value was not, so
that fix only went half way.

The rendered value now falls back to the query parameter, which is what the
state carries a moment later anyway - the display branch only exists when that
parameter is set. The form state is untouched: the Controller stays registered
and the value still comes from setValue.

The test proves the first frame rather than the settled one: React Testing
Library flushes passive effects inside render(), so it can never see it. It
mounts through createRoot inside flushSync instead, which commits the DOM and
layout effects but not the effect that sets the value.
… fails

The container and its React root were only removed on the success path, so a
failing assertion left the mounted screen in document.body and every later test
in the file found its elements twice. Mutating the fallback it guards turned
nineteen of twenty-nine tests red, eighteen of them for that reason alone -
which makes the counter-check useless as evidence, because it looks like a
crash instead of the one failure it is. Cleanup now runs regardless, and the
same mutation turns exactly one test red.
Review on DFXswiss#1274 blocked on CONTRIBUTING: every instrumented file the
branch touches must hit 100% on statements, branches, functions and
lines. invoice.screen.tsx already did; navigation.hook.ts and qr-code.tsx
did not. Cover the remaining hook surface (history navigate, goBack,
setParams, clearParams, setRedirect, string merge) and the real QrBasic /
QrCopy paths, including the loading skeleton that must not encode a
scannable URL.
The merchant /invoice test never opened ?pay=1. Open the three payer
states this change introduces — wording, verified display-only payee,
and unknown recipient — against the real API.
… twice

Display-only payee puts the name in the group and in the error text, so
a page-wide getByText is a strict-mode collision. Scope the assertion
to the error paragraph.
… themselves

CONTRIBUTING requires the reality declaration to change in the pull request
that introduces a fake. The four invoice baselines fulfil
GET /v1/paymentLink/recipient with a static 200 or 404; the declaration said
nothing about it, so a green visual run read as proof of the lookup.
The entry said three payer tests; the one with an editable payee never
submits a recipient and triggers no lookup.
…e state

Editing an already validated amount left the continue button active with the
previous callback for the length of the debounce, so a payer could send an
amount the screen no longer showed. The live form values now clear the callback,
and validatePayment ignores the answer of a request that has been superseded.

The displayed payee carried its error state in the text colour alone. It now
sets aria-invalid and points at the explanation through aria-describedby, and
its label id comes from useId so two mounted instances cannot collide.
The payer mode reaches a fifth visual state that no baseline showed: no printed
recipient, the payee typed and confirmed, the invoice fields unlocked with the
currency prefix. The spec now takes that screenshot and the handbook entry names
it alongside the other four.
The loading skeleton was pinned only for non-SVG data, where the image branch
can never run; the SVG case is now covered as well. The goBack test asserted the
path and stayed silent about the query string it carries along.
@joshuakrueger-dfx
joshuakrueger-dfx marked this pull request as draft September 2, 2026 15:53
@joshuakrueger-dfx
joshuakrueger-dfx force-pushed the feat/invoice-payer-wording branch from 7f1fb5f to 1ba7fe3 Compare September 2, 2026 15:53
@joshuakrueger-dfx joshuakrueger-dfx changed the title Label the invoice screen for the payer when opened with ?pay c638b50e - Label the invoice screen for the payer when opened with ?pay Sep 2, 2026
Clearing the callback on the live form values left two gaps. An answer that
arrived inside the debounce window wrote the superseded callback back, because
nothing invalidated the request that was already in flight; and the recipient
was not part of the check at all, so in the typed-payee branch a payer could
continue with a route they had already replaced. The callback is now only set
when the values the request was sent with still match the form, and the
recipient joins the invoice number and the amount in the clearing effect.

The payee error is announced when it appears instead of only being reachable
through the description, and the generated label id no longer starts with a
colon, so it can be used as a selector.
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.

3 participants