diff --git a/.env.example b/.env.example index 204d6d6..daa3ef8 100644 --- a/.env.example +++ b/.env.example @@ -41,4 +41,12 @@ DOMAIN= # Resend RESEND_API_KEY= -EMAIL_FROM= \ No newline at end of file +EMAIL_FROM= + +# Stripe billing (required on the stripe branch) +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..f3de8a2 100644 --- a/exceptions/http_exceptions.py +++ b/exceptions/http_exceptions.py @@ -248,6 +248,41 @@ 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 ActiveSubscriptionError(HTTPException): + def __init__(self) -> None: + super().__init__( + status_code=409, + detail="This organization already has an active subscription.", + ) + + +class StripeServiceUnavailableError(HTTPException): + def __init__(self, action: str) -> None: + super().__init__( + status_code=503, + detail=f"Unable to {action}. Please try again later.", + ) + + +class StripeSessionError(HTTPException): + def __init__(self, session_kind: str) -> None: + super().__init__( + status_code=502, + detail=f"Stripe did not return a {session_kind} URL.", + ) + + class CsrfError(HTTPException): """Raised when CSRF validation fails.""" diff --git a/main.py b/main.py index c89d72f..68a12de 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ import logging from contextlib import asynccontextmanager + from dotenv import load_dotenv from fastapi import FastAPI, Request, Depends, status from fastapi.responses import RedirectResponse, Response @@ -46,6 +47,9 @@ RateLimitError, ) from exceptions.exceptions import NeedsNewTokens +from routers.app import billing as billing_router +from utils.app.credentials import validate_billing_environment +from utils.app.billing import resolve_billing_nav_href, should_resolve_billing_nav from utils.core.db import set_up_db, clear_engine_cache logger = logging.getLogger("uvicorn.error") @@ -54,8 +58,8 @@ @asynccontextmanager async def lifespan(app: FastAPI): - # Optional startup logic load_dotenv() + validate_billing_environment() set_up_db() yield clear_engine_cache() @@ -80,6 +84,18 @@ 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 should_resolve_billing_nav(request): + access_token = request.cookies.get("access_token") + if access_token: + request.state.billing_nav_href = resolve_billing_nav_href( + request, access_token + ) + return await call_next(request) + + @app.middleware("http") async def flash_cookie_middleware(request: Request, call_next): flash = get_flash_cookie(request) @@ -96,9 +112,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 +133,8 @@ async def csrf_middleware(request: Request, call_next): app.include_router(role.router) app.include_router(static_pages.router) app.include_router(user.router) +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 c5afb86..cadceba 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..438b746 --- /dev/null +++ b/routers/app/billing.py @@ -0,0 +1,305 @@ +"""Organization billing routes (Stripe Checkout, Customer Portal, webhooks).""" + +from __future__ import annotations + +import logging +import uuid + +import stripe +from fastapi import APIRouter, Depends, Query, Request, Response +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlmodel import Session + +from exceptions.http_exceptions import ( + ActiveSubscriptionError, + InsufficientPermissionsError, + OrganizationNotFoundError, + StripeServiceUnavailableError, + StripeSessionError, +) +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 + + +@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) + if not user.has_permission(AppPermissions.VIEW_BILLING, org_id): + raise InsufficientPermissionsError() + + billing = get_org_billing(session, org_id) + status = billing_status(billing) + settings = stripe_settings() + can_manage = user.has_permission(AppPermissions.MANAGE_BILLING, org_id) + + 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 ActiveSubscriptionError() + + 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 StripeServiceUnavailableError("start checkout") from None + + checkout_url = checkout.url + if not checkout_url: + raise StripeSessionError("checkout") + 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 StripeServiceUnavailableError("open billing portal") from None + + portal_url = portal.url + if not portal_url: + raise StripeSessionError("portal") + 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 in {WebhookHandleResult.FAILED, WebhookHandleResult.RETRYABLE}: + 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..70feef9 100644 --- a/routers/core/organization.py +++ b/routers/core/organization.py @@ -23,6 +23,7 @@ UserAlreadyMemberError, DataIntegrityError, ) +from utils.app.billing import cancel_org_stripe_subscription from pydantic import EmailStr from utils.core.htmx import is_htmx_request, set_flash_cookie @@ -81,6 +82,7 @@ async def read_organization( "user": user, "user_permissions": user_permissions, "ValidPermissions": ValidPermissions, + "AppPermissions": AppPermissions, "all_permissions": list(ValidPermissions) + list(AppPermissions), "pending_invitations": pending_invitations, }, @@ -249,6 +251,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 4b5e4f5..41d2624 100644 --- a/static/css/extras.css +++ b/static/css/extras.css @@ -579,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/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 %} - +