CalWizz Free Tier Logic - Code Review

Date: 2026-02-20
Reviewed by: Subagent
Files analyzed: models.py, app.py, decorators.py, subscription_manager.py, email_scheduler.py


Executive Summary

✅ The free tier logic is working correctly. The implementation uses a lazy-evaluation pattern where trial expiration checks happen on-demand when users try to access paid features, rather than via a scheduled background job. This is an efficient and correct approach.


Question 1: What happens when a user’s trial expires?

Answer: User is downgraded to ‘free’ tier automatically on next access

How it works:

  1. When a user tries to access a @subscription_required('basic') route
  2. The decorator calls has_subscription_access(user, 'basic') in decorators.py:69
  3. Which calls SubscriptionManager.has_active_subscription(user) in subscription_manager.py:17
  4. This method checks:
    • Is subscription_tier not ‘free’? ✓
    • Is subscription_status ‘active’? ✓
    • Does trial_end_date exist and is it in the past? ✓
    • Does user have no stripe_subscription_id? ✓
  5. If all true → calls downgrade_to_free(user) which sets:
    • subscription_tier = 'free'
    • subscription_status = 'active' (user account remains active)
  6. Returns False → user is redirected to /upgrade

Code reference (subscription_manager.py:17-36):

@staticmethod
def has_active_subscription(user):
    if user.subscription_tier == 'free':
        return False
 
    if user.subscription_status == 'active':
        if user.trial_end_date:
            if datetime.utcnow() > user.trial_end_date:
                if not user.stripe_subscription_id:
                    # Trial expired, downgrade to free
                    SubscriptionManager.downgrade_to_free(user)
                    return False
        return True
    return False

Question 2: Is there a background job that downgrades expired trials?

Answer: No — and that’s intentional and correct

What the email scheduler does:

  • Runs daily at 9:00 AM UTC (email_scheduler.py:36)
  • Sends reminder emails at 3 days and 0 days before expiration
  • Does NOT modify subscription status or tier

Why lazy evaluation is correct:

  1. Efficiency: No need to query all users daily
  2. Immediacy: Users are downgraded exactly when needed, not with a delay
  3. Simplicity: No race conditions between scheduled job and user access
  4. Fewer moving parts: Less infrastructure to maintain

Question 3: What features are restricted for free tier vs Plus tier?

Free Tier Features

FeatureAccess
Login / Account✅ Yes
View Dashboard (sample data)✅ Yes
Connect Google OAuth✅ Yes
Calendar Sync❌ No
CSV Export❌ No
PDF Export❌ No
Historical Data Fetch❌ No
Weekly Email Summaries❌ No

Plus (Basic) Tier Features

FeatureAccess
Everything above✅ Yes
Calendar Sync✅ Yes
CSV Export✅ Yes
PDF Export✅ Yes
Historical Data Fetch✅ Yes
Weekly Email Summaries✅ Yes

Code reference — routes with @subscription_required('basic'):

RouteFile:Line
/sync-calendarapp.py:771
/api/sync-calendarapp.py:849
/api/export-csvapp.py:1099
/api/export-pdfapp.py:1159

Code reference — get_feature_limits() in subscription_manager.py:179-216:

free_limits = {
    'calendar_sync': False,
    'export_csv': False,
    'export_pdf': False,
    'advanced_analytics': False,
    'weekly_email': False,
    'api_access': False,
    'max_events': 100,
    'data_retention_days': 30
}
 
basic_limits = {
    'calendar_sync': True,
    'export_csv': True,
    'export_pdf': True,
    'advanced_analytics': True,
    'weekly_email': True,
    'api_access': False,
    'max_events': 10000,
    'data_retention_days': 365
}

Question 4: Is the free tier actually “free forever”?

Answer: YES — Free tier is permanent and functional

Evidence:

  1. Account stays active (subscription_manager.py:76-85):

    def downgrade_to_free(user):
        db_user.subscription_tier = 'free'
        db_user.subscription_status = 'active'  # <-- stays active!
        db.commit()
  2. Free users can still:

    • Log in anytime
    • View the dashboard with sample data
    • Connect Google OAuth (just can’t sync)
    • See what features they’re missing
    • Upgrade when ready
  3. No lockout mechanism exists — there’s no code that blocks free users from logging in or viewing the app

  4. Sample data fallback (app.py:150-168):

    def get_user_parser(user):
        if user is None:
            parser = CalendarParser('google_calendar_sample.json').load()
            return parser, 'sample'
        # ... if no cached calendar data ...
        parser = CalendarParser('google_calendar_sample.json').load()
        return parser, 'sample'

Minor Observations (Not Bugs)

1. Dual Access Check Methods

There are two ways to check feature access:

  • User.has_access_to(feature_tier) in models.py:78-94 — property-based, no side effects
  • SubscriptionManager.has_active_subscription(user) — performs downgrade if needed

Status: Not a problem. The decorators correctly use SubscriptionManager, and the User method isn’t used in production code paths.

2. Cached User Object After Downgrade

When has_active_subscription() calls downgrade_to_free(), the user object passed to the route may still show subscription_tier='basic' (stale data).

Status: Not a problem. The method returns False regardless, so access is correctly denied. The DB is updated and subsequent requests will have correct data.

3. Trial Start Sets Tier to ‘basic’

When a user starts a trial (User.start_trial() or SubscriptionManager.start_trial()), their subscription_tier is immediately set to 'basic'.

Status: Correct behavior. Trial users get full Basic access during trial period.


Conclusion

The free tier implementation is correct and complete.

  • ✅ Trial expiration triggers automatic downgrade on access
  • ✅ Free tier is truly free forever (no lockout)
  • ✅ Feature gating is properly enforced via decorators
  • ✅ Sample data provides value to free users
  • ✅ Clear upgrade path exists

No fixes needed.