tally-sdk 0.2.1

Rust SDK for the Tally Solana subscriptions platform
Documentation
//! Dashboard-specific data types and structures

#![forbid(unsafe_code)]
#![allow(clippy::arithmetic_side_effects)] // Safe for business logic calculations
#![allow(clippy::cast_possible_truncation)] // Controlled truncation for display formatting
#![allow(clippy::cast_lossless)] // Safe casting for USDC formatting

use crate::program_types::{Plan, Subscription};
use anchor_client::solana_sdk::pubkey::Pubkey;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Overview statistics for a merchant dashboard
#[allow(clippy::derive_partial_eq_without_eq)] // Contains f64 methods
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Overview {
    /// Total revenue earned (in USDC microlamports)
    pub total_revenue: u64,
    /// Number of active subscriptions
    pub active_subscriptions: u32,
    /// Number of inactive subscriptions
    pub inactive_subscriptions: u32,
    /// Total number of plans
    pub total_plans: u32,
    /// Revenue this month (in USDC microlamports)
    pub monthly_revenue: u64,
    /// New subscriptions this month
    pub monthly_new_subscriptions: u32,
    /// Canceled subscriptions this month
    pub monthly_canceled_subscriptions: u32,
    /// Average revenue per user (in USDC microlamports)
    pub average_revenue_per_user: u64,
    /// Merchant authority address
    pub merchant_authority: Pubkey,
    /// USDC mint being used
    pub usdc_mint: Pubkey,
}

impl Overview {
    /// Get total revenue formatted as USDC (6 decimal places)
    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn total_revenue_formatted(&self) -> f64 {
        self.total_revenue as f64 / 1_000_000.0
    }

    /// Get monthly revenue formatted as USDC (6 decimal places)
    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn monthly_revenue_formatted(&self) -> f64 {
        self.monthly_revenue as f64 / 1_000_000.0
    }

    /// Get average revenue per user formatted as USDC (6 decimal places)
    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn average_revenue_per_user_formatted(&self) -> f64 {
        self.average_revenue_per_user as f64 / 1_000_000.0
    }

    /// Calculate churn rate as a percentage
    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn churn_rate(&self) -> f64 {
        let total_subs = self.active_subscriptions + self.inactive_subscriptions;
        if total_subs == 0 {
            return 0.0;
        }
        (self.inactive_subscriptions as f64 / total_subs as f64) * 100.0
    }
}

/// Analytics data for a specific subscription plan
#[allow(clippy::derive_partial_eq_without_eq)] // Contains f64 fields
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PlanAnalytics {
    /// The plan being analyzed
    pub plan: Plan,
    /// Plan PDA address
    pub plan_address: Pubkey,
    /// Number of active subscriptions
    pub active_count: u32,
    /// Number of inactive subscriptions
    pub inactive_count: u32,
    /// Total revenue generated by this plan (in USDC microlamports)
    pub total_revenue: u64,
    /// Revenue this month (in USDC microlamports)
    pub monthly_revenue: u64,
    /// New subscriptions this month
    pub monthly_new_subscriptions: u32,
    /// Canceled subscriptions this month
    pub monthly_canceled_subscriptions: u32,
    /// Average subscription duration in days
    pub average_duration_days: f64,
    /// Conversion rate percentage (if applicable)
    pub conversion_rate: Option<f64>,
}

impl PlanAnalytics {
    /// Get total revenue formatted as USDC (6 decimal places)
    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn total_revenue_formatted(&self) -> f64 {
        self.total_revenue as f64 / 1_000_000.0
    }

    /// Get monthly revenue formatted as USDC (6 decimal places)
    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn monthly_revenue_formatted(&self) -> f64 {
        self.monthly_revenue as f64 / 1_000_000.0
    }

    /// Calculate total subscriptions (active + inactive)
    #[must_use]
    pub const fn total_subscriptions(&self) -> u32 {
        self.active_count + self.inactive_count
    }

    /// Calculate churn rate as a percentage
    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn churn_rate(&self) -> f64 {
        let total = self.total_subscriptions();
        if total == 0 {
            return 0.0;
        }
        (self.inactive_count as f64 / total as f64) * 100.0
    }

    /// Calculate monthly growth rate as a percentage
    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn monthly_growth_rate(&self) -> f64 {
        if self.monthly_canceled_subscriptions >= self.monthly_new_subscriptions {
            return 0.0;
        }
        let net_growth = self.monthly_new_subscriptions - self.monthly_canceled_subscriptions;
        let base = if self.active_count >= net_growth {
            self.active_count - net_growth
        } else {
            return 100.0; // If we can't calculate a base, assume 100% growth
        };

        if base == 0 {
            return 100.0;
        }

        (net_growth as f64 / base as f64) * 100.0
    }
}

/// Real-time event data for dashboard monitoring
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DashboardEvent {
    /// Event type
    pub event_type: DashboardEventType,
    /// Plan address (if applicable)
    pub plan_address: Option<Pubkey>,
    /// Subscription address (if applicable)
    pub subscription_address: Option<Pubkey>,
    /// Subscriber address (if applicable)
    pub subscriber: Option<Pubkey>,
    /// Amount involved (if applicable, in USDC microlamports)
    pub amount: Option<u64>,
    /// Transaction signature
    pub transaction_signature: Option<String>,
    /// Unix timestamp when the event occurred
    pub timestamp: i64,
    /// Additional event metadata
    pub metadata: HashMap<String, String>,
}

/// Types of events that can occur in the subscription system
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DashboardEventType {
    /// New subscription started
    SubscriptionStarted,
    /// Subscription renewed
    SubscriptionRenewed,
    /// Subscription canceled
    SubscriptionCanceled,
    /// Payment failed
    PaymentFailed,
    /// New plan created
    PlanCreated,
    /// Plan updated
    PlanUpdated,
    /// Merchant fees withdrawn
    FeesWithdrawn,
}

impl DashboardEvent {
    /// Get amount formatted as USDC (6 decimal places)
    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn amount_formatted(&self) -> Option<f64> {
        self.amount.map(|amount| amount as f64 / 1_000_000.0)
    }

    /// Check if this event affects revenue calculations
    #[must_use]
    pub const fn affects_revenue(&self) -> bool {
        matches!(
            self.event_type,
            DashboardEventType::SubscriptionStarted | DashboardEventType::SubscriptionRenewed
        )
    }

    /// Check if this event affects subscription counts
    #[must_use]
    pub const fn affects_subscription_count(&self) -> bool {
        matches!(
            self.event_type,
            DashboardEventType::SubscriptionStarted | DashboardEventType::SubscriptionCanceled
        )
    }
}

/// Event stream for real-time dashboard updates
#[derive(Clone, Debug)]
pub struct EventStream {
    /// Buffer of recent events
    pub events: Vec<DashboardEvent>,
    /// Maximum number of events to buffer
    pub max_buffer_size: usize,
    /// Whether the stream is actively monitoring
    pub is_active: bool,
}

impl EventStream {
    /// Create a new event stream with default buffer size
    #[must_use]
    pub fn new() -> Self {
        Self::with_buffer_size(1000)
    }

    /// Create a new event stream with custom buffer size
    #[must_use]
    pub fn with_buffer_size(buffer_size: usize) -> Self {
        Self {
            events: Vec::with_capacity(buffer_size),
            max_buffer_size: buffer_size,
            is_active: false,
        }
    }

    /// Add an event to the stream
    pub fn add_event(&mut self, event: DashboardEvent) {
        self.events.push(event);

        // Remove oldest events if buffer is full
        if self.events.len() > self.max_buffer_size {
            self.events.remove(0);
        }
    }

    /// Get recent events within a time window (in seconds)
    #[must_use]
    pub fn recent_events(&self, window_secs: i64) -> Vec<&DashboardEvent> {
        let now = chrono::Utc::now().timestamp();
        let cutoff = now - window_secs;

        self.events
            .iter()
            .filter(|event| event.timestamp >= cutoff)
            .collect()
    }

    /// Get events of a specific type
    #[must_use]
    pub fn events_of_type(&self, event_type: &DashboardEventType) -> Vec<&DashboardEvent> {
        self.events
            .iter()
            .filter(|event| &event.event_type == event_type)
            .collect()
    }

    /// Clear all events from the buffer
    pub fn clear(&mut self) {
        self.events.clear();
    }

    /// Start monitoring events
    pub const fn start(&mut self) {
        self.is_active = true;
    }

    /// Stop monitoring events
    pub const fn stop(&mut self) {
        self.is_active = false;
    }
}

impl Default for EventStream {
    fn default() -> Self {
        Self::new()
    }
}

/// Subscription details with enhanced information for dashboard display
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DashboardSubscription {
    /// The subscription data from the blockchain
    pub subscription: Subscription,
    /// Subscription PDA address
    pub address: Pubkey,
    /// Associated plan information
    pub plan: Plan,
    /// Plan PDA address
    pub plan_address: Pubkey,
    /// Human-readable status
    pub status: SubscriptionStatus,
    /// Days until next renewal (if active)
    pub days_until_renewal: Option<i64>,
    /// Total amount paid over subscription lifetime
    pub total_paid: u64,
}

/// Human-readable subscription status
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SubscriptionStatus {
    /// Subscription is active and current
    Active,
    /// Subscription is active but overdue (within grace period)
    Overdue,
    /// Subscription is inactive/canceled
    Inactive,
    /// Subscription is expired (past grace period)
    Expired,
}

impl DashboardSubscription {
    /// Get total paid amount formatted as USDC (6 decimal places)
    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn total_paid_formatted(&self) -> f64 {
        self.total_paid as f64 / 1_000_000.0
    }

    /// Calculate the status based on subscription data
    #[must_use]
    pub const fn calculate_status(
        subscription: &Subscription,
        current_timestamp: i64,
    ) -> SubscriptionStatus {
        if !subscription.active {
            return SubscriptionStatus::Inactive;
        }

        if current_timestamp <= subscription.next_renewal_ts {
            SubscriptionStatus::Active
        } else {
            // We would need the plan's grace period to determine if it's overdue or expired
            // For now, just mark as overdue if past renewal time
            SubscriptionStatus::Overdue
        }
    }

    /// Calculate days until next renewal
    #[must_use]
    pub const fn calculate_days_until_renewal(
        next_renewal_ts: i64,
        current_timestamp: i64,
    ) -> Option<i64> {
        if next_renewal_ts <= current_timestamp {
            return None; // Past due
        }

        let seconds_diff = next_renewal_ts - current_timestamp;
        Some(seconds_diff / 86400) // Convert to days
    }
}