1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct RatedUsageRecord {
21 pub rated_record_id: String,
27
28 pub observation: UsageObservation,
30
31 pub rating: RatingResult,
33
34 pub supersedes: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct RatingResult {
43 pub line_items: Vec<RatedLineItem>,
45
46 pub total_cost: Decimal,
48
49 pub currency: CurrencyCode,
51
52 pub price_snapshot_id: String,
54
55 pub rated_at: DateTime<Utc>,
57}
58
59#[derive(Debug, Clone, Default)]
63pub struct RatingContext {
64 pub cumulative_baseline_usage_mtok: u64,
68
69 pub billing_period: Option<String>,
71
72 pub tenant_scope: Option<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct RatedLineItem {
81 pub meter_kind: MeterKind,
83
84 pub quantity: u64,
86
87 pub unit_price: Decimal,
89
90 pub subtotal: Decimal,
92}
93
94pub trait RatingEngine: Send + Sync {
115 fn rate(
124 &self,
125 observation: &UsageObservation,
126 snapshot: &PriceSnapshot,
127 context: &RatingContext,
128 ) -> Result<RatingResult, RatingError>;
129}
130
131#[derive(Debug, Clone)]
133pub enum RatingError {
134 NoPriceForMeter {
136 meter_kind: MeterKind,
137 snapshot_id: String,
138 },
139 DecimalOverflow {
141 meter_kind: MeterKind,
142 quantity: u64,
143 unit_price: Decimal,
144 },
145 InvalidTierConfig(String),
147 NoMatchingTier {
149 usage_mtok: u64,
150 },
151 Other(String),
153}
154
155pub struct DefaultRatingEngine;
168
169impl DefaultRatingEngine {
170 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 for (meter_kind, &quantity) in &observation.meter_set.meters {
188 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 let quantity_dec: Decimal = quantity.into();
198 let unit_price = price.unit_price;
199
200 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 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
253fn 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 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 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 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 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)); }
364}