CalWizz Subscription Code Audit
Date: 2026-02-21
Auditor: Clawd (subagent)
Files reviewed: app.py, stripe_client.py, subscription_manager.py, decorators.py, models.py
Executive Summary
Current model: 14-day trial → paywall (hard lockout)
Finding: When trial expires without payment, users are downgraded to “free” tier but free tier has no features. This is a hard lockout, not a freemium fallback.
Gap vs. freemium spec: The freemium spec proposes a functional free tier with basic analytics. Current implementation has no such tier—it’s trial or nothing.
Current User Journey
1. New Signup
User signs up via Google OAuth
↓
subscription_tier = 'free'
subscription_status = 'active'
has_used_trial = False
↓
Can view dashboard with SAMPLE data only
Cannot sync their own calendar (requires 'basic' tier)
2. Start Trial
User clicks "Start Trial" → POST /start-trial
↓
SubscriptionManager.start_trial(user, trial_days=7) # Note: 7 days, not 14!
↓
subscription_tier = 'basic'
subscription_status = 'active'
has_used_trial = True
trial_end_date = now + 7 days
↓
Full access to all Basic features
3. Trial Expires (NO PAYMENT)
User accesses a protected route (e.g., /sync-calendar)
↓
subscription_required('basic') decorator calls:
has_subscription_access() → SubscriptionManager.has_active_subscription()
↓
Check: trial_end_date < now AND no stripe_subscription_id?
YES → downgrade_to_free(user)
↓
subscription_tier = 'free'
subscription_status = 'active' # Still "active" but on free tier
↓
User blocked from ALL paid features
Redirect to /upgrade page
4. Trial Expires (WITH PAYMENT)
User paid during trial → checkout.session.completed webhook
↓
stripe_subscription_id is set
↓
has_active_subscription() returns True (subscription_status = 'active')
↓
Full access continues
Feature Gates (What’s Blocked)
Routes with @subscription_required('basic'):
| Route | Feature | What Happens on Free |
|---|---|---|
/sync-calendar | Sync Google Calendar | Redirect to /upgrade |
/api/sync-calendar | API calendar sync | 403 JSON error |
/api/export-csv | Export to CSV | 403 JSON error |
/api/export-pdf | Export to PDF | 403 JSON error |
/meeting-exclusions | Exclude meetings | Redirect to /upgrade |
/api/meeting-exclusions | Save exclusions | 403 JSON error |
What Free Users CAN Do:
- View dashboard with sample data (not their own calendar)
- Log in/out
- View account page
- See upgrade page
What Free Users CANNOT Do:
- Sync their actual calendar data
- View their own analytics
- Export anything
- Configure meeting exclusions
Code Flow Details
SubscriptionManager.has_active_subscription(user) (subscription_manager.py:20-41)
def has_active_subscription(user):
if user.subscription_tier == 'free':
return False # ← Immediate rejection for free tier
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, no payment → downgrade
SubscriptionManager.downgrade_to_free(user)
return False
return True
return Falseget_feature_limits(user) (subscription_manager.py:181-231)
free_limits = {
'calendar_sync': False, # ← No sync
'export_csv': False, # ← No export
'export_pdf': False, # ← No export
'advanced_analytics': False, # ← No analytics
'weekly_email': False, # ← No emails
'max_events': 100, # ← Irrelevant (can't load events anyway)
'data_retention_days': 30 # ← Irrelevant
}Gaps vs. Freemium Spec
| Freemium Spec Proposes | Current Reality |
|---|---|
| Free users get 1 calendar, last 30 days | Free users get sample data only |
| Free users see basic stats (hours, count) | Free users see sample data stats |
| Free users see “by day of week” breakdown | Works on sample data, not their own |
| Schedule Health Score gated (soft gate) | No soft gates exist |
| Export gated with “Unlock with Pro” prompt | Hard 403 error, no prompt |
| Second calendar prompts upgrade | No prompt, just blocked |
Key Missing Concept: The spec envisions a functional free tier where users see their own (limited) data. Current implementation shows sample data to free users, not their own calendar.
Critical Issues
1. No Graceful Free Tier
Problem: Free tier = sample data only. Users can’t see ANY of their own analytics.
Impact: Zero value for non-paying users, no conversion hook.
Fix Required: Allow free users to sync and view 30 days of their own data.
2. Trial Length Mismatch
Problem: Code uses trial_days=7 but marketing says “14-day trial”.
Location: SubscriptionManager.start_trial(user, trial_days=7) and /start-trial route.
Fix Required: Standardize on 14 days (or update marketing).
3. No Soft Gates / Upgrade Prompts
Problem: Blocked features return hard 403 or redirect. No “Unlock with Pro” prompts.
Impact: Users don’t know what they’re missing.
Fix Required: Add soft gates that show grayed-out features with upgrade prompts.
4. No Trial Expiry Notification
Problem: User discovers trial ended only when blocked.
Impact: Surprise lockout, bad UX.
Fix Required: Email warning at 3 days, 1 day, and expiry.
Recommendations
Phase 1: Fix the Basics (Quick Wins)
- Standardize trial to 14 days - Update
start_trial(trial_days=14) - Add trial expiry emails - Use existing email_scheduler infrastructure
- Show “trial ending” banner - In dashboard when < 3 days left
Phase 2: Implement Free Tier (Per Spec)
- Allow free users to sync - Remove
@subscription_required('basic')from/sync-calendarwith limits - Add 30-day date restriction for free - Filter events in
get_user_parser()for free tier - Limit free tier features - Basic stats only, no advanced charts
- Add soft gates in UI - Show locked features as grayed with upgrade prompts
Phase 3: Conversion Optimization
- Track feature gate hits - Log when free users hit limits (analytics)
- A/B test gate placements - Which prompts convert best
- Add “why upgrade” tooltips - On locked features
Proposed Code Changes
1. Update trial length (subscription_manager.py)
- def start_trial(user, trial_days=7):
+ def start_trial(user, trial_days=14):2. Allow limited sync for free tier (app.py)
# New decorator or modified logic
def subscription_required_or_limited(tier='basic'):
"""Allow free users with limited data, or full access for paid."""
# If free tier, apply 30-day limit to query
# If basic tier, no limit
pass3. Add feature flag for free tier limits (decorators.py)
def get_date_range_limit(user):
"""Return date range limit based on subscription."""
if not user or user.subscription_tier == 'free':
return timedelta(days=30) # Free: last 30 days only
return None # Paid: unlimitedFiles to Modify
| File | Changes Needed |
|---|---|
subscription_manager.py | Fix trial days, add free tier feature checks |
decorators.py | Add soft gate logic, free tier limits |
app.py | Update /sync-calendar to allow limited free sync |
templates/index.html | Add soft gate UI for locked features |
email_scheduler.py | Add trial expiry warning emails |
Next Steps
- Adam to decide: Keep 7-day trial or move to 14-day?
- Adam to decide: Implement freemium spec or stay with trial-only model?
- If freemium: Create detailed implementation plan for free tier limits
- If trial-only: At minimum, add expiry warnings and clearer upgrade prompts
Audit complete. No code changes made—awaiting review.