fix: show why a card payment failed instead of leaving the form spinning
All checks were successful
Build and deploy / Validate source (push) Successful in 7s
Build and deploy / Integration suite on a real stack (push) Successful in 3m0s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 56s

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 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-29 11:58:56 -03:00
parent 8bc57195e8
commit 4437232d27
2 changed files with 35 additions and 6 deletions

View File

@@ -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)