-- ============================================================================ -- Mitai Jinkendo v9c: Subscription & Coupon System Migration -- ============================================================================ -- Created: 2026-03-19 -- Purpose: Add flexible tier system with Feature-Registry Pattern -- -- Tables added: -- 1. app_settings - Global configuration -- 2. tiers - Subscription tiers (simplified) -- 3. features - Feature registry (all limitable features) -- 4. tier_limits - Tier x Feature matrix -- 5. user_feature_restrictions - Individual user overrides -- 6. user_feature_usage - Usage tracking -- 7. coupons - Coupon management -- 8. coupon_redemptions - Redemption history -- 9. access_grants - Time-limited access grants -- 10. user_activity_log - Activity tracking -- 11. user_stats - Aggregated statistics -- -- Feature-Registry Pattern: -- Instead of hardcoded columns (max_weight_entries, max_ai_calls), -- all limits are defined in features table and configured via tier_limits. -- This allows adding new limitable features without schema changes. -- ============================================================================ -- ============================================================================ -- 1. app_settings - Global configuration -- ============================================================================ CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, description TEXT, updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- ============================================================================ -- 2. tiers - Subscription tiers (simplified) -- ============================================================================ CREATE TABLE IF NOT EXISTS tiers ( id TEXT PRIMARY KEY, -- 'free', 'basic', 'premium', 'selfhosted' name TEXT NOT NULL, -- Display name description TEXT, -- Marketing description price_monthly_cents INTEGER, -- NULL for free/selfhosted price_yearly_cents INTEGER, -- NULL for free/selfhosted stripe_price_id_monthly TEXT, -- Stripe Price ID (for v9d) stripe_price_id_yearly TEXT, -- Stripe Price ID (for v9d) active BOOLEAN DEFAULT true, -- Can new users subscribe? sort_order INTEGER DEFAULT 0, created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- ============================================================================ -- 3. features - Feature registry (all limitable features) -- ============================================================================ CREATE TABLE IF NOT EXISTS features ( id TEXT PRIMARY KEY, -- 'weight_entries', 'ai_calls', 'photos', etc. name TEXT NOT NULL, -- Display name description TEXT, -- What is this feature? category TEXT, -- 'data', 'ai', 'export', 'integration' limit_type TEXT DEFAULT 'count', -- 'count', 'boolean', 'quota' reset_period TEXT DEFAULT 'never', -- 'never', 'monthly', 'daily' default_limit INTEGER, -- Fallback if no tier_limit defined active BOOLEAN DEFAULT true, -- Is this feature currently used? created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- ============================================================================ -- 4. tier_limits - Tier x Feature matrix -- ============================================================================ CREATE TABLE IF NOT EXISTS tier_limits ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tier_id TEXT NOT NULL REFERENCES tiers(id) ON DELETE CASCADE, feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, limit_value INTEGER, -- NULL = unlimited, 0 = disabled created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(tier_id, feature_id) ); -- ============================================================================ -- 5. user_feature_restrictions - Individual user overrides -- ============================================================================ CREATE TABLE IF NOT EXISTS user_feature_restrictions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, limit_value INTEGER, -- NULL = unlimited, 0 = disabled reason TEXT, -- Why was this override applied? created_by UUID, -- Admin profile_id created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(profile_id, feature_id) ); -- ============================================================================ -- 6. user_feature_usage - Usage tracking -- ============================================================================ CREATE TABLE IF NOT EXISTS user_feature_usage ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, usage_count INTEGER DEFAULT 0, reset_at TIMESTAMP, -- When does this counter reset? created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(profile_id, feature_id) ); -- ============================================================================ -- 7. coupons - Coupon management -- ============================================================================ CREATE TABLE IF NOT EXISTS coupons ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), code TEXT UNIQUE NOT NULL, type TEXT NOT NULL, -- 'single_use', 'period', 'wellpass' tier_id TEXT REFERENCES tiers(id) ON DELETE SET NULL, duration_days INTEGER, -- For period/wellpass coupons max_redemptions INTEGER, -- NULL = unlimited redemption_count INTEGER DEFAULT 0, valid_from TIMESTAMP, valid_until TIMESTAMP, active BOOLEAN DEFAULT true, created_by UUID, -- Admin profile_id description TEXT, -- Internal note created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- ============================================================================ -- 8. coupon_redemptions - Redemption history -- ============================================================================ CREATE TABLE IF NOT EXISTS coupon_redemptions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE, profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, redeemed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, access_grant_id UUID, -- FK to access_grants (created as result) UNIQUE(coupon_id, profile_id) -- One redemption per user per coupon ); -- ============================================================================ -- 9. access_grants - Time-limited access grants -- ============================================================================ CREATE TABLE IF NOT EXISTS access_grants ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, tier_id TEXT NOT NULL REFERENCES tiers(id) ON DELETE CASCADE, granted_by TEXT, -- 'coupon', 'admin', 'trial', 'subscription' coupon_id UUID REFERENCES coupons(id) ON DELETE SET NULL, valid_from TIMESTAMP NOT NULL, valid_until TIMESTAMP NOT NULL, is_active BOOLEAN DEFAULT true, -- Can be paused by Wellpass logic paused_by UUID, -- access_grant.id that paused this paused_at TIMESTAMP, -- When was it paused? remaining_days INTEGER, -- Days left when paused (for resume) created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- ============================================================================ -- 10. user_activity_log - Activity tracking -- ============================================================================ CREATE TABLE IF NOT EXISTS user_activity_log ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, action TEXT NOT NULL, -- 'login', 'logout', 'coupon_redeemed', 'tier_changed' details JSONB, -- Flexible metadata ip_address TEXT, user_agent TEXT, created TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_activity_log_profile ON user_activity_log(profile_id, created DESC); CREATE INDEX IF NOT EXISTS idx_activity_log_action ON user_activity_log(action, created DESC); -- ============================================================================ -- 11. user_stats - Aggregated statistics -- ============================================================================ CREATE TABLE IF NOT EXISTS user_stats ( profile_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE, last_login TIMESTAMP, login_count INTEGER DEFAULT 0, weight_entries_count INTEGER DEFAULT 0, ai_calls_count INTEGER DEFAULT 0, photos_count INTEGER DEFAULT 0, total_data_points INTEGER DEFAULT 0, created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- ============================================================================ -- Extend profiles table with subscription fields -- ============================================================================ ALTER TABLE profiles ADD COLUMN IF NOT EXISTS tier TEXT DEFAULT 'free'; ALTER TABLE profiles ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMP; ALTER TABLE profiles ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false; ALTER TABLE profiles ADD COLUMN IF NOT EXISTS email_verify_token TEXT; ALTER TABLE profiles ADD COLUMN IF NOT EXISTS invited_by UUID REFERENCES profiles(id) ON DELETE SET NULL; ALTER TABLE profiles ADD COLUMN IF NOT EXISTS invitation_token TEXT; -- ============================================================================ -- Insert initial data -- ============================================================================ -- App settings INSERT INTO app_settings (key, value, description) VALUES ('trial_duration_days', '14', 'Default trial duration for new registrations'), ('post_trial_tier', 'free', 'Tier after trial expires (free/disabled)'), ('require_email_verification', 'true', 'Require email verification before activation'), ('self_registration_enabled', 'true', 'Allow self-registration') ON CONFLICT (key) DO NOTHING; -- Tiers INSERT INTO tiers (id, name, description, price_monthly_cents, price_yearly_cents, active, sort_order) VALUES ('free', 'Free', 'Eingeschränkte Basis-Funktionen', NULL, NULL, true, 1), ('basic', 'Basic', 'Kernfunktionen ohne KI', 499, 4990, true, 2), ('premium', 'Premium', 'Alle Features inkl. KI und Connectoren', 999, 9990, true, 3), ('selfhosted', 'Self-Hosted', 'Unbegrenzt (für Heimserver)', NULL, NULL, false, 4) ON CONFLICT (id) DO NOTHING; -- Features (11 initial features) INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active) VALUES ('weight_entries', 'Gewichtseinträge', 'Anzahl Gewichtsmessungen', 'data', 'count', 'never', NULL, true), ('circumference_entries', 'Umfangs-Einträge', 'Anzahl Umfangsmessungen', 'data', 'count', 'never', NULL, true), ('caliper_entries', 'Caliper-Einträge', 'Anzahl Hautfaltenmessungen', 'data', 'count', 'never', NULL, true), ('nutrition_entries', 'Ernährungs-Einträge', 'Anzahl Ernährungslogs', 'data', 'count', 'never', NULL, true), ('activity_entries', 'Aktivitäts-Einträge', 'Anzahl Trainings/Aktivitäten', 'data', 'count', 'never', NULL, true), ('photos', 'Progress-Fotos', 'Anzahl hochgeladene Fotos', 'data', 'count', 'never', NULL, true), ('ai_calls', 'KI-Analysen', 'KI-Auswertungen pro Monat', 'ai', 'count', 'monthly', 0, true), ('ai_pipeline', 'KI-Pipeline', 'Vollständige Pipeline-Analyse', 'ai', 'boolean', 'never', 0, true), ('export_csv', 'CSV-Export', 'Daten als CSV exportieren', 'export', 'boolean', 'never', 0, true), ('export_json', 'JSON-Export', 'Daten als JSON exportieren', 'export', 'boolean', 'never', 0, true), ('export_zip', 'ZIP-Export', 'Vollständiger Backup-Export', 'export', 'boolean', 'never', 0, true) ON CONFLICT (id) DO NOTHING; -- Tier x Feature Matrix (tier_limits) -- Format: (tier, feature, limit) - NULL = unlimited, 0 = disabled -- FREE tier (sehr eingeschränkt) INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES ('free', 'weight_entries', 30), ('free', 'circumference_entries', 10), ('free', 'caliper_entries', 10), ('free', 'nutrition_entries', 30), ('free', 'activity_entries', 30), ('free', 'photos', 5), ('free', 'ai_calls', 0), -- Keine KI ('free', 'ai_pipeline', 0), -- Keine Pipeline ('free', 'export_csv', 0), -- Kein Export ('free', 'export_json', 0), ('free', 'export_zip', 0) ON CONFLICT (tier_id, feature_id) DO NOTHING; -- BASIC tier (Kernfunktionen) INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES ('basic', 'weight_entries', NULL), -- Unbegrenzt ('basic', 'circumference_entries', NULL), ('basic', 'caliper_entries', NULL), ('basic', 'nutrition_entries', NULL), ('basic', 'activity_entries', NULL), ('basic', 'photos', 50), ('basic', 'ai_calls', 3), -- 3 KI-Calls/Monat ('basic', 'ai_pipeline', 0), -- Keine Pipeline ('basic', 'export_csv', 1), -- Export erlaubt ('basic', 'export_json', 1), ('basic', 'export_zip', 1) ON CONFLICT (tier_id, feature_id) DO NOTHING; -- PREMIUM tier (alles unbegrenzt) INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES ('premium', 'weight_entries', NULL), ('premium', 'circumference_entries', NULL), ('premium', 'caliper_entries', NULL), ('premium', 'nutrition_entries', NULL), ('premium', 'activity_entries', NULL), ('premium', 'photos', NULL), ('premium', 'ai_calls', NULL), -- Unbegrenzt KI ('premium', 'ai_pipeline', 1), -- Pipeline erlaubt ('premium', 'export_csv', 1), ('premium', 'export_json', 1), ('premium', 'export_zip', 1) ON CONFLICT (tier_id, feature_id) DO NOTHING; -- SELFHOSTED tier (alles unbegrenzt) INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES ('selfhosted', 'weight_entries', NULL), ('selfhosted', 'circumference_entries', NULL), ('selfhosted', 'caliper_entries', NULL), ('selfhosted', 'nutrition_entries', NULL), ('selfhosted', 'activity_entries', NULL), ('selfhosted', 'photos', NULL), ('selfhosted', 'ai_calls', NULL), ('selfhosted', 'ai_pipeline', 1), ('selfhosted', 'export_csv', 1), ('selfhosted', 'export_json', 1), ('selfhosted', 'export_zip', 1) ON CONFLICT (tier_id, feature_id) DO NOTHING; -- ============================================================================ -- Migrate existing profiles -- ============================================================================ -- Lars' Profile → selfhosted tier with email verified UPDATE profiles SET tier = 'selfhosted', email_verified = true WHERE email = 'lars@stommer.com' OR role = 'admin'; -- Other existing profiles → free tier, unverified UPDATE profiles SET tier = 'free', email_verified = false WHERE tier IS NULL OR tier = ''; -- Initialize user_stats for existing profiles INSERT INTO user_stats (profile_id, weight_entries_count, photos_count) SELECT p.id, (SELECT COUNT(*) FROM weight_log WHERE profile_id = p.id), (SELECT COUNT(*) FROM photos WHERE profile_id = p.id) FROM profiles p ON CONFLICT (profile_id) DO NOTHING; -- ============================================================================ -- Create indexes for performance -- ============================================================================ CREATE INDEX IF NOT EXISTS idx_tier_limits_tier ON tier_limits(tier_id); CREATE INDEX IF NOT EXISTS idx_tier_limits_feature ON tier_limits(feature_id); CREATE INDEX IF NOT EXISTS idx_user_restrictions_profile ON user_feature_restrictions(profile_id); CREATE INDEX IF NOT EXISTS idx_user_usage_profile ON user_feature_usage(profile_id); CREATE INDEX IF NOT EXISTS idx_access_grants_profile ON access_grants(profile_id, valid_until DESC); CREATE INDEX IF NOT EXISTS idx_access_grants_active ON access_grants(profile_id, is_active, valid_until DESC); CREATE INDEX IF NOT EXISTS idx_coupons_code ON coupons(code); CREATE INDEX IF NOT EXISTS idx_coupon_redemptions_profile ON coupon_redemptions(profile_id); -- ============================================================================ -- Migration complete -- ============================================================================ -- Run this migration with: -- psql -h localhost -U mitai_prod -d mitai_prod < backend/migrations/v9c_subscription_system.sql -- -- Or via Docker: -- docker exec -i mitai-postgres psql -U mitai_prod -d mitai_prod < backend/migrations/v9c_subscription_system.sql -- ============================================================================