Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'))

Expand Down
10 changes: 10 additions & 0 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/constants/analytics.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/app/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/app/messages/es-419.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/app/messages/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
24 changes: 24 additions & 0 deletions src/utils/__tests__/sentry.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 46 additions & 17 deletions src/utils/sentry.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] },
Expand All @@ -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] },
Expand Down Expand Up @@ -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))
}

/**
Expand Down Expand Up @@ -435,21 +457,28 @@ 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 {
errorContent = await response.clone().json()
} 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) => {
Expand Down
Loading