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