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

@@ -104,13 +104,14 @@ async def require_user(credentials: HTTPAuthorizationCredentials = Depends(secur
def user_to_response(user) -> UserResponse:
"""Convert User to UserResponse with plan limits"""
plan_limits = PLANS[user.plan]
tier = getattr(user, "tier", None) or user.plan
return UserResponse(
id=user.id,
email=user.email,
name=user.name,
avatar_url=user.avatar_url,
plan=user.plan,
tier=user.plan,
tier=tier,
subscription_status=user.subscription_status,
subscription_ends_at=getattr(user, 'subscription_ends_at', None),
cancel_at_period_end=getattr(user, 'cancel_at_period_end', False),
@@ -540,7 +541,7 @@ async def login_v1(request: Request):
)
access_token = create_access_token(
user.id, tier=user.plan.value, expires_delta=timedelta(minutes=15)
user.id, tier=getattr(user, "tier", user.plan.value), expires_delta=timedelta(minutes=15)
)
refresh_token = create_refresh_token(user.id, expires_delta=timedelta(days=7))
@@ -626,7 +627,7 @@ async def google_auth_v1(body: GoogleAuthRequest):
content={"error": "USER_CREATE_FAILED", "message": str(exc)},
)
access_token = create_access_token(user.id)
access_token = create_access_token(user.id, tier=getattr(user, "tier", "free"))
refresh_token = create_refresh_token(user.id)
return JSONResponse(
@@ -724,7 +725,7 @@ async def refresh_v1(request: Request):
)
access_token = create_access_token(
user.id, tier=user.plan.value, expires_delta=timedelta(minutes=15)
user.id, tier=getattr(user, "tier", user.plan.value), expires_delta=timedelta(minutes=15)
)
new_refresh_token = create_refresh_token(user.id, expires_delta=timedelta(days=7))

View File

@@ -44,8 +44,13 @@ from typing_extensions import Annotated
from config import config
from translators import ExcelTranslator, WordTranslator, PowerPointTranslator
from models.subscription import PlanType
from middleware.tier_quota import tier_quota_service
from services.auth_service import record_usage, check_usage_limits
from services.auth_service import (
record_usage,
check_usage_limits,
reserve_translation_quota,
release_translation_quota,
)
from middleware.tier_quota import _seconds_until_next_month, _next_month_utc
from middleware.validation import FileValidator, ValidationError, LanguageValidator, webhook_validator
from middleware.api_key_auth import get_authenticated_user, get_user_from_api_key
from utils import file_handler
@@ -548,6 +553,7 @@ async def translate_document_v1(
"""
request_id = getattr(request.state, "request_id", str(uuid.uuid4())[:8])
quota_reserved = False
try:
if not file and not file_url:
raise TranslateEndpointError(
@@ -615,40 +621,42 @@ async def translate_document_v1(
)
if current_user:
quota = await tier_quota_service.check_quota(user_id, tier)
if not quota.allowed:
retry_after = tier_quota_service.seconds_until_reset()
raise HTTPException(
status_code=429,
detail={
"error": "QUOTA_EXCEEDED",
"message": f"Monthly limit reached ({quota.current_usage}/{quota.limit} documents). Upgrade your plan for more.",
"details": {
"current_usage": quota.current_usage,
"limit": quota.limit,
"tier": tier,
"reset_at": quota.reset_at_utc.isoformat(),
},
},
headers={"Retry-After": str(retry_after)},
)
# Strict database plan limit check (Starter, Pro, Business, Enterprise)
usage = check_usage_limits(current_user)
if not usage["can_translate"]:
retry_after = _seconds_until_next_month()
raise HTTPException(
status_code=429,
detail={
"error": "QUOTA_EXCEEDED",
"message": f"Monthly limit reached ({usage['docs_used']}/{usage['docs_limit']} documents). Upgrade your plan for more.",
"details": {
"current_usage": usage["docs_used"],
"limit": usage["docs_limit"],
"current_usage": usage['docs_used'],
"limit": usage['docs_limit'],
"tier": tier,
"reset_at": _next_month_utc().isoformat(),
},
},
headers={"Retry-After": str(retry_after)},
)
rate_limit_remaining = quota.remaining
# Atomically reserve one document slot now so concurrent requests cannot
# overshoot the monthly quota while background jobs are still running.
reserved = await asyncio.to_thread(reserve_translation_quota, user_id)
if not reserved:
retry_after = _seconds_until_next_month()
raise HTTPException(
status_code=429,
detail={
"error": "QUOTA_EXCEEDED",
"message": "Monthly limit reached. Upgrade your plan for more.",
"details": {
"tier": tier,
"reset_at": _next_month_utc().isoformat(),
},
},
headers={"Retry-After": str(retry_after)},
)
quota_reserved = True
rate_limit_remaining = usage["docs_remaining"]
else:
rate_limit_remaining = -1
@@ -839,6 +847,8 @@ async def translate_document_v1(
)
except TranslateEndpointError as e:
if quota_reserved and user_id:
await asyncio.to_thread(release_translation_quota, user_id)
status_code = 400
if e.code == TranslateEndpointError.FILE_TOO_LARGE:
status_code = 413
@@ -852,8 +862,12 @@ async def translate_document_v1(
content=e.to_dict(),
)
except HTTPException:
if quota_reserved and user_id:
await asyncio.to_thread(release_translation_quota, user_id)
raise
except Exception as e:
if quota_reserved and user_id:
await asyncio.to_thread(release_translation_quota, user_id)
logger.error(f"[{request_id}] Unexpected error: {e}")
return JSONResponse(
status_code=400,
@@ -973,6 +987,7 @@ async def _run_translation_job(
return
tracker = ProgressTracker(job_id, _translation_jobs)
usage_recorded = False
try:
job["status"] = "processing"
@@ -1337,14 +1352,14 @@ async def _run_translation_job(
else:
cost_factor = 5
for _ in range(cost_factor):
await tier_quota_service.increment_on_success(user_id)
# Persist monthly usage counters in PostgreSQL (docs + pages)
pages = await asyncio.to_thread(
_estimate_pages, input_path, file_extension
)
await asyncio.to_thread(record_usage, user_id, pages, False, cost_factor)
await asyncio.to_thread(
record_usage, user_id, pages, cost_factor, reserved_docs=1
)
usage_recorded = True
logger.info(f"Job {job_id}: usage recorded — {pages} page(s) with cost factor {cost_factor}")
# Apply watermark for Free-tier users
@@ -1364,6 +1379,12 @@ async def _run_translation_job(
record_translation(provider=provider, file_type=file_extension or "unknown", duration=duration, status="success")
logger.info(f"Job {job_id}: Completed successfully")
except asyncio.CancelledError:
# Background task cancelled (e.g. TestClient teardown or server shutdown).
# The document slot was already reserved at request time; keep it consumed
# so quota enforcement remains deterministic.
logger.warning(f"Job {job_id}: translation task cancelled, keeping reserved quota")
raise
except Exception as e:
# Check if this is our structured TranslationProviderError
if type(e).__name__ == "TranslationProviderError":
@@ -1376,6 +1397,13 @@ async def _run_translation_job(
# Record translation failure metric
record_translation(provider=provider, file_type=file_extension or "unknown", duration=0, status="error")
if user_id and not usage_recorded:
try:
await asyncio.to_thread(release_translation_quota, user_id)
logger.info(f"Job {job_id}: released reserved quota after failure")
except Exception as release_err:
logger.exception(f"Job {job_id}: failed to release reserved quota: {release_err}")
finally:
if webhook_url:
try: