import logging from infrasynth.shared.enums import SubscriptionStatus from .base import BasePaymentGateway, CheckoutSessionResult, WebhookResult logger = logging.getLogger(__name__) class MercadoPagoGateway(BasePaymentGateway): """MercadoPago payment gateway. Configuration keys (read from ``PaymentGateway.config``): - ``access_token`` (required for most operations) """ gateway_slug = "mercadopago" STATUS_MAP = { "authorized": SubscriptionStatus.ACTIVE, "pending": SubscriptionStatus.PAST_DUE, "paused": SubscriptionStatus.PAST_DUE, "cancelled": SubscriptionStatus.CANCELLED, } def __init__(self, config: dict | None = None): config = {str(key).lower(): value for key, value in (config or {}).items()} self.access_token = config.get("access_token") self.webhook_secret = config.get("webhook_secret") self._sdk = None @property def sdk(self): if self._sdk is None: import mercadopago self._sdk = mercadopago.SDK(self.access_token) return self._sdk def create_checkout_session(self, plan, user=None, *, tenant=None, **kwargs) -> CheckoutSessionResult: self._require_credentials() preference = { "items": [ { "title": plan.name, "quantity": 1, "currency_id": plan.price_currency, "unit_price": int(plan.price_amount), } ], "back_urls": { "success": kwargs.get("success_url") or "https://example.com/success", "failure": kwargs.get("cancel_url") or "https://example.com/cancel", "pending": kwargs.get("cancel_url") or "https://example.com/cancel", }, "auto_return": "approved", "notification_url": kwargs.get("notification_url") or "", "metadata": { "plan_slug": plan.slug, "app_slug": getattr(getattr(plan, "app", None), "slug", ""), "user_id": str(getattr(user, "pk", "")), "tenant_id": str(getattr(tenant, "pk", "")), }, } result = self.sdk.preference().create(preference) if result.get("status") != 201: raise ValueError(f"MercadoPago error: {result.get('response')}") response = result["response"] return CheckoutSessionResult( session_id=response["id"], checkout_url=response.get("init_point") or "", client_secret="", ) def handle_webhook(self, payload, headers) -> WebhookResult: event_type = payload.get("type") or payload.get("action") or "payment" data = payload.get("data") or payload # MercadoPago signs a manifest of id/request-id/ts with HMAC-SHA256. if self.webhook_secret: if not self._verify_signature(payload, headers): return WebhookResult(event_type=event_type, is_handled=False, data=data) return WebhookResult( event_type=event_type, is_handled=True, data=data, ) def _verify_signature(self, payload, headers) -> bool: import hashlib import hmac secret = self.webhook_secret or "" signature = headers.get("x-signature") or headers.get("X-Signature") or "" request_id = headers.get("x-request-id") or headers.get("X-Request-Id") or "" parts = dict(part.split("=", 1) for part in signature.split(",") if "=" in part) ts = parts.get("ts", "") v1 = parts.get("v1", "") if not ts or not v1: return False data_id = str((payload.get("data") or {}).get("id") or "") manifest = f"id:{data_id};request-id:{request_id};ts:{ts};" expected = hmac.new(secret.encode(), manifest.encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) def cancel_subscription(self, subscription) -> bool: self._require_credentials() if not subscription.external_id: return False result = self.sdk.preapproval().update(subscription.external_id, {"status": "cancelled"}) return result.get("status") in (200, 201) def sync_subscription(self, subscription) -> dict: self._require_credentials() if not subscription.external_id: return {} result = self.sdk.preapproval().get(subscription.external_id) if result.get("status") != 200: return {} data = result.get("response") or {} raw_status: str | None = data.get("status") if isinstance(data, dict) else None return { "status": self.STATUS_MAP.get(raw_status or "", raw_status), "current_period_start": self._parse_datetime(data.get("date_created")), "current_period_end": self._parse_datetime(data.get("next_payment_date")), "trial_end": self._parse_datetime(data.get("trial_end_date")), "cancel_at_period_end": data.get("auto_recurring", {}).get("end_date") is None and data.get("status") == "cancelled", "metadata": data.get("metadata") or {}, } def get_invoice(self, invoice) -> dict: self._require_credentials() if not invoice.external_id: return {} result = self.sdk.payment().get(invoice.external_id) if result.get("status") != 200: return {} data = result.get("response") or {} return { "external_id": data.get("id"), "status": data.get("status"), "amount": data.get("transaction_amount"), "currency": (data.get("currency_id") or "USD").upper(), "paid_at": self._parse_datetime(data.get("date_approved")), "payment_method": data.get("payment_method_id") or "", } def health_check(self) -> bool: return bool(self.access_token) def _require_credentials(self) -> None: if not self.access_token: raise ValueError("MercadoPago access token not configured") @staticmethod def _parse_datetime(value): if not value: return None from django.utils.dateparse import parse_datetime return parse_datetime(value)