Skip to main content

kardo_core/pro/
mod.rs

1//! Pro features module — licensing, trial management, and feature gating.
2//!
3//! Manages:
4//! - Reverse trial (7 days, no credit card)
5//! - License key activation (VV-XXXX-XXXX-XXXX-XXXX format)
6//! - Feature availability checks
7//! - Device fingerprinting
8
9#[cfg(feature = "llm")]
10pub mod cloud;
11#[cfg(feature = "llm")]
12pub mod recommendations;
13
14use chrono::{NaiveDateTime, Utc};
15use regex::Regex;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::db::AppDb;
20use crate::error::KardoResult;
21
22/// Current license status of the application.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24#[serde(tag = "type", rename_all = "snake_case")]
25pub enum LicenseStatus {
26    Free,
27    Trial {
28        days_remaining: u32,
29        started_at: String,
30    },
31    TrialExpired,
32    Pro {
33        license_key: String,
34        activated_at: String,
35    },
36    ProExpired,
37}
38
39/// Features that require a Pro license or active trial.
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum ProFeature {
43    AiRecommendations,
44    AiFixSuggestions,
45    ExportReports,
46    DynamicBadge,
47    ScoreHistory,
48    CustomRules,
49}
50
51// ── DB keys ──
52
53const KEY_TRIAL_STARTED: &str = "pro.trial_started_at";
54const KEY_LICENSE_KEY: &str = "pro.license_key";
55const KEY_LICENSE_ACTIVATED: &str = "pro.license_activated_at";
56
57/// Trial duration in days.
58const TRIAL_DAYS: i64 = 14;
59
60/// License key format: VV-XXXX-XXXX-XXXX-XXXX (alphanumeric groups).
61const LICENSE_KEY_PATTERN: &str = r"^VV-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$";
62
63/// Manages Pro features, trial, and license activation.
64pub struct ProManager {
65    app_db: AppDb,
66}
67
68impl ProManager {
69    /// Create a new ProManager backed by the given AppDb.
70    pub fn new(app_db: AppDb) -> Self {
71        Self { app_db }
72    }
73
74    /// Get the current license status.
75    pub fn get_status(&self) -> LicenseStatus {
76        // Check for active Pro license first
77        if let Ok(Some(key)) = self.app_db.get_setting(KEY_LICENSE_KEY) {
78            if let Ok(Some(activated_at)) = self.app_db.get_setting(KEY_LICENSE_ACTIVATED) {
79                // For now, Pro licenses don't expire (no server-side validation yet)
80                return LicenseStatus::Pro {
81                    license_key: key,
82                    activated_at,
83                };
84            }
85        }
86
87        // Check for trial
88        if let Ok(Some(started_str)) = self.app_db.get_setting(KEY_TRIAL_STARTED) {
89            if let Ok(started) = NaiveDateTime::parse_from_str(&started_str, "%Y-%m-%dT%H:%M:%S") {
90                let now = Utc::now().naive_utc();
91                let elapsed_days = (now - started).num_days();
92                if elapsed_days < TRIAL_DAYS {
93                    let remaining = (TRIAL_DAYS - elapsed_days) as u32;
94                    return LicenseStatus::Trial {
95                        days_remaining: remaining,
96                        started_at: started_str,
97                    };
98                }
99                return LicenseStatus::TrialExpired;
100            }
101        }
102
103        LicenseStatus::Free
104    }
105
106    /// Start a reverse trial (7 days, no credit card required).
107    ///
108    /// Returns an error if a trial has already been started.
109    pub fn start_trial(&self) -> KardoResult<LicenseStatus> {
110        // Check if trial already exists
111        if let Ok(Some(_)) = self.app_db.get_setting(KEY_TRIAL_STARTED) {
112            return Err("Trial has already been started".into());
113        }
114
115        // Check if already Pro
116        if let Ok(Some(_)) = self.app_db.get_setting(KEY_LICENSE_KEY) {
117            return Err("Already has a Pro license".into());
118        }
119
120        let now = Utc::now().naive_utc();
121        let started_at = now.format("%Y-%m-%dT%H:%M:%S").to_string();
122
123        self.app_db
124            .set_setting(KEY_TRIAL_STARTED, &started_at)
125            .map_err(|e| format!("Failed to save trial start: {}", e))?;
126
127        Ok(LicenseStatus::Trial {
128            days_remaining: TRIAL_DAYS as u32,
129            started_at,
130        })
131    }
132
133    /// Activate a license key.
134    ///
135    /// Validates the format (VV-XXXX-XXXX-XXXX-XXXX) and stores in DB.
136    pub fn activate_license(&self, key: &str) -> KardoResult<LicenseStatus> {
137        // Validate format
138        let re = Regex::new(LICENSE_KEY_PATTERN)
139            .map_err(|e| format!("Internal regex error: {}", e))?;
140
141        if !re.is_match(key) {
142            return Err(format!(
143                "Invalid license key format. Expected: VV-XXXX-XXXX-XXXX-XXXX, got: {}",
144                key
145            ).into());
146        }
147
148        let now = Utc::now().naive_utc();
149        let activated_at = now.format("%Y-%m-%dT%H:%M:%S").to_string();
150
151        self.app_db
152            .set_setting(KEY_LICENSE_KEY, key)
153            .map_err(|e| format!("Failed to save license key: {}", e))?;
154
155        self.app_db
156            .set_setting(KEY_LICENSE_ACTIVATED, &activated_at)
157            .map_err(|e| format!("Failed to save activation date: {}", e))?;
158
159        Ok(LicenseStatus::Pro {
160            license_key: key.to_string(),
161            activated_at,
162        })
163    }
164
165    /// Check if a specific Pro feature is available.
166    ///
167    /// Available during active trial or with Pro license.
168    /// Free tier gets only ScoreHistory.
169    pub fn is_feature_available(&self, feature: &ProFeature) -> bool {
170        match self.get_status() {
171            LicenseStatus::Pro { .. } | LicenseStatus::Trial { .. } => true,
172            LicenseStatus::Free | LicenseStatus::TrialExpired | LicenseStatus::ProExpired => {
173                // Free tier: only basic features
174                matches!(feature, ProFeature::ScoreHistory)
175            }
176        }
177    }
178
179    /// Generate a device fingerprint (hash of hostname + username).
180    ///
181    /// This is a simple fingerprint for basic device identification.
182    /// Real cryptographic device binding comes later.
183    pub fn device_fingerprint() -> String {
184        let hostname = hostname::get()
185            .map(|h| h.to_string_lossy().to_string())
186            .unwrap_or_else(|_| "unknown-host".to_string());
187
188        let username = whoami::username();
189
190        let mut hasher = Sha256::new();
191        hasher.update(format!("{}:{}", hostname, username));
192        let result = hasher.finalize();
193        format!("{:x}", result)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use tempfile::TempDir;
201
202    fn make_test_db() -> (AppDb, TempDir) {
203        let tmp = TempDir::new().unwrap();
204        let db_path = tmp.path().join("test_app.db");
205        let db = AppDb::open(&db_path).unwrap();
206        (db, tmp)
207    }
208
209    #[test]
210    fn test_initial_status_is_free() {
211        let (db, _tmp) = make_test_db();
212        let mgr = ProManager::new(db);
213        assert_eq!(mgr.get_status(), LicenseStatus::Free);
214    }
215
216    #[test]
217    fn test_start_trial() {
218        let (db, _tmp) = make_test_db();
219        let mgr = ProManager::new(db);
220        let result = mgr.start_trial();
221        assert!(result.is_ok());
222        match result.unwrap() {
223            LicenseStatus::Trial { days_remaining, .. } => {
224                assert_eq!(days_remaining, 14);
225            }
226            other => panic!("Expected Trial, got {:?}", other),
227        }
228    }
229
230    #[test]
231    fn test_cannot_start_trial_twice() {
232        let (db, _tmp) = make_test_db();
233        let mgr = ProManager::new(db);
234        assert!(mgr.start_trial().is_ok());
235        let second = mgr.start_trial();
236        assert!(second.is_err());
237        assert!(second.unwrap_err().to_string().contains("already been started"));
238    }
239
240    #[test]
241    fn test_activate_valid_license() {
242        let (db, _tmp) = make_test_db();
243        let mgr = ProManager::new(db);
244        let result = mgr.activate_license("VV-ABC1-2DEF-3GH4-5IJK");
245        assert!(result.is_ok());
246        match result.unwrap() {
247            LicenseStatus::Pro { license_key, .. } => {
248                assert_eq!(license_key, "VV-ABC1-2DEF-3GH4-5IJK");
249            }
250            other => panic!("Expected Pro, got {:?}", other),
251        }
252    }
253
254    #[test]
255    fn test_reject_invalid_license_format() {
256        let (db, _tmp) = make_test_db();
257        let mgr = ProManager::new(db);
258
259        // Missing prefix
260        assert!(mgr.activate_license("ABCD-1234-5678-9012").is_err());
261        // Wrong separator
262        assert!(mgr.activate_license("VV_ABCD_1234_5678_9012").is_err());
263        // Too short
264        assert!(mgr.activate_license("VV-ABC-123-456-789").is_err());
265        // Empty
266        assert!(mgr.activate_license("").is_err());
267    }
268
269    #[test]
270    fn test_pro_status_after_activation() {
271        let (db, _tmp) = make_test_db();
272        let mgr = ProManager::new(db);
273        mgr.activate_license("VV-AAAA-BBBB-CCCC-DDDD").unwrap();
274        match mgr.get_status() {
275            LicenseStatus::Pro { license_key, .. } => {
276                assert_eq!(license_key, "VV-AAAA-BBBB-CCCC-DDDD");
277            }
278            other => panic!("Expected Pro, got {:?}", other),
279        }
280    }
281
282    #[test]
283    fn test_feature_available_during_trial() {
284        let (db, _tmp) = make_test_db();
285        let mgr = ProManager::new(db);
286        mgr.start_trial().unwrap();
287        assert!(mgr.is_feature_available(&ProFeature::AiRecommendations));
288        assert!(mgr.is_feature_available(&ProFeature::ExportReports));
289        assert!(mgr.is_feature_available(&ProFeature::CustomRules));
290    }
291
292    #[test]
293    fn test_feature_limited_on_free() {
294        let (db, _tmp) = make_test_db();
295        let mgr = ProManager::new(db);
296        // Free: only ScoreHistory
297        assert!(mgr.is_feature_available(&ProFeature::ScoreHistory));
298        assert!(!mgr.is_feature_available(&ProFeature::AiRecommendations));
299        assert!(!mgr.is_feature_available(&ProFeature::ExportReports));
300    }
301
302    #[test]
303    fn test_device_fingerprint_not_empty() {
304        let fp = ProManager::device_fingerprint();
305        assert!(!fp.is_empty());
306        // SHA256 hex is 64 chars
307        assert_eq!(fp.len(), 64);
308    }
309
310    #[test]
311    fn test_device_fingerprint_deterministic() {
312        let fp1 = ProManager::device_fingerprint();
313        let fp2 = ProManager::device_fingerprint();
314        assert_eq!(fp1, fp2);
315    }
316
317    #[test]
318    fn test_trial_expired() {
319        let (db, _tmp) = make_test_db();
320        // Manually set trial to 15 days ago
321        let past = Utc::now().naive_utc() - chrono::Duration::days(15);
322        let past_str = past.format("%Y-%m-%dT%H:%M:%S").to_string();
323        db.set_setting(KEY_TRIAL_STARTED, &past_str).unwrap();
324
325        let mgr = ProManager::new(db);
326        assert_eq!(mgr.get_status(), LicenseStatus::TrialExpired);
327        // Features should be limited
328        assert!(!mgr.is_feature_available(&ProFeature::AiRecommendations));
329    }
330}