Skip to content

Commit 4d70df2

Browse files
Merge origin/stripe into propagate PR; keep main conflict guidance and unfiltered CI push triggers
2 parents 2a88198 + 336f2f9 commit 4d70df2

32 files changed

Lines changed: 2399 additions & 39 deletions

.env.example

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

3838
# Resend
3939
RESEND_API_KEY=
40-
EMAIL_FROM=
40+
EMAIL_FROM=
41+
42+
# Stripe billing (required on the stripe branch)
43+
STRIPE_SECRET_KEY=
44+
STRIPE_WEBHOOK_SECRET=
45+
STRIPE_PRICE_ID=
46+
STRIPE_PLAN_NAME=Pro
47+
# Set to 0 to disable Stripe Tax automatic calculation at Checkout
48+
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 & 5 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
5054

5155
logger = logging.getLogger("uvicorn.error")
@@ -54,11 +58,10 @@
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
61-
# Optional shutdown logic
6265

6366

6467
# Initialize the FastAPI app
@@ -80,6 +83,18 @@ async def lifespan(app: FastAPI):
8083
# server-side, then clears the cookie on the response.
8184

8285

86+
@app.middleware("http")
87+
async def nav_billing_middleware(request: Request, call_next):
88+
request.state.billing_nav_href = None
89+
if should_resolve_billing_nav(request):
90+
access_token = request.cookies.get("access_token")
91+
if access_token:
92+
request.state.billing_nav_href = resolve_billing_nav_href(
93+
request, access_token
94+
)
95+
return await call_next(request)
96+
97+
8398
@app.middleware("http")
8499
async def flash_cookie_middleware(request: Request, call_next):
85100
flash = get_flash_cookie(request)
@@ -96,9 +111,10 @@ async def csrf_middleware(request: Request, call_next):
96111
request.state.csrf_token = token
97112

98113
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())
114+
if not request.url.path.startswith("/webhooks/"):
115+
submitted = await extract_submitted_csrf_token(request)
116+
if not validate_csrf_token(request, submitted):
117+
return await csrf_error_handler(request, CsrfError())
102118

103119
response = await call_next(request)
104120
if request.cookies.get(CSRF_COOKIE_NAME) != token:
@@ -116,6 +132,8 @@ async def csrf_middleware(request: Request, call_next):
116132
app.include_router(role.router)
117133
app.include_router(static_pages.router)
118134
app.include_router(user.router)
135+
app.include_router(billing_router.router)
136+
app.include_router(billing_router.webhook_router)
119137

120138

121139
# --- 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)