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();