1#![forbid(unsafe_code)]
4#![allow(clippy::arithmetic_side_effects)] #![allow(clippy::cast_possible_truncation)] #![allow(clippy::cast_lossless)] use crate::program_types::{Plan, Subscription};
9use anchor_client::solana_sdk::pubkey::Pubkey;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13#[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
16pub struct Overview {
17 pub total_revenue: u64,
19 pub active_subscriptions: u32,
21 pub inactive_subscriptions: u32,
23 pub total_plans: u32,
25 pub monthly_revenue: u64,
27 pub monthly_new_subscriptions: u32,
29 pub monthly_canceled_subscriptions: u32,
31 pub average_revenue_per_user: u64,
33 pub merchant_authority: Pubkey,
35 pub usdc_mint: Pubkey,
37}
38
39impl Overview {
40 #[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 #[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 #[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 #[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#[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
76pub struct PlanAnalytics {
77 pub plan: Plan,
79 pub plan_address: Pubkey,
81 pub active_count: u32,
83 pub inactive_count: u32,
85 pub total_revenue: u64,
87 pub monthly_revenue: u64,
89 pub monthly_new_subscriptions: u32,
91 pub monthly_canceled_subscriptions: u32,
93 pub average_duration_days: f64,
95 pub conversion_rate: Option<f64>,
97}
98
99impl PlanAnalytics {
100 #[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 #[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 #[must_use]
116 pub const fn total_subscriptions(&self) -> u32 {
117 self.active_count + self.inactive_count
118 }
119
120 #[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 #[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; };
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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
155pub struct DashboardEvent {
156 pub event_type: DashboardEventType,
158 pub plan_address: Option<Pubkey>,
160 pub subscription_address: Option<Pubkey>,
162 pub subscriber: Option<Pubkey>,
164 pub amount: Option<u64>,
166 pub transaction_signature: Option<String>,
168 pub timestamp: i64,
170 pub metadata: HashMap<String, String>,
172}
173
174#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
176pub enum DashboardEventType {
177 SubscriptionStarted,
179 SubscriptionRenewed,
181 SubscriptionCanceled,
183 PaymentFailed,
185 PlanCreated,
187 PlanUpdated,
189 FeesWithdrawn,
191}
192
193impl DashboardEvent {
194 #[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 #[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 #[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#[derive(Clone, Debug)]
222pub struct EventStream {
223 pub events: Vec<DashboardEvent>,
225 pub max_buffer_size: usize,
227 pub is_active: bool,
229}
230
231impl EventStream {
232 #[must_use]
234 pub fn new() -> Self {
235 Self::with_buffer_size(1000)
236 }
237
238 #[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 pub fn add_event(&mut self, event: DashboardEvent) {
250 self.events.push(event);
251
252 if self.events.len() > self.max_buffer_size {
254 self.events.remove(0);
255 }
256 }
257
258 #[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 #[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 pub fn clear(&mut self) {
281 self.events.clear();
282 }
283
284 pub const fn start(&mut self) {
286 self.is_active = true;
287 }
288
289 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
303pub struct DashboardSubscription {
304 pub subscription: Subscription,
306 pub address: Pubkey,
308 pub plan: Plan,
310 pub plan_address: Pubkey,
312 pub status: SubscriptionStatus,
314 pub days_until_renewal: Option<i64>,
316 pub total_paid: u64,
318}
319
320#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
322pub enum SubscriptionStatus {
323 Active,
325 Overdue,
327 Inactive,
329 Expired,
331}
332
333impl DashboardSubscription {
334 #[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 #[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 SubscriptionStatus::Overdue
357 }
358 }
359
360 #[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; }
369
370 let seconds_diff = next_renewal_ts - current_timestamp;
371 Some(seconds_diff / 86400) }
373}