feat(story-5.2): add Stripe subscription checkout with session verification

Add POST /api/v1/stripe/create-subscription-checkout endpoint with
get_or_create_stripe_customer (SELECT FOR UPDATE), plan_id→price_id
mapping from env vars, active subscription guard (409), and
session_id in success URL. Add GET /verify-checkout-session endpoint
for server-side payment verification. Add /subscription-success
frontend page with loading/verified/failed states.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vonic 2026-04-14 23:58:57 +07:00
parent 71edc183c4
commit 07a4bc3fc3
7 changed files with 382 additions and 18 deletions

View file

@ -1,6 +1,6 @@
# Story 5.2: Tích hợp Stripe Subscription Checkout (Stripe Payment Integration) # Story 5.2: Tích hợp Stripe Subscription Checkout (Stripe Payment Integration)
Status: ready-for-dev Status: done
## Story ## Story
@ -29,21 +29,21 @@ so that tôi có thể điền thông tin thẻ tín dụng mà không sợ bị
## Tasks / Subtasks ## Tasks / Subtasks
- [ ] Task 1: Thêm `stripe_customer_id` vào User (Backend DB) - [x] Task 1: Thêm `stripe_customer_id` vào User (Backend DB)
- [ ] Subtask 1.1: Alembic migration thêm column `stripe_customer_id` (String, nullable, unique, indexed) vào `User`. - [x] Subtask 1.1: Alembic migration thêm column `stripe_customer_id` — đã có trong migration 124.
- [ ] Subtask 1.2: Tạo helper function `get_or_create_stripe_customer(user)` — nếu `stripe_customer_id` null → gọi `stripe.customers.create(email=user.email)` → lưu ID vào DB. - [x] Subtask 1.2: Tạo helper function `get_or_create_stripe_customer(user)` — SELECT FOR UPDATE, tạo Stripe customer nếu chưa có, persist ID.
- [ ] Task 2: Tạo Subscription Checkout Endpoint (Backend) - [x] Task 2: Tạo Subscription Checkout Endpoint (Backend)
- [ ] Subtask 2.1: Tạo endpoint `POST /api/v1/stripe/create-subscription-checkout`. - [x] Subtask 2.1: Tạo endpoint `POST /api/v1/stripe/create-subscription-checkout`.
- [ ] Subtask 2.2: Request body: `{ "plan_id": "pro_monthly" | "pro_yearly" }`. - [x] Subtask 2.2: Request body: `{ "plan_id": "pro_monthly" | "pro_yearly" }` — validated bằng `PlanId` enum.
- [ ] Subtask 2.3: Map `plan_id` → Stripe Price ID từ env vars (`STRIPE_PRO_MONTHLY_PRICE_ID`, `STRIPE_PRO_YEARLY_PRICE_ID`). Tuyệt đối **không nhận price từ frontend** — phòng tránh giả mạo giá. - [x] Subtask 2.3: Map `plan_id` → Stripe Price ID từ env vars (`STRIPE_PRO_MONTHLY_PRICE_ID`, `STRIPE_PRO_YEARLY_PRICE_ID`). Frontend không gửi price.
- [ ] Subtask 2.4: Gọi `stripe.checkout.sessions.create(mode='subscription', customer=stripe_customer_id, ...)`. - [x] Subtask 2.4: Gọi `stripe.checkout.sessions.create(mode='subscription', customer=stripe_customer_id, ...)`.
- [ ] Subtask 2.5: Trả về `{ "checkout_url": "https://checkout.stripe.com/..." }`. - [x] Subtask 2.5: Trả về `{ "checkout_url": "https://checkout.stripe.com/..." }`.
- [ ] Task 3: Kết nối Frontend với Endpoint mới - [x] Task 3: Kết nối Frontend với Endpoint mới
- [ ] Subtask 3.1: Từ `pricing-section.tsx`, nút "Upgrade to Pro" gọi `POST /api/v1/stripe/create-subscription-checkout` với `plan_id`. - [x] Subtask 3.1: `pricing-section.tsx` đã gọi endpoint với `plan_id` — done trong Story 5.1.
- [ ] Subtask 3.2: Redirect đến `checkout_url`. - [x] Subtask 3.2: Redirect đến `checkout_url` — done trong Story 5.1.
- [ ] Subtask 3.3: Xử lý success return URL — hiển thị toast "Subscription activated!" - [x] Subtask 3.3: `/subscription-success` page tạo mới — invalidates user query + toast "Subscription activated!"
## Dev Notes ## Dev Notes
@ -63,3 +63,36 @@ Sau checkout, Stripe sẽ gửi `checkout.session.completed` → webhook handler
### References ### References
- `surfsense_backend/app/routes/stripe_routes.py` — endpoint PAYG hiện tại (tham khảo pattern) - `surfsense_backend/app/routes/stripe_routes.py` — endpoint PAYG hiện tại (tham khảo pattern)
- Stripe Subscription Checkout docs: https://stripe.com/docs/billing/subscriptions/build-subscriptions - Stripe Subscription Checkout docs: https://stripe.com/docs/billing/subscriptions/build-subscriptions
## Dev Agent Record
### Implementation Notes
- Migration 124 đã có `stripe_customer_id``stripe_subscription_id` từ Story 3.5 — không cần migration mới.
- `get_or_create_stripe_customer`: dùng `SELECT FOR UPDATE` để tránh duplicate customer khi concurrent requests.
- `_get_price_id_for_plan`: map `PlanId` enum → env var `STRIPE_PRO_MONTHLY_PRICE_ID` / `STRIPE_PRO_YEARLY_PRICE_ID`. Frontend không gửi price ID.
- Endpoint `POST /create-subscription-checkout`: `mode='subscription'`, `customer=stripe_customer_id`, `success_url=/subscription-success`, `cancel_url=/pricing`.
- `PlanId` enum trong schemas/stripe.py đảm bảo frontend chỉ gửi giá trị hợp lệ.
- Frontend success page `/subscription-success`: toast "Subscription activated!" + invalidate user query.
### Completion Notes
✅ Tất cả tasks/subtasks hoàn thành. AC 1-3 đều được đáp ứng.
### File List
- `surfsense_backend/app/config/__init__.py` — added `STRIPE_PRO_MONTHLY_PRICE_ID`, `STRIPE_PRO_YEARLY_PRICE_ID`
- `surfsense_backend/app/schemas/stripe.py` — added `PlanId` enum, `CreateSubscriptionCheckoutRequest`, `CreateSubscriptionCheckoutResponse`
- `surfsense_backend/app/routes/stripe_routes.py` — added `_get_subscription_success_url`, `_get_price_id_for_plan`, `get_or_create_stripe_customer`, `POST /create-subscription-checkout`
- `surfsense_web/app/subscription-success/page.tsx` — new success page with toast + user query invalidation
### Review Findings
- [x] [Review][Decision] Success page verify payment server-side — added GET /verify-checkout-session endpoint + frontend verify flow
- [x] [Review][Patch] Success URL includes `?session_id={CHECKOUT_SESSION_ID}` template variable [stripe_routes.py:89]
- [x] [Review][Patch] Duplicate NEXT_FRONTEND_URL check removed — refactored to `_get_subscription_urls()` [stripe_routes.py:82]
- [x] [Review][Patch] Added active subscription guard (409 Conflict) before creating checkout [stripe_routes.py:370]
- [x] [Review][Patch] Toast only fires once via `useRef` flag + only after verified [subscription-success/page.tsx]
- [x] [Review][Defer] Webhook không xử lý subscription-mode checkout — deferred to Story 5.3
- [x] [Review][Defer] Không có handler cho subscription lifecycle events — deferred to Story 5.3
- [x] [Review][Defer] Orphan Stripe customer nếu commit fail sau API call — deferred, low probability
### Change Log
- 2026-04-14: Implement subscription checkout endpoint with Stripe customer creation and success page.

View file

@ -8,3 +8,9 @@
## Deferred from: code review of story 5-1 (2026-04-14) ## Deferred from: code review of story 5-1 (2026-04-14)
- `ref` cast `as any` on Switch component in `pricing.tsx:99` — pre-existing issue, not introduced by this change. Should use proper `React.ComponentRef<typeof Switch>` type. - `ref` cast `as any` on Switch component in `pricing.tsx:99` — pre-existing issue, not introduced by this change. Should use proper `React.ComponentRef<typeof Switch>` type.
## Deferred from: code review of story 5-2 (2026-04-14)
- Webhook handler needs to distinguish `mode='subscription'` from `mode='payment'` in `checkout.session.completed` and update User's `subscription_status`, `plan_id`, `stripe_subscription_id` — scope of Story 5.3.
- Subscription lifecycle events (`invoice.paid`, `customer.subscription.updated/deleted`, `invoice.payment_failed`) not handled — scope of Story 5.3.
- `_get_or_create_stripe_customer` can create orphaned Stripe customers if `db_session.commit()` fails after `customers.create`. Consider idempotency key in future.

View file

@ -67,7 +67,7 @@ development_status:
epic-4-retrospective: optional epic-4-retrospective: optional
epic-5: in-progress epic-5: in-progress
5-1-pricing-plan-selection-ui: done 5-1-pricing-plan-selection-ui: done
5-2-stripe-payment-integration: ready-for-dev 5-2-stripe-payment-integration: done
5-3-stripe-webhook-sync: ready-for-dev 5-3-stripe-webhook-sync: ready-for-dev
5-4-usage-tracking-rate-limit-enforcement: ready-for-dev 5-4-usage-tracking-rate-limit-enforcement: ready-for-dev
epic-5-retrospective: optional epic-5-retrospective: optional

View file

@ -296,6 +296,9 @@ class Config:
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY") STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET") STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
STRIPE_PRICE_ID = os.getenv("STRIPE_PRICE_ID") STRIPE_PRICE_ID = os.getenv("STRIPE_PRICE_ID")
# Stripe subscription price IDs
STRIPE_PRO_MONTHLY_PRICE_ID = os.getenv("STRIPE_PRO_MONTHLY_PRICE_ID")
STRIPE_PRO_YEARLY_PRICE_ID = os.getenv("STRIPE_PRO_YEARLY_PRICE_ID")
STRIPE_PAGES_PER_UNIT = int(os.getenv("STRIPE_PAGES_PER_UNIT", "1000")) STRIPE_PAGES_PER_UNIT = int(os.getenv("STRIPE_PAGES_PER_UNIT", "1000"))
STRIPE_PAGE_BUYING_ENABLED = ( STRIPE_PAGE_BUYING_ENABLED = (
os.getenv("STRIPE_PAGE_BUYING_ENABLED", "TRUE").upper() == "TRUE" os.getenv("STRIPE_PAGE_BUYING_ENABLED", "TRUE").upper() == "TRUE"

View file

@ -1,4 +1,4 @@
"""Stripe routes for pay-as-you-go page purchases.""" """Stripe routes for pay-as-you-go page purchases and subscriptions."""
from __future__ import annotations from __future__ import annotations
@ -13,11 +13,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from stripe import SignatureVerificationError, StripeClient, StripeError from stripe import SignatureVerificationError, StripeClient, StripeError
from app.config import config from app.config import config
from app.db import PagePurchase, PagePurchaseStatus, User, get_async_session from app.db import PagePurchase, PagePurchaseStatus, SubscriptionStatus, User, get_async_session
from app.schemas.stripe import ( from app.schemas.stripe import (
CreateCheckoutSessionRequest, CreateCheckoutSessionRequest,
CreateCheckoutSessionResponse, CreateCheckoutSessionResponse,
CreateSubscriptionCheckoutRequest,
CreateSubscriptionCheckoutResponse,
PagePurchaseHistoryResponse, PagePurchaseHistoryResponse,
PlanId,
StripeStatusResponse, StripeStatusResponse,
StripeWebhookResponse, StripeWebhookResponse,
) )
@ -76,6 +79,86 @@ def _normalize_optional_string(value: Any) -> str | None:
return getattr(value, "id", str(value)) return getattr(value, "id", str(value))
def _get_subscription_urls() -> tuple[str, str]:
"""Return (success_url, cancel_url) for subscription checkout."""
if not config.NEXT_FRONTEND_URL:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="NEXT_FRONTEND_URL is not configured.",
)
base = config.NEXT_FRONTEND_URL.rstrip("/")
success_url = f"{base}/subscription-success?session_id={{CHECKOUT_SESSION_ID}}"
cancel_url = f"{base}/pricing"
return success_url, cancel_url
def _get_price_id_for_plan(plan_id: PlanId) -> str:
"""Map a plan_id enum to the corresponding Stripe Price ID from env vars."""
if plan_id == PlanId.pro_monthly:
price_id = config.STRIPE_PRO_MONTHLY_PRICE_ID
if not price_id:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="STRIPE_PRO_MONTHLY_PRICE_ID is not configured.",
)
return price_id
if plan_id == PlanId.pro_yearly:
price_id = config.STRIPE_PRO_YEARLY_PRICE_ID
if not price_id:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="STRIPE_PRO_YEARLY_PRICE_ID is not configured.",
)
return price_id
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown plan_id: {plan_id}",
)
async def _get_or_create_stripe_customer(
stripe_client: StripeClient,
user: User,
db_session: AsyncSession,
) -> str:
"""Return existing Stripe customer ID or create a new one and persist it.
Uses SELECT ... FOR UPDATE to prevent duplicate customer creation under
concurrent requests for the same user.
"""
if user.stripe_customer_id:
return user.stripe_customer_id
locked_user = (
(
await db_session.execute(
select(User).where(User.id == user.id).with_for_update()
)
)
.unique()
.scalar_one()
)
# Re-check after acquiring the lock — another request may have created it.
if locked_user.stripe_customer_id:
return locked_user.stripe_customer_id
try:
customer = stripe_client.v1.customers.create(
params={"email": locked_user.email, "metadata": {"user_id": str(locked_user.id)}}
)
except StripeError as exc:
logger.exception("Failed to create Stripe customer for user %s", locked_user.id)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Unable to create Stripe customer.",
) from exc
locked_user.stripe_customer_id = str(customer.id)
await db_session.commit()
return locked_user.stripe_customer_id
def _get_metadata(checkout_session: Any) -> dict[str, str]: def _get_metadata(checkout_session: Any) -> dict[str, str]:
metadata = getattr(checkout_session, "metadata", None) or {} metadata = getattr(checkout_session, "metadata", None) or {}
if isinstance(metadata, dict): if isinstance(metadata, dict):
@ -271,6 +354,91 @@ async def create_checkout_session(
return CreateCheckoutSessionResponse(checkout_url=checkout_url) return CreateCheckoutSessionResponse(checkout_url=checkout_url)
@router.post(
"/create-subscription-checkout",
response_model=CreateSubscriptionCheckoutResponse,
)
async def create_subscription_checkout(
body: CreateSubscriptionCheckoutRequest,
user: User = Depends(current_active_user),
db_session: AsyncSession = Depends(get_async_session),
) -> CreateSubscriptionCheckoutResponse:
"""Create a Stripe Checkout Session for a recurring subscription."""
stripe_client = get_stripe_client()
price_id = _get_price_id_for_plan(body.plan_id)
success_url, cancel_url = _get_subscription_urls()
# Prevent duplicate subscriptions
if user.subscription_status == SubscriptionStatus.ACTIVE:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="You already have an active subscription.",
)
customer_id = await _get_or_create_stripe_customer(stripe_client, user, db_session)
try:
checkout_session = stripe_client.v1.checkout.sessions.create(
params={
"mode": "subscription",
"customer": customer_id,
"success_url": success_url,
"cancel_url": cancel_url,
"line_items": [{"price": price_id, "quantity": 1}],
"metadata": {
"user_id": str(user.id),
"plan_id": body.plan_id.value,
},
}
)
except StripeError as exc:
logger.exception(
"Failed to create Stripe subscription checkout for user %s", user.id
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Unable to create Stripe subscription checkout session.",
) from exc
checkout_url = getattr(checkout_session, "url", None)
if not checkout_url:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Stripe subscription checkout session did not return a URL.",
)
return CreateSubscriptionCheckoutResponse(checkout_url=checkout_url)
@router.get("/verify-checkout-session")
async def verify_checkout_session(
session_id: str,
user: User = Depends(current_active_user),
) -> dict:
"""Verify a Stripe Checkout Session belongs to the user and is paid."""
stripe_client = get_stripe_client()
try:
session = stripe_client.v1.checkout.sessions.retrieve(session_id)
except StripeError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid checkout session.",
) from exc
metadata = getattr(session, "metadata", None) or {}
if metadata.get("user_id") != str(user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Session does not belong to this user.",
)
payment_status = getattr(session, "payment_status", None)
return {
"verified": payment_status in {"paid", "no_payment_required"},
"payment_status": payment_status,
}
@router.get("/status", response_model=StripeStatusResponse) @router.get("/status", response_model=StripeStatusResponse)
async def get_stripe_status() -> StripeStatusResponse: async def get_stripe_status() -> StripeStatusResponse:
"""Return page-buying availability for frontend feature gating.""" """Return page-buying availability for frontend feature gating."""

View file

@ -1,13 +1,21 @@
"""Schemas for Stripe-backed page purchases.""" """Schemas for Stripe-backed page purchases and subscriptions."""
import uuid import uuid
from datetime import datetime from datetime import datetime
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.db import PagePurchaseStatus from app.db import PagePurchaseStatus
class PlanId(str, Enum):
"""Supported subscription plan identifiers."""
pro_monthly = "pro_monthly"
pro_yearly = "pro_yearly"
class CreateCheckoutSessionRequest(BaseModel): class CreateCheckoutSessionRequest(BaseModel):
"""Request body for creating a page-purchase checkout session.""" """Request body for creating a page-purchase checkout session."""
@ -15,6 +23,18 @@ class CreateCheckoutSessionRequest(BaseModel):
search_space_id: int = Field(ge=1) search_space_id: int = Field(ge=1)
class CreateSubscriptionCheckoutRequest(BaseModel):
"""Request body for creating a subscription checkout session."""
plan_id: PlanId
class CreateSubscriptionCheckoutResponse(BaseModel):
"""Response containing the Stripe-hosted subscription checkout URL."""
checkout_url: str
class CreateCheckoutSessionResponse(BaseModel): class CreateCheckoutSessionResponse(BaseModel):
"""Response containing the Stripe-hosted checkout URL.""" """Response containing the Stripe-hosted checkout URL."""

View file

@ -0,0 +1,134 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Loader2, XCircle } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type VerifyState = "loading" | "verified" | "failed";
function SubscriptionSuccessContent() {
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const sessionId = searchParams.get("session_id");
const [state, setState] = useState<VerifyState>("loading");
const hasVerified = useRef(false);
useEffect(() => {
if (hasVerified.current) return;
hasVerified.current = true;
if (!sessionId) {
setState("failed");
return;
}
(async () => {
try {
const res = await authenticatedFetch(
`${BACKEND_URL}/api/v1/stripe/verify-checkout-session?session_id=${encodeURIComponent(sessionId)}`
);
if (!res.ok) {
setState("failed");
return;
}
const data = await res.json();
if (data.verified) {
setState("verified");
toast.success("Subscription activated! Welcome to Pro.");
void queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
} else {
setState("failed");
}
} catch {
setState("failed");
}
})();
}, [sessionId, queryClient]);
if (state === "loading") {
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<Loader2 className="mx-auto h-10 w-10 animate-spin text-muted-foreground" />
<CardTitle className="text-2xl">Verifying payment</CardTitle>
</CardHeader>
</Card>
</div>
);
}
if (state === "failed") {
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<XCircle className="mx-auto h-10 w-10 text-destructive" />
<CardTitle className="text-2xl">Verification failed</CardTitle>
<CardDescription>
We couldn&apos;t verify your payment. If you were charged, your subscription will activate shortly.
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-center gap-3">
<Button asChild>
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/pricing">View Plans</Link>
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" />
<CardTitle className="text-2xl">Subscription activated!</CardTitle>
<CardDescription>
Your Pro plan is now active. Enjoy unlimited power.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-center">
<p className="text-sm text-muted-foreground">
Your account has been upgraded. All Pro features are now available.
</p>
</CardContent>
<CardFooter className="flex justify-center gap-3">
<Button asChild>
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/pricing">View Plans</Link>
</Button>
</CardFooter>
</Card>
</div>
);
}
export default function SubscriptionSuccessPage() {
return (
<Suspense>
<SubscriptionSuccessContent />
</Suspense>
);
}