mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 01:06:23 +02:00
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>
85 lines
4.4 KiB
Markdown
85 lines
4.4 KiB
Markdown
# 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 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 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. Khi Stripe gửi `checkout.session.completed`, `PagePurchase.status = COMPLETED` và `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: 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
|
||
|
||
### 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,
|
||
}
|
||
```
|
||
|
||
### 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` (class `PagePurchase`, `PagePurchaseStatus`)
|
||
|
||
## Dev Agent Record
|
||
|
||
### Agent Model Used
|
||
_TBD_
|
||
|
||
### File List
|
||
- `surfsense_backend/app/routes/stripe_routes.py`
|
||
- Frontend purchase history component (new)
|