From 4437232d27ab73593e5151a88a90fc9029eb98a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 29 Sep 2026 11:58:56 -0300 Subject: [PATCH] fix: show why a card payment failed instead of leaving the form spinning When creating the card payment failed, the form's onSubmit rejected with no message and Mercado Pago's button kept spinning. The page now shows the reason above the form. A payment Mercado Pago refuses is logged with its status, message and cause codes (payment_intent_refused) and answered 422 with that reason; other failures log their type. Co-Authored-By: Claude Opus 5.5 --- app/api/payments.py | 25 +++++++++++++++++++++++-- web/checkout.js | 16 ++++++++++++---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/app/api/payments.py b/app/api/payments.py index 578f24e..3a97eac 100644 --- a/app/api/payments.py +++ b/app/api/payments.py @@ -7,6 +7,7 @@ and an unverified delivery is recorded and refused rather than retried. """ from uuid import uuid4 +import httpx from fastapi import APIRouter, Depends, HTTPException, Request from psycopg.types.json import Jsonb @@ -60,6 +61,17 @@ def event_provider(): return payment.name +def provider_reason(response): + """A short, loggable reason from a refused provider call.""" + try: + data = response.json() + except ValueError: + return f'HTTP {response.status_code}' + causes = '; '.join(f"{c.get('code')}: {c.get('description')}" for c in data.get('cause') or [] + if isinstance(c, dict)) + return (causes or data.get('message') or data.get('error') or f'HTTP {response.status_code}')[:300] + + @router.post('/api/payments/intent') def intent(body: PaymentIntent, session_id=Depends(owner)): """Start paying an approved quote: a PIX code, or a card token from the @@ -105,8 +117,17 @@ def intent(body: PaymentIntent, session_id=Depends(owner)): quote['approved']['customer'], method) except ValueError as exc: raise HTTPException(422, str(exc)) - except Exception: - audit('payment_intent_failed', quote=str(body.quote_id)) + except httpx.HTTPStatusError as exc: + # Mercado Pago's own reason (status, message and cause codes) goes to + # the log; it never contains card data, only what was refused. + reason = provider_reason(exc.response) + audit('payment_intent_refused', quote=str(body.quote_id), method=body.method.type, + status=exc.response.status_code, reason=reason) + if exc.response.status_code < 500: + raise HTTPException(422, f'O Mercado Pago recusou o pagamento: {reason}') + raise HTTPException(502, 'Payment provider unavailable; try again') + except Exception as exc: + audit('payment_intent_failed', quote=str(body.quote_id), error=type(exc).__name__) raise HTTPException(502, 'Payment provider unavailable; try again') c.execute('''INSERT INTO dtf_local.payment_intents(id,quote_id,provider,provider_payment_id,method, status,amount_cents,response) VALUES(%s,%s,%s,%s,%s,%s,%s,%s) diff --git a/web/checkout.js b/web/checkout.js index 3f7c317..b4d7804 100644 --- a/web/checkout.js +++ b/web/checkout.js @@ -415,10 +415,18 @@ onSubmit:async data=>{ if (!cartOk()) { await refresh(); throw new Error('cart changed'); } message(''); - const result=await api('/payments/intent',{quote_id:draftId,method:{ - type:'card', token:data.token, payment_method_id:data.payment_method_id, - installments:Number(data.installments)||1, - issuer_id:data.issuer_id==null?null:String(data.issuer_id)}}); + let result; + try { + result=await api('/payments/intent',{quote_id:draftId,method:{ + type:'card', token:data.token, payment_method_id:data.payment_method_id, + installments:Number(data.installments)||1, + issuer_id:data.issuer_id==null?null:String(data.issuer_id)}}); + } catch(error) { + // Rejecting stops the form's spinner; the reason is shown above it. + message(error.message || 'Não foi possível concluir o pagamento. Tente de novo.'); + status.scrollIntoView({behavior:'smooth',block:'center'}); + throw error; + } if (result.challenge) { showChallenge(result.challenge, into); waitForOrder(); return; } if (result.status==='approved' || result.status==='pending') { unmountCard();