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:
10
CHANGELOG.md
10
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user