All checks were successful
Build and deploy / Validate source (push) Successful in 11s
Build and deploy / Integration suite on a real stack (push) Successful in 2m50s
Build and deploy / Secret scan and release gate (push) Successful in 8s
Build and deploy / Publish images (push) Successful in 1m54s
A PIX is created with a 30-minute date_of_expiration, and the PIX page counts down to it. When it runs out the page says the code expired and offers a new one. The API keeps one open code per quote: the same code until it expires, then exactly one new attempt (serialised per quote, with its own idempotency key), the old one marked expired. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
5.9 KiB
Python
117 lines
5.9 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')
|
|
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
|