""" Tier-based monthly translation quota. Uses Redis counter per user per month; fallback in-memory when Redis unavailable. Coexists with IP-based rate limiting in rate_limiting.py. Free tier: 2 translations per calendar month. Paid tiers (starter/pro/business/enterprise): unlimited. """ from __future__ import annotations import os import logging from dataclasses import dataclass from datetime import datetime, timezone, timedelta from typing import Optional logger = logging.getLogger(__name__) # Free tier: 2 translations per calendar month. FREE_TIER_MONTHLY_LIMIT = 2 KEY_PREFIX = "quota:monthly" def _utc_month_str(dt: Optional[datetime] = None) -> str: """Current month in UTC as YYYY-MM.""" t = dt or datetime.now(timezone.utc) return t.strftime("%Y-%m") def _next_month_utc(dt: Optional[datetime] = None) -> datetime: """First second of next month UTC.""" now = dt or datetime.now(timezone.utc) if now.month == 12: return datetime(now.year + 1, 1, 1, tzinfo=timezone.utc) return datetime(now.year, now.month + 1, 1, tzinfo=timezone.utc) def _seconds_until_next_month(dt: Optional[datetime] = None) -> int: """Seconds until start of next month UTC.""" now = dt or datetime.now(timezone.utc) return max(0, int((_next_month_utc(now) - now).total_seconds())) @dataclass class QuotaResult: """Result of a quota check.""" allowed: bool remaining: int # -1 for paid tiers (unlimited) reset_at_utc: datetime current_usage: int = 0 limit: int = FREE_TIER_MONTHLY_LIMIT # --------------------------------------------------------------------------- # Redis backend # --------------------------------------------------------------------------- def _get_async_redis(): """Return async Redis client or None. Uses shared client from core.redis.""" from core.redis import get_async_redis return get_async_redis() # --------------------------------------------------------------------------- # In-memory fallback # --------------------------------------------------------------------------- _memory_usage: dict[tuple[str, str], int] = {} # (user_id, month_str) -> count def _memory_get(user_id: str, month_str: str) -> int: return _memory_usage.get((user_id, month_str), 0) def _memory_incr(user_id: str, month_str: str) -> int: key = (user_id, month_str) _memory_usage[key] = _memory_usage.get(key, 0) + 1 return _memory_usage[key] # --------------------------------------------------------------------------- # TierQuotaService # --------------------------------------------------------------------------- class TierQuotaService: """ Monthly translation quota per user by tier. Redis key pattern: quota:monthly:{user_id}:{YYYY-MM}, TTL 32 days. """ def __init__(self): self._redis = None def _redis_client(self): if self._redis is None: self._redis = _get_async_redis() return self._redis async def check_quota(self, user_id: str, tier: str) -> QuotaResult: """Check monthly quota. Free = 2/month; paid = unlimited.""" reset_at = _next_month_utc() tier_lower = (tier or "free").lower() if tier_lower in ("pro", "business", "enterprise", "starter"): return QuotaResult( allowed=True, remaining=-1, reset_at_utc=reset_at, current_usage=0, limit=0, ) # Free tier — monthly counter month_str = _utc_month_str() redis_client = self._redis_client() if redis_client: try: key = f"{KEY_PREFIX}:{user_id}:{month_str}" count = await redis_client.get(key) count = int(count or 0) except Exception as e: logger.warning("Tier quota Redis get failed: %s, using in-memory", e) count = _memory_get(user_id, month_str) else: count = _memory_get(user_id, month_str) remaining = max(0, FREE_TIER_MONTHLY_LIMIT - count) return QuotaResult( allowed=count < FREE_TIER_MONTHLY_LIMIT, remaining=remaining, reset_at_utc=reset_at, current_usage=count, limit=FREE_TIER_MONTHLY_LIMIT, ) async def increment_on_success(self, user_id: str) -> None: """Increment monthly translation count (call after successful translation).""" month_str = _utc_month_str() redis_client = self._redis_client() if redis_client: try: key = f"{KEY_PREFIX}:{user_id}:{month_str}" pipe = redis_client.pipeline() pipe.incr(key) pipe.expire(key, 32 * 24 * 3600) # 32 days await pipe.execute() return except Exception as e: logger.warning("Tier quota Redis increment failed: %s, using in-memory", e) _memory_incr(user_id, month_str) def seconds_until_reset(self) -> int: """Seconds until start of next month UTC.""" return _seconds_until_next_month() # Singleton tier_quota_service = TierQuotaService()