from __future__ import annotations import hashlib import hmac import json from datetime import datetime, timezone from typing import Any, Protocol class PaymentWebhookVerificationError(Exception): def __init__(self, signature_status: str, message: str, *, status_code: int = 400) -> None: super().__init__(message) self.signature_status = signature_status self.message = message self.status_code = status_code class PaymentProviderAdapter(Protocol): def get_status(self, external_payment_id: str, *, context: dict[str, Any] | None = None) -> dict[str, Any] | str: ... _PROVIDER_ADAPTERS: dict[str, PaymentProviderAdapter] = {} def register_payment_provider_adapter(provider: str, adapter: PaymentProviderAdapter) -> None: normalized = normalize_provider(provider) if normalized: _PROVIDER_ADAPTERS[normalized] = adapter def get_payment_provider_adapter(provider: str | None) -> PaymentProviderAdapter | None: return _PROVIDER_ADAPTERS.get(normalize_provider(provider)) def normalize_provider(provider: str | None) -> str: return str(provider or "").strip().lower() or "manual" def raw_payload_hash(raw_body: bytes) -> str: return hashlib.sha256(raw_body).hexdigest() def safe_json_loads(raw_body: bytes) -> dict[str, Any]: if not raw_body: return {} parsed = json.loads(raw_body.decode("utf-8")) if not isinstance(parsed, dict): raise ValueError("Webhook payload must be a JSON object") return parsed def normalize_headers(headers: Any) -> dict[str, str]: return {str(key).lower(): str(value) for key, value in dict(headers).items()} def header_value(headers: dict[str, str], *names: str) -> str | None: for name in names: value = headers.get(name.lower()) normalized = str(value or "").strip() if normalized: return normalized return None def metadata_value(payload: dict[str, Any] | None, *keys: str) -> str | None: data = payload or {} metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} candidates = [data, metadata] payment = data.get("payment") if isinstance(data.get("payment"), dict) else {} candidates.append(payment) nested_metadata = payment.get("metadata") if isinstance(payment.get("metadata"), dict) else {} candidates.append(nested_metadata) data_object = data.get("data", {}).get("object") if isinstance(data.get("data"), dict) else {} if isinstance(data_object, dict): candidates.append(data_object) for source in candidates: for key in keys: value = source.get(key) normalized = str(value or "").strip() if normalized: return normalized return None def extract_payment_provider(payload: dict[str, Any], headers: dict[str, str]) -> str: return normalize_provider( header_value(headers, "x-payment-provider", "x-provider") or metadata_value(payload, "payment_provider", "provider") ) def extract_provider_account_id(payload: dict[str, Any], headers: dict[str, str]) -> str | None: return header_value( headers, "x-provider-account-id", "x-merchant-id", "x-terminal-id", "x-integration-id", ) or metadata_value( payload, "provider_account_id", "payment_provider_account_id", "merchant_id", "terminal_id", "account_id", "integration_id", ) def extract_external_event_id(payload: dict[str, Any], headers: dict[str, str]) -> str | None: return header_value(headers, "x-webhook-event-id", "x-event-id") or metadata_value( payload, "external_event_id", "webhook_event_id", "event_id", ) def extract_external_payment_id(payload: dict[str, Any], headers: dict[str, str]) -> str | None: return header_value(headers, "x-payment-id", "x-external-payment-id") or metadata_value( payload, "external_payment_id", "external_payment_ref", "transaction_id", "provider_payment_id", "payment_id", "id", ) def extract_event_type(payload: dict[str, Any], headers: dict[str, str]) -> str: return ( header_value(headers, "x-webhook-event-type", "x-event-type") or metadata_value(payload, "event_type", "type") or normalize_payment_event_type(None, metadata_value(payload, "status", "provider_status")) ) def normalize_payment_status(provider: str | None, provider_status: str | None) -> str: normalized = str(provider_status or "").strip().lower() normalized = normalized.replace("-", "_").replace(" ", "_") if normalized in {"succeeded", "success", "paid", "captured", "settled", "completed"}: return "success" if normalized in {"authorized", "authorised", "processing", "pending", "created", "new"}: return "pending" if normalized in {"failed", "declined", "error", "rejected"}: return "failed" if normalized in {"canceled", "cancelled", "voided", "expired"}: return "canceled" if normalized in {"partial", "partially_paid", "partial_paid"}: return "partial" return normalized if normalized in {"pending", "success", "failed", "canceled", "partial"} else "pending" def normalize_payment_event_type(provider: str | None, provider_status: str | None, event_type: str | None = None) -> str: raw_event_type = str(event_type or "").strip().lower() if raw_event_type.startswith("payment."): if raw_event_type in {"payment.succeeded", "payment.captured", "payment.paid"}: return "payment.received" if raw_event_type in {"payment.cancelled", "payment.canceled"}: return "payment.canceled" return raw_event_type status = normalize_payment_status(provider, provider_status) if status in {"success", "partial"}: return "payment.received" if status == "failed": return "payment.failed" if status == "canceled": return "payment.canceled" return "payment.pending" def _signature_candidates(signature_header: str) -> set[str]: raw = str(signature_header or "").strip() if not raw: return set() candidates = {raw} if raw.startswith("sha256="): candidates.add(raw.split("=", 1)[1]) for part in raw.split(","): key, sep, value = part.partition("=") if sep and key.strip() in {"v1", "sha256"} and value.strip(): candidates.add(value.strip()) return candidates def verify_payment_webhook_signature( *, raw_body: bytes, headers: dict[str, str], secret: str | None, replay_window_seconds: int = 600, now: datetime | None = None, required: bool = True, ) -> str: normalized_secret = str(secret or "").strip() if not required and not normalized_secret: return "not_required" if not normalized_secret: raise PaymentWebhookVerificationError("missing_secret", "Payment webhook secret is not configured", status_code=403) timestamp = header_value(headers, "x-webhook-timestamp", "x-provider-timestamp", "x-timestamp") if timestamp: try: event_ts = int(float(timestamp)) except ValueError as exc: raise PaymentWebhookVerificationError("invalid_timestamp", "Invalid webhook timestamp") from exc now_ts = int((now or datetime.now(timezone.utc)).timestamp()) if abs(now_ts - event_ts) > max(int(replay_window_seconds), 1): raise PaymentWebhookVerificationError("expired", "Webhook timestamp is outside the replay window") signature_header = header_value(headers, "x-webhook-signature", "x-signature", "stripe-signature") if not signature_header: raise PaymentWebhookVerificationError("missing", "Payment webhook signature is missing", status_code=403) body_to_sign = raw_body if timestamp: body_to_sign = f"{timestamp}.".encode("utf-8") + raw_body expected = hmac.new(normalized_secret.encode("utf-8"), body_to_sign, hashlib.sha256).hexdigest() for candidate in _signature_candidates(signature_header): if hmac.compare_digest(candidate, expected): return "valid" raise PaymentWebhookVerificationError("invalid", "Payment webhook signature is invalid", status_code=403)