Files
dtf-system/app/api/payments.py
Cauê Faleiros 4437232d27
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
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 <noreply@anthropic.com>
2026-09-29 11:58:56 -03:00

138 lines
7.0 KiB
Python

"""The provider's callback.
Unauthenticated by necessity — a payment provider has no session — so the
signature is the only thing standing between this endpoint and an attacker
creating orders. It is verified before the body is parsed, let alone acted on,
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
from .. import payments
from ..core import db
from ..core.auth import audit, client_ip, owner, rate_limit
from ..core.models import PaymentIntent
from ..runtime import payment
router = APIRouter()
# Generous: a provider legitimately retries, and a signature check is cheap.
# This exists so an unsigned flood cannot keep the database busy.
WEBHOOK_LIMIT = 600
# How long a card waiting for the bank's confirmation holds off a new attempt.
CHALLENGE_MINUTES = 10
@router.post('/api/payments/webhook')
async def webhook(request: Request):
rate_limit('payment-webhook', client_ip(request), WEBHOOK_LIMIT, 900)
body = await request.body()
query = dict(request.query_params)
if not payment.verify(request.headers, body, query):
audit('payment_webhook_rejected', ip=client_ip(request), reason='signature')
raise HTTPException(403, 'Invalid signature')
event = payment.parse(body, query)
if event is None:
# Verified, so genuinely from the provider, but not about a payment.
# Acknowledge it: refusing would make the provider retry for ever.
return {'status': 'ignored'}
with db.connect() as c:
stored = payments.record(c, event_provider(), event)
if stored is None:
# Already delivered. Acknowledge without acting again.
return {'status': 'duplicate'}
outcome = payments.apply(c, event)
c.execute('UPDATE dtf_local.payment_events SET processed_at=now(), outcome=%s WHERE id=%s',
(outcome, stored['id']))
# audit()'s own first parameter is named `event`, so the id goes under another key.
audit('payment_webhook_applied', payment_event=event.event_id,
status=event.status, outcome=outcome)
return {'status': 'applied', 'outcome': outcome}
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
provider's own form. Asking twice for the same method returns the same
payment; a quote already paid returns 409."""
rate_limit('payment-intent', str(session_id), 30, 900)
with db.connect() as c:
try:
quote = payments.approved_quote(c, body.quote_id, session_id)
except payments.PaymentRefused as refusal:
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
if c.execute('SELECT 1 FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone():
raise HTTPException(409, 'Quote is already paid')
# Never a second charge: an approved payment is waiting for its
# notification to become the order, and a card in review may still be.
# A card waiting for the bank's confirmation (3-D Secure) blocks only
# for CHALLENGE_MINUTES: a customer who gave up on it must still be able
# to pay, and an unanswered challenge is not charged.
if c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s
AND (status='approved' OR (method='card' AND status='pending'
AND NOT (jsonb_typeof(response->'challenge')='object'
AND created_at < now() - make_interval(mins => %s))))''',
(body.quote_id, CHALLENGE_MINUTES)).fetchone():
raise HTTPException(409, 'A payment for this quote is already approved or in review')
method = body.method.model_dump()
if body.method.type == 'pix':
# One open PIX per quote: the same code until it expires, and a new
# one only after that, when the old code can no longer be paid.
# Serialised per quote, so two clicks never open two codes.
c.execute('SELECT pg_advisory_xact_lock(hashtext(%s))', ('pix:'+str(body.quote_id),))
existing = c.execute('''SELECT *, COALESCE((response->>'expires_at')::timestamptz <= now(), false) AS expired
FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'
AND status='pending' ORDER BY created_at DESC LIMIT 1''', (body.quote_id,)).fetchone()
if existing and not existing['expired']:
return existing['response']
if existing:
c.execute("UPDATE dtf_local.payment_intents SET status='expired', updated_at=now() WHERE id=%s",
(existing['id'],))
method['attempt'] = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_intents
WHERE quote_id=%s AND method='pix' ''', (body.quote_id,)).fetchone()['n'] + 1
try:
created = payment.create(str(body.quote_id), quote['approved']['total_cents'],
quote['approved']['customer'], method)
except ValueError as exc:
raise HTTPException(422, str(exc))
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)
ON CONFLICT(provider,provider_payment_id) DO NOTHING''',
(uuid4(), body.quote_id, payment.name, created['id'], body.method.type,
created['status'], quote['approved']['total_cents'], Jsonb(created)))
return created