All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m3s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m31s
The payment page lists credit card (preselected), debit card and PIX. Each card option limits Mercado Pago's form to its kind; debit is paid at once. Card payments ask for 3-D Secure when the issuer requires it, and a challenge opens the bank's page in a frame, which needs PAYMENT_CHALLENGE_SOURCES=https: (frames and form posts only). A card left waiting for that confirmation stops blocking a new attempt after ten minutes, and a refusal reported by the notification returns the customer to the payment choice. Written from the documentation; not yet run with a real debit card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
5.0 KiB
Python
106 lines
5.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
|
|
|
|
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')
|
|
if body.method.type == 'pix':
|
|
existing = c.execute('''SELECT * 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:
|
|
return existing['response']
|
|
try:
|
|
created = payment.create(str(body.quote_id), quote['approved']['total_cents'],
|
|
quote['approved']['customer'], body.method.model_dump())
|
|
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
|