Close the gaps between the documented contract (API-STANDARD, TENANCY, ENTITLEMENTS) and the implementation, and remove committed build artifacts. Security: - verify + process inbound webhooks (HMAC/handler verify, size limit, timestamp tolerance, idempotency via InboundEvent.external_id) - real 2FA login flow (pre-auth challenge; tokens only after verify/recovery) - wire HybridPermission into security/audit views; add API-key rotate and users/<id>/permissions|roles endpoints - tenant-scoped throttling on by default; webhook replay protection - verify MercadoPago webhook signatures - login brute-force guard, configurable password policy, real ALTCHA PoW Correctness: - apply verified billing webhooks idempotently (subscription/entitlement/ invoice/PaymentTransaction); scheduled payment lifecycle jobs - capture audit update diffs automatically; add audit retention purge - working notification retries, per-channel rate limits, log retention - pluggable virus scanner, upload-size limit, pipeline toggle - feature rollout %/environment targeting; settings-driven registrations - workflow guards (instance cap, route depth, self-assignment, clone on re-entry) - wire every previously-dead INFRASYNTH_* setting; drop truly dead ones Delivery: - README + CHANGELOG; CI format check + coverage gate - keep test media out of the tree; untrack .coverage, __pycache__, egg-info, docs/ and invoice artifacts
161 lines
6.2 KiB
Python
161 lines
6.2 KiB
Python
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)
|