Files
dtf-system/app/mercadopago.py
Cauê Faleiros bd56170248 feat: let production select Mercado Pago and acknowledge simulated notifications
The production compose hard-coded the fake payment adapter; it now takes
PAYMENT_ADAPTER and the MP_* settings from the stack's environment, so the
sandbox can run with test credentials. A signed notification about a payment
Mercado Pago does not have, such as the panel's "Simular notificação", is
acknowledged instead of answering 500 and being retried; any other lookup
failure still raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:41:18 -03:00

173 lines
8.7 KiB
Python

"""Mercado Pago: payment creation, webhook verification and status lookup.
Written from the public API documentation and exercised only against a fake
HTTP transport. It is not a verified integration until it has passed the
sandbox flows in docs/PRODUCTION_INPUTS.md with the client's own account;
until then the runtime refuses to select it without explicit credentials.
The notification is only a pointer. Its body says "payment 123 changed" and
nothing about amount or status, so nothing in it is trusted beyond the id:
the payment is fetched from the API with our access token, and that response
is what the order service compares against the approved quote.
Signature (x-signature: "ts=<unix>,v1=<hex>"): HMAC-SHA256, keyed with the
webhook secret from the integration panel, over the manifest
"id:<data.id>;request-id:<x-request-id>;ts:<ts>;", where data.id comes from
the notification URL's query string (lower-cased when alphanumeric). A part
whose value is absent from the notification is left out of the manifest.
"""
import hashlib
import hmac
import json
import os
import time
from decimal import Decimal, InvalidOperation
from typing import Mapping
import httpx
from .adapters import PaymentEvent
API = 'https://api.mercadopago.com'
# How old a signed timestamp may be. Mercado Pago retries a failed delivery
# every 15 minutes, re-signing each attempt, so a replayed old one is refused.
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'}
class MercadoPagoPayment:
name = 'mercadopago'
def __init__(self, access_token=None, webhook_secret=None, notification_url=None,
transport=None, clock=time.time):
self.access_token = access_token or os.environ.get('MP_ACCESS_TOKEN', '')
self.webhook_secret = (webhook_secret or os.environ.get('MP_WEBHOOK_SECRET', '')).encode()
self.notification_url = notification_url or os.environ.get('MP_NOTIFICATION_URL', '')
if not self.access_token or not self.webhook_secret:
raise RuntimeError('Mercado Pago needs MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET')
self.http = httpx.Client(base_url=API, transport=transport, timeout=15,
headers={'Authorization': f'Bearer {self.access_token}'})
self.clock = clock
# Payments ------------------------------------------------------------
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
"""Create a payment for an approved quote.
The quote id is the idempotency key, so a retry after a timeout returns
the payment already created rather than charging again. `method` is
{'type': 'pix'} or {'type': 'card', 'token', 'payment_method_id',
'installments', 'issuer_id'} from Mercado Pago's card form: card data is
tokenised in the customer's browser and never reaches this server.
"""
method = method or {'type': 'pix'}
body = {'transaction_amount': float(Decimal(total_cents) / 100),
'description': f'DTF - cotação {quote_id[:8]}',
'external_reference': quote_id,
'payer': {'email': customer['mail'],
'identification': {'type': 'CNPJ', 'number': customer['cnpj']}}}
if self.notification_url:
body['notification_url'] = self.notification_url
if method['type'] == 'pix':
body['payment_method_id'] = 'pix'
elif method['type'] == 'card':
body.update(token=method['token'], payment_method_id=method['payment_method_id'],
installments=int(method.get('installments', 1)))
if method.get('issuer_id'):
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 \
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()
payment = response.json()
transaction = (payment.get('point_of_interaction') or {}).get('transaction_data') or {}
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')}
def lookup(self, payment_id: str) -> dict:
response = self.http.get(f'/v1/payments/{payment_id}')
response.raise_for_status()
return response.json()
# Webhooks ------------------------------------------------------------
def verify(self, headers: Mapping[str, str], body: bytes, query: Mapping[str, str] | None = None) -> bool:
signature = headers.get('x-signature') or ''
parts = dict(part.strip().split('=', 1) for part in signature.split(',') if '=' in part)
ts, supplied = parts.get('ts', ''), parts.get('v1', '')
if not ts.isdigit() or not supplied:
return False
if abs(self.clock() - int(ts[:10])) > MAX_SIGNATURE_AGE:
return False
data_id = (query or {}).get('data.id', '')
if data_id.isalnum():
data_id = data_id.lower()
manifest = ''
if data_id:
manifest += f'id:{data_id};'
request_id = headers.get('x-request-id') or ''
if request_id:
manifest += f'request-id:{request_id};'
manifest += f'ts:{ts};'
expected = hmac.new(self.webhook_secret, manifest.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, supplied.lower())
def parse(self, body: bytes, query: Mapping[str, str] | None = None) -> PaymentEvent | None:
try:
data = json.loads(body)
except ValueError:
return None
if not isinstance(data, dict) or data.get('type') != 'payment':
return None
payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '')
if not payment_id.isdigit():
return None
try:
payment = self.lookup(payment_id)
except httpx.HTTPStatusError as error:
# Signed by Mercado Pago but about no payment of ours, such as the
# panel's "Simular notificação". Anything else is raised so that a
# real notification is retried.
if error.response.status_code == 404:
return None
raise
return event_from_payment(payment, notification_id=str(data.get('id', '')))
def event_from_payment(payment: dict, notification_id: str = '') -> PaymentEvent:
"""Normalise a payment fetched from the API. Amount is None unless it is BRL
and a whole number of centavos, so a foreign or malformed amount is refused."""
amount = None
if payment.get('currency_id') == 'BRL':
try:
cents = Decimal(str(payment.get('transaction_amount'))) * 100
if cents == cents.to_integral_value():
amount = int(cents)
except (InvalidOperation, TypeError, ValueError):
amount = None
status = STATUSES.get(payment.get('status'), 'pending')
# One event per payment state: a notification id alone would let the same
# approval be applied twice under two notifications, and would collapse a
# later refund into the earlier approval.
event_id = f"{payment.get('id')}:{payment.get('status')}"
return PaymentEvent(event_id=event_id, reference=str(payment.get('external_reference') or ''),
status=status, amount_cents=amount,
raw={'provider': 'mercadopago', 'payment_id': str(payment.get('id')),
'status': payment.get('status'), 'status_detail': payment.get('status_detail'),
'currency_id': payment.get('currency_id'),
'transaction_amount': payment.get('transaction_amount'),
'date_approved': payment.get('date_approved'),
'notification_id': notification_id})