Skip to content

ENG-703: offer the card charge when the invoice API is unreachable - #92

Merged
islandbitcoin merged 3 commits into
mainfrom
jabariennis/eng-703
Oct 14, 2026
Merged

islandbitcoin merged 3 commits into
mainfrom
jabariennis/eng-703

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Problem

Offline, the keypad sent every amount above a cent to createInvoice. That request fails offline, and the catch block always showed "Unable to create invoice". It never offered the Flashcard charge, even though the charge needs no API. On the Pixel repro, 7 and 8 sats reached the card (they are under a cent and skip the invoice), while 65 sats hit the invoice path and failed.

Change

  • src/utils/apolloErrors.ts (new): isApiUnreachable(err) is true when the request got no server answer (fetch threw) or the server failed (5xx), with no GraphQL errors. A 4xx (for example an expired session) and any GraphQL error are real answers and keep the existing toast.
  • src/screens/Keypad.tsx, onCreateInvoice:
    • In the catch, an unreachable API shows the info toast "Can't reach Flash: card payments only" (5 s) and navigates to CashuCardCharge. satAmount is already in the store, so the charge reads the right amount.
    • The invoice mutation is sent with NO_RETRY_CONTEXT, so the card is offered after one request rather than after 5 RetryLink attempts (5-9 s offline).
    • SAT entry with no price at all already reached the card silently. It now shows the same toast.
    • Fiat entry with no local-currency price (the conversion is NaN) now shows "Bitcoin price is still loading. Please try again in a moment." instead of "Please enter a valid amount.", and never stores NaN sats.
  • src/graphql/retryPolicy.ts (new) + src/graphql/ApolloClient.ts: RetryLink gets retryIf: shouldRetryOperation. An operation whose context sets noRetry goes out once. Every other operation still retries up to 5 attempts.
  • src/utils/amounts.ts: the price-loading copy is exported as PRICE_LOADING_MESSAGE and reused (same text).

These stay as they were: the validation refusals (too small, too large, no price), the GraphQL business-error toast, online success going to Invoice, and the card's own refusals in CashuCardCharge (over balance, CHANGE_NEEDS_MINT). The 65-sat repro with 61 sats on the card will now reach the charge screen and be refused there for insufficient balance. That outcome is correct.

The offline signal is the failed request itself (Apollo networkError). NetInfo is not a dependency, adding it would need a native rebuild, and its reachability flag lags right after an airplane-mode toggle.

How it was tested

New tests, plus their results on 718c700 (the code before this change):

Test On 718c700
__tests__/utils/apolloErrors.test.ts: all 6 cases fail (module missing)
__tests__/graphql/retryPolicy.test.ts: no-retry sends once; others retry 5; the app client sends a no-retry mutation once offline fail (module missing). The app-client test fails with 5 fetches when the ApolloClient.ts wiring is reverted
Keypad: SAT 65 above a cent, offline → card + toast fail
Keypad: USD $0.65, offline → card, setSatAmount('1300') fail
Keypad: 5xx → card fail
Keypad: online → Invoice, mutation called once with context: {noRetry: true} fail
Keypad: SAT with no price → card + toast fail
Keypad: JMD with NaN conversion → price-loading toast, no NaN stored fail
Keypad: 4xx keeps "Unable to create invoice", no card pass (regression guard). Fails when the 4xx status check in isApiUnreachable is removed (checked locally)
Keypad: GraphQL business error keeps its toast, no card pass (regression guard)
Keypad: too large refuses before any request pass (regression guard)
Keypad: SAT below a cent with a price → card, no toast pass (regression guard)
Keypad: USD with no BTC price → price-loading toast pass (regression guard)

The regression guards assert behaviour that must not change, so they pass on the old code by design.

Commands, all green:

  • yarn test --runInBand: 73 suites passed, 1 skipped; 1559 tests passed, 9 skipped (baseline 1539 passed, so +20)
  • yarn typecheck
  • yarn lint: 0 errors. The 1 warning (no-shadow, line 75 of an untouched file) was already on main
  • node_modules/.bin/prettier --check on the new and changed test and util files. The pre-existing formatting drift in Keypad.tsx and ApolloClient.ts is untouched, and the new lines are prettier-clean
  • typos on all changed files

No Maestro flow was added. The e2e job runs a simulator build against the real TEST API, and making the API disappear partway through a flow would need DNS or firewall changes on the CI runner. Suggested on-device check: Pixel in airplane mode, charge an amount above a cent that is under the card balance (for example 30 sats with 61 on the card), and confirm the charge completes.

Follow-ups

  • Price persistence across relaunch (out of scope): useSatPrice / useRealtimePrice keep the price only in memory. After an offline relaunch, fiat entry refuses with the price-loading message, while SAT entry still reaches the card.
  • The NFC "You can only issue one request at a time" toast after backing out of a charge is ENG-704.
  • The info toast uses the library's default renderer, because toastConfig defines only success and error.
  • A 5xx retried by RetryLink could orphan an unpaid invoice (pre-existing). With no retries on this mutation, that can no longer happen here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6

Dread and others added 2 commits October 14, 2026 11:44
NO_RETRY_CONTEXT sends a request once. The shared RetryLink still retries
every other operation up to five attempts.

Refs ENG-703

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
When createInvoice fails with no server answer (offline, DNS, timeout) or a
5xx, show "Can't reach Flash: card payments only" and go to
CashuCardCharge, where satAmount is already stored. A 4xx or GraphQL error
keeps the existing invoice error. The invoice request is sent once so the
card is offered without 5-9 s of retries.

SAT entry with no price now says why the invoice was skipped, and fiat entry
with no local-currency price shows the price-loading message instead of
"Please enter a valid amount."

Refs ENG-703

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
@linear

linear Bot commented Oct 14, 2026

Copy link
Copy Markdown

ENG-703

Review fix for ENG-703. The keypad's cold-start refetchPrice went through
the shared RetryLink with retries on, so an offline SAT entry waited out
5 attempts (up to ~9 s) with no spinner before the card was offered.
The RealtimePrice query now carries NO_RETRY_CONTEXT; refetch reuses the
query options, and the 20 s poll is its own retry.

Adds a hook test against the app client: with fetch rejecting, the mount
query and refetchPrice each send one request and refetchPrice resolves 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
@islandbitcoin
islandbitcoin merged commit fce28c1 into main Oct 14, 2026
5 checks passed
@islandbitcoin
islandbitcoin deleted the jabariennis/eng-703 branch October 14, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant