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>
174 lines
6.1 KiB
Python
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"}
|