feat: PIX codes expire after 30 minutes, with a countdown
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>
This commit is contained in:
Cauê Faleiros
2026-09-28 15:40:26 -03:00
parent 875a7ef9c7
commit 641aafc87d
7 changed files with 81 additions and 10 deletions

View File

@@ -106,6 +106,11 @@ class FakePayment:
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
kind = (method or {}).get('type', 'pix')
if kind == 'pix':
from datetime import datetime, timedelta, timezone
expires = (datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat(timespec='milliseconds')
return {'provider': 'fake', 'id': f"local-{quote_id}-pix-{(method or {}).get('attempt', 1)}",
'status': 'pending', 'total_cents': total_cents, 'expires_at': expires}
return {'provider': 'fake', 'id': f'local-{quote_id}-{kind}',
'status': 'pending', 'total_cents': total_cents}

View File

@@ -84,14 +84,25 @@ def intent(body: PaymentIntent, session_id=Depends(owner)):
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':
existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method='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:
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'], body.method.model_dump())
quote['approved']['customer'], method)
except ValueError as exc:
raise HTTPException(422, str(exc))
except Exception:

View File

@@ -21,6 +21,7 @@ import hmac
import json
import os
import time
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from typing import Mapping
@@ -35,6 +36,9 @@ MAX_SIGNATURE_AGE = 30 * 60
STATUSES = {'approved': 'approved', 'pending': 'pending', 'in_process': 'pending',
'authorized': 'pending', 'in_mediation': 'pending', 'rejected': 'rejected',
'cancelled': 'cancelled', 'refunded': 'refunded', 'charged_back': 'refunded'}
# A PIX code stops working after this; Mercado Pago then cancels the payment.
PIX_MINUTES = 30
BRASILIA = timezone(timedelta(hours=-3))
class MercadoPagoPayment:
@@ -70,8 +74,11 @@ class MercadoPagoPayment:
'identification': {'type': 'CNPJ', 'number': customer['cnpj']}}}
if self.notification_url:
body['notification_url'] = self.notification_url
expires_at = None
if method['type'] == 'pix':
body['payment_method_id'] = 'pix'
expires_at = (datetime.now(BRASILIA) + timedelta(minutes=PIX_MINUTES)).isoformat(timespec='milliseconds')
body['date_of_expiration'] = expires_at
elif method['type'] == 'card':
# The issuer decides whether the cardholder must confirm in the
# bank's app or page (3-D Secure); debit cards usually must.
@@ -82,10 +89,11 @@ class MercadoPagoPayment:
body['issuer_id'] = method['issuer_id']
else:
raise ValueError('Unsupported payment method')
# A PIX retry must return the same code. A card retry after a decline
# is a new attempt with a new token, so the token is part of the key;
# the intent route refuses new attempts once one is approved or in review.
key = f'dtf-quote-{quote_id}-pix' if method['type'] == 'pix' else \
# A PIX retry must return the same code; a new one, after the last
# expired, is the next attempt. A card retry after a decline is a new
# attempt with a new token, so the token is part of the key; the intent
# route refuses new attempts once one is approved or in review.
key = f"dtf-quote-{quote_id}-pix-{int(method.get('attempt', 1))}" if method['type'] == 'pix' else \
f"dtf-quote-{quote_id}-card-{hashlib.sha256(method['token'].encode()).hexdigest()[:24]}"
response = self.http.post('/v1/payments', json=body, headers={'X-Idempotency-Key': key})
response.raise_for_status()
@@ -104,6 +112,7 @@ class MercadoPagoPayment:
'pix_qr_code': transaction.get('qr_code'),
'pix_qr_code_base64': transaction.get('qr_code_base64'),
'ticket_url': transaction.get('ticket_url'),
'expires_at': payment.get('date_of_expiration') or expires_at,
'challenge': challenge}
def lookup(self, payment_id: str) -> dict: