SurfSense/_bmad-output/implementation-artifacts/5-3-stripe-webhook-sync.md
Vonic 04fb9eec0f 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>
2026-04-14 14:08:55 +07:00

4.4 KiB
Raw Blame History

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 = COMPLETEDuser.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

@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 ~86345)
  • 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)