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

@@ -1,10 +1,10 @@
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
_commit: 6a0c868 _commit: c920923
_src_path: /home/Deeman/Projects/materia_saas_boilerplate _src_path: /home/Deeman/Projects/materia_saas_boilerplate
author_email: '' author_email: ''
author_name: '' author_name: ''
base_url: https://padelnomics.io base_url: https://padelnomics.io
description: Plan, finance, and build your padel business description: Plan, finance, and build your padel business
include_paddle: false payment_provider: lemonsqueezy
project_name: Padelnomics project_name: Padelnomics
project_slug: padelnomics project_slug: padelnomics

View File

@@ -17,6 +17,13 @@ RESEND_API_KEY=
EMAIL_FROM=hello@padelnomics.io EMAIL_FROM=hello@padelnomics.io
ADMIN_EMAIL=leads@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 limiting
RATE_LIMIT_REQUESTS=100 RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW=60 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: depends_on:
- app - 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 # Optional: Litestream for backups
litestream: litestream:
image: litestream/litestream:latest image: litestream/litestream:latest
@@ -66,5 +52,3 @@ services:
- app - app
volumes: volumes:
caddy_data:
caddy_config:

View File

@@ -1,10 +1,27 @@
""" """
Services page: links to partner services (court suppliers, financing). Billing domain: checkout, webhooks, subscription management.
Replaces Stripe billing for Phase 1 — all features are free. 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( bp = Blueprint(
"billing", "billing",
__name__, __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") @bp.route("/pricing")
async def pricing(): async def pricing():
"""Redirect pricing to services — everything is free in Phase 1.""" """Pricing page."""
return await render_template("pricing.html") 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 os
import secrets import secrets
import hashlib
import hmac
import aiosqlite import aiosqlite
import httpx import httpx
from pathlib import Path from pathlib import Path
@@ -29,6 +31,14 @@ class Config:
MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15")) MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15"))
SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30")) 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", "") RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
EMAIL_FROM: str = os.getenv("EMAIL_FROM", "hello@padelnomics.io") EMAIL_FROM: str = os.getenv("EMAIL_FROM", "hello@padelnomics.io")
ADMIN_EMAIL: str = os.getenv("ADMIN_EMAIL", "leads@padelnomics.io") ADMIN_EMAIL: str = os.getenv("ADMIN_EMAIL", "leads@padelnomics.io")
@@ -36,6 +46,18 @@ class Config:
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100")) RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60")) 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() config = Config()
@@ -267,6 +289,17 @@ def setup_request_id(app):
response.headers["X-Request-ID"] = get_request_id() response.headers["X-Request-ID"] = get_request_id()
return response 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 # 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_token ON auth_tokens(token);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id); 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 -- Rate Limits
CREATE TABLE IF NOT EXISTS rate_limits ( CREATE TABLE IF NOT EXISTS rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,