"""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 charge for files that are gone: an unpaid cart's files are # removed after a while. A payment under way keeps them a day longer, # time enough for the provider's notice to become the order. uploads = payments.quote_uploads(quote['approved']) live = c.execute('''SELECT count(*) AS n FROM dtf_local.uploads WHERE id=ANY(%s) AND purged_at IS NULL AND expires_at>now()''', (uploads,)).fetchone()['n'] if live != len(set(uploads)): raise HTTPException(410, 'Os arquivos deste pedido expiraram porque ele não foi pago a tempo. ' 'Monte o pedido de novo para pagar.') payments.hold_uploads(c, uploads, '1 day') # 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