Files
dtf-system/app/api/payments.py
Cauê Faleiros 933bd30cbd feat: an unpaid cart's files are kept 2 days, a paid order's 30
Files are uploaded before payment so the price and the security check use
the file itself, but an abandoned cart kept them for 30 days. Now a finished
upload is held 2 days, a quote waiting for review 7, an approved quote 2 more
to be paid, and the paid order keeps its originals for 30 days from upload.
A payment never starts for files that are gone; one under way holds them a
day. Files attached to an order take the order's window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:09:02 -03:00

148 lines
7.7 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 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