1use serde::{Deserialize, Serialize};
7use std::collections::VecDeque;
8use std::sync::Arc;
9use tokio::sync::{RwLock, broadcast};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13pub enum NotificationPriority {
14 Low,
16 Medium,
18 High,
20 Critical,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub enum NotificationCategory {
27 PaymentMismatch,
29 TransactionReplaced,
31 TransactionDropped,
33 Reorganization,
35 LargeTransaction,
37 SuspiciousActivity,
39 SystemHealth,
41 Info,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct AdminNotification {
48 pub id: String,
50 pub category: NotificationCategory,
52 pub priority: NotificationPriority,
54 pub title: String,
56 pub message: String,
58 pub order_id: Option<String>,
60 pub txid: Option<String>,
62 pub user_id: Option<String>,
64 pub metadata: NotificationMetadata,
66 pub created_at: chrono::DateTime<chrono::Utc>,
68 pub acknowledged: bool,
70 pub acknowledged_at: Option<chrono::DateTime<chrono::Utc>>,
72 pub acknowledged_by: Option<String>,
74}
75
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
78pub struct NotificationMetadata {
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub expected_sats: Option<u64>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub received_sats: Option<u64>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub difference_sats: Option<i64>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub address: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub original_txid: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub replacement_txid: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub fee_increase_sats: Option<u64>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub suggested_action: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub refund_address: Option<String>,
106}
107
108impl AdminNotification {
109 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 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 pub fn with_txid(mut self, txid: impl Into<String>) -> Self {
141 self.txid = Some(txid.into());
142 self
143 }
144
145 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 pub fn with_metadata(mut self, metadata: NotificationMetadata) -> Self {
153 self.metadata = metadata;
154 self
155 }
156
157 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 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 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 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 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 NotificationPriority::Critical
299 } else if amount_sats > 1_000_000_000 {
300 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
327pub struct AdminNotificationService {
329 notification_tx: broadcast::Sender<AdminNotification>,
331 recent: Arc<RwLock<VecDeque<AdminNotification>>>,
333 max_recent: usize,
335 large_tx_threshold: u64,
337}
338
339impl AdminNotificationService {
340 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, }
349 }
350
351 pub fn with_large_tx_threshold(mut self, threshold_sats: u64) -> Self {
353 self.large_tx_threshold = threshold_sats;
354 self
355 }
356
357 pub fn subscribe(&self) -> broadcast::Receiver<AdminNotification> {
359 self.notification_tx.subscribe()
360 }
361
362 pub async fn notify(&self, notification: AdminNotification) {
364 {
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 let _ = self.notification_tx.send(notification.clone());
375
376 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize)]
567pub struct NotificationStats {
568 pub total: usize,
570 pub unacknowledged: usize,
572 pub by_category: std::collections::HashMap<NotificationCategory, usize>,
574 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 let notification = AdminNotification::large_transaction(
620 "txid",
621 15_000_000_000, "bc1qtest",
623 None,
624 );
625 assert_eq!(notification.priority, NotificationPriority::Critical);
626
627 let notification = AdminNotification::large_transaction(
629 "txid",
630 5_000_000_000, "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}