Skip to content

Commit 91abb30

Browse files
committed
feat: Implement Stripe integration for premium user upgrades Phase 2
- Add PremiumUser model to store Stripe payment and premium status data. - Create API endpoint for creating Stripe Checkout Sessions. - Implement webhook handler for processing Stripe events and updating user status. - Add service functions to check premium status and enforce item creation limits. - Update frontend to handle item creation limits and show upgrade modal for free users. - Integrate Stripe SDK and configure necessary environment variables. - Ensure proper error handling and logging throughout the payment process.
1 parent f4ef4a3 commit 91abb30

27 files changed

+894
-42
lines changed

.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,8 @@ SENTRY_DSN=
4444
# Configure these with your own Docker registry images
4545
DOCKER_IMAGE_BACKEND=backend
4646
DOCKER_IMAGE_FRONTEND=frontend
47+
48+
# Stripe (get keys from https://dashboard.stripe.com/test/apikeys)
49+
STRIPE_SECRET_KEY=sk_test_changethis
50+
STRIPE_WEBHOOK_SECRET=whsec_changethis
51+
STRIPE_PREMIUM_PRICE_CENTS=100

backend/app/alembic/env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
# target_metadata = None
2020

2121
from app.models import SQLModel # noqa
22+
from app.premium_users.models import PremiumUser # noqa - register for migrations
2223
from app.core.config import settings # noqa
2324

2425
target_metadata = SQLModel.metadata
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Add premium_user table
2+
3+
Revision ID: 43e367b997b2
4+
Revises: 1a31ce608336
5+
Create Date: 2025-12-14 07:23:37.975275
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
from sqlalchemy.dialects import postgresql
12+
13+
# revision identifiers, used by Alembic.
14+
revision = '43e367b997b2'
15+
down_revision = '1a31ce608336'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
op.create_table('premium_user',
23+
sa.Column('premium_user_id', sa.Uuid(), nullable=False),
24+
sa.Column('user_id', sa.Uuid(), nullable=False),
25+
sa.Column('stripe_customer_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
26+
sa.Column('is_premium', sa.Boolean(), nullable=False),
27+
sa.Column('payment_intent_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
28+
sa.Column('paid_at', sa.DateTime(), nullable=True),
29+
sa.Column('created_at', sa.DateTime(), nullable=False),
30+
sa.Column('updated_at', sa.DateTime(), nullable=False),
31+
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
32+
sa.PrimaryKeyConstraint('premium_user_id')
33+
)
34+
op.create_index(op.f('ix_premium_user_stripe_customer_id'), 'premium_user', ['stripe_customer_id'], unique=False)
35+
op.create_index(op.f('ix_premium_user_user_id'), 'premium_user', ['user_id'], unique=True)
36+
op.drop_index(op.f('ix_premiumuser_stripe_customer_id'), table_name='premiumuser')
37+
op.drop_index(op.f('ix_premiumuser_user_id'), table_name='premiumuser')
38+
op.drop_table('premiumuser')
39+
# ### end Alembic commands ###
40+
41+
42+
def downgrade():
43+
# ### commands auto generated by Alembic - please adjust! ###
44+
op.create_table('premiumuser',
45+
sa.Column('premium_user_id', sa.UUID(), autoincrement=False, nullable=False),
46+
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=False),
47+
sa.Column('stripe_customer_id', sa.VARCHAR(), autoincrement=False, nullable=False),
48+
sa.Column('is_premium', sa.BOOLEAN(), autoincrement=False, nullable=False),
49+
sa.Column('payment_intent_id', sa.VARCHAR(), autoincrement=False, nullable=True),
50+
sa.Column('paid_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
51+
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
52+
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
53+
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('premiumuser_user_id_fkey')),
54+
sa.PrimaryKeyConstraint('premium_user_id', name=op.f('premiumuser_pkey'))
55+
)
56+
op.create_index(op.f('ix_premiumuser_user_id'), 'premiumuser', ['user_id'], unique=True)
57+
op.create_index(op.f('ix_premiumuser_stripe_customer_id'), 'premiumuser', ['stripe_customer_id'], unique=False)
58+
op.drop_index(op.f('ix_premium_user_user_id'), table_name='premium_user')
59+
op.drop_index(op.f('ix_premium_user_stripe_customer_id'), table_name='premium_user')
60+
op.drop_table('premium_user')
61+
# ### end Alembic commands ###

backend/app/api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
from app.api.routes import items, login, private, users, utils
44
from app.core.config import settings
5+
from app.premium_users import routes as premium_routes
56

67
api_router = APIRouter()
78
api_router.include_router(login.router)
89
api_router.include_router(users.router)
910
api_router.include_router(utils.router)
1011
api_router.include_router(items.router)
12+
api_router.include_router(premium_routes.router)
1113

1214

1315
if settings.ENVIRONMENT == "local":

backend/app/api/routes/items.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import uuid
22
from typing import Any
33

4-
from fastapi import APIRouter, HTTPException
4+
from fastapi import APIRouter, HTTPException, status
55
from sqlmodel import func, select
66

77
from app.api.deps import CurrentUser, SessionDep
88
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message
9+
from app.premium_users.service import FREE_TIER_ITEM_LIMIT, can_user_create_item
910

1011
router = APIRouter(prefix="/items", tags=["items"])
1112

@@ -60,7 +61,15 @@ def create_item(
6061
) -> Any:
6162
"""
6263
Create new item.
64+
65+
Free users limited to 2 items. Premium users have no limit.
6366
"""
67+
if not can_user_create_item(session, current_user):
68+
raise HTTPException(
69+
status_code=status.HTTP_403_FORBIDDEN,
70+
detail=f"Free tier limited to {FREE_TIER_ITEM_LIMIT} items. Upgrade to premium for unlimited items.",
71+
)
72+
6473
item = Item.model_validate(item_in, update={"owner_id": current_user.id})
6574
session.add(item)
6675
session.commit()

backend/app/core/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ def all_cors_origins(self) -> list[str]:
5656
POSTGRES_PASSWORD: str = ""
5757
POSTGRES_DB: str = ""
5858

59+
# Stripe Configuration
60+
STRIPE_SECRET_KEY: str = ""
61+
STRIPE_WEBHOOK_SECRET: str = ""
62+
STRIPE_PREMIUM_PRICE_CENTS: int = 100 # $1.00 in cents
63+
5964
@computed_field # type: ignore[prop-decorator]
6065
@property
6166
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:

backend/app/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from app.api.main import api_router
77
from app.core.config import settings
8+
from app.premium_users.stripe import init_stripe
89

910

1011
def custom_generate_unique_id(route: APIRoute) -> str:
@@ -14,6 +15,9 @@ def custom_generate_unique_id(route: APIRoute) -> str:
1415
if settings.SENTRY_DSN and settings.ENVIRONMENT != "local":
1516
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True)
1617

18+
# Initialize Stripe API client
19+
init_stripe()
20+
1721
app = FastAPI(
1822
title=settings.PROJECT_NAME,
1923
openapi_url=f"{settings.API_V1_STR}/openapi.json",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""
2+
Premium Users module.
3+
4+
Handles Stripe payment tracking and premium user status.
5+
"""
6+
7+
from app.premium_users.models import PremiumUser
8+
9+
__all__ = ["PremiumUser"]
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""
2+
Premium User database model.
3+
4+
Stores Stripe payment and premium status data with a one-to-one
5+
relationship to the User table. Created when a user completes
6+
Stripe checkout and used to check premium status for feature gating.
7+
"""
8+
9+
import uuid
10+
from datetime import datetime, timezone
11+
12+
from sqlmodel import Field, SQLModel
13+
14+
15+
def utc_now() -> datetime:
16+
"""Return current UTC time as timezone-aware datetime."""
17+
return datetime.now(timezone.utc)
18+
19+
20+
class PremiumUser(SQLModel, table=True):
21+
"""
22+
Stores Stripe payment and premium status data.
23+
One-to-one relationship with User.
24+
25+
Created when a user completes Stripe checkout.
26+
Used to check premium status for feature gating (e.g., item limits).
27+
"""
28+
29+
__tablename__ = "premium_user"
30+
31+
premium_user_id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
32+
user_id: uuid.UUID = Field(foreign_key="user.id", unique=True, index=True)
33+
stripe_customer_id: str = Field(index=True) # Stripe's customer ID (cus_xxx)
34+
is_premium: bool = Field(default=False) # Premium status flag
35+
payment_intent_id: str | None = Field(default=None) # Stripe payment intent (pi_xxx)
36+
paid_at: datetime | None = Field(default=None) # When payment completed
37+
created_at: datetime = Field(default_factory=utc_now)
38+
updated_at: datetime = Field(default_factory=utc_now)
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""
2+
Stripe payment API routes.
3+
4+
Handles checkout session creation and webhook processing for premium
5+
user upgrades. The checkout flow redirects users to Stripe's hosted
6+
payment page, and webhooks handle fulfillment after successful payment.
7+
"""
8+
9+
import logging
10+
from uuid import UUID
11+
12+
from fastapi import APIRouter, HTTPException, Request, status
13+
from pydantic import BaseModel
14+
from sqlmodel import Session, select
15+
16+
from app.api.deps import CurrentUser, SessionDep
17+
from app.core.config import settings
18+
from app.premium_users.models import PremiumUser, utc_now
19+
from app.premium_users.stripe import get_stripe_client
20+
21+
logger = logging.getLogger(__name__)
22+
23+
router = APIRouter(prefix="/stripe", tags=["stripe"])
24+
25+
26+
class CheckoutSessionResponse(BaseModel):
27+
"""Response containing Stripe Checkout URL."""
28+
29+
checkout_url: str
30+
31+
32+
@router.post("/create-checkout-session", response_model=CheckoutSessionResponse)
33+
def create_checkout_session(
34+
session: SessionDep,
35+
current_user: CurrentUser,
36+
) -> CheckoutSessionResponse:
37+
"""
38+
Create a Stripe Checkout Session for premium upgrade.
39+
40+
Returns a URL to redirect the user to Stripe's hosted checkout page.
41+
After payment, Stripe redirects back to our success URL and sends
42+
a webhook to fulfill the order.
43+
"""
44+
logger.debug(f"Creating checkout session for user {current_user.id}")
45+
46+
# Check if user is already premium
47+
existing = session.exec(
48+
select(PremiumUser).where(PremiumUser.user_id == current_user.id)
49+
).first()
50+
51+
if existing and existing.is_premium:
52+
logger.debug(f"User {current_user.id} is already premium")
53+
raise HTTPException(
54+
status_code=status.HTTP_400_BAD_REQUEST,
55+
detail="User is already premium",
56+
)
57+
58+
stripe = get_stripe_client()
59+
60+
try:
61+
checkout_session = stripe.checkout.Session.create(
62+
payment_method_types=["card"],
63+
line_items=[
64+
{
65+
"price_data": {
66+
"currency": "usd",
67+
"product_data": {
68+
"name": "Premium Upgrade",
69+
"description": "Unlock unlimited items and premium badge",
70+
},
71+
"unit_amount": settings.STRIPE_PREMIUM_PRICE_CENTS,
72+
},
73+
"quantity": 1,
74+
}
75+
],
76+
mode="payment",
77+
success_url=f"{settings.FRONTEND_HOST}/payment-success?session_id={{CHECKOUT_SESSION_ID}}",
78+
cancel_url=f"{settings.FRONTEND_HOST}/settings",
79+
metadata={
80+
"user_id": str(current_user.id),
81+
},
82+
)
83+
84+
logger.debug(f"Created checkout session {checkout_session.id} for user {current_user.id}")
85+
86+
return CheckoutSessionResponse(checkout_url=checkout_session.url)
87+
88+
except stripe.error.StripeError as e:
89+
logger.error(f"Stripe error creating checkout session: {e}")
90+
raise HTTPException(
91+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
92+
detail="Failed to create checkout session",
93+
)
94+
95+
96+
@router.post("/webhook")
97+
async def stripe_webhook(request: Request, session: SessionDep) -> dict:
98+
"""
99+
Handle Stripe webhook events.
100+
101+
Stripe sends events here when payment status changes. We use this
102+
to fulfill the premium upgrade after successful payment.
103+
104+
Note: This endpoint is PUBLIC (no auth) - Stripe calls it directly.
105+
Security comes from signature verification.
106+
"""
107+
payload = await request.body()
108+
sig_header = request.headers.get("stripe-signature")
109+
110+
stripe = get_stripe_client()
111+
112+
try:
113+
event = stripe.webhooks.Webhook.construct_event(
114+
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
115+
)
116+
except ValueError as e:
117+
logger.error(f"Invalid webhook payload: {e}")
118+
raise HTTPException(status_code=400, detail="Invalid payload")
119+
except stripe.error.SignatureVerificationError as e:
120+
logger.error(f"Invalid webhook signature: {e}")
121+
raise HTTPException(status_code=400, detail="Invalid signature")
122+
123+
logger.debug(f"Received Stripe webhook: {event['type']}")
124+
125+
# Handle checkout session completed
126+
if event["type"] == "checkout.session.completed":
127+
checkout_session = event["data"]["object"]
128+
_handle_checkout_completed(session, checkout_session)
129+
130+
return {"status": "success"}
131+
132+
133+
def _handle_checkout_completed(
134+
session: Session,
135+
checkout_session: dict,
136+
) -> None:
137+
"""
138+
Process successful checkout - grant premium status to user.
139+
140+
Creates or updates PremiumUser record with payment details.
141+
Idempotent: safe to call multiple times for same checkout session.
142+
"""
143+
user_id_str = checkout_session.get("metadata", {}).get("user_id")
144+
if not user_id_str:
145+
logger.error("No user_id in checkout session metadata")
146+
return
147+
148+
user_id = UUID(user_id_str)
149+
stripe_customer_id = checkout_session.get("customer")
150+
payment_intent_id = checkout_session.get("payment_intent")
151+
152+
logger.debug(f"Processing premium upgrade for user {user_id}")
153+
154+
# Check for existing record (idempotency)
155+
existing = session.exec(
156+
select(PremiumUser).where(PremiumUser.user_id == user_id)
157+
).first()
158+
159+
if existing:
160+
if existing.is_premium:
161+
logger.debug(f"User {user_id} already premium, skipping")
162+
return
163+
164+
# Update existing record
165+
existing.is_premium = True
166+
existing.stripe_customer_id = stripe_customer_id
167+
existing.payment_intent_id = payment_intent_id
168+
existing.paid_at = utc_now()
169+
existing.updated_at = utc_now()
170+
session.add(existing)
171+
else:
172+
# Create new record
173+
premium_user = PremiumUser(
174+
user_id=user_id,
175+
stripe_customer_id=stripe_customer_id,
176+
is_premium=True,
177+
payment_intent_id=payment_intent_id,
178+
paid_at=utc_now(),
179+
)
180+
session.add(premium_user)
181+
182+
session.commit()
183+
logger.debug(f"User {user_id} upgraded to premium")

0 commit comments

Comments
 (0)