Skip to main content

latch_billing/
rating.rs

1//! Rating module - defines rated usage records and the `RatingEngine` trait.
2//!
3//! Rating is the process of converting a `UsageObservation` (raw meters)
4//! into a `RatedUsageRecord` (meters + cost) using a `PriceSnapshot`.
5
6use crate::CurrencyCode;
7use crate::observation::{MeterKind, UsageObservation};
8use crate::pricing::{PriceSnapshot, TierConfig};
9use chrono::{DateTime, Utc};
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12
13/// A rated usage record - the result of applying rating to an observation.
14///
15/// This is the "derived" data - it combines the immutable observation
16/// with the computed cost (rating result).
17///
18/// Each record has an independent identity (`rated_record_id`) for audit trails.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct RatedUsageRecord {
21    /// Unique identifier for this rated record (independent of observation.event_id).
22    ///
23    /// Format: `"{observation.event_id}:v{revision}"`
24    /// - First rating of an observation: `":v1"`
25    /// - Correction: `":v2"` (supersedes v1)
26    pub rated_record_id: String,
27
28    /// The original observation (immutable fact).
29    pub observation: UsageObservation,
30
31    /// The rating result (computed cost).
32    pub rating: RatingResult,
33
34    /// If this record supersedes a previous one, this points to the old record's ID.
35    ///
36    /// Only the latest record in the `supersedes` chain is "active".
37    pub supersedes: Option<String>,
38}
39
40/// The result of rating an observation.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct RatingResult {
43    /// Line items - one per meter kind that was rated.
44    pub line_items: Vec<RatedLineItem>,
45
46    /// Total cost across all line items.
47    pub total_cost: Decimal,
48
49    /// Currency for the total cost.
50    pub currency: CurrencyCode,
51
52    /// ID of the price snapshot used for rating.
53    pub price_snapshot_id: String,
54
55    /// When this rating was performed.
56    pub rated_at: DateTime<Utc>,
57}
58
59/// Context for rating - carries cumulative usage, billing period, etc.
60///
61/// This is needed for tier-based pricing (Phase 3).
62#[derive(Debug, Clone, Default)]
63pub struct RatingContext {
64    /// Cumulative usage of the baseline meter (in MTok).
65    ///
66    /// This is used to determine which tier to apply.
67    pub cumulative_baseline_usage_mtok: u64,
68
69    /// Billing period identifier (e.g., "2025-05") for tier reset.
70    pub billing_period: Option<String>,
71
72    /// Tenant scope for tenant-level accumulation.
73    pub tenant_scope: Option<String>,
74}
75
76/// A single rated line item.
77///
78/// Connects a meter reading to its computed cost.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct RatedLineItem {
81    /// Which meter this line item is for.
82    pub meter_kind: MeterKind,
83
84    /// The quantity that was rated.
85    pub quantity: u64,
86
87    /// Unit price used for this line item.
88    pub unit_price: Decimal,
89
90    /// Subtotal for this line item (= quantity * unit_price, adjusted for display units).
91    pub subtotal: Decimal,
92}
93
94/// Trait for rating engines.
95///
96/// A RatingEngine takes a `UsageObservation`, a `PriceSnapshot`,
97/// and a `RatingContext` and produces a `RatingResult`.
98///
99/// # Design
100///
101/// - `PricingSource` resolves/fetches the snapshot.
102/// - `RatingEngine` computes the cost from the snapshot and context.
103/// This separation allows for easy caching of snapshots and
104/// unit testing of the rating logic.
105///
106/// # Example
107///
108/// ```rust,ignore
109/// let engine = DefaultRatingEngine::new();
110/// let snapshot = pricing_source.resolve_snapshot(&model_ref, Some(&provider_ref))?;
111/// let context = RatingContext::default();
112/// let result = engine.rate(&observation, &snapshot, &context)?;
113/// ```
114pub trait RatingEngine: Send + Sync {
115    /// Rate a usage observation using the given price snapshot and context.
116    ///
117    /// # Errors
118    ///
119    /// Returns `RatingError` if:
120    /// - No price is found for a meter kind in the observation
121    /// - The computed cost overflows Decimal
122    /// - Tier configuration is invalid
123    fn rate(
124        &self,
125        observation: &UsageObservation,
126        snapshot: &PriceSnapshot,
127        context: &RatingContext,
128    ) -> Result<RatingResult, RatingError>;
129}
130
131/// Error type for rating operations.
132#[derive(Debug, Clone)]
133pub enum RatingError {
134    /// No price found for this meter kind in the snapshot.
135    NoPriceForMeter {
136        meter_kind: MeterKind,
137        snapshot_id: String,
138    },
139    /// Decimal overflow during cost calculation.
140    DecimalOverflow {
141        meter_kind: MeterKind,
142        quantity: u64,
143        unit_price: Decimal,
144    },
145    /// Invalid tier configuration.
146    InvalidTierConfig(String),
147    /// No tiers match the given usage.
148    NoMatchingTier {
149        usage_mtok: u64,
150    },
151    /// Generic error.
152    Other(String),
153}
154
155// ============================================================================
156// DefaultRatingEngine
157// ============================================================================
158
159/// Default implementation of `RatingEngine`.
160///
161/// Applies pricing by:
162/// 1. Iterating over meters in `UsageObservation`
163/// 2. Looking up price in `PriceSnapshot`
164/// 3. Calculating subtotal (= quantity * unit_price)
165/// 4. If tier config exists, applying tier-based multiplier
166/// 5. Summing all line items for total cost
167pub struct DefaultRatingEngine;
168
169impl DefaultRatingEngine {
170    /// Create a new `DefaultRatingEngine`.
171    pub fn new() -> Self {
172        Self
173    }
174}
175
176impl RatingEngine for DefaultRatingEngine {
177    fn rate(
178        &self,
179        observation: &UsageObservation,
180        snapshot: &PriceSnapshot,
181        context: &RatingContext,
182    ) -> Result<RatingResult, RatingError> {
183        let mut line_items = Vec::new();
184        let mut total_cost: Decimal = 0.into();
185
186        // Iterate over meters in observation
187        for (meter_kind, &quantity) in &observation.meter_set.meters {
188            // Look up price
189            let price = snapshot.prices.get(meter_kind).ok_or_else(|| {
190                RatingError::NoPriceForMeter {
191                    meter_kind: meter_kind.clone(),
192                    snapshot_id: snapshot.snapshot_id.clone(),
193                }
194            })?;
195
196            // Convert quantity to Decimal
197            let quantity_dec: Decimal = quantity.into();
198            let unit_price = price.unit_price;
199
200            // Calculate subtotal = unit_price * quantity
201            let subtotal = unit_price
202                .checked_mul(quantity_dec)
203                .ok_or_else(|| RatingError::DecimalOverflow {
204                    meter_kind: meter_kind.clone(),
205                    quantity,
206                    unit_price,
207                })?;
208
209            // Apply tier multiplier if tier config exists
210            let final_subtotal = if let Some(ref tier_config) = snapshot.tiers {
211                let multiplier = calculate_tier_multiplier(tier_config, context)?;
212                if let Some(m) = multiplier {
213                    subtotal
214                        .checked_mul(m)
215                        .ok_or_else(|| RatingError::DecimalOverflow {
216                            meter_kind: meter_kind.clone(),
217                            quantity,
218                            unit_price,
219                        })?
220                } else {
221                    subtotal
222                }
223            } else {
224                subtotal
225            };
226
227            total_cost = total_cost
228                .checked_add(final_subtotal)
229                .ok_or_else(|| RatingError::DecimalOverflow {
230                    meter_kind: meter_kind.clone(),
231                    quantity,
232                    unit_price,
233                })?;
234
235            line_items.push(RatedLineItem {
236                meter_kind: meter_kind.clone(),
237                quantity,
238                unit_price,
239                subtotal: final_subtotal,
240            });
241        }
242
243        Ok(RatingResult {
244            line_items,
245            total_cost,
246            currency: snapshot.currency.clone(),
247            price_snapshot_id: snapshot.snapshot_id.clone(),
248            rated_at: Utc::now(),
249        })
250    }
251}
252
253/// Calculate tier multiplier based on cumulative usage.
254fn calculate_tier_multiplier(
255    tier_config: &TierConfig,
256    context: &RatingContext,
257) -> Result<Option<Decimal>, RatingError> {
258    let cumulative = context.cumulative_baseline_usage_mtok;
259
260    // Find the matching tier (last boundary where cumulative <= up_to_mtok)
261    let mut matched_multiplier: Option<Decimal> = None;
262
263    for boundary in &tier_config.boundaries {
264        if cumulative <= boundary.up_to_mtok {
265            if let Some(mp) = boundary.price_multiplier {
266                matched_multiplier = Some(mp);
267            }
268        }
269    }
270
271    Ok(matched_multiplier)
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::observation::MeterSet;
278    use crate::pricing::{MeterPrice, TierBoundary, TierConfig, TierBaseline, AccumulationScope};
279    use rust_decimal_macros::dec;
280
281    #[test]
282    fn rated_line_item_calculation() {
283        // 1000 tokens * $0.0003/token = $0.30
284        let item = RatedLineItem {
285            meter_kind: MeterKind::InputTokens,
286            quantity: 1000,
287            unit_price: dec!(0.0003),
288            subtotal: dec!(0.30),
289        };
290        assert_eq!(item.subtotal, dec!(0.30));
291    }
292
293    #[test]
294    fn rating_result_total_cost() {
295        let result = RatingResult {
296            line_items: vec![],
297            total_cost: dec!(1.50),
298            currency: CurrencyCode::usd(),
299            price_snapshot_id: "snap-123".to_string(),
300            rated_at: Utc::now(),
301        };
302        assert_eq!(result.total_cost, dec!(1.50));
303    }
304
305    #[test]
306    fn default_rating_engine_basic() {
307        let engine = DefaultRatingEngine::new();
308
309        // Create observation
310        let mut meter_set = MeterSet::new();
311        meter_set.accumulate(MeterKind::InputTokens, 1000).unwrap();
312
313        let observation = UsageObservation {
314            event_id: crate::identity::UsageEventId::from_raw("test-1"),
315            subject: crate::identity::BillingSubject::default(),
316            meter_set,
317            model_ref: crate::pricing::ModelRef {
318                billable_model: "test".to_string(),
319                vendor: None,
320                region: None,
321                tier: None,
322            },
323            provider_ref: None,
324            source: crate::observation::UsageSource::Estimated,
325            outcome: crate::observation::UsageOutcome::Success,
326            timing: crate::observation::UsageTiming {
327                observed_at: Utc::now(),
328                completed_at: None,
329            },
330            correlation: crate::identity::CorrelationIds::default(),
331            attributes: crate::observation::Attributes::new(),
332        };
333
334        // Create snapshot
335        let mut prices = std::collections::HashMap::new();
336        prices.insert(
337            MeterKind::InputTokens,
338            MeterPrice {
339                unit_price: dec!(0.0003),
340                unit_display: "1M tokens".to_string(),
341            },
342        );
343
344        let snapshot = PriceSnapshot {
345            snapshot_id: "test-snap".to_string(),
346            model_ref: crate::pricing::ModelRef {
347                billable_model: "test".to_string(),
348                vendor: None,
349                region: None,
350                tier: None,
351            },
352            currency: CurrencyCode::usd(),
353            prices,
354            tiers: None,
355            effective_from: Utc::now(),
356            effective_until: None,
357        };
358
359        let context = RatingContext::default();
360
361        let result = engine.rate(&observation, &snapshot, &context).unwrap();
362        assert_eq!(result.total_cost, dec!(0.30)); // 1000 * 0.0003
363    }
364}