tally_sdk/
dashboard_types.rs

1//! Dashboard-specific data types and structures
2
3#![forbid(unsafe_code)]
4#![allow(clippy::arithmetic_side_effects)] // Safe for business logic calculations
5#![allow(clippy::cast_possible_truncation)] // Controlled truncation for display formatting
6#![allow(clippy::cast_lossless)] // Safe casting for USDC formatting
7
8use crate::program_types::{Plan, Subscription};
9use anchor_client::solana_sdk::pubkey::Pubkey;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Overview statistics for a merchant dashboard
14#[allow(clippy::derive_partial_eq_without_eq)] // Contains f64 methods
15#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
16pub struct Overview {
17    /// Total revenue earned (in USDC microlamports)
18    pub total_revenue: u64,
19    /// Number of active subscriptions
20    pub active_subscriptions: u32,
21    /// Number of inactive subscriptions
22    pub inactive_subscriptions: u32,
23    /// Total number of plans
24    pub total_plans: u32,
25    /// Revenue this month (in USDC microlamports)
26    pub monthly_revenue: u64,
27    /// New subscriptions this month
28    pub monthly_new_subscriptions: u32,
29    /// Canceled subscriptions this month
30    pub monthly_canceled_subscriptions: u32,
31    /// Average revenue per user (in USDC microlamports)
32    pub average_revenue_per_user: u64,
33    /// Merchant authority address
34    pub merchant_authority: Pubkey,
35    /// USDC mint being used
36    pub usdc_mint: Pubkey,
37}
38
39impl Overview {
40    /// Get total revenue formatted as USDC (6 decimal places)
41    #[must_use]
42    #[allow(clippy::cast_precision_loss)]
43    pub fn total_revenue_formatted(&self) -> f64 {
44        self.total_revenue as f64 / 1_000_000.0
45    }
46
47    /// Get monthly revenue formatted as USDC (6 decimal places)
48    #[must_use]
49    #[allow(clippy::cast_precision_loss)]
50    pub fn monthly_revenue_formatted(&self) -> f64 {
51        self.monthly_revenue as f64 / 1_000_000.0
52    }
53
54    /// Get average revenue per user formatted as USDC (6 decimal places)
55    #[must_use]
56    #[allow(clippy::cast_precision_loss)]
57    pub fn average_revenue_per_user_formatted(&self) -> f64 {
58        self.average_revenue_per_user as f64 / 1_000_000.0
59    }
60
61    /// Calculate churn rate as a percentage
62    #[must_use]
63    #[allow(clippy::cast_precision_loss)]
64    pub fn churn_rate(&self) -> f64 {
65        let total_subs = self.active_subscriptions + self.inactive_subscriptions;
66        if total_subs == 0 {
67            return 0.0;
68        }
69        (self.inactive_subscriptions as f64 / total_subs as f64) * 100.0
70    }
71}
72
73/// Analytics data for a specific subscription plan
74#[allow(clippy::derive_partial_eq_without_eq)] // Contains f64 fields
75#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
76pub struct PlanAnalytics {
77    /// The plan being analyzed
78    pub plan: Plan,
79    /// Plan PDA address
80    pub plan_address: Pubkey,
81    /// Number of active subscriptions
82    pub active_count: u32,
83    /// Number of inactive subscriptions
84    pub inactive_count: u32,
85    /// Total revenue generated by this plan (in USDC microlamports)
86    pub total_revenue: u64,
87    /// Revenue this month (in USDC microlamports)
88    pub monthly_revenue: u64,
89    /// New subscriptions this month
90    pub monthly_new_subscriptions: u32,
91    /// Canceled subscriptions this month
92    pub monthly_canceled_subscriptions: u32,
93    /// Average subscription duration in days
94    pub average_duration_days: f64,
95    /// Conversion rate percentage (if applicable)
96    pub conversion_rate: Option<f64>,
97}
98
99impl PlanAnalytics {
100    /// Get total revenue formatted as USDC (6 decimal places)
101    #[must_use]
102    #[allow(clippy::cast_precision_loss)]
103    pub fn total_revenue_formatted(&self) -> f64 {
104        self.total_revenue as f64 / 1_000_000.0
105    }
106
107    /// Get monthly revenue formatted as USDC (6 decimal places)
108    #[must_use]
109    #[allow(clippy::cast_precision_loss)]
110    pub fn monthly_revenue_formatted(&self) -> f64 {
111        self.monthly_revenue as f64 / 1_000_000.0
112    }
113
114    /// Calculate total subscriptions (active + inactive)
115    #[must_use]
116    pub const fn total_subscriptions(&self) -> u32 {
117        self.active_count + self.inactive_count
118    }
119
120    /// Calculate churn rate as a percentage
121    #[must_use]
122    #[allow(clippy::cast_precision_loss)]
123    pub fn churn_rate(&self) -> f64 {
124        let total = self.total_subscriptions();
125        if total == 0 {
126            return 0.0;
127        }
128        (self.inactive_count as f64 / total as f64) * 100.0
129    }
130
131    /// Calculate monthly growth rate as a percentage
132    #[must_use]
133    #[allow(clippy::cast_precision_loss)]
134    pub fn monthly_growth_rate(&self) -> f64 {
135        if self.monthly_canceled_subscriptions >= self.monthly_new_subscriptions {
136            return 0.0;
137        }
138        let net_growth = self.monthly_new_subscriptions - self.monthly_canceled_subscriptions;
139        let base = if self.active_count >= net_growth {
140            self.active_count - net_growth
141        } else {
142            return 100.0; // If we can't calculate a base, assume 100% growth
143        };
144
145        if base == 0 {
146            return 100.0;
147        }
148
149        (net_growth as f64 / base as f64) * 100.0
150    }
151}
152
153/// Real-time event data for dashboard monitoring
154#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
155pub struct DashboardEvent {
156    /// Event type
157    pub event_type: DashboardEventType,
158    /// Plan address (if applicable)
159    pub plan_address: Option<Pubkey>,
160    /// Subscription address (if applicable)
161    pub subscription_address: Option<Pubkey>,
162    /// Subscriber address (if applicable)
163    pub subscriber: Option<Pubkey>,
164    /// Amount involved (if applicable, in USDC microlamports)
165    pub amount: Option<u64>,
166    /// Transaction signature
167    pub transaction_signature: Option<String>,
168    /// Unix timestamp when the event occurred
169    pub timestamp: i64,
170    /// Additional event metadata
171    pub metadata: HashMap<String, String>,
172}
173
174/// Types of events that can occur in the subscription system
175#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
176pub enum DashboardEventType {
177    /// New subscription started
178    SubscriptionStarted,
179    /// Subscription renewed
180    SubscriptionRenewed,
181    /// Subscription canceled
182    SubscriptionCanceled,
183    /// Payment failed
184    PaymentFailed,
185    /// New plan created
186    PlanCreated,
187    /// Plan updated
188    PlanUpdated,
189    /// Merchant fees withdrawn
190    FeesWithdrawn,
191}
192
193impl DashboardEvent {
194    /// Get amount formatted as USDC (6 decimal places)
195    #[must_use]
196    #[allow(clippy::cast_precision_loss)]
197    pub fn amount_formatted(&self) -> Option<f64> {
198        self.amount.map(|amount| amount as f64 / 1_000_000.0)
199    }
200
201    /// Check if this event affects revenue calculations
202    #[must_use]
203    pub const fn affects_revenue(&self) -> bool {
204        matches!(
205            self.event_type,
206            DashboardEventType::SubscriptionStarted | DashboardEventType::SubscriptionRenewed
207        )
208    }
209
210    /// Check if this event affects subscription counts
211    #[must_use]
212    pub const fn affects_subscription_count(&self) -> bool {
213        matches!(
214            self.event_type,
215            DashboardEventType::SubscriptionStarted | DashboardEventType::SubscriptionCanceled
216        )
217    }
218}
219
220/// Event stream for real-time dashboard updates
221#[derive(Clone, Debug)]
222pub struct EventStream {
223    /// Buffer of recent events
224    pub events: Vec<DashboardEvent>,
225    /// Maximum number of events to buffer
226    pub max_buffer_size: usize,
227    /// Whether the stream is actively monitoring
228    pub is_active: bool,
229}
230
231impl EventStream {
232    /// Create a new event stream with default buffer size
233    #[must_use]
234    pub fn new() -> Self {
235        Self::with_buffer_size(1000)
236    }
237
238    /// Create a new event stream with custom buffer size
239    #[must_use]
240    pub fn with_buffer_size(buffer_size: usize) -> Self {
241        Self {
242            events: Vec::with_capacity(buffer_size),
243            max_buffer_size: buffer_size,
244            is_active: false,
245        }
246    }
247
248    /// Add an event to the stream
249    pub fn add_event(&mut self, event: DashboardEvent) {
250        self.events.push(event);
251
252        // Remove oldest events if buffer is full
253        if self.events.len() > self.max_buffer_size {
254            self.events.remove(0);
255        }
256    }
257
258    /// Get recent events within a time window (in seconds)
259    #[must_use]
260    pub fn recent_events(&self, window_secs: i64) -> Vec<&DashboardEvent> {
261        let now = chrono::Utc::now().timestamp();
262        let cutoff = now - window_secs;
263
264        self.events
265            .iter()
266            .filter(|event| event.timestamp >= cutoff)
267            .collect()
268    }
269
270    /// Get events of a specific type
271    #[must_use]
272    pub fn events_of_type(&self, event_type: &DashboardEventType) -> Vec<&DashboardEvent> {
273        self.events
274            .iter()
275            .filter(|event| &event.event_type == event_type)
276            .collect()
277    }
278
279    /// Clear all events from the buffer
280    pub fn clear(&mut self) {
281        self.events.clear();
282    }
283
284    /// Start monitoring events
285    pub const fn start(&mut self) {
286        self.is_active = true;
287    }
288
289    /// Stop monitoring events
290    pub const fn stop(&mut self) {
291        self.is_active = false;
292    }
293}
294
295impl Default for EventStream {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301/// Subscription details with enhanced information for dashboard display
302#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
303pub struct DashboardSubscription {
304    /// The subscription data from the blockchain
305    pub subscription: Subscription,
306    /// Subscription PDA address
307    pub address: Pubkey,
308    /// Associated plan information
309    pub plan: Plan,
310    /// Plan PDA address
311    pub plan_address: Pubkey,
312    /// Human-readable status
313    pub status: SubscriptionStatus,
314    /// Days until next renewal (if active)
315    pub days_until_renewal: Option<i64>,
316    /// Total amount paid over subscription lifetime
317    pub total_paid: u64,
318}
319
320/// Human-readable subscription status
321#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
322pub enum SubscriptionStatus {
323    /// Subscription is active and current
324    Active,
325    /// Subscription is active but overdue (within grace period)
326    Overdue,
327    /// Subscription is inactive/canceled
328    Inactive,
329    /// Subscription is expired (past grace period)
330    Expired,
331}
332
333impl DashboardSubscription {
334    /// Get total paid amount formatted as USDC (6 decimal places)
335    #[must_use]
336    #[allow(clippy::cast_precision_loss)]
337    pub fn total_paid_formatted(&self) -> f64 {
338        self.total_paid as f64 / 1_000_000.0
339    }
340
341    /// Calculate the status based on subscription data
342    #[must_use]
343    pub const fn calculate_status(
344        subscription: &Subscription,
345        current_timestamp: i64,
346    ) -> SubscriptionStatus {
347        if !subscription.active {
348            return SubscriptionStatus::Inactive;
349        }
350
351        if current_timestamp <= subscription.next_renewal_ts {
352            SubscriptionStatus::Active
353        } else {
354            // We would need the plan's grace period to determine if it's overdue or expired
355            // For now, just mark as overdue if past renewal time
356            SubscriptionStatus::Overdue
357        }
358    }
359
360    /// Calculate days until next renewal
361    #[must_use]
362    pub const fn calculate_days_until_renewal(
363        next_renewal_ts: i64,
364        current_timestamp: i64,
365    ) -> Option<i64> {
366        if next_renewal_ts <= current_timestamp {
367            return None; // Past due
368        }
369
370        let seconds_diff = next_renewal_ts - current_timestamp;
371        Some(seconds_diff / 86400) // Convert to days
372    }
373}