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
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:
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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'},
|
||||||
|
|||||||
@@ -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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user