memvid_cli/
org_ticket_cache.rs

1//! Organisation-level ticket caching
2//!
3//! Caches org-level tickets locally to avoid repeated API calls.
4//! Tickets are automatically refreshed when expired.
5
6use std::fs;
7use std::path::PathBuf;
8
9use anyhow::{Context, Result};
10use chrono::Utc;
11
12use crate::api::{fetch_org_ticket, OrgTicket, OrgTicketResponse};
13use crate::config::CliConfig;
14
15const ORG_TICKET_FILE: &str = "org_ticket.json";
16
17/// How often to refresh ticket for write operations (5 minutes)
18const WRITE_OP_REFRESH_SECS: i64 = 300;
19
20/// Cached org ticket with metadata
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22pub struct CachedOrgTicket {
23    pub ticket: OrgTicket,
24    pub plan_id: String,
25    pub plan_name: String,
26    pub org_id: String,
27    pub org_name: String,
28    pub total_storage_bytes: u64,
29    pub subscription_status: String,
30    /// ISO date when paid plan started
31    #[serde(default)]
32    pub plan_start_date: Option<String>,
33    /// ISO date when current billing period ends (renews)
34    #[serde(default)]
35    pub current_period_end: Option<String>,
36    /// ISO date when paid plan ends (for canceled subscriptions in grace period)
37    #[serde(default)]
38    pub plan_end_date: Option<String>,
39    /// Unix timestamp when this ticket was cached (for freshness checks)
40    #[serde(default)]
41    pub cached_at: i64,
42}
43
44impl CachedOrgTicket {
45    /// Check if the cached ticket has expired
46    pub fn is_expired(&self) -> bool {
47        self.ticket.is_expired()
48    }
49
50    /// Get capacity in bytes from the ticket
51    pub fn capacity_bytes(&self) -> u64 {
52        self.ticket.capacity_bytes
53    }
54
55    /// Check if the plan is a paid plan
56    pub fn is_paid(&self) -> bool {
57        self.ticket.is_paid()
58    }
59
60    /// Check if the subscription is canceled but still in grace period
61    pub fn is_in_grace_period(&self) -> bool {
62        if self.subscription_status != "canceled" {
63            return false;
64        }
65        if let Some(ref end_date) = self.plan_end_date {
66            // Parse ISO date and check if still valid
67            if let Ok(end) = chrono::DateTime::parse_from_rfc3339(end_date) {
68                return end > Utc::now();
69            }
70        }
71        false
72    }
73
74    /// Get days remaining in grace period (None if not in grace period)
75    pub fn grace_period_days_remaining(&self) -> Option<i64> {
76        if self.subscription_status != "canceled" {
77            return None;
78        }
79        if let Some(ref end_date) = self.plan_end_date {
80            if let Ok(end) = chrono::DateTime::parse_from_rfc3339(end_date) {
81                let now = Utc::now();
82                if end > now {
83                    let duration = end.signed_duration_since(now);
84                    return Some(duration.num_days());
85                }
86            }
87        }
88        None
89    }
90
91    /// Check if the ticket is too old for write operations
92    /// Write operations require fresher subscription status to prevent
93    /// users from continuing to use the CLI after cancellation
94    pub fn is_stale_for_writes(&self) -> bool {
95        if self.cached_at == 0 {
96            // Old cache format without cached_at - treat as stale
97            return true;
98        }
99        let now = Utc::now().timestamp();
100        now - self.cached_at > WRITE_OP_REFRESH_SECS
101    }
102}
103
104/// Get the path to the org ticket cache file
105fn cache_path(config: &CliConfig) -> PathBuf {
106    config.cache_dir.join(ORG_TICKET_FILE)
107}
108
109/// Load a cached org ticket from disk
110pub fn load(config: &CliConfig) -> Result<CachedOrgTicket> {
111    let path = cache_path(config);
112    let data = fs::read(&path)
113        .with_context(|| "no cached org ticket available")?;
114    let cached: CachedOrgTicket = serde_json::from_slice(&data)
115        .with_context(|| format!("failed to parse cached org ticket: {}", path.display()))?;
116    Ok(cached)
117}
118
119/// Store an org ticket response to disk
120pub fn store(config: &CliConfig, response: &OrgTicketResponse) -> Result<()> {
121    let path = cache_path(config);
122    if let Some(parent) = path.parent() {
123        fs::create_dir_all(parent).with_context(|| {
124            format!(
125                "failed to create org ticket cache directory: {}",
126                parent.display()
127            )
128        })?;
129    }
130
131    let cached = CachedOrgTicket {
132        ticket: response.ticket.clone(),
133        plan_id: response.plan.id.clone(),
134        plan_name: response.plan.name.clone(),
135        org_id: response.organisation.id.clone(),
136        org_name: response.organisation.name.clone(),
137        total_storage_bytes: response.organisation.total_storage_bytes,
138        subscription_status: response.subscription.status.clone(),
139        plan_start_date: response.subscription.plan_start_date.clone(),
140        current_period_end: response.subscription.current_period_end.clone(),
141        plan_end_date: response.subscription.plan_end_date.clone(),
142        cached_at: Utc::now().timestamp(),
143    };
144
145    let tmp = path.with_extension("json.tmp");
146    let data = serde_json::to_vec_pretty(&cached)?;
147    fs::write(&tmp, data)
148        .with_context(|| format!("failed to write org ticket cache file: {}", tmp.display()))?;
149    fs::rename(&tmp, &path).with_context(|| {
150        format!(
151            "failed to atomically persist org ticket cache file: {}",
152            path.display()
153        )
154    })?;
155    Ok(())
156}
157
158/// Clear the cached org ticket
159pub fn clear(config: &CliConfig) -> Result<()> {
160    let path = cache_path(config);
161    if path.exists() {
162        fs::remove_file(&path).with_context(|| {
163            format!("failed to remove org ticket cache file: {}", path.display())
164        })?;
165    }
166    Ok(())
167}
168
169/// Get a valid org ticket, fetching from API if needed
170///
171/// This function:
172/// 1. Tries to load a cached ticket
173/// 2. If cached ticket is valid (not expired), returns it
174/// 3. If no cache or expired, fetches a new ticket from the API
175/// 4. Caches the new ticket and returns it
176pub fn get_or_refresh(config: &CliConfig) -> Result<CachedOrgTicket> {
177    // Try cached ticket first
178    if let Ok(cached) = load(config) {
179        if !cached.is_expired() {
180            return Ok(cached);
181        }
182        log::debug!("Cached org ticket expired, refreshing...");
183    }
184
185    // Fetch new ticket
186    refresh(config)
187}
188
189/// Force refresh the org ticket from the API
190pub fn refresh(config: &CliConfig) -> Result<CachedOrgTicket> {
191    log::debug!("Fetching org ticket from API...");
192    let response = fetch_org_ticket(config)?;
193    store(config, &response)?;
194    load(config)
195}
196
197/// Get org ticket if API key is configured, otherwise return None
198///
199/// This is useful for optional ticket validation - if no API key is set,
200/// we fall back to free tier limits without requiring authentication.
201///
202/// When API key is set, this automatically syncs the plan ticket from the
203/// dashboard if needed (first use or expired). This enables seamless
204/// capacity validation without explicit `memvid plan sync`.
205pub fn get_optional(config: &CliConfig) -> Option<CachedOrgTicket> {
206    // No API key = free tier, silent fallback
207    if config.api_key.is_none() {
208        log::debug!("No API key set, using free tier limits");
209        return None;
210    }
211
212    // Check if we need to fetch (no cache or expired)
213    let needs_fetch = match load(config) {
214        Ok(cached) => cached.is_expired(),
215        Err(_) => true,
216    };
217
218    if needs_fetch {
219        // Silent auto-sync on first use or when expired
220        log::debug!("Auto-syncing plan ticket from dashboard...");
221        match refresh(config) {
222            Ok(ticket) => {
223                log::info!(
224                    "Plan synced: {} ({})",
225                    ticket.plan_name,
226                    format_capacity(ticket.capacity_bytes())
227                );
228                return Some(ticket);
229            }
230            Err(err) => {
231                // Check if it's an auth error - silently fall back to free tier
232                let err_str = err.to_string();
233                if err_str.contains("Invalid API key") || err_str.contains("401") {
234                    log::debug!("API key invalid, using free tier limits");
235                } else {
236                    // Only warn for non-auth errors (network issues, etc.)
237                    log::warn!("Failed to sync plan (using free tier limits): {}", err);
238                }
239                return None;
240            }
241        }
242    }
243
244    // Use cached ticket
245    match load(config) {
246        Ok(ticket) => Some(ticket),
247        Err(_) => None,
248    }
249}
250
251/// Format capacity bytes for display
252fn format_capacity(bytes: u64) -> String {
253    if bytes >= 1024 * 1024 * 1024 * 1024 {
254        format!("{:.1} TB", bytes as f64 / (1024.0 * 1024.0 * 1024.0 * 1024.0))
255    } else if bytes >= 1024 * 1024 * 1024 {
256        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
257    } else if bytes >= 1024 * 1024 {
258        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
259    } else if bytes >= 1024 {
260        format!("{:.1} KB", bytes as f64 / 1024.0)
261    } else {
262        format!("{} B", bytes)
263    }
264}
265
266/// Get a fresh org ticket for write operations
267///
268/// Write operations (put, find, ask) require up-to-date subscription status
269/// to prevent users from continuing to use the CLI after cancellation.
270///
271/// Smart refresh logic:
272/// - If cached status is "active" → only refresh if older than 5 minutes
273/// - If cached status is "canceled"/"inactive" → always refresh (instant unblock on reactivation)
274///
275/// This ensures:
276/// - Active users get fast operations (minimal API calls)
277/// - Canceled users can't abuse the CLI (checked every operation)
278/// - Reactivated users get instant access (no waiting for cache expiry)
279pub fn get_fresh_for_writes(config: &CliConfig) -> Option<CachedOrgTicket> {
280    // No API key = free tier, no ticket needed
281    if config.api_key.is_none() {
282        return None;
283    }
284
285    // Check if we have a cached ticket and determine refresh strategy
286    let needs_refresh = match load(config) {
287        Ok(cached) => {
288            if cached.is_expired() {
289                true
290            } else {
291                // Smart refresh: active users get 5-min cache, others always refresh
292                let status = cached.subscription_status.as_str();
293                if status == "active" || status == "trialing" {
294                    // Active subscription: only refresh if stale (> 5 min)
295                    cached.is_stale_for_writes()
296                } else {
297                    // Canceled/inactive: always refresh to catch reactivation instantly
298                    log::debug!("Non-active status '{}', refreshing to check for reactivation", status);
299                    true
300                }
301            }
302        }
303        Err(_) => true,
304    };
305
306    if needs_refresh {
307        log::debug!("Refreshing ticket for write operation...");
308        match refresh(config) {
309            Ok(ticket) => return Some(ticket),
310            Err(err) => {
311                let err_str = err.to_string();
312                if err_str.contains("Invalid API key") || err_str.contains("401") {
313                    log::debug!("API key invalid, using free tier limits");
314                } else {
315                    log::warn!("Failed to refresh ticket: {}", err);
316                }
317                return None;
318            }
319        }
320    }
321
322    // Use cached ticket (it's fresh enough)
323    load(config).ok()
324}