feat: offer credit card, debit card and PIX, with the bank's 3-D Secure step
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m3s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m31s

The payment page lists credit card (preselected), debit card and PIX. Each
card option limits Mercado Pago's form to its kind; debit is paid at once.
Card payments ask for 3-D Secure when the issuer requires it, and a
challenge opens the bank's page in a frame, which needs
PAYMENT_CHALLENGE_SOURCES=https: (frames and form posts only). A card left
waiting for that confirmation stops blocking a new attempt after ten
minutes, and a refusal reported by the notification returns the customer to
the payment choice. Written from the documentation; not yet run with a real
debit card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-28 13:24:06 -03:00
parent 7b6675d4d4
commit eae3dad306
11 changed files with 86 additions and 12 deletions

View File

@@ -21,6 +21,8 @@ router = APIRouter()
# Generous: a provider legitimately retries, and a signature check is cheap.
# This exists so an unsigned flood cannot keep the database busy.
WEBHOOK_LIMIT = 600
# How long a card waiting for the bank's confirmation holds off a new attempt.
CHALLENGE_MINUTES = 10
@router.post('/api/payments/webhook')
@@ -73,8 +75,14 @@ def intent(body: PaymentIntent, session_id=Depends(owner)):
raise HTTPException(409, 'Quote is already paid')
# Never a second charge: an approved payment is waiting for its
# notification to become the order, and a card in review may still be.
# A card waiting for the bank's confirmation (3-D Secure) blocks only
# for CHALLENGE_MINUTES: a customer who gave up on it must still be able
# to pay, and an unanswered challenge is not charged.
if c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s
AND (status='approved' OR (method='card' AND status='pending'))''', (body.quote_id,)).fetchone():
AND (status='approved' OR (method='card' AND status='pending'
AND NOT (jsonb_typeof(response->'challenge')='object'
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')
if body.method.type == 'pix':
existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'

View File

@@ -73,8 +73,11 @@ class MercadoPagoPayment:
if method['type'] == 'pix':
body['payment_method_id'] = 'pix'
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.
body.update(token=method['token'], payment_method_id=method['payment_method_id'],
installments=int(method.get('installments', 1)))
installments=int(method.get('installments', 1)),
three_d_secure_mode='optional')
if method.get('issuer_id'):
body['issuer_id'] = method['issuer_id']
else:
@@ -88,13 +91,20 @@ class MercadoPagoPayment:
response.raise_for_status()
payment = response.json()
transaction = (payment.get('point_of_interaction') or {}).get('transaction_data') or {}
# A payment waiting for 3-D Secure carries the bank's challenge page,
# which the Site shows in a frame by posting `creq` to that address.
three_ds = payment.get('three_ds_info') or {}
challenge = ({'url': three_ds['external_resource_url'], 'creq': three_ds['creq']}
if payment.get('status_detail') == 'pending_challenge'
and three_ds.get('external_resource_url') and three_ds.get('creq') else None)
return {'provider': self.name, 'id': str(payment['id']),
'status': STATUSES.get(payment.get('status'), 'pending'),
'status_detail': payment.get('status_detail'),
'total_cents': total_cents,
'pix_qr_code': transaction.get('qr_code'),
'pix_qr_code_base64': transaction.get('qr_code_base64'),
'ticket_url': transaction.get('ticket_url')}
'ticket_url': transaction.get('ticket_url'),
'challenge': challenge}
def lookup(self, payment_id: str) -> dict:
response = self.http.get(f'/v1/payments/{payment_id}')

View File

@@ -72,12 +72,17 @@ def upload_row(c, upload_id, session_id, lock=False):
def quote_view(c, row):
from .quote_review import review_reason # it imports this module
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
# The latest payment attempt, so the payment page can tell a refused card
# (the notification updates it) from one still waiting.
attempt = c.execute('''SELECT method,status,response->>'status_detail' AS status_detail
FROM dtf_local.payment_intents WHERE quote_id=%s ORDER BY created_at DESC LIMIT 1''', (row['id'],)).fetchone()
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
return {'id': row['id'], 'created_at': row['created_at'],
'draft': row['draft'], 'approved': row['approved'],
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
'auto_approved': row.get('reviewed_by') == 'auto',
'review_reason': None if row['approved'] else review_reason(row['draft']),
'payment': attempt,
'order': order}