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
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m16s
This commit is contained in:
@@ -278,6 +278,7 @@ def _db_user_to_model(db_user) -> User:
|
||||
password_hash=db_user.password_hash,
|
||||
avatar_url=db_user.avatar_url,
|
||||
plan=PlanType(db_user.plan) if db_user.plan else PlanType.FREE,
|
||||
tier=db_user.tier or "free",
|
||||
subscription_status=SubscriptionStatus(db_user.subscription_status)
|
||||
if db_user.subscription_status
|
||||
else SubscriptionStatus.ACTIVE,
|
||||
@@ -460,36 +461,48 @@ def update_user(user_id: str, updates: Dict[str, Any]) -> Optional[User]:
|
||||
return User(**users[user_id])
|
||||
|
||||
|
||||
def _reset_usage_if_needed(user_id: str) -> Optional[User]:
|
||||
"""Reset monthly counters if usage_reset_date is in a previous month."""
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
from database.connection import get_sync_session
|
||||
from database.repositories import UserRepository
|
||||
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
repo.reset_usage_if_needed(user_id)
|
||||
else:
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
reset_date = user.usage_reset_date
|
||||
if reset_date.month != now.month or reset_date.year != now.year:
|
||||
update_user(
|
||||
user_id,
|
||||
{
|
||||
"docs_translated_this_month": 0,
|
||||
"pages_translated_this_month": 0,
|
||||
"api_calls_this_month": 0,
|
||||
"usage_reset_date": now.isoformat(),
|
||||
},
|
||||
)
|
||||
return get_user_by_id(user_id)
|
||||
|
||||
|
||||
def check_usage_limits(user: User) -> Dict[str, Any]:
|
||||
"""Check if user has exceeded their plan limits"""
|
||||
# Ensure counters are reset if we've entered a new month.
|
||||
refreshed = _reset_usage_if_needed(user.id)
|
||||
if refreshed:
|
||||
user = refreshed
|
||||
|
||||
plan = PLANS[user.plan]
|
||||
|
||||
# Reset usage if it's a new month
|
||||
now = datetime.now(timezone.utc)
|
||||
if (
|
||||
user.usage_reset_date.month != now.month
|
||||
or user.usage_reset_date.year != now.year
|
||||
):
|
||||
update_user(
|
||||
user.id,
|
||||
{
|
||||
"docs_translated_this_month": 0,
|
||||
"pages_translated_this_month": 0,
|
||||
"api_calls_this_month": 0,
|
||||
"usage_reset_date": now.isoformat() if not USE_DATABASE else now,
|
||||
},
|
||||
)
|
||||
user.docs_translated_this_month = 0
|
||||
user.pages_translated_this_month = 0
|
||||
user.api_calls_this_month = 0
|
||||
|
||||
docs_limit = plan["docs_per_month"]
|
||||
docs_remaining = (
|
||||
max(0, docs_limit - user.docs_translated_this_month) if docs_limit > 0 else -1
|
||||
)
|
||||
unlimited = docs_limit == -1
|
||||
docs_remaining = -1 if unlimited else max(0, docs_limit - user.docs_translated_this_month)
|
||||
|
||||
return {
|
||||
"can_translate": docs_remaining != 0 or user.extra_credits > 0,
|
||||
"can_translate": unlimited or docs_remaining != 0 or user.extra_credits > 0,
|
||||
"docs_used": user.docs_translated_this_month,
|
||||
"docs_limit": docs_limit,
|
||||
"docs_remaining": docs_remaining,
|
||||
@@ -502,23 +515,190 @@ def check_usage_limits(user: User) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def record_usage(
|
||||
user_id: str, pages_count: int, use_credits: bool = False, cost_factor: int = 1
|
||||
user_id: str, pages_count: int, cost_factor: int = 1, reserved_docs: int = 0
|
||||
) -> bool:
|
||||
"""Record document translation usage with optional cost factor depending on AI model"""
|
||||
"""Record document translation usage with optional cost factor depending on AI model.
|
||||
|
||||
`reserved_docs` is the number of document slots already reserved at request time
|
||||
(e.g. by ``reserve_translation_quota``). Those slots are not counted again; only
|
||||
the remaining ``max(0, cost_factor - reserved_docs)`` docs are added here.
|
||||
|
||||
Automatically consumes extra credits first; falls back to monthly quota.
|
||||
Returns True if usage was recorded successfully, False otherwise.
|
||||
"""
|
||||
user = _reset_usage_if_needed(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
total_cost = pages_count * cost_factor
|
||||
docs_to_record = max(0, cost_factor - reserved_docs)
|
||||
plan = PLANS[user.plan]
|
||||
docs_limit = plan["docs_per_month"]
|
||||
unlimited = docs_limit == -1
|
||||
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
from database.connection import get_sync_session
|
||||
from database.repositories import UserRepository
|
||||
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
|
||||
if unlimited:
|
||||
# Paid unlimited plans: track usage for analytics/downgrade safety.
|
||||
repo.increment_usage(user_id, docs=docs_to_record, pages=total_cost)
|
||||
return True
|
||||
|
||||
# Prefer credits first, then quota.
|
||||
if user.extra_credits > 0:
|
||||
credits_to_use = min(user.extra_credits, total_cost)
|
||||
if repo.use_credits(user_id, credits_to_use):
|
||||
remaining_cost = total_cost - credits_to_use
|
||||
if remaining_cost > 0:
|
||||
repo.increment_usage(
|
||||
user_id, docs=docs_to_record, pages=remaining_cost
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
if user.docs_translated_this_month + docs_to_record <= docs_limit:
|
||||
repo.increment_usage(user_id, docs=docs_to_record, pages=total_cost)
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
# JSON fallback (non-atomic, dev only)
|
||||
if unlimited:
|
||||
return update_user(
|
||||
user_id,
|
||||
{
|
||||
"docs_translated_this_month": user.docs_translated_this_month
|
||||
+ docs_to_record,
|
||||
"pages_translated_this_month": user.pages_translated_this_month
|
||||
+ total_cost,
|
||||
},
|
||||
) is not None
|
||||
|
||||
if user.extra_credits > 0:
|
||||
credits_to_use = min(user.extra_credits, total_cost)
|
||||
new_credits = user.extra_credits - credits_to_use
|
||||
remaining_cost = total_cost - credits_to_use
|
||||
updates = {"extra_credits": new_credits}
|
||||
if remaining_cost > 0:
|
||||
if user.docs_translated_this_month + docs_to_record > docs_limit:
|
||||
return False
|
||||
updates["docs_translated_this_month"] = (
|
||||
user.docs_translated_this_month + docs_to_record
|
||||
)
|
||||
updates["pages_translated_this_month"] = (
|
||||
user.pages_translated_this_month + remaining_cost
|
||||
)
|
||||
return update_user(user_id, updates) is not None
|
||||
|
||||
if user.docs_translated_this_month + docs_to_record <= docs_limit:
|
||||
return update_user(
|
||||
user_id,
|
||||
{
|
||||
"docs_translated_this_month": user.docs_translated_this_month
|
||||
+ docs_to_record,
|
||||
"pages_translated_this_month": user.pages_translated_this_month
|
||||
+ total_cost,
|
||||
},
|
||||
) is not None
|
||||
return False
|
||||
|
||||
|
||||
def reserve_translation_quota(user_id: str) -> bool:
|
||||
"""Atomically reserve one document slot at request time.
|
||||
|
||||
Returns True if the reservation succeeded. This prevents race conditions where
|
||||
multiple concurrent requests could exceed the monthly quota.
|
||||
"""
|
||||
user = _reset_usage_if_needed(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
plan = PLANS[user.plan]
|
||||
docs_limit = plan["docs_per_month"]
|
||||
unlimited = docs_limit == -1
|
||||
|
||||
if unlimited:
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
from database.connection import get_sync_session
|
||||
from database.repositories import UserRepository
|
||||
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
repo.increment_usage(user_id, docs=1, pages=0)
|
||||
return True
|
||||
return update_user(
|
||||
user_id,
|
||||
{"docs_translated_this_month": user.docs_translated_this_month + 1},
|
||||
) is not None
|
||||
|
||||
# Limited plan: prefer credits first, then quota.
|
||||
if user.extra_credits > 0:
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
from database.connection import get_sync_session
|
||||
from database.repositories import UserRepository
|
||||
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
return repo.use_credits(user_id, 1)
|
||||
return update_user(
|
||||
user_id, {"extra_credits": max(0, user.extra_credits - 1)}
|
||||
) is not None
|
||||
|
||||
if user.docs_translated_this_month + 1 <= docs_limit:
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
from database.connection import get_sync_session
|
||||
from database.repositories import UserRepository
|
||||
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
repo.increment_usage(user_id, docs=1, pages=0)
|
||||
return True
|
||||
return update_user(
|
||||
user_id,
|
||||
{"docs_translated_this_month": user.docs_translated_this_month + 1},
|
||||
) is not None
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def release_translation_quota(user_id: str) -> bool:
|
||||
"""Release a previously reserved document slot (e.g. on translation failure)."""
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
updates = {
|
||||
"docs_translated_this_month": user.docs_translated_this_month + cost_factor,
|
||||
"pages_translated_this_month": user.pages_translated_this_month + (pages_count * cost_factor),
|
||||
}
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
from database.connection import get_sync_session
|
||||
from database.repositories import UserRepository
|
||||
from sqlalchemy import update
|
||||
|
||||
if use_credits:
|
||||
updates["extra_credits"] = max(0, user.extra_credits - (pages_count * cost_factor))
|
||||
with get_sync_session() as session:
|
||||
now = datetime.now(timezone.utc)
|
||||
session.execute(
|
||||
update(db_models.User)
|
||||
.where(
|
||||
db_models.User.id == user_id,
|
||||
db_models.User.docs_translated_this_month > 0,
|
||||
)
|
||||
.values(
|
||||
docs_translated_this_month=db_models.User.docs_translated_this_month
|
||||
- 1,
|
||||
updated_at=now,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
result = update_user(user_id, updates)
|
||||
return result is not None
|
||||
if user.docs_translated_this_month > 0:
|
||||
return update_user(
|
||||
user_id,
|
||||
{"docs_translated_this_month": user.docs_translated_this_month - 1},
|
||||
) is not None
|
||||
return True
|
||||
|
||||
|
||||
def add_credits(user_id: str, credits: int) -> bool:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user