From 715ef990e3a1d6fd701abab91fa2a8370e41f33b Mon Sep 17 00:00:00 2001 From: Rafiuzzaman Khan Date: Fri, 3 Jul 2026 14:59:04 -0400 Subject: [PATCH 1/6] add payment/billing --- .env.example | 50 +- exceptions/http_exceptions.py | 11 + main.py | 53 ++- migrations/add_billing_tables.py | 124 +++++ pyproject.toml | 1 + routers/app/billing.py | 320 +++++++++++++ routers/core/organization.py | 5 + static/css/extras.css | 107 +++++ templates/base/partials/footer.html | 55 ++- templates/base/partials/header.html | 10 +- templates/billing/billing.html | 82 ++++ templates/organization/organization.html | 5 + tests/browser/conftest.py | 6 + tests/browser/test_mobile_layout_overflow.py | 15 +- tests/conftest.py | 13 + tests/routers/app/test_billing.py | 463 +++++++++++++++++++ tests/routers/core/test_organization.py | 12 +- tests/utils/app/test_billing_helpers.py | 151 ++++++ tests/utils/app/test_credentials.py | 37 ++ tests/utils/test_db.py | 47 +- tests/utils/test_models.py | 13 +- user_guide/00-overview.qmd | 4 +- user_guide/04-customization.qmd | 6 + user_guide/07-billing.qmd | 154 ++++++ utils/app/billing.py | 239 ++++++++++ utils/app/credentials.py | 29 ++ utils/app/enums.py | 52 ++- utils/app/models.py | 51 +- utils/app/stripe_billing.py | 341 ++++++++++++++ utils/core/auth.py | 2 +- utils/core/db.py | 47 +- utils/core/dependencies.py | 18 + utils/core/models.py | 36 +- uv.lock | 15 + 34 files changed, 2448 insertions(+), 126 deletions(-) create mode 100644 migrations/add_billing_tables.py create mode 100644 routers/app/billing.py create mode 100644 templates/billing/billing.html create mode 100644 tests/routers/app/test_billing.py create mode 100644 tests/utils/app/test_billing_helpers.py create mode 100644 tests/utils/app/test_credentials.py create mode 100644 user_guide/07-billing.qmd create mode 100644 utils/app/billing.py create mode 100644 utils/app/credentials.py create mode 100644 utils/app/stripe_billing.py diff --git a/.env.example b/.env.example index f58040a..56d2496 100644 --- a/.env.example +++ b/.env.example @@ -1,40 +1,10 @@ -# Secret key for JWT -SECRET_KEY= - -# Base URL -BASE_URL=http://localhost:8000 - -# Database connection mode (0 = direct, 1 = pooled) -USE_POOL=0 - -# Shared database settings -DB_HOST=127.0.0.1 -DB_SSLMODE=prefer - -# Direct connection settings (when USE_POOL=0) -DB_USER=postgres -DB_PASSWORD=postgres -DB_PORT=5432 -DB_NAME= - -# Pooled connection settings (when USE_POOL=1) -# DB_APPUSER= -# DB_APPUSER_PASSWORD= -# DB_POOL_PORT=6543 -# DB_POOL_NAME= - -# Domain for production (used by Caddy for TLS) -DOMAIN= - -# Rate limit storage: memory (single process) or postgres (multi-worker) -# RATE_LIMIT_BACKEND=memory - -# Comma-separated reverse-proxy peer IPs for X-Forwarded-For (e.g. 127.0.0.1,::1) -# TRUSTED_PROXY_IPS= - -# Set to 0 to disable CSRF checks (not recommended in production) -# CSRF_ENABLED=1 - -# Resend -RESEND_API_KEY= -EMAIL_FROM= \ No newline at end of file +# Billing (set to 0 to disable Stripe and hide billing routes/UI) +BILLING_ENABLED=1 + +# Stripe billing (required when BILLING_ENABLED=1) +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_ID= +STRIPE_PLAN_NAME=Pro +# Set to 0 to disable Stripe Tax automatic calculation at Checkout +STRIPE_TAX_ENABLED=1 diff --git a/exceptions/http_exceptions.py b/exceptions/http_exceptions.py index 99216fe..abfd9fb 100644 --- a/exceptions/http_exceptions.py +++ b/exceptions/http_exceptions.py @@ -248,6 +248,17 @@ def __init__( ) +class StripeSubscriptionCancelError(HTTPException): + def __init__(self) -> None: + super().__init__( + status_code=409, + detail=( + "Could not cancel the Stripe subscription for this organization. " + "Open Billing, cancel the subscription in Stripe, and try deleting again." + ), + ) + + class CsrfError(HTTPException): """Raised when CSRF validation fails.""" diff --git a/main.py b/main.py index d2f7935..a77562f 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,10 @@ import logging from contextlib import asynccontextmanager + from dotenv import load_dotenv + +load_dotenv() + from fastapi import FastAPI, Request, Depends, status from fastapi.responses import RedirectResponse, Response from fastapi.staticfiles import StaticFiles @@ -46,6 +50,9 @@ RateLimitError, ) from exceptions.exceptions import NeedsNewTokens +from routers.app import billing as billing_router +from utils.app.credentials import billing_enabled, validate_billing_environment +from utils.app.billing import billing_nav_href from utils.core.db import set_up_db logger = logging.getLogger("uvicorn.error") @@ -54,11 +61,9 @@ @asynccontextmanager async def lifespan(app: FastAPI): - # Optional startup logic - load_dotenv() + validate_billing_environment() set_up_db() yield - # Optional shutdown logic # Initialize the FastAPI app @@ -80,6 +85,38 @@ async def lifespan(app: FastAPI): # server-side, then clears the cookie on the response. +@app.middleware("http") +async def nav_billing_middleware(request: Request, call_next): + request.state.billing_nav_href = None + if billing_enabled(): + access_token = request.cookies.get("access_token") + if access_token: + from sqlmodel import Session, select + from sqlalchemy.orm import selectinload + + from utils.core.db import get_engine + from utils.core.dependencies import get_optional_user_from_access_token + from utils.core.models import Role, User + + engine = get_engine() + with Session(engine) as session: + user = get_optional_user_from_access_token(access_token, session) + if user and user.id is not None: + eager_user = session.exec( + select(User) + .where(User.id == user.id) + .options( + selectinload(User.roles).selectinload(Role.organization), + selectinload(User.roles).selectinload(Role.permissions), + ) + ).first() + if eager_user is not None: + request.state.billing_nav_href = billing_nav_href( + request, eager_user + ) + return await call_next(request) + + @app.middleware("http") async def flash_cookie_middleware(request: Request, call_next): flash = get_flash_cookie(request) @@ -96,9 +133,10 @@ async def csrf_middleware(request: Request, call_next): request.state.csrf_token = token if csrf_enabled() and request.method in UNSAFE_HTTP_METHODS: - submitted = await extract_submitted_csrf_token(request) - if not validate_csrf_token(request, submitted): - return await csrf_error_handler(request, CsrfError()) + if not request.url.path.startswith("/webhooks/"): + submitted = await extract_submitted_csrf_token(request) + if not validate_csrf_token(request, submitted): + return await csrf_error_handler(request, CsrfError()) response = await call_next(request) if request.cookies.get(CSRF_COOKIE_NAME) != token: @@ -116,6 +154,9 @@ async def csrf_middleware(request: Request, call_next): app.include_router(role.router) app.include_router(static_pages.router) app.include_router(user.router) +if billing_enabled(): + app.include_router(billing_router.router) + app.include_router(billing_router.webhook_router) # --- Exception Handling Middlewares --- diff --git a/migrations/add_billing_tables.py b/migrations/add_billing_tables.py new file mode 100644 index 0000000..205a33c --- /dev/null +++ b/migrations/add_billing_tables.py @@ -0,0 +1,124 @@ +""" +Add billing tables for organization-scoped Stripe subscriptions. + +Usage: + uv run python -m migrations.add_billing_tables .env + uv run python -m migrations.add_billing_tables .env --apply +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass + +from dotenv import load_dotenv +from sqlalchemy import text +from sqlmodel import Session, create_engine + +from utils.core.db import get_connection_url + +TABLES = ("organizationbilling", "stripewebhookevent") + + +@dataclass +class MigrationStats: + missing_tables: tuple[str, ...] = () + all_present: bool = False + + +def _table_exists(session: Session, table_name: str) -> bool: + result = session.connection().execute( + text( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = :table_name + """ + ), + {"table_name": table_name}, + ) + return result.first() is not None + + +def add_billing_tables(env_file: str, apply: bool) -> MigrationStats: + load_dotenv(env_file, override=True) + engine = create_engine(get_connection_url()) + stats = MigrationStats() + + try: + with Session(engine) as session: + missing = tuple( + table for table in TABLES if not _table_exists(session, table) + ) + stats.missing_tables = missing + stats.all_present = not missing + + if apply and missing: + session.connection().execute( + text( + """ + CREATE TABLE IF NOT EXISTS organizationbilling ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL UNIQUE REFERENCES organization(id) ON DELETE CASCADE, + stripe_customer_id VARCHAR, + stripe_subscription_id VARCHAR, + status VARCHAR NOT NULL DEFAULT 'none', + price_id VARCHAR, + current_period_start TIMESTAMP, + current_period_end TIMESTAMP, + last_payment_at TIMESTAMP, + cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_organizationbilling_organization_id + ON organizationbilling (organization_id); + CREATE INDEX IF NOT EXISTS ix_organizationbilling_stripe_customer_id + ON organizationbilling (stripe_customer_id); + CREATE INDEX IF NOT EXISTS ix_organizationbilling_stripe_subscription_id + ON organizationbilling (stripe_subscription_id); + CREATE INDEX IF NOT EXISTS ix_organizationbilling_status + ON organizationbilling (status); + + CREATE TABLE IF NOT EXISTS stripewebhookevent ( + id SERIAL PRIMARY KEY, + stripe_event_id VARCHAR NOT NULL UNIQUE, + processed_at TIMESTAMP NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_stripewebhookevent_stripe_event_id + ON stripewebhookevent (stripe_event_id); + """ + ) + ) + session.commit() + else: + session.rollback() + finally: + engine.dispose() + + return stats + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("env_file", help="Path to .env file") + parser.add_argument( + "--apply", + action="store_true", + help="Apply migration (default is dry-run)", + ) + args = parser.parse_args() + stats = add_billing_tables(args.env_file, apply=args.apply) + if stats.all_present: + print("All billing tables already exist.") + return + print("Missing tables:", ", ".join(stats.missing_tables)) + if args.apply: + print("Migration applied.") + else: + print("Dry run only. Re-run with --apply to create tables.") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 71dbd27..54e8c36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "fastapi<1.0.0,>=0.115.5", "pillow>=11.0.0", "psycopg2-binary>=2.9.10", + "stripe>=15.3.0", ] [dependency-groups] diff --git a/routers/app/billing.py b/routers/app/billing.py new file mode 100644 index 0000000..329b68d --- /dev/null +++ b/routers/app/billing.py @@ -0,0 +1,320 @@ +"""Organization billing routes (Stripe Checkout, Customer Portal, webhooks).""" + +from __future__ import annotations + +import logging +import uuid + +import stripe +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlmodel import Session + +from exceptions.http_exceptions import ( + InsufficientPermissionsError, + OrganizationNotFoundError, +) +from utils.app.billing import ( + billing_status, + billing_status_label, + can_manage_billing_portal, + can_subscribe_to_billing, + get_org_billing, + org_may_start_checkout, + organization_id_from_metadata, +) +from utils.app.enums import AppPermissions +from utils.app.stripe_billing import ( + WebhookHandleResult, + claim_webhook_event, + construct_webhook_event, + create_checkout_session, + create_portal_session, + handle_stripe_webhook_event, + release_webhook_event_claim, + retrieve_checkout_session, + stripe_settings, +) +from utils.core.dependencies import get_session, get_user_with_relations +from utils.core.htmx import htmx_redirect, is_htmx_request, set_flash_cookie +from utils.core.models import User + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/organizations", tags=["billing"]) +webhook_router = APIRouter(prefix="/webhooks", tags=["webhooks"]) +templates = Jinja2Templates(directory="templates") + + +def _require_org_member(user: User, org_id: int): + org = next((item for item in user.organizations if item.id == org_id), None) + if org is None: + raise OrganizationNotFoundError() + return org + + +def _user_permissions_for_org(user: User, org_id: int) -> set: + permissions: set = set() + for role in user.roles: + if role.organization_id == org_id: + for permission in role.permissions: + permissions.add(permission.name) + return permissions + + +@router.get("/{org_id}/billing") +async def read_billing( + org_id: int, + request: Request, + user: User = Depends(get_user_with_relations), + session: Session = Depends(get_session), +): + org = _require_org_member(user, org_id) + permissions = _user_permissions_for_org(user, org_id) + if AppPermissions.VIEW_BILLING not in permissions: + raise InsufficientPermissionsError() + + billing = get_org_billing(session, org_id) + status = billing_status(billing) + settings = stripe_settings() + can_manage = AppPermissions.MANAGE_BILLING in permissions + + return templates.TemplateResponse( + request, + "billing/billing.html", + { + "user": user, + "organization": org, + "billing": billing, + "billing_status": status, + "billing_status_label": billing_status_label(status), + "plan_name": settings.plan_name, + "can_manage_billing": can_manage, + "can_subscribe": can_subscribe_to_billing( + can_manage=can_manage, + status=status, + billing=billing, + ), + "can_manage_portal": can_manage_billing_portal( + can_manage=can_manage, + billing=billing, + status=status, + ), + }, + ) + + +def _redirect_to_billing( + request: Request, org_id: int, *, message: str, level: str = "success" +) -> Response: + response = RedirectResponse( + url=router.url_path_for("read_billing", org_id=org_id), + status_code=303, + ) + set_flash_cookie(response, message, level=level) + return response + + +def _redirect_to_stripe(request: Request, url: str) -> Response: + if is_htmx_request(request): + response = Response(status_code=200) + htmx_redirect(response, url) + return response + return RedirectResponse(url=url, status_code=303) + + +@router.post("/{org_id}/billing/checkout") +async def start_checkout( + org_id: int, + request: Request, + user: User = Depends(get_user_with_relations), + session: Session = Depends(get_session), +): + _require_org_member(user, org_id) + if not user.has_permission(AppPermissions.MANAGE_BILLING, org_id): + raise InsufficientPermissionsError() + + if not org_may_start_checkout(session, org_id): + logger.warning( + "Checkout blocked for org_id=%s: organization already has an active subscription", + org_id, + ) + raise InsufficientPermissionsError( + "This organization already has an active subscription." + ) + + assert user.account is not None + billing = get_org_billing(session, org_id) + idempotency_key = f"checkout-org-{org_id}-{uuid.uuid4().hex}" + try: + checkout = create_checkout_session( + org_id=org_id, + owner_email=user.account.email, + customer_id=billing.stripe_customer_id if billing else None, + idempotency_key=idempotency_key, + ) + except stripe.StripeError: + logger.exception( + "Stripe Checkout session creation failed for org_id=%s", org_id + ) + raise HTTPException( + status_code=503, + detail="Unable to start checkout. Please try again later.", + ) from None + + checkout_url = checkout.url + if not checkout_url: + raise InsufficientPermissionsError("Stripe did not return a checkout URL.") + return _redirect_to_stripe(request, checkout_url) + + +@router.post("/{org_id}/billing/portal") +async def start_customer_portal( + org_id: int, + request: Request, + user: User = Depends(get_user_with_relations), + session: Session = Depends(get_session), +): + _require_org_member(user, org_id) + if not user.has_permission(AppPermissions.MANAGE_BILLING, org_id): + raise InsufficientPermissionsError() + + billing = get_org_billing(session, org_id) + if billing is None or not billing.stripe_customer_id: + raise InsufficientPermissionsError( + "Subscribe first before managing billing details." + ) + + try: + portal = create_portal_session( + org_id=org_id, + customer_id=billing.stripe_customer_id, + ) + except stripe.StripeError: + logger.exception("Stripe Customer Portal session failed for org_id=%s", org_id) + raise HTTPException( + status_code=503, + detail="Unable to open billing portal. Please try again later.", + ) from None + + portal_url = portal.url + if not portal_url: + raise InsufficientPermissionsError("Stripe did not return a portal URL.") + return _redirect_to_stripe(request, portal_url) + + +@router.get("/{org_id}/billing/success") +async def billing_checkout_success( + org_id: int, + request: Request, + session_id: str | None = Query(default=None), + user: User = Depends(get_user_with_relations), + session: Session = Depends(get_session), +): + _require_org_member(user, org_id) + if not session_id: + return _redirect_to_billing( + request, + org_id, + message="Checkout could not be verified. Check billing status shortly.", + level="info", + ) + + try: + checkout_session = retrieve_checkout_session(session_id) + except stripe.StripeError: + logger.exception( + "Failed to verify Stripe checkout session %s for org_id=%s", + session_id, + org_id, + ) + return _redirect_to_billing( + request, + org_id, + message="Checkout could not be verified. Check billing status shortly.", + level="info", + ) + + checkout_org_id = organization_id_from_metadata( + getattr(checkout_session, "metadata", None) + ) + if checkout_org_id is None: + checkout_org_id = organization_id_from_metadata( + {"organization_id": getattr(checkout_session, "client_reference_id", None)} + ) + if checkout_org_id != org_id: + raise InsufficientPermissionsError( + "Checkout session does not match organization." + ) + + payment_status = getattr(checkout_session, "payment_status", None) + checkout_status = getattr(checkout_session, "status", None) + if payment_status != "paid" and checkout_status != "complete": + return _redirect_to_billing( + request, + org_id, + message="Checkout is not complete yet. Billing status will update shortly.", + level="info", + ) + + response = RedirectResponse( + url=router.url_path_for("read_billing", org_id=org_id), + status_code=303, + ) + set_flash_cookie( + response, + "Subscription checkout completed. Billing status will update shortly.", + ) + return response + + +@router.get("/{org_id}/billing/cancel") +async def billing_checkout_cancel( + org_id: int, + request: Request, + user: User = Depends(get_user_with_relations), +): + _require_org_member(user, org_id) + response = RedirectResponse( + url=router.url_path_for("read_billing", org_id=org_id), + status_code=303, + ) + set_flash_cookie(response, "Checkout canceled.", level="info") + return response + + +@webhook_router.post("/stripe") +async def stripe_webhook( + request: Request, + session: Session = Depends(get_session), +): + payload = await request.body() + signature = request.headers.get("stripe-signature") + if not signature: + logger.warning("Stripe webhook rejected: missing Stripe-Signature header") + return Response(status_code=400, content="Missing Stripe-Signature header") + + try: + event = construct_webhook_event(payload, signature) + except ValueError: + logger.warning("Stripe webhook rejected: invalid payload") + return Response(status_code=400, content="Invalid payload") + except stripe.SignatureVerificationError: + logger.warning("Stripe webhook rejected: invalid signature") + return Response(status_code=400, content="Invalid signature") + + if not claim_webhook_event(session, event.id): + logger.info( + "Skipping duplicate Stripe webhook event %s (%s)", + event.id, + event.type, + ) + return Response(status_code=200, content='{"ok": true}') + + result = handle_stripe_webhook_event(session, event) + if result is WebhookHandleResult.FAILED: + release_webhook_event_claim(session, event.id) + return Response(status_code=500, content="Webhook handler failed") + + return Response(status_code=200, content='{"ok": true}') diff --git a/routers/core/organization.py b/routers/core/organization.py index 4c79312..3f4c15d 100644 --- a/routers/core/organization.py +++ b/routers/core/organization.py @@ -23,6 +23,8 @@ UserAlreadyMemberError, DataIntegrityError, ) +from utils.app.billing import cancel_org_stripe_subscription +from utils.app.credentials import billing_enabled from pydantic import EmailStr from utils.core.htmx import is_htmx_request, set_flash_cookie @@ -81,8 +83,10 @@ async def read_organization( "user": user, "user_permissions": user_permissions, "ValidPermissions": ValidPermissions, + "AppPermissions": AppPermissions, "all_permissions": list(ValidPermissions) + list(AppPermissions), "pending_invitations": pending_invitations, + "billing_enabled": billing_enabled(), }, ) @@ -249,6 +253,7 @@ def delete_organization( logger.info( f"User {user.id} deleting organization {org_id} ('{organization.name}')." ) + cancel_org_stripe_subscription(session, org_id) session.delete(organization) session.commit() diff --git a/static/css/extras.css b/static/css/extras.css index 1eb9ccb..41d2624 100644 --- a/static/css/extras.css +++ b/static/css/extras.css @@ -434,6 +434,68 @@ input:invalid:not(:placeholder-shown) + .invalid-feedback { } } +/* Site footer: align horizontal padding with navbar; spread copyright and contact. */ +.site-footer-inner { + padding-inline: 1.25rem; +} + +.site-footer-row { + display: grid; + gap: 1.25rem; + text-align: center; +} + +.site-footer-copyright, +.site-footer-links, +.site-footer-contact { + min-width: 0; +} + +.site-footer-heading { + font-family: var(--font-display); + font-weight: 600; + font-size: 1rem; + line-height: 1.15; + letter-spacing: -0.015em; + color: var(--muted); + margin: 0 0 0.5rem; +} + +.site-footer-body, +.site-footer-body a { + font-family: var(--font-body); + font-size: 1rem; + line-height: 1.55; + color: var(--muted); +} + +.site-footer-body a:hover { + color: var(--primary); +} + +@media (min-width: 992px) { + .site-footer-row { + grid-template-columns: 1fr auto 1fr; + align-items: end; + gap: 2rem; + } + + .site-footer-copyright { + justify-self: start; + text-align: start; + } + + .site-footer-links { + justify-self: center; + text-align: center; + } + + .site-footer-contact { + justify-self: end; + text-align: end; + } +} + /* Pending invitations in organization members card. */ .invitation-list-item-body { display: flex; @@ -517,4 +579,49 @@ input:invalid:not(:placeholder-shown) + .invalid-feedback { min-width: 0; width: 100%; } +} + +/* --- Billing page --- */ + +.billing-page-eyebrow { + font-family: var(--font-mono); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.billing-status-card .card-header { + background: var(--surface-elevated, #f8fafb); +} + +.billing-status-badge { + font-family: var(--font-mono); + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.billing-status-badge-none, +.billing-status-badge-canceled { + background: rgba(28, 42, 48, 0.08); + color: var(--slate, #1c2a30); +} + +.billing-status-badge-active, +.billing-status-badge-trialing { + background: rgba(14, 124, 123, 0.12); + color: var(--primary, #0e7c7b); +} + +.billing-status-badge-past_due, +.billing-status-badge-unpaid, +.billing-status-badge-incomplete { + background: rgba(180, 35, 24, 0.12); + color: #b42318; +} + +.billing-details-list dt { + font-family: var(--font-mono); + font-size: 0.8rem; + color: rgba(28, 42, 48, 0.7); } \ No newline at end of file diff --git a/templates/base/partials/footer.html b/templates/base/partials/footer.html index 550c4c1..af9b546 100644 --- a/templates/base/partials/footer.html +++ b/templates/base/partials/footer.html @@ -1,28 +1,35 @@ - diff --git a/templates/base/partials/header.html b/templates/base/partials/header.html index 1dcf509..701aa16 100644 --- a/templates/base/partials/header.html +++ b/templates/base/partials/header.html @@ -18,11 +18,16 @@ {% include 'base/partials/nav.html' %} {% if user %} - +