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>
138 lines
7.0 KiB
Python
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
|