Files
dtf-system/app/mercadopago.py
Cauê Faleiros 4c01e932c3
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m23s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
feat: place PDF artwork in print files, add card payment, count only failed logins
PDF artwork: a single-page PDF source is placed in the print file as a
vector form through pikepdf, never rasterised, using the CropBox and
inherited /Rotate the Site measured with pdf.js. Multi-page and protected
PDFs go to hand preparation. PyMuPDF was not used because of its AGPL
licence. Raster tests cover crop, page rotation, placement rotation and
mirroring, and fail when the rotation or crop handling is broken.

Card payment: Mercado Pago's Card Payment Brick on the Site when
MP_PUBLIC_KEY is set; the card becomes a one-time token in Mercado Pago's
secure fields. Each card attempt has its own idempotency key, and the intent
route refuses new attempts once a payment is approved or a card is in
review, so a quote cannot be charged twice. The Site CSP admits Mercado
Pago's origins only through PAYMENT_CSP_SOURCES, empty by default.

Logins: every attempt counts against the source address, only failures
against the account. Counting successful sign-ins let ordinary use lock an
operator out and made CI's final browser sign-in fail.

No new required settings; production behaviour is unchanged until the
provider credentials are configured. Verified with the full CI integration
sequence locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:14:56 -03:00

165 lines
8.4 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
payment = self.lookup(payment_id)
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})