"""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 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 @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 Exception: audit('payment_intent_failed', quote=str(body.quote_id)) 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