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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user