"""Gumroad adapter. Receives "Ping" notifications from Gumroad — form-encoded POSTs sent when a sale occurs. Gumroad's Ping URL is configured in the seller dashboard (Settings → Advanced → Ping URL). Authentication -------------- Gumroad does not HMAC-sign the body. Their recommended pattern is to put a secret in the URL itself:: https://licenses.datatools.unalogix.com/webhooks/gumroad?secret=... The webhook receiver pulls the secret from the query string and :meth:`GumroadAdapter.verify_webhook` constant-time-compares it against the configured value. If they don't match, the request is dropped with 404 (so a probing attacker can't tell whether the endpoint exists, much less that it's the wrong secret). The "test" field ---------------- Gumroad sends ``test=true`` on test pings fired from the dashboard. We treat test pings as real sales (they create licenses just like production sales), but tag them with ``notes='gumroad test ping'`` so the operator can filter / delete them later. Refusing test pings would block the standard "Send Test Ping" verification flow. Refunds, disputes, cancellations -------------------------------- Stubbed for now (``parse_refund`` returns None). Gumroad doesn't include refund signals in the standard sale Ping — refunds arrive via the separate "Resource subscriptions" mechanism. Wiring that in is PR 2.1; until then, refunds are handled by the operator running ``datatools-admin revoke``. """ from __future__ import annotations import hmac from decimal import Decimal from typing import Any, Optional from app.adapters.base import RefundEvent, SaleEvent from app.products import lookup as product_lookup class GumroadAdapter: source_name = "gumroad" def __init__(self, secret: Optional[str]) -> None: self._secret = secret # --- Auth ---------------------------------------------------------------- def verify_webhook(self, *, body: bytes, headers: dict[str, str]) -> bool: """Not used — Gumroad authentication is via URL query param, which only the route handler has direct access to. Call :meth:`verify_secret` instead.""" return False def verify_secret(self, presented: Optional[str]) -> bool: """Constant-time compare against the configured secret. Returns False (not an exception) so the route handler can decide the response code — we return 404 to avoid signaling endpoint existence to an unauthenticated prober. """ if not self._secret or not presented: return False return hmac.compare_digest(presented, self._secret) # --- Parsing ------------------------------------------------------------- def parse_sale(self, payload: dict[str, Any]) -> Optional[SaleEvent]: """Parse a Gumroad Ping form-encoded payload into a SaleEvent. Returns None if the payload isn't a sale (e.g. some future event type we don't yet handle). Returns None *with no row side-effect* if the product_id is unmapped — the caller should treat that as an error and record it in the audit row, not silently drop. """ # Sale pings always include sale_id (the order ID) and email. sale_id = payload.get("sale_id") email = payload.get("email") product_id = ( payload.get("product_id") or payload.get("product_permalink") or payload.get("permalink") ) if not (sale_id and email and product_id): return None mapping = product_lookup(self.source_name, str(product_id)) if mapping is None: # Unmapped — surface to caller as a SaleEvent with no tier. # We deliberately don't raise here so the caller can # log it to gumroad_events with error info and still # return 200 (no Gumroad retry storm). raise UnmappedProductError( f"Gumroad product_id {product_id!r} has no entry in " "config/products.yaml. Add a mapping and replay this " f"sale (sale_id={sale_id})." ) name = (payload.get("full_name") or "").strip() or _email_local(email) price_cents = _to_int(payload.get("price")) amount_paid = Decimal(price_cents) / Decimal(100) if price_cents is not None else None currency = (payload.get("currency") or "USD").upper() promotion = (payload.get("offer_code") or "").strip() or None notes = None if _is_truthy(payload.get("test")): notes = "gumroad test ping" return SaleEvent( source=self.source_name, source_order_id=str(sale_id), buyer_name=name, buyer_email=email.strip(), tier=mapping.tier, years=mapping.years, promotion=promotion, amount_paid=amount_paid, currency=currency, notes=notes, raw_payload=dict(payload), ) def parse_refund(self, payload: dict[str, Any]) -> Optional[RefundEvent]: # PR 2.1. return None class UnmappedProductError(ValueError): """Raised when a sale arrives for a product not in products.yaml. Caller catches and logs into ``gumroad_events.error`` so the operator can fix the mapping and replay. """ # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _email_local(email: str) -> str: """Fallback display name when ``full_name`` is missing — the part of the email before the ``@``, capitalized. Better than 'Unknown' for support tickets and the buyer's own delivery email.""" local = email.split("@", 1)[0] return local.replace(".", " ").title() def _to_int(v: Any) -> Optional[int]: if v is None or v == "": return None try: return int(v) except (TypeError, ValueError): return None def _is_truthy(v: Any) -> bool: if isinstance(v, bool): return v if v is None: return False return str(v).strip().lower() in {"1", "true", "yes", "on"}