Skip to content

fix(i18n): localize backend errors, drop locale-unsafe comparisons - #2554

Open
innolope-dev wants to merge 2 commits into
devfrom
fix/locale-safe-errors
Open

fix(i18n): localize backend errors, drop locale-unsafe comparisons#2554
innolope-dev wants to merge 2 commits into
devfrom
fix/locale-safe-errors

Conversation

@innolope-dev

Copy link
Copy Markdown
Collaborator

Why

Follow-up to #2447 (next-intl, en / es-419 / pt-BR). Two gaps remained on the error path:

  1. Backend errors reached non-English users in EnglishfriendlyError passed them through untranslated because it had no stable discriminant to key off.
  2. One branch compared a translated string against an English literal, so it was dead in every locale.

Backend companion: peanutprotocol/peanut-api-ts#1249. This PR does not depend on it — see below.

Wire codes over prose

friendlyError now checks a backend code before any message matcher and maps it through an allow-list.

The allow-list is load-bearing. ethers sets .code to NETWORK_ERROR / CALL_EXCEPTION, and EIP-1193 wallets use numeric codes like 4001. Mapping an arbitrary .code straight to a translation key would turn every one of those into a missing-message error. Anything unlisted falls through to today's matchers — which is also what makes this safe to merge before the API deploys the contract, since pre-contract errors simply carry no code. Both cases have regression tests.

rain.ts was already reading code: 'STALE_CARD_APPROVAL' off the 409 and then throwing it away in favour of the English string. The three typed Rain errors now carry their discriminant, so the Rain paths localize without waiting on the backend at all — the FE constructs those errors itself.

The cooldown needed a type change

errors.rainCooldownRetry takes an ICU plural on retryAfterSec (rounded up, floored at 1 — a 20s wait must not read "0 minutes"), plus a countdown-less variant for when the backend sends no number, mirroring a distinction rain.ts already made.

This required a third FriendlyError variant rather than another member of FriendlyErrorCode: next-intl types t()'s values as the intersection of every union member's ICU args, so a parameterized key inside the param-less union makes {minutes} required at every call site and breaks t(result.code). There's a comment on the type explaining it for whoever adds the next one.

Also fixed

  • ServiceUnavailableErrorfetchWithSentry rethrows it with the real timeout on .cause, which the classifier doesn't walk, so every server fetch timeout was collapsing to the generic support fallback instead of the retryable code. Matched on the name we set ourselves rather than walking .cause generically, which would have silently reclassified every wrapped error in the app.
  • withdraw/[country]/bank compared the mapper's output to 'Something failed. Please try again.' — a literal in no catalog, so dead in every locale including English. Deleting the branch would have regressed: two pre-throw setError calls set already-correct copy (backend-authored, and the confirm-pending notice) that the else would then clobber. Replaced with a local errorAlreadyDisplayed flag.
  • The Rain cooldown pill rendered a hardcoded Card cool-down; src/context/** is outside the jsx-no-literals globs, which is how it shipped.
  • The SDK's transactionHash fetch failure is static English, so it gets a key instead of passing through.
  • regions.utils compares an internal region name — locale-safe today, hoisted to a named constant with a note.

Second commit is revertible on its own

qr-pay and withdraw/manteca re-implemented the classifier's precedence by hand, so every code above would have silently failed to reach them. Routing them through friendlyError is not a straight swap — three behaviours are deliberately preserved: the per-flow SessionKeyGrantRequiredError copy (the two screens intentionally differ), the looser .includes('not allowed') match, and the flow-specific signFailed fallback with its captureException (falling through to genericSupport would have been a copy regression on what is always a signing failure). Split out so it can be reverted if QA on the Rain paths disagrees.

What stays a passthrough

The liquidity text — it does not originate in peanut-api-ts at all (it's SDK/routing-layer), so no backend code can address it. Verified by grep across the API repo.

Verification

pnpm typecheck clean · 174 suites / 2311 tests pass · eslint 0 errors · prettier --check . clean

7 new keys × 3 locales; catalog parity + ICU gates pass. es-419 / pt-BR copy is machine-assisted, same caveat #2447 flagged.

Not covered: no manual multi-locale walkthrough — the "try again in N minutes" rendering is unit-tested but hasn't been seen on a device.

Backend errors were reaching non-English users in English, and one branch
compared a translated string against an English literal.

Wire codes over prose. `friendlyError` now checks a backend `code` before any
message matcher, mapping it through an allow-list to a display code. The
allow-list is load-bearing and must stay one: ethers sets `.code` to
NETWORK_ERROR/CALL_EXCEPTION and EIP-1193 wallets use numeric codes, so mapping
an arbitrary code straight to a translation key would turn every one of them
into a missing-message error. Anything unlisted falls through to today's
matchers, which is also what makes this safe to ship before the API deploys the
contract — pre-contract errors simply carry no code.

rain.ts was already reading `code: 'STALE_CARD_APPROVAL'` off the 409 and then
discarding it in favour of the English string; the three typed Rain errors now
carry their discriminant, and the generic throws become ApiError so a backend
code survives. So the Rain paths localize without waiting on the backend.

The cooldown is now `errors.rainCooldownRetry` with an ICU plural on retryAfterSec
(rounded up, floored at 1 — a 20s wait must not read "0 minutes"), plus a
countdown-less variant for when the backend sends no number, mirroring a
distinction rain.ts already made. This needed a third `FriendlyError` variant
rather than another member of `FriendlyErrorCode`: next-intl types t()'s values
as the INTERSECTION of every union member's ICU args, so a parameterized key in
the param-less union makes `{minutes}` required at every call site.

Also fixed:
- ServiceUnavailableError (fetchWithSentry's rethrow, real cause on `.cause`,
  which the classifier does not walk) was collapsing every server fetch timeout
  to the generic support fallback instead of the retryable code.
- The SDK's transactionHash fetch failure is static English, so it gets a key
  instead of passing through.
- withdraw/[country]/bank compared the mapper's OUTPUT to "Something failed.
  Please try again." — a literal in no catalog, so dead in every locale,
  English included. Replaced with an errorAlreadyDisplayed flag: deleting the
  branch outright would have regressed, because two pre-throw setError calls
  set already-correct copy that the else-branch would then clobber.
- The cooldown pill rendered a hardcoded "Card cool-down"; src/context is
  outside the jsx-no-literals globs, which is how it shipped.
- regions.utils compares an internal region name; hoisted to a named constant
  with a note that it must move to a stable key if names get localized.

The liquidity and rain-collateral passthroughs stay as the pre-contract
fallback. The liquidity text does not originate in peanut-api-ts at all (it is
SDK/routing-layer), so no backend code can address it.
…Error

Both screens re-implemented friendlyError's precedence by hand, so every wire
code added in the previous commit would have silently failed to reach them —
and their inline `rainCollateralErrorMessage` call was a second passthrough site
that would have kept showing untranslated backend English.

Deliberately not a straight swap. Three behaviours are preserved:
- SessionKeyGrantRequiredError keeps its per-flow copy. The two screens
  intentionally differ here (cardAuthNeeded vs cardAuthFailed), so this stays
  an explicit branch ahead of the classifier rather than a shared code.
- The `.includes('not allowed')` match is looser than the classifier's
  'not allowed by the user agent'. Kept as-is so this screen's matching does
  not narrow.
- The fallback stays flow-specific: falling through to genericSupport
  ("contact support") would have been a regression from signFailed
  ("couldn't sign the transaction") on what is always a signing failure. The
  Sentry captureException on that path is preserved with it.

The errorCode sentinel both screens already had is now fed from the classifier,
so downstream retry gates key off a locale-independent value.

Separate commit so it can be reverted on its own if a QA pass on the Rain paths
turns up copy regressions.
@innolope-dev innolope-dev self-assigned this Jul 28, 2026
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jul 28, 2026 4:35pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1c073e16-45a3-4fea-8928-30b112667371

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/locale-safe-errors

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6656.05 → 6656.59 (+0.54)
Findings: +3 net (+58 new, -55 resolved)

🆕 New findings (58)

  • critical complexity — src/app/(mobile-ui)/qr-pay/page.tsx — CC 305, MI 52.63, SLOC 1072
  • critical complexity — src/app/(mobile-ui)/withdraw/manteca/page.tsx — CC 157, MI 51.58, SLOC 594
  • critical complexity — src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx — CC 101, MI 53.43, SLOC 384
  • critical method-complexity — src/app/(mobile-ui)/qr-pay/page.tsx:89 — QRPayPage CC 75 SLOC 397
  • critical complexity — src/utils/friendly-error.utils.tsx — CC 69, MI 44.95, SLOC 233
  • critical complexity — src/services/rain.ts — CC 65, MI 59.85, SLOC 266
  • high hotspot — src/app/(mobile-ui)/qr-pay/page.tsx — 95 commits, +1156/-1110 lines since 6 months ago
  • high hotspot — src/app/(mobile-ui)/withdraw/manteca/page.tsx — 63 commits, +647/-396 lines since 6 months ago
  • high hotspot — src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx — 55 commits, +560/-278 lines since 6 months ago
  • high method-complexity — src/utils/friendly-error.utils.tsx:150 — CC 47 SLOC 167
  • high method-complexity — src/app/(mobile-ui)/withdraw/manteca/page.tsx:99 — MantecaBankWithdrawFlow CC 44 SLOC 245
  • high complexity — src/utils/regions.utils.ts — CC 44, MI 61.03, SLOC 138
  • medium react-long-component — src/app/(mobile-ui)/qr-pay/page.tsx:89 — QRPayPage is 1547 lines — split it
  • medium react-long-component — src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx:55 — WithdrawBankPage is 569 lines — split it
  • medium high-mdd — src/app/(mobile-ui)/qr-pay/page.tsx:89 — QRPayPage: MDD 460.2 (uses across many lines from declarations)
  • medium high-mdd — src/app/(mobile-ui)/withdraw/manteca/page.tsx:99 — MantecaBankWithdrawFlow: MDD 300.3 (uses across many lines from declarations)
  • medium high-mdd — src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx:55 — WithdrawBankPage: MDD 205.1 (uses across many lines from declarations)
  • medium high-dlt — src/app/(mobile-ui)/qr-pay/page.tsx:89 — QRPayPage: DLT 124 (calls 124 distinct functions — high context load)
  • medium high-dlt — src/app/(mobile-ui)/withdraw/manteca/page.tsx:99 — MantecaBankWithdrawFlow: DLT 99 (calls 99 distinct functions — high context load)
  • medium high-dlt — src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx:55 — WithdrawBankPage: DLT 85 (calls 85 distinct functions — high context load)

…and 38 more.

✅ Resolved (55)

  • src/app/(mobile-ui)/qr-pay/page.tsx — CC 304, MI 52.63, SLOC 1073
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx — CC 156, MI 51.57, SLOC 595
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx — CC 102, MI 53.37, SLOC 386
  • src/app/(mobile-ui)/qr-pay/page.tsx:88 — QRPayPage CC 75 SLOC 395
  • src/services/rain.ts — CC 65, MI 60.08, SLOC 262
  • src/utils/friendly-error.utils.tsx — CC 57, MI 45.98, SLOC 195
  • src/app/(mobile-ui)/qr-pay/page.tsx — 94 commits, +1140/-1100 lines since 6 months ago
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx — 62 commits, +635/-387 lines since 6 months ago
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx — 54 commits, +548/-275 lines since 6 months ago
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx:98 — MantecaBankWithdrawFlow CC 44 SLOC 243
  • src/utils/regions.utils.ts — CC 44, MI 61.11, SLOC 137
  • src/utils/friendly-error.utils.tsx:96 — CC 41 SLOC 147
  • src/app/(mobile-ui)/qr-pay/page.tsx:88 — QRPayPage is 1542 lines — split it
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx:55 — WithdrawBankPage is 560 lines — split it
  • src/app/(mobile-ui)/qr-pay/page.tsx:88 — QRPayPage: MDD 460.4 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx:98 — MantecaBankWithdrawFlow: MDD 301.6 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx:55 — WithdrawBankPage: MDD 199.9 (uses across many lines from declarations)
  • src/app/(mobile-ui)/qr-pay/page.tsx:88 — QRPayPage: DLT 122 (calls 122 distinct functions — high context load)
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx:98 — MantecaBankWithdrawFlow: DLT 97 (calls 97 distinct functions — high context load)
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx:55 — WithdrawBankPage: DLT 87 (calls 87 distinct functions — high context load)

…and 35 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/services/api-error.ts 0.0 4.4 +4.4
src/hooks/useFriendlyError.ts 4.9 6.3 +1.4
src/utils/friendly-error.utils.tsx 10.1 11.0 +0.9

@github-actions

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2314 ran, 0 failed, 0 skipped, 40.7s

📊 Coverage (unit)

metric %
statements 61.0%
branches 44.3%
functions 50.2%
lines 61.5%
⏱ 10 slowest test cases
time test
3.4s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.1s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.4s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.4s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.3s src/utils/__tests__/url.utils.test.ts › uses the public BASE_URL in Capacitor, not the localhost WebView origin
0.2s src/utils/__tests__/auth-token.test.ts › returns the token hydrated from Preferences after authReady
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

innolope-dev added a commit that referenced this pull request Jul 28, 2026
…nner removal

Carries the localization follow-up cherry-picked from the dev-based PRs
(peanut-ui #2554 / #2555) plus the iOS-only removal of the pink beta feedback
ribbon:

- Backend errors now map to localized copy via a wire-code allow-list instead
  of being echoed in English; the Rain cooldown renders "try again in N
  minutes" in the user's language.
- Locale-unsafe comparisons removed (a dead branch in the bank-withdraw flow
  that compared a translated string to an English literal).
- Untranslated toasts, placeholders, aria-labels and alt text extracted.
- 39 hand-rolled test intl wrappers collapsed into one shared helper.
- The beta feedback ribbon is hidden on iOS native only; connectivity and
  maintenance banners still show, and Android/web are unchanged.

App icon note: no icon change here. The BETA badge was dropped from the iOS
icon in 1.0.38 and every build since (1.0.39, 1.0.40, 1.0.41) has carried the
clean 1024px marketing icon.
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