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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-18 13:52:23 +01:00
parent 77da44f3c8
commit 0b218f35ca
5 changed files with 184 additions and 13 deletions

View File

@@ -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

View File

@@ -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)

View File

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

View File

@@ -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

View File

@@ -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__":