c638b50e - Label the invoice screen for the payer when opened with ?pay - #1274
c638b50e - Label the invoice screen for the payer when opened with ?pay#1274joshuakrueger-dfx wants to merge 26 commits into
Conversation
Review — full runTwo independent review lanes (conformity + logic), every finding re-verified against the source. Measured locally on Bugs introduced by this PR1. The first painted frame shows merchant wording in payer mode. 2. The retained query string is carried into the payment request. 3. The wording switch is incomplete. 4. The new German string breaks the file's form of address. 5. 6. The test named "survives reload / back" tests neither. Pre-existing bugs in the files this PR touchesThese 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. 8. The pay-request call bypasses the SDK. Hard requirements for this PR
Checked and dismissedThe 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 |
854d59c to
0076733
Compare
0076733 to
eb43000
Compare
|
@marassteiner please review This is a PR, not an issue: 14 files against |
|
@marassteiner please review This is a PR, not an issue: 14 files against |
|
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. |
|
EN: Working on this now — job |
marassteiner
left a comment
There was a problem hiding this comment.
Review — request changes
Verdict: Request changes (one merge-blocker). No approval until coverage on every touched instrumented file meets CONTRIBUTING.
Local run: LOCAL_RUN_OK — npm install (after npm ci lock/host mismatch unrelated to this diff), npm run lint exit 0, CI=true npm test -- --watchAll=false → 76 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:
-
SDK deviation (finding 8 / declared deviation) — exception granted.
Hand-builtGET /v1/paymentLink/paymentviafetchJsonininvoice.screen.tsx:136may remain for this PR. Reason accepted:@dfx.swiss/reacthas 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. -
Pre-existing defects reported in the PR body — deferral granted (do not block this PR). Tracked as:
# Topic Issue 1 validateRecipientrace / superseded responses#1345 2 fetchJsondrops HTTP status on payment GET#1346 3 string-form clearParamsno-op (NavigationLink)#1347 4 isSafeRedirectUriaccepts anyhttpshost#1348 5 Transrecipient markup withescapeValue: 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.jsonentry 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.
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.
|
@marassteiner please re-review Coverage merge-blocker from your previous review is addressed on head
Commit: |
|
EN: Working on this now — job |
marassteiner
left a comment
There was a problem hiding this comment.
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:
- Rebase hygiene (~18 commits behind; overlapping
de/fr/it.json+ handbook metadata) — GitHub reportsmergeable: MERGEABLE. Recommend rebasing onto currentdevelopbefore merge for a clean translation/metadata integration, but this is not held as a merge-blocker while the PR remains mergeable without conflict. - Full-stack E2E payer path —
e2e-stackcovers merchant/invoiceonly; payer mode is unit+visual covered. CONTRIBUTING uses "should" here. Follow-up: #1364 - Reality declaration for
installRecipientRoute(e2e/invoice-screen.spec.ts) —developCONTRIBUTING 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;developalready contains manye2e/**page.routemocks without per-entry declarations; visual e2e does not run in CI and the handbook already notes a stubbed API. Tracked as: #1365 - 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.hookand realqr-codecomponent - 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
left a comment
There was a problem hiding this comment.
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.ts → payment-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.
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.
bba6b9d to
888ca5b
Compare
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.
7f1fb5f to
1ba7fe3
Compare
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.
…onfig The repository has no Prettier check in CI for TypeScript, so the four files this branch adds had drifted from `.prettierrc`. `navigation.hook.ts` is left alone: it does not match the config on develop either, and reformatting it would touch lines this branch never wrote.
A response that belongs to values the payer has since changed no longer writes anything: the comparison that used to guard only the success branch now runs ahead of the error branch and the catch as well, and the effect that clears the callback clears a rendered payment error with it. The confirmation tick in the editable branch gets the accessible name the display branch already had, so the end-to-end test can ask for the role instead of a layout class. The tests follow the same three dimensions the code does: the invoice number had no case of its own, the loading state was invisible behind a mock, and the seven new translation keys had no check that all three languages carry them.
|
EN: DE: DetailsWhy the checks are skippingThe pull request is a draft, so PR CI, CodeQL, the review bot and the handbook job all record Separately, and not a label question: the full-stack stage takes What changed since the last review
Two further defects are newly reported, not fixed — the confirmation tick proves the route Measured on
|
|
@marassteiner please re-review — head EN: DE: DetailsSince
|
|
EN: Working on this now — job |
|
Codex vendor cannot run on this instance — not a code defect.
Per the review contract: if a vendor cannot run, abort; do not record approved; do not substitute another vendor. Grok quality on this head Written exceptions granted on this review:
Task stays in pr-review, not done. CI on this head is SKIPPED (draft from a fork, no |
|
Codex vendor cannot run on this instance — not a code defect.
Per the review contract: if a vendor cannot run, abort; do not record approved; do not substitute another vendor. Grok logic on this head Written exceptions granted on this review:
Task stays in pr-review, not done. CI on this head is SKIPPED. Do not mark ready. Re-run when Codex is authenticated. |
The screen test mocks `useNavigation` and can therefore only observe the intent; the hook test drives the real hook without the screen. Their composition — the one that carries the security claim of this branch — was covered by neither, and no end-to-end test presses "Continue to payment" either. This one starts on a printed payer URL that also carries `lightning` and `merchant`, clicks through to the payment page and asserts the exact key set that arrives there.
`StyledInput` renders its visible label without `htmlFor` and the input without an id, so the only accessible name a field has comes from its placeholder. Payer mode deliberately drops the placeholder, which left a screen reader with an unnamed textbox on the one screen written for that user. The field now carries its label as an accessible name. The test mock used to invent the label association the real component does not make, which hid this and let the surrounding tests find fields by a name that only existed in the mock. It now mirrors the component, and the locators follow the same mechanism the application uses. Also closes the recipient arm of the stale-answer guarantee: removing the recipient from the comparison used to leave every test green.
|
@marassteiner please re-review — head EN: DE: DetailsWhat the second family found, and what came of it
Three of its other findings were already known and reported in the description ( Thank you for granting the two new exceptions on Measured on
|
|
Job dfxswiss__app__1274__pr-review__r4: Der Lauf ist mit Fehlercode 1 beendet worden; es wurde nichts veröffentlicht. |
EN:
A
payquery parameter switches/invoiceto 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 foreignlightningvalue into the payment page. The branch is rebased onto currentdevelop(b3052bd) instead of carrying a merge commit, and the seventeen reviewed commits are unchanged in content. The commits after them answer review passes over the finished branch, my own and one from a second model family: a payment answer that no longer matches the form cannot reach the payer, the payee field carries an accessible name and announces its error state, a fifth handbook baseline covers the typed and verified payee, and a new test drives the screen through the real navigation hook to prove no foreign parameter reaches the payment page. No CI check has run on this head — this is a draft from a fork, and the full-stack stage cannot run on a fork at all; both are open items below.DE:
Ein
pay-Query-Parameter stellt/invoiceauf 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 fremdenlightning-Wert in die Zahlungsseite tragen konnte. Der Branch ist auf das aktuelledevelop(b3052bd) rebased statt über einen Merge-Commit integriert, die siebzehn reviewten Commits sind inhaltlich unverändert. Die Commits danach beantworten Review-Pässe über den fertigen Branch, eigene und einen aus einer zweiten Modellfamilie: Eine Zahlungsantwort, die nicht mehr zum Formular passt, erreicht den Zahler nicht, das Empfängerfeld trägt einen zugänglichen Namen und meldet seinen Fehlerzustand, eine fünfte Handbuch-Baseline deckt den getippten, bestätigten Empfänger ab, und ein neuer Test führt den Screen durch den echten Navigations-Hook und belegt, dass kein fremder Parameter die Zahlungsseite erreicht. Auf diesem Head ist kein CI-Check gelaufen — es ist ein Draft aus einem Fork, und die Full-Stack-Stufe kann auf einem Fork grundsätzlich nicht laufen; beides steht unten als offener Punkt.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
recipientparameter as the payer signal, which would add no new parameter at all — insufficient because the documentation definesrecipientas 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
skipping.ciorci:fulllabel, and applying a label needs write access — this account has read access only (AddLabelsToLabelableis refused). Marking it ready would not help either:ci-on-readycannot dispatch a fork head.cie2e-stack.ymlprobesE2E_API_CHECKOUT_KEYbefore it looks at the label, and a fork pull request never receives repository secrets, so it takesmode=noneeven withci:full. It needs a branch inside this repository or a manualworkflow_dispatch.feat/payment-link-pay-contract).@dfx.swiss/reactwith that method.refactor: use the SDK invoice payment call.Two issues can close when this lands: #1364 (payer flow in the full-stack harness) and #1365
(reality declaration for the stubbed recipient lookup). Both are on this branch.
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.tsxstillbuilds the URL for
GET /v1/paymentLink/paymentby hand and fires it withfetchJson.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/reacthas no method for this endpoint;the SDK side is written and open as DFXswiss/packages#206. Until it lands 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.
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
payswitches 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.One thing changes for the merchant without a string changing, which is why
qr-code.tsxis in thisdiff: the placeholder shown while the link is still being built used to be a real QR code drawn at
near-zero contrast — invisible to
zbarimg, readable by a phone camera. It is now a plainskeleton.
The screen used to clear the query on mount, so a reload, a tab restore or a back navigation
dropped
payand fell back to merchant wording while autofill restored the values — it looked asif 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.tsxresolveslightningthroughLnurl.decodebefore it looks at theinvoice at all. "Continue to payment" therefore navigates with the object form of
navigateandthe new
replaceParams: true, so the target query is an allowlist ofrouteId/route,amount,messageandexpiryDateand inherits nothing. The option is additive and off by default; everyother call site merges exactly as before.
Review history
Round 1 raised eight findings: first painted frame, retained query in the payment request,
incomplete wording switch, German form of address,
payparsed as a blocklist instead of anallowlist, 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.
Round 2 (TaprootFreak, 2026-08-13) asked for the payer flow in the full-stack harness. Three
payer cases are in
e2e-stack/specs/payment-links.spec.tssince then.Round 3 (marassteiner, 2026-08-26) found one defect: inserting the invoice-screen reality
declaration split a pair of collection-invoice bullets in
docs/test-architecture.md, so "the sametwo specs" pointed at the wrong tests. Fixed — the entry now reads Both collection-invoice specs
also fabricate the invoice rejection. Two
codex-prlanes reported on that head that the Codexvendor could not run on that instance, explicitly not a code defect.
After that, my own review passes over the finished branch found what the later commits fix.
No reviewer raised these; they are listed so nobody has to guess where they came from.
debounced form values while the button is gated on live validity, so editing an already
validated amount, invoice number or recipient left the button active with the previous callback
— the payer could send values the screen no longer showed. Changing any of the three now clears
the callback, and an answer may only write once the values it was sent with still match the
form. The guard covers the failure paths too, so an error message about an amount the payer has
since replaced neither appears nor lingers.
aria-invalid, points at the explanation througharia-describedby, and the explanationannounces itself. Its label id comes from
useId(), so two mounted instances (the app is alsomounted as a widget) cannot collide on a fixed DOM id, and the confirmation tick in the editable
branch got the accessible name the display branch already had.
recipient, the payee typed and confirmed, invoice fields unlocked with the currency prefix.
without a case of its own, the seven new translation keys had no check that all three languages
carry them, and the files this branch adds had drifted from
.prettierrc— CI runs no Prettiercheck for TypeScript.
only observe the intent; the hook test runs without the screen. A new test starts on a printed
payer URL that also carries
lightningandmerchant, clicks "Continue to payment" and assertsthe exact key set that arrives at
/pl.StyledInputrenders its visible labelwithout
htmlForand the input without an id, so a field is named by its placeholder — andpayer mode deliberately drops that placeholder. A screen reader met an unnamed textbox on the
one screen written for that user. The field now carries its label as an accessible name, and
the test mock no longer invents the label association the real component does not make.
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; the last two are new and are the fourth open item above.
validateRecipienthas no guard against superseded responses. It fires per debouncedkeystroke with no request token. If an earlier answer arrives last, the tick and the currency
belong to a name the user has already replaced, while the payment call stays blocked because it
requires the names to match — a form that stalls with no error. invoice.screen: guard validateRecipient against superseded responses #1345.
fetchJsondiscards the HTTP status. The only failure criterion is anerrorfield, so a4xx body without one takes the success path.
payment-link.context.tsxchecksstatusCodeforthe same endpoint — the two consumers disagree. invoice.screen / fetchJson: HTTP status discarded on paymentLink/payment GET #1346.
clearParamsis silently ignored in the string form ofnavigate.NavigationLinkpassesclearParams: ['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 (
PaymentLinkProvideris mounted onlyunder
/pl), so this is exposure in the URL and in browser history, not a proven takeover.isSafeRedirectUriaccepts any https target with no host allowlist, and the parameter isstripped from the address bar afterwards, so the payer sees a clean URL. isSafeRedirectUri: any https target accepted (no host allowlist) #1348.
<strong>,<br>,<i>and<p>become real elements. Not XSS — unknown tags are escaped and injectedattributes 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.
validateRecipientuses only the currency from the answer; the payee shown is the string from the URL. With a
numeric value the screen sends
routeIdand displays "Payee: 42 ✓" — a payer cannot tell whomthey are paying, and the tick carries an accessible name that says "verified". Fixing it needs a
display name from the API, so it is not a change this repository can make alone.
the mount clear removed every unknown parameter unconditionally.
replaceParams: trueprotectsthe continue button only; see point 3.
offline browser, a 401 or a 500 produce the same business-level message as a genuine 404 and
disable the flow, which blames a valid printed recipient and gives the payer no accurate retry
signal. Pre-existing, and found by the second model family.
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
zbarimgand 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: six 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.
collectCoverageFromissrc/**/*.{ts,tsx,js,jsx}minussrc/**/*.d.ts; the runner excludes test files, and translationJSON, the e2e specs and the baselines carry no coverage. Measured on this head with the full suite:
src/screens/invoice.screen.tsxsrc/hooks/navigation.hook.tssrc/components/payment/qr-code.tsxOn
develop(b3052bd) all three sit at 0 / 0 / 0 / 0 — measured there with the full suite,not inferred: no test on develop executes any of them.
Not verified
paper and scanned with a real camera.
a route with that label ever exists there, that image changes.
.finallyof a superseded request leaves the loading state alone while a newer requestis still in flight: the mocks in this suite cannot hold two requests open at once, so that one
line is covered by no test. Everything else the later commits added is pinned by a test.
src/__tests__/realunit.screen.test.tsx, number formatting) failslocally 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 samewording so the two do not drift apart.