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: def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
kind = (method or {}).get('type', 'pix') 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}', return {'provider': 'fake', 'id': f'local-{quote_id}-{kind}',
'status': 'pending', 'total_cents': total_cents} '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))))''', AND created_at < now() - make_interval(mins => %s))))''',
(body.quote_id, CHALLENGE_MINUTES)).fetchone(): (body.quote_id, CHALLENGE_MINUTES)).fetchone():
raise HTTPException(409, 'A payment for this quote is already approved or in review') raise HTTPException(409, 'A payment for this quote is already approved or in review')
method = body.method.model_dump()
if body.method.type == 'pix': 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() 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'] 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: try:
created = payment.create(str(body.quote_id), quote['approved']['total_cents'], 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: except ValueError as exc:
raise HTTPException(422, str(exc)) raise HTTPException(422, str(exc))
except Exception: except Exception:

View File

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

View File

@@ -12,6 +12,7 @@ from urllib.error import HTTPError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from uuid import uuid4 from uuid import uuid4
from app.core import db
from tests.smoke_test import BASE, Client, approved_quote, upload_bytes, item_spec, with_host from tests.smoke_test import BASE, Client, approved_quote, upload_bytes, item_spec, with_host
SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode() SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode()
@@ -60,7 +61,22 @@ def run():
# Starting a PIX twice returns the same one. A card in review blocks every # Starting a PIX twice returns the same one. A card in review blocks every
# further attempt, so one quote can never be charged twice. # further attempt, so one quote can never be charged twice.
pix = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}}) pix = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})
assert pix['expires_at']
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == pix['id'] assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == pix['id']
# Once the code has expired, asking again opens a new one, and only one.
with db.connect() as c:
c.execute('''UPDATE dtf_local.payment_intents
SET response=jsonb_set(response,'{expires_at}',to_jsonb((now()-interval '1 minute')::text))
WHERE provider_payment_id=%s''', (pix['id'],))
renewed = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})
assert renewed['id'] != pix['id'], 'an expired PIX was offered again'
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == renewed['id']
with db.connect() as c:
statuses = {r['provider_payment_id']: r['status'] for r in c.execute(
"SELECT provider_payment_id,status FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'",
(quote_id,)).fetchall()}
assert statuses == {pix['id']: 'expired', renewed['id']: 'pending'}, statuses
print('PASS: a PIX code expires after 30 minutes and is replaced by exactly one new code')
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'card'}}, expected=422) customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'card'}}, expected=422)
card = {'type': 'card', 'token': 'tok-1', 'payment_method_id': 'visa', 'installments': 1} card = {'type': 'card', 'token': 'tok-1', 'payment_method_id': 'visa', 'installments': 1}
customer.call('/payments/intent', {'quote_id': quote_id, 'method': card}) customer.call('/payments/intent', {'quote_id': quote_id, 'method': card})

View File

@@ -8,6 +8,7 @@ import hashlib
import hmac import hmac
import json import json
import unittest import unittest
from datetime import datetime, timezone
import httpx import httpx
@@ -110,11 +111,20 @@ class MercadoPagoTests(unittest.TestCase):
request = self.requests[-1] request = self.requests[-1]
body = json.loads(request.content) body = json.loads(request.content)
self.assertEqual(request.headers['x-idempotency-key'], self.assertEqual(request.headers['x-idempotency-key'],
'dtf-quote-11111111-2222-3333-4444-555555555555-pix') 'dtf-quote-11111111-2222-3333-4444-555555555555-pix-1')
self.assertEqual((body['payment_method_id'], body['transaction_amount']), ('pix', 123.45)) self.assertEqual((body['payment_method_id'], body['transaction_amount']), ('pix', 123.45))
self.assertEqual(body['external_reference'], '11111111-2222-3333-4444-555555555555') self.assertEqual(body['external_reference'], '11111111-2222-3333-4444-555555555555')
self.assertEqual(body['notification_url'], 'https://dtf.example/api/payments/webhook') self.assertEqual(body['notification_url'], 'https://dtf.example/api/payments/webhook')
self.assertEqual((created['pix_qr_code'], created['status']), ('000201PIX', 'pending')) self.assertEqual((created['pix_qr_code'], created['status']), ('000201PIX', 'pending'))
# The code expires in 30 minutes, in the format Mercado Pago documents.
expires = datetime.fromisoformat(body['date_of_expiration'])
self.assertRegex(body['date_of_expiration'], r'^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}-03:00$')
self.assertAlmostEqual((expires - datetime.now(timezone.utc)).total_seconds(), 1800, delta=60)
self.assertEqual(created['expires_at'], body['date_of_expiration'])
# A new code after the last expired is the next attempt, not the same payment.
self.mp.create('11111111-2222-3333-4444-555555555555', 12345,
{'mail': 'a@example.test', 'cnpj': '11222333000181'}, {'type': 'pix', 'attempt': 2})
self.assertTrue(self.requests[-1].headers['x-idempotency-key'].endswith('-pix-2'))
def test_card_payment_uses_the_browser_token_only(self): def test_card_payment_uses_the_browser_token_only(self):
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'}, self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},

View File

@@ -7,6 +7,7 @@
const naPagina = () => ['pagamento','pix'].includes(document.documentElement.dataset.rota); const naPagina = () => ['pagamento','pix'].includes(document.documentElement.dataset.rota);
const naPix = () => document.documentElement.dataset.rota === 'pix'; const naPix = () => document.documentElement.dataset.rota === 'pix';
let shownCart = null; let shownCart = null;
let pixClock = null;
let busy = false; let busy = false;
let draftId = localStorage.getItem('dtf-quote'); let draftId = localStorage.getItem('dtf-quote');
let requestKey = localStorage.getItem('dtf-request-key'); let requestKey = localStorage.getItem('dtf-request-key');
@@ -162,6 +163,7 @@
if(version!==refreshVersion || draftId!==shownId) return; if(version!==refreshVersion || draftId!==shownId) return;
if (!naPagina()) { message(''); vaiPara(PAGAMENTO); return; } if (!naPagina()) { message(''); vaiPara(PAGAMENTO); return; }
shownCart=cartSnapshot(); shownCart=cartSnapshot();
clearInterval(pixClock);
unmountCard(); unmountCard();
actions.replaceChildren(); actions.replaceChildren();
message(''); message('');
@@ -319,9 +321,26 @@
try{ await navigator.clipboard.writeText(intent.pix_qr_code); copiar.textContent='Código copiado'; } try{ await navigator.clipboard.writeText(intent.pix_qr_code); copiar.textContent='Código copiado'; }
catch(_){ code.select(); } catch(_){ code.select(); }
},linha); },linha);
box.append(linha,node('p','Aguardando a confirmação do pagamento…','aguarda')); const prazo=node('p',null,'prazo');
box.append(linha,prazo,node('p','Aguardando a confirmação do pagamento…','aguarda'));
actions.append(box); actions.append(box);
waitForOrder(); waitForOrder();
// The code stops working when it expires; a new one is a click away.
const fim=Date.parse(intent.expires_at);
if (!Number.isFinite(fim)) { prazo.remove(); return; }
const tick=()=>{
const s=Math.max(0,Math.ceil((fim-Date.now())/1000));
prazo.textContent='Pague em '+String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');
if (s>0) return;
clearInterval(pixClock);
const fimBox=node('div',null,'pixBox');
fimBox.append(node('h4','O código PIX expirou'),
node('p','Gere um novo código para pagar. O anterior não pode mais ser pago.','pagNota'));
button('Gerar novo PIX',()=>refresh(),fimBox);
actions.replaceChildren(fimBox);
};
tick();
pixClock=setInterval(tick,1000);
} }
// Card: Mercado Pago's own form (Card Payment Brick). The card is typed into // Card: Mercado Pago's own form (Card Payment Brick). The card is typed into
@@ -399,7 +418,7 @@
// The cart page keeps only the sending progress and errors, never a payment. // The cart page keeps only the sending progress and errors, never a payment.
window.addEventListener('dtf-page-changed',()=>{ window.addEventListener('dtf-page-changed',()=>{
if (naPagina()) refresh(); if (naPagina()) refresh();
else if (!busy) { refreshVersion++; message(''); actions.replaceChildren(); pintaResumo(null); } else if (!busy) { refreshVersion++; clearInterval(pixClock); message(''); actions.replaceChildren(); pintaResumo(null); }
}); });
refresh(); refresh();
})(); })();

View File

@@ -254,6 +254,7 @@ html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{display:grid;grid-te
.pixBox .codigo{display:flex;gap:10px;width:100%} .pixBox .codigo{display:flex;gap:10px;width:100%}
.pixBox .codigo input{flex:1;min-width:0;font-size:13px} .pixBox .codigo input{flex:1;min-width:0;font-size:13px}
.pixBox .aguarda{font-size:14px;color:var(--texto2)} .pixBox .aguarda{font-size:14px;color:var(--texto2)}
.pixBox .prazo{font-family:"Inter",sans-serif;font-weight:800;font-size:18px;font-variant-numeric:tabular-nums}
.confirmado{border:1px solid #BFE6CE;background:var(--verde-fundo);border-radius:16px;padding:24px} .confirmado{border:1px solid #BFE6CE;background:var(--verde-fundo);border-radius:16px;padding:24px}
.confirmado h4{font-size:22px;color:#14532D;margin-bottom:8px} .confirmado h4{font-size:22px;color:#14532D;margin-bottom:8px}
.confirmado p{font-size:15px;color:#14532D;margin-bottom:18px;line-height:1.5} .confirmado p{font-size:15px;color:#14532D;margin-bottom:18px;line-height:1.5}