mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
Merge pull request #2 from deptrai/develop
feat(story-5.2): Stripe subscription checkout with session verification
This commit is contained in:
commit
bb340ce9a2
7 changed files with 382 additions and 18 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
134
surfsense_web/app/subscription-success/page.tsx
Normal file
134
surfsense_web/app/subscription-success/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue