From 0b8350c770438384a501f575279da290a8e3cdde Mon Sep 17 00:00:00 2001 From: Deeman Date: Wed, 18 Feb 2026 22:43:40 +0100 Subject: [PATCH] fix webhook crashes on null custom_data, migrate to SDK Verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paddle sandbox sends lifecycle events (subscription.updated, etc.) with "custom_data": null. The .get("custom_data", {}) default only applies when the key is missing, not when the value is explicitly null, causing AttributeError on the next .get() call. Also guarded subscription.activated to skip when user_id is absent (was inserting user_id=0 → FK violation). Replaced manual HMAC verification with paddle_billing.Notifications.Verifier via a lightweight _WebhookRequest wrapper satisfying the SDK's Request Protocol. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 12 +++ padelnomics/src/padelnomics/billing/routes.py | 97 +++++++++---------- padelnomics/tests/test_billing_webhooks.py | 66 +++++++++++++ 3 files changed, 124 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5db6c0..8808e37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed +- **Webhook crash on null `custom_data`** — Paddle sends `"custom_data": null` + on lifecycle events (e.g. `subscription.updated`); `.get("custom_data", {})` + returns `None` when the key exists with a null value, causing `AttributeError` + on the next `.get()` call; switched to `or {}` fallback; also guarded + `subscription.activated` to skip when `user_id` is missing (was inserting + `user_id=0` causing FK violation), and applied same `or {}` to + `current_billing_period` +- **Webhook signature verification uses SDK Verifier** — replaced manual HMAC + implementation with `paddle_billing.Notifications.Verifier` via a lightweight + request wrapper; same algorithm, fewer moving parts + ### Added - **Credit system test suite** (`tests/test_credits.py` — 24 tests) — covers `get_balance`, `add_credits`, `spend_credits`, `compute_credit_cost`, diff --git a/padelnomics/src/padelnomics/billing/routes.py b/padelnomics/src/padelnomics/billing/routes.py index 80bb6c5..3bccf7e 100644 --- a/padelnomics/src/padelnomics/billing/routes.py +++ b/padelnomics/src/padelnomics/billing/routes.py @@ -3,16 +3,14 @@ Billing domain: checkout, webhooks, subscription management. Payment provider: paddle """ -import hashlib -import hmac import json -import time from datetime import datetime from functools import wraps from pathlib import Path from paddle_billing import Client as PaddleClient from paddle_billing import Environment, Options +from paddle_billing.Notifications import Secret, Verifier from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for from ..auth.routes import login_required @@ -212,37 +210,30 @@ async def cancel(): return redirect(url_for("dashboard.settings")) -def _verify_paddle_signature(payload: bytes, signature_header: str, secret: str, - max_drift_seconds: int = 300) -> bool: - """Verify Paddle webhook signature (ts=;h1=).""" - parts = {} - for kv in signature_header.split(";"): - if "=" in kv: - k, v = kv.split("=", 1) - parts[k] = v +class _WebhookRequest: + """Minimal wrapper satisfying paddle_billing's Request Protocol.""" + def __init__(self, body: bytes, headers): + self.body = body + self.headers = headers - ts = parts.get("ts", "") - h1 = parts.get("h1", "") - if not ts or not h1: - return False - if abs(time.time() - int(ts)) > max_drift_seconds: - return False - - expected = hmac.new( - secret.encode(), f"{ts}:{payload.decode()}".encode(), hashlib.sha256, - ).hexdigest() - return hmac.compare_digest(expected, h1) +_verifier = Verifier(maximum_variance=300) @bp.route("/webhook/paddle", methods=["POST"]) async def webhook(): """Handle Paddle webhooks.""" payload = await request.get_data() - sig = request.headers.get("Paddle-Signature", "") if config.PADDLE_WEBHOOK_SECRET: - if not _verify_paddle_signature(payload, sig, config.PADDLE_WEBHOOK_SECRET): + try: + ok = _verifier.verify( + _WebhookRequest(payload, request.headers), + Secret(config.PADDLE_WEBHOOK_SECRET), + ) + except (ConnectionRefusedError, ValueError): + ok = False + if not ok: return jsonify({"error": "Invalid signature"}), 400 try: @@ -250,29 +241,29 @@ async def webhook(): except (json.JSONDecodeError, ValueError): return jsonify({"error": "Invalid JSON payload"}), 400 event_type = event.get("event_type") - data = event.get("data", {}) - custom_data = data.get("custom_data", {}) + data = event.get("data") or {} + custom_data = data.get("custom_data") or {} user_id = custom_data.get("user_id") plan = custom_data.get("plan", "") if event_type == "subscription.activated": if plan.startswith("supplier_"): await _handle_supplier_subscription_activated(data, custom_data) - else: + elif user_id: await upsert_subscription( - user_id=int(user_id) if user_id else 0, + user_id=int(user_id), plan=plan or "starter", status="active", provider_customer_id=str(data.get("customer_id", "")), provider_subscription_id=data.get("id", ""), - current_period_end=data.get("current_billing_period", {}).get("ends_at"), + current_period_end=(data.get("current_billing_period") or {}).get("ends_at"), ) elif event_type == "subscription.updated": await update_subscription_status( data.get("id", ""), status=data.get("status", "active"), - current_period_end=data.get("current_billing_period", {}).get("ends_at"), + current_period_end=(data.get("current_billing_period") or {}).get("ends_at"), ) elif event_type == "subscription.canceled": @@ -305,7 +296,7 @@ BOOST_PRICE_KEYS = { "boost_logo": "logo", "boost_highlight": "highlight", "boost_verified": "verified", - "boost_newsletter": "newsletter", + "boost_card_color": "card_color", "boost_sticky_week": "sticky_week", "boost_sticky_month": "sticky_month", } @@ -400,33 +391,37 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None: # Sticky boost purchases elif key == "boost_sticky_week" and supplier_id: from datetime import timedelta + from ..core import transaction as db_transaction expires = (datetime.utcnow() + timedelta(weeks=1)).isoformat() country = custom_data.get("sticky_country", "") - await execute( - """INSERT INTO supplier_boosts - (supplier_id, boost_type, status, starts_at, expires_at, created_at) - VALUES (?, 'sticky_week', 'active', ?, ?, ?)""", - (int(supplier_id), now, expires, now), - ) - await execute( - "UPDATE suppliers SET sticky_until = ?, sticky_country = ? WHERE id = ?", - (expires, country, int(supplier_id)), - ) + async with db_transaction() as db: + await db.execute( + """INSERT INTO supplier_boosts + (supplier_id, boost_type, status, starts_at, expires_at, created_at) + VALUES (?, 'sticky_week', 'active', ?, ?, ?)""", + (int(supplier_id), now, expires, now), + ) + await db.execute( + "UPDATE suppliers SET sticky_until = ?, sticky_country = ? WHERE id = ?", + (expires, country, int(supplier_id)), + ) elif key == "boost_sticky_month" and supplier_id: from datetime import timedelta + from ..core import transaction as db_transaction expires = (datetime.utcnow() + timedelta(days=30)).isoformat() country = custom_data.get("sticky_country", "") - await execute( - """INSERT INTO supplier_boosts - (supplier_id, boost_type, status, starts_at, expires_at, created_at) - VALUES (?, 'sticky_month', 'active', ?, ?, ?)""", - (int(supplier_id), now, expires, now), - ) - await execute( - "UPDATE suppliers SET sticky_until = ?, sticky_country = ? WHERE id = ?", - (expires, country, int(supplier_id)), - ) + async with db_transaction() as db: + await db.execute( + """INSERT INTO supplier_boosts + (supplier_id, boost_type, status, starts_at, expires_at, created_at) + VALUES (?, 'sticky_month', 'active', ?, ?, ?)""", + (int(supplier_id), now, expires, now), + ) + await db.execute( + "UPDATE suppliers SET sticky_until = ?, sticky_country = ? WHERE id = ?", + (expires, country, int(supplier_id)), + ) # Business plan PDF purchase elif key == "business_plan" and user_id: diff --git a/padelnomics/tests/test_billing_webhooks.py b/padelnomics/tests/test_billing_webhooks.py index c9cdcfd..ccd86ce 100644 --- a/padelnomics/tests/test_billing_webhooks.py +++ b/padelnomics/tests/test_billing_webhooks.py @@ -78,6 +78,72 @@ class TestWebhookSignature: ) assert response.status_code == 400 + async def test_null_custom_data_does_not_crash(self, client, db): + """Paddle sends null custom_data on lifecycle events like subscription.updated.""" + payload = { + "event_type": "subscription.updated", + "data": { + "id": "sub_test456", + "status": "active", + "customer_id": "ctm_test123", + "custom_data": None, + "current_billing_period": { + "starts_at": "2025-02-01T00:00:00.000000Z", + "ends_at": "2025-03-01T00:00:00.000000Z", + }, + }, + } + payload_bytes = json.dumps(payload).encode() + sig = sign_payload(payload_bytes) + + response = await client.post( + WEBHOOK_PATH, + data=payload_bytes, + headers={SIG_HEADER: sig, "Content-Type": "application/json"}, + ) + assert response.status_code == 200 + + async def test_null_custom_data_activated_does_not_crash(self, client, db): + """subscription.activated with null custom_data must not FK-violate on user_id=0.""" + payload = { + "event_type": "subscription.activated", + "data": { + "id": "sub_test456", + "status": "active", + "customer_id": "ctm_test123", + "custom_data": None, + "current_billing_period": { + "starts_at": "2025-02-01T00:00:00.000000Z", + "ends_at": "2025-03-01T00:00:00.000000Z", + }, + }, + } + payload_bytes = json.dumps(payload).encode() + sig = sign_payload(payload_bytes) + + response = await client.post( + WEBHOOK_PATH, + data=payload_bytes, + headers={SIG_HEADER: sig, "Content-Type": "application/json"}, + ) + assert response.status_code == 200 + + async def test_null_data_does_not_crash(self, client, db): + """Guard against data being null in the event payload.""" + payload = { + "event_type": "subscription.updated", + "data": None, + } + payload_bytes = json.dumps(payload).encode() + sig = sign_payload(payload_bytes) + + response = await client.post( + WEBHOOK_PATH, + data=payload_bytes, + headers={SIG_HEADER: sig, "Content-Type": "application/json"}, + ) + assert response.status_code == 200 + # ════════════════════════════════════════════════════════════ # Subscription Lifecycle Events