feat(subscriptions): add subscription change management with upgrade/downgrade scheduling

- Add subscription_change_pending_tier and subscription_change_pending_date fields to UserProfile
- Create migration 019_add_subscription_change_pending
- Add apply_pending_subscription_changes(), request_subscription_change(), cancel_pending_subscription_change() utilities
- Add POST /api/subscriptions/change and DELETE /api/subscriptions/change endpoints
- Update GET /api/subscriptions/my to apply pending changes and return pending change info
- Update subscription view to apply pending changes and pass period_start + pending info
- Update subscription.html: per-tier action buttons (upgrade/downgrade/cancel), pending-change banner, period start date
- Add Celery daily task apply_pending_subscription_changes_all at 00:05 UTC
- Add 19 new tests covering all new utility functions and API endpoints

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-07 20:14:12 +00:00
parent 0599cf86b3
commit 231f983429
9 changed files with 1074 additions and 59 deletions
+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
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}