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:
+127
-5
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
<p class="text-gray-500 text-sm mt-1">Your current plan, usage and available upgrades.</p>
|
||||
</div>
|
||||
|
||||
<!-- Flash messages -->
|
||||
<div id="flash-container" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
{% if not multi_user_enabled %}
|
||||
<!-- Single-user notice -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-xl p-6 flex items-start gap-4">
|
||||
@@ -29,6 +32,26 @@
|
||||
|
||||
{% else %}
|
||||
|
||||
<!-- Pending-change banner -->
|
||||
{% if pending_tier_id %}
|
||||
<div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-6 flex items-start gap-3">
|
||||
<i class="fas fa-clock text-amber-500 text-xl flex-shrink-0 mt-0.5" aria-hidden="true"></i>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold text-amber-800">Pending plan change scheduled</p>
|
||||
<p class="text-amber-700 text-sm mt-1">
|
||||
Your plan will change to <strong>{{ pending_tier.name }}</strong>
|
||||
on <strong>{% if pending_date %}{{ pending_date.strftime('%B') }} {{ pending_date.day }}, {{ pending_date.year }}{% endif %}</strong>.
|
||||
You keep all current plan benefits until then.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onclick="cancelPendingChange()"
|
||||
class="ml-auto flex-shrink-0 text-sm text-amber-700 underline hover:text-amber-900 font-medium"
|
||||
aria-label="Cancel pending plan change">
|
||||
Cancel change
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Current plan card -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
|
||||
@@ -53,6 +76,12 @@
|
||||
{% else %}
|
||||
<p class="mt-3 text-lg font-bold text-gray-500">Free</p>
|
||||
{% endif %}
|
||||
{% if period_start %}
|
||||
<p class="text-xs text-gray-400 mt-2">
|
||||
<i class="fas fa-calendar-alt mr-1" aria-hidden="true"></i>
|
||||
Since {{ period_start.strftime('%b') }} {{ period_start.day }}, {{ period_start.year }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Usage this period -->
|
||||
@@ -143,67 +172,107 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Upgrade options (only show tiers above current) -->
|
||||
{% set tier_order = ['free', 'starter', 'professional', 'business'] %}
|
||||
{% set current_index = tier_order.index(tier_id) %}
|
||||
{% set upgrade_tiers = all_tiers | selectattr('id', 'ne', tier_id) | list %}
|
||||
|
||||
{% if tier_id != 'business' %}
|
||||
<!-- All available plans -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-arrow-up-right-dots text-indigo-500" aria-hidden="true"></i>
|
||||
Upgrade your plan
|
||||
<i class="fas fa-layer-group text-indigo-500" aria-hidden="true"></i>
|
||||
Available Plans
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{% for t in all_tiers %}
|
||||
{% if tier_order.index(t.id) > current_index %}
|
||||
<div class="bg-white rounded-xl border-2
|
||||
{% if t.highlight %}border-indigo-500{% else %}border-gray-200{% endif %}
|
||||
p-5 flex flex-col justify-between hover:shadow-md transition">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-bold text-gray-900">{{ t.name }}</span>
|
||||
{% if t.badge %}
|
||||
<span class="text-xs bg-indigo-100 text-indigo-700 font-semibold rounded-full px-2 py-0.5">{{ t.badge }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-gray-500 text-xs mb-3">{{ t.tagline }}</p>
|
||||
{% if t.price_monthly > 0 %}
|
||||
<p class="text-xl font-extrabold text-gray-900">${{ t.price_monthly }}<span class="text-sm font-normal text-gray-400">/mo</span></p>
|
||||
{% endif %}
|
||||
<ul class="mt-3 space-y-1.5 text-xs text-gray-600">
|
||||
{% for feature in t.features[:4] %}
|
||||
<li class="flex items-start gap-1.5">
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0" aria-hidden="true"></i>
|
||||
{{ feature }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if t.features | length > 4 %}
|
||||
<li class="text-indigo-500 font-medium">+ {{ (t.features | length) - 4 }} more features</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
{% if t.id == 'business' %}
|
||||
<a href="mailto:sales@docuelevate.io?subject=Business+Plan+Enquiry"
|
||||
class="block text-center py-2 px-4 rounded-lg bg-gray-800 text-white text-sm font-semibold hover:bg-gray-700 transition">
|
||||
Contact Sales
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/pricing"
|
||||
class="block text-center py-2 px-4 rounded-lg
|
||||
{% if t.highlight %}bg-indigo-600 hover:bg-indigo-700{% else %}bg-blue-600 hover:bg-blue-700{% endif %}
|
||||
text-white text-sm font-semibold transition">
|
||||
Upgrade to {{ t.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% set t_rank = tier_order.index(t.id) %}
|
||||
{% set current_rank = tier_order.index(tier_id) %}
|
||||
{% set is_current = (t.id == tier_id) %}
|
||||
{% set is_pending = (t.id == pending_tier_id) %}
|
||||
{% set is_upgrade = (t_rank > current_rank) %}
|
||||
{% set is_downgrade = (t_rank < current_rank) %}
|
||||
<div class="bg-white rounded-xl border-2
|
||||
{% if is_current %}border-indigo-500 ring-2 ring-indigo-200{% elif t.highlight %}border-indigo-300{% else %}border-gray-200{% endif %}
|
||||
p-5 flex flex-col justify-between hover:shadow-md transition relative">
|
||||
|
||||
{% if is_current %}
|
||||
<span class="absolute top-3 right-3 text-xs bg-indigo-100 text-indigo-700 font-semibold rounded-full px-2 py-0.5">
|
||||
Current
|
||||
</span>
|
||||
{% elif is_pending %}
|
||||
<span class="absolute top-3 right-3 text-xs bg-amber-100 text-amber-700 font-semibold rounded-full px-2 py-0.5">
|
||||
Pending
|
||||
</span>
|
||||
{% elif t.badge %}
|
||||
<span class="absolute top-3 right-3 text-xs bg-indigo-100 text-indigo-700 font-semibold rounded-full px-2 py-0.5">
|
||||
{{ t.badge }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<p class="font-bold text-gray-900 pr-16">{{ t.name }}</p>
|
||||
<p class="text-gray-500 text-xs mt-1 mb-3">{{ t.tagline }}</p>
|
||||
{% if t.price_monthly > 0 %}
|
||||
<p class="text-xl font-extrabold text-gray-900">${{ t.price_monthly }}<span class="text-sm font-normal text-gray-400">/mo</span></p>
|
||||
{% else %}
|
||||
<p class="text-xl font-extrabold text-gray-500">Free</p>
|
||||
{% endif %}
|
||||
<ul class="mt-3 space-y-1 text-xs text-gray-600">
|
||||
{% for feature in t.features[:3] %}
|
||||
<li class="flex items-start gap-1.5">
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0" aria-hidden="true"></i>
|
||||
{{ feature }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if t.features | length > 3 %}
|
||||
<li class="text-indigo-500 font-medium">+ {{ (t.features | length) - 3 }} more</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
{% if is_current and not pending_tier_id %}
|
||||
<span class="block text-center py-2 px-4 rounded-lg bg-gray-100 text-gray-400 text-sm font-semibold cursor-default">
|
||||
Your current plan
|
||||
</span>
|
||||
{% elif is_current and pending_tier_id %}
|
||||
<!-- Keep current plan (cancel pending downgrade) -->
|
||||
<button type="button"
|
||||
onclick="changePlan('{{ t.id }}')"
|
||||
class="block w-full text-center py-2 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold transition">
|
||||
Keep {{ t.name }}
|
||||
</button>
|
||||
{% elif is_upgrade %}
|
||||
{% if t.id == 'business' %}
|
||||
<a href="mailto:sales@docuelevate.io?subject=Business+Plan+Enquiry"
|
||||
class="block text-center py-2 px-4 rounded-lg bg-gray-800 hover:bg-gray-700 text-white text-sm font-semibold transition">
|
||||
Contact Sales
|
||||
</a>
|
||||
{% else %}
|
||||
<button type="button"
|
||||
onclick="changePlan('{{ t.id }}')"
|
||||
class="block w-full text-center py-2 px-4 rounded-lg bg-green-600 hover:bg-green-700 text-white text-sm font-semibold transition">
|
||||
Upgrade to {{ t.name }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% elif is_pending %}
|
||||
<button type="button"
|
||||
onclick="cancelPendingChange()"
|
||||
class="block w-full text-center py-2 px-4 rounded-lg bg-amber-100 hover:bg-amber-200 text-amber-800 text-sm font-semibold transition">
|
||||
Cancel scheduled change
|
||||
</button>
|
||||
{% else %}
|
||||
<!-- Downgrade -->
|
||||
<button type="button"
|
||||
onclick="changePlan('{{ t.id }}')"
|
||||
class="block w-full text-center py-2 px-4 rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-semibold transition">
|
||||
Downgrade to {{ t.name }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-3">
|
||||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||||
Upgrades take effect immediately. Downgrades are scheduled for the end of your current billing period.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
@@ -215,4 +284,71 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
||||
|
||||
function showFlash(message, type) {
|
||||
const container = document.getElementById('flash-container');
|
||||
let bgClass, borderClass, iconClass, textClass;
|
||||
if (type === 'success') {
|
||||
bgClass = 'bg-green-50'; borderClass = 'border-green-300';
|
||||
iconClass = 'fa-check-circle text-green-500'; textClass = 'text-green-800';
|
||||
} else if (type === 'warning') {
|
||||
bgClass = 'bg-amber-50'; borderClass = 'border-amber-300';
|
||||
iconClass = 'fa-clock text-amber-500'; textClass = 'text-amber-800';
|
||||
} else {
|
||||
bgClass = 'bg-red-50'; borderClass = 'border-red-300';
|
||||
iconClass = 'fa-exclamation-circle text-red-500'; textClass = 'text-red-800';
|
||||
}
|
||||
container.innerHTML = `
|
||||
<div class="${bgClass} border ${borderClass} rounded-xl p-4 mb-6 flex items-start gap-3" role="alert">
|
||||
<i class="fas ${iconClass} text-xl flex-shrink-0 mt-0.5" aria-hidden="true"></i>
|
||||
<p class="${textClass} text-sm">${message}</p>
|
||||
</div>`;
|
||||
container.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
async function changePlan(planId) {
|
||||
try {
|
||||
const res = await fetch('/api/subscriptions/change', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': CSRF_TOKEN,
|
||||
},
|
||||
body: JSON.stringify({ plan_id: planId, billing_cycle: 'monthly' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
showFlash(data.detail || 'An error occurred. Please try again.', 'error');
|
||||
return;
|
||||
}
|
||||
const msgType = data.immediate ? 'success' : 'warning';
|
||||
showFlash(data.message, msgType);
|
||||
// Reload after a short delay to reflect the change
|
||||
setTimeout(() => window.location.reload(), 1800);
|
||||
} catch (err) {
|
||||
showFlash('Network error. Please try again.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelPendingChange() {
|
||||
try {
|
||||
const res = await fetch('/api/subscriptions/change', {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': CSRF_TOKEN },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
showFlash(data.detail || 'An error occurred. Please try again.', 'error');
|
||||
return;
|
||||
}
|
||||
showFlash(data.message, 'success');
|
||||
setTimeout(() => window.location.reload(), 1800);
|
||||
} catch (err) {
|
||||
showFlash('Network error. Please try again.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Add pending subscription-change columns to user_profiles
|
||||
|
||||
Revision ID: 020_add_subscription_change_pending
|
||||
Revises: 019_add_is_complimentary
|
||||
Create Date: 2026-03-07
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "020_add_subscription_change_pending"
|
||||
down_revision: Union[str, None] = "019_add_is_complimentary"
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add subscription_change_pending_tier and subscription_change_pending_date columns."""
|
||||
op.add_column(
|
||||
"user_profiles",
|
||||
sa.Column("subscription_change_pending_tier", sa.String(50), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"user_profiles",
|
||||
sa.Column("subscription_change_pending_date", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove pending subscription-change columns."""
|
||||
op.drop_column("user_profiles", "subscription_change_pending_date")
|
||||
op.drop_column("user_profiles", "subscription_change_pending_tier")
|
||||
@@ -491,3 +491,372 @@ def test_list_tiers_api(client):
|
||||
assert "starter" in ids
|
||||
assert "professional" in ids
|
||||
assert "business" in ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New: subscription change management utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
from app.utils.subscription import (
|
||||
SubscriptionChangeError,
|
||||
_tier_rank,
|
||||
apply_pending_subscription_changes,
|
||||
cancel_pending_subscription_change,
|
||||
request_subscription_change,
|
||||
)
|
||||
|
||||
# ---- _tier_rank -----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tier_rank_known_tiers():
|
||||
assert _tier_rank("free") == 0
|
||||
assert _tier_rank("starter") == 1
|
||||
assert _tier_rank("professional") == 2
|
||||
assert _tier_rank("business") == 3
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tier_rank_unknown_defaults_to_zero():
|
||||
assert _tier_rank("unknown") == 0
|
||||
|
||||
|
||||
# ---- apply_pending_subscription_changes -----------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_pending_no_profile_returns_false():
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
assert apply_pending_subscription_changes(db, "nobody") is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_pending_no_pending_returns_false():
|
||||
db = MagicMock()
|
||||
profile = MagicMock()
|
||||
profile.subscription_change_pending_tier = None
|
||||
profile.subscription_change_pending_date = None
|
||||
db.query.return_value.filter.return_value.first.return_value = profile
|
||||
assert apply_pending_subscription_changes(db, "user1") is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_pending_future_date_returns_false():
|
||||
from datetime import timedelta
|
||||
|
||||
db = MagicMock()
|
||||
profile = MagicMock()
|
||||
profile.subscription_change_pending_tier = "free"
|
||||
profile.subscription_change_pending_date = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
db.query.return_value.filter.return_value.first.return_value = profile
|
||||
assert apply_pending_subscription_changes(db, "user1") is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_pending_due_date_applies_change():
|
||||
from datetime import timedelta
|
||||
|
||||
db = MagicMock()
|
||||
profile = MagicMock()
|
||||
profile.subscription_tier = "starter"
|
||||
profile.subscription_change_pending_tier = "free"
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
profile.subscription_change_pending_date = past
|
||||
db.query.return_value.filter.return_value.first.return_value = profile
|
||||
|
||||
result = apply_pending_subscription_changes(db, "user1")
|
||||
|
||||
assert result is True
|
||||
assert profile.subscription_tier == "free"
|
||||
assert profile.subscription_period_start == past
|
||||
assert profile.subscription_change_pending_tier is None
|
||||
assert profile.subscription_change_pending_date is None
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_pending_commit_failure_returns_false():
|
||||
from datetime import timedelta
|
||||
|
||||
db = MagicMock()
|
||||
profile = MagicMock()
|
||||
profile.subscription_tier = "starter"
|
||||
profile.subscription_change_pending_tier = "free"
|
||||
profile.subscription_change_pending_date = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
db.query.return_value.filter.return_value.first.return_value = profile
|
||||
db.commit.side_effect = Exception("DB error")
|
||||
|
||||
result = apply_pending_subscription_changes(db, "user1")
|
||||
|
||||
assert result is False
|
||||
db.rollback.assert_called_once()
|
||||
|
||||
|
||||
# ---- request_subscription_change -----------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_request_change_invalid_plan_raises():
|
||||
db = MagicMock()
|
||||
with patch("app.utils.subscription.get_all_tiers", return_value=[{"id": "free"}, {"id": "starter"}]):
|
||||
with pytest.raises(SubscriptionChangeError, match="Unknown subscription plan"):
|
||||
request_subscription_change(db, "user1", "galaxy_tier")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_request_change_same_plan_no_pending_raises():
|
||||
"""Requesting the active plan when no pending change exists should raise."""
|
||||
db = MagicMock()
|
||||
profile = MagicMock()
|
||||
profile.subscription_tier = "starter"
|
||||
profile.subscription_change_pending_tier = None
|
||||
db.query.return_value.filter.return_value.first.return_value = profile
|
||||
|
||||
with patch("app.utils.subscription.get_all_tiers", return_value=[{"id": t} for t in TIER_ORDER]):
|
||||
with pytest.raises(SubscriptionChangeError, match="already on this plan"):
|
||||
request_subscription_change(db, "user1", "starter")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_request_change_same_plan_cancels_pending():
|
||||
"""Requesting the active plan when a downgrade is pending should cancel it."""
|
||||
db = MagicMock()
|
||||
profile = MagicMock()
|
||||
profile.subscription_tier = "starter"
|
||||
profile.subscription_change_pending_tier = "free"
|
||||
db.query.return_value.filter.return_value.first.return_value = profile
|
||||
|
||||
with patch("app.utils.subscription.get_all_tiers", return_value=[{"id": t} for t in TIER_ORDER]):
|
||||
result = request_subscription_change(db, "user1", "starter")
|
||||
|
||||
assert result["immediate"] is True
|
||||
assert result["new_tier"] == "starter"
|
||||
assert "cancelled" in result["message"].lower()
|
||||
assert profile.subscription_change_pending_tier is None
|
||||
assert profile.subscription_change_pending_date is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_request_upgrade_is_immediate():
|
||||
"""Upgrading to a higher plan should take effect immediately."""
|
||||
|
||||
db = MagicMock()
|
||||
profile = MagicMock()
|
||||
profile.subscription_tier = "free"
|
||||
profile.subscription_change_pending_tier = None
|
||||
db.query.return_value.filter.return_value.first.return_value = profile
|
||||
|
||||
with (
|
||||
patch("app.utils.subscription.get_all_tiers", return_value=[{"id": t} for t in TIER_ORDER]),
|
||||
patch("app.utils.subscription.get_tier", return_value={"id": "starter", "name": "Starter"}),
|
||||
):
|
||||
result = request_subscription_change(db, "user1", "starter")
|
||||
|
||||
assert result["immediate"] is True
|
||||
assert result["new_tier"] == "starter"
|
||||
assert profile.subscription_tier == "starter"
|
||||
# Period start should be set to approximately now
|
||||
assert profile.subscription_period_start is not None
|
||||
# Pending should be cleared
|
||||
assert profile.subscription_change_pending_tier is None
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_request_downgrade_schedules_for_future():
|
||||
"""Downgrade request within the billing period should be scheduled."""
|
||||
from datetime import timedelta
|
||||
|
||||
db = MagicMock()
|
||||
profile = MagicMock()
|
||||
profile.subscription_tier = "professional"
|
||||
profile.subscription_change_pending_tier = None
|
||||
# Period started 10 days ago — we're mid-month
|
||||
profile.subscription_period_start = datetime.now(timezone.utc) - timedelta(days=10)
|
||||
|
||||
db.query.return_value.filter.return_value.first.return_value = profile
|
||||
|
||||
with (
|
||||
patch("app.utils.subscription.get_all_tiers", return_value=[{"id": t} for t in TIER_ORDER]),
|
||||
patch("app.utils.subscription.get_tier", return_value={"id": "starter", "name": "Starter"}),
|
||||
):
|
||||
result = request_subscription_change(db, "user1", "starter")
|
||||
|
||||
assert result["immediate"] is False
|
||||
assert result["effective_date"] is not None
|
||||
# The effective date should be in the future
|
||||
effective = datetime.fromisoformat(result["effective_date"])
|
||||
assert effective > datetime.now(timezone.utc)
|
||||
assert profile.subscription_change_pending_tier == "starter"
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_request_downgrade_immediate_when_period_elapsed():
|
||||
"""Downgrade request after billing period elapsed should apply immediately."""
|
||||
from datetime import timedelta
|
||||
|
||||
db = MagicMock()
|
||||
profile = MagicMock()
|
||||
profile.subscription_tier = "professional"
|
||||
profile.subscription_change_pending_tier = None
|
||||
# Period started more than 1 month ago
|
||||
profile.subscription_period_start = datetime.now(timezone.utc) - timedelta(days=40)
|
||||
|
||||
db.query.return_value.filter.return_value.first.return_value = profile
|
||||
|
||||
with (
|
||||
patch("app.utils.subscription.get_all_tiers", return_value=[{"id": t} for t in TIER_ORDER]),
|
||||
patch("app.utils.subscription.get_tier", return_value={"id": "starter", "name": "Starter"}),
|
||||
):
|
||||
result = request_subscription_change(db, "user1", "starter")
|
||||
|
||||
assert result["immediate"] is True
|
||||
assert profile.subscription_tier == "starter"
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
# ---- cancel_pending_subscription_change ----------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cancel_pending_no_pending_returns_false():
|
||||
db = MagicMock()
|
||||
profile = MagicMock()
|
||||
profile.subscription_change_pending_tier = None
|
||||
db.query.return_value.filter.return_value.first.return_value = profile
|
||||
assert cancel_pending_subscription_change(db, "user1") is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cancel_pending_clears_fields():
|
||||
db = MagicMock()
|
||||
profile = MagicMock()
|
||||
profile.subscription_change_pending_tier = "free"
|
||||
db.query.return_value.filter.return_value.first.return_value = profile
|
||||
|
||||
result = cancel_pending_subscription_change(db, "user1")
|
||||
|
||||
assert result is True
|
||||
assert profile.subscription_change_pending_tier is None
|
||||
assert profile.subscription_change_pending_date is None
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cancel_pending_no_profile_returns_false():
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
assert cancel_pending_subscription_change(db, "ghost") is False
|
||||
|
||||
|
||||
# ---- API: POST /api/subscriptions/change ---------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_api_change_subscription_upgrade(db_session):
|
||||
"""POST /api/subscriptions/change should immediately upgrade the plan."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.database import get_db
|
||||
from app.main import app
|
||||
from app.models import UserProfile
|
||||
from app.utils.subscription import seed_default_plans
|
||||
|
||||
# Create a user profile on the free tier
|
||||
profile = UserProfile(user_id="testuser", subscription_tier="free")
|
||||
db_session.add(profile)
|
||||
db_session.commit()
|
||||
seed_default_plans(db_session)
|
||||
|
||||
def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
with TestClient(app, base_url="http://localhost") as tc:
|
||||
with (
|
||||
patch("app.api.subscriptions._require_authenticated", return_value="testuser"),
|
||||
patch("app.config.settings.multi_user_enabled", True),
|
||||
):
|
||||
resp = tc.post(
|
||||
"/api/subscriptions/change",
|
||||
json={"plan_id": "starter", "billing_cycle": "monthly"},
|
||||
)
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["immediate"] is True
|
||||
assert data["new_tier"] == "starter"
|
||||
|
||||
db_session.refresh(profile)
|
||||
assert profile.subscription_tier == "starter"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_api_cancel_pending_change_no_pending_returns_404(db_session):
|
||||
"""DELETE /api/subscriptions/change should return 404 if nothing is pending."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.database import get_db
|
||||
from app.main import app
|
||||
from app.models import UserProfile
|
||||
|
||||
profile = UserProfile(user_id="testuser2", subscription_tier="starter")
|
||||
db_session.add(profile)
|
||||
db_session.commit()
|
||||
|
||||
def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
with TestClient(app, base_url="http://localhost") as tc:
|
||||
with (
|
||||
patch("app.api.subscriptions._require_authenticated", return_value="testuser2"),
|
||||
patch("app.config.settings.multi_user_enabled", True),
|
||||
):
|
||||
resp = tc.delete("/api/subscriptions/change")
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_api_cancel_pending_change_success(db_session):
|
||||
"""DELETE /api/subscriptions/change should cancel a pending downgrade."""
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.database import get_db
|
||||
from app.main import app
|
||||
from app.models import UserProfile
|
||||
|
||||
profile = UserProfile(
|
||||
user_id="testuser3",
|
||||
subscription_tier="starter",
|
||||
subscription_change_pending_tier="free",
|
||||
subscription_change_pending_date=datetime.now(timezone.utc) + timedelta(days=20),
|
||||
)
|
||||
db_session.add(profile)
|
||||
db_session.commit()
|
||||
|
||||
def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
with TestClient(app, base_url="http://localhost") as tc:
|
||||
with (
|
||||
patch("app.api.subscriptions._require_authenticated", return_value="testuser3"),
|
||||
patch("app.config.settings.multi_user_enabled", True),
|
||||
):
|
||||
resp = tc.delete("/api/subscriptions/change")
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["cancelled"] is True
|
||||
|
||||
db_session.refresh(profile)
|
||||
assert profile.subscription_change_pending_tier is None
|
||||
|
||||
Reference in New Issue
Block a user