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:
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user