Problem
When a payer clicks Pay on a report, the App sends the Onyx-cached report total it last rendered. If that cached total is even slightly stale (e.g. a few cents of drift after a currency exchange-rate re-conversion on a foreign-currency expense), it no longer matches the server-computed reimbursable total. The backend correctly rejects the payment with an "amount changed" error to prevent paying an amount the user didn't actually see and consent to.
The problem is what the App does next:
- It shows a generic error —
Unexpected error. Please try again later. (src/languages/en.ts:1536, via iou.error.other at src/libs/actions/IOU/PayMoneyRequest.ts:397).
- Its failure data rolls Onyx back to the same stale report snapshot (
src/libs/actions/IOU/PayMoneyRequest.ts:401-407).
So clicking Pay again sends the same stale total and hits the same wall. The user is stuck in a dead-end loop with no indication of what's wrong or how to recover. The only current workaround is to manually reload the report so the client picks up the corrected total — which a customer should never need to know to do.
The cached total is read here: src/libs/actions/IOU/PayMoneyRequest.ts:250-257.
Expected behavior
Clicking Pay should succeed (or self-recover) without the user needing to manually reload the report. The backend consent guardrail is correct and should stay — the fix is for the client to stop dead-ending on a stale total.
Actual behavior
Payment fails with a generic "Unexpected error. Please try again later." Retrying without a manual page reload reproduces the same error every time.
Proposed direction
Make the client auto-recover from a stale total instead of dead-ending. When PayMoneyRequest fails specifically because the displayed amount no longer matches the server total, the App should re-fetch the report to refresh the cached total, then re-confirm with the corrected amount (or silently retry within a trivial tolerance) — no manual reload required.
There is clean prior art for this shape: the Reauthentication middleware already intercepts a specific backend response code, transparently fixes the underlying state, and re-runs the original request. A "stale total" recovery should follow the same pattern.
Implementation notes
Three changes, in order of importance:
-
Auto-recover (core fix) — in PayMoneyRequest.ts, detect the "amount changed" failure, trigger an OpenReport/reconnect to pull the live total, then re-confirm and retry. Today the failure data blindly restores the same stale iouReport snapshot (lines 401-407), which guarantees the next attempt fails too.
-
Don't clobber a server-pushed total — confirm whether the failure response already includes a refreshed report.total in its onyxData. If it does, the generic failure path is overwriting it with the stale value; honoring it may largely resolve the issue on its own.
-
Meaningful error copy (fallback) — if auto-retry isn't pursued, at minimum replace the generic iou.error.other text with something actionable ("The amount on this report changed — review the new total and pay again"). This still requires user action, so it's the weakest option and shouldn't be the only change.
Scope sizing: a quick log query on how often PayMoneyRequest hits this "amount changed" rejection across all users (not just one report) would size the impact and confirm FX-rate timing is the dominant trigger.
Platform
Web (and any platform sharing this Onyx flow).
Originated from an internal Concierge escalation (Expensify/Expensify#648321). The backend guardrail is working as designed; this is a frontend recovery/UX defect in App.
Upwork Automation - Do Not Edit
Problem
When a payer clicks Pay on a report, the App sends the Onyx-cached report total it last rendered. If that cached total is even slightly stale (e.g. a few cents of drift after a currency exchange-rate re-conversion on a foreign-currency expense), it no longer matches the server-computed reimbursable total. The backend correctly rejects the payment with an "amount changed" error to prevent paying an amount the user didn't actually see and consent to.
The problem is what the App does next:
Unexpected error. Please try again later.(src/languages/en.ts:1536, viaiou.error.otheratsrc/libs/actions/IOU/PayMoneyRequest.ts:397).src/libs/actions/IOU/PayMoneyRequest.ts:401-407).So clicking Pay again sends the same stale total and hits the same wall. The user is stuck in a dead-end loop with no indication of what's wrong or how to recover. The only current workaround is to manually reload the report so the client picks up the corrected total — which a customer should never need to know to do.
The cached total is read here:
src/libs/actions/IOU/PayMoneyRequest.ts:250-257.Expected behavior
Clicking Pay should succeed (or self-recover) without the user needing to manually reload the report. The backend consent guardrail is correct and should stay — the fix is for the client to stop dead-ending on a stale total.
Actual behavior
Payment fails with a generic "Unexpected error. Please try again later." Retrying without a manual page reload reproduces the same error every time.
Proposed direction
Make the client auto-recover from a stale total instead of dead-ending. When
PayMoneyRequestfails specifically because the displayed amount no longer matches the server total, the App should re-fetch the report to refresh the cached total, then re-confirm with the corrected amount (or silently retry within a trivial tolerance) — no manual reload required.There is clean prior art for this shape: the
Reauthenticationmiddleware already intercepts a specific backend response code, transparently fixes the underlying state, and re-runs the original request. A "stale total" recovery should follow the same pattern.Implementation notes
Three changes, in order of importance:
Auto-recover (core fix) — in
PayMoneyRequest.ts, detect the "amount changed" failure, trigger anOpenReport/reconnect to pull the live total, then re-confirm and retry. Today the failure data blindly restores the same staleiouReportsnapshot (lines 401-407), which guarantees the next attempt fails too.Don't clobber a server-pushed total — confirm whether the failure response already includes a refreshed
report.totalin itsonyxData. If it does, the generic failure path is overwriting it with the stale value; honoring it may largely resolve the issue on its own.Meaningful error copy (fallback) — if auto-retry isn't pursued, at minimum replace the generic
iou.error.othertext with something actionable ("The amount on this report changed — review the new total and pay again"). This still requires user action, so it's the weakest option and shouldn't be the only change.Scope sizing: a quick log query on how often
PayMoneyRequesthits this "amount changed" rejection across all users (not just one report) would size the impact and confirm FX-rate timing is the dominant trigger.Platform
Web (and any platform sharing this Onyx flow).
Originated from an internal Concierge escalation (Expensify/Expensify#648321). The backend guardrail is working as designed; this is a frontend recovery/UX defect in App.
Upwork Automation - Do Not Edit