docs: rewrite story 3.5 and epic 5 stories to match actual codebase

Stories were written for a subscription SaaS model, but SurfSense is a
self-hosted product with BYOK + optional PAYG page packs via Stripe.

Key corrections:
- Story 3.5: Not "remove BYOK + token billing" → actual gap is adding
  HTTP-layer quota pre-check before document upload enqueue
- Story 5.1: Pricing UI already exists (Free/PAYG/Enterprise) → gap is
  wiring "Get Started" button to existing Stripe checkout endpoint
- Story 5.2: mode=payment PAYG already works → needs verification/hardening
  not a subscription checkout rewrite
- Story 5.3: Webhook already handles checkout.session.completed correctly
  → no subscription events needed, gap is idempotency test + purchase history UI
- Story 5.4: PageLimitService + enforcement in tasks/connectors already exists
  → gap is HTTP-layer pre-check, quota UI indicator, and 402 frontend handling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vonic 2026-04-14 14:08:55 +07:00
parent dc545f8028
commit 04fb9eec0f
5 changed files with 274 additions and 132 deletions

View file

@ -1,62 +1,62 @@
# Story 3.5: Lựa chọn Mô hình LLM dựa trên Subscription (Model Selection via Quota)
# Story 3.5: Enforce Page Quota tại Document Upload API
Status: ready-for-dev
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Context / Correction Note
> **⚠️ Story gốc bị sai hướng.** Story gốc mô tả việc xóa BYOK và dùng system API keys — đây là sai với kiến trúc thực tế của SurfSense. SurfSense là **self-hosted** product, BYOK (Bring-Your-Own-Key) là tính năng cốt lõi và cần giữ nguyên. Hệ thống quota thực tế dùng **pages** (document processing), không phải tokens LLM.
## Story
As a Người dùng,
I want chọn cấu hình mô hình trí tuệ nhân tạo (VD: Claude 3.5 Sonnet, GPT-4) được cung cấp sẵn mà không cần điền API key cá nhân,
so that tôi có thể dùng trực tiếp và chi phí sử dụng được trừ thẳng vào số Token thuộc gói cước của tôi.
I want nhận thông báo rõ ràng khi tôi đã dùng hết pages quota,
so that tôi biết cần mua thêm page packs trước khi upload thêm tài liệu.
## Actual Architecture (as-is)
- **LLM**: BYOK via `NewLLMConfig` — user tự cấu hình API key của từng provider (OpenAI, Anthropic, etc.). **Giữ nguyên, không thay đổi.**
- **Quota**: `pages_limit` / `pages_used` trên bảng `User` — track lượng pages đã ETL
- **PageLimitService**: `surfsense_backend/app/services/page_limit_service.py` — đã implement đầy đủ `check_page_limit()`, `update_page_usage()`, `get_page_usage()`
- **Enforcement đã có**: trong `document_tasks.py` (Celery), các connector indexers (Google Drive, OneDrive, Dropbox, Notion, etc.)
- **Enforcement còn thiếu**: tại HTTP API layer trước khi enqueue task
## Acceptance Criteria
1. Hệ thống cung cấp Dropdown chọn `LLM Model` trong giao diện Chat.
2. Tuyệt đối không hiển thị ô nhập "Your API Key" ở Frontend (Xóa bỏ logic Bring-Your-Own-Key cũ nếu có).
3. Hệ thống Backend tính toán chi phí (Token * Đơn giá của Model).
4. Hệ thống kiểm tra Quota hàng tháng của người dùng; nếu vượt quá (Quota Exceeded), trả về lỗi 403/429 báo "Hãy nâng cấp gói".
1. Khi user upload document và `pages_used >= pages_limit`, API trả về HTTP 402 với message rõ ràng trước khi queue Celery task.
2. Frontend bắt lỗi 402 từ upload API và hiển thị toast/modal hướng user đến trang Pricing để mua thêm pages.
3. Endpoint `GET /api/v1/users/me` (hoặc equivalent) trả về `pages_used``pages_limit` để FE hiển thị quota indicator.
## Tasks / Subtasks
- [ ] Task 1: Dọn dẹp Kiến trúc Client cũ (Frontend)
- [ ] Subtask 1.1: Gỡ bỏ flow nhập `api_key` tự do ở giao diện LLM Configs (`surfsense_web/app/dashboard/[search_space_id]/llm-configs`), khoá cứng các tuỳ chọn chọn Provider.
- [ ] Subtask 1.2: Triển khai Component `ModelSelector` trên UI Chat (hoặc sửa đổi UI chọn LLM Config cũ sang danh sách LLM thương mại).
- [ ] Task 2: Cập nhật Schema & Table Quản lý Quota (Backend - `db.py`)
- [ ] Subtask 2.1: Bổ sung column `token_balance` (Integer) vào model `User` (hoặc tạo table `UserSubscription`).
- [ ] Subtask 2.2: Bổ sung ENUM `LLM_MODEL` vào config db.
- [ ] Task 3: Tích hợp logic Trừ Quota vào RAG Engine (Backend - `rag_engine` / `chat_session_api`)
- [ ] Subtask 3.1: Ở API Endpoint Streaming, kiểm tra `token_balance` trước khi khởi tạo luồng SSE. Nếu <= 0, trả HTTP 402/429.
- [ ] Subtask 3.2: Dùng `tiktoken` hoặc đếm chữ ước tính số tokens Generate để UPDATE trừ lùi vào Database sau mỗi chu kỳ trả lời xong.
- [ ] Task 4: Hiển thị Banner nâng cấp (Frontend)
- [ ] Subtask 4.1: Bắt lỗi 402/429 từ SSE, render Alert UI Upgrade.
- [ ] Task 1: Thêm quota check vào Document Upload API route
- [ ] Subtask 1.1: Tại route xử lý document upload (tìm trong `surfsense_backend/app/routes/`), inject `PageLimitService` và gọi `check_page_limit()` với estimated pages trước khi enqueue Celery task.
- [ ] Subtask 1.2: Raise `HTTPException(status_code=402, detail="Page quota exceeded. Please purchase more pages.")` khi bị vượt giới hạn.
- [ ] Task 2: Frontend xử lý lỗi 402
- [ ] Subtask 2.1: Trong component upload document, bắt HTTP 402 response và render toast/alert "Bạn đã hết page quota. Mua thêm tại /pricing".
- [ ] Subtask 2.2: Link trong toast/alert dẫn đến `/pricing`.
- [ ] Task 3: Hiển thị quota indicator (nice-to-have)
- [ ] Subtask 3.1: Thêm `pages_used` / `pages_limit` vào response của current user endpoint.
- [ ] Subtask 3.2: Hiển thị progress bar nhỏ trong UI.
## Dev Notes
### Relevant Architecture Patterns & Constraints
- **Billing Pivot Constraint:** Đây là cốt lõi của quá trình chuyển đổi sang nền tảng Thương mại (SaaS SaaS Commercialization). CẤM các logic Hardcode API key người dùng truyền vào, tất cả Server Auth phải lấy từ biến môi trường `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`.
- **Concurrency & Database Locks:** Việc trừ Token balance phải chịu sự cạnh tranh cao (ví dụ mở 2 tab chat đồng thời). Yêu cầu cân nhắc dùng `session.execute(update(User).where(...).values(token_balance=User.token_balance - cost))` để tận dụng Database atomic locks (tránh Race conditions).
- **RAG Endpoint:** `Surfsense` stream thông qua giao thức SSE ở `/api/v1/chat/stream`. Hãy thêm interceptor kiểm tra số dư ngay đầu route (Fail-fast).
### What Already Works (Don't Re-Implement)
- `PageLimitService.check_page_limit()` — dùng trực tiếp, không cần viết lại
- Quota enforcement trong Celery tasks và connectors — đã có, giữ nguyên
- `pages_limit` tự động tăng khi user mua page pack (xem `stripe_routes.py:_fulfill_completed_purchase`)
### Project Structure Notes
- Module thay đổi:
- `surfsense_backend/app/db.py`
- `surfsense_backend/app/routes/chat_routes.py` (hoặc nơi implement SSE)
- `surfsense_web/src/components/chat/`
- Phải đảm bảo DB schema migration (bằng Alembic) khi có thuộc tính mới `token_balance` ở User.
- Endpoint `/api/v1/chat/stream` hiện đang phụ thuộc vào `NewLLMConfig`. Cần chỉnh sửa kiến trúc để ánh xạ ID Model do người dùng chọn sang Backend Config cố định của hệ thống.
### What Needs to Change
- Chỉ cần thêm 1 check tại HTTP layer (trước khi enqueue) để user nhận feedback ngay, thay vì đợi task chạy xong mới biết bị reject.
### References
- [Epic 3 - RAG Engine Requirements]: Epic `3.5`.
- [Constraint from BMad Rule]: "All billing must handle quota limits based on the user's current subscription".
- `surfsense_backend/app/services/page_limit_service.py`
- `surfsense_backend/app/tasks/celery_tasks/document_tasks.py` (xem cách dùng PageLimitService ở đây làm mẫu)
## Dev Agent Record
### Agent Model Used
Antigravity Claude 3.5 Sonnet Engine (Context: 120k)
_TBD_
### File List
- `surfsense_backend/app/db.py`
- `surfsense_backend/app/routes/chat_routes.py`
- `surfsense_web/app/dashboard/[search_space_id]/llm-configs/...`
- `surfsense_web/components/chat/...`
- `surfsense_backend/app/routes/` (document upload route — cần tìm file cụ thể)
- `surfsense_backend/app/services/page_limit_service.py` (đọc, không sửa)
- Frontend upload component (cần tìm file cụ thể)

View file

@ -1,51 +1,76 @@
# Story 5.1: Giao diện Bảng giá & Lựa chọn Gói Cước (Pricing Plan Selection UI)
# Story 5.1: Kết nối Pricing UI với Stripe Checkout
Status: ready-for-dev
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Context / Correction Note
> **⚠️ Story gốc bị sai hướng.** Story gốc mô tả tạo mới pricing page với Free/Pro/Team subscription tiers. Thực tế, pricing page **đã tồn tại** với mô hình PAYG (Pay-As-You-Go page packs), không phải subscription. Stripe checkout endpoint cũng đã tồn tại. Task thực tế là **wire up** nút "Get Started" của PAYG tier với endpoint hiện có.
## Story
As a Khách hàng tiềm năng,
I want xem một bảng giá rõ ràng về các gói cước (ví dụ: Free, Pro, Team) với quyền lợi tương ứng,
so that tôi biết chính xác số lượng file/tin nhắn mình nhận được trước khi quyết định nâng cấp hoặc duy trì để quản lý ví (Wallet/Token) của mình.
As a Người dùng đã đăng nhập,
I want bấm "Get Started" trên trang Pricing để mua page packs,
so that tôi có thể tiếp tục upload tài liệu sau khi hết quota.
## Actual Architecture (as-is)
**Đã tồn tại và hoạt động:**
- `surfsense_web/app/(home)/pricing/page.tsx` — pricing page route
- `surfsense_web/components/pricing/pricing-section.tsx` — UI với 3 tiers:
- **FREE**: 500 pages included, button href="/login"
- **PAY AS YOU GO**: $1/1,000 pages, button href="/login" ← **cần sửa**
- **ENTERPRISE**: Contact Sales, button href="/contact"
- `surfsense_backend/app/routes/stripe_routes.py:create_checkout_session` — endpoint `POST /api/v1/stripe/create-checkout-session` đã implement, mode=`payment`, yêu cầu `search_space_id``quantity`
**Chưa làm:**
- Nút "Get Started" của PAYG tier chỉ link đến `/login`, chưa gọi Stripe checkout
- Không có flow chọn số lượng page packs (quantity)
## Acceptance Criteria
1. UI hiển thị các mức giá (monthly/yearly) rõ ràng cùng các bullets tính năng.
2. Thiết kế áp dụng chuẩn UX-DR1 (Dark mode, Base Zinc, Accent Indigo) hiện có của app.
3. Kèm theo hiệu ứng hover mượt mà cho các Pricing Cards (<150ms delay).
4. Phân bổ ít nhất 2 gói cước (Free, Pro) gắn liền với Limit.
1. Khi user đã đăng nhập bấm "Get Started" ở PAYG tier, hiện modal/form cho phép chọn số lượng pack (1, 5, 10, etc.).
2. Sau khi confirm, gọi `POST /api/v1/stripe/create-checkout-session` với `quantity``search_space_id`, nhận `checkout_url`.
3. Redirect user đến `checkout_url` (Stripe-hosted checkout page).
4. Nếu user chưa đăng nhập, redirect đến `/login` trước (behavior hiện tại giữ nguyên cho FREE tier).
## Tasks / Subtasks
- [ ] Task 1: Dựng Page Route Pricing (Frontend)
- [ ] Subtask 1.1: Tạo Component `/src/pages/pricing/page.tsx` (app router tuỳ cấu hình Next.js hoặc Vite App).
- [ ] Subtask 1.2: Sử dụng thư viện `framer-motion` (nếu có sẵn) hoặc Tailwind Utilities (`transition-all duration-150`) để thoả mãn Animation criteria.
- [ ] Task 2: Data cấu hình Static Pricing (Frontend)
- [ ] Subtask 2.1: Cấu trúc Object Constant cho Gói "Free" (Limits: 10 docs, 50 LLM messages/day).
- [ ] Subtask 2.2: Cấu trúc Object Constant cho Gói "Pro" (Limits: 100 docs, 1000 LLM messages/day).
- [ ] Task 3: Liên kết Nút "Upgrade" (Frontend)
- [ ] Subtask 3.1: Nút nâng cấp sẽ chèn hàm mock gọi `/api/v1/stripe/checkout` (Endpoint này sẽ được làm chi tiết ở story 5.2).
- [ ] Task 1: Cập nhật nút PAYG trong `pricing-section.tsx`
- [ ] Subtask 1.1: Thay `href="/login"` bằng `onClick` handler. Nếu user chưa authenticated, redirect `/login`. Nếu đã authenticated, mở modal chọn quantity.
- [ ] Subtask 1.2: Tạo `PurchasePagesModal` component với dropdown/input chọn số pack (110), hiển thị tổng tiền (`quantity × $1`).
- [ ] Task 2: Gọi Stripe checkout API
- [ ] Subtask 2.1: Khi user confirm trong modal, gọi `POST /api/v1/stripe/create-checkout-session` với `{ quantity, search_space_id }`.
- [ ] Subtask 2.2: Nhận `checkout_url` và redirect bằng `window.location.href = checkout_url`.
- [ ] Task 3: Xử lý return URL sau checkout
- [ ] Subtask 3.1: Kiểm tra success/cancel URL config hiện tại trong `stripe_routes.py` (`_get_checkout_urls`).
- [ ] Subtask 3.2: Sau purchase thành công, hiển thị toast "Mua thành công! X pages đã được thêm vào tài khoản."
## Dev Notes
### Relevant Architecture Patterns & Constraints
- **State Management & Data Retrieval:** Vì UI Pricing khá tĩnh, hãy triển khai bằng cấu trúc Const Typescript thay vì gọi DB ở màn đầu tiên nhằm tối ưu tốc độ load.
- Chú ý đến Graceful degradation: Nếu user Offline, màn pricing vẫn load được Static Data, nhưng disable nút "Purchase" để tránh lỗi Network Request.
### Stripe Checkout Request Schema (hiện tại)
```python
class CreateCheckoutSessionRequest(BaseModel):
search_space_id: int
quantity: int # số pack, mỗi pack = STRIPE_PAGES_PER_UNIT pages
```
### Project Structure Notes
- Module thay đổi:
- `surfsense_web/src/pages/pricing/page.tsx` (hoặc tương đương tuỳ thư mục routes).
- `surfsense_web/src/constants/billing.ts` (Lưu định mức cước).
### API Endpoint
```
POST /api/v1/stripe/create-checkout-session
Authorization: Bearer <token>
Body: { "search_space_id": 1, "quantity": 2 }
Response: { "checkout_url": "https://checkout.stripe.com/..." }
```
### References
- [Epic 5 - Commercialization & Account Limits].
- `surfsense_web/components/pricing/pricing-section.tsx`
- `surfsense_backend/app/routes/stripe_routes.py` (lines ~204271)
- `surfsense_web/app/dashboard/[search_space_id]/purchase-cancel/page.tsx`
## Dev Agent Record
### Agent Model Used
Antigravity Claude 3.5 Sonnet Engine
_TBD_
### File List
- `surfsense_web/src/pages/pricing...`
- `surfsense_web/components/pricing/pricing-section.tsx`
- `surfsense_web/components/pricing/PurchasePagesModal.tsx` (new)

View file

@ -1,46 +1,82 @@
# Story 5.2: Tích hợp Stripe Checkout (Stripe Payment Integration)
# Story 5.2: Xác minh & Hardening Stripe PAYG Flow
Status: ready-for-dev
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Context / Correction Note
> **⚠️ Story gốc bị sai hướng.** Story gốc mô tả implement `mode: "subscription"` Stripe Checkout. Thực tế, SurfSense dùng mô hình **PAYG (Pay-As-You-Go page packs)** với `mode: "payment"` (one-time purchase). Endpoint `POST /api/v1/stripe/create-checkout-session` **đã tồn tại và hoạt động**. Story này chỉ cần hardening, không cần implement mới.
## Story
As a Người dùng,
I want bấm "Nâng cấp" và được chuyển tới trang thanh toán an toàn,
so that tôi có thể điền thông tin thẻ tín dụng mà không sợ bị lộ dữ liệu trên máy chủ của SurfSense.
As a Kỹ sư Hệ thống,
I want đảm bảo Stripe PAYG checkout flow hoạt động ổn định end-to-end,
so that user có thể mua page packs và pages được cộng vào tài khoản đúng cách.
## Actual Architecture (as-is)
**Đã implement:**
- `POST /api/v1/stripe/create-checkout-session` — tạo Stripe Checkout Session (`mode: "payment"`)
- Nhận `search_space_id`, `quantity`
- Tạo `PagePurchase` record với status `PENDING`
- Trả về `checkout_url`
- `_fulfill_completed_purchase()` — khi webhook confirm, tăng `user.pages_limit`
- `_get_checkout_urls()` — tạo success/cancel URL cho Stripe redirect
**Config cần thiết (env vars):**
- `STRIPE_SECRET_KEY`
- `STRIPE_PRICE_ID` — price ID cho 1 page pack
- `STRIPE_WEBHOOK_SECRET`
- `STRIPE_PAGES_PER_UNIT` — pages per pack (default: 1000)
**Còn thiếu / chưa xác minh:**
- Frontend chưa gọi endpoint này (chặn bởi Story 5.1)
- Chưa có test end-to-end với Stripe test mode
- `STRIPE_PRICE_ID` cần được tạo trên Stripe Dashboard và config đúng
## Acceptance Criteria
1. Khi User bấm thanh toán, BE gọi API Stripe lấy `sessionId` của Stripe Checkout (Chế độ Subscription / Recurring Mode, không phải Chế độ Mua đứt OTP).
2. Hệ thống redirect User an toàn qua cổng Stripe được Hosted trực tiếp bởi Stripe Server.
1. Với Stripe test keys, tạo checkout session thành công, redirect đến Stripe test checkout page.
2. Sau khi hoàn tất thanh toán (dùng test card `4242 4242 4242 4242`), webhook trigger và `pages_limit` của user tăng lên đúng số lượng.
3. Nếu `STRIPE_SECRET_KEY` hoặc `STRIPE_PRICE_ID` không được cấu hình, API trả về lỗi có thể đọc được (không crash 500).
## Tasks / Subtasks
- [ ] Task 1: Nâng cấp `stripe_routes.py`
- [ ] Subtask 1.1: Bổ sung Endpoint POST `/api/v1/stripe/create-subscription-checkout`.
- [ ] Subtask 1.2: Phân tích `plan_id` từ Request body hoặc User, cấu hình `mode='subscription'` trong dict truyền cho thư viện Stripe (thay vì chế độ `payment` cũ).
- [ ] Task 2: Liên kết Action Nút ở UI
- [ ] Subtask 2.1: Ở `Pricing` Component, xử lý `onClick` bằng cách submit POST form request tới API mới, bắt `checkout_url` và route trình duyệt tới URL đó bằng thẻ A hoặc JS `window.location.href`.
- [ ] Task 1: Verify Stripe config
- [ ] Subtask 1.1: Kiểm tra `surfsense_backend/app/config.py` — đảm bảo `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PAGES_PER_UNIT` được đọc từ env vars.
- [ ] Subtask 1.2: Đảm bảo `_ensure_page_buying_enabled()` trả về lỗi 503 rõ ràng thay vì crash khi Stripe chưa config.
- [ ] Task 2: Test end-to-end với Stripe CLI
- [ ] Subtask 2.1: Dùng `stripe listen --forward-to localhost:8000/api/v1/stripe/webhook` để test webhook locally.
- [ ] Subtask 2.2: Verify `PagePurchase.status` chuyển từ `PENDING``COMPLETED` sau webhook.
- [ ] Subtask 2.3: Verify `user.pages_limit` tăng đúng `quantity × STRIPE_PAGES_PER_UNIT`.
- [ ] Task 3: Thêm Stripe setup vào `.env.example`
- [ ] Subtask 3.1: Bổ sung `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PAGES_PER_UNIT` vào `surfsense_backend/.env.example` với comment hướng dẫn.
## Dev Notes
### Relevant Architecture Patterns & Constraints
- Codebase hiện có `surfsense_backend/app/routes/stripe_routes.py` nhưng ĐANG GẮN code Payment Intent cho Token "Page Purchase" One-time 1 lần. DEV cần lưu ý CẤU HÌNH LẠI để route mới sinh này phục vụ riêng cho gói Subscription hàng tháng (Gửi theo CustomerID nếu User đã bind).
- Security: Giá `stripe_price_id` bắt buộc phải map và define ở Back-End environment variable (Vd `STRIPE_PRO_PLAN_ID`), tuyệt đối không chấp nhận param `price` từ Header gửi lên (Phòng tránh giả mạo giá tiền).
### Current Flow (hoạt động)
```
FE → POST /api/v1/stripe/create-checkout-session
→ Stripe Checkout (hosted page)
→ Stripe → POST /api/v1/stripe/webhook (checkout.session.completed)
→ _fulfill_completed_purchase() → user.pages_limit += pages_granted
→ Stripe redirects FE → success_url
```
### Project Structure Notes
- Module thay đổi:
- `surfsense_backend/app/routes/stripe_routes.py`
- `surfsense_web/src/pages/pricing/page.tsx`
### PagePurchase Status Enum
- `PENDING` — checkout tạo nhưng chưa thanh toán
- `COMPLETED` — thanh toán thành công, pages đã được grant
- `FAILED` — thanh toán thất bại
### References
- [Epic 5.2 - Subscriptions]
- `surfsense_backend/app/routes/stripe_routes.py`
- `surfsense_backend/app/db.py` (class `PagePurchase`, lines ~1616+)
- Stripe Test Cards: https://stripe.com/docs/testing
## Dev Agent Record
### Agent Model Used
Antigravity Claude 3.5 Sonnet Engine
_TBD_
### File List
- `surfsense_backend/app/routes/stripe_routes.py`
- `surfsense_backend/app/config.py`
- `surfsense_backend/.env.example`

View file

@ -1,49 +1,85 @@
# Story 5.3: Webhook & Cập nhật Trạng thái Gói cước (Stripe Webhook Sync)
# Story 5.3: Hardening Stripe Webhook & Purchase History
Status: ready-for-dev
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Context / Correction Note
> **⚠️ Story gốc bị sai hướng.** Story gốc mô tả xử lý `customer.subscription.*` events để sync subscription status — đây là mô hình subscription SaaS, không phải mô hình thực tế của SurfSense. Webhook handler **đã tồn tại** và xử lý đúng event `checkout.session.completed` cho PAYG page packs. Không cần `stripe_customer_id` hay `subscription_status` vì SurfSense không dùng subscription.
## Story
As a Kỹ sư Hệ thống,
I want backend tự động hứng Webhook từ Stripe mỗi khi có thanh toán thành công, gia hạn, hoặc hủy gói,
so that database được cập nhật trạng thái Subscription của user (Active/Canceled) mà không cần can thiệp thủ công.
I want webhook handler xử lý đầy đủ các trường hợp edge-case của Stripe payment lifecycle,
so that mọi giao dịch đều được ghi nhận chính xác và pages luôn được cộng đúng.
## Actual Architecture (as-is)
**Đã implement và đúng:**
- `POST /api/v1/stripe/webhook` — verify Stripe-Signature, xử lý events:
- `checkout.session.completed``_fulfill_completed_purchase()` → tăng `pages_limit`
- `checkout.session.async_payment_succeeded` → fulfill
- `checkout.session.async_payment_failed``_mark_purchase_failed()`
- `checkout.session.expired` → mark failed
- Idempotency guard: `_get_or_create_purchase_from_checkout_session()` dùng DB để tránh double-grant
- `GET /api/v1/stripe/purchases` — lịch sử mua hàng của user
- `GET /api/v1/stripe/status` — check Stripe config status
**Không cần (và không nên thêm):**
- `customer.subscription.*` events — SurfSense không dùng subscription
- `stripe_customer_id` field trên User — không cần cho PAYG flow
- `subscription_status` / `plan_id` columns — không liên quan đến PAYG
**Còn thiếu:**
- Webhook chưa handle `payment_intent.payment_failed` (nếu payment thất bại sau khi session tạo)
- Chưa có notification/email khi purchase thành công (nice-to-have)
- Frontend `/purchases` page chưa có UI hiển thị lịch sử mua
## Acceptance Criteria
1. Backend bắt được Event Type qua HTTP POST.
2. Kiểm tra chính xác Webhook-Signature tránh Event giả.
3. Update trạng thái (Status, Expiry date, Plan_id) vào User record tương ứng trên Database Postgres.
1. Khi Stripe gửi `checkout.session.completed`, `PagePurchase.status = COMPLETED``user.pages_limit` tăng đúng.
2. Nếu cùng 1 webhook event gửi 2 lần (Stripe retry), hệ thống chỉ grant pages 1 lần (idempotency).
3. Khi Stripe gửi `checkout.session.expired` hoặc `checkout.session.async_payment_failed`, `PagePurchase.status = FAILED`, `pages_limit` không thay đổi.
4. Endpoint `GET /api/v1/stripe/purchases` trả về danh sách purchase history đúng cho user hiện tại.
## Tasks / Subtasks
- [ ] Task 1: Dựng Webhook Route
- [ ] Subtask 1.1: Tạo Route `/api/v1/stripe/webhook` (đã có route cũ dành cho Page Purchase, xem ở `stripe_routes.py` line 281).
- [ ] Subtask 1.2: Code logic giải mãi Signature.
- [ ] Task 2: Listen Subscription Events
- [ ] Subtask 2.1: Phân tích Webhook Event Type. Lắng nghe ít nhất 2 Event cơ bản: `customer.subscription.updated``customer.subscription.deleted`. Xử lý và fetch customer ID để map với User nội bộ (có thể dùng `stripe_customer_id` lưu trên bảng `users`).
- [ ] Task 3: Database User Updates
- [ ] Subtask 3.1: Viết hàm DB handler gọi tới DB để ghi đè `subscription_status` = 'active', set `plan_id`, và cập nhật `token_balance` hàng tháng khi có trigger chu kỳ mới. Cập nhật `users.py` controller.
- [ ] Task 1: Verify idempotency của webhook handler
- [ ] Subtask 1.1: Đọc `_get_or_create_purchase_from_checkout_session()` — đảm bảo có DB-level lock (SELECT FOR UPDATE hoặc unique constraint) để tránh race condition khi Stripe retry.
- [ ] Subtask 1.2: Viết unit test simulate webhook event gửi 2 lần, assert `pages_limit` chỉ tăng 1 lần.
- [ ] Task 2: Thêm Purchase History UI (Frontend)
- [ ] Subtask 2.1: Tạo page hoặc section trong Dashboard hiển thị danh sách `PagePurchase` từ `GET /api/v1/stripe/purchases`.
- [ ] Subtask 2.2: Hiển thị: ngày mua, số pages, trạng thái (Completed/Failed/Pending), số tiền.
- [ ] Task 3: Xử lý `payment_intent.payment_failed` (defensive)
- [ ] Subtask 3.1: Thêm case xử lý event `payment_intent.payment_failed` trong webhook handler — tìm `PagePurchase` qua `stripe_payment_intent_id` và mark failed.
## Dev Notes
### Relevant Architecture Patterns & Constraints
- **Security Check:** Webhook API endpoint `MUST` parse raw body using `await request.body()`. Nếu FastAPI parse ra Pydantic Object TRƯỚC chữ ký signature thì thư viện Stripe auth sẽ báo lỗi văng Exception.
- **Race Condition in DB:** Do event `checkout.session.completed``customer.subscription.created` có thể call webhook cục bộ gần như đồng thời, phải code check Upsert (Ví dụ: set timestamp check updatedAt để tránh data đè lên nhau).
### Existing Webhook Handler Structure
```python
@router.post("/webhook")
async def stripe_webhook(request, db_session):
# 1. Verify Stripe-Signature
# 2. Parse event
# 3. Route by event type:
event_handlers = {
"checkout.session.completed": _fulfill_completed_purchase,
"checkout.session.async_payment_succeeded": _fulfill_completed_purchase,
"checkout.session.expired": _mark_purchase_failed,
"checkout.session.async_payment_failed": _mark_purchase_failed,
}
```
### Project Structure Notes
- Module thay đổi:
- `surfsense_backend/app/routes/stripe_routes.py`
- `surfsense_backend/app/db.py`
### Idempotency Pattern (đã có)
`_get_or_create_purchase_from_checkout_session()` query theo `stripe_checkout_session_id` — nếu đã COMPLETED thì skip, tránh double-grant.
### References
- [Epic 5.3 - Webhook Sync]
- `surfsense_backend/app/routes/stripe_routes.py` (lines ~86345)
- `surfsense_backend/app/db.py` (class `PagePurchase`, `PagePurchaseStatus`)
## Dev Agent Record
### Agent Model Used
Antigravity Claude 3.5 Sonnet Engine
_TBD_
### File List
- `surfsense_backend/app/routes/stripe_routes.py`
- Frontend purchase history component (new)

View file

@ -1,48 +1,93 @@
# Story 5.4: Hệ thống Khóa Tác vụ dựa trên Hạn Mức (Usage Tracking & Rate Limit Enforcement)
# Story 5.4: Enforce Page Quota tại HTTP API Layer & Frontend Feedback
Status: ready-for-dev
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Context / Correction Note
> **⚠️ Story gốc bị sai một phần.** Story gốc mô tả implement quota từ đầu (tạo `check_upload_quota()`, v.v.). Thực tế, hệ thống quota **đã tồn tại đầy đủ** với `PageLimitService`, enforcement trong Celery tasks và tất cả connector indexers. Task thực tế chỉ là: (1) thêm check tại HTTP API layer trước khi enqueue task, (2) feedback rõ ràng ở Frontend, (3) hiển thị quota usage trong UI.
## Story
As a Kỹ sư Hệ thống,
I want những người dùng hết quota (vượt quá file upload hoặc số lượng tin nhắn) bị từ chối dịch vụ cho đến khi nâng cấp,
so that mô hình kinh doanh không bị lỗ do chi phí LLM và Storage, áp dụng theo FR13.
As a Người dùng,
I want nhận phản hồi ngay lập tức khi tôi hết page quota thay vì đợi task xử lý xong mới biết,
so that tôi có thể mua thêm pages trước khi tiếp tục upload.
## Actual Architecture (as-is)
**Đã implement và đang hoạt động:**
- `surfsense_backend/app/services/page_limit_service.py`:
- `PageLimitService.check_page_limit(user_id, estimated_pages)` — raises `PageLimitExceededError`
- `PageLimitService.update_page_usage(user_id, pages_to_add)`
- `PageLimitService.get_page_usage(user_id)``(pages_used, pages_limit)`
- `PageLimitService.estimate_pages_before_processing(file_path)` — ước tính từ file size/type
- `PageLimitService.estimate_pages_from_metadata(filename, file_size)` — pure function, không cần file
- Enforcement **đã có** trong:
- `surfsense_backend/app/tasks/celery_tasks/document_tasks.py` — check trước khi xử lý
- `surfsense_backend/app/tasks/connector_indexers/google_drive_indexer.py`
- `surfsense_backend/app/tasks/connector_indexers/onedrive_indexer.py`
- `surfsense_backend/app/tasks/connector_indexers/dropbox_indexer.py`
- (và các connectors khác)
**Còn thiếu:**
- Check tại HTTP API layer (document upload route) — hiện tại task mới fail sau khi đã enqueue
- Frontend hiển thị `pages_used` / `pages_limit` (quota indicator)
- Frontend bắt lỗi 402 từ upload và hiển thị upgrade prompt
## Acceptance Criteria
1. Endpoint Upload (Document Parser) và Endpoint Chat (RAG AI) sẽ query Database để check `Subscription_Status``document_count`, `token_balance`.
2. Nếu Document count >= Limit hệ thống từ chối Upload và trả lỗi `402 Payment Required` hoặc `403`.
3. UI hiển thị Toast Error hoặc một Modal up-sell Upgrade to Pro.
1. Khi user upload document và estimated pages sẽ vượt `pages_limit`, API trả về 402 **ngay lập tức** (không enqueue Celery task).
2. Frontend upload component bắt HTTP 402 và hiển thị toast: "Bạn đã hết page quota (X/Y pages). Mua thêm tại /pricing".
3. Dashboard hiển thị quota indicator: "X / Y pages used" với progress bar.
4. API `GET /api/v1/users/me` (hoặc equivalent) trả về `pages_used``pages_limit`.
## Tasks / Subtasks
- [ ] Task 1: Check Limits ở Route `/api/v1/documents`
- [ ] Subtask 1.1: Tạo function Utils `check_upload_quota()` query lấy Package Plan của JWT User. Đếm số object hiện có trong DB (Count records trong table `documents` nơi `user_id = X`).
- [ ] Subtask 1.2: Nếu đạt MAX_LIMIT (ví dụ 10 đối với FREE), Raise HTTPException 402/403.
- [ ] Task 2: Refine Frontend Validation
- [ ] Subtask 2.1: Ở component `DocumentUploader`, handle catch Error response `402`. Bật Toast thông báo "Bạn đã hết File Limit miễn phí".
- [ ] Task 1: Thêm quota pre-check vào Document Upload HTTP route
- [ ] Subtask 1.1: Tìm document upload route (search `surfsense_backend/app/routes/` cho endpoint nhận file upload).
- [ ] Subtask 1.2: Inject `PageLimitService`, gọi `estimate_pages_from_metadata(filename, file_size)` để ước tính.
- [ ] Subtask 1.3: Gọi `check_page_limit(user_id, estimated_pages)` — nếu raises `PageLimitExceededError`, return `HTTPException(402, "Page quota exceeded. Purchase more pages at /pricing.")`.
- [ ] Task 2: Expose quota info trên user endpoint
- [ ] Subtask 2.1: Đảm bảo `GET /api/v1/users/me` response bao gồm `pages_used``pages_limit` (kiểm tra `UserRead` schema trong `db.py`).
- [ ] Task 3: Frontend quota indicator
- [ ] Subtask 3.1: Đọc `pages_used` / `pages_limit` từ current user data (đã có trong Zero/DB sync hoặc API).
- [ ] Subtask 3.2: Hiển thị progress bar nhỏ trong Dashboard sidebar hoặc header: "X / Y pages".
- [ ] Subtask 3.3: Khi `pages_used / pages_limit > 0.9`, đổi màu indicator sang warning (amber).
- [ ] Task 4: Frontend upload error handling
- [ ] Subtask 4.1: Trong document upload component, bắt HTTP 402 response.
- [ ] Subtask 4.2: Hiển thị toast/alert với link đến `/pricing`.
## Dev Notes
### Relevant Architecture Patterns & Constraints
- Yêu cầu NFR Architecture: KHÔNG HARDCODE số file LIMIT vào trong code API mà phải load Enum hoặc Database row. Nên dùng một `config module` tĩnh hoặc Map Object dựa trên `plan_id` enum để check.
- Nếu User Plan là `PRO`, `MAX_LIMIT` là 100.
- Khúc check Chat Token Balance đã được cover một phần ở Story 3.5, nên Story 5.4 tập trung vào `File Upload Rate Limit``Chat Frequency Rate Limit` (tránh crawler/spam theo giờ).
### Không cần viết lại PageLimitService
`PageLimitService` đã có đầy đủ logic. Chỉ cần inject và gọi tại HTTP layer.
### Project Structure Notes
- Module thay đổi:
- `surfsense_backend/app/routes/documents_routes.py` (hoặc endpoints xử lý Upload mới nhất).
- `surfsense_web/src/components/UploadDocument.tsx`
### Pattern dùng trong Celery tasks (làm mẫu)
```python
# Trong document_tasks.py
page_limit_service = PageLimitService(db_session)
await page_limit_service.check_page_limit(user_id, estimated_pages)
# → raises PageLimitExceededError nếu vượt quota
```
### UserRead schema — kiểm tra xem đã expose pages_used chưa
```python
# Tìm trong db.py hoặc schemas/
class UserRead(schemas.BaseUser[uuid.UUID]):
pages_limit: int
pages_used: int # ← check xem field này có trong schema không
```
### References
- [Epic 5.4 - Quota Check]
- `surfsense_backend/app/services/page_limit_service.py`
- `surfsense_backend/app/tasks/celery_tasks/document_tasks.py` (xem cách dùng làm mẫu)
- `surfsense_backend/app/db.py` (UserRead schema / User model)
- `surfsense_web/` (document upload component — cần tìm)
## Dev Agent Record
### Agent Model Used
Antigravity Claude 3.5 Sonnet Engine
_TBD_
### File List
- `surfsense_backend/app/routes/documents_routes.py`
- `surfsense_backend/app/routes/` (document upload route)
- `surfsense_backend/app/services/page_limit_service.py` (đọc, không sửa)
- Frontend upload + dashboard components