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'):

RouteFeatureWhat Happens on Free
/sync-calendarSync Google CalendarRedirect to /upgrade
/api/sync-calendarAPI calendar sync403 JSON error
/api/export-csvExport to CSV403 JSON error
/api/export-pdfExport to PDF403 JSON error
/meeting-exclusionsExclude meetingsRedirect to /upgrade
/api/meeting-exclusionsSave exclusions403 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 False

get_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 ProposesCurrent Reality
Free users get 1 calendar, last 30 daysFree 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” breakdownWorks on sample data, not their own
Schedule Health Score gated (soft gate)No soft gates exist
Export gated with “Unlock with Pro” promptHard 403 error, no prompt
Second calendar prompts upgradeNo 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)

  1. Standardize trial to 14 days - Update start_trial(trial_days=14)
  2. Add trial expiry emails - Use existing email_scheduler infrastructure
  3. Show “trial ending” banner - In dashboard when < 3 days left

Phase 2: Implement Free Tier (Per Spec)

  1. Allow free users to sync - Remove @subscription_required('basic') from /sync-calendar with limits
  2. Add 30-day date restriction for free - Filter events in get_user_parser() for free tier
  3. Limit free tier features - Basic stats only, no advanced charts
  4. Add soft gates in UI - Show locked features as grayed with upgrade prompts

Phase 3: Conversion Optimization

  1. Track feature gate hits - Log when free users hit limits (analytics)
  2. A/B test gate placements - Which prompts convert best
  3. 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
    pass

3. 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: unlimited

Files to Modify

FileChanges Needed
subscription_manager.pyFix trial days, add free tier feature checks
decorators.pyAdd soft gate logic, free tier limits
app.pyUpdate /sync-calendar to allow limited free sync
templates/index.htmlAdd soft gate UI for locked features
email_scheduler.pyAdd trial expiry warning emails

Next Steps

  1. Adam to decide: Keep 7-day trial or move to 14-day?
  2. Adam to decide: Implement freemium spec or stay with trial-only model?
  3. If freemium: Create detailed implementation plan for free tier limits
  4. If trial-only: At minimum, add expiry warnings and clearer upgrade prompts

Audit complete. No code changes made—awaiting review.