"""Storefront webhook receivers. PR 2 wires Gumroad. Future storefronts each get their own route (``/webhooks/lemonsqueezy``, ``/webhooks/stripe``, ...). All share the same downstream flow: audit-log the raw payload, parse via adapter, mint, send email, mark processed. Handler contract ---------------- We **always** return 200 once a request authenticates, even on downstream failures. Gumroad retries non-2xx for ~3 days, which would turn a single broken sale into hours of duplicate webhook storms. Our idempotency keys (``UNIQUE(source, source_order_id)``) make at-least-once handling safe; the storefront retries on network errors only. When something downstream fails (unmapped product, DB error, email failure), we record the cause in ``gumroad_events.error`` so the operator can fix and replay. Unauthenticated requests return 404 — we don't want to signal endpoint existence or "wrong secret" to a prober. """ from __future__ import annotations import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from sqlalchemy.orm import Session from app.adapters.gumroad import GumroadAdapter, UnmappedProductError from app.config import get_settings from app.db import get_session from app.email import EmailDeliveryError, LicenseEmail, get_email_service from app.mint import mint_from_sale from app.models import GumroadEvent router = APIRouter(prefix="/webhooks") log = logging.getLogger(__name__) def _gumroad_adapter() -> GumroadAdapter: settings = get_settings() return GumroadAdapter(secret=settings.resolve_gumroad_secret()) @router.post("/gumroad", status_code=200) async def gumroad( request: Request, secret: Optional[str] = Query(default=None), session: Session = Depends(get_session), ) -> dict: adapter = _gumroad_adapter() if not adapter.verify_secret(secret): # 404 — no information leak about endpoint existence. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") # Gumroad's Ping is form-encoded; FastAPI doesn't auto-parse # without a Form() dependency, and we want the raw map for the # audit log regardless of schema. raw_form = await request.form() payload = {k: str(v) for k, v in raw_form.items()} # Audit row FIRST — any later failure leaves us a replayable record. event = GumroadEvent( event_type="sale", order_id=payload.get("sale_id"), raw_payload=payload, ) session.add(event) session.flush() try: sale = adapter.parse_sale(payload) except UnmappedProductError as e: event.error = str(e) log.warning("Gumroad sale with unmapped product: %s", e) return {"status": "logged-no-mint", "reason": "unmapped_product"} except Exception as e: # pragma: no cover — defensive event.error = f"parse error: {e!r}" log.exception("Gumroad parse failure") return {"status": "logged-no-mint", "reason": "parse_error"} if sale is None: event.error = "payload did not parse as a sale" return {"status": "logged-no-mint", "reason": "not_a_sale"} try: row = mint_from_sale(session, sale) session.flush() except Exception as e: # pragma: no cover — defensive event.error = f"mint error: {e!r}" log.exception("mint_from_sale failed") return {"status": "logged-no-mint", "reason": "mint_error"} try: get_email_service().send_license( LicenseEmail( to_name=row.name, to_email=row.email, tier=row.tier, license_key=row.license_key, expires_at_iso=row.expires_at.isoformat(), blob=row.blob, ) ) except EmailDeliveryError as e: event.error = f"email error: {e}" log.warning("Email delivery failed (license already minted): %s", e) # The buyer can still be served from the DB via the renewal # portal in PR 3 / a manual resend, so we don't fail the # webhook over an email hiccup. event.processed = True return {"status": "minted-email-failed", "license_key": row.license_key} event.processed = True return {"status": "ok", "license_key": row.license_key}