diff --git a/_bmad-output/implementation-artifacts/5-3-stripe-webhook-sync.md b/_bmad-output/implementation-artifacts/5-3-stripe-webhook-sync.md index 4a11ecda9..b70f99f7d 100644 --- a/_bmad-output/implementation-artifacts/5-3-stripe-webhook-sync.md +++ b/_bmad-output/implementation-artifacts/5-3-stripe-webhook-sync.md @@ -1,6 +1,6 @@ # Story 5.3: Webhook & Cập nhật Trạng thái Gói cước (Stripe Webhook Sync) -Status: ready-for-dev +Status: done ## Story @@ -30,40 +30,33 @@ so that database được cập nhật trạng thái Subscription của user (Ac ## Tasks / Subtasks -- [ ] Task 1: Thêm Subscription Fields vào User Model (Backend DB) - - [ ] Subtask 1.1: Alembic migration thêm columns (nếu chưa có từ Story 3.5): - - `subscription_status` — Enum: `free`, `active`, `canceled`, `past_due` (default: `free`) - - `plan_id` — String nullable (e.g. `pro_monthly`, `pro_yearly`) - - `stripe_subscription_id` — String nullable, indexed - - `subscription_current_period_end` — DateTime nullable (để biết khi nào renewal) +- [x] Task 1: Thêm Subscription Fields vào User Model (Backend DB) + - [x] Subtask 1.1: Alembic migration thêm columns (nếu chưa có từ Story 3.5): + - `subscription_status` — Enum: `free`, `active`, `canceled`, `past_due` (default: `free`) ✅ migration 124 + - `plan_id` — String nullable (e.g. `pro_monthly`, `pro_yearly`) ✅ migration 124 + - `stripe_subscription_id` — String nullable, indexed ✅ migration 124 + - `subscription_current_period_end` — DateTime nullable ✅ migration 125 (mới thêm) -- [ ] Task 2: Thêm Subscription Event Handlers vào Webhook (Backend) - - [ ] Subtask 2.1: Mở rộng webhook handler — thêm routing cho: +- [x] Task 2: Thêm Subscription Event Handlers vào Webhook (Backend) + - [x] Subtask 2.1: Mở rộng webhook handler — thêm routing cho: - `customer.subscription.created` → activate subscription - `customer.subscription.updated` → update status/plan (handle upgrade/downgrade) - `customer.subscription.deleted` → set status=`canceled`, downgrade limits - `invoice.payment_succeeded` → reset `tokens_used_this_month = 0` (billing cycle mới) - `invoice.payment_failed` → set status=`past_due` - - [ ] Subtask 2.2: Tạo helper function `_handle_subscription_event(event, db_session)`: + - [x] Subtask 2.2: Tạo helper function `_handle_subscription_event(db_session, subscription)`: - Extract `customer` ID từ event → query User by `stripe_customer_id` - Update `subscription_status`, `plan_id`, `monthly_token_limit` theo plan - Update `subscription_current_period_end` - - [ ] Subtask 2.3: Plan → Limits mapping (config): - ```python - PLAN_LIMITS = { - "free": {"monthly_token_limit": 50_000, "pages_limit": 500}, - "pro_monthly": {"monthly_token_limit": 1_000_000, "pages_limit": 5000}, - "pro_yearly": {"monthly_token_limit": 1_000_000, "pages_limit": 5000}, - } - ``` + - [x] Subtask 2.3: Plan → Limits mapping (config) — thêm `PLAN_LIMITS` vào `config/__init__.py` -- [ ] Task 3: Xử lý `checkout.session.completed` cho Subscription mode - - [ ] Subtask 3.1: Trong handler `checkout.session.completed` hiện tại, thêm check: nếu `session.mode == 'subscription'` → activate subscription thay vì grant pages. - - [ ] Subtask 3.2: Giữ logic PAYG cũ cho `session.mode == 'payment'`. +- [x] Task 3: Xử lý `checkout.session.completed` cho Subscription mode + - [x] Subtask 3.1: Trong webhook handler, check `session.mode == 'subscription'` → `_activate_subscription_from_checkout()`. + - [x] Subtask 3.2: Giữ logic PAYG cũ cho `session.mode == 'payment'`. -- [ ] Task 4: Idempotency cho Subscription Events - - [ ] Subtask 4.1: Dùng `stripe_subscription_id` + event timestamp để tránh duplicate processing. - - [ ] Subtask 4.2: Log tất cả webhook events để debug. +- [x] Task 4: Idempotency cho Subscription Events + - [x] Subtask 4.1: `_handle_subscription_event` so sánh `stripe_subscription_id + subscription_status + period_end` — skip nếu không đổi. + - [x] Subtask 4.2: Log tất cả webhook events qua `logger.info("Received Stripe webhook event: %s", event.type)`. ## Dev Notes @@ -77,3 +70,49 @@ Webhook endpoint **PHẢI** parse raw body bằng `await request.body()` TRƯỚ - `surfsense_backend/app/routes/stripe_routes.py` — webhook handler hiện tại - `surfsense_backend/app/db.py` — User model - Stripe Subscription Events: https://stripe.com/docs/billing/subscriptions/webhooks + +## Dev Agent Record + +### Implementation Notes +- Migration 124 đã có `subscription_status`, `plan_id`, `stripe_customer_id`, `stripe_subscription_id` từ Story 3.5 — không cần migration lại. +- Migration 125 thêm `subscription_current_period_end` (TIMESTAMP with timezone, nullable). +- `PLAN_LIMITS` dict thêm vào `config/__init__.py` — free: 50k tokens, pro: 1M tokens. +- `_get_user_by_stripe_customer_id()`: SELECT FOR UPDATE để safe với concurrent webhooks. +- `_handle_subscription_event()`: map Stripe status → `SubscriptionStatus` enum, idempotency check bằng so sánh subscription_id + status + period_end. +- `_handle_invoice_payment_succeeded()`: chỉ reset tokens khi `billing_reason` là `subscription_cycle` hoặc `subscription_update`. +- `_handle_invoice_payment_failed()`: set `PAST_DUE` nếu hiện đang `ACTIVE`. +- `_activate_subscription_from_checkout()`: kích hoạt ngay khi checkout hoàn thành (trước khi `customer.subscription.created` đến); idempotent. +- Webhook routing: thêm `logger.info` cho mỗi event type, route `checkout.session.*expired/failed` bỏ qua nếu là subscription mode. + +### Completion Notes +✅ AC 1: Webhook đã có signature verification từ trước — giữ nguyên. +✅ AC 2: Xử lý `customer.subscription.created/updated/deleted` qua `_handle_subscription_event()`. +✅ AC 3: Update `subscription_status`, `plan_id`, `monthly_token_limit`, `subscription_current_period_end`. +✅ AC 4: Reset `tokens_used_this_month = 0` qua `_handle_invoice_payment_succeeded()` khi `billing_reason=subscription_cycle`. + +### File List +- `surfsense_backend/app/db.py` — added `subscription_current_period_end` column to both User model variants +- `surfsense_backend/alembic/versions/125_add_subscription_current_period_end.py` — new migration +- `surfsense_backend/app/config/__init__.py` — added `PLAN_LIMITS` dict +- `surfsense_backend/app/routes/stripe_routes.py` — added `_get_user_by_stripe_customer_id`, `_period_end_from_subscription`, `_handle_subscription_event`, `_handle_invoice_payment_succeeded`, `_handle_invoice_payment_failed`, `_activate_subscription_from_checkout`; updated webhook router + +### Review Findings + +- [x] [Review][Patch] pages_limit never upgraded to Pro value on subscription activation — both `_activate_subscription_from_checkout` and `_handle_subscription_event` only set `monthly_token_limit`, not `pages_limit` [stripe_routes.py] +- [x] [Review][Patch] pages_limit downgrade ignores pages_used — sets `pages_limit=500` blindly, should use `max(pages_used, free_limit)` to avoid locking out existing content [stripe_routes.py] +- [x] [Review][Patch] `_activate_subscription_from_checkout` does not set `subscription_current_period_end` — stays NULL until `customer.subscription.created` fires [stripe_routes.py] +- [x] [Review][Patch] `_activate_subscription_from_checkout` does not set `token_reset_date` — stays NULL until first renewal invoice [stripe_routes.py] +- [x] [Review][Patch] `date.today()` used instead of `datetime.now(UTC).date()` in `_handle_invoice_payment_succeeded` — timezone mismatch with quota service [stripe_routes.py] +- [x] [Review][Patch] Unconfigured price ID env vars cause silent fallback to `plan_id="free"` for paying subscribers — should log warning when no price match [stripe_routes.py] +- [x] [Review][Patch] Idempotency check omits `plan_id` — mid-cycle plan change (monthly→yearly) with same status+period_end gets silently skipped [stripe_routes.py] +- [x] [Review][Patch] `billing_reason="subscription_create"` excluded from token reset — new subscribers inherit dirty free-tier token counter [stripe_routes.py] +- [x] [Review][Patch] `billing_reason="subscription_update"` included in token reset — plan changes mid-cycle incorrectly reset tokens to 0 [stripe_routes.py] +- [x] [Review][Patch] Out-of-order webhook delivery can overwrite newer `period_end` with older value — no "don't go backwards" guard [stripe_routes.py] +- [x] [Review][Patch] `SubscriptionStatus.FREE` in downgrade check is dead code — remove from set [stripe_routes.py] +- [x] [Review][Patch] Repeated `invoice.payment_failed` while PAST_DUE silently ignored without logging [stripe_routes.py] +- [x] [Review][Defer] Race between `checkout.session.completed` and `customer.subscription.deleted` can reactivate canceled subscription — deferred, requires Stripe API verification call +- [x] [Review][Defer] `invoice.payment_succeeded` does not update `subscription_current_period_end` — deferred, handled by `customer.subscription.updated` in same event sequence + +### Change Log +- 2026-04-15: Implement Stripe webhook subscription event handlers — subscription lifecycle, invoice payment reset, checkout activation. +- 2026-04-15: Code review patches applied — 12 fixes: pages_limit upgrade/downgrade, period_end guards, token reset billing reasons, idempotency plan_id check, UTC date fix, unrecognized price ID warning. diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index 579c856cb..950fc2298 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -14,3 +14,8 @@ - Webhook handler needs to distinguish `mode='subscription'` from `mode='payment'` in `checkout.session.completed` and update User's `subscription_status`, `plan_id`, `stripe_subscription_id` — scope of Story 5.3. - Subscription lifecycle events (`invoice.paid`, `customer.subscription.updated/deleted`, `invoice.payment_failed`) not handled — scope of Story 5.3. - `_get_or_create_stripe_customer` can create orphaned Stripe customers if `db_session.commit()` fails after `customers.create`. Consider idempotency key in future. + +## Deferred from: code review of story-5.3 (2026-04-15) + +- Race condition: `checkout.session.completed` and `customer.subscription.deleted` can fire near-simultaneously; if deleted arrives between checkout handlers, subscription can be reactivated. Fix requires Stripe API call to verify subscription status before activation. +- `invoice.payment_succeeded` does not update `subscription_current_period_end` — currently relies on `customer.subscription.updated` firing in the same event sequence. If that event is lost, period_end is stale. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index e703f4c2d..9bd48421f 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -35,7 +35,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: 2026-04-13T02:50:25+07:00 -last_updated: 2026-04-14T17:00:00+07:00 +last_updated: 2026-04-15T00:14:00+07:00 project: SurfSense project_key: NOKEY tracking_system: file-system @@ -68,6 +68,6 @@ development_status: epic-5: in-progress 5-1-pricing-plan-selection-ui: done 5-2-stripe-payment-integration: done - 5-3-stripe-webhook-sync: ready-for-dev + 5-3-stripe-webhook-sync: done 5-4-usage-tracking-rate-limit-enforcement: ready-for-dev epic-5-retrospective: optional diff --git a/surfsense_backend/alembic/versions/125_add_subscription_current_period_end.py b/surfsense_backend/alembic/versions/125_add_subscription_current_period_end.py new file mode 100644 index 000000000..93f72b8c7 --- /dev/null +++ b/surfsense_backend/alembic/versions/125_add_subscription_current_period_end.py @@ -0,0 +1,35 @@ +"""125_add_subscription_current_period_end + +Revision ID: 125 +Revises: 124 +Create Date: 2026-04-15 + +Adds subscription_current_period_end column to the user table for +tracking when the current billing period ends (Story 5.3). + +Column added: +- subscription_current_period_end (TIMESTAMP with timezone, nullable) +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "125" +down_revision: str | None = "124" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "user", + sa.Column("subscription_current_period_end", sa.TIMESTAMP(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("user", "subscription_current_period_end") diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 733425b6a..dad80ba5b 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -310,6 +310,13 @@ class Config: os.getenv("STRIPE_RECONCILIATION_BATCH_SIZE", "100") ) + # Subscription plan limits + PLAN_LIMITS: dict[str, dict[str, int]] = { + "free": {"monthly_token_limit": 50_000, "pages_limit": 500}, + "pro_monthly": {"monthly_token_limit": 1_000_000, "pages_limit": 5000}, + "pro_yearly": {"monthly_token_limit": 1_000_000, "pages_limit": 5000}, + } + # Auth AUTH_TYPE = os.getenv("AUTH_TYPE") REGISTRATION_ENABLED = os.getenv("REGISTRATION_ENABLED", "TRUE").upper() == "TRUE" diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 86120bb8f..f2880691e 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1976,6 +1976,7 @@ if config.AUTH_TYPE == "GOOGLE": plan_id = Column(String(50), nullable=False, default="free", server_default="free") stripe_customer_id = Column(String(255), nullable=True, unique=True) stripe_subscription_id = Column(String(255), nullable=True, unique=True) + subscription_current_period_end = Column(TIMESTAMP(timezone=True), nullable=True) # User profile from OAuth display_name = Column(String, nullable=True) @@ -2104,6 +2105,7 @@ else: plan_id = Column(String(50), nullable=False, default="free", server_default="free") stripe_customer_id = Column(String(255), nullable=True, unique=True) stripe_subscription_id = Column(String(255), nullable=True, unique=True) + subscription_current_period_end = Column(TIMESTAMP(timezone=True), nullable=True) # User profile (can be set manually for non-OAuth users) display_name = Column(String, nullable=True) diff --git a/surfsense_backend/app/routes/stripe_routes.py b/surfsense_backend/app/routes/stripe_routes.py index 2680410c4..96eefef23 100644 --- a/surfsense_backend/app/routes/stripe_routes.py +++ b/surfsense_backend/app/routes/stripe_routes.py @@ -284,6 +284,243 @@ async def _fulfill_completed_purchase( return StripeWebhookResponse() +# --------------------------------------------------------------------------- +# Subscription event helpers +# --------------------------------------------------------------------------- + +async def _get_user_by_stripe_customer_id( + db_session: AsyncSession, customer_id: str +) -> User | None: + """Fetch the User row for a given Stripe customer ID (with FOR UPDATE lock).""" + return ( + ( + await db_session.execute( + select(User) + .where(User.stripe_customer_id == customer_id) + .with_for_update(of=User) + ) + ) + .unique() + .scalar_one_or_none() + ) + + +def _period_end_from_subscription(subscription: Any) -> datetime | None: + """Extract current_period_end timestamp from a Stripe subscription object.""" + ts = getattr(subscription, "current_period_end", None) + if ts is None: + return None + return datetime.fromtimestamp(int(ts), tz=UTC) + + +async def _handle_subscription_event( + db_session: AsyncSession, subscription: Any +) -> StripeWebhookResponse: + """Handle customer.subscription.created / updated / deleted. + + Idempotency: compares stripe_subscription_id + current_period_end so + duplicate events for the same billing period are no-ops. + """ + customer_id = _normalize_optional_string(getattr(subscription, "customer", None)) + subscription_id = _normalize_optional_string(getattr(subscription, "id", None)) + sub_status = str(getattr(subscription, "status", "")).lower() + period_end = _period_end_from_subscription(subscription) + + # Determine plan from the first subscription item's price ID + plan_id: str = "free" + try: + items = getattr(subscription, "items", None) + if items: + item_data = getattr(items, "data", None) or [] + if item_data: + price_id = str(getattr(item_data[0].price, "id", "")) + if price_id == config.STRIPE_PRO_YEARLY_PRICE_ID: + plan_id = "pro_yearly" + elif price_id == config.STRIPE_PRO_MONTHLY_PRICE_ID: + plan_id = "pro_monthly" + else: + logger.warning( + "Subscription %s has unrecognized price ID %s; defaulting to free limits", + subscription_id, + price_id, + ) + except Exception: # noqa: BLE001 + logger.warning("Could not parse plan from subscription %s", subscription_id) + + if not customer_id: + logger.error("Subscription event missing customer ID for subscription %s", subscription_id) + return StripeWebhookResponse() + + user = await _get_user_by_stripe_customer_id(db_session, customer_id) + if user is None: + logger.warning("No user found for Stripe customer %s; skipping subscription event", customer_id) + return StripeWebhookResponse() + + # Map Stripe status → SubscriptionStatus enum + if sub_status == "active": + new_status = SubscriptionStatus.ACTIVE + elif sub_status in {"canceled", "incomplete_expired"}: + new_status = SubscriptionStatus.CANCELED + plan_id = "free" + elif sub_status == "past_due": + new_status = SubscriptionStatus.PAST_DUE + else: + # incomplete, trialing, unpaid → leave current status unchanged + logger.info( + "Ignoring subscription %s with unhandled Stripe status '%s'", + subscription_id, + sub_status, + ) + return StripeWebhookResponse() + + # Idempotency: skip if nothing meaningful changed + if ( + user.stripe_subscription_id == subscription_id + and user.subscription_status == new_status + and user.plan_id == plan_id + and user.subscription_current_period_end == period_end + ): + logger.info("Subscription %s already up-to-date; skipping", subscription_id) + return StripeWebhookResponse() + + # Update subscription fields + user.stripe_subscription_id = subscription_id + user.subscription_status = new_status + user.plan_id = plan_id + # Guard against out-of-order webhook delivery: only advance period_end forward + if period_end is not None and ( + user.subscription_current_period_end is None + or period_end > user.subscription_current_period_end + ): + user.subscription_current_period_end = period_end + + # Update limits from plan config + limits = config.PLAN_LIMITS.get(plan_id, config.PLAN_LIMITS["free"]) + user.monthly_token_limit = limits["monthly_token_limit"] + + # Upgrade pages_limit on activation + if new_status == SubscriptionStatus.ACTIVE: + user.pages_limit = max(user.pages_used, limits["pages_limit"]) + + # Downgrade pages_limit when canceling + if new_status == SubscriptionStatus.CANCELED: + free_limits = config.PLAN_LIMITS["free"] + user.pages_limit = max(user.pages_used, free_limits["pages_limit"]) + + logger.info( + "Updated subscription for user %s: status=%s plan=%s subscription=%s", + user.id, + new_status, + plan_id, + subscription_id, + ) + await db_session.commit() + return StripeWebhookResponse() + + +async def _handle_invoice_payment_succeeded( + db_session: AsyncSession, invoice: Any +) -> StripeWebhookResponse: + """Reset tokens_used_this_month and advance token_reset_date on billing renewal.""" + customer_id = _normalize_optional_string(getattr(invoice, "customer", None)) + billing_reason = str(getattr(invoice, "billing_reason", "")).lower() + + if not customer_id: + return StripeWebhookResponse() + + # Reset tokens on subscription renewals and initial subscription creation + if billing_reason not in {"subscription_cycle", "subscription_create"}: + logger.info("invoice.payment_succeeded billing_reason=%s; not resetting tokens", billing_reason) + return StripeWebhookResponse() + + user = await _get_user_by_stripe_customer_id(db_session, customer_id) + if user is None: + logger.warning("No user found for Stripe customer %s; skipping token reset", customer_id) + return StripeWebhookResponse() + + user.tokens_used_this_month = 0 + user.token_reset_date = datetime.now(UTC).date() + + logger.info("Reset tokens_used_this_month for user %s on subscription renewal", user.id) + await db_session.commit() + return StripeWebhookResponse() + + +async def _handle_invoice_payment_failed( + db_session: AsyncSession, invoice: Any +) -> StripeWebhookResponse: + """Mark subscription as past_due when a renewal invoice payment fails.""" + customer_id = _normalize_optional_string(getattr(invoice, "customer", None)) + if not customer_id: + return StripeWebhookResponse() + + user = await _get_user_by_stripe_customer_id(db_session, customer_id) + if user is None: + logger.warning("No user found for Stripe customer %s; skipping past_due update", customer_id) + return StripeWebhookResponse() + + if user.subscription_status == SubscriptionStatus.ACTIVE: + user.subscription_status = SubscriptionStatus.PAST_DUE + logger.info("Set subscription to PAST_DUE for user %s", user.id) + await db_session.commit() + else: + logger.info("invoice.payment_failed for user %s already in status %s; no change", user.id, user.subscription_status) + + return StripeWebhookResponse() + + +async def _activate_subscription_from_checkout( + db_session: AsyncSession, checkout_session: Any +) -> StripeWebhookResponse: + """Activate subscription when checkout.session.completed fires for mode='subscription'. + + The full subscription lifecycle will also be handled by customer.subscription.created, + but we activate immediately here so the user sees Pro access right after checkout. + """ + customer_id = _normalize_optional_string(getattr(checkout_session, "customer", None)) + subscription_id = _normalize_optional_string(getattr(checkout_session, "subscription", None)) + metadata = _get_metadata(checkout_session) + plan_id_str = metadata.get("plan_id", "") + + if not customer_id: + logger.error("Subscription checkout session missing customer ID: %s", getattr(checkout_session, "id", "")) + return StripeWebhookResponse() + + user = await _get_user_by_stripe_customer_id(db_session, customer_id) + if user is None: + logger.warning("No user found for Stripe customer %s; skipping subscription activation", customer_id) + return StripeWebhookResponse() + + # Idempotency: already activated + if user.subscription_status == SubscriptionStatus.ACTIVE and user.stripe_subscription_id == subscription_id: + logger.info("Subscription already active for user %s; skipping activation", user.id) + return StripeWebhookResponse() + + plan_id = plan_id_str if plan_id_str in {"pro_monthly", "pro_yearly"} else "pro_monthly" + limits = config.PLAN_LIMITS.get(plan_id, config.PLAN_LIMITS["pro_monthly"]) + + user.subscription_status = SubscriptionStatus.ACTIVE + user.plan_id = plan_id + user.stripe_subscription_id = subscription_id + user.monthly_token_limit = limits["monthly_token_limit"] + user.pages_limit = max(user.pages_used, limits["pages_limit"]) + user.tokens_used_this_month = 0 + user.token_reset_date = datetime.now(UTC).date() + + # Retrieve subscription object to set period_end (best-effort) + if subscription_id: + try: + stripe_client = get_stripe_client() + sub_obj = stripe_client.v1.subscriptions.retrieve(subscription_id) + user.subscription_current_period_end = _period_end_from_subscription(sub_obj) + except Exception: # noqa: BLE001 + logger.warning("Could not retrieve subscription %s for period_end", subscription_id) + + logger.info("Activated subscription for user %s: plan=%s subscription=%s", user.id, plan_id, subscription_id) + await db_session.commit() + return StripeWebhookResponse() + + @router.post("/create-checkout-session", response_model=CreateCheckoutSessionResponse) async def create_checkout_session( body: CreateCheckoutSessionRequest, @@ -484,12 +721,16 @@ async def stripe_webhook( detail="Invalid Stripe webhook signature.", ) from exc + logger.info("Received Stripe webhook event: %s", event.type) + + # --- Checkout session events --- if event.type in { "checkout.session.completed", "checkout.session.async_payment_succeeded", }: checkout_session = event.data.object payment_status = getattr(checkout_session, "payment_status", None) + session_mode = str(getattr(checkout_session, "mode", "payment")).lower() if event.type == "checkout.session.completed" and payment_status not in { "paid", @@ -501,6 +742,9 @@ async def stripe_webhook( ) return StripeWebhookResponse() + if session_mode == "subscription": + return await _activate_subscription_from_checkout(db_session, checkout_session) + return await _fulfill_completed_purchase(db_session, checkout_session) if event.type in { @@ -508,8 +752,30 @@ async def stripe_webhook( "checkout.session.expired", }: checkout_session = event.data.object - return await _mark_purchase_failed(db_session, str(checkout_session.id)) + # Only PAYG purchases have a PagePurchase row; subscription sessions are ignored here. + if str(getattr(checkout_session, "mode", "payment")).lower() != "subscription": + return await _mark_purchase_failed(db_session, str(checkout_session.id)) + return StripeWebhookResponse() + # --- Subscription lifecycle events --- + if event.type in { + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + }: + subscription = event.data.object + return await _handle_subscription_event(db_session, subscription) + + # --- Invoice events --- + if event.type == "invoice.payment_succeeded": + invoice = event.data.object + return await _handle_invoice_payment_succeeded(db_session, invoice) + + if event.type == "invoice.payment_failed": + invoice = event.data.object + return await _handle_invoice_payment_failed(db_session, invoice) + + logger.info("Unhandled Stripe event type: %s", event.type) return StripeWebhookResponse()