Skip to main content

latch_billing/
export.rs

1//! Export module - defines the `RatedRecordExporter` trait.
2//!
3//! Exporters consume `RatedUsageRecord`s (not raw observations) and
4//! send them to external systems (Stripe, OpenMeter, Kafka, etc.).
5
6use crate::rating::RatedUsageRecord;
7
8/// Trait for exporting rated usage records to external systems.
9///
10/// **Design note**: Exporters consume *rated* records, not raw observations.
11/// This means rating (pricing application) happens before export.
12///
13/// # When to use
14///
15/// - **Billing integration**: Export to Stripe, OpenMeter, etc.
16/// - **Analytics**: Export to data warehouse
17/// - **Real-time dashboards**: Export to Kafka for streaming analytics
18///
19/// # Implementations (Phase 5)
20///
21/// - `OpenMeterExporter`: Export to OpenMeter API
22/// - `KafkaExporter`: Export to Kafka topic
23/// - `StripeExporter`: Export to Stripe for invoicing
24///
25/// # Example
26///
27/// ```rust,ignore
28/// struct StdoutExporter;
29///
30/// impl RatedRecordExporter for StdoutExporter {
31///     fn export(&self, record: &RatedUsageRecord) -> Result<(), ExportError> {
32///         println!("Exported: {} {}", record.rating.total_cost, record.rating.currency);
33///         Ok(())
34///     }
35/// }
36/// ```
37pub trait RatedRecordExporter: Send + Sync {
38    /// Export a rated usage record.
39    ///
40    /// # Errors
41    ///
42    /// Returns `ExportError` if the record cannot be exported.
43    /// Callers should implement retry logic (the exporter itself
44    /// should be idempotent).
45    fn export(&self, record: &RatedUsageRecord) -> Result<(), ExportError>;
46}
47
48/// Error type for export operations.
49#[derive(Debug, Clone)]
50pub enum ExportError {
51    /// Connection error (retryable).
52    ConnectionError(String),
53    /// Authentication error (not retryable - config issue).
54    AuthError(String),
55    /// Invalid data (not retryable - data issue).
56    InvalidData(String),
57    /// Rate limited (retryable with backoff).
58    RateLimited { retry_after_secs: Option<u64> },
59    /// Generic error.
60    Other(String),
61}
62
63impl std::fmt::Display for ExportError {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        match self {
66            ExportError::ConnectionError(e) => write!(f, "Export connection error: {e}"),
67            ExportError::AuthError(e) => write!(f, "Export auth error: {e}"),
68            ExportError::InvalidData(e) => write!(f, "Export invalid data: {e}"),
69            ExportError::RateLimited {
70                retry_after_secs: _,
71            } => write!(f, "Export rate limited"),
72            ExportError::Other(e) => write!(f, "Export error: {e}"),
73        }
74    }
75}
76
77impl std::error::Error for ExportError {}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::identity::UsageEventId;
83    use crate::observation::{MeterKind, MeterSet, UsageObservation, UsageOutcome, UsageSource, UsageTiming, Attributes};
84    use crate::pricing::ModelRef;
85    use crate::rating::{RatedLineItem, RatingResult};
86    use crate::CurrencyCode;
87    use chrono::Utc;
88    use rust_decimal_macros::dec;
89    use std::sync::{Arc, Mutex};
90
91    /// Simple stdout exporter for testing.
92    struct StdoutExporter {
93        exported: Arc<Mutex<Vec<String>>>,
94    }
95
96    impl StdoutExporter {
97        fn new() -> Self {
98            Self {
99                exported: Arc::new(Mutex::new(Vec::new())),
100            }
101        }
102    }
103
104    impl RatedRecordExporter for StdoutExporter {
105        fn export(&self, record: &RatedUsageRecord) -> Result<(), ExportError> {
106            let msg = format!(
107                "{} {}",
108                record.rating.total_cost, record.rating.currency.0
109            );
110            self.exported.lock().unwrap().push(msg);
111            Ok(())
112        }
113    }
114
115    #[test]
116    fn stdout_exporter_works() {
117        let exporter = StdoutExporter::new();
118
119        // Create a minimal rated record
120        let record = RatedUsageRecord {
121            rated_record_id: "test-1:v1".to_string(),
122            observation: UsageObservation {
123                event_id: UsageEventId::from_raw("test-1"),
124                subject: crate::identity::BillingSubject::default(),
125                meter_set: MeterSet::new(),
126                model_ref: ModelRef {
127                    billable_model: "test".to_string(),
128                    vendor: None,
129                    region: None,
130                    tier: None,
131                },
132                provider_ref: None,
133                source: UsageSource::Estimated,
134                outcome: UsageOutcome::Success,
135                timing: UsageTiming {
136                    observed_at: Utc::now(),
137                    completed_at: None,
138                },
139                correlation: crate::identity::CorrelationIds::default(),
140                attributes: Attributes::new(),
141            },
142            rating: RatingResult {
143                line_items: vec![RatedLineItem {
144                    meter_kind: MeterKind::InputTokens,
145                    quantity: 100,
146                    unit_price: dec!(0.0003),
147                    subtotal: dec!(0.03),
148                }],
149                total_cost: dec!(0.03),
150                currency: CurrencyCode::usd(),
151                price_snapshot_id: "snap-1".to_string(),
152                rated_at: Utc::now(),
153            },
154            supersedes: None,
155        };
156
157        exporter.export(&record).unwrap();
158        assert_eq!(exporter.exported.lock().unwrap().len(), 1);
159    }
160
161    #[test]
162    fn export_error_display() {
163        let err = ExportError::ConnectionError("timeout".to_string());
164        assert_eq!(err.to_string(), "Export connection error: timeout");
165    }
166}