Skip to content

Commit ce46a7e

Browse files
Merge origin/stripe into propagate PR; resolve engine-cache and billing conflicts
Keep main's shared engine cache and clear_engine_cache lifespan cleanup alongside stripe billing imports, and retain sync_default_role_permissions in set_up_db with try/finally dispose. Co-authored-by: Christopher Carroll Smith <chriscarrollsmith@users.noreply.github.com>
2 parents 246f969 + d64803e commit ce46a7e

32 files changed

Lines changed: 2389 additions & 38 deletions

.env.example

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,12 @@ DOMAIN=
4141

4242
# Resend
4343
RESEND_API_KEY=
44-
EMAIL_FROM=
44+
EMAIL_FROM=
45+
46+
# Stripe billing (required on the stripe branch)
47+
STRIPE_SECRET_KEY=
48+
STRIPE_WEBHOOK_SECRET=
49+
STRIPE_PRICE_ID=
50+
STRIPE_PLAN_NAME=Pro
51+
# Set to 0 to disable Stripe Tax automatic calculation at Checkout
52+
STRIPE_TAX_ENABLED=1

exceptions/http_exceptions.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,41 @@ def __init__(
248248
)
249249

250250

251+
class StripeSubscriptionCancelError(HTTPException):
252+
def __init__(self) -> None:
253+
super().__init__(
254+
status_code=409,
255+
detail=(
256+
"Could not cancel the Stripe subscription for this organization. "
257+
"Open Billing, cancel the subscription in Stripe, and try deleting again."
258+
),
259+
)
260+
261+
262+
class ActiveSubscriptionError(HTTPException):
263+
def __init__(self) -> None:
264+
super().__init__(
265+
status_code=409,
266+
detail="This organization already has an active subscription.",
267+
)
268+
269+
270+
class StripeServiceUnavailableError(HTTPException):
271+
def __init__(self, action: str) -> None:
272+
super().__init__(
273+
status_code=503,
274+
detail=f"Unable to {action}. Please try again later.",
275+
)
276+
277+
278+
class StripeSessionError(HTTPException):
279+
def __init__(self, session_kind: str) -> None:
280+
super().__init__(
281+
status_code=502,
282+
detail=f"Stripe did not return a {session_kind} URL.",
283+
)
284+
285+
251286
class CsrfError(HTTPException):
252287
"""Raised when CSRF validation fails."""
253288

main.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
from contextlib import asynccontextmanager
3+
34
from dotenv import load_dotenv
45
from fastapi import FastAPI, Request, Depends, status
56
from fastapi.responses import RedirectResponse, Response
@@ -46,6 +47,9 @@
4647
RateLimitError,
4748
)
4849
from exceptions.exceptions import NeedsNewTokens
50+
from routers.app import billing as billing_router
51+
from utils.app.credentials import validate_billing_environment
52+
from utils.app.billing import resolve_billing_nav_href, should_resolve_billing_nav
4953
from utils.core.db import set_up_db, clear_engine_cache
5054

5155
logger = logging.getLogger("uvicorn.error")
@@ -54,8 +58,8 @@
5458

5559
@asynccontextmanager
5660
async def lifespan(app: FastAPI):
57-
# Optional startup logic
5861
load_dotenv()
62+
validate_billing_environment()
5963
set_up_db()
6064
yield
6165
clear_engine_cache()
@@ -80,6 +84,18 @@ async def lifespan(app: FastAPI):
8084
# server-side, then clears the cookie on the response.
8185

8286

87+
@app.middleware("http")
88+
async def nav_billing_middleware(request: Request, call_next):
89+
request.state.billing_nav_href = None
90+
if should_resolve_billing_nav(request):
91+
access_token = request.cookies.get("access_token")
92+
if access_token:
93+
request.state.billing_nav_href = resolve_billing_nav_href(
94+
request, access_token
95+
)
96+
return await call_next(request)
97+
98+
8399
@app.middleware("http")
84100
async def flash_cookie_middleware(request: Request, call_next):
85101
flash = get_flash_cookie(request)
@@ -96,9 +112,10 @@ async def csrf_middleware(request: Request, call_next):
96112
request.state.csrf_token = token
97113

98114
if csrf_enabled() and request.method in UNSAFE_HTTP_METHODS:
99-
submitted = await extract_submitted_csrf_token(request)
100-
if not validate_csrf_token(request, submitted):
101-
return await csrf_error_handler(request, CsrfError())
115+
if not request.url.path.startswith("/webhooks/"):
116+
submitted = await extract_submitted_csrf_token(request)
117+
if not validate_csrf_token(request, submitted):
118+
return await csrf_error_handler(request, CsrfError())
102119

103120
response = await call_next(request)
104121
if request.cookies.get(CSRF_COOKIE_NAME) != token:
@@ -116,6 +133,8 @@ async def csrf_middleware(request: Request, call_next):
116133
app.include_router(role.router)
117134
app.include_router(static_pages.router)
118135
app.include_router(user.router)
136+
app.include_router(billing_router.router)
137+
app.include_router(billing_router.webhook_router)
119138

120139

121140
# --- Exception Handling Middlewares ---

migrations/add_billing_tables.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""
2+
Add billing tables for organization-scoped Stripe subscriptions.
3+
4+
Usage:
5+
uv run python -m migrations.add_billing_tables .env
6+
uv run python -m migrations.add_billing_tables .env --apply
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
from dataclasses import dataclass
13+
14+
from dotenv import load_dotenv
15+
from sqlalchemy import text
16+
from sqlmodel import Session, create_engine
17+
18+
from utils.core.db import get_connection_url
19+
20+
TABLES = ("organizationbilling", "stripewebhookevent")
21+
22+
23+
@dataclass
24+
class MigrationStats:
25+
missing_tables: tuple[str, ...] = ()
26+
all_present: bool = False
27+
28+
29+
def _table_exists(session: Session, table_name: str) -> bool:
30+
result = session.connection().execute(
31+
text(
32+
"""
33+
SELECT 1
34+
FROM information_schema.tables
35+
WHERE table_schema = 'public'
36+
AND table_name = :table_name
37+
"""
38+
),
39+
{"table_name": table_name},
40+
)
41+
return result.first() is not None
42+
43+
44+
def add_billing_tables(env_file: str, apply: bool) -> MigrationStats:
45+
load_dotenv(env_file, override=True)
46+
engine = create_engine(get_connection_url())
47+
stats = MigrationStats()
48+
49+
try:
50+
with Session(engine) as session:
51+
missing = tuple(
52+
table for table in TABLES if not _table_exists(session, table)
53+
)
54+
stats.missing_tables = missing
55+
stats.all_present = not missing
56+
57+
if apply and missing:
58+
session.connection().execute(
59+
text(
60+
"""
61+
CREATE TABLE IF NOT EXISTS organizationbilling (
62+
id SERIAL PRIMARY KEY,
63+
organization_id INTEGER NOT NULL UNIQUE REFERENCES organization(id) ON DELETE CASCADE,
64+
stripe_customer_id VARCHAR,
65+
stripe_subscription_id VARCHAR,
66+
status VARCHAR NOT NULL DEFAULT 'none',
67+
price_id VARCHAR,
68+
current_period_start TIMESTAMP,
69+
current_period_end TIMESTAMP,
70+
last_payment_at TIMESTAMP,
71+
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
72+
created_at TIMESTAMP NOT NULL,
73+
updated_at TIMESTAMP NOT NULL
74+
);
75+
CREATE INDEX IF NOT EXISTS ix_organizationbilling_organization_id
76+
ON organizationbilling (organization_id);
77+
CREATE INDEX IF NOT EXISTS ix_organizationbilling_stripe_customer_id
78+
ON organizationbilling (stripe_customer_id);
79+
CREATE INDEX IF NOT EXISTS ix_organizationbilling_stripe_subscription_id
80+
ON organizationbilling (stripe_subscription_id);
81+
CREATE INDEX IF NOT EXISTS ix_organizationbilling_status
82+
ON organizationbilling (status);
83+
84+
CREATE TABLE IF NOT EXISTS stripewebhookevent (
85+
id SERIAL PRIMARY KEY,
86+
stripe_event_id VARCHAR NOT NULL UNIQUE,
87+
processed_at TIMESTAMP NOT NULL
88+
);
89+
CREATE INDEX IF NOT EXISTS ix_stripewebhookevent_stripe_event_id
90+
ON stripewebhookevent (stripe_event_id);
91+
"""
92+
)
93+
)
94+
session.commit()
95+
else:
96+
session.rollback()
97+
finally:
98+
engine.dispose()
99+
100+
return stats
101+
102+
103+
def main() -> None:
104+
parser = argparse.ArgumentParser(description=__doc__)
105+
parser.add_argument("env_file", help="Path to .env file")
106+
parser.add_argument(
107+
"--apply",
108+
action="store_true",
109+
help="Apply migration (default is dry-run)",
110+
)
111+
args = parser.parse_args()
112+
stats = add_billing_tables(args.env_file, apply=args.apply)
113+
if stats.all_present:
114+
print("All billing tables already exist.")
115+
return
116+
print("Missing tables:", ", ".join(stats.missing_tables))
117+
if args.apply:
118+
print("Migration applied.")
119+
else:
120+
print("Dry run only. Re-run with --apply to create tables.")
121+
122+
123+
if __name__ == "__main__":
124+
main()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ dependencies = [
2121
"fastapi<1.0.0,>=0.115.5",
2222
"pillow>=11.0.0",
2323
"psycopg2-binary>=2.9.10",
24+
"stripe>=15.3.0",
2425
]
2526

2627
[dependency-groups]

0 commit comments

Comments
 (0)