Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,12 @@ DOMAIN=

# Resend
RESEND_API_KEY=
EMAIL_FROM=
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
35 changes: 35 additions & 0 deletions exceptions/http_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
27 changes: 23 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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 ---
Expand Down
124 changes: 124 additions & 0 deletions migrations/add_billing_tables.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading