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:
- When a user tries to access a
@subscription_required('basic')route - The decorator calls
has_subscription_access(user, 'basic')indecorators.py:69 - Which calls
SubscriptionManager.has_active_subscription(user)insubscription_manager.py:17 - This method checks:
- Is
subscription_tiernot ‘free’? ✓ - Is
subscription_status‘active’? ✓ - Does
trial_end_dateexist and is it in the past? ✓ - Does user have no
stripe_subscription_id? ✓
- Is
- If all true → calls
downgrade_to_free(user)which sets:subscription_tier = 'free'subscription_status = 'active'(user account remains active)
- 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 FalseQuestion 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:
- Efficiency: No need to query all users daily
- Immediacy: Users are downgraded exactly when needed, not with a delay
- Simplicity: No race conditions between scheduled job and user access
- Fewer moving parts: Less infrastructure to maintain
Question 3: What features are restricted for free tier vs Plus tier?
Free Tier Features
| Feature | Access |
|---|---|
| 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
| Feature | Access |
|---|---|
| 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'):
| Route | File:Line |
|---|---|
/sync-calendar | app.py:771 |
/api/sync-calendar | app.py:849 |
/api/export-csv | app.py:1099 |
/api/export-pdf | app.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:
-
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() -
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
-
No lockout mechanism exists — there’s no code that blocks free users from logging in or viewing the app
-
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)inmodels.py:78-94— property-based, no side effectsSubscriptionManager.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.