Merge pull request #499 from christianlouis/copilot/add-subscription-management-features

Merge main → subscription-management-features; fix migration chain collision
This commit is contained in:
Christian Krakau-Louis
2026-03-07 22:03:02 +01:00
committed by GitHub
9 changed files with 1083 additions and 59 deletions
+127 -5
View File
@@ -1,9 +1,11 @@
"""API endpoints for subscription tiers and usage statistics.
Public endpoints:
GET /api/subscriptions/tiers — list all available plans
GET /api/subscriptions/my — current user's plan + usage (auth required)
GET /api/subscriptions/platform — platform-wide stats (admin only)
GET /api/subscriptions/tiers — list all available plans
GET /api/subscriptions/my — current user's plan + usage (auth required)
POST /api/subscriptions/change — request a plan change (auth required)
DELETE /api/subscriptions/change — cancel a pending plan change (auth required)
GET /api/subscriptions/platform — platform-wide stats (admin only)
"""
import logging
@@ -11,6 +13,7 @@ from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
@@ -19,10 +22,14 @@ from app.database import get_db
from app.utils.subscription import (
TIER_ORDER,
TIERS,
SubscriptionChangeError,
apply_pending_subscription_changes,
cancel_pending_subscription_change,
get_all_tiers,
get_tier,
get_user_tier_id,
get_user_usage,
request_subscription_change,
)
logger = logging.getLogger(__name__)
@@ -33,6 +40,37 @@ DbSession = Annotated[Session, Depends(get_db)]
AdminUser = Annotated[dict, Depends(_require_admin)]
# ---------------------------------------------------------------------------
# Request / response models
# ---------------------------------------------------------------------------
class SubscriptionChangeRequest(BaseModel):
"""Request body for a subscription plan change."""
plan_id: str
billing_cycle: str = "monthly" # "monthly" | "yearly"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Extract the authenticated user's owner_id from the session."""
user = request.session.get("user") or {}
return user.get("username") or user.get("email") or user.get("sub") or ""
def _require_authenticated(request: Request) -> str:
"""Return the owner_id or raise 401."""
owner_id = _get_owner_id(request)
if not owner_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
return owner_id
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@@ -50,8 +88,12 @@ def list_tiers() -> dict[str, Any]:
@router.get("/my", summary="Get current user's subscription and usage")
def my_subscription(request: Request, db: DbSession) -> dict[str, Any]:
"""Return the authenticated user's subscription tier and current usage counts."""
"""Return the authenticated user's subscription tier and current usage counts.
Also applies any pending subscription changes that have become due.
"""
from app.config import settings
from app.models import UserProfile
user = request.session.get("user")
@@ -67,18 +109,98 @@ def my_subscription(request: Request, db: DbSession) -> dict[str, Any]:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
owner_id: str = user.get("username") or user.get("email") or user.get("sub") or ""
# Apply any pending change that has become due
apply_pending_subscription_changes(db, owner_id)
tier_id = get_user_tier_id(db, owner_id)
tier = get_tier(tier_id)
tier = get_tier(tier_id, db)
usage = get_user_usage(db, owner_id)
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
pending_tier_id: str | None = profile.subscription_change_pending_tier if profile else None
pending_date: str | None = (
profile.subscription_change_pending_date.isoformat()
if profile and profile.subscription_change_pending_date
else None
)
period_start: str | None = (
profile.subscription_period_start.isoformat() if profile and profile.subscription_period_start else None
)
return {
"multi_user_mode": True,
"owner_id": owner_id,
"tier": tier,
"usage": usage,
"period_start": period_start,
"pending_change": (
{
"tier_id": pending_tier_id,
"tier": get_tier(pending_tier_id, db),
"effective_date": pending_date,
}
if pending_tier_id
else None
),
}
@router.post("/change", summary="Request a subscription plan change", status_code=status.HTTP_200_OK)
def change_subscription(request: Request, body: SubscriptionChangeRequest, db: DbSession) -> dict[str, Any]:
"""Request a subscription tier change.
**Upgrades** (moving to a higher-ranked plan) take effect immediately.
**Downgrades** (moving to a lower-ranked plan) are scheduled for the end
of the current billing period to prevent gaming. The user keeps their
current plan benefits until the scheduled date.
Requesting the currently active tier while a downgrade is pending cancels
that pending change.
"""
from app.config import settings
if not settings.multi_user_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Subscription management is not available in single-user mode.",
)
owner_id = _require_authenticated(request)
try:
result = request_subscription_change(db, owner_id, body.plan_id, body.billing_cycle)
except SubscriptionChangeError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return result
@router.delete("/change", summary="Cancel a pending subscription change", status_code=status.HTTP_200_OK)
def cancel_subscription_change(request: Request, db: DbSession) -> dict[str, Any]:
"""Cancel a scheduled future subscription change.
Only downgrades can be pending; upgrades always take effect immediately.
Returns 404 when there is no pending change to cancel.
"""
from app.config import settings
if not settings.multi_user_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Subscription management is not available in single-user mode.",
)
owner_id = _require_authenticated(request)
cancelled = cancel_pending_subscription_change(db, owner_id)
if not cancelled:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No pending subscription change found.")
return {"cancelled": True, "message": "Your pending subscription change has been cancelled."}
@router.get("/platform", summary="Platform-wide usage statistics (admin only)")
def platform_stats(request: Request, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Return aggregate statistics across all users and tiers (admin only)."""
+7
View File
@@ -25,6 +25,7 @@ from app.tasks.process_with_ocr import process_with_ocr # noqa: F401
from app.tasks.refine_text_with_gpt import refine_text_with_gpt # noqa: F401
from app.tasks.rotate_pdf_pages import rotate_pdf_pages # noqa: F401
from app.tasks.send_to_all import send_to_all_destinations # noqa: F401
from app.tasks.subscription_tasks import apply_pending_subscription_changes_all # noqa: F401
# Import new send tasks
from app.tasks.upload_to_dropbox import upload_to_dropbox # noqa: F401
@@ -104,6 +105,12 @@ celery.conf.beat_schedule = {
"schedule": crontab(minute="*/5"), # Every 5 minutes
"options": {"expires": 240}, # 4 minutes expiry
},
# Apply scheduled subscription downgrades daily at 00:05 UTC
"apply-pending-subscription-changes": {
"task": "app.tasks.subscription_tasks.apply_pending_subscription_changes_all",
"schedule": crontab(hour="0", minute="5"), # 00:05 UTC daily
"options": {"expires": 3600},
},
}
# Remove None entries from beat_schedule
+7
View File
@@ -238,6 +238,13 @@ class UserProfile(Base):
subscription_period_start = Column(DateTime(timezone=True), nullable=True)
allow_overage = Column(Boolean, nullable=False, default=False, server_default="0")
# Pending subscription change (added in migration 020_add_subscription_change_pending)
# When a user requests a downgrade, the new tier is stored here and the
# change is applied on `subscription_change_pending_date`. Upgrades are
# applied immediately and these fields are left NULL.
subscription_change_pending_tier = Column(String(50), nullable=True)
subscription_change_pending_date = Column(DateTime(timezone=True), nullable=True)
# When True, the user is on a complimentary (uncharged) plan — they keep all tier
# quota benefits but are never billed via Stripe. Automatically set for admin users.
is_complimentary = Column(Boolean, nullable=False, default=False, server_default="0")
+50
View File
@@ -0,0 +1,50 @@
"""Celery task to apply pending subscription changes that have become due.
Runs daily to ensure that scheduled downgrades are applied on time.
"""
import logging
from app.celery_app import celery
logger = logging.getLogger(__name__)
@celery.task(name="app.tasks.subscription_tasks.apply_pending_subscription_changes_all")
def apply_pending_subscription_changes_all() -> dict[str, int]:
"""Apply all pending subscription changes whose effective date has arrived.
Iterates over every ``UserProfile`` that has a pending change and calls
:func:`app.utils.subscription.apply_pending_subscription_changes` for
each one.
Returns:
A dict with ``{"applied": <count>, "checked": <count>}``.
"""
from app.database import SessionLocal
from app.models import UserProfile
from app.utils.subscription import apply_pending_subscription_changes
applied = 0
checked = 0
db = SessionLocal()
try:
profiles = (
db.query(UserProfile)
.filter(
UserProfile.subscription_change_pending_tier.isnot(None),
UserProfile.subscription_change_pending_date.isnot(None),
)
.all()
)
for profile in profiles:
checked += 1
if apply_pending_subscription_changes(db, profile.user_id):
applied += 1
except Exception as exc:
logger.error("Error in apply_pending_subscription_changes_all: %s", exc)
finally:
db.close()
logger.info("apply_pending_subscription_changes_all: checked=%d applied=%d", checked, applied)
return {"checked": checked, "applied": applied}
+276
View File
@@ -502,3 +502,279 @@ def get_user_usage(db: Session, owner_id: str) -> dict[str, int]:
if profile and (profile.subscription_billing_cycle or "monthly") == "yearly" and profile.subscription_period_start:
result["year_to_date"] = get_year_file_count(db, owner_id, profile.subscription_period_start)
return result
# ---------------------------------------------------------------------------
# Subscription change management
# ---------------------------------------------------------------------------
class SubscriptionChangeError(Exception):
"""Raised when a requested subscription change is not permitted."""
def _tier_rank(tier_id: str) -> int:
"""Return the numeric rank of *tier_id* (0 = free … 3 = business).
Unknown tier IDs are treated as rank 0 (free).
"""
try:
return TIER_ORDER.index(tier_id)
except ValueError:
return 0
def apply_pending_subscription_changes(db: Session, owner_id: str) -> bool:
"""Apply any pending subscription change that is now due.
Checks whether the scheduled change date has arrived and, if so, applies
the new tier immediately.
Args:
db: Database session.
owner_id: Stable user identifier.
Returns:
``True`` if a pending change was applied, ``False`` otherwise.
"""
from app.models import UserProfile
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
if not profile:
return False
pending_tier = profile.subscription_change_pending_tier
pending_date = profile.subscription_change_pending_date
if not pending_tier or not pending_date:
return False
now = datetime.now(timezone.utc)
# Normalise pending_date to UTC-aware for comparison
if pending_date.tzinfo is None:
pending_date = pending_date.replace(tzinfo=timezone.utc)
if now < pending_date:
return False # Not yet due
old_tier = profile.subscription_tier or DEFAULT_TIER
profile.subscription_tier = pending_tier
profile.subscription_period_start = pending_date # New period started at change date
profile.subscription_change_pending_tier = None
profile.subscription_change_pending_date = None
try:
db.commit()
logger.info(
"Applied pending subscription change for %s: %s%s",
owner_id,
old_tier,
pending_tier,
)
except Exception as exc:
db.rollback()
logger.error("Failed to apply pending subscription change for %s: %s", owner_id, exc)
return False
return True
def request_subscription_change(
db: Session,
owner_id: str,
new_tier_id: str,
billing_cycle: str = "monthly",
) -> dict[str, Any]:
"""Process a user-initiated subscription change request.
Upgrade rules
-------------
Upgrades (moving to a higher-ranked tier) take effect **immediately**:
the tier is switched and the period start is reset to *now*. Any
previously scheduled downgrade is cancelled.
Downgrade rules
---------------
Downgrades (moving to a lower-ranked tier) are **always scheduled** for
the end of the current billing period:
* If ``subscription_period_start`` is set and the period end is in the
future, the change is queued for that date.
* If there is no period start (e.g. admin-assigned tier), the period start
is treated as *now* and the change is scheduled one month out.
* If the period has already elapsed the change is applied immediately.
Cancelling a pending downgrade
--------------------------------
Requesting the *current* tier when there is a pending change cancels that
pending change.
Args:
db: Database session.
owner_id: Stable user identifier.
new_tier_id: Target plan ID (e.g. ``"starter"``).
billing_cycle: ``"monthly"`` or ``"yearly"`` — stored on upgrade.
Returns:
A dict with keys ``immediate`` (bool), ``effective_date`` (ISO-8601 str
or ``None``), ``old_tier``, ``new_tier``, ``message``.
Raises:
SubscriptionChangeError: If the requested change is not allowed.
"""
from app.models import UserProfile
now = datetime.now(timezone.utc)
# Validate target tier
valid_ids = [t["id"] for t in get_all_tiers(db)]
if new_tier_id not in valid_ids:
raise SubscriptionChangeError(f"Unknown subscription plan: {new_tier_id!r}")
# Ensure profile row exists
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
if not profile:
profile = UserProfile(user_id=owner_id)
db.add(profile)
db.flush()
old_tier_id = profile.subscription_tier or DEFAULT_TIER
# Cancel pending change when user re-selects their current active tier
if new_tier_id == old_tier_id:
if profile.subscription_change_pending_tier:
profile.subscription_change_pending_tier = None
profile.subscription_change_pending_date = None
db.commit()
return {
"immediate": True,
"effective_date": None,
"old_tier": old_tier_id,
"new_tier": old_tier_id,
"message": "Pending subscription change cancelled.",
}
raise SubscriptionChangeError("You are already on this plan.")
old_rank = _tier_rank(old_tier_id)
new_rank = _tier_rank(new_tier_id)
is_upgrade = new_rank > old_rank
if is_upgrade:
# Apply immediately — reset period start
profile.subscription_tier = new_tier_id
profile.subscription_billing_cycle = billing_cycle
profile.subscription_period_start = now
# Cancel any previously scheduled downgrade
profile.subscription_change_pending_tier = None
profile.subscription_change_pending_date = None
try:
db.commit()
except Exception as exc:
db.rollback()
raise SubscriptionChangeError("Failed to apply subscription upgrade.") from exc
logger.info("Immediate upgrade for %s: %s%s", owner_id, old_tier_id, new_tier_id)
return {
"immediate": True,
"effective_date": None,
"old_tier": old_tier_id,
"new_tier": new_tier_id,
"message": f"You have been upgraded to {get_tier(new_tier_id, db)['name']}. "
"Your new limits are active immediately.",
}
# --- Downgrade path ---
# Determine end of the *first* billing period for the current plan.
# Rule: a downgrade is immediate if the user has completed at least one
# full month on the current plan; otherwise it is scheduled for the
# end of that first month. This prevents gaming: a user who just
# upgraded cannot immediately downgrade to avoid paying the first month.
import calendar
period_start: datetime | None = profile.subscription_period_start
if period_start is None:
# No recorded start → treat today as start; schedule for one month out
period_start = now
profile.subscription_period_start = period_start
if period_start.tzinfo is None:
period_start = period_start.replace(tzinfo=timezone.utc)
# End of the first billing month (same day next month, clamped to valid day)
next_month_num = period_start.month % 12 + 1
next_year = period_start.year + (1 if period_start.month == 12 else 0)
max_day = calendar.monthrange(next_year, next_month_num)[1]
next_day = min(period_start.day, max_day)
change_date = period_start.replace(year=next_year, month=next_month_num, day=next_day)
if change_date <= now:
profile.subscription_tier = new_tier_id
profile.subscription_billing_cycle = billing_cycle
profile.subscription_period_start = now
profile.subscription_change_pending_tier = None
profile.subscription_change_pending_date = None
try:
db.commit()
except Exception as exc:
db.rollback()
raise SubscriptionChangeError("Failed to apply subscription downgrade.") from exc
logger.info("Immediate downgrade for %s: %s%s (period elapsed)", owner_id, old_tier_id, new_tier_id)
return {
"immediate": True,
"effective_date": None,
"old_tier": old_tier_id,
"new_tier": new_tier_id,
"message": f"Your subscription has been changed to {get_tier(new_tier_id, db)['name']}.",
}
# Schedule the downgrade
profile.subscription_change_pending_tier = new_tier_id
profile.subscription_change_pending_date = change_date
try:
db.commit()
except Exception as exc:
db.rollback()
raise SubscriptionChangeError("Failed to schedule subscription downgrade.") from exc
logger.info(
"Scheduled downgrade for %s: %s%s on %s",
owner_id,
old_tier_id,
new_tier_id,
change_date.isoformat(),
)
return {
"immediate": False,
"effective_date": change_date.isoformat(),
"old_tier": old_tier_id,
"new_tier": new_tier_id,
"message": (
f"Your downgrade to {get_tier(new_tier_id, db)['name']} has been scheduled for "
f"{change_date.strftime('%B')} {change_date.day}, {change_date.year}. "
"You will continue to have access to your current plan until then."
),
}
def cancel_pending_subscription_change(db: Session, owner_id: str) -> bool:
"""Cancel a pending subscription change for *owner_id*.
Args:
db: Database session.
owner_id: Stable user identifier.
Returns:
``True`` if a pending change was cancelled, ``False`` if there was nothing to cancel.
"""
from app.models import UserProfile
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
if not profile or not profile.subscription_change_pending_tier:
return False
profile.subscription_change_pending_tier = None
profile.subscription_change_pending_date = None
try:
db.commit()
logger.info("Cancelled pending subscription change for %s", owner_id)
except Exception as exc:
db.rollback()
logger.error("Failed to cancel pending subscription change for %s: %s", owner_id, exc)
return False
return True
+25 -1
View File
@@ -10,7 +10,14 @@ import logging
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from app.utils.subscription import TIER_ORDER, get_all_tiers, get_tier, get_user_tier_id, get_user_usage
from app.utils.subscription import (
TIER_ORDER,
apply_pending_subscription_changes,
get_all_tiers,
get_tier,
get_user_tier_id,
get_user_usage,
)
from app.views.base import APIRouter, get_db, require_login, templates
logger = logging.getLogger(__name__)
@@ -36,19 +43,31 @@ async def pricing_page(request: Request, db: Session = Depends(get_db)):
async def my_subscription_page(request: Request, db: Session = Depends(get_db)):
"""Authenticated user's subscription status and usage page."""
from app.config import settings
from app.models import UserProfile
user = request.session.get("user") or {}
owner_id: str = user.get("username") or user.get("email") or user.get("sub") or ""
if settings.multi_user_enabled and owner_id:
# Apply any pending changes that have become due before rendering
apply_pending_subscription_changes(db, owner_id)
tier_id = get_user_tier_id(db, owner_id)
usage = get_user_usage(db, owner_id)
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
pending_tier_id = profile.subscription_change_pending_tier if profile else None
pending_date = profile.subscription_change_pending_date if profile else None
period_start = profile.subscription_period_start if profile else None
else:
tier_id = "business"
usage = None
pending_tier_id = None
pending_date = None
period_start = None
tier = get_tier(tier_id, db)
all_tiers = get_all_tiers(db)
pending_tier = get_tier(pending_tier_id, db) if pending_tier_id else None
return templates.TemplateResponse(
"subscription.html",
@@ -60,5 +79,10 @@ async def my_subscription_page(request: Request, db: Session = Depends(get_db)):
"all_tiers": all_tiers,
"multi_user_enabled": settings.multi_user_enabled,
"owner_id": owner_id,
"tier_order": TIER_ORDER,
"pending_tier_id": pending_tier_id,
"pending_tier": pending_tier,
"pending_date": pending_date,
"period_start": period_start,
},
)