"""Author RAG Chatbot SaaS — Client Access (Subscription) Model. Records every subscription grant from SuperAdmin to an author. The token_hash is stored (never the raw token) for security. Revocation is instant via Redis blacklist + this record. """ from datetime import datetime from sqlalchemy import Boolean, DateTime, Integer, String, Text from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base, TimestampMixin, generate_uuid class ClientAccess(Base, TimestampMixin): """Subscription access record — one row per grant from SuperAdmin.""" __tablename__ = "client_access" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid) author_id: Mapped[str] = mapped_column( String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) granted_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("users.id"), nullable=True ) # NULL for Stripe-auto-provisioned subscriptions (no human granter) plan: Mapped[str] = mapped_column( String(20), nullable=False, ) # Legacy plan string — also see plan_id FK below plan_id: Mapped[str | None] = mapped_column( String(50), nullable=True, index=True ) # FK to PricingPlan.id — set for Stripe-provisioned subscriptions stripe_subscription_id: Mapped[str | None] = mapped_column( String(255), nullable=True, unique=True, index=True ) # Links this access record to the Stripe subscription for webhook updates granted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) token_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) # Token budget token_budget: Mapped[int] = mapped_column(Integer, nullable=False) tokens_used: Mapped[int] = mapped_column(Integer, default=0, nullable=False) bonus_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False) # Revocation is_revoked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) revoked_by: Mapped[str | None] = mapped_column(String(36), nullable=True) revoke_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) # Auto-renewal auto_renew: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # Admin notes notes: Mapped[str | None] = mapped_column(Text, nullable=True) # Relationships author: Mapped["User"] = relationship("User", foreign_keys=[author_id], back_populates="access_records") @property def total_token_budget(self) -> int: """Total available tokens including any bonus grants.""" return self.token_budget + self.bonus_tokens def __repr__(self) -> str: return f""