Skip to content

fix(qr-pay): cashier-action copy when the merchant charge expires (TASK-21384) - #2906

Merged
jjramirezn merged 2 commits into
devfrom
fix/21384-qr-charge-expired
Sep 2, 2026
Merged

fix(qr-pay): cashier-action copy when the merchant charge expires (TASK-21384)#2906
jjramirezn merged 2 commits into
devfrom
fix/21384-qr-charge-expired

Conversation

@abalinda

@abalinda abalinda commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

When Manteca returns PAYMENT_DESTINATION_EXPIRED, the app showed "We are currently experiencing issues with Mercado Pago payments" — so users reported Peanut as down. It is not an outage, and it is not the QR: on a static Mercado Pago POS sticker (mpago.la/pos/<id>, EMV tag 01 = 11, no tag 54) the QR never expires. What expired is the charge the cashier entered on the till. Rescanning alone does not fix it. The cashier must enter the amount again.

Three parts:

  1. Copy — new qrPay.errors.merchantChargeExpired in en, es-419 and pt-BR, naming the cashier action.
  2. No retry loopPAYMENT_DESTINATION_EXPIRED joins NON_RETRYABLE_QR_PAY_ERRORS. Retrying the same dead charge cannot change the outcome, so it only burns the 3-attempt budget.
  3. Sentry409 on qr-payment/init is suppressed, but scoped to PAYMENT_DESTINATION_EXPIRED, not to the status. This is what closes PEANUT-UI-9FR. SKIP_REPORTING rules now take an optional errorCodes list; a rule without one still decides on URL + status alone, so the existing entries behave exactly as before. Added after review — see below.

A qr_merchant_charge_expired_shown PostHog event keeps the frequency signal that Sentry stops carrying once this is no longer an error.

Copy shipped (review the wording here — see the screenshots note below):

  • en: The cashier's charge has timed out. Ask them to enter the amount again, then scan once more.
  • es-419: El cobro del cajero se venció. Pídele que ingrese el monto de nuevo y escanea otra vez.
  • pt-BR: A cobrança do caixa expirou. Peça para inserir o valor de novo e escaneie mais uma vez.

Task

TASK-21384 — https://app.notion.com/p/3ba838117579811082e3ce25496e2495

Risks / breaking changes

⚠️ Deploy order: peanut-api-ts #1484 must ship first. The service throws new Error(errorData.error || …), and today that field is Unexpected error — the backend has no branch for this code, so it falls to a 500. Until #1484 is live these branches never fire and behaviour is unchanged. Merging this first is harmless, just inert.

The Sentry 409 entry is scoped to the qr-payment/init pattern, so no other route's 409s are hidden.

Design notes / accepted trade-offs

Review response (Chip second opinion, kimi-k3): the first version of this PR suppressed every 409 on qr-payment/init by status alone. That would have swallowed any future conflict on the route — a double-submit or an in-flight state clash is exactly the kind of payments race worth an event. Today PAYMENT_DESTINATION_EXPIRED is the only 409 the route can return (the classifier is its sole source; the one other 409 in the Manteca routes, deposit.ts:178, is a path this pattern does not match), so the finding was about tomorrow, not today. It was still the right call: the body is already parsed a few lines below the skip check, so scoping cost ~15 lines and removed a trap nobody would have found later. Fixed in daa998a2, with tests on both halves.

Fixtures do not cover qr-pay error states, and Fixture.fails can only answer 500 with a fixed body — it cannot express "409 with this error code". Extending that contract is framework work that does not belong in a bugfix, so it is noted as a follow-up rather than done here. That is also why there are no screenshots.

QA

npx jest qr-pay-states — new case asserts the cashier copy renders and that the "currently experiencing issues" toast does not.

Screenshots: ⚠️ NONE — the state needs Manteca to return PAYMENT_DESTINATION_EXPIRED against a real till, which no local sandbox or fixture can produce, and which needs #1484 deployed first. The exact copy is quoted above so the wording is still reviewable.

Manteca's PAYMENT_DESTINATION_EXPIRED means the charge the cashier entered
on the till timed out, not that the QR expired. On a static Mercado Pago POS
sticker the QR never expires, so the app was telling users the payment rail
was down when the fix was one sentence to the cashier.

Three parts: the copy names the cashier action in en, es-419 and pt-BR; the
code joins the non-retryable list, because retrying the same dead charge only
burns the attempt budget; and 409 joins 400 and 422 on the qr-payment/init
Sentry ignore list, since all three are user-facing outcomes, not server bugs.

Needs peanut-api-ts #1484 deployed first — that is what turns the error field
from "Unexpected error" into PAYMENT_DESTINATION_EXPIRED.

TASK-21384
@notion-workspace

Copy link
Copy Markdown

@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
peanut-wallet Ready Ready Preview Sep 1, 2026 2:03pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 1, 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: Essentials

Run ID: 24cb3a91-8785-497a-a58e-c17977b8e796

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

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

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7152.21 → 7145.25 (-6.96)
Findings: 0 net (+30 new, -30 resolved)

🆕 New findings (30)

  • critical complexity — src/app/(mobile-ui)/qr-pay/page.tsx — CC 314, MI 52.33, SLOC 1135
  • critical method-complexity — src/app/(mobile-ui)/qr-pay/page.tsx:100 — QRPayPage CC 79 SLOC 413
  • high hotspot — src/app/(mobile-ui)/qr-pay/page.tsx — 121 commits, +1464/-1309 lines since 6 months ago
  • high hotspot — src/constants/analytics.consts.ts — 64 commits, +467/-26 lines since 6 months ago
  • high hotspot — src/utils/sentry.utils.ts — 48 commits, +896/-329 lines since 6 months ago
  • high complexity — src/constants/analytics.consts.ts — CC 1, MI 30.27, SLOC 217
  • medium react-long-component — src/app/(mobile-ui)/qr-pay/page.tsx:100 — QRPayPage is 1641 lines — split it
  • medium high-mdd — src/app/(mobile-ui)/qr-pay/page.tsx:100 — QRPayPage: MDD 473.3 (uses across many lines from declarations)
  • medium high-dlt — src/app/(mobile-ui)/qr-pay/page.tsx:100 — QRPayPage: DLT 130 (calls 130 distinct functions — high context load)
  • medium high-mdd — src/utils/sentry.utils.ts:505 — fetchWithSentry: MDD 53.1 (uses across many lines from declarations)
  • medium structural-dup — app/(mobile-ui)/dev/shake-test/page.tsx:29 — 46 duplicate lines / 204 tokens with app/(mobile-ui)/qr-pay/page.tsx:986
  • medium high-mdd — src/app/(mobile-ui)/qr-pay/page.tsx:707 — : MDD 43.0 (uses across many lines from declarations)
  • medium method-complexity — src/app/(mobile-ui)/qr-pay/page.tsx:707 — CC 27 SLOC 146
  • medium high-mdd — src/app/(mobile-ui)/qr-pay/page.tsx:203 — : MDD 21.6 (uses across many lines from declarations)
  • medium high-mdd — src/utils/sentry.utils.ts:454 — reportNonOkResponse: MDD 21.2 (uses across many lines from declarations)
  • medium method-complexity — src/app/(mobile-ui)/qr-pay/page.tsx:631 — CC 16 SLOC 53
  • medium react-effect-derives-state — src/app/(mobile-ui)/qr-pay/page.tsx:410 — small useEffect that only sets state from deps
  • medium react-effect-derives-state — src/app/(mobile-ui)/qr-pay/page.tsx:1087 — small useEffect that only sets state from deps
  • low high-dlt — src/utils/sentry.utils.ts:505 — fetchWithSentry: DLT 24 (calls 24 distinct functions — high context load)
  • low high-dlt — src/app/(mobile-ui)/qr-pay/page.tsx:707 — : DLT 23 (calls 23 distinct functions — high context load)

…and 10 more.

✅ Resolved (30)

  • src/app/(mobile-ui)/qr-pay/page.tsx — CC 313, MI 52.42, SLOC 1127
  • src/app/(mobile-ui)/qr-pay/page.tsx:97 — QRPayPage CC 79 SLOC 413
  • src/app/(mobile-ui)/qr-pay/page.tsx — 120 commits, +1454/-1309 lines since 6 months ago
  • src/constants/analytics.consts.ts — 63 commits, +466/-26 lines since 6 months ago
  • src/utils/sentry.utils.ts — 46 commits, +843/-305 lines since 6 months ago
  • src/constants/analytics.consts.ts — CC 1, MI 30.33, SLOC 216
  • src/app/(mobile-ui)/qr-pay/page.tsx:97 — QRPayPage is 1634 lines — split it
  • src/app/(mobile-ui)/qr-pay/page.tsx:97 — QRPayPage: MDD 470.6 (uses across many lines from declarations)
  • src/app/(mobile-ui)/qr-pay/page.tsx:97 — QRPayPage: DLT 130 (calls 130 distinct functions — high context load)
  • src/utils/sentry.utils.ts:476 — fetchWithSentry: MDD 53.1 (uses across many lines from declarations)
  • app/(mobile-ui)/dev/shake-test/page.tsx:29 — 46 duplicate lines / 204 tokens with app/(mobile-ui)/qr-pay/page.tsx:976
  • src/app/(mobile-ui)/qr-pay/page.tsx:697 — : MDD 43.0 (uses across many lines from declarations)
  • src/app/(mobile-ui)/qr-pay/page.tsx:697 — CC 27 SLOC 146
  • src/app/(mobile-ui)/qr-pay/page.tsx:200 — : MDD 21.6 (uses across many lines from declarations)
  • src/app/(mobile-ui)/qr-pay/page.tsx:628 — CC 15 SLOC 45
  • src/app/(mobile-ui)/qr-pay/page.tsx:407 — small useEffect that only sets state from deps
  • src/app/(mobile-ui)/qr-pay/page.tsx:1077 — small useEffect that only sets state from deps
  • src/utils/sentry.utils.ts:476 — fetchWithSentry: DLT 24 (calls 24 distinct functions — high context load)
  • src/app/(mobile-ui)/qr-pay/page.tsx:697 — : DLT 23 (calls 23 distinct functions — high context load)
  • src/app/(mobile-ui)/qr-pay/page.tsx:319 — resetState: DLT 19 (calls 19 distinct functions — high context load)

…and 10 more.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 4995 ran, 0 failed, 0 skipped, 1.1m

📊 Coverage (unit)

metric %
statements 73.5%
branches 58.9%
functions 64.7%
lines 74.5%
⏱ 10 slowest test cases
time test
2.4s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.0s src/hooks/query/__tests__/user.test.tsx › does NOT clear a token that rotated mid-request (stale 401 racing a fresh login)
1.0s src/components/Profile/views/__tests__/ResidenceChangeModal.test.tsx › saves the declared residence, refetches, and closes
0.8s scripts/__tests__/release-version.test.js › increments the OTA component within the current build
0.7s src/components/Profile/views/__tests__/ResidenceChangeModal.test.tsx › swaps from the server value with no device mirror at all
0.7s src/components/Profile/views/__tests__/ResidenceChangeModal.test.tsx › prefers the server value over a stale device mirror
0.7s src/components/Profile/views/__tests__/ResidenceChangeModal.test.tsx › the change cooldown shows its date and blocks changing to another country, not re-saving
0.6s src/components/Setup/Views/__tests__/Residence.test.tsx › reveals the second selector via the multi-doc link
0.6s src/components/Profile/views/__tests__/ResidenceChangeModal.test.tsx › moving to a country in neither slot leaves the second document alone
0.6s src/utils/__tests__/crisp.test.ts › retries configuration on the next open after a failure
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@chip-peanut-bot chip-peanut-bot Bot 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.

Chip review — no blocking findings — this is not an approval

Clean: the exact 409 contract reaches the cashier-action copy, the typed rejection does not retry, and telemetry keeps the frequency signal without sending QR payload data.

Findings

  • MINOR · src/utils/sentry.utils.ts:39 · [moonshotai/kimi-k3] Sentry 409 ignore on qr-payment/init suppresses all conflicts, not just the expired-charge code
    Failure scenario: the ignore entry matches only on URL pattern + HTTP status, so after this ships, any 409 from qr-payment/init is silently dropped from Sentry. 409 Conflict is the canonical status payment backends use for duplicate / already-in-flight initiation conflicts — and this screen has client-side retry logic that can produce concurrent in-flight initiations. If Manteca/peanut-api ever returns 409 for a double-submit or state conflict (a payments race worth investigating), there will be zero server-side signal: the client falls through to a generic error branch and Sentry discards the event. Evidence: src/utils/sentry.utils.ts line 39, { pattern: /qr-payment\/init/, statuses: [400, 409, 422] } — the SKIP_REPORTING structure cannot distinguish PAYMENT_DESTINATION_EXPIRED from any other 409 body. Fix: extend the SKIP_REPORTING entries with an optional error-code/message matcher (e.g. only ignore when the response/message contains PAYMENT_DESTINATION_EXPIRED), or keep 409 reportable until the backend filter can be scoped to that code.

Checked clean

  • Verified the detached worktree head, supplied base SHA, and merge base; the review covers only the seven-file 32-line change at the exact head.
  • Traced the response contract through mantecaApi: response.error becomes Error.message, and the local backend branch maps PAYMENT_DESTINATION_EXPIRED to HTTP 409 with that exact error code.
  • Checked retry and state behavior: PAYMENT_DESTINATION_EXPIRED is non-retryable, clears merchant-waiting state, and renders the dedicated error instead of the provider-outage fallback.
  • Checked English, Latin American Spanish, and Brazilian Portuguese copy plus the QR-pay state test; the new test asserts the cashier action and rejects the outage message.
  • Checked telemetry and Sentry boundaries: the PostHog event includes only qr_type, and the 409 suppression remains limited to qr-payment/init where the backend classifies this expected outcome.
  • Checked current CI at the exact head: unit, typecheck, eslint, format, analyze, deploy preview, and aggregate ci-success completed successfully; ds-shots was still running and no design-system files changed.
  • Ran correctness, security, adversarial, and slop passes; no reachable regression or actionable defect survived verification.

Second opinion by moonshotai/kimi-k3: 1 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

Exact head: e8a2f858b65d · Context: repo, engineering, ci

Comment thread src/utils/sentry.utils.ts Outdated
@abalinda
abalinda marked this pull request as ready for review September 1, 2026 13:50
@abalinda
abalinda requested review from kushagrasarathe and a lite review from Copilot September 1, 2026 13:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Updates the QR Pay UX and telemetry for Manteca’s PAYMENT_DESTINATION_EXPIRED outcome so users are instructed to take the correct cashier action (re-enter the amount) instead of seeing a misleading “provider outage” message. This also reduces unnecessary retries and noise in Sentry while preserving observability via PostHog.

Changes:

  • Adds a new localized error copy key qrPay.errors.merchantChargeExpired (en, es-419, pt-BR) and renders it when PAYMENT_DESTINATION_EXPIRED is returned.
  • Treats PAYMENT_DESTINATION_EXPIRED as non-retryable in the /qr-pay init query to avoid burning the retry budget on a deterministic outcome.
  • Suppresses Sentry reporting for 409 on qr-payment/init and adds a new PostHog event qr_merchant_charge_expired_shown to keep a frequency signal.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/utils/sentry.utils.ts Adds 409 to the Sentry skip-reporting rule for qr-payment/init.
src/i18n/app/messages/en.json Adds English merchantChargeExpired copy under qrPay.errors.
src/i18n/app/messages/es-419.json Adds es-419 merchantChargeExpired copy under qrPay.errors.
src/i18n/app/messages/pt-BR.json Adds pt-BR merchantChargeExpired copy under qrPay.errors.
src/constants/analytics.consts.ts Introduces QR_MERCHANT_CHARGE_EXPIRED_SHOWN analytics event constant.
src/app/(mobile-ui)/qr-pay/page.tsx Adds non-retryable code + maps expired-charge error to the new copy and PostHog event.
src/app/(mobile-ui)/qr-pay/tests/qr-pay-states.test.tsx Adds a regression test ensuring the cashier-action copy renders and outage copy does not.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

… code

Chip review on #2906 (second opinion, kimi-k3): suppressing every 409 on
qr-payment/init by status alone would swallow any future conflict on that
route — a double-submit or an in-flight state clash is exactly the kind of
payments race worth a Sentry event.

SKIP_REPORTING rules now take an optional errorCodes list. A rule without one
still decides on URL and status alone, so nothing else changes; the 409 entry
carries PAYMENT_DESTINATION_EXPIRED and matches on the error field of the
body, which is already parsed a few lines down.

Two tests pin both halves: the expired charge stays silent, a different 409 on
the same route still reports.

@chip-peanut-bot chip-peanut-bot Bot 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.

Chip review — no blocking findings — this is not an approval

Clean. The expired merchant-charge path now shows actionable cashier copy, avoids futile retries, preserves frequency analytics, and suppresses only the matching 409 error code from Sentry.

Checked clean

  • Earlier reviews contained no findings to re-evaluate.
  • Verified the detached worktree, exact head SHA, base ref, base SHA, and merge base.
  • Traced PAYMENT_DESTINATION_EXPIRED from the API response through fetch reporting, service error propagation, React Query retry policy, UI copy, and PostHog capture.
  • Verified the scoped Sentry rule suppresses PAYMENT_DESTINATION_EXPIRED while a different qr-payment/init 409 remains reportable; unit coverage pins both branches.
  • Validated all changed translation JSON files and git diff whitespace; unit, typecheck, lint, format, analyze, and aggregate CI checks passed on the exact head.

Second opinion by moonshotai/kimi-k3: 0 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

Exact head: daa998a29b4c · Context: repo, ci, backend

@jjramirezn
jjramirezn merged commit a7c2f86 into dev Sep 2, 2026
25 checks passed
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