diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index 81b9185132..e325b5a8e9 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -1443,6 +1443,20 @@ describe('GROUP 5: Error States', () => { }) }) + test('Expired merchant charge names the cashier action, not a provider outage', async () => { + // A static POS sticker never expires — what timed out is the charge the + // cashier rang up. Blaming the rail sends the user away; naming the + // cashier action gets them paid. + mockMantecaApi.initiateQrPayment.mockRejectedValue(new Error('PAYMENT_DESTINATION_EXPIRED')) + + renderQrPay({ qrCode: 'mercadopago://pay?id=123', type: 'MERCADO_PAGO', t: '1' }) + + await waitFor(() => { + expect(screen.getByText(/enter the amount again/i)).toBeInTheDocument() + }) + expect(screen.queryByText(/currently experiencing issues/i)).not.toBeInTheDocument() + }) + test('Below-minimum Pix charge shows the Pix minimum-amount error', async () => { mockMantecaApi.initiateQrPayment.mockRejectedValue(new Error('PIX_MIN_AMOUNT')) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 71a2834bc5..5adc1968dc 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -85,6 +85,9 @@ const MIN_QR_PAYMENT_AMOUNT = '0.1' // change the outcome, so fail fast instead of burning the 3-attempt budget. const NON_RETRYABLE_QR_PAY_ERRORS = [ 'PAYMENT_DESTINATION_DECODING_ERROR', + // The cashier's charge timed out on the till. Only the cashier can clear it, + // so retrying the same destination just burns the attempt budget. + 'PAYMENT_DESTINATION_EXPIRED', 'PIX_MIN_AMOUNT', 'PIX_RECURRING_NOT_SUPPORTED', // Missing auth header (AJV 400) — retrying sends the same headerless request, @@ -653,6 +656,13 @@ export default function QRPayPage() { setErrorInitiatingPayment(qrType === EQrType.PIX ? t('errors.pixDecode') : t('errors.genericDecode')) posthog.capture(ANALYTICS_EVENTS.QR_DECODING_ERROR_SHOWN, { qr_type: qrType }) setWaitingForMerchantAmount(false) + } else if (error.message.includes('PAYMENT_DESTINATION_EXPIRED')) { + // The QR is fine — a static POS sticker never expires. The charge + // the cashier rang up on the till did. Name the cashier action + // instead of blaming the rail. + setErrorInitiatingPayment(t('errors.merchantChargeExpired')) + posthog.capture(ANALYTICS_EVENTS.QR_MERCHANT_CHARGE_EXPIRED_SHOWN, { qr_type: qrType }) + setWaitingForMerchantAmount(false) } else if (error.message.includes('PIX_MIN_AMOUNT')) { // Deterministic rejection — the merchant-encoded amount is below // the rail minimum, so there's no merchant amount to wait for. diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index f7345160ec..2931f18e49 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -157,6 +157,7 @@ export const ANALYTICS_EVENTS = { QR_SCANNED: 'qr_scanned', QR_NOTIFY_ME_CLICKED: 'qr_notify_me_clicked', QR_DECODING_ERROR_SHOWN: 'qr_decoding_error_shown', + QR_MERCHANT_CHARGE_EXPIRED_SHOWN: 'qr_merchant_charge_expired_shown', // ── Home ── BALANCE_VISIBILITY_TOGGLED: 'balance_visibility_toggled', diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 29bad817de..cfa5261825 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -1255,6 +1255,7 @@ "pixDecode": "We could not decode this Pix QR code. Please ask the merchant to generate a new one.", "genericDecode": "We could not decode this particular QR code. Please ask the Merchant if they can generate a Mercado Pago QR", "providerIssues": "We are currently experiencing issues with {method} payments. We are working to fix it as soon as possible", + "merchantChargeExpired": "The cashier's charge has timed out. Ask them to enter the amount again, then scan once more.", "initiateUnexpected": "Could not initiate payment due to unexpected error. Please contact support", "fetchDetails": "Could not fetch qr payment details", "cardAuthNeeded": "One-time card authorization needed. You'll be asked to confirm once.", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index e81dc723fc..5aadfe20df 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -1255,6 +1255,7 @@ "pixDecode": "No pudimos decodificar este código QR de Pix. Pídele al comercio que genere uno nuevo.", "genericDecode": "No pudimos decodificar este código QR. Pregúntale al comercio si puede generar un QR de Mercado Pago", "providerIssues": "Estamos teniendo problemas con los pagos de {method}. Estamos trabajando para solucionarlo lo antes posible", + "merchantChargeExpired": "El cobro del cajero se venció. Pídele que ingrese el monto de nuevo y escanea otra vez.", "initiateUnexpected": "No se pudo iniciar el pago por un error inesperado. Contacta a soporte", "fetchDetails": "No se pudieron obtener los detalles del pago QR", "cardAuthNeeded": "Se necesita una autorización única de la tarjeta. Se te pedirá confirmar una vez.", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index c4d7c0593c..3c592626e4 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -1255,6 +1255,7 @@ "pixDecode": "Não conseguimos decodificar este código QR do Pix. Peça ao lojista para gerar um novo.", "genericDecode": "Não conseguimos decodificar este código QR. Pergunte ao lojista se ele pode gerar um QR do Mercado Pago", "providerIssues": "Estamos com problemas nos pagamentos de {method}. Estamos trabalhando para resolver o quanto antes", + "merchantChargeExpired": "A cobrança do caixa expirou. Peça para inserir o valor de novo e escaneie mais uma vez.", "initiateUnexpected": "Não foi possível iniciar o pagamento por um erro inesperado. Fale com o suporte", "fetchDetails": "Não foi possível obter os detalhes do pagamento QR", "cardAuthNeeded": "É necessária uma autorização única do cartão. Você vai confirmar uma vez.", diff --git a/src/utils/__tests__/sentry.utils.test.ts b/src/utils/__tests__/sentry.utils.test.ts index 58d12b986c..e90b21ce42 100644 --- a/src/utils/__tests__/sentry.utils.test.ts +++ b/src/utils/__tests__/sentry.utils.test.ts @@ -108,6 +108,30 @@ describe('fetchWithSentry — expected-response suppression', () => { expect(Sentry.captureMessage).not.toHaveBeenCalled() }) + // qr-payment/init 409 is expected for exactly one outcome: the cashier's + // charge on the till timed out (BE peanut-api-ts #1484). The suppression is + // scoped to that code so a different conflict on the same route still pages + // us — the pair below pins both halves. + it('does NOT report qr-payment/init 409 when the body is PAYMENT_DESTINATION_EXPIRED', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse(409, { error: 'PAYMENT_DESTINATION_EXPIRED' })) + + const res = await fetchWithSentry('https://api.peanut.me/manteca/qr-payment/init', { + method: 'POST', + body: '{}', + }) + + expect(res.status).toBe(409) + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + it('DOES report a different qr-payment/init 409 — the status alone must not suppress', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse(409, { error: 'DUPLICATE_PAYMENT_IN_FLIGHT' })) + + await fetchWithSentry('https://api.peanut.me/manteca/qr-payment/init', { method: 'POST', body: '{}' }) + + expect(Sentry.captureMessage).toHaveBeenCalledTimes(1) + }) + it('reports a non-2xx exactly once, via captureMessage and never via console.warn', async () => { // captureConsoleIntegration listens on ['error','warn'], so a console.warn // here produced a SECOND event for every non-2xx in the app, grouped by diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index 66e7930a23..bacbdfae4c 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -10,8 +10,12 @@ import { canUseNativeHttp, nativeHttpRequest } from './native-http' * Endpoint + status combinations to skip reporting. * These are expected responses, not errors. * Pattern can be a string (exact match) or regex. + * + * `errorCodes` narrows a rule to specific `error` values in the response body. + * Use it when a status is expected for one known outcome but would still be + * worth reporting for anything else that shares it. */ -const SKIP_REPORTING: Array<{ pattern: string | RegExp; statuses: number[] }> = [ +const SKIP_REPORTING: Array<{ pattern: string | RegExp; statuses: number[]; errorCodes?: string[] }> = [ // /get-user is the auth-status probe — 401/404 mean stale JWT, expected, not a server bug. { pattern: /\/get-user(?:\b|$)/, statuses: [400, 401, 403, 404] }, { pattern: /users/, statuses: [400, 401, 403, 404] }, @@ -36,6 +40,11 @@ const SKIP_REPORTING: Array<{ pattern: string | RegExp; statuses: number[] }> = // provider can't decode (bad/expired/unsupported) — both are user-input // outcomes shown to the user, not server bugs. (BE peanut-api-ts #1041.) { pattern: /qr-payment\/init/, statuses: [400, 422] }, + // 409 on the same route is expected for exactly one outcome: the charge the + // cashier rang up on the till timed out (BE peanut-api-ts #1484). Scoped to + // that code, so a different conflict on this route — a double-submit, say — + // still reaches Sentry instead of being swallowed by the status alone. + { pattern: /qr-payment\/init/, statuses: [409], errorCodes: ['PAYMENT_DESTINATION_EXPIRED'] }, // Rain card secrets endpoints are intentionally rate-limited (5/min) — a // 429 here is an expected outcome surfaced to the user, not a server bug. { pattern: /\/rain\/cards\/[^/]+\/details/, statuses: [429] }, @@ -300,17 +309,30 @@ function getFeatureTag(url: string): string | null { } /** - * Check if this endpoint + status combo should skip Sentry reporting + * Find the rule that suppresses this endpoint + status combo, if any. + * A rule with `errorCodes` still has to be checked against the response body — + * see `bodyCarriesSkippedCode`. */ -function shouldSkipReporting(url: string, status: number): boolean { - for (const rule of SKIP_REPORTING) { +function findSkipRule(url: string, status: number): (typeof SKIP_REPORTING)[number] | undefined { + return SKIP_REPORTING.find((rule) => { const matches = typeof rule.pattern === 'string' ? url.includes(rule.pattern) : rule.pattern.test(url) + return matches && rule.statuses.includes(status) + }) +} - if (matches && rule.statuses.includes(status)) { - return true - } - } - return false +/** + * Does the response body carry one of the rule's `errorCodes`? The backend + * sends `{ error, message }`, so the code is `error`; a body that failed to + * parse as JSON arrives here as text and is matched whole. + */ +function bodyCarriesSkippedCode(codes: string[], body: JSONValue): boolean { + const code = + typeof body === 'string' + ? body + : typeof body === 'object' && body !== null && 'error' in body + ? String((body as { error: unknown }).error) + : '' + return codes.some((skipped) => code.includes(skipped)) } /** @@ -435,14 +457,10 @@ const reportNonOkResponse = async (url: string, options: RequestInit, response: // non-2xx responses (username availability 404, get-user-from-cookie // 401 on cleared session, etc). Logging them clutters DevTools and // gets picked up by forward-logs-shared as Sentry breadcrumbs. - if (shouldSkipReporting(url, response.status)) return - - // console.info, not warn — captureConsoleIntegration listens on - // ['error','warn'], so a warn here became a SECOND Sentry event for every - // non-2xx in the app, grouped by this call site rather than by request. - // The explicit captureMessage below is the real report: it fingerprints on - // [method, url, status] and carries headers, body and response. - console.info(`Request to ${String(url).replace(/[\r\n]/g, '')} failed with status ${response.status}`) + const skipRule = findSkipRule(url, response.status) + // A rule with no `errorCodes` decides on URL + status alone, so nothing + // below needs to run. + if (skipRule && !skipRule.errorCodes) return let errorContent: JSONValue try { @@ -450,6 +468,17 @@ const reportNonOkResponse = async (url: string, options: RequestInit, response: } catch { errorContent = await response.clone().text() } + + // A scoped rule suppresses only its own codes. Any other failure sharing + // the status falls through and is reported. + if (skipRule?.errorCodes && bodyCarriesSkippedCode(skipRule.errorCodes, errorContent)) return + + // console.info, not warn — captureConsoleIntegration listens on + // ['error','warn'], so a warn here became a SECOND Sentry event for every + // non-2xx in the app, grouped by this call site rather than by request. + // The explicit captureMessage below is the real report: it fingerprints on + // [method, url, status] and carries headers, body and response. + console.info(`Request to ${String(url).replace(/[\r\n]/g, '')} failed with status ${response.status}`) const method = options.method || 'GET' const featureTag = getFeatureTag(url) Sentry.withScope((scope) => {