Files
datatools-dev/server/app/adapters/gumroad.py
Michael 2bbaba954b feat(server): Gumroad webhook receiver + Postmark email (PR 2)
Wires the second source-adapter (Gumroad) plus the email delivery
that lets the server fulfill a sale end-to-end without operator
intervention.

Auth model: Gumroad doesn't HMAC the body, so we use their
recommended URL-secret pattern (?secret=...). Wrong/missing secret
returns 404 — no signal to a prober that the endpoint exists.

Webhook flow (server/app/routes/webhooks.py):
  1. audit-log the raw payload (gumroad_events row) BEFORE anything
     else, so a later failure leaves us replayable
  2. parse via GumroadAdapter (server/app/adapters/gumroad.py)
  3. mint_from_sale — UNIQUE(source, source_order_id) dedups
     duplicate webhook retries
  4. send the license email
  5. mark gumroad_events.processed = true

Always returns 200 once auth passes. Non-2xx would trigger Gumroad's
3-day retry storm; we'd rather record the failure on the audit row
and replay manually after fixing whatever surfaced.

Product → tier mapping is per-source YAML at
server/config/products.yaml (lru_cached). Adding a SKU = edit yaml,
restart api. Unmapped product_id is an error on the audit row, not
a crash.

EmailService (server/app/email.py): provider-agnostic interface with
Postmark as the first implementation. When POSTMARK_TOKEN is unset
the factory returns LoggingEmailService instead, so the webhook
exercises end-to-end before Postmark is provisioned.

48 unit tests (was 21) including:
- Gumroad secret verify with constant-time compare
- Sale parsing: amount-in-cents, name fallback from email,
  test=true tagging, missing-required fields, offer codes
- Product mapping lookups
- Email rendering text + HTML, HTML-escapes user input
- Postmark client via httpx.MockTransport (success and 4xx)
- Webhook end-to-end: secret check, audit log, idempotency on
  retry, unmapped product, email failure keeps license

Smoke test (server/scripts/smoke.sh) extended to POST a synthetic
Ping payload, verify the row + audit log, prove wrong-secret is
rejected, prove duplicate sale_id stays one row.

SQLite-test compatibility:
- BigInteger primary key uses with_variant(Integer, "sqlite") since
  SQLite only autoincrements INTEGER PRIMARY KEY.
- python-multipart pulled in for FastAPI Form parsing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:33:43 +00:00

174 lines
6.1 KiB
Python

"""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"}