infrasynth-backend-kit/infrasynth/billing/gateways/wompi.py
jcv-dev 551b42eab5 feat: production-hardening pass across the kit
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
2026-09-24 10:41:21 -05:00

149 lines
5.9 KiB
Python

import hashlib
import hmac
import json
import logging
import requests
from infrasynth.shared.enums import SubscriptionStatus
from .base import BasePaymentGateway, CheckoutSessionResult, WebhookResult
logger = logging.getLogger(__name__)
class WompiGateway(BasePaymentGateway):
"""Wompi (Colombia) payment gateway.
Configuration keys (read from ``PaymentGateway.config``):
- ``public_key`` (required for checkout sessions)
- ``secret_key`` (required for voiding transactions)
- ``webhook_secret`` (used to verify inbound webhook signatures)
- ``environment`` ("sandbox" | "production", default "sandbox")
- ``base_url`` (optional override)
"""
gateway_slug = "wompi"
BASE_URLS = {
"production": "https://production.wompi.co/v1",
"sandbox": "https://sandbox.wompi.co/v1",
}
STATUS_MAP = {
"APPROVED": SubscriptionStatus.ACTIVE,
"PENDING": SubscriptionStatus.PAST_DUE,
"VOIDED": SubscriptionStatus.CANCELLED,
"DECLINED": SubscriptionStatus.PAST_DUE,
"ERROR": SubscriptionStatus.PAST_DUE,
}
def __init__(self, config: dict | None = None):
config = {str(key).lower(): value for key, value in (config or {}).items()}
self.public_key = config.get("public_key")
self.secret_key = config.get("secret_key")
self.webhook_secret = config.get("webhook_secret")
environment = config.get("environment", "sandbox")
self.base_url = config.get("base_url") or self.BASE_URLS.get(environment, self.BASE_URLS["sandbox"])
self.timeout = config.get("timeout") or 30
def create_checkout_session(self, plan, user=None, *, tenant=None, **kwargs) -> CheckoutSessionResult:
if not self.public_key:
raise ValueError("Wompi public key not configured")
payload = {
"name": plan.name,
"amount_in_cents": int(plan.price_amount),
"currency": plan.price_currency.lower(),
"single_use": True,
"redirect_url": kwargs.get("success_url") or "https://example.com/success",
"customer_email": getattr(user, "email", None) or None,
}
try:
response = requests.post(
f"{self.base_url}/payment_links",
headers={"Authorization": f"Bearer {self.public_key}"},
json=payload,
timeout=self.timeout,
)
except requests.RequestException as exc:
raise ValueError(f"Wompi request failed: {exc}") from exc
if response.status_code >= 400:
raise ValueError(f"Wompi error {response.status_code}: {response.text[:500]}")
data = response.json().get("data") or {}
return CheckoutSessionResult(
session_id=data.get("id", ""),
checkout_url=data.get("url", ""),
client_secret="",
)
def handle_webhook(self, payload, headers) -> WebhookResult:
event_type = payload.get("event") or "transaction.updated"
data = payload.get("data") or payload
is_handled = True
if self.webhook_secret:
signature = headers.get("x-signature") or headers.get("X-Signature") or ""
raw_body = json.dumps(payload, separators=(",", ":"))
expected = hmac.new(self.webhook_secret.encode(), raw_body.encode(), hashlib.sha256).hexdigest()
is_handled = hmac.compare_digest(signature, expected)
return WebhookResult(event_type=event_type, is_handled=is_handled, data=data)
def cancel_subscription(self, subscription) -> bool:
if not self.secret_key or not subscription.external_id:
return False
try:
response = requests.post(
f"{self.base_url}/transactions/{subscription.external_id}/void",
headers={"Authorization": f"Bearer {self.secret_key}"},
timeout=self.timeout,
)
except requests.RequestException:
logger.exception("Wompi void request failed for %s", subscription.external_id)
return False
return response.status_code == 200
def sync_subscription(self, subscription) -> dict:
if not self.public_key or not subscription.external_id:
return {}
try:
response = requests.get(
f"{self.base_url}/transactions/{subscription.external_id}",
headers={"Authorization": f"Bearer {self.public_key}"},
timeout=self.timeout,
)
except requests.RequestException:
logger.exception("Wompi transaction request failed for %s", subscription.external_id)
return {}
if response.status_code != 200:
return {}
data: dict = response.json().get("data") or {}
status: str | None = data.get("status")
return {
"status": self.STATUS_MAP.get(status or "", status),
"metadata": data.get("metadata") or {},
}
def get_invoice(self, invoice) -> dict:
if not self.public_key or not invoice.external_id:
return {}
try:
response = requests.get(
f"{self.base_url}/transactions/{invoice.external_id}",
headers={"Authorization": f"Bearer {self.public_key}"},
timeout=self.timeout,
)
except requests.RequestException:
logger.exception("Wompi transaction request failed for %s", invoice.external_id)
return {}
if response.status_code != 200:
return {}
data = response.json().get("data") or {}
return {
"external_id": data.get("id"),
"status": data.get("status"),
"amount": (data.get("amount_in_cents") or 0) / 100,
"currency": (data.get("currency") or "cop").upper(),
"payment_method": data.get("payment_method", {}).get("type", ""),
}
def health_check(self) -> bool:
return bool(self.public_key)