diff --git a/app/adapters.py b/app/adapters.py index 19172e7..0d6336a 100644 --- a/app/adapters.py +++ b/app/adapters.py @@ -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} diff --git a/app/api/payments.py b/app/api/payments.py index 6ccf1a1..578f24e 100644 --- a/app/api/payments.py +++ b/app/api/payments.py @@ -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: diff --git a/app/mercadopago.py b/app/mercadopago.py index 4b8c747..15b46a7 100644 --- a/app/mercadopago.py +++ b/app/mercadopago.py @@ -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: diff --git a/tests/payment_test.py b/tests/payment_test.py index e38d548..8357b90 100644 --- a/tests/payment_test.py +++ b/tests/payment_test.py @@ -12,6 +12,7 @@ from urllib.error import HTTPError from urllib.request import Request, urlopen from uuid import uuid4 +from app.core import db 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() @@ -60,7 +61,22 @@ def run(): # Starting a PIX twice returns the same one. A card in review blocks every # further attempt, so one quote can never be charged twice. 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'] + # 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) card = {'type': 'card', 'token': 'tok-1', 'payment_method_id': 'visa', 'installments': 1} customer.call('/payments/intent', {'quote_id': quote_id, 'method': card}) diff --git a/tests/test_mercadopago.py b/tests/test_mercadopago.py index d23af0f..5f749f6 100644 --- a/tests/test_mercadopago.py +++ b/tests/test_mercadopago.py @@ -8,6 +8,7 @@ import hashlib import hmac import json import unittest +from datetime import datetime, timezone import httpx @@ -110,11 +111,20 @@ class MercadoPagoTests(unittest.TestCase): request = self.requests[-1] body = json.loads(request.content) 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['external_reference'], '11111111-2222-3333-4444-555555555555') self.assertEqual(body['notification_url'], 'https://dtf.example/api/payments/webhook') 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): self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'}, diff --git a/web/checkout.js b/web/checkout.js index 0f6ba5a..7dc7d83 100644 --- a/web/checkout.js +++ b/web/checkout.js @@ -7,6 +7,7 @@ const naPagina = () => ['pagamento','pix'].includes(document.documentElement.dataset.rota); const naPix = () => document.documentElement.dataset.rota === 'pix'; let shownCart = null; + let pixClock = null; let busy = false; let draftId = localStorage.getItem('dtf-quote'); let requestKey = localStorage.getItem('dtf-request-key'); @@ -162,6 +163,7 @@ if(version!==refreshVersion || draftId!==shownId) return; if (!naPagina()) { message(''); vaiPara(PAGAMENTO); return; } shownCart=cartSnapshot(); + clearInterval(pixClock); unmountCard(); actions.replaceChildren(); message(''); @@ -319,9 +321,26 @@ try{ await navigator.clipboard.writeText(intent.pix_qr_code); copiar.textContent='Código copiado'; } catch(_){ code.select(); } },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); 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 @@ -399,7 +418,7 @@ // The cart page keeps only the sending progress and errors, never a payment. window.addEventListener('dtf-page-changed',()=>{ if (naPagina()) refresh(); - else if (!busy) { refreshVersion++; message(''); actions.replaceChildren(); pintaResumo(null); } + else if (!busy) { refreshVersion++; clearInterval(pixClock); message(''); actions.replaceChildren(); pintaResumo(null); } }); refresh(); })(); diff --git a/web/site-v2.css b/web/site-v2.css index 2c957ac..36615a0 100644 --- a/web/site-v2.css +++ b/web/site-v2.css @@ -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 input{flex:1;min-width:0;font-size:13px} .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 h4{font-size:22px;color:#14532D;margin-bottom:8px} .confirmado p{font-size:15px;color:#14532D;margin-bottom:18px;line-height:1.5}