fix(billing): unify quota counters, fix Stripe webhooks, tier/plan sync
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m16s

This commit is contained in:
2026-06-14 17:39:34 +02:00
parent fa637abff0
commit 45e44dd7b2
9 changed files with 546 additions and 256 deletions

View File

@@ -4,7 +4,7 @@ Stripe payment integration for subscriptions and credits
import os
import logging
from typing import Optional, Dict, Any
from datetime import datetime
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
@@ -250,19 +250,23 @@ async def handle_webhook(payload: bytes, sig_header: str) -> Dict[str, Any]:
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
await handle_checkout_completed(session)
elif event["type"] == "customer.subscription.updated":
subscription = event["data"]["object"]
await handle_subscription_updated(subscription)
elif event["type"] == "customer.subscription.deleted":
subscription = event["data"]["object"]
await handle_subscription_deleted(subscription)
elif event["type"] == "invoice.payment_failed":
invoice = event["data"]["object"]
await handle_payment_failed(invoice)
elif event["type"] == "invoice.paid":
invoice = event["data"]["object"]
await handle_invoice_paid(invoice)
return {"status": "success"}
@@ -276,7 +280,9 @@ async def handle_checkout_completed(session: Dict):
session_id = session.get("id")
# Check for duplicate session processing using PaymentHistory
# Check for duplicate session processing using PaymentHistory.
# Use the Stripe payment_intent (one-time) or subscription id (recurring) as the
# idempotency key, NOT the checkout session id — Stripe can redeliver the event.
db_available = False
try:
from database.connection import get_sync_session
@@ -285,14 +291,22 @@ async def handle_checkout_completed(session: Dict):
except ImportError:
pass
payment_intent_id = session.get("payment_intent")
subscription_id = session.get("subscription")
if db_available and session_id:
try:
with get_sync_session() as db_session:
existing = db_session.query(DBPaymentHistory).filter(
DBPaymentHistory.stripe_payment_intent_id == session_id
).first()
from sqlalchemy import or_
filters = [DBPaymentHistory.stripe_payment_intent_id == session_id]
if payment_intent_id:
filters.append(DBPaymentHistory.stripe_payment_intent_id == payment_intent_id)
if subscription_id:
filters.append(DBPaymentHistory.stripe_invoice_id == subscription_id)
existing = db_session.query(DBPaymentHistory).filter(or_(*filters)).first()
if existing:
logger.info("Checkout session %s already processed. Skipping.", session_id)
logger.info("Checkout session %s already processed (pi=%s sub=%s). Skipping.",
session_id, payment_intent_id, subscription_id)
return
except Exception as e:
logger.error("Error checking PaymentHistory duplication: %s", e)
@@ -312,7 +326,7 @@ async def handle_checkout_completed(session: Dict):
with get_sync_session() as db_session:
payment = DBPaymentHistory(
user_id=user_id,
stripe_payment_intent_id=session_id,
stripe_payment_intent_id=payment_intent_id or session_id,
stripe_invoice_id=session.get("invoice") or session.get("subscription"),
amount_cents=session.get("amount_total") or 0,
currency=session.get("currency") or "usd",
@@ -377,7 +391,6 @@ async def handle_checkout_completed(session: Dict):
subscription_id = subscription_raw.get("id")
period_end = subscription_raw.get("current_period_end")
if period_end:
from datetime import timezone
subscription_ends_at = datetime.fromtimestamp(period_end, tz=timezone.utc)
# Derive tier from plan (DB constraint: only 'free' or 'pro')
@@ -408,7 +421,7 @@ async def handle_checkout_completed(session: Dict):
with get_sync_session() as db_session:
payment = DBPaymentHistory(
user_id=user_id,
stripe_payment_intent_id=session_id,
stripe_payment_intent_id=payment_intent_id or session_id,
stripe_invoice_id=subscription_id or session.get("invoice"),
amount_cents=session.get("amount_total") or 0,
currency=session.get("currency") or "usd",
@@ -481,15 +494,13 @@ async def handle_subscription_updated(subscription: Dict):
period_end = subscription.get("current_period_end")
ends_str = ""
if period_end:
from datetime import timezone
ends_str = datetime.fromtimestamp(period_end, tz=timezone.utc).strftime("%d/%m/%Y")
period_end = subscription.get("current_period_end")
update_user(user_id, {
"subscription_status": status.value,
"cancel_at_period_end": stripe_cancel_at_period_end,
"subscription_ends_at": datetime.fromtimestamp(
subscription.get("current_period_end", 0)
).isoformat() if subscription.get("current_period_end") else None
"subscription_ends_at": datetime.fromtimestamp(period_end, tz=timezone.utc) if period_end else None,
})
# Send cancellation email if they just selected to cancel
@@ -531,7 +542,7 @@ async def handle_subscription_deleted(subscription: Dict):
if not user:
return
had_active_sub = user.plan != PlanType.FREE.value or user.tier != "free"
had_active_sub = user.plan != PlanType.FREE or user.tier != "free"
update_user(user_id, {
"plan": PlanType.FREE.value,
@@ -621,6 +632,50 @@ async def handle_payment_failed(invoice: Dict):
logger.error("handle_payment_failed DB error: %s", exc)
async def handle_invoice_paid(invoice: Dict):
"""Extend subscription_ends_at when a recurring invoice is paid."""
customer_id = invoice.get("customer")
if not customer_id:
return
subscription_id = invoice.get("subscription")
period_end = invoice.get("period_end") or invoice.get("lines", {}).get("data", [{}])[0].get("period", {}).get("end")
try:
from database.connection import get_sync_session
from database.models import User as DBUser
with get_sync_session() as session:
db_user = (
session.query(DBUser)
.filter(DBUser.stripe_customer_id == customer_id)
.first()
)
if not db_user:
return
if subscription_id and db_user.stripe_subscription_id != subscription_id:
# The paid invoice belongs to a different subscription; do not update.
logger.warning(
"Invoice paid for customer %s but subscription id mismatch (expected %s, got %s)",
customer_id, db_user.stripe_subscription_id, subscription_id,
)
return
if period_end:
new_end = datetime.fromtimestamp(period_end, tz=timezone.utc)
if db_user.subscription_ends_at is None or new_end > db_user.subscription_ends_at:
db_user.subscription_ends_at = new_end
db_user.updated_at = datetime.now(timezone.utc)
session.commit()
logger.info(
"Extended subscription_ends_at for user %s to %s",
db_user.id, new_end.isoformat(),
)
except Exception as exc:
logger.error("handle_invoice_paid error: %s", exc)
async def cancel_subscription(user_id: str) -> Dict[str, Any]:
"""Cancel a user's subscription at period end."""
if not is_stripe_configured():
@@ -631,6 +686,10 @@ async def cancel_subscription(user_id: str) -> Dict[str, Any]:
return {"error": "No active subscription found"}
try:
subscription = stripe.Subscription.retrieve(user.stripe_subscription_id)
if subscription.customer != user.stripe_customer_id:
return {"error": "Subscription does not belong to current user"}
subscription = stripe.Subscription.modify(
user.stripe_subscription_id,
cancel_at_period_end=True,
@@ -638,13 +697,13 @@ async def cancel_subscription(user_id: str) -> Dict[str, Any]:
cancel_at = None
if subscription.cancel_at:
cancel_at = datetime.fromtimestamp(subscription.cancel_at).isoformat()
cancel_at = datetime.fromtimestamp(subscription.cancel_at, tz=timezone.utc)
subscription_ends_at = None
ends_str = ""
if subscription.current_period_end:
subscription_ends_at = datetime.fromtimestamp(subscription.current_period_end).isoformat()
ends_str = datetime.fromtimestamp(subscription.current_period_end).strftime("%d/%m/%Y")
subscription_ends_at = datetime.fromtimestamp(subscription.current_period_end, tz=timezone.utc)
ends_str = datetime.fromtimestamp(subscription.current_period_end, tz=timezone.utc).strftime("%d/%m/%Y")
is_new_cancel = not user.cancel_at_period_end