diff --git a/.copier-answers.yml b/.copier-answers.yml index 07c7238..96a89c9 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,10 +1,10 @@ # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY -_commit: 6a0c868 +_commit: c920923 _src_path: /home/Deeman/Projects/materia_saas_boilerplate author_email: '' author_name: '' base_url: https://padelnomics.io description: Plan, finance, and build your padel business -include_paddle: false +payment_provider: lemonsqueezy project_name: Padelnomics project_slug: padelnomics diff --git a/padelnomics/.env.example b/padelnomics/.env.example index a8fa40f..1d13364 100644 --- a/padelnomics/.env.example +++ b/padelnomics/.env.example @@ -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 diff --git a/padelnomics/Caddyfile b/padelnomics/Caddyfile deleted file mode 100644 index 7da363f..0000000 --- a/padelnomics/Caddyfile +++ /dev/null @@ -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 - } -} diff --git a/padelnomics/README.md b/padelnomics/README.md deleted file mode 100644 index 3034c08..0000000 --- a/padelnomics/README.md +++ /dev/null @@ -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 -``` diff --git a/padelnomics/docker-compose.yml b/padelnomics/docker-compose.yml index 966fd25..77142a5 100644 --- a/padelnomics/docker-compose.yml +++ b/padelnomics/docker-compose.yml @@ -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: diff --git a/padelnomics/src/padelnomics/billing/routes.py b/padelnomics/src/padelnomics/billing/routes.py index 836f5ac..f4e3581 100644 --- a/padelnomics/src/padelnomics/billing/routes.py +++ b/padelnomics/src/padelnomics/billing/routes.py @@ -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/") +@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 + diff --git a/padelnomics/src/padelnomics/core.py b/padelnomics/src/padelnomics/core.py index 2bef81b..8bbb803 100644 --- a/padelnomics/src/padelnomics/core.py +++ b/padelnomics/src/padelnomics/core.py @@ -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 # ============================================================================= diff --git a/padelnomics/src/padelnomics/migrations/schema.sql b/padelnomics/src/padelnomics/migrations/schema.sql index 37337e1..94aacd8 100644 --- a/padelnomics/src/padelnomics/migrations/schema.sql +++ b/padelnomics/src/padelnomics/migrations/schema.sql @@ -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,