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
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>
165 lines
8.4 KiB
Python
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})
|