diff --git a/app/api/subscriptions.py b/app/api/subscriptions.py index 031d5527..572ba48b 100644 --- a/app/api/subscriptions.py +++ b/app/api/subscriptions.py @@ -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).""" diff --git a/app/celery_worker.py b/app/celery_worker.py index 509f6967..ddf83efe 100644 --- a/app/celery_worker.py +++ b/app/celery_worker.py @@ -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 diff --git a/app/models.py b/app/models.py index 1f160041..3f111091 100644 --- a/app/models.py +++ b/app/models.py @@ -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 019) + # 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) + # Onboarding tracking (added in migration 017) onboarding_completed = Column(Boolean, nullable=False, default=False, server_default="0") onboarding_completed_at = Column(DateTime(timezone=True), nullable=True) diff --git a/app/tasks/subscription_tasks.py b/app/tasks/subscription_tasks.py new file mode 100644 index 00000000..3ad276ea --- /dev/null +++ b/app/tasks/subscription_tasks.py @@ -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": , "checked": }``. + """ + from app.database import SessionLocal + from app.models import UserProfile + from app.utils.subscription import apply_pending_subscription_changes + + db = SessionLocal() + applied = 0 + checked = 0 + 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} diff --git a/app/utils/subscription.py b/app/utils/subscription.py index 6f7ea841..3cadfdc6 100644 --- a/app/utils/subscription.py +++ b/app/utils/subscription.py @@ -498,3 +498,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 %-d, %Y')}. " + "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 diff --git a/app/views/subscriptions.py b/app/views/subscriptions.py index f30e8837..ee3e59cb 100644 --- a/app/views/subscriptions.py +++ b/app/views/subscriptions.py @@ -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, }, ) diff --git a/frontend/templates/subscription.html b/frontend/templates/subscription.html index ef9a476b..df9e4d93 100644 --- a/frontend/templates/subscription.html +++ b/frontend/templates/subscription.html @@ -13,6 +13,9 @@

Your current plan, usage and available upgrades.

+ +
+ {% if not multi_user_enabled %}
@@ -29,6 +32,26 @@ {% else %} + + {% if pending_tier_id %} +
+ +
+

Pending plan change scheduled

+

+ Your plan will change to {{ pending_tier.name }} + on {{ pending_date | string | truncate(10, True, '') }}. + You keep all current plan benefits until then. +

+
+ +
+ {% endif %} +
@@ -53,6 +76,12 @@ {% else %}

Free

{% endif %} + {% if period_start %} +

+ + Since {{ period_start.strftime('%b %-d, %Y') }} +

+ {% endif %}
@@ -143,67 +172,107 @@
- - {% 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' %} +

- - Upgrade your plan + + Available Plans

-
+
{% for t in all_tiers %} - {% if tier_order.index(t.id) > current_index %} -
-
-
- {{ t.name }} - {% if t.badge %} - {{ t.badge }} - {% endif %} -
-

{{ t.tagline }}

- {% if t.price_monthly > 0 %} -

${{ t.price_monthly }}/mo

- {% endif %} -
    - {% for feature in t.features[:4] %} -
  • - - {{ feature }} -
  • - {% endfor %} - {% if t.features | length > 4 %} -
  • + {{ (t.features | length) - 4 }} more features
  • - {% endif %} -
-
-
- {% if t.id == 'business' %} - - Contact Sales - - {% else %} - - Upgrade to {{ t.name }} - - {% endif %} -
-
+ {% 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) %} +
+ + {% if is_current %} + + Current + + {% elif is_pending %} + + Pending + + {% elif t.badge %} + + {{ t.badge }} + {% endif %} + +
+

{{ t.name }}

+

{{ t.tagline }}

+ {% if t.price_monthly > 0 %} +

${{ t.price_monthly }}/mo

+ {% else %} +

Free

+ {% endif %} +
    + {% for feature in t.features[:3] %} +
  • + + {{ feature }} +
  • + {% endfor %} + {% if t.features | length > 3 %} +
  • + {{ (t.features | length) - 3 }} more
  • + {% endif %} +
+
+ +
+ {% if is_current and not pending_tier_id %} + + Your current plan + + {% elif is_current and pending_tier_id %} + + + {% elif is_upgrade %} + {% if t.id == 'business' %} + + Contact Sales + + {% else %} + + {% endif %} + {% elif is_pending %} + + {% else %} + + + {% endif %} +
+
{% endfor %}
+

+ + Upgrades take effect immediately. Downgrades are scheduled for the end of your current billing period. +

- {% endif %} {% endif %} @@ -215,4 +284,62 @@
+ + {% endblock %} diff --git a/migrations/versions/019_add_subscription_change_pending.py b/migrations/versions/019_add_subscription_change_pending.py new file mode 100644 index 00000000..f9ee935f --- /dev/null +++ b/migrations/versions/019_add_subscription_change_pending.py @@ -0,0 +1,33 @@ +"""Add pending subscription-change columns to user_profiles + +Revision ID: 019_add_subscription_change_pending +Revises: 018_add_local_users_and_billing +Create Date: 2026-03-07 +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "019_add_subscription_change_pending" +down_revision: Union[str, None] = "018_add_local_users_and_billing" +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") diff --git a/tests/test_subscription.py b/tests/test_subscription.py index 2f3a66c1..f8f71af5 100644 --- a/tests/test_subscription.py +++ b/tests/test_subscription.py @@ -485,3 +485,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