From 0b218f35caf5cac4a35c7bf4438a69e7750c0645 Mon Sep 17 00:00:00 2001 From: Deeman Date: Wed, 18 Feb 2026 13:52:23 +0100 Subject: [PATCH] add Paddle webhook auto-setup, ngrok tunnel, and clean DB on each dev run setup_paddle.py creates a notification destination in Paddle and writes the webhook secret + setting ID to .env. dev_run.sh resets the DB, seeds data, and starts an ngrok tunnel to update the webhook URL automatically for end-to-end checkout testing. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 10 +- padelnomics/.env.example | 4 + padelnomics/scripts/dev_run.sh | 113 +++++++++++++++++- padelnomics/scripts/dev_setup.sh | 3 - .../src/padelnomics/scripts/setup_paddle.py | 67 ++++++++++- 5 files changed, 184 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ff4db..2f13636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). checks prerequisites, installs deps, creates `.env` with auto-generated `SECRET_KEY`, runs migrations, seeds test data, optionally sets up Paddle sandbox products, and builds CSS -- **Dev run script** (`scripts/dev_run.sh`) — starts app, worker, and CSS - watcher in parallel with colored/labeled output and clean Ctrl-C shutdown +- **Dev run script** (`scripts/dev_run.sh`) — resets DB, runs migrations, seeds + data, builds CSS, then starts app + worker + CSS watcher in parallel with + colored/labeled output and clean Ctrl-C shutdown; when Paddle is configured + and ngrok is installed, starts a tunnel and updates the Paddle webhook + destination automatically for end-to-end checkout testing +- **Paddle webhook auto-setup** — `setup_paddle.py` now creates a notification + destination in Paddle with the 5 event types the webhook handler processes, + and writes `PADDLE_WEBHOOK_SECRET` to `.env` automatically - **Resend test email docs** — documented Resend test addresses (`delivered@resend.dev`, `bounced@resend.dev`, etc.) in `.env.example` and README for testing email flows without a verified domain diff --git a/padelnomics/.env.example b/padelnomics/.env.example index 2ed1b1a..c7c299c 100644 --- a/padelnomics/.env.example +++ b/padelnomics/.env.example @@ -29,9 +29,13 @@ EMAIL_FROM=hello@padelnomics.io ADMIN_EMAIL=leads@padelnomics.io # Paddle — leave blank to skip checkout (overlay won't initialize) +# Run `uv run python -m padelnomics.scripts.setup_paddle` to create products +# and a webhook notification destination. It writes PADDLE_WEBHOOK_SECRET and +# PADDLE_NOTIFICATION_SETTING_ID here automatically. PADDLE_API_KEY= PADDLE_CLIENT_TOKEN= PADDLE_WEBHOOK_SECRET= +PADDLE_NOTIFICATION_SETTING_ID= PADDLE_ENVIRONMENT=sandbox # Umami — leave blank for dev (analytics tracking disabled) diff --git a/padelnomics/scripts/dev_run.sh b/padelnomics/scripts/dev_run.sh index e61a85e..99e2554 100755 --- a/padelnomics/scripts/dev_run.sh +++ b/padelnomics/scripts/dev_run.sh @@ -3,20 +3,67 @@ # # Usage: ./scripts/dev_run.sh # -# Starts: app (port 5000), background worker, CSS watcher. +# On each start: resets the DB, runs migrations, seeds data, builds CSS, +# optionally starts ngrok for Paddle webhook forwarding, then starts +# app (port 5000), background worker, and CSS watcher. # Ctrl-C stops everything cleanly. set -euo pipefail cd "$(dirname "$0")/.." -# -- Colors for each process ------------------------------------------------- +# -- Colors & helpers -------------------------------------------------------- + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' COLOR_APP='\033[0;36m' # cyan COLOR_WORKER='\033[0;33m' # yellow COLOR_CSS='\033[0;35m' # magenta -NC='\033[0m' -BOLD='\033[1m' +COLOR_NGROK='\033[0;32m' # green + +info() { echo -e "${BLUE}==>${NC} ${BOLD}$1${NC}"; } +ok() { echo -e "${GREEN} ✓${NC} $1"; } +warn() { echo -e "${YELLOW} !${NC} $1"; } +fail() { echo -e "${RED} ✗${NC} $1"; exit 1; } + +# -- Preflight --------------------------------------------------------------- + +if [ ! -f .env ]; then + fail ".env not found. Run ./scripts/dev_setup.sh first." +fi + +# Load config from .env (|| true prevents set -e from aborting on empty values) +DATABASE_PATH=$(grep '^DATABASE_PATH=' .env 2>/dev/null | cut -d= -f2- || true) +DATABASE_PATH=${DATABASE_PATH:-data/app.db} +PADDLE_API_KEY=$(grep '^PADDLE_API_KEY=' .env 2>/dev/null | cut -d= -f2- || true) +PADDLE_NOTIFICATION_SETTING_ID=$(grep '^PADDLE_NOTIFICATION_SETTING_ID=' .env 2>/dev/null | cut -d= -f2- || true) +PADDLE_ENVIRONMENT=$(grep '^PADDLE_ENVIRONMENT=' .env 2>/dev/null | cut -d= -f2- || true) +PADDLE_ENVIRONMENT=${PADDLE_ENVIRONMENT:-sandbox} + +# -- Preparation ------------------------------------------------------------- + +info "Resetting database" +rm -f "$DATABASE_PATH" +ok "Removed $DATABASE_PATH" + +info "Running migrations" +uv run python -m padelnomics.migrations.migrate +ok "Migrations applied" + +info "Seeding development data" +uv run python -m padelnomics.scripts.seed_dev_data +ok "Dev data seeded" + +info "Building CSS" +make css-build +ok "CSS built" + +# -- Process management ------------------------------------------------------ PIDS=() @@ -34,7 +81,6 @@ cleanup() { trap cleanup SIGINT SIGTERM # Prefix each line of a command's output with a colored label. -# Usage: run_with_label COLOR LABEL COMMAND... run_with_label() { local color="$1" label="$2" shift 2 @@ -44,13 +90,70 @@ run_with_label() { PIDS+=($!) } +# -- Ngrok tunnel (if Paddle is configured) ---------------------------------- + +TUNNEL_URL="" + +if [ -n "$PADDLE_API_KEY" ] && [ -n "$PADDLE_NOTIFICATION_SETTING_ID" ]; then + if command -v ngrok >/dev/null 2>&1; then + info "Starting ngrok tunnel for Paddle webhooks" + ngrok http 5000 --log=stdout --log-level=warn > /tmp/padelnomics-ngrok.log 2>&1 & + NGROK_PID=$! + PIDS+=($NGROK_PID) + + # Wait for ngrok to be ready (up to 10 seconds) + WAIT_SECONDS=10 + for i in $(seq 1 $WAIT_SECONDS); do + TUNNEL_URL=$(curl -s http://localhost:4040/api/tunnels 2>/dev/null \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['tunnels'][0]['public_url'])" 2>/dev/null) \ + && break + sleep 1 + done + + if [ -n "$TUNNEL_URL" ]; then + ok "ngrok tunnel: $TUNNEL_URL" + + # Update Paddle notification destination with tunnel URL + WEBHOOK_URL="${TUNNEL_URL}/billing/webhook/paddle" + info "Updating Paddle webhook destination → $WEBHOOK_URL" + uv run python -c " +import os +from dotenv import load_dotenv +load_dotenv() +from paddle_billing import Client, Environment, Options +from paddle_billing.Resources.NotificationSettings.Operations import UpdateNotificationSetting + +env = Environment.SANDBOX if '${PADDLE_ENVIRONMENT}' == 'sandbox' else Environment.PRODUCTION +paddle = Client('${PADDLE_API_KEY}', options=Options(env)) +paddle.notification_settings.update( + '${PADDLE_NOTIFICATION_SETTING_ID}', + UpdateNotificationSetting(destination='${WEBHOOK_URL}'), +) +print(' Updated.') +" + ok "Paddle webhooks → $WEBHOOK_URL" + else + warn "ngrok started but tunnel URL not available — webhooks won't reach localhost" + fi + else + warn "ngrok not installed — Paddle webhooks won't reach localhost" + warn "Install: https://ngrok.com/download (or brew install ngrok)" + fi +elif [ -n "$PADDLE_API_KEY" ]; then + warn "PADDLE_NOTIFICATION_SETTING_ID not set — run setup_paddle to create webhook destination" +fi + # -- Start processes --------------------------------------------------------- +echo "" echo -e "${BOLD}Starting Padelnomics dev environment${NC}" echo "" echo " app: http://localhost:5000" echo " admin: http://localhost:5000/admin" echo " login: http://localhost:5000/auth/dev-login?email=dev@localhost" +if [ -n "$TUNNEL_URL" ]; then + echo " tunnel: $TUNNEL_URL" +fi echo "" echo "Press Ctrl-C to stop all processes." echo "" diff --git a/padelnomics/scripts/dev_setup.sh b/padelnomics/scripts/dev_setup.sh index 7e04588..f507e85 100755 --- a/padelnomics/scripts/dev_setup.sh +++ b/padelnomics/scripts/dev_setup.sh @@ -66,9 +66,6 @@ else read -rp " Paddle client token: " PADDLE_CLIENT_TOKEN [ -n "$PADDLE_CLIENT_TOKEN" ] && sed -i "s/^PADDLE_CLIENT_TOKEN=.*/PADDLE_CLIENT_TOKEN=${PADDLE_CLIENT_TOKEN}/" .env - - read -rp " Paddle webhook secret: " PADDLE_WEBHOOK_SECRET - [ -n "$PADDLE_WEBHOOK_SECRET" ] && sed -i "s/^PADDLE_WEBHOOK_SECRET=.*/PADDLE_WEBHOOK_SECRET=${PADDLE_WEBHOOK_SECRET}/" .env fi read -rp " Resend API key (for email testing): " RESEND_API_KEY diff --git a/padelnomics/src/padelnomics/scripts/setup_paddle.py b/padelnomics/src/padelnomics/scripts/setup_paddle.py index fd2d046..8cd60eb 100644 --- a/padelnomics/src/padelnomics/scripts/setup_paddle.py +++ b/padelnomics/src/padelnomics/scripts/setup_paddle.py @@ -1,14 +1,16 @@ """ -Create all Paddle products and prices for Padelnomics. +Create all Paddle products, prices, and webhook notification destination. Run once per environment (sandbox, then production). -Creates products in Paddle and writes the resulting IDs to the paddle_products DB table. +Creates products in Paddle, writes IDs to the paddle_products DB table, +and sets up a webhook notification destination (writing the secret to .env). Usage: uv run python -m padelnomics.scripts.setup_paddle """ import os +import re import sqlite3 import sys from pathlib import Path @@ -16,7 +18,10 @@ from pathlib import Path from dotenv import load_dotenv from paddle_billing import Client as PaddleClient from paddle_billing import Environment, Options +from paddle_billing.Entities.Events.EventTypeName import EventTypeName +from paddle_billing.Entities.NotificationSettings.NotificationSettingType import NotificationSettingType from paddle_billing.Entities.Shared import CurrencyCode, Money, TaxCategory +from paddle_billing.Resources.NotificationSettings.Operations import CreateNotificationSetting from paddle_billing.Resources.Prices.Operations import CreatePrice from paddle_billing.Resources.Products.Operations import CreateProduct @@ -25,6 +30,7 @@ load_dotenv() PADDLE_API_KEY = os.getenv("PADDLE_API_KEY", "") PADDLE_ENVIRONMENT = os.getenv("PADDLE_ENVIRONMENT", "sandbox") DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db") +BASE_URL = os.getenv("BASE_URL", "http://localhost:5000") if not PADDLE_API_KEY: print("ERROR: Set PADDLE_API_KEY in .env first") @@ -204,7 +210,62 @@ def main(): conn.close() print(f"\n✓ All products written to {db_path}") - print(" No .env changes needed — price IDs are now in the paddle_products table.") + + # -- Notification destination (webhook) ----------------------------------- + + webhook_url = f"{BASE_URL}/billing/webhook/paddle" + + # Events the webhook handler actually processes + subscribed_events = [ + EventTypeName.SubscriptionActivated, + EventTypeName.SubscriptionUpdated, + EventTypeName.SubscriptionCanceled, + EventTypeName.SubscriptionPastDue, + EventTypeName.TransactionCompleted, + ] + + print(f"\nCreating webhook notification destination...") + print(f" URL: {webhook_url}") + + notification_setting = paddle.notification_settings.create( + CreateNotificationSetting( + description=f"Padelnomics {PADDLE_ENVIRONMENT}", + destination=webhook_url, + subscribed_events=subscribed_events, + type=NotificationSettingType.Url, + include_sensitive_fields=True, + api_version=1, + ) + ) + + webhook_secret = notification_setting.endpoint_secret_key + print(f" ID: {notification_setting.id}") + print(f" Secret: {webhook_secret}") + + # Write webhook secret and notification setting ID to .env + env_path = Path(".env") + if env_path.exists(): + env_text = env_path.read_text() + env_text = re.sub( + r"^PADDLE_WEBHOOK_SECRET=.*$", + f"PADDLE_WEBHOOK_SECRET={webhook_secret}", + env_text, + flags=re.MULTILINE, + ) + env_text = re.sub( + r"^PADDLE_NOTIFICATION_SETTING_ID=.*$", + f"PADDLE_NOTIFICATION_SETTING_ID={notification_setting.id}", + env_text, + flags=re.MULTILINE, + ) + env_path.write_text(env_text) + print(f"\n✓ PADDLE_WEBHOOK_SECRET and PADDLE_NOTIFICATION_SETTING_ID written to .env") + else: + print(f"\n Add to .env:") + print(f" PADDLE_WEBHOOK_SECRET={webhook_secret}") + print(f" PADDLE_NOTIFICATION_SETTING_ID={notification_setting.id}") + + print("\nDone. dev_run.sh will start ngrok and update the webhook URL automatically.") if __name__ == "__main__":