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,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)