kaccy_bitcoin/
notifications.rs

1//! Admin notifications for payment issues
2//!
3//! This module provides a notification system for alerting administrators
4//! about payment discrepancies, RBF replacements, and other important events.
5
6use serde::{Deserialize, Serialize};
7use std::collections::VecDeque;
8use std::sync::Arc;
9use tokio::sync::{RwLock, broadcast};
10
11/// Priority level for notifications
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13pub enum NotificationPriority {
14    /// Low priority - informational
15    Low,
16    /// Medium priority - requires attention
17    Medium,
18    /// High priority - requires prompt action
19    High,
20    /// Critical priority - requires immediate action
21    Critical,
22}
23
24/// Notification category
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub enum NotificationCategory {
27    /// Payment amount mismatch
28    PaymentMismatch,
29    /// Transaction replaced via RBF
30    TransactionReplaced,
31    /// Transaction dropped from mempool
32    TransactionDropped,
33    /// Block reorganization detected
34    Reorganization,
35    /// Large transaction detected
36    LargeTransaction,
37    /// Suspicious activity detected
38    SuspiciousActivity,
39    /// System health issue
40    SystemHealth,
41    /// General information
42    Info,
43}
44
45/// Admin notification
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct AdminNotification {
48    /// Unique notification ID
49    pub id: String,
50    /// Notification category
51    pub category: NotificationCategory,
52    /// Priority level
53    pub priority: NotificationPriority,
54    /// Short title
55    pub title: String,
56    /// Detailed message
57    pub message: String,
58    /// Related order ID (if applicable)
59    pub order_id: Option<String>,
60    /// Related transaction ID (if applicable)
61    pub txid: Option<String>,
62    /// Related user ID (if applicable)
63    pub user_id: Option<String>,
64    /// Additional metadata
65    pub metadata: NotificationMetadata,
66    /// When the notification was created
67    pub created_at: chrono::DateTime<chrono::Utc>,
68    /// Whether the notification has been acknowledged
69    pub acknowledged: bool,
70    /// When the notification was acknowledged
71    pub acknowledged_at: Option<chrono::DateTime<chrono::Utc>>,
72    /// Who acknowledged the notification
73    pub acknowledged_by: Option<String>,
74}
75
76/// Additional notification metadata
77#[derive(Debug, Clone, Default, Serialize, Deserialize)]
78pub struct NotificationMetadata {
79    /// Expected amount in satoshis
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub expected_sats: Option<u64>,
82    /// Received amount in satoshis
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub received_sats: Option<u64>,
85    /// Difference in satoshis
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub difference_sats: Option<i64>,
88    /// Payment address
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub address: Option<String>,
91    /// Original transaction ID (for RBF)
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub original_txid: Option<String>,
94    /// Replacement transaction ID (for RBF)
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub replacement_txid: Option<String>,
97    /// Fee increase in satoshis (for RBF)
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub fee_increase_sats: Option<u64>,
100    /// Suggested action
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub suggested_action: Option<String>,
103    /// Refund address (if available)
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub refund_address: Option<String>,
106}
107
108impl AdminNotification {
109    /// Create a new notification
110    pub fn new(
111        category: NotificationCategory,
112        priority: NotificationPriority,
113        title: impl Into<String>,
114        message: impl Into<String>,
115    ) -> Self {
116        Self {
117            id: uuid::Uuid::new_v4().to_string(),
118            category,
119            priority,
120            title: title.into(),
121            message: message.into(),
122            order_id: None,
123            txid: None,
124            user_id: None,
125            metadata: NotificationMetadata::default(),
126            created_at: chrono::Utc::now(),
127            acknowledged: false,
128            acknowledged_at: None,
129            acknowledged_by: None,
130        }
131    }
132
133    /// Set order ID
134    pub fn with_order(mut self, order_id: impl Into<String>) -> Self {
135        self.order_id = Some(order_id.into());
136        self
137    }
138
139    /// Set transaction ID
140    pub fn with_txid(mut self, txid: impl Into<String>) -> Self {
141        self.txid = Some(txid.into());
142        self
143    }
144
145    /// Set user ID
146    pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
147        self.user_id = Some(user_id.into());
148        self
149    }
150
151    /// Set metadata
152    pub fn with_metadata(mut self, metadata: NotificationMetadata) -> Self {
153        self.metadata = metadata;
154        self
155    }
156
157    /// Create an underpayment notification
158    pub fn underpayment(
159        order_id: &str,
160        txid: &str,
161        expected_sats: u64,
162        received_sats: u64,
163        address: &str,
164        refund_address: Option<String>,
165    ) -> Self {
166        let shortfall = expected_sats.saturating_sub(received_sats);
167        let percentage = (shortfall as f64 / expected_sats as f64 * 100.0) as u32;
168
169        let priority = if percentage > 50 {
170            NotificationPriority::High
171        } else if percentage > 20 {
172            NotificationPriority::Medium
173        } else {
174            NotificationPriority::Low
175        };
176
177        Self::new(
178            NotificationCategory::PaymentMismatch,
179            priority,
180            format!("Underpayment: {} sats short", shortfall),
181            format!(
182                "Order {} received {} sats but expected {} sats ({}% short)",
183                order_id, received_sats, expected_sats, percentage
184            ),
185        )
186        .with_order(order_id)
187        .with_txid(txid)
188        .with_metadata(NotificationMetadata {
189            expected_sats: Some(expected_sats),
190            received_sats: Some(received_sats),
191            difference_sats: Some(-(shortfall as i64)),
192            address: Some(address.to_string()),
193            suggested_action: Some(if percentage > 10 {
194                "Contact user for additional payment or cancel order".to_string()
195            } else {
196                "Consider accepting as minor discrepancy".to_string()
197            }),
198            refund_address,
199            ..Default::default()
200        })
201    }
202
203    /// Create an overpayment notification
204    pub fn overpayment(
205        order_id: &str,
206        txid: &str,
207        expected_sats: u64,
208        received_sats: u64,
209        address: &str,
210        refund_address: Option<String>,
211    ) -> Self {
212        let excess = received_sats.saturating_sub(expected_sats);
213        let percentage = (excess as f64 / expected_sats as f64 * 100.0) as u32;
214
215        let priority = if excess > 100_000 {
216            // > 0.001 BTC
217            NotificationPriority::High
218        } else if percentage > 20 {
219            NotificationPriority::Medium
220        } else {
221            NotificationPriority::Low
222        };
223
224        let suggested_action = if let Some(ref refund_addr) = refund_address {
225            format!("Process refund of {} sats to {}", excess, refund_addr)
226        } else {
227            "Unable to determine refund address - manual intervention required".to_string()
228        };
229
230        Self::new(
231            NotificationCategory::PaymentMismatch,
232            priority,
233            format!("Overpayment: {} sats excess", excess),
234            format!(
235                "Order {} received {} sats but expected {} sats ({}% over)",
236                order_id, received_sats, expected_sats, percentage
237            ),
238        )
239        .with_order(order_id)
240        .with_txid(txid)
241        .with_metadata(NotificationMetadata {
242            expected_sats: Some(expected_sats),
243            received_sats: Some(received_sats),
244            difference_sats: Some(excess as i64),
245            address: Some(address.to_string()),
246            suggested_action: Some(suggested_action),
247            refund_address,
248            ..Default::default()
249        })
250    }
251
252    /// Create an RBF replacement notification
253    pub fn rbf_replacement(
254        order_id: Option<&str>,
255        original_txid: &str,
256        replacement_txid: &str,
257        fee_increase: Option<u64>,
258    ) -> Self {
259        let mut notification = Self::new(
260            NotificationCategory::TransactionReplaced,
261            NotificationPriority::Medium,
262            "Transaction Replaced (RBF)",
263            format!(
264                "Transaction {} was replaced by {}",
265                original_txid, replacement_txid
266            ),
267        )
268        .with_metadata(NotificationMetadata {
269            original_txid: Some(original_txid.to_string()),
270            replacement_txid: Some(replacement_txid.to_string()),
271            fee_increase_sats: fee_increase,
272            suggested_action: Some(
273                "Verify replacement transaction is valid and update order tracking".to_string(),
274            ),
275            ..Default::default()
276        });
277
278        if let Some(order) = order_id {
279            notification = notification.with_order(order);
280        }
281
282        notification
283    }
284
285    /// Create a large transaction notification
286    pub fn large_transaction(
287        txid: &str,
288        amount_sats: u64,
289        address: &str,
290        order_id: Option<&str>,
291    ) -> Self {
292        let btc_amount = amount_sats as f64 / 100_000_000.0;
293
294        let mut notification = Self::new(
295            NotificationCategory::LargeTransaction,
296            if amount_sats > 10_000_000_000 {
297                // > 100 BTC
298                NotificationPriority::Critical
299            } else if amount_sats > 1_000_000_000 {
300                // > 10 BTC
301                NotificationPriority::High
302            } else {
303                NotificationPriority::Medium
304            },
305            format!("Large Transaction: {:.4} BTC", btc_amount),
306            format!(
307                "Received {} sats ({:.4} BTC) at address {}",
308                amount_sats, btc_amount, address
309            ),
310        )
311        .with_txid(txid)
312        .with_metadata(NotificationMetadata {
313            received_sats: Some(amount_sats),
314            address: Some(address.to_string()),
315            suggested_action: Some("Review and verify legitimacy".to_string()),
316            ..Default::default()
317        });
318
319        if let Some(order) = order_id {
320            notification = notification.with_order(order);
321        }
322
323        notification
324    }
325}
326
327/// Admin notification service
328pub struct AdminNotificationService {
329    /// Notification channel
330    notification_tx: broadcast::Sender<AdminNotification>,
331    /// Recent notifications (ring buffer)
332    recent: Arc<RwLock<VecDeque<AdminNotification>>>,
333    /// Maximum recent notifications to keep
334    max_recent: usize,
335    /// Large transaction threshold in satoshis
336    large_tx_threshold: u64,
337}
338
339impl AdminNotificationService {
340    /// Create a new notification service
341    pub fn new() -> Self {
342        let (notification_tx, _) = broadcast::channel(100);
343        Self {
344            notification_tx,
345            recent: Arc::new(RwLock::new(VecDeque::with_capacity(1000))),
346            max_recent: 1000,
347            large_tx_threshold: 100_000_000, // 1 BTC default
348        }
349    }
350
351    /// Set the large transaction threshold
352    pub fn with_large_tx_threshold(mut self, threshold_sats: u64) -> Self {
353        self.large_tx_threshold = threshold_sats;
354        self
355    }
356
357    /// Subscribe to notifications
358    pub fn subscribe(&self) -> broadcast::Receiver<AdminNotification> {
359        self.notification_tx.subscribe()
360    }
361
362    /// Send a notification
363    pub async fn notify(&self, notification: AdminNotification) {
364        // Add to recent notifications
365        {
366            let mut recent = self.recent.write().await;
367            if recent.len() >= self.max_recent {
368                recent.pop_front();
369            }
370            recent.push_back(notification.clone());
371        }
372
373        // Broadcast to subscribers
374        let _ = self.notification_tx.send(notification.clone());
375
376        // Log based on priority
377        match notification.priority {
378            NotificationPriority::Critical => {
379                tracing::error!(
380                    category = ?notification.category,
381                    title = %notification.title,
382                    "CRITICAL: {}", notification.message
383                );
384            }
385            NotificationPriority::High => {
386                tracing::warn!(
387                    category = ?notification.category,
388                    title = %notification.title,
389                    "{}", notification.message
390                );
391            }
392            NotificationPriority::Medium => {
393                tracing::info!(
394                    category = ?notification.category,
395                    title = %notification.title,
396                    "{}", notification.message
397                );
398            }
399            NotificationPriority::Low => {
400                tracing::debug!(
401                    category = ?notification.category,
402                    title = %notification.title,
403                    "{}", notification.message
404                );
405            }
406        }
407    }
408
409    /// Notify about underpayment
410    pub async fn notify_underpayment(
411        &self,
412        order_id: &str,
413        txid: &str,
414        expected_sats: u64,
415        received_sats: u64,
416        address: &str,
417        refund_address: Option<String>,
418    ) {
419        let notification = AdminNotification::underpayment(
420            order_id,
421            txid,
422            expected_sats,
423            received_sats,
424            address,
425            refund_address,
426        );
427        self.notify(notification).await;
428    }
429
430    /// Notify about overpayment
431    pub async fn notify_overpayment(
432        &self,
433        order_id: &str,
434        txid: &str,
435        expected_sats: u64,
436        received_sats: u64,
437        address: &str,
438        refund_address: Option<String>,
439    ) {
440        let notification = AdminNotification::overpayment(
441            order_id,
442            txid,
443            expected_sats,
444            received_sats,
445            address,
446            refund_address,
447        );
448        self.notify(notification).await;
449    }
450
451    /// Notify about RBF replacement
452    pub async fn notify_rbf_replacement(
453        &self,
454        order_id: Option<&str>,
455        original_txid: &str,
456        replacement_txid: &str,
457        fee_increase: Option<u64>,
458    ) {
459        let notification = AdminNotification::rbf_replacement(
460            order_id,
461            original_txid,
462            replacement_txid,
463            fee_increase,
464        );
465        self.notify(notification).await;
466    }
467
468    /// Notify about large transaction if above threshold
469    pub async fn check_large_transaction(
470        &self,
471        txid: &str,
472        amount_sats: u64,
473        address: &str,
474        order_id: Option<&str>,
475    ) {
476        if amount_sats >= self.large_tx_threshold {
477            let notification =
478                AdminNotification::large_transaction(txid, amount_sats, address, order_id);
479            self.notify(notification).await;
480        }
481    }
482
483    /// Get recent notifications
484    pub async fn get_recent(&self, limit: usize) -> Vec<AdminNotification> {
485        let recent = self.recent.read().await;
486        recent.iter().rev().take(limit).cloned().collect()
487    }
488
489    /// Get unacknowledged notifications
490    pub async fn get_unacknowledged(&self) -> Vec<AdminNotification> {
491        let recent = self.recent.read().await;
492        recent.iter().filter(|n| !n.acknowledged).cloned().collect()
493    }
494
495    /// Get notifications by category
496    pub async fn get_by_category(&self, category: NotificationCategory) -> Vec<AdminNotification> {
497        let recent = self.recent.read().await;
498        recent
499            .iter()
500            .filter(|n| n.category == category)
501            .cloned()
502            .collect()
503    }
504
505    /// Get notifications by priority
506    pub async fn get_by_priority(
507        &self,
508        min_priority: NotificationPriority,
509    ) -> Vec<AdminNotification> {
510        let recent = self.recent.read().await;
511        recent
512            .iter()
513            .filter(|n| n.priority >= min_priority)
514            .cloned()
515            .collect()
516    }
517
518    /// Acknowledge a notification
519    pub async fn acknowledge(&self, notification_id: &str, admin_id: &str) -> bool {
520        let mut recent = self.recent.write().await;
521        for notification in recent.iter_mut() {
522            if notification.id == notification_id {
523                notification.acknowledged = true;
524                notification.acknowledged_at = Some(chrono::Utc::now());
525                notification.acknowledged_by = Some(admin_id.to_string());
526                return true;
527            }
528        }
529        false
530    }
531
532    /// Get notification statistics
533    pub async fn get_stats(&self) -> NotificationStats {
534        let recent = self.recent.read().await;
535
536        let mut by_category: std::collections::HashMap<NotificationCategory, usize> =
537            std::collections::HashMap::new();
538        let mut by_priority: std::collections::HashMap<NotificationPriority, usize> =
539            std::collections::HashMap::new();
540        let mut unacknowledged = 0;
541
542        for notification in recent.iter() {
543            *by_category.entry(notification.category).or_insert(0) += 1;
544            *by_priority.entry(notification.priority).or_insert(0) += 1;
545            if !notification.acknowledged {
546                unacknowledged += 1;
547            }
548        }
549
550        NotificationStats {
551            total: recent.len(),
552            unacknowledged,
553            by_category,
554            by_priority,
555        }
556    }
557}
558
559impl Default for AdminNotificationService {
560    fn default() -> Self {
561        Self::new()
562    }
563}
564
565/// Notification statistics
566#[derive(Debug, Clone, Serialize)]
567pub struct NotificationStats {
568    /// Total notifications
569    pub total: usize,
570    /// Unacknowledged notifications
571    pub unacknowledged: usize,
572    /// Count by category
573    pub by_category: std::collections::HashMap<NotificationCategory, usize>,
574    /// Count by priority
575    pub by_priority: std::collections::HashMap<NotificationPriority, usize>,
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn test_underpayment_notification() {
584        let notification = AdminNotification::underpayment(
585            "order-123",
586            "txid-abc",
587            100_000,
588            80_000,
589            "bc1qtest",
590            Some("bc1qrefund".to_string()),
591        );
592
593        assert_eq!(notification.category, NotificationCategory::PaymentMismatch);
594        assert!(notification.title.contains("20000"));
595        assert_eq!(notification.metadata.expected_sats, Some(100_000));
596        assert_eq!(notification.metadata.received_sats, Some(80_000));
597        assert_eq!(notification.metadata.difference_sats, Some(-20000));
598    }
599
600    #[test]
601    fn test_overpayment_notification() {
602        let notification = AdminNotification::overpayment(
603            "order-456",
604            "txid-def",
605            100_000,
606            150_000,
607            "bc1qtest",
608            None,
609        );
610
611        assert_eq!(notification.category, NotificationCategory::PaymentMismatch);
612        assert!(notification.title.contains("50000"));
613        assert_eq!(notification.metadata.difference_sats, Some(50000));
614    }
615
616    #[test]
617    fn test_large_transaction_priority() {
618        // > 100 BTC should be critical
619        let notification = AdminNotification::large_transaction(
620            "txid",
621            15_000_000_000, // 150 BTC
622            "bc1qtest",
623            None,
624        );
625        assert_eq!(notification.priority, NotificationPriority::Critical);
626
627        // > 10 BTC should be high
628        let notification = AdminNotification::large_transaction(
629            "txid",
630            5_000_000_000, // 50 BTC
631            "bc1qtest",
632            None,
633        );
634        assert_eq!(notification.priority, NotificationPriority::High);
635    }
636
637    #[tokio::test]
638    async fn test_notification_service() {
639        let service = AdminNotificationService::new();
640
641        let notification = AdminNotification::new(
642            NotificationCategory::Info,
643            NotificationPriority::Low,
644            "Test",
645            "Test notification",
646        );
647
648        service.notify(notification).await;
649
650        let recent = service.get_recent(10).await;
651        assert_eq!(recent.len(), 1);
652        assert_eq!(recent[0].title, "Test");
653    }
654}