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 %}
-
+
-
Profile
+ {% if request.state.billing_nav_href %}
+ -
+ Billing
+
+ {% endif %}
-
Logout
@@ -38,6 +43,9 @@
diff --git a/templates/billing/billing.html b/templates/billing/billing.html
new file mode 100644
index 0000000..db37307
--- /dev/null
+++ b/templates/billing/billing.html
@@ -0,0 +1,82 @@
+{% extends "base.html" %}
+
+{% block title %}Billing — {{ organization.name }}{% endblock %}
+
+{% block content %}
+
+
+
+
Organization billing
+
{{ organization.name }}
+
+
+
+
+
+
+
+
+ - Organization
+ - {{ organization.name }}
+
+ {% if billing and billing.current_period_end %}
+ - {% if billing.cancel_at_period_end %}Access until{% else %}Renews on{% endif %}
+ - {{ billing.current_period_end.strftime('%Y-%m-%d %H:%M UTC') }}
+ {% endif %}
+
+ {% if billing and billing.last_payment_at %}
+ - Last payment
+ - {{ billing.last_payment_at.strftime('%Y-%m-%d %H:%M UTC') }}
+ {% endif %}
+
+ {% if billing and billing.cancel_at_period_end %}
+ - Cancellation
+ - Scheduled at period end
+ {% endif %}
+
+
+ {% if can_manage_billing %}
+
+ {% if can_subscribe %}
+
+ {% endif %}
+
+ {% if can_manage_portal %}
+
+ {% endif %}
+
+
+ Checkout and payment method updates are handled securely by Stripe.
+ {% if billing_status.value == 'past_due' %}
+ Your subscription is past due — use Manage billing to update your payment method.
+ {% elif billing_status.value == 'incomplete' %}
+ Your last checkout did not finish — use Subscribe with Stripe to try again.
+ {% endif %}
+
+ {% else %}
+
+ Only the organization owner can start or manage billing.
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/templates/organization/organization.html b/templates/organization/organization.html
index 6bc3a4e..964737d 100644
--- a/templates/organization/organization.html
+++ b/templates/organization/organization.html
@@ -19,6 +19,11 @@
Delete Organization
{% endif %}
+ {% if AppPermissions.VIEW_BILLING in user_permissions %}
+
+ Billing
+
+ {% endif %}
diff --git a/tests/browser/conftest.py b/tests/browser/conftest.py
index f88cd56..aede2b4 100644
--- a/tests/browser/conftest.py
+++ b/tests/browser/conftest.py
@@ -120,11 +120,21 @@ def _start_live_server(env: dict[str, str], port: int) -> subprocess.Popen:
return proc
+_STRIPE_BROWSER_ENV = {
+ "STRIPE_SECRET_KEY": "sk_test_dummy",
+ "STRIPE_WEBHOOK_SECRET": "whsec_test_dummy",
+ "STRIPE_PRICE_ID": "price_test_dummy",
+ "STRIPE_PLAN_NAME": "Pro",
+ "STRIPE_TAX_ENABLED": "0",
+}
+
+
@pytest.fixture(scope="session")
def browser_env():
"""Build an environment dict for the live server subprocess."""
env = _apply_rate_limit_env(browser_db_env())
env["BASE_URL"] = "http://127.0.0.1:8113"
+ env.update(_STRIPE_BROWSER_ENV)
env["CSRF_ENABLED"] = "0"
return env
@@ -135,6 +145,7 @@ def browser_csrf_env():
env = _apply_rate_limit_env(browser_db_env())
env["DB_NAME"] = "webapp-browser-csrf-test-db"
env["BASE_URL"] = "http://127.0.0.1:8114"
+ env.update(_STRIPE_BROWSER_ENV)
env["CSRF_ENABLED"] = "1"
return env
diff --git a/tests/conftest.py b/tests/conftest.py
index 1067b15..9486349 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -28,6 +28,12 @@
create_access_token,
create_tracked_refresh_token,
)
+
+load_dotenv()
+os.environ.setdefault("STRIPE_SECRET_KEY", "sk_test_dummy")
+os.environ.setdefault("STRIPE_WEBHOOK_SECRET", "whsec_test_dummy")
+os.environ.setdefault("STRIPE_PRICE_ID", "price_test_dummy")
+
from main import app
from datetime import datetime, UTC, timedelta
from utils.core.rate_limit import clear_all_rate_limiters
@@ -66,6 +72,11 @@ def env_vars(monkeypatch):
m.setenv("RESEND_API_KEY", "test")
m.setenv("EMAIL_FROM", "test@example.com")
m.setenv("BASE_URL", "http://localhost:8000")
+ m.setenv("STRIPE_SECRET_KEY", "sk_test_dummy")
+ m.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test_dummy")
+ m.setenv("STRIPE_PRICE_ID", "price_test_dummy")
+ m.setenv("STRIPE_PLAN_NAME", "Pro")
+ m.setenv("STRIPE_TAX_ENABLED", "0")
m.setenv("CSRF_ENABLED", "0")
yield
diff --git a/tests/routers/app/test_billing.py b/tests/routers/app/test_billing.py
new file mode 100644
index 0000000..17c1ba8
--- /dev/null
+++ b/tests/routers/app/test_billing.py
@@ -0,0 +1,500 @@
+"""Router tests for organization billing."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlmodel import Session, select
+
+from main import app
+from utils.app.billing import get_org_billing, sync_billing_from_subscription
+from utils.app.enums import BillingStatus
+from utils.app.models import StripeWebhookEvent
+from utils.app.stripe_billing import WebhookHandleResult, handle_stripe_webhook_event
+from utils.core.models import Organization, utc_now
+
+
+@pytest.fixture
+def auth_client_member(
+ session: Session, test_organization: Organization, org_member_user
+) -> TestClient:
+ from utils.core.auth import create_access_token, create_tracked_refresh_token
+
+ client = TestClient(app, follow_redirects=False)
+ assert org_member_user.account is not None
+ access_token = create_access_token({"sub": org_member_user.account.email})
+ refresh_token = create_tracked_refresh_token(
+ org_member_user.account_id, org_member_user.account.email, session
+ )
+ session.commit()
+ client.cookies.set("access_token", access_token)
+ client.cookies.set("refresh_token", refresh_token)
+ return client
+
+
+def test_billing_page_owner_can_view(
+ auth_client_owner: TestClient, test_organization: Organization
+) -> None:
+ assert test_organization.id is not None
+ response = auth_client_owner.get(
+ app.url_path_for("read_billing", org_id=test_organization.id)
+ )
+ assert response.status_code == 200
+ assert "Subscribe with Stripe" in response.text
+ assert "Pro plan" in response.text
+
+
+def test_billing_page_member_can_view(
+ auth_client_member: TestClient,
+ test_organization: Organization,
+) -> None:
+ assert test_organization.id is not None
+ response = auth_client_member.get(
+ app.url_path_for("read_billing", org_id=test_organization.id)
+ )
+ assert response.status_code == 200
+ assert "Only the organization owner" in response.text
+
+
+def test_checkout_owner_redirects_to_stripe(
+ auth_client_owner: TestClient,
+ test_organization: Organization,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ assert test_organization.id is not None
+
+ class FakeCheckout:
+ url = "https://checkout.stripe.test/session_123"
+
+ monkeypatch.setattr(
+ "routers.app.billing.create_checkout_session",
+ lambda **kwargs: FakeCheckout(),
+ )
+
+ response = auth_client_owner.post(
+ app.url_path_for("start_checkout", org_id=test_organization.id)
+ )
+ assert response.status_code == 303
+ assert response.headers["location"] == FakeCheckout.url
+
+
+def test_checkout_rejects_active_subscription(
+ auth_client_owner: TestClient,
+ session: Session,
+ test_organization: Organization,
+) -> None:
+ assert test_organization.id is not None
+ sync_billing_from_subscription(
+ session,
+ org_id=test_organization.id,
+ stripe_customer_id="cus_active",
+ stripe_subscription_id="sub_active",
+ status=BillingStatus.ACTIVE.value,
+ price_id="price_test_dummy",
+ current_period_start=None,
+ current_period_end=None,
+ cancel_at_period_end=False,
+ )
+
+ response = auth_client_owner.post(
+ app.url_path_for("start_checkout", org_id=test_organization.id)
+ )
+ assert response.status_code == 409
+
+
+def test_checkout_member_forbidden(
+ auth_client_member: TestClient,
+ test_organization: Organization,
+) -> None:
+ assert test_organization.id is not None
+ response = auth_client_member.post(
+ app.url_path_for("start_checkout", org_id=test_organization.id)
+ )
+ assert response.status_code == 403
+
+
+def test_portal_requires_customer(
+ auth_client_owner: TestClient, test_organization: Organization
+) -> None:
+ assert test_organization.id is not None
+ response = auth_client_owner.post(
+ app.url_path_for("start_customer_portal", org_id=test_organization.id)
+ )
+ assert response.status_code == 403
+
+
+def test_billing_success_requires_verified_checkout_session(
+ auth_client_owner: TestClient,
+ test_organization: Organization,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ assert test_organization.id is not None
+
+ class FakeCheckoutSession:
+ metadata = {"organization_id": str(test_organization.id)}
+ client_reference_id = str(test_organization.id)
+ payment_status = "paid"
+ status = "complete"
+
+ monkeypatch.setattr(
+ "routers.app.billing.retrieve_checkout_session",
+ lambda session_id: FakeCheckoutSession(),
+ )
+
+ response = auth_client_owner.get(
+ app.url_path_for(
+ "billing_checkout_success",
+ org_id=test_organization.id,
+ ),
+ params={"session_id": "cs_test_verified"},
+ )
+ assert response.status_code == 303
+ assert response.headers["location"].endswith(
+ f"/organizations/{test_organization.id}/billing"
+ )
+
+
+def test_billing_success_without_session_id_is_informational(
+ auth_client_owner: TestClient,
+ test_organization: Organization,
+) -> None:
+ assert test_organization.id is not None
+ response = auth_client_owner.get(
+ app.url_path_for("billing_checkout_success", org_id=test_organization.id)
+ )
+ assert response.status_code == 303
+
+
+def test_webhook_rejects_missing_signature(unauth_client: TestClient) -> None:
+ response = unauth_client.post(
+ "/webhooks/stripe",
+ content=b"{}",
+ headers={"Content-Type": "application/json"},
+ )
+ assert response.status_code == 400
+
+
+def test_webhook_checkout_completed_updates_billing(
+ unauth_client: TestClient,
+ session: Session,
+ test_organization: Organization,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ assert test_organization.id is not None
+ org_id = test_organization.id
+
+ subscription = SimpleNamespace(
+ id="sub_test",
+ customer="cus_test",
+ status="active",
+ metadata={"organization_id": str(org_id)},
+ current_period_start=1_700_000_000,
+ current_period_end=1_700_086_400,
+ cancel_at_period_end=False,
+ items=SimpleNamespace(
+ data=[SimpleNamespace(price=SimpleNamespace(id="price_test_dummy"))]
+ ),
+ )
+
+ event = SimpleNamespace(
+ id="evt_test_checkout",
+ type="checkout.session.completed",
+ data=SimpleNamespace(
+ object=SimpleNamespace(
+ metadata={"organization_id": str(org_id)},
+ client_reference_id=str(org_id),
+ subscription="sub_test",
+ customer="cus_test",
+ )
+ ),
+ )
+
+ monkeypatch.setattr(
+ "routers.app.billing.construct_webhook_event",
+ lambda payload, signature: event,
+ )
+ monkeypatch.setattr(
+ "utils.app.stripe_billing.stripe.Subscription.retrieve",
+ lambda subscription_id: subscription,
+ )
+
+ response = unauth_client.post(
+ "/webhooks/stripe",
+ content=b"{}",
+ headers={
+ "Content-Type": "application/json",
+ "Stripe-Signature": "test",
+ },
+ )
+ assert response.status_code == 200
+
+ billing = get_org_billing(session, org_id)
+ assert billing is not None
+ assert billing.stripe_customer_id == "cus_test"
+ assert billing.stripe_subscription_id == "sub_test"
+ assert billing.status == BillingStatus.ACTIVE.value
+
+ stored_event = session.exec(
+ select(StripeWebhookEvent).where(
+ StripeWebhookEvent.stripe_event_id == "evt_test_checkout"
+ )
+ ).one()
+ assert stored_event.id is not None
+
+
+def test_webhook_is_idempotent(
+ unauth_client: TestClient,
+ session: Session,
+ test_organization: Organization,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ assert test_organization.id is not None
+ session.add(
+ StripeWebhookEvent(
+ stripe_event_id="evt_duplicate",
+ processed_at=utc_now(),
+ )
+ )
+ session.commit()
+
+ event = SimpleNamespace(
+ id="evt_duplicate",
+ type="checkout.session.completed",
+ data=SimpleNamespace(object=SimpleNamespace()),
+ )
+ monkeypatch.setattr(
+ "routers.app.billing.construct_webhook_event",
+ lambda payload, signature: event,
+ )
+ called = {"count": 0}
+
+ def _fail_handler(db_session: Session, stripe_event: Any) -> WebhookHandleResult:
+ called["count"] += 1
+ return WebhookHandleResult.HANDLED
+
+ monkeypatch.setattr(
+ "routers.app.billing.handle_stripe_webhook_event", _fail_handler
+ )
+
+ response = unauth_client.post(
+ "/webhooks/stripe",
+ content=b"{}",
+ headers={
+ "Content-Type": "application/json",
+ "Stripe-Signature": "test",
+ },
+ )
+ assert response.status_code == 200
+ assert called["count"] == 0
+
+
+def test_webhook_subscription_deleted_clears_subscription_id(
+ session: Session,
+ test_organization: Organization,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ assert test_organization.id is not None
+ sync_billing_from_subscription(
+ session,
+ org_id=test_organization.id,
+ stripe_customer_id="cus_delete_event",
+ stripe_subscription_id="sub_delete_event",
+ status=BillingStatus.ACTIVE.value,
+ price_id="price_test_dummy",
+ current_period_start=None,
+ current_period_end=None,
+ cancel_at_period_end=False,
+ )
+
+ event = SimpleNamespace(
+ id="evt_subscription_deleted",
+ type="customer.subscription.deleted",
+ data=SimpleNamespace(
+ object=SimpleNamespace(
+ customer="cus_delete_event",
+ metadata={"organization_id": str(test_organization.id)},
+ current_period_start=1_700_000_000,
+ current_period_end=1_700_086_400,
+ items=SimpleNamespace(
+ data=[SimpleNamespace(price=SimpleNamespace(id="price_test_dummy"))]
+ ),
+ )
+ ),
+ )
+
+ result = handle_stripe_webhook_event(session, event)
+ assert result is WebhookHandleResult.HANDLED
+
+ billing = get_org_billing(session, test_organization.id)
+ assert billing is not None
+ assert billing.status == BillingStatus.CANCELED.value
+ assert billing.stripe_subscription_id is None
+
+
+def test_webhook_payment_failed_syncs_past_due_status(
+ session: Session,
+ test_organization: Organization,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ assert test_organization.id is not None
+ subscription = SimpleNamespace(
+ id="sub_past_due",
+ customer="cus_past_due",
+ status="past_due",
+ metadata={"organization_id": str(test_organization.id)},
+ current_period_start=1_700_000_000,
+ current_period_end=1_700_086_400,
+ cancel_at_period_end=False,
+ items=SimpleNamespace(
+ data=[SimpleNamespace(price=SimpleNamespace(id="price_test_dummy"))]
+ ),
+ )
+ event = SimpleNamespace(
+ id="evt_payment_failed",
+ type="invoice.payment_failed",
+ data=SimpleNamespace(
+ object=SimpleNamespace(
+ subscription="sub_past_due",
+ metadata={"organization_id": str(test_organization.id)},
+ )
+ ),
+ )
+ monkeypatch.setattr(
+ "utils.app.stripe_billing.stripe.Subscription.retrieve",
+ lambda subscription_id: subscription,
+ )
+
+ result = handle_stripe_webhook_event(session, event)
+ assert result is WebhookHandleResult.HANDLED
+
+ billing = get_org_billing(session, test_organization.id)
+ assert billing is not None
+ assert billing.status == BillingStatus.PAST_DUE.value
+
+
+def test_webhook_missing_metadata_is_retryable(
+ session: Session,
+) -> None:
+ event = SimpleNamespace(
+ id="evt_missing_metadata",
+ type="checkout.session.completed",
+ data=SimpleNamespace(
+ object=SimpleNamespace(
+ metadata={}, client_reference_id=None, subscription=None
+ )
+ ),
+ )
+ result = handle_stripe_webhook_event(session, event)
+ assert result is WebhookHandleResult.RETRYABLE
+
+
+def test_webhook_retryable_releases_claim(
+ unauth_client: TestClient,
+ session: Session,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ event = SimpleNamespace(
+ id="evt_retryable",
+ type="checkout.session.completed",
+ data=SimpleNamespace(
+ object=SimpleNamespace(
+ metadata={}, client_reference_id=None, subscription=None
+ )
+ ),
+ )
+ monkeypatch.setattr(
+ "routers.app.billing.construct_webhook_event",
+ lambda payload, signature: event,
+ )
+
+ response = unauth_client.post(
+ "/webhooks/stripe",
+ content=b"{}",
+ headers={
+ "Content-Type": "application/json",
+ "Stripe-Signature": "test",
+ },
+ )
+ assert response.status_code == 500
+
+ stored = session.exec(
+ select(StripeWebhookEvent).where(
+ StripeWebhookEvent.stripe_event_id == "evt_retryable"
+ )
+ ).first()
+ assert stored is None
+
+
+def test_org_delete_cancels_subscription(
+ auth_client_owner: TestClient,
+ session: Session,
+ test_organization: Organization,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ assert test_organization.id is not None
+ sync_billing_from_subscription(
+ session,
+ org_id=test_organization.id,
+ stripe_customer_id="cus_delete",
+ stripe_subscription_id="sub_delete",
+ status=BillingStatus.ACTIVE.value,
+ price_id="price_test_dummy",
+ current_period_start=None,
+ current_period_end=None,
+ cancel_at_period_end=False,
+ )
+ canceled: list[str] = []
+
+ monkeypatch.setattr(
+ "utils.app.stripe_billing.cancel_subscription",
+ lambda subscription_id: canceled.append(subscription_id) or SimpleNamespace(),
+ )
+
+ response = auth_client_owner.post(
+ app.url_path_for("delete_organization", org_id=test_organization.id)
+ )
+ assert response.status_code in {303, 200}
+ assert canceled == ["sub_delete"]
+
+ remaining = session.exec(
+ select(Organization).where(Organization.id == test_organization.id)
+ ).first()
+ assert remaining is None
+
+
+def test_org_delete_blocked_when_stripe_cancel_fails(
+ auth_client_owner: TestClient,
+ session: Session,
+ test_organization: Organization,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ assert test_organization.id is not None
+ sync_billing_from_subscription(
+ session,
+ org_id=test_organization.id,
+ stripe_customer_id="cus_delete_fail",
+ stripe_subscription_id="sub_delete_fail",
+ status=BillingStatus.ACTIVE.value,
+ price_id="price_test_dummy",
+ current_period_start=None,
+ current_period_end=None,
+ cancel_at_period_end=False,
+ )
+
+ def _raise_cancel(subscription_id: str) -> None:
+ raise RuntimeError("stripe unavailable")
+
+ monkeypatch.setattr("utils.app.stripe_billing.cancel_subscription", _raise_cancel)
+
+ response = auth_client_owner.post(
+ app.url_path_for("delete_organization", org_id=test_organization.id)
+ )
+ assert response.status_code == 409
+
+ remaining = session.exec(
+ select(Organization).where(Organization.id == test_organization.id)
+ ).first()
+ assert remaining is not None
diff --git a/tests/routers/core/test_organization.py b/tests/routers/core/test_organization.py
index a8e34d9..a3c8db5 100644
--- a/tests/routers/core/test_organization.py
+++ b/tests/routers/core/test_organization.py
@@ -1,5 +1,6 @@
from utils.core.models import Organization, Role, Permission, User
from utils.core.enums import ValidPermissions
+from utils.app.enums import AppPermissions
from utils.core.db import create_default_roles
from main import app
from sqlmodel import select
@@ -55,14 +56,19 @@ def test_create_organization_success(auth_client, session, test_user):
expected_admin_permissions = {
p.name
for p in all_permissions
- if p.name != ValidPermissions.DELETE_ORGANIZATION
+ if p.name
+ not in (
+ ValidPermissions.DELETE_ORGANIZATION,
+ AppPermissions.MANAGE_BILLING,
+ )
}
assert admin_permission_names == expected_admin_permissions
- # Verify permissions for Member role (should have none)
+ # Verify permissions for Member role (view billing only)
member_role = next((role for role in roles if role.name == "Member"), None)
assert member_role is not None
- assert len(member_role.permissions) == 0
+ member_permission_names = {p.name for p in member_role.permissions}
+ assert member_permission_names == {AppPermissions.VIEW_BILLING}
def test_create_organization_empty_name(auth_client):
diff --git a/tests/test_get_pages_jinja_integration.py b/tests/test_get_pages_jinja_integration.py
index 35fa17f..04de79f 100644
--- a/tests/test_get_pages_jinja_integration.py
+++ b/tests/test_get_pages_jinja_integration.py
@@ -124,6 +124,7 @@ def test_all_jinja_checked_read_routes_are_in_runtime_matrix(self, route_context
}
runtime_reads.add("read_reset_password")
runtime_reads.add("recover_account_confirm")
+ runtime_reads.add("read_billing")
uncovered = checked_reads - runtime_reads
assert not uncovered, (
@@ -199,6 +200,21 @@ def test_recover_page_renders(
assert_full_page_rendered(response)
assert "recover" in response.text.lower()
+ def test_billing_page_renders(
+ self,
+ auth_client_owner,
+ test_organization,
+ missing_context_variables,
+ ):
+ assert not missing_context_variables
+ assert test_organization.id is not None
+ response = auth_client_owner.get(
+ _url("read_billing", org_id=test_organization.id)
+ )
+ assert_full_page_rendered(response)
+ assert "Organization billing" in response.text
+ assert "Pro plan" in response.text
+
def test_register_with_invitation_token_renders(
self, unauth_client, test_invitation, missing_context_variables
):
diff --git a/tests/utils/app/test_billing_helpers.py b/tests/utils/app/test_billing_helpers.py
new file mode 100644
index 0000000..16317d8
--- /dev/null
+++ b/tests/utils/app/test_billing_helpers.py
@@ -0,0 +1,169 @@
+"""Tests for billing helper functions."""
+
+from datetime import UTC, datetime
+
+from sqlmodel import Session
+
+from utils.app.billing import (
+ billing_status,
+ get_or_create_org_billing,
+ org_has_active_subscription,
+ org_may_start_checkout,
+ should_resolve_billing_nav,
+ sync_billing_from_subscription,
+)
+from utils.app.enums import BillingStatus
+from utils.app.models import OrganizationBilling
+from utils.core.models import Organization
+
+
+def test_get_or_create_org_billing(session: Session) -> None:
+ org = Organization(name="Billing org")
+ session.add(org)
+ session.commit()
+ session.refresh(org)
+ assert org.id is not None
+
+ first = get_or_create_org_billing(session, org.id)
+ second = get_or_create_org_billing(session, org.id)
+ assert first.id is not None
+ assert second.id == first.id
+
+
+def test_org_has_active_subscription(session: Session) -> None:
+ org = Organization(name="Paid org")
+ session.add(org)
+ session.commit()
+ session.refresh(org)
+ assert org.id is not None
+
+ assert org_has_active_subscription(session, org.id) is False
+
+ sync_billing_from_subscription(
+ session,
+ org_id=org.id,
+ stripe_customer_id="cus_123",
+ stripe_subscription_id="sub_123",
+ status=BillingStatus.ACTIVE.value,
+ price_id="price_123",
+ current_period_start=datetime.now(UTC),
+ current_period_end=datetime.now(UTC),
+ cancel_at_period_end=False,
+ )
+ assert org_has_active_subscription(session, org.id) is True
+
+
+def test_past_due_is_not_treated_as_active(session: Session) -> None:
+ org = Organization(name="Past due org")
+ session.add(org)
+ session.commit()
+ session.refresh(org)
+ assert org.id is not None
+
+ sync_billing_from_subscription(
+ session,
+ org_id=org.id,
+ stripe_customer_id="cus_past_due",
+ stripe_subscription_id="sub_past_due",
+ status=BillingStatus.PAST_DUE.value,
+ price_id="price_123",
+ current_period_start=datetime.now(UTC),
+ current_period_end=datetime.now(UTC),
+ cancel_at_period_end=False,
+ )
+ assert org_has_active_subscription(session, org.id) is False
+
+
+def test_sync_clears_subscription_id_when_explicitly_set_to_none(
+ session: Session,
+) -> None:
+ org = Organization(name="Canceled org")
+ session.add(org)
+ session.commit()
+ session.refresh(org)
+ assert org.id is not None
+
+ sync_billing_from_subscription(
+ session,
+ org_id=org.id,
+ stripe_customer_id="cus_123",
+ stripe_subscription_id="sub_123",
+ status=BillingStatus.ACTIVE.value,
+ price_id="price_123",
+ current_period_start=datetime.now(UTC),
+ current_period_end=datetime.now(UTC),
+ cancel_at_period_end=False,
+ )
+ sync_billing_from_subscription(
+ session,
+ org_id=org.id,
+ stripe_subscription_id=None,
+ status=BillingStatus.CANCELED.value,
+ )
+ billing = get_or_create_org_billing(session, org.id)
+ assert billing.stripe_subscription_id is None
+ assert billing.status == BillingStatus.CANCELED.value
+
+
+def test_incomplete_subscription_may_start_checkout(session: Session) -> None:
+ org = Organization(name="Incomplete org")
+ session.add(org)
+ session.commit()
+ session.refresh(org)
+ assert org.id is not None
+
+ sync_billing_from_subscription(
+ session,
+ org_id=org.id,
+ stripe_customer_id="cus_incomplete",
+ stripe_subscription_id="sub_incomplete",
+ status=BillingStatus.INCOMPLETE.value,
+ price_id="price_123",
+ current_period_start=None,
+ current_period_end=None,
+ cancel_at_period_end=False,
+ )
+ assert org_may_start_checkout(session, org.id) is True
+
+
+def test_active_subscription_may_not_start_checkout(session: Session) -> None:
+ org = Organization(name="Active org")
+ session.add(org)
+ session.commit()
+ session.refresh(org)
+ assert org.id is not None
+
+ sync_billing_from_subscription(
+ session,
+ org_id=org.id,
+ stripe_customer_id="cus_active",
+ stripe_subscription_id="sub_active",
+ status=BillingStatus.ACTIVE.value,
+ price_id="price_123",
+ current_period_start=datetime.now(UTC),
+ current_period_end=datetime.now(UTC),
+ cancel_at_period_end=False,
+ )
+ assert org_may_start_checkout(session, org.id) is False
+
+
+def test_billing_status_defaults_to_none() -> None:
+ assert billing_status(None) == BillingStatus.NONE
+ assert billing_status(OrganizationBilling(organization_id=1)) == BillingStatus.NONE
+
+
+def test_should_resolve_billing_nav() -> None:
+ from types import SimpleNamespace
+
+ assert should_resolve_billing_nav(
+ SimpleNamespace(method="GET", url=SimpleNamespace(path="/dashboard/"))
+ )
+ assert not should_resolve_billing_nav(
+ SimpleNamespace(method="POST", url=SimpleNamespace(path="/dashboard/"))
+ )
+ assert not should_resolve_billing_nav(
+ SimpleNamespace(method="GET", url=SimpleNamespace(path="/static/css/site.css"))
+ )
+ assert not should_resolve_billing_nav(
+ SimpleNamespace(method="GET", url=SimpleNamespace(path="/webhooks/stripe"))
+ )
diff --git a/tests/utils/app/test_credentials.py b/tests/utils/app/test_credentials.py
new file mode 100644
index 0000000..c733ac4
--- /dev/null
+++ b/tests/utils/app/test_credentials.py
@@ -0,0 +1,28 @@
+"""Tests for application credential helpers."""
+
+from __future__ import annotations
+
+import pytest
+
+from utils.app.credentials import validate_billing_environment
+
+
+def test_validate_billing_raises_when_missing(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False)
+ monkeypatch.delenv("STRIPE_WEBHOOK_SECRET", raising=False)
+ monkeypatch.delenv("STRIPE_PRICE_ID", raising=False)
+ monkeypatch.delenv("BASE_URL", raising=False)
+ with pytest.raises(ValueError, match="Missing billing environment variables"):
+ validate_billing_environment()
+
+
+def test_validate_billing_passes_when_configured(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_dummy")
+ monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test_dummy")
+ monkeypatch.setenv("STRIPE_PRICE_ID", "price_test_dummy")
+ monkeypatch.setenv("BASE_URL", "http://localhost:8000")
+ validate_billing_environment()
diff --git a/tests/utils/test_db.py b/tests/utils/test_db.py
index dbccef8..7bcaf12 100644
--- a/tests/utils/test_db.py
+++ b/tests/utils/test_db.py
@@ -9,6 +9,7 @@
create_default_roles,
create_permissions,
seed_account_emails,
+ sync_default_role_permissions,
tear_down_db,
set_up_db,
)
@@ -280,11 +281,51 @@ def test_create_default_roles(session: Session, test_organization: Organization)
.join(RolePermissionLink)
.where(RolePermissionLink.role_id == admin_role.id)
).all()
- # Admin should have all permissions except DELETE_ORGANIZATION
- assert len(admin_permissions) == len(all_perms) - 1
+ # Admin should have all permissions except DELETE_ORGANIZATION and MANAGE_BILLING
+ assert len(admin_permissions) == len(all_perms) - 2
assert str(ValidPermissions.DELETE_ORGANIZATION) not in {
p.name for p in admin_permissions
}
+ assert str(AppPermissions.MANAGE_BILLING) not in {p.name for p in admin_permissions}
+
+
+def test_sync_default_role_permissions_backfills_new_app_permissions(
+ session: Session, test_organization: Organization
+):
+ create_permissions(session)
+ session.commit()
+ assert test_organization.id is not None
+ create_default_roles(session, test_organization.id, check_first=True)
+ session.commit()
+
+ owner_role = session.exec(
+ select(Role).where(
+ Role.organization_id == test_organization.id,
+ Role.name == "Owner",
+ )
+ ).one()
+ billing_perm = session.exec(
+ select(Permission).where(Permission.name == AppPermissions.MANAGE_BILLING)
+ ).one()
+ link = session.exec(
+ select(RolePermissionLink).where(
+ RolePermissionLink.role_id == owner_role.id,
+ RolePermissionLink.permission_id == billing_perm.id,
+ )
+ ).first()
+ assert link is not None
+ session.delete(link)
+ session.commit()
+
+ sync_default_role_permissions(session)
+
+ link = session.exec(
+ select(RolePermissionLink).where(
+ RolePermissionLink.role_id == owner_role.id,
+ RolePermissionLink.permission_id == billing_perm.id,
+ )
+ ).first()
+ assert link is not None
def test_assign_permissions_to_role(session: Session, test_organization: Organization):
diff --git a/tests/utils/test_models.py b/tests/utils/test_models.py
index 3c0ed87..3c29364 100644
--- a/tests/utils/test_models.py
+++ b/tests/utils/test_models.py
@@ -592,12 +592,12 @@ def test_account_recovery_token_is_expired(session: Session, test_account: Accou
expired_token = AccountRecoveryToken(
account_id=test_account.id,
email="expired@example.com",
- expires_at=datetime.now(UTC) - timedelta(hours=1),
+ expires_at=utc_naive_now() - timedelta(hours=1),
)
valid_token = AccountRecoveryToken(
account_id=test_account.id,
email="valid@example.com",
- expires_at=datetime.now(UTC) + timedelta(days=7),
+ expires_at=utc_naive_now() + timedelta(days=7),
)
session.add(expired_token)
session.add(valid_token)
diff --git a/user_guide/04-customization.qmd b/user_guide/04-customization.qmd
index 35934c9..cd39d51 100644
--- a/user_guide/04-customization.qmd
+++ b/user_guide/04-customization.qmd
@@ -107,6 +107,7 @@ Both paths use the same POST route URLs and form field contracts. (See [Architec
- Dashboard page: `dashboard.py`
- Static pages (e.g., about, privacy policy, terms of service): `static_pages.py`
- Custom FastAPI routes for your app: `routers/app/`
+ - Organization billing (Stripe Checkout, portal, webhooks): `billing.py` — see [Billing](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/billing.html)
- Jinja2 templates: `templates/`
- Static assets: `static/`
- Unit tests: `tests/`
@@ -121,6 +122,7 @@ Both paths use the same POST route URLs and form field contracts. (See [Architec
- Shared helper functions: `utils/`
- HTMX request detection: `htmx.py`
- Custom template helper functions for your app: `utils/app/`
+ - Billing helpers and Stripe sync: `billing.py`, `stripe_billing.py`, `credentials.py`
- Exceptions: `exceptions/`
- HTTP exceptions: `http_exceptions.py`
- Other custom exceptions: `exceptions.py`
diff --git a/user_guide/07-billing.qmd b/user_guide/07-billing.qmd
new file mode 100644
index 0000000..f55d385
--- /dev/null
+++ b/user_guide/07-billing.qmd
@@ -0,0 +1,154 @@
+---
+title: "Billing"
+description: "Organization-scoped Stripe subscriptions with Checkout, Customer Portal, and webhooks."
+guide-section: "Application"
+---
+
+## Overview
+
+The template includes organization-scoped billing wired to **Stripe**:
+
+- **Stripe Checkout** for new subscriptions
+- **Stripe Customer Portal** for payment-method updates and cancellation
+- **Stripe webhooks** to keep subscription state in PostgreSQL
+- **Owner-only** management (`Manage Billing` permission)
+- **Member read access** (`View Billing` permission)
+
+Billing is template wiring. Forks add their own product rules (seat limits, feature gating, trials) using helpers in `utils/app/billing.py`.
+
+The billing page lives at `/organizations/{id}/billing`. Owners also reach it from the profile dropdown in the navbar.
+
+## Environment variables
+
+Add these to `.env` (see `.env.example`):
+
+```bash
+STRIPE_SECRET_KEY=sk_test_...
+STRIPE_WEBHOOK_SECRET=whsec_...
+STRIPE_PRICE_ID=price_...
+STRIPE_PLAN_NAME=Pro
+STRIPE_TAX_ENABLED=1
+BASE_URL=http://localhost:8000
+```
+
+All Stripe variables are required at startup on the **`stripe`** branch.
+
+`STRIPE_TAX_ENABLED=1` enables Stripe Tax automatic calculation at Checkout (configure tax in your Stripe Dashboard).
+
+## Stripe Dashboard setup
+
+1. Create a **Product** with a **monthly recurring Price** and copy the Price ID into `STRIPE_PRICE_ID`.
+2. Enable the **Customer Portal** (Settings → Billing → Customer portal) so owners can update cards and cancel.
+3. Add a webhook endpoint pointing to `{BASE_URL}/webhooks/stripe`.
+4. Subscribe to events:
+ - `checkout.session.completed`
+ - `customer.subscription.created`
+ - `customer.subscription.updated`
+ - `customer.subscription.deleted`
+ - `invoice.paid`
+ - `invoice.payment_failed`
+5. Enable **Stripe Tax** if you sell to customers in multiple tax jurisdictions.
+
+### Local webhook forwarding
+
+```bash
+stripe listen --forward-to localhost:8000/webhooks/stripe
+```
+
+Use the signing secret printed by the Stripe CLI as `STRIPE_WEBHOOK_SECRET` during development.
+
+### Manual end-to-end checklist
+
+Use this once to confirm billing works against Stripe test mode:
+
+1. Fill in `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, and `BASE_URL=http://localhost:8000` in `.env`.
+2. Start the app and database (`docker compose up -d`, then `uv run uvicorn main:app --reload`).
+3. In a second terminal, run `stripe listen --forward-to localhost:8000/webhooks/stripe` and copy the printed `whsec_…` value into `STRIPE_WEBHOOK_SECRET`. Restart the app if it was already running.
+4. Register or log in, create an organization, and open **Billing** from the profile dropdown (or the organization settings page).
+5. As the org owner, click **Subscribe with Stripe** and complete Checkout with test card `4242 4242 4242 4242`, any future expiry, any CVC, and any postal code.
+6. After redirect, confirm the billing page shows **Active** (webhooks may take a few seconds).
+7. Check server logs for `Handled Stripe webhook event … for org_id=…`.
+8. Optionally open **Manage billing** to reach the Stripe Customer Portal and verify cancel or payment-method update.
+
+If checkout completes but status stays **Free**, check webhook delivery in the Stripe Dashboard and that `STRIPE_WEBHOOK_SECRET` matches the CLI listener secret.
+
+## Routes
+
+| Route | Who | Purpose |
+|-------|-----|---------|
+| `GET /organizations/{id}/billing` | Members with View Billing | Billing status page |
+| `POST /organizations/{id}/billing/checkout` | Owner | Start Stripe Checkout |
+| `POST /organizations/{id}/billing/portal` | Owner | Open Stripe Customer Portal |
+| `GET /organizations/{id}/billing/success` | Member | Return from Checkout (verifies `session_id` with Stripe) |
+| `POST /webhooks/stripe` | Stripe | Sync subscription state |
+
+## How state is stored
+
+Each organization has one `OrganizationBilling` row (see `utils/app/models.py`):
+
+- Stripe customer and subscription IDs
+- Subscription status (`active`, `past_due`, `canceled`, etc.)
+- Current billing period and last payment timestamp
+
+Card numbers are never stored in your database. Stripe Checkout and the Customer Portal collect and manage payment methods.
+
+## Webhooks
+
+Stripe notifies your app at `POST /webhooks/stripe` when subscriptions change or payments succeed or fail. The handler:
+
+1. Verifies the `Stripe-Signature` header
+2. Claims the event ID before processing (idempotent delivery)
+3. Updates `OrganizationBilling` for the matching `organization_id` metadata
+4. Releases the claim and returns `500` on handler failure or retryable parse errors so Stripe retries
+5. Keeps the claim and returns `200` for successfully handled events and unhandled event types
+
+Handled events include checkout completion, subscription lifecycle changes, successful invoices, and failed payments.
+
+## Subscription status helpers
+
+Use these helpers in fork code:
+
+```python
+from utils.app.billing import org_has_active_subscription
+
+if not org_has_active_subscription(session, org_id):
+ raise HTTPException(status_code=402, detail="Subscription required.")
+```
+
+`org_has_active_subscription()` returns `True` only for **`trialing`** and **`active`** statuses. `past_due`, `unpaid`, and `incomplete` are not treated as fully active.
+
+Owners with an `incomplete` checkout can use **Subscribe with Stripe** again to finish payment.
+
+## Organization deletion
+
+Deleting an organization attempts to cancel its Stripe subscription first. If Stripe cancellation fails, deletion is blocked with a `409` response so the owner can cancel in the Customer Portal and try again.
+
+## Upgrading existing databases
+
+If you deployed before billing was added, run:
+
+```bash
+uv run python -m migrations.add_billing_tables .env --apply
+```
+
+Fresh installs create billing tables automatically via `set_up_db()`. Startup also backfills billing permissions onto existing default roles.
+
+## Testing
+
+Billing router tests live in `tests/routers/app/test_billing.py`. Helper tests are in `tests/utils/app/test_billing_helpers.py`.
+
+Run billing tests:
+
+```bash
+uv run pytest tests/routers/app/test_billing.py tests/utils/app/test_billing_helpers.py -q
+```
+
+Tests set dummy Stripe environment variables in `tests/conftest.py` and mock Stripe API calls where needed.
+
+## When billing breaks
+
+Check these in order:
+
+1. **Server logs** — successful webhooks log `Handled Stripe webhook event … for org_id=…`; bad signatures and duplicate events are logged too. Handler failures include a stack trace.
+2. **Stripe Dashboard** — Developers → Webhooks for delivery errors, and Customers / Subscriptions for the live Stripe state.
+3. **Database** — `organization_billing` for the org’s cached status; `stripe_webhook_event` for recently claimed event IDs (idempotency only, not a full audit trail).
diff --git a/utils/app/billing.py b/utils/app/billing.py
new file mode 100644
index 0000000..93ba42e
--- /dev/null
+++ b/utils/app/billing.py
@@ -0,0 +1,279 @@
+"""Billing helpers for organization-scoped Stripe subscriptions."""
+
+from __future__ import annotations
+
+import logging
+from datetime import UTC, datetime
+from typing import Optional
+
+from fastapi import Request
+from sqlalchemy.orm import selectinload
+from sqlmodel import Session, select
+
+from utils.app.enums import (
+ ACTIVE_BILLING_STATUSES,
+ CHECKOUT_ELIGIBLE_STATUSES,
+ PORTAL_ELIGIBLE_STATUSES,
+ AppPermissions,
+ BillingStatus,
+)
+from utils.app.models import OrganizationBilling
+from utils.core.auth import validate_token
+from utils.core.models import Account, Role, User, utc_now
+
+logger = logging.getLogger(__name__)
+
+_BILLING_NAV_SKIP_PREFIXES = ("/static", "/webhooks")
+
+
+class _UnsetType:
+ __slots__ = ()
+
+
+_UNSET = _UnsetType()
+
+
+def _as_utc_naive(value: datetime) -> datetime:
+ if value.tzinfo is None:
+ return value
+ return value.astimezone(UTC).replace(tzinfo=None)
+
+
+def get_org_billing(session: Session, org_id: int) -> OrganizationBilling | None:
+ return session.exec(
+ select(OrganizationBilling).where(OrganizationBilling.organization_id == org_id)
+ ).first()
+
+
+def get_or_create_org_billing(session: Session, org_id: int) -> OrganizationBilling:
+ billing = get_org_billing(session, org_id)
+ if billing is not None:
+ return billing
+ billing = OrganizationBilling(organization_id=org_id)
+ session.add(billing)
+ session.commit()
+ session.refresh(billing)
+ return billing
+
+
+def billing_status(billing: OrganizationBilling | None) -> BillingStatus:
+ if billing is None or not billing.status:
+ return BillingStatus.NONE
+ try:
+ return BillingStatus(billing.status)
+ except ValueError:
+ return BillingStatus.NONE
+
+
+def org_has_active_subscription(session: Session, org_id: int) -> bool:
+ """Return True when the organization has a paid-up trialing or active subscription."""
+ billing = get_org_billing(session, org_id)
+ if billing is None:
+ return False
+ return billing_status(billing) in ACTIVE_BILLING_STATUSES
+
+
+def org_may_start_checkout(session: Session, org_id: int) -> bool:
+ billing = get_org_billing(session, org_id)
+ status = billing_status(billing)
+ if status in CHECKOUT_ELIGIBLE_STATUSES:
+ return True
+ return billing is None or not billing.stripe_customer_id
+
+
+def sync_billing_from_subscription(
+ session: Session,
+ *,
+ org_id: int,
+ stripe_customer_id: str | None | _UnsetType = _UNSET,
+ stripe_subscription_id: str | None | _UnsetType = _UNSET,
+ status: str | None | _UnsetType = _UNSET,
+ price_id: str | None | _UnsetType = _UNSET,
+ current_period_start: datetime | None | _UnsetType = _UNSET,
+ current_period_end: datetime | None | _UnsetType = _UNSET,
+ cancel_at_period_end: bool | _UnsetType = _UNSET,
+) -> OrganizationBilling:
+ billing = get_or_create_org_billing(session, org_id)
+ if not isinstance(stripe_customer_id, _UnsetType):
+ billing.stripe_customer_id = stripe_customer_id
+ if not isinstance(stripe_subscription_id, _UnsetType):
+ billing.stripe_subscription_id = stripe_subscription_id
+ if not isinstance(status, _UnsetType) and status is not None:
+ billing.status = status
+ if not isinstance(price_id, _UnsetType):
+ billing.price_id = price_id
+ if not isinstance(current_period_start, _UnsetType):
+ period_start = current_period_start
+ billing.current_period_start = (
+ _as_utc_naive(period_start) if period_start is not None else None
+ )
+ if not isinstance(current_period_end, _UnsetType):
+ period_end = current_period_end
+ billing.current_period_end = (
+ _as_utc_naive(period_end) if period_end is not None else None
+ )
+ if not isinstance(cancel_at_period_end, _UnsetType):
+ billing.cancel_at_period_end = cancel_at_period_end
+ billing.updated_at = utc_now()
+ session.add(billing)
+ session.commit()
+ session.refresh(billing)
+ return billing
+
+
+def record_last_payment(session: Session, org_id: int, paid_at: datetime) -> None:
+ billing = get_org_billing(session, org_id)
+ if billing is None:
+ return
+ billing.last_payment_at = _as_utc_naive(paid_at)
+ billing.updated_at = utc_now()
+ session.add(billing)
+ session.commit()
+
+
+def cancel_org_stripe_subscription(session: Session, org_id: int) -> None:
+ """Cancel the Stripe subscription before organization deletion."""
+ from exceptions.http_exceptions import StripeSubscriptionCancelError
+
+ billing = get_org_billing(session, org_id)
+ if billing is None or not billing.stripe_subscription_id:
+ return
+ if billing_status(billing) not in ACTIVE_BILLING_STATUSES | {
+ BillingStatus.PAST_DUE,
+ BillingStatus.UNPAID,
+ }:
+ return
+
+ from utils.app.stripe_billing import cancel_subscription
+
+ try:
+ cancel_subscription(billing.stripe_subscription_id)
+ except Exception as exc:
+ logger.exception(
+ "Failed to cancel Stripe subscription for org_id=%s before delete",
+ org_id,
+ )
+ raise StripeSubscriptionCancelError() from exc
+
+ billing.status = BillingStatus.CANCELED.value
+ billing.stripe_subscription_id = None
+ billing.cancel_at_period_end = False
+ billing.updated_at = utc_now()
+ session.add(billing)
+ session.commit()
+
+
+def billing_status_label(status: BillingStatus) -> str:
+ labels = {
+ BillingStatus.NONE: "Free",
+ BillingStatus.TRIALING: "Trialing",
+ BillingStatus.ACTIVE: "Active",
+ BillingStatus.PAST_DUE: "Past due",
+ BillingStatus.CANCELED: "Canceled",
+ BillingStatus.UNPAID: "Unpaid",
+ BillingStatus.INCOMPLETE: "Incomplete",
+ }
+ return labels.get(status, status.value.replace("_", " ").title())
+
+
+def can_subscribe_to_billing(
+ *,
+ can_manage: bool,
+ status: BillingStatus,
+ billing: OrganizationBilling | None,
+) -> bool:
+ if not can_manage:
+ return False
+ if status in CHECKOUT_ELIGIBLE_STATUSES:
+ return True
+ return billing is None or not billing.stripe_customer_id
+
+
+def can_manage_billing_portal(
+ *,
+ can_manage: bool,
+ billing: OrganizationBilling | None,
+ status: BillingStatus,
+) -> bool:
+ if not can_manage or billing is None or not billing.stripe_customer_id:
+ return False
+ return status in PORTAL_ELIGIBLE_STATUSES
+
+
+def should_resolve_billing_nav(request: Request) -> bool:
+ """Return True when the response may include the site navbar billing link."""
+ if request.method != "GET":
+ return False
+ return not request.url.path.startswith(_BILLING_NAV_SKIP_PREFIXES)
+
+
+def resolve_billing_nav_href(request: Request, access_token: str) -> str | None:
+ """Resolve the billing nav URL from an access token (single DB round-trip)."""
+ from utils.core.db import get_engine
+
+ decoded = validate_token(access_token, token_type="access")
+ if not decoded:
+ return None
+ email = decoded.get("sub")
+ if not email:
+ return None
+
+ engine = get_engine()
+ with Session(engine) as session:
+ user = session.exec(
+ select(User)
+ .join(Account)
+ .where(Account.email == email)
+ .options(
+ selectinload(User.roles).selectinload(Role.organization),
+ selectinload(User.roles).selectinload(Role.permissions),
+ )
+ ).first()
+ if user is None:
+ return None
+ return billing_nav_href(request, user)
+
+
+def billing_nav_href(request: Request, user: User) -> str | None:
+ """Return billing page URL for the nav menu when the user may view billing."""
+ if not user.organizations:
+ return None
+
+ selected_org = None
+ selected_org_id_str = request.cookies.get("selected_organization_id")
+ if selected_org_id_str:
+ try:
+ selected_org_id = int(selected_org_id_str)
+ selected_org = next(
+ (org for org in user.organizations if org.id == selected_org_id),
+ None,
+ )
+ except ValueError:
+ pass
+
+ if selected_org is None:
+ selected_org = user.organizations[0]
+
+ if selected_org.id is None:
+ return None
+
+ if not user.has_permission(AppPermissions.VIEW_BILLING, selected_org):
+ return None
+
+ return str(request.url_for("read_billing", org_id=selected_org.id))
+
+
+def organization_id_from_metadata(metadata: object) -> Optional[int]:
+ if not isinstance(metadata, dict):
+ return None
+ raw = metadata.get("organization_id")
+ if raw is None:
+ return None
+ if isinstance(raw, int):
+ return raw
+ if isinstance(raw, str):
+ try:
+ return int(raw)
+ except ValueError:
+ return None
+ return None
diff --git a/utils/app/credentials.py b/utils/app/credentials.py
new file mode 100644
index 0000000..a91387a
--- /dev/null
+++ b/utils/app/credentials.py
@@ -0,0 +1,18 @@
+"""Environment validation for application-specific services."""
+
+from __future__ import annotations
+
+import os
+
+
+def validate_billing_environment() -> None:
+ """Ensure required Stripe billing environment variables are set."""
+ required = (
+ "STRIPE_SECRET_KEY",
+ "STRIPE_WEBHOOK_SECRET",
+ "STRIPE_PRICE_ID",
+ "BASE_URL",
+ )
+ missing = [key for key in required if not os.getenv(key)]
+ if missing:
+ raise ValueError("Missing billing environment variables: " + ", ".join(missing))
diff --git a/utils/app/enums.py b/utils/app/enums.py
index cfdea59..0516873 100644
--- a/utils/app/enums.py
+++ b/utils/app/enums.py
@@ -1,10 +1,9 @@
"""
-Example app-specific permissions. Replace these with your own permissions
-that correspond to your application's data models and access control needs.
+App-specific permissions. Replace or extend these for your application's
+access control needs.
-These are automatically registered alongside the core ValidPermissions
-during database setup, and can be used with User.has_permission() in the
-same way as core permissions.
+These are automatically registered alongside core ValidPermissions during
+database setup and can be used with User.has_permission() the same way.
"""
from enum import StrEnum
@@ -14,3 +13,46 @@ class AppPermissions(StrEnum):
READ_ORGANIZATION_RESOURCES = "Read Organization Resources"
WRITE_ORGANIZATION_RESOURCES = "Write Organization Resources"
DELETE_ORGANIZATION_RESOURCES = "Delete Organization Resources"
+ MANAGE_BILLING = "Manage Billing"
+ VIEW_BILLING = "View Billing"
+
+
+class BillingStatus(StrEnum):
+ """Stripe-aligned subscription states stored for each organization."""
+
+ NONE = "none"
+ TRIALING = "trialing"
+ ACTIVE = "active"
+ PAST_DUE = "past_due"
+ CANCELED = "canceled"
+ UNPAID = "unpaid"
+ INCOMPLETE = "incomplete"
+
+
+ACTIVE_BILLING_STATUSES = frozenset(
+ {
+ BillingStatus.TRIALING,
+ BillingStatus.ACTIVE,
+ }
+)
+
+
+CHECKOUT_ELIGIBLE_STATUSES = frozenset(
+ {
+ BillingStatus.NONE,
+ BillingStatus.CANCELED,
+ BillingStatus.INCOMPLETE,
+ BillingStatus.UNPAID,
+ }
+)
+
+
+PORTAL_ELIGIBLE_STATUSES = frozenset(
+ {
+ BillingStatus.TRIALING,
+ BillingStatus.ACTIVE,
+ BillingStatus.PAST_DUE,
+ BillingStatus.UNPAID,
+ BillingStatus.INCOMPLETE,
+ }
+)
diff --git a/utils/app/models.py b/utils/app/models.py
index 482dfbb..5cbd708 100644
--- a/utils/app/models.py
+++ b/utils/app/models.py
@@ -1,29 +1,22 @@
"""
-Example application data model.
+Application data models.
-Replace this module with your own application-specific SQLModel classes.
-Any SQLModel table classes defined here will be automatically created in the
-database on startup, as long as this module is imported in utils/core/db.py.
+SQLModel table classes defined here are automatically created in the
+database on startup when this module is imported in utils/core/db.py.
"""
-from typing import Optional
from datetime import datetime
-from sqlmodel import SQLModel, Field
-from utils.core.models import utc_now
+from typing import Optional
+from sqlmodel import Field, SQLModel
-# --- Replace the example model below with your own application models ---
+from utils.core.models import utc_now
class OrganizationResource(SQLModel, table=True):
"""
Example application data model representing a resource owned by an
- organization. Replace this with your own application-specific models.
-
- Each resource belongs to a single organization (via organization_id foreign
- key). Users with the READ_ORGANIZATION_RESOURCES permission can view these
- resources, users with WRITE_ORGANIZATION_RESOURCES can create/edit them, and
- users with DELETE_ORGANIZATION_RESOURCES can delete them.
+ organization. Replace or extend with your own application-specific models.
"""
id: Optional[int] = Field(default=None, primary_key=True)
@@ -34,3 +27,33 @@ class OrganizationResource(SQLModel, table=True):
description: Optional[str] = None
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
+
+
+class OrganizationBilling(SQLModel, table=True):
+ """Stripe subscription state for one organization (1:1 with Organization)."""
+
+ id: Optional[int] = Field(default=None, primary_key=True)
+ organization_id: int = Field(
+ foreign_key="organization.id",
+ unique=True,
+ ondelete="CASCADE",
+ index=True,
+ )
+ stripe_customer_id: Optional[str] = Field(default=None, index=True)
+ stripe_subscription_id: Optional[str] = Field(default=None, index=True)
+ status: str = Field(default="none", index=True)
+ price_id: Optional[str] = None
+ current_period_start: Optional[datetime] = None
+ current_period_end: Optional[datetime] = None
+ last_payment_at: Optional[datetime] = None
+ cancel_at_period_end: bool = Field(default=False)
+ created_at: datetime = Field(default_factory=utc_now)
+ updated_at: datetime = Field(default_factory=utc_now)
+
+
+class StripeWebhookEvent(SQLModel, table=True):
+ """Processed Stripe webhook event IDs for idempotent handling."""
+
+ id: Optional[int] = Field(default=None, primary_key=True)
+ stripe_event_id: str = Field(unique=True, index=True)
+ processed_at: datetime = Field(default_factory=utc_now)
diff --git a/utils/app/stripe_billing.py b/utils/app/stripe_billing.py
new file mode 100644
index 0000000..00ac844
--- /dev/null
+++ b/utils/app/stripe_billing.py
@@ -0,0 +1,350 @@
+"""Thin Stripe integration for organization subscriptions."""
+
+from __future__ import annotations
+
+import logging
+import os
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from enum import StrEnum
+from typing import Any
+
+import stripe
+from sqlalchemy.exc import IntegrityError
+from sqlmodel import Session, select
+
+from utils.app.billing import (
+ organization_id_from_metadata,
+ record_last_payment,
+ sync_billing_from_subscription,
+)
+from utils.app.enums import BillingStatus
+from utils.app.models import StripeWebhookEvent
+from utils.core.models import utc_now
+
+logger = logging.getLogger(__name__)
+
+
+class WebhookHandleResult(StrEnum):
+ HANDLED = "handled"
+ IGNORED = "ignored"
+ RETRYABLE = "retryable"
+ FAILED = "failed"
+
+
+@dataclass(frozen=True)
+class StripeSettings:
+ secret_key: str
+ webhook_secret: str
+ price_id: str
+ base_url: str
+ plan_name: str
+ tax_enabled: bool
+
+
+def stripe_settings() -> StripeSettings:
+ secret_key = os.getenv("STRIPE_SECRET_KEY", "")
+ webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET", "")
+ price_id = os.getenv("STRIPE_PRICE_ID", "")
+ base_url = os.getenv("BASE_URL", "").rstrip("/")
+ plan_name = os.getenv("STRIPE_PLAN_NAME", "Pro")
+ tax_raw = os.getenv("STRIPE_TAX_ENABLED", "1").lower()
+ tax_enabled = tax_raw not in {"0", "false", "no"}
+ return StripeSettings(
+ secret_key=secret_key,
+ webhook_secret=webhook_secret,
+ price_id=price_id,
+ base_url=base_url,
+ plan_name=plan_name,
+ tax_enabled=tax_enabled,
+ )
+
+
+def _configure_stripe() -> StripeSettings:
+ settings = stripe_settings()
+ stripe.api_key = settings.secret_key
+ return settings
+
+
+def create_checkout_session(
+ *,
+ org_id: int,
+ owner_email: str,
+ customer_id: str | None,
+ idempotency_key: str,
+) -> stripe.checkout.Session:
+ settings = _configure_stripe()
+ success_url = (
+ f"{settings.base_url}/organizations/{org_id}/billing/success"
+ "?session_id={CHECKOUT_SESSION_ID}"
+ )
+ cancel_url = f"{settings.base_url}/organizations/{org_id}/billing/cancel"
+
+ params: dict[str, Any] = {
+ "mode": "subscription",
+ "line_items": [{"price": settings.price_id, "quantity": 1}],
+ "success_url": success_url,
+ "cancel_url": cancel_url,
+ "client_reference_id": str(org_id),
+ "metadata": {"organization_id": str(org_id)},
+ "subscription_data": {"metadata": {"organization_id": str(org_id)}},
+ "billing_address_collection": "required",
+ }
+ if settings.tax_enabled:
+ params["automatic_tax"] = {"enabled": True}
+ if customer_id:
+ params["customer"] = customer_id
+ else:
+ params["customer_email"] = owner_email
+
+ return stripe.checkout.Session.create(
+ **params,
+ idempotency_key=idempotency_key,
+ )
+
+
+def create_portal_session(
+ *, org_id: int, customer_id: str
+) -> stripe.billing_portal.Session:
+ settings = _configure_stripe()
+ return_url = f"{settings.base_url}/organizations/{org_id}/billing"
+ return stripe.billing_portal.Session.create(
+ customer=customer_id,
+ return_url=return_url,
+ )
+
+
+def retrieve_checkout_session(session_id: str) -> stripe.checkout.Session:
+ _configure_stripe()
+ return stripe.checkout.Session.retrieve(
+ session_id,
+ expand=["subscription"],
+ )
+
+
+def cancel_subscription(subscription_id: str) -> stripe.Subscription:
+ _configure_stripe()
+ return stripe.Subscription.cancel(subscription_id)
+
+
+def construct_webhook_event(payload: bytes, signature: str) -> stripe.Event:
+ settings = _configure_stripe()
+ return stripe.Webhook.construct_event(
+ payload,
+ signature,
+ settings.webhook_secret,
+ )
+
+
+def _timestamp_to_datetime(value: int | None) -> datetime | None:
+ if value is None:
+ return None
+ return datetime.fromtimestamp(value, tz=UTC)
+
+
+def _subscription_price_id(subscription: Any) -> str | None:
+ items = getattr(subscription, "items", None)
+ data = getattr(items, "data", None) if items is not None else None
+ if not data:
+ return None
+ first = data[0]
+ price = getattr(first, "price", None)
+ if price is None:
+ return None
+ return getattr(price, "id", None)
+
+
+def _sync_from_stripe_subscription(
+ session: Session, subscription: Any, org_id: int
+) -> None:
+ sync_billing_from_subscription(
+ session,
+ org_id=org_id,
+ stripe_customer_id=getattr(subscription, "customer", None),
+ stripe_subscription_id=getattr(subscription, "id", None),
+ status=str(getattr(subscription, "status", "none")),
+ price_id=_subscription_price_id(subscription),
+ current_period_start=_timestamp_to_datetime(
+ getattr(subscription, "current_period_start", None)
+ ),
+ current_period_end=_timestamp_to_datetime(
+ getattr(subscription, "current_period_end", None)
+ ),
+ cancel_at_period_end=bool(getattr(subscription, "cancel_at_period_end", False)),
+ )
+
+
+def _org_id_from_subscription(subscription: Any) -> int | None:
+ metadata = getattr(subscription, "metadata", None)
+ return organization_id_from_metadata(metadata)
+
+
+def _org_id_from_invoice(invoice: Any) -> int | None:
+ metadata = getattr(invoice, "metadata", None)
+ org_id = organization_id_from_metadata(metadata)
+ if org_id is not None:
+ return org_id
+ subscription_details = getattr(invoice, "subscription_details", None)
+ if subscription_details is not None:
+ sub_metadata = getattr(subscription_details, "metadata", None)
+ org_id = organization_id_from_metadata(sub_metadata)
+ if org_id is not None:
+ return org_id
+ parent = getattr(invoice, "parent", None)
+ if parent is not None:
+ sub_details = getattr(parent, "subscription_details", None)
+ if sub_details is not None:
+ return organization_id_from_metadata(getattr(sub_details, "metadata", None))
+ return None
+
+
+def claim_webhook_event(session: Session, event_id: str) -> bool:
+ """Atomically claim a Stripe event ID before processing."""
+ try:
+ session.add(
+ StripeWebhookEvent(stripe_event_id=event_id, processed_at=utc_now())
+ )
+ session.commit()
+ return True
+ except IntegrityError:
+ session.rollback()
+ return False
+
+
+def release_webhook_event_claim(session: Session, event_id: str) -> None:
+ event = session.exec(
+ select(StripeWebhookEvent).where(StripeWebhookEvent.stripe_event_id == event_id)
+ ).first()
+ if event is not None:
+ session.delete(event)
+ session.commit()
+
+
+def _log_webhook_handled(
+ event_id: str, event_type: str, org_id: int
+) -> WebhookHandleResult:
+ logger.info(
+ "Handled Stripe webhook event %s (%s) for org_id=%s",
+ event_id,
+ event_type,
+ org_id,
+ )
+ return WebhookHandleResult.HANDLED
+
+
+def handle_stripe_webhook_event(
+ session: Session, event: stripe.Event
+) -> WebhookHandleResult:
+ event_type = event.type
+ data_object = event.data.object
+
+ try:
+ if event_type == "checkout.session.completed":
+ org_id = organization_id_from_metadata(
+ getattr(data_object, "metadata", None)
+ )
+ if org_id is None:
+ org_id = organization_id_from_metadata(
+ {
+ "organization_id": getattr(
+ data_object, "client_reference_id", None
+ )
+ }
+ )
+ subscription_id = getattr(data_object, "subscription", None)
+ if org_id is None:
+ logger.error(
+ "Stripe event %s (%s) missing organization_id metadata; will retry",
+ event.id,
+ event_type,
+ )
+ return WebhookHandleResult.RETRYABLE
+ if not subscription_id:
+ logger.error(
+ "Stripe event %s (%s) checkout session missing subscription; will retry",
+ event.id,
+ event_type,
+ )
+ return WebhookHandleResult.RETRYABLE
+ subscription = stripe.Subscription.retrieve(subscription_id)
+ _sync_from_stripe_subscription(session, subscription, org_id)
+ return _log_webhook_handled(event.id, event_type, org_id)
+
+ if event_type in {
+ "customer.subscription.created",
+ "customer.subscription.updated",
+ "customer.subscription.deleted",
+ }:
+ org_id = _org_id_from_subscription(data_object)
+ if org_id is None:
+ logger.error(
+ "Stripe event %s (%s) subscription missing organization_id metadata; will retry",
+ event.id,
+ event_type,
+ )
+ return WebhookHandleResult.RETRYABLE
+ if event_type == "customer.subscription.deleted":
+ sync_billing_from_subscription(
+ session,
+ org_id=org_id,
+ stripe_customer_id=getattr(data_object, "customer", None),
+ stripe_subscription_id=None,
+ status=BillingStatus.CANCELED.value,
+ price_id=_subscription_price_id(data_object),
+ current_period_start=_timestamp_to_datetime(
+ getattr(data_object, "current_period_start", None)
+ ),
+ current_period_end=_timestamp_to_datetime(
+ getattr(data_object, "current_period_end", None)
+ ),
+ cancel_at_period_end=False,
+ )
+ return _log_webhook_handled(event.id, event_type, org_id)
+ _sync_from_stripe_subscription(session, data_object, org_id)
+ return _log_webhook_handled(event.id, event_type, org_id)
+
+ if event_type == "invoice.paid":
+ org_id = _org_id_from_invoice(data_object)
+ if org_id is None:
+ logger.error(
+ "Stripe event %s (%s) invoice missing organization_id metadata; will retry",
+ event.id,
+ event_type,
+ )
+ return WebhookHandleResult.RETRYABLE
+ transitions = getattr(data_object, "status_transitions", None)
+ paid_at = None
+ if transitions is not None:
+ paid_at = _timestamp_to_datetime(getattr(transitions, "paid_at", None))
+ record_last_payment(session, org_id, paid_at or utc_now())
+ subscription_id = getattr(data_object, "subscription", None)
+ if subscription_id:
+ subscription = stripe.Subscription.retrieve(subscription_id)
+ _sync_from_stripe_subscription(session, subscription, org_id)
+ return _log_webhook_handled(event.id, event_type, org_id)
+
+ if event_type == "invoice.payment_failed":
+ org_id = _org_id_from_invoice(data_object)
+ subscription_id = getattr(data_object, "subscription", None)
+ if org_id is None:
+ logger.error(
+ "Stripe event %s (%s) failed invoice missing organization_id metadata; will retry",
+ event.id,
+ event_type,
+ )
+ return WebhookHandleResult.RETRYABLE
+ if subscription_id:
+ subscription = stripe.Subscription.retrieve(subscription_id)
+ _sync_from_stripe_subscription(session, subscription, org_id)
+ return _log_webhook_handled(event.id, event_type, org_id)
+ logger.error(
+ "Stripe event %s (%s) failed invoice missing subscription; will retry",
+ event.id,
+ event_type,
+ )
+ return WebhookHandleResult.RETRYABLE
+
+ return WebhookHandleResult.IGNORED
+ except Exception:
+ logger.exception("Failed to process Stripe webhook event %s", event.id)
+ return WebhookHandleResult.FAILED
diff --git a/utils/core/auth.py b/utils/core/auth.py
index 5164993..4e46e4e 100644
--- a/utils/core/auth.py
+++ b/utils/core/auth.py
@@ -207,7 +207,7 @@ def create_tracked_refresh_token(
expires_delta = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
else:
expires_delta = timedelta(hours=SESSION_REFRESH_TOKEN_EXPIRE_HOURS)
- expires_at = datetime.now(UTC) + expires_delta
+ expires_at = datetime.now(UTC).replace(tzinfo=None) + expires_delta
db_token = RefreshToken(
account_id=account_id,
jti=jti,
diff --git a/utils/core/db.py b/utils/core/db.py
index 4448aec..a0dd1ad 100644
--- a/utils/core/db.py
+++ b/utils/core/db.py
@@ -232,12 +232,17 @@ def create_default_roles(
admin_permissions = [
permission
for permission in owner_permissions
- if permission.name != ValidPermissions.DELETE_ORGANIZATION
+ if permission.name
+ not in (
+ ValidPermissions.DELETE_ORGANIZATION,
+ AppPermissions.MANAGE_BILLING,
+ )
]
- # Get Owner and Administrator roles by name
+ # Get Owner, Administrator, and Member roles by name
owner_role = next(role for role in roles_in_db if role.name == "Owner")
admin_role = next(role for role in roles_in_db if role.name == "Administrator")
+ member_role = next(role for role in roles_in_db if role.name == "Member")
# Assign all permissions to Owner
assign_permissions_to_role(
@@ -249,10 +254,37 @@ def create_default_roles(
session, admin_role, admin_permissions, check_first=check_first
)
+ view_billing_permission = session.exec(
+ select(Permission).where(Permission.name == AppPermissions.VIEW_BILLING)
+ ).first()
+ if view_billing_permission is not None:
+ assign_permissions_to_role(
+ session,
+ member_role,
+ [view_billing_permission],
+ check_first=check_first,
+ )
+
session.commit()
return roles_in_db
+def sync_default_role_permissions(session: Session) -> None:
+ """
+ Backfill default role permission links for every organization.
+
+ Call after create_permissions() so roles pick up newly added AppPermissions
+ on existing databases (not only when an org is first created).
+ """
+ from utils.core.models import Organization
+
+ organizations = session.exec(select(Organization)).all()
+ for organization in organizations:
+ if organization.id is None:
+ continue
+ create_default_roles(session, organization.id, check_first=True)
+
+
def create_permissions(session: Session) -> None:
"""
Creates permissions in the database from both core (ValidPermissions)
@@ -315,6 +347,7 @@ def set_up_db(drop: bool = False) -> None:
with Session(engine) as session:
create_permissions(session)
session.commit()
+ sync_default_role_permissions(session)
seed_account_emails(session)
finally:
engine.dispose()
diff --git a/utils/core/dependencies.py b/utils/core/dependencies.py
index 7d86ce2..a99e41a 100644
--- a/utils/core/dependencies.py
+++ b/utils/core/dependencies.py
@@ -135,6 +135,24 @@ def get_account_from_credentials(
return account, session
+def get_optional_user_from_access_token(
+ access_token: Optional[str], session: Session
+) -> Optional[User]:
+ """Load a user from a valid access token without touching refresh tokens."""
+ if not access_token:
+ return None
+ decoded = validate_token(access_token, token_type="access")
+ if not decoded:
+ return None
+ email = decoded.get("sub")
+ if not email:
+ return None
+ account = session.exec(select(Account).where(Account.email == email)).first()
+ if account is None or account.id is None:
+ return None
+ return session.exec(select(User).where(User.account_id == account.id)).first()
+
+
def get_account_from_tokens(
tokens: tuple[Optional[str], Optional[str]], session: Session
) -> tuple[Optional[Account], Optional[str], Optional[str]]:
diff --git a/utils/core/models.py b/utils/core/models.py
index f0d3386..f1b4258 100644
--- a/utils/core/models.py
+++ b/utils/core/models.py
@@ -79,7 +79,7 @@ class PasswordResetToken(SQLModel, table=True):
account_id: Optional[int] = Field(foreign_key="private.account.id")
token: str = Field(default_factory=lambda: str(uuid4()), index=True, unique=True)
expires_at: datetime = Field(
- default_factory=lambda: datetime.now(UTC) + timedelta(hours=1)
+ default_factory=lambda: utc_naive_now() + timedelta(hours=1)
)
used: bool = Field(default=False)
@@ -121,7 +121,7 @@ class EmailVerificationToken(SQLModel, table=True):
token: str = Field(default_factory=lambda: str(uuid4()), index=True, unique=True)
new_email: str
expires_at: datetime = Field(
- default_factory=lambda: datetime.now(UTC) + timedelta(hours=1)
+ default_factory=lambda: utc_naive_now() + timedelta(hours=1)
)
used: bool = Field(default=False)
@@ -141,7 +141,7 @@ class AccountRecoveryToken(SQLModel, table=True):
token: str = Field(default_factory=lambda: str(uuid4()), index=True, unique=True)
email: str # the email address to restore
expires_at: datetime = Field(
- default_factory=lambda: datetime.now(UTC) + timedelta(days=7)
+ default_factory=lambda: utc_naive_now() + timedelta(days=7)
)
used: bool = Field(default=False)
diff --git a/uv.lock b/uv.lock
index 50a7daa..e285249 100644
--- a/uv.lock
+++ b/uv.lock
@@ -507,6 +507,7 @@ dependencies = [
{ name = "python-multipart" },
{ name = "resend" },
{ name = "sqlmodel" },
+ { name = "stripe" },
{ name = "uvicorn" },
]
@@ -539,6 +540,7 @@ requires-dist = [
{ name = "python-multipart", specifier = ">=0.0.17,<1.0.0" },
{ name = "resend", specifier = ">=2.4.0,<3.0.0" },
{ name = "sqlmodel", specifier = ">=0.0.22,<1.0.0" },
+ { name = "stripe", specifier = ">=15.3.0" },
{ name = "uvicorn", specifier = ">=0.32.0,<1.0.0" },
]
@@ -2388,6 +2390,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
+[[package]]
+name = "stripe"
+version = "15.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/07/aed5656b3fd89a36e80835fe62c668692ee4ebf3a935095933d03f67a47b/stripe-15.3.0.tar.gz", hash = "sha256:ff05524255f51bf7df0e5bc4a99dbd0f737afd05587b7657c82e0d99c7fc45a4", size = 1514134, upload-time = "2026-06-24T22:49:40.276Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/f4/0dcf5bc464941132e3b109d26086a8a0aae7e4ed7e0243a1c47084b4de4b/stripe-15.3.0-py3-none-any.whl", hash = "sha256:99c427d306d9348123e73da3f4f2cae0a5732cac9b703155dba0418e32d3cf1c", size = 2172165, upload-time = "2026-06-24T22:49:38.18Z" },
+]
+
[[package]]
name = "terminado"
version = "0.18.1"