apply copier update: switch to LemonSqueezy payment provider

Update from materia_saas_boilerplate template with payment_provider=lemonsqueezy.
Adds full LemonSqueezy billing routes (checkout, webhooks, subscription management),
HMAC webhook verification, subscriptions/API keys schema, and removes Caddy in favor
of Nginx Proxy Manager.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-13 08:09:31 +01:00
parent 2f4be38e07
commit 2ef9822d95
8 changed files with 434 additions and 178 deletions

View File

@@ -17,6 +17,13 @@ RESEND_API_KEY=
EMAIL_FROM=hello@padelnomics.io
ADMIN_EMAIL=leads@padelnomics.io
# LemonSqueezy
LEMONSQUEEZY_API_KEY=
LEMONSQUEEZY_STORE_ID=
LEMONSQUEEZY_WEBHOOK_SECRET=
LEMONSQUEEZY_MONTHLY_VARIANT_ID=
LEMONSQUEEZY_YEARLY_VARIANT_ID=
# Rate limiting
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW=60

View File

@@ -1,17 +0,0 @@
# Replace with your domain
padelnomics.io {
reverse_proxy app:5000
# Security headers
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
X-XSS-Protection "1; mode=block"
Referrer-Policy strict-origin-when-cross-origin
}
# Logging
log {
output file /var/log/caddy/access.log
}
}

View File

@@ -1,136 +0,0 @@
# Padelnomics
Plan, finance, and build your padel business
## Quick Start
```bash
# Install uv (if not already installed)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Install dependencies
uv sync
# Copy environment file
cp .env.example .env
# Edit .env with your settings
# Initialize database
uv run python -m padelnomics.migrations.migrate
# Run the app
uv run python -m padelnomics.app
# In another terminal, run the worker
uv run python -m padelnomics.worker
```
Visit http://localhost:5000
## Configuration
Copy `.env.example` to `.env` and configure:
```bash
# Required
SECRET_KEY=generate-a-real-secret-key
RESEND_API_KEY=re_xxxx
# Stripe (required for billing)
STRIPE_SECRET_KEY=sk_test_xxxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxx
STRIPE_PRICE_STARTER=price_xxxx
STRIPE_PRICE_PRO=price_xxxx
```
## Development
```bash
# Run with auto-reload
uv run python -m padelnomics.app
# Run worker
uv run python -m padelnomics.worker
# Run tests
uv run pytest
```
## Project Structure
```
src/padelnomics/
app.py # Application factory, blueprints
core.py # DB, config, email, middleware
worker.py # Background task processor
auth/ # Authentication domain
routes.py # Login, signup, magic links
templates/
billing/ # Billing domain
routes.py # Checkout, webhooks, subscriptions
templates/
dashboard/ # User dashboard domain
routes.py # Settings, API keys, search
templates/
public/ # Marketing pages domain
routes.py # Landing, pricing, terms
templates/
api/ # REST API domain
routes.py # API endpoints, rate limiting
templates/ # Shared templates
base.html
components/
email/
migrations/
schema.sql
migrate.py
```
## Deployment
### Docker (recommended)
```bash
# Build and run
docker compose up -d
# View logs
docker compose logs -f
```
### Manual
```bash
# Install dependencies
uv sync --frozen
# Run migrations
uv run python -m padelnomics.migrations.migrate
# Run with hypercorn
uv run hypercorn padelnomics.app:app --bind 0.0.0.0:5000
```
## Stripe Setup
1. Create products/prices in Stripe Dashboard
2. Add price IDs to `.env`
3. Set up webhook endpoint: `https://yourdomain.com/billing/webhook/stripe`
4. Enable events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`
## Litestream Backups
Configure `litestream.yml` with your S3/R2 bucket, then:
```bash
# Run with replication
litestream replicate -config litestream.yml
```

View File

@@ -40,20 +40,6 @@ services:
depends_on:
- app
# Optional: Caddy for HTTPS
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
# Optional: Litestream for backups
litestream:
image: litestream/litestream:latest
@@ -66,5 +52,3 @@ services:
- app
volumes:
caddy_data:
caddy_config:

View File

@@ -1,10 +1,27 @@
"""
Services page: links to partner services (court suppliers, financing).
Replaces Stripe billing for Phase 1 — all features are free.
Billing domain: checkout, webhooks, subscription management.
Payment provider: lemonsqueezy
"""
from pathlib import Path
from quart import Blueprint, render_template
import json
from datetime import datetime
from functools import wraps
from pathlib import Path
from quart import Blueprint, render_template, request, redirect, url_for, flash, g, jsonify, session
import httpx
from ..core import config, fetch_one, fetch_all, execute
from ..core import verify_hmac_signature
from ..auth.routes import login_required
# Blueprint with its own template folder
bp = Blueprint(
"billing",
__name__,
@@ -13,7 +30,327 @@ bp = Blueprint(
)
# =============================================================================
# SQL Queries
# =============================================================================
async def get_subscription(user_id: int) -> dict | None:
"""Get user's subscription."""
return await fetch_one(
"SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
(user_id,)
)
async def upsert_subscription(
user_id: int,
plan: str,
status: str,
provider_customer_id: str,
provider_subscription_id: str,
current_period_end: str = None,
) -> int:
"""Create or update subscription."""
now = datetime.utcnow().isoformat()
customer_col = "lemonsqueezy_customer_id"
subscription_col = "lemonsqueezy_subscription_id"
existing = await fetch_one("SELECT id FROM subscriptions WHERE user_id = ?", (user_id,))
if existing:
await execute(
f"""UPDATE subscriptions
SET plan = ?, status = ?, {customer_col} = ?, {subscription_col} = ?,
current_period_end = ?, updated_at = ?
WHERE user_id = ?""",
(plan, status, provider_customer_id, provider_subscription_id,
current_period_end, now, user_id),
)
return existing["id"]
else:
return await execute(
f"""INSERT INTO subscriptions
(user_id, plan, status, {customer_col}, {subscription_col},
current_period_end, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user_id, plan, status, provider_customer_id, provider_subscription_id,
current_period_end, now, now),
)
async def get_subscription_by_provider_id(subscription_id: str) -> dict | None:
return await fetch_one(
"SELECT * FROM subscriptions WHERE lemonsqueezy_subscription_id = ?",
(subscription_id,)
)
async def update_subscription_status(provider_subscription_id: str, status: str, **extra) -> None:
"""Update subscription status by provider subscription ID."""
extra["updated_at"] = datetime.utcnow().isoformat()
extra["status"] = status
sets = ", ".join(f"{k} = ?" for k in extra)
values = list(extra.values())
values.append(provider_subscription_id)
await execute(
f"UPDATE subscriptions SET {sets} WHERE lemonsqueezy_subscription_id = ?", tuple(values)
)
async def can_access_feature(user_id: int, feature: str) -> bool:
"""Check if user can access a feature based on their plan."""
sub = await get_subscription(user_id)
plan = sub["plan"] if sub and sub["status"] in ("active", "on_trial", "cancelled") else "free"
return feature in config.PLAN_FEATURES.get(plan, [])
async def is_within_limits(user_id: int, resource: str, current_count: int) -> bool:
"""Check if user is within their plan limits."""
sub = await get_subscription(user_id)
plan = sub["plan"] if sub and sub["status"] in ("active", "on_trial", "cancelled") else "free"
limit = config.PLAN_LIMITS.get(plan, {}).get(resource, 0)
if limit == -1:
return True
return current_count < limit
# =============================================================================
# Access Gating
# =============================================================================
def subscription_required(allowed=("active", "on_trial", "cancelled")):
"""Decorator to gate routes behind active subscription."""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
if "user_id" not in session:
return redirect(url_for("auth.login"))
sub = await get_subscription(session["user_id"])
if not sub or sub["status"] not in allowed:
return redirect(url_for("billing.pricing"))
return await func(*args, **kwargs)
return wrapper
return decorator
# =============================================================================
# Routes
# =============================================================================
@bp.route("/pricing")
async def pricing():
"""Redirect pricing to services — everything is free in Phase 1."""
return await render_template("pricing.html")
"""Pricing page."""
user_sub = None
if "user_id" in session:
user_sub = await get_subscription(session["user_id"])
return await render_template("pricing.html", subscription=user_sub)
@bp.route("/success")
@login_required
async def success():
"""Checkout success page."""
return await render_template("success.html")
# =============================================================================
# LemonSqueezy Implementation
# =============================================================================
VARIANT_TO_PLAN: dict = {}
def _get_variant_map() -> dict:
if not VARIANT_TO_PLAN:
VARIANT_TO_PLAN[config.LEMONSQUEEZY_MONTHLY_VARIANT_ID] = "pro"
VARIANT_TO_PLAN[config.LEMONSQUEEZY_YEARLY_VARIANT_ID] = "pro"
return VARIANT_TO_PLAN
def determine_plan(variant_id) -> str:
return _get_variant_map().get(str(variant_id), "free")
@bp.route("/checkout/<plan>")
@login_required
async def checkout(plan: str):
"""Create LemonSqueezy checkout."""
variant_id = {
"monthly": config.LEMONSQUEEZY_MONTHLY_VARIANT_ID,
"yearly": config.LEMONSQUEEZY_YEARLY_VARIANT_ID,
}.get(plan)
if not variant_id:
await flash("Invalid plan selected.", "error")
return redirect(url_for("billing.pricing"))
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.lemonsqueezy.com/v1/checkouts",
headers={
"Authorization": f"Bearer {config.LEMONSQUEEZY_API_KEY}",
"Content-Type": "application/vnd.api+json",
"Accept": "application/vnd.api+json",
},
json={
"data": {
"type": "checkouts",
"attributes": {
"checkout_data": {
"email": g.user["email"],
"custom": {"user_id": str(g.user["id"])},
},
"product_options": {
"redirect_url": f"{config.BASE_URL}/billing/success",
},
},
"relationships": {
"store": {
"data": {"type": "stores", "id": config.LEMONSQUEEZY_STORE_ID}
},
"variant": {
"data": {"type": "variants", "id": variant_id}
},
},
}
},
)
response.raise_for_status()
checkout_url = response.json()["data"]["attributes"]["url"]
# Return URL for Lemon.js overlay, or redirect for non-JS
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify({"checkout_url": checkout_url})
return redirect(checkout_url)
@bp.route("/manage", methods=["POST"])
@login_required
async def manage():
"""Redirect to LemonSqueezy customer portal."""
sub = await get_subscription(g.user["id"])
if not sub or not sub.get("lemonsqueezy_subscription_id"):
await flash("No active subscription found.", "error")
return redirect(url_for("dashboard.settings"))
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.lemonsqueezy.com/v1/subscriptions/{sub['lemonsqueezy_subscription_id']}",
headers={
"Authorization": f"Bearer {config.LEMONSQUEEZY_API_KEY}",
"Accept": "application/vnd.api+json",
},
)
response.raise_for_status()
portal_url = response.json()["data"]["attributes"]["urls"]["customer_portal"]
return redirect(portal_url)
@bp.route("/cancel", methods=["POST"])
@login_required
async def cancel():
"""Cancel subscription via LemonSqueezy API."""
sub = await get_subscription(g.user["id"])
if sub and sub.get("lemonsqueezy_subscription_id"):
async with httpx.AsyncClient() as client:
await client.patch(
f"https://api.lemonsqueezy.com/v1/subscriptions/{sub['lemonsqueezy_subscription_id']}",
headers={
"Authorization": f"Bearer {config.LEMONSQUEEZY_API_KEY}",
"Content-Type": "application/vnd.api+json",
"Accept": "application/vnd.api+json",
},
json={
"data": {
"type": "subscriptions",
"id": sub["lemonsqueezy_subscription_id"],
"attributes": {"cancelled": True},
}
},
)
return redirect(url_for("dashboard.settings"))
@bp.route("/resume", methods=["POST"])
@login_required
async def resume():
"""Resume a cancelled subscription before period end."""
sub = await get_subscription(g.user["id"])
if sub and sub.get("lemonsqueezy_subscription_id"):
async with httpx.AsyncClient() as client:
await client.patch(
f"https://api.lemonsqueezy.com/v1/subscriptions/{sub['lemonsqueezy_subscription_id']}",
headers={
"Authorization": f"Bearer {config.LEMONSQUEEZY_API_KEY}",
"Content-Type": "application/vnd.api+json",
"Accept": "application/vnd.api+json",
},
json={
"data": {
"type": "subscriptions",
"id": sub["lemonsqueezy_subscription_id"],
"attributes": {"cancelled": False},
}
},
)
return redirect(url_for("dashboard.settings"))
@bp.route("/webhook/lemonsqueezy", methods=["POST"])
async def webhook():
"""Handle LemonSqueezy webhooks."""
payload = await request.get_data()
signature = request.headers.get("X-Signature", "")
if not verify_hmac_signature(payload, signature, config.LEMONSQUEEZY_WEBHOOK_SECRET):
return jsonify({"error": "Invalid signature"}), 401
event = json.loads(payload)
event_name = event["meta"]["event_name"]
custom_data = event["meta"].get("custom_data", {})
user_id = custom_data.get("user_id")
data = event["data"]
attrs = data["attributes"]
if event_name == "subscription_created":
await upsert_subscription(
user_id=int(user_id) if user_id else 0,
plan=determine_plan(attrs.get("variant_id")),
status=attrs["status"],
provider_customer_id=str(attrs["customer_id"]),
provider_subscription_id=data["id"],
current_period_end=attrs.get("renews_at"),
)
elif event_name in ("subscription_updated", "subscription_payment_success"):
await update_subscription_status(
data["id"],
status=attrs["status"],
plan=determine_plan(attrs.get("variant_id")),
current_period_end=attrs.get("renews_at"),
)
elif event_name == "subscription_cancelled":
await update_subscription_status(data["id"], status="cancelled")
elif event_name in ("subscription_expired", "order_refunded"):
await update_subscription_status(data["id"], status="expired")
elif event_name == "subscription_payment_failed":
await update_subscription_status(data["id"], status="past_due")
elif event_name == "subscription_paused":
await update_subscription_status(data["id"], status="paused")
elif event_name in ("subscription_unpaused", "subscription_resumed"):
await update_subscription_status(data["id"], status="active")
return jsonify({"received": True}), 200

View File

@@ -3,6 +3,8 @@ Core infrastructure: database, config, email, and shared utilities.
"""
import os
import secrets
import hashlib
import hmac
import aiosqlite
import httpx
from pathlib import Path
@@ -29,12 +31,32 @@ class Config:
MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15"))
SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30"))
PAYMENT_PROVIDER: str = "lemonsqueezy"
LEMONSQUEEZY_API_KEY: str = os.getenv("LEMONSQUEEZY_API_KEY", "")
LEMONSQUEEZY_STORE_ID: str = os.getenv("LEMONSQUEEZY_STORE_ID", "")
LEMONSQUEEZY_WEBHOOK_SECRET: str = os.getenv("LEMONSQUEEZY_WEBHOOK_SECRET", "")
LEMONSQUEEZY_MONTHLY_VARIANT_ID: str = os.getenv("LEMONSQUEEZY_MONTHLY_VARIANT_ID", "")
LEMONSQUEEZY_YEARLY_VARIANT_ID: str = os.getenv("LEMONSQUEEZY_YEARLY_VARIANT_ID", "")
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
EMAIL_FROM: str = os.getenv("EMAIL_FROM", "hello@padelnomics.io")
ADMIN_EMAIL: str = os.getenv("ADMIN_EMAIL", "leads@padelnomics.io")
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
PLAN_FEATURES: dict = {
"free": ["basic"],
"starter": ["basic", "export"],
"pro": ["basic", "export", "api", "priority_support"],
}
PLAN_LIMITS: dict = {
"free": {"items": 100, "api_calls": 1000},
"starter": {"items": 1000, "api_calls": 10000},
"pro": {"items": -1, "api_calls": -1}, # -1 = unlimited
}
config = Config()
@@ -267,6 +289,17 @@ def setup_request_id(app):
response.headers["X-Request-ID"] = get_request_id()
return response
# =============================================================================
# Webhook Signature Verification
# =============================================================================
def verify_hmac_signature(payload: bytes, signature: str, secret: str) -> bool:
"""Verify HMAC-SHA256 webhook signature."""
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
# =============================================================================
# Soft Delete Helpers
# =============================================================================

View File

@@ -28,6 +28,54 @@ CREATE TABLE IF NOT EXISTS auth_tokens (
CREATE INDEX IF NOT EXISTS idx_auth_tokens_token ON auth_tokens(token);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
-- Subscriptions
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
plan TEXT NOT NULL DEFAULT 'free',
status TEXT NOT NULL DEFAULT 'free',
lemonsqueezy_customer_id TEXT,
lemonsqueezy_subscription_id TEXT,
current_period_end TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_provider ON subscriptions(lemonsqueezy_subscription_id);
-- API Keys
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
key_hash TEXT UNIQUE NOT NULL,
key_prefix TEXT NOT NULL,
scopes TEXT DEFAULT 'read',
created_at TEXT NOT NULL,
last_used_at TEXT,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id);
-- API Request Log
CREATE TABLE IF NOT EXISTS api_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
endpoint TEXT NOT NULL,
method TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_api_requests_user ON api_requests(user_id);
CREATE INDEX IF NOT EXISTS idx_api_requests_date ON api_requests(created_at);
-- Rate Limits
CREATE TABLE IF NOT EXISTS rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,