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

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