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>
4.4 KiB
Story 5.3: Hardening Stripe Webhook & Purchase History
Status: ready-for-dev
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 eventcheckout.session.completedcho PAYG page packs. Không cầnstripe_customer_idhaysubscription_statusvì SurfSense không dùng subscription.
Story
As a Kỹ sư Hệ thố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ăngpages_limitcheckout.session.async_payment_succeeded→ fulfillcheckout.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 userGET /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 subscriptionstripe_customer_idfield trên User — không cần cho PAYG flowsubscription_status/plan_idcolumns — 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
/purchasespage chưa có UI hiển thị lịch sử mua
Acceptance Criteria
- Khi Stripe gửi
checkout.session.completed,PagePurchase.status = COMPLETEDvàuser.pages_limittăng đúng. - Nếu cùng 1 webhook event gửi 2 lần (Stripe retry), hệ thống chỉ grant pages 1 lần (idempotency).
- Khi Stripe gửi
checkout.session.expiredhoặccheckout.session.async_payment_failed,PagePurchase.status = FAILED,pages_limitkhông thay đổi. - Endpoint
GET /api/v1/stripe/purchasestrả về danh sách purchase history đúng cho user hiện tại.
Tasks / Subtasks
- 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_limitchỉ tăng 1 lần.
- Subtask 1.1: Đọc
- 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
PagePurchasetừ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.
- Subtask 2.1: Tạo page hoặc section trong Dashboard hiển thị danh sách
- Task 3: Xử lý
payment_intent.payment_failed(defensive)- Subtask 3.1: Thêm case xử lý event
payment_intent.payment_failedtrong webhook handler — tìmPagePurchasequastripe_payment_intent_idvà mark failed.
- Subtask 3.1: Thêm case xử lý event
Dev Notes
Existing Webhook Handler Structure
@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,
}
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
surfsense_backend/app/routes/stripe_routes.py(lines ~86–345)surfsense_backend/app/db.py(classPagePurchase,PagePurchaseStatus)
Dev Agent Record
Agent Model Used
TBD
File List
surfsense_backend/app/routes/stripe_routes.py- Frontend purchase history component (new)