"""Paid-tier access checks. Two sources can grant paid access: 1. ``user.tier in {"paid", "enterprise"}`` — set by Paddle webhook in Phase D.3 once a subscription is active. 2. ``user.credit_until > now()`` — non-subscription credit. Currently populated by the admin CLI (`python -m app.cli grant-credit`) and, in D.3, by the referral-conversion path (3 months at 50% off). Either is sufficient. We use a single ``paid_status`` function so the Settings page can show *why* a user has paid access ("paid subscription" vs "credit, 47 days left") without duplicating the rules. """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone from fastapi import Depends, HTTPException, status from app.auth import CurrentUser, require_auth from app.models import User # How many hours of news the free tier sees. Paid sees whatever the # endpoint's `since_hours` param requests (up to its own max). FREE_NEWS_WINDOW_HOURS = 6.0 def _utcnow() -> datetime: return datetime.now(timezone.utc) @dataclass(frozen=True) class PaidStatus: """Snapshot of paid-tier status for one user.""" active: bool source: str | None # "tier" | "credit" | None expires_at: datetime | None # only meaningful when source == "credit" days_remaining: int | None # only meaningful when source == "credit" def _aware(dt: datetime | None) -> datetime | None: """MariaDB round-trips DateTime(timezone=True) as a naive UTC value via aiomysql. Normalise to tz-aware so comparisons against utcnow() never raise.""" if dt is None: return None if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt def paid_status(user: User | None) -> PaidStatus: """Compute paid-tier status for a User row. ``user=None`` (anonymous or admin bearer-token) returns inactive — callers should special-case admin separately via ``is_paid_active``.""" if user is None: return PaidStatus(False, None, None, None) if user.tier in ("paid", "enterprise"): return PaidStatus(True, "tier", None, None) cu = _aware(getattr(user, "credit_until", None)) if cu is not None and cu > _utcnow(): days = max(0, (cu - _utcnow()).days) return PaidStatus(True, "credit", cu, days) return PaidStatus(False, None, None, None) def is_paid_active(principal: CurrentUser | User | None) -> bool: """True if the principal has paid-tier access right now. Admin bearer-token (``CurrentUser.is_admin=True``) always passes.""" if principal is None: return False if isinstance(principal, CurrentUser): if principal.is_admin: return True return paid_status(principal.user).active return paid_status(principal).active async def require_paid( principal: CurrentUser = Depends(require_auth), ) -> CurrentUser: """FastAPI dependency for paid-only endpoints. Returns the principal on success; raises 402 Payment Required otherwise. 402 is the semantically-correct code for "auth succeeded but plan insufficient" — distinct from 401 (not authenticated) and 403 (authenticated but forbidden by ACL). Frontends key off it to show the upgrade prompt rather than redirecting to /login.""" if is_paid_active(principal): return principal raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ "code": "paid_required", "message": "This feature requires an active paid plan or credit.", }, )