diff --git a/_bmad-output/implementation-artifacts/5-2-stripe-payment-integration.md b/_bmad-output/implementation-artifacts/5-2-stripe-payment-integration.md index 9cda25ca6..b7dd6c9ec 100644 --- a/_bmad-output/implementation-artifacts/5-2-stripe-payment-integration.md +++ b/_bmad-output/implementation-artifacts/5-2-stripe-payment-integration.md @@ -1,6 +1,6 @@ # Story 5.2: Tích hợp Stripe Subscription Checkout (Stripe Payment Integration) -Status: ready-for-dev +Status: done ## 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 -- [ ] 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`. - - [ ] 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] Task 1: Thêm `stripe_customer_id` vào User (Backend DB) + - [x] Subtask 1.1: Alembic migration thêm column `stripe_customer_id` — đã có trong migration 124. + - [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) - - [ ] Subtask 2.1: Tạo endpoint `POST /api/v1/stripe/create-subscription-checkout`. - - [ ] Subtask 2.2: Request body: `{ "plan_id": "pro_monthly" | "pro_yearly" }`. - - [ ] 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á. - - [ ] 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] Task 2: Tạo Subscription Checkout Endpoint (Backend) + - [x] Subtask 2.1: Tạo endpoint `POST /api/v1/stripe/create-subscription-checkout`. + - [x] Subtask 2.2: Request body: `{ "plan_id": "pro_monthly" | "pro_yearly" }` — validated bằng `PlanId` enum. + - [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. + - [x] Subtask 2.4: Gọi `stripe.checkout.sessions.create(mode='subscription', customer=stripe_customer_id, ...)`. + - [x] Subtask 2.5: Trả về `{ "checkout_url": "https://checkout.stripe.com/..." }`. -- [ ] 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`. - - [ ] Subtask 3.2: Redirect đến `checkout_url`. - - [ ] Subtask 3.3: Xử lý success return URL — hiển thị toast "Subscription activated!" +- [x] Task 3: Kết nối Frontend với Endpoint mới + - [x] Subtask 3.1: `pricing-section.tsx` đã gọi endpoint với `plan_id` — done trong Story 5.1. + - [x] Subtask 3.2: Redirect đến `checkout_url` — done trong Story 5.1. + - [x] Subtask 3.3: `/subscription-success` page tạo mới — invalidates user query + toast "Subscription activated!" ## Dev Notes @@ -63,3 +63,36 @@ Sau checkout, Stripe sẽ gửi `checkout.session.completed` → webhook handler ### References - `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 + +## Dev Agent Record + +### Implementation Notes +- Migration 124 đã có `stripe_customer_id` và `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. diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index c2c8c0211..579c856cb 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -8,3 +8,9 @@ ## 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` 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. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 8598953da..e703f4c2d 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -67,7 +67,7 @@ development_status: epic-4-retrospective: optional epic-5: in-progress 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-4-usage-tracking-rate-limit-enforcement: ready-for-dev epic-5-retrospective: optional diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 575db4c7b..733425b6a 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -296,6 +296,9 @@ class Config: STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY") STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET") 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_PAGE_BUYING_ENABLED = ( os.getenv("STRIPE_PAGE_BUYING_ENABLED", "TRUE").upper() == "TRUE" diff --git a/surfsense_backend/app/routes/stripe_routes.py b/surfsense_backend/app/routes/stripe_routes.py index 672f67cad..2680410c4 100644 --- a/surfsense_backend/app/routes/stripe_routes.py +++ b/surfsense_backend/app/routes/stripe_routes.py @@ -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 @@ -13,11 +13,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from stripe import SignatureVerificationError, StripeClient, StripeError 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 ( CreateCheckoutSessionRequest, CreateCheckoutSessionResponse, + CreateSubscriptionCheckoutRequest, + CreateSubscriptionCheckoutResponse, PagePurchaseHistoryResponse, + PlanId, StripeStatusResponse, StripeWebhookResponse, ) @@ -76,6 +79,86 @@ def _normalize_optional_string(value: Any) -> str | None: 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]: metadata = getattr(checkout_session, "metadata", None) or {} if isinstance(metadata, dict): @@ -271,6 +354,91 @@ async def create_checkout_session( 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) async def get_stripe_status() -> StripeStatusResponse: """Return page-buying availability for frontend feature gating.""" diff --git a/surfsense_backend/app/schemas/stripe.py b/surfsense_backend/app/schemas/stripe.py index 1c3185601..f1322b5e9 100644 --- a/surfsense_backend/app/schemas/stripe.py +++ b/surfsense_backend/app/schemas/stripe.py @@ -1,13 +1,21 @@ -"""Schemas for Stripe-backed page purchases.""" +"""Schemas for Stripe-backed page purchases and subscriptions.""" import uuid from datetime import datetime +from enum import Enum from pydantic import BaseModel, ConfigDict, Field from app.db import PagePurchaseStatus +class PlanId(str, Enum): + """Supported subscription plan identifiers.""" + + pro_monthly = "pro_monthly" + pro_yearly = "pro_yearly" + + class CreateCheckoutSessionRequest(BaseModel): """Request body for creating a page-purchase checkout session.""" @@ -15,6 +23,18 @@ class CreateCheckoutSessionRequest(BaseModel): 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): """Response containing the Stripe-hosted checkout URL.""" diff --git a/surfsense_web/app/subscription-success/page.tsx b/surfsense_web/app/subscription-success/page.tsx new file mode 100644 index 000000000..3982eeea3 --- /dev/null +++ b/surfsense_web/app/subscription-success/page.tsx @@ -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("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 ( +
+ + + + Verifying payment… + + +
+ ); + } + + if (state === "failed") { + return ( +
+ + + + Verification failed + + We couldn't verify your payment. If you were charged, your subscription will activate shortly. + + + + + + + +
+ ); + } + + return ( +
+ + + + Subscription activated! + + Your Pro plan is now active. Enjoy unlimited power. + + + +

+ Your account has been upgraded. All Pro features are now available. +

+
+ + + + +
+
+ ); +} + +export default function SubscriptionSuccessPage() { + return ( + + + + ); +}