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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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:
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +31,32 @@ 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")
|
||||||
|
|
||||||
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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user