orders.rs (27440B)
1 //! Order management API handlers 2 3 use std::time::Instant; 4 5 use axum::{ 6 extract::{Path, Query, State}, 7 http::StatusCode, 8 response::{IntoResponse, Json}, 9 }; 10 use bigdecimal::BigDecimal; 11 use opentelemetry::KeyValue; 12 use sqlx::PgPool; 13 use tracing::{debug, error, info, instrument, warn}; 14 use uuid::Uuid; 15 16 use crate::logging::hash_email; 17 use crate::models::{CreateOrderRequest, OrderQueryParams, OrdersResponse}; 18 use crate::AppState; 19 use crate::{ 20 db::{self, with_transaction, OrderTotals}, 21 metrics::metrics, 22 }; 23 24 /// Product detail response from products service 25 #[derive(Debug, serde::Deserialize)] 26 #[allow(dead_code)] // Fields deserialized from products service response, not all used directly 27 struct ProductDetail { 28 eid: Uuid, 29 product_name: String, 30 final_price: BigDecimal, 31 stock: i32, 32 is_active: bool, 33 } 34 35 /// Request to reserve stock in inventory service 36 #[derive(Debug, serde::Serialize)] 37 struct ReserveStockRequest { 38 product_uuid: Uuid, 39 quantity: i32, 40 } 41 42 /// Request to release reserved stock in inventory service 43 #[derive(Debug, serde::Serialize)] 44 struct ReleaseStockRequest { 45 product_uuid: Uuid, 46 quantity: i32, 47 } 48 49 /// Request to confirm sale in inventory service 50 #[derive(Debug, serde::Serialize)] 51 struct ConfirmSaleRequest { 52 product_uuid: Uuid, 53 quantity: i32, 54 order_uuid: Uuid, 55 } 56 57 /// Response from inventory stock operations 58 #[derive(Debug, serde::Deserialize)] 59 #[allow(dead_code)] // Fields deserialized from inventory service response, not all used directly 60 struct StockOperationResponse { 61 success: bool, 62 message: String, 63 product_uuid: Uuid, 64 available_quantity: Option<i32>, 65 } 66 67 /// Release reserved stock for a product in the inventory service 68 /// 69 /// Calls the inventory service to release previously reserved stock. 70 /// Used for rollback when order creation fails. 71 async fn release_stock(state: &AppState, product_uuid: Uuid, quantity: i32) { 72 let url = format!("{}/inventory/release", state.inventory_service_url()); 73 74 let request_body = ReleaseStockRequest { 75 product_uuid, 76 quantity, 77 }; 78 79 let request = state.http_client().post(&url).json(&request_body); 80 81 let response = request.send().await; 82 83 match response { 84 Ok(resp) => { 85 if resp.status().is_success() { 86 debug!( 87 product.uuid = %product_uuid, 88 quantity = quantity, 89 "Stock released successfully" 90 ); 91 } else { 92 // This is serious — we have leaked inventory 93 error!( 94 product.uuid = %product_uuid, 95 quantity = quantity, 96 error.r#type = "compensation_failure", 97 downstream.status = %resp.status(), 98 "CRITICAL: Failed to release reserved stock" 99 ); 100 } 101 } 102 Err(e) => { 103 error!( 104 product.uuid = %product_uuid, 105 quantity = quantity, 106 error.r#type = "compensation_failure", 107 error.message = %e, 108 "CRITICAL: Failed to release reserved stock" 109 ); 110 } 111 } 112 } 113 114 /// Release all reserved stock (for rollback scenarios) 115 /// 116 /// Iterates through all reserved items and releases their stock. 117 /// Logs errors but doesn't fail - this is a best-effort cleanup. 118 async fn release_all_reserved_stock(state: &AppState, reserved_items: &[(Uuid, i32)]) { 119 if reserved_items.is_empty() { 120 return; 121 } 122 123 info!( 124 item_count = reserved_items.len(), 125 "Releasing reserved stock" 126 ); 127 128 for (product_uuid, quantity) in reserved_items { 129 release_stock(state, *product_uuid, *quantity).await; 130 } 131 132 info!(item_count = reserved_items.len(), "Stock release complete"); 133 } 134 135 /// Confirm a sale with the inventory service 136 /// 137 /// Calls the inventory service to confirm the sale and convert reserved stock to sold. 138 /// This is called after an order is successfully created and committed. 139 async fn confirm_sale( 140 state: &AppState, 141 product_uuid: Uuid, 142 quantity: i32, 143 order_uuid: Uuid, 144 ) -> Result<(), String> { 145 let url = format!("{}/inventory/confirm-sale", state.inventory_service_url()); 146 147 let request_body = ConfirmSaleRequest { 148 product_uuid, 149 quantity, 150 order_uuid, 151 }; 152 153 let request = state.http_client().post(&url).json(&request_body); 154 155 let response = request 156 .send() 157 .await 158 .map_err(|e| format!("Failed to call inventory service: {}", e))?; 159 160 if response.status().is_success() { 161 Ok(()) 162 } else { 163 Err(format!( 164 "Inventory service returned error status: {}", 165 response.status() 166 )) 167 } 168 } 169 170 /// Confirm all sales for order items 171 /// 172 /// Iterates through all order items and confirms the sale with inventory service. 173 /// Logs errors but doesn't fail the order since it's already committed. 174 async fn confirm_all_sales(state: &AppState, order_uuid: Uuid, items: &[(Uuid, i32)]) { 175 if items.is_empty() { 176 return; 177 } 178 179 info!( 180 item_count = items.len(), 181 order_uuid = %order_uuid, 182 "Confirming sales with inventory service" 183 ); 184 185 for (product_uuid, quantity) in items { 186 match confirm_sale(state, *product_uuid, *quantity, order_uuid).await { 187 Ok(_) => { 188 info!( 189 product_uuid = %product_uuid, 190 quantity = quantity, 191 order_uuid = %order_uuid, 192 "Successfully confirmed sale" 193 ); 194 } 195 Err(e) => { 196 // Log error but don't fail - order is already committed 197 warn!( 198 product_uuid = %product_uuid, 199 order_uuid = %order_uuid, 200 error = %e, 201 "Failed to confirm sale - manual intervention may be required" 202 ); 203 } 204 } 205 } 206 } 207 208 /// Reserve stock for a product in the inventory service 209 /// 210 /// Calls the inventory service to reserve stock for an order. 211 /// Returns error if insufficient stock is available. 212 async fn reserve_stock( 213 state: &AppState, 214 product_uuid: Uuid, 215 quantity: i32, 216 ) -> Result<StockOperationResponse, axum::response::Response> { 217 let url = format!("{}/inventory/reserve", state.inventory_service_url()); 218 219 let request_body = ReserveStockRequest { 220 product_uuid, 221 quantity, 222 }; 223 224 let request = state.http_client().post(&url).json(&request_body); 225 226 let response = match request.send().await { 227 Ok(resp) => resp, 228 Err(e) => { 229 eprintln!("Failed to call inventory service: {}", e); 230 return Err(( 231 StatusCode::SERVICE_UNAVAILABLE, 232 Json(serde_json::json!({ 233 "error": "Inventory service unavailable", 234 "details": e.to_string() 235 })), 236 ) 237 .into_response()); 238 } 239 }; 240 241 match response.status() { 242 reqwest::StatusCode::OK => { 243 let stock_response: StockOperationResponse = match response.json().await { 244 Ok(r) => r, 245 Err(e) => { 246 eprintln!("Failed to parse inventory response: {}", e); 247 return Err(( 248 StatusCode::INTERNAL_SERVER_ERROR, 249 Json(serde_json::json!({ 250 "error": "Failed to parse inventory response", 251 "details": e.to_string() 252 })), 253 ) 254 .into_response()); 255 } 256 }; 257 258 if !stock_response.success { 259 return Err(( 260 StatusCode::CONFLICT, 261 Json(serde_json::json!({ 262 "error": "Insufficient stock", 263 "product_uuid": product_uuid.to_string(), 264 "requested_quantity": quantity, 265 "available_quantity": stock_response.available_quantity, 266 "message": stock_response.message 267 })), 268 ) 269 .into_response()); 270 } 271 272 Ok(stock_response) 273 } 274 reqwest::StatusCode::CONFLICT => { 275 // Inventory service returned 409 - insufficient stock 276 let error_response: serde_json::Value = response.json().await.unwrap_or_else(|_| { 277 serde_json::json!({ 278 "error": "Insufficient stock", 279 "product_uuid": product_uuid.to_string() 280 }) 281 }); 282 283 Err((StatusCode::CONFLICT, Json(error_response)).into_response()) 284 } 285 status => { 286 eprintln!("Inventory service returned status: {}", status); 287 Err(( 288 StatusCode::INTERNAL_SERVER_ERROR, 289 Json(serde_json::json!({ 290 "error": "Failed to reserve stock", 291 "status": status.as_u16() 292 })), 293 ) 294 .into_response()) 295 } 296 } 297 } 298 299 /// Validate that a product exists and is available 300 /// 301 /// Calls the products service to verify the product exists and is active. 302 /// Returns the product details for further validation (price, stock, etc.) 303 async fn validate_product( 304 state: &AppState, 305 product_uuid: Uuid, 306 ) -> Result<ProductDetail, axum::response::Response> { 307 let url = format!("{}/products/{}", state.products_service_url(), product_uuid); 308 309 let request = state.http_client().get(&url); 310 311 let response = match request.send().await { 312 Ok(resp) => resp, 313 Err(e) => { 314 eprintln!("Failed to call products service: {}", e); 315 return Err(( 316 StatusCode::SERVICE_UNAVAILABLE, 317 Json(serde_json::json!({ 318 "error": "Products service unavailable", 319 "details": e.to_string() 320 })), 321 ) 322 .into_response()); 323 } 324 }; 325 326 match response.status() { 327 reqwest::StatusCode::OK => { 328 let product: ProductDetail = match response.json().await { 329 Ok(p) => p, 330 Err(e) => { 331 eprintln!("Failed to parse product response: {}", e); 332 return Err(( 333 StatusCode::INTERNAL_SERVER_ERROR, 334 Json(serde_json::json!({ 335 "error": "Failed to parse product data", 336 "details": e.to_string() 337 })), 338 ) 339 .into_response()); 340 } 341 }; 342 343 // Verify product is active 344 if !product.is_active { 345 return Err(( 346 StatusCode::BAD_REQUEST, 347 Json(serde_json::json!({ 348 "error": "Product is not available", 349 "product_uuid": product_uuid.to_string(), 350 "product_name": product.product_name 351 })), 352 ) 353 .into_response()); 354 } 355 356 Ok(product) 357 } 358 reqwest::StatusCode::NOT_FOUND => Err(( 359 StatusCode::BAD_REQUEST, 360 Json(serde_json::json!({ 361 "error": "Product not found", 362 "product_uuid": product_uuid.to_string() 363 })), 364 ) 365 .into_response()), 366 status => { 367 eprintln!("Products service returned status: {}", status); 368 Err(( 369 StatusCode::INTERNAL_SERVER_ERROR, 370 Json(serde_json::json!({ 371 "error": "Failed to validate product", 372 "status": status.as_u16() 373 })), 374 ) 375 .into_response()) 376 } 377 } 378 } 379 380 /// Order creation error type 381 #[derive(Debug)] 382 pub enum OrderError { 383 Database(sqlx::Error), 384 } 385 386 impl From<sqlx::Error> for OrderError { 387 fn from(err: sqlx::Error) -> Self { 388 OrderError::Database(err) 389 } 390 } 391 392 /// Create a new order 393 /// 394 /// # Endpoint 395 /// `POST /orders` 396 /// 397 /// Creates a new order with items, shipping address, and payment info. 398 /// Automatically generates order number and calculates totals. 399 /// 400 /// Uses the `with_transaction` wrapper to ensure all database operations 401 /// are atomic and instrumented with OpenTelemetry semantic conventions. 402 #[instrument( 403 name = "create_order", 404 skip(state, request), 405 fields( 406 customer.email_hash = %hash_email(&request.customer_email), 407 order.item_count = request.items.len(), 408 order.number = tracing::field::Empty, 409 ) 410 )] 411 pub async fn create_order( 412 State(state): State<AppState>, 413 Json(request): Json<CreateOrderRequest>, 414 ) -> impl IntoResponse { 415 // Start timing and record a checkout attempt 416 let start = Instant::now(); 417 metrics().checkout_attempts.add(1, &[]); 418 419 // Business KPI: Conversion funnel started 420 metrics().funnel_started.add(1, &[]); 421 422 // Validate all products exist, are available, and have correct prices 423 for item in &request.items { 424 let product = match validate_product(&state, item.product_uuid).await { 425 Ok(product) => product, 426 Err(response) => { 427 // Product validation failed — record checkout failure 428 record_checkout_failure("product_validation", &start); 429 // and then return error immediately 430 return response; 431 } 432 }; 433 434 // Validate price matches actual product price (prevent price manipulation) 435 if item.unit_price != product.final_price { 436 record_checkout_failure("price_mismatch", &start); 437 return ( 438 StatusCode::BAD_REQUEST, 439 Json(serde_json::json!({ 440 "error": "Price mismatch", 441 "product_uuid": item.product_uuid.to_string(), 442 "product_name": product.product_name, 443 "submitted_price": item.unit_price.to_string(), 444 "actual_price": product.final_price.to_string(), 445 "message": "The submitted price does not match the current product price" 446 })), 447 ) 448 .into_response(); 449 } 450 } 451 452 // Business KPI: Payment info validated (all products and prices confirmed) 453 metrics().funnel_payment_info.add(1, &[]); 454 455 // Reserve stock for all items 456 // Track reserved items so we can release them if something fails 457 let mut reserved_items: Vec<(Uuid, i32)> = Vec::new(); 458 459 for item in &request.items { 460 match reserve_stock(&state, item.product_uuid, item.quantity).await { 461 Ok(_) => { 462 reserved_items.push((item.product_uuid, item.quantity)); 463 } 464 Err(response) => { 465 // Stock reservation failed - release any already reserved stock 466 release_all_reserved_stock(&state, &reserved_items).await; 467 return response; 468 } 469 } 470 } 471 472 // Calculate totals 473 let subtotal: BigDecimal = request 474 .items 475 .iter() 476 .map(|item| &item.unit_price * BigDecimal::from(item.quantity)) 477 .sum(); 478 479 // Simple tax calculation (8% for demo) 480 let tax_amount = &subtotal * BigDecimal::from(8) / BigDecimal::from(100); 481 482 // Flat shipping rate for demo 483 let shipping_amount = BigDecimal::from(10); 484 485 let total = &subtotal + &tax_amount + &shipping_amount; 486 487 let totals = OrderTotals { 488 subtotal, 489 tax_amount, 490 shipping_amount, 491 total: total.clone(), 492 }; 493 494 // Clone data needed for the transaction closure 495 let customer_email = request.customer_email.clone(); 496 let customer_phone = request.customer_phone.clone(); 497 let items = request.items.clone(); 498 let shipping_address = request.shipping_address.clone(); 499 let payment = request.payment.clone(); 500 501 // Execute all database operations within an instrumented transaction 502 let result = with_transaction(state.pool(), "checkout", |tx| { 503 // Move owned values into the closure 504 let customer_email = customer_email.clone(); 505 let customer_phone = customer_phone.clone(); 506 let items = items.clone(); 507 let shipping_address = shipping_address.clone(); 508 let payment = payment.clone(); 509 let totals = totals.clone(); 510 let total = total.clone(); 511 512 Box::pin(async move { 513 // Generate order number 514 let order_number = db::generate_order_number(tx).await?; 515 // This fills in the order.number which was empty, tracing::field::Empty, 516 // at instrumentation macro 517 tracing::Span::current().record("order.number", &order_number); 518 // From this point, every Log in the span includes order.number 519 520 // Create order record 521 let created_order = db::create_order( 522 tx, 523 &order_number, 524 &customer_email, 525 customer_phone.as_deref(), 526 &totals, 527 ) 528 .await?; 529 530 // Create order items 531 db::create_order_items(tx, created_order.id, &items).await?; 532 533 // Create shipping address 534 db::create_shipping_address(tx, created_order.id, &shipping_address).await?; 535 536 // Generate payment reference and create payment 537 let payment_reference = db::generate_payment_reference(tx).await?; 538 db::create_payment(tx, created_order.id, &payment, &payment_reference, &total).await?; 539 540 // Update order status to processing after successful payment 541 db::update_order_payment_status(tx, created_order.id, "paid", "processing").await?; 542 543 Ok::<_, OrderError>(created_order) 544 }) 545 }) 546 .await; 547 548 match result { 549 Ok(created_order) => { 550 // Transaction committed successfully! 551 552 // Record successful checkout metrics 553 let duration = start.elapsed().as_secs_f64(); 554 metrics() 555 .checkout_duration 556 .record(duration, &[KeyValue::new("outcome", "success")]); 557 // Business KPI: Conversion funnel completed 558 metrics().funnel_completed.add(1, &[]); 559 // Business KPI: Time from checkout start to completion 560 metrics().time_to_checkout.record(duration, &[]); 561 // Record the order total as a dollar-value histogram 562 metrics() 563 .order_total_amount 564 .record(bigdecimal_to_f64(&total), &[]); 565 // Record how many line items were in this order 566 metrics() 567 .order_items_count 568 .record(request.items.len() as u64, &[]); 569 570 // Now confirm the sale with inventory service to convert reserved stock to sold 571 confirm_all_sales(&state, created_order.uuid, &reserved_items).await; 572 573 tracing::info!( 574 order.number = %created_order.order_number, 575 order.uuid = %created_order.uuid, 576 order.total = %total, 577 order.status = "processing", 578 duration_ms = (duration * 1000.0) as u64, 579 "Order created successfully" 580 ); 581 582 ( 583 StatusCode::CREATED, 584 Json(serde_json::json!({ 585 "success": true, 586 "order_number": created_order.order_number, 587 "order_uuid": created_order.uuid, 588 "message": "Order created successfully" 589 })), 590 ) 591 .into_response() 592 } 593 594 Err(e) => { 595 // Transaction failed (rolled back automatically) 596 tracing::warn!( 597 reserved_count = reserved_items.len(), 598 "Order creation failed, releasing reserved stock" 599 ); 600 // Record failure 601 record_checkout_failure("order_creation", &start); 602 // Release reserved stock 603 release_all_reserved_stock(&state, &reserved_items).await; 604 605 let duration = start.elapsed().as_secs_f64(); 606 607 let error_msg = match e { 608 OrderError::Database(db_err) => { 609 tracing::error!( 610 error.r#type = "database", 611 error.message = %db_err, 612 duration_ms = (duration * 1000.0) as u64, 613 "Database error during order creation" 614 ); 615 format!("Database error: {}", db_err) 616 } 617 }; 618 619 ( 620 StatusCode::INTERNAL_SERVER_ERROR, 621 Json(serde_json::json!({ 622 "error": "Failed to create order", 623 "details": error_msg 624 })), 625 ) 626 .into_response() 627 } 628 } 629 } 630 631 /// List orders with pagination and filtering 632 /// 633 /// # Endpoint 634 /// `GET /orders` 635 /// 636 /// Supports two modes: 637 /// 1. Authenticated user: Requires X-User-Email header, returns all orders for that user 638 /// 2. Guest user: Requires both email and order_number query params, returns single order 639 pub async fn list_orders( 640 State(state): State<AppState>, 641 headers: axum::http::HeaderMap, 642 Query(params): Query<OrderQueryParams>, 643 ) -> impl IntoResponse { 644 // Check for authenticated user via X-User-Email header 645 let user_email = headers 646 .get("X-User-Email") 647 .and_then(|h| h.to_str().ok()) 648 .map(String::from); 649 650 if let Some(email) = user_email { 651 // Authenticated user: return all their orders 652 return list_user_orders(state.pool(), &email, ¶ms).await; 653 } 654 655 // Guest user: require both email and order_number 656 match (¶ms.customer_email, ¶ms.order_number) { 657 (Some(email), Some(order_number)) => { 658 get_guest_order(state.pool(), email, order_number).await 659 } 660 _ => ( 661 StatusCode::BAD_REQUEST, 662 Json(serde_json::json!({ 663 "error": "Guest users must provide both 'customer_email' and 'order_number' query parameters" 664 })), 665 ) 666 .into_response(), 667 } 668 } 669 670 /// List all orders for an authenticated user 671 async fn list_user_orders( 672 pool: &PgPool, 673 user_email: &str, 674 params: &OrderQueryParams, 675 ) -> axum::response::Response { 676 let page = params.page.unwrap_or(1).max(1); 677 let page_size = params.page_size.unwrap_or(20).clamp(1, 100); 678 let result = db::list_orders_by_email( 679 pool, 680 user_email, 681 params.status.as_deref(), 682 params.payment_status.as_deref(), 683 page, 684 page_size, 685 ) 686 .await; 687 688 match result { 689 Ok((orders, total_count)) => { 690 let total_pages = ((total_count as f64) / (page_size as f64)).ceil() as i32; 691 692 let response = OrdersResponse { 693 orders, 694 total_count, 695 page, 696 page_size, 697 total_pages, 698 }; 699 700 (StatusCode::OK, Json(response)).into_response() 701 } 702 Err(e) => { 703 tracing::error!(error = %e, "Database error fetching orders"); 704 ( 705 StatusCode::INTERNAL_SERVER_ERROR, 706 Json(serde_json::json!({ 707 "error": "Failed to fetch orders", 708 "details": e.to_string() 709 })), 710 ) 711 .into_response() 712 } 713 } 714 } 715 716 /// Get a single order for a guest user 717 async fn get_guest_order( 718 pool: &PgPool, 719 email: &str, 720 order_number: &str, 721 ) -> axum::response::Response { 722 let result = db::get_order_by_email_and_number(pool, email, order_number).await; 723 724 match result { 725 Ok(Some(order)) => { 726 // Return as single-item list for consistency 727 let response = OrdersResponse { 728 orders: vec![order], 729 total_count: 1, 730 page: 1, 731 page_size: 1, 732 total_pages: 1, 733 }; 734 (StatusCode::OK, Json(response)).into_response() 735 } 736 Ok(None) => ( 737 StatusCode::NOT_FOUND, 738 Json(serde_json::json!({ 739 "error": "Order not found or email does not match" 740 })), 741 ) 742 .into_response(), 743 Err(e) => { 744 tracing::error!(error = %e, "Database error fetching order"); 745 ( 746 StatusCode::INTERNAL_SERVER_ERROR, 747 Json(serde_json::json!({ 748 "error": "Failed to fetch order", 749 "details": e.to_string() 750 })), 751 ) 752 .into_response() 753 } 754 } 755 } 756 757 /// Get order details by UUID 758 /// 759 /// # Endpoint 760 /// `GET /orders/{uuid}` 761 pub async fn get_order_by_id( 762 State(state): State<AppState>, 763 Path(uuid): Path<Uuid>, 764 ) -> impl IntoResponse { 765 let result = db::get_order_by_uuid(state.pool(), uuid).await; 766 767 match result { 768 Ok(Some(order)) => (StatusCode::OK, Json(order)).into_response(), 769 Ok(None) => ( 770 StatusCode::NOT_FOUND, 771 Json(serde_json::json!({ 772 "error": "Order not found", 773 "uuid": uuid.to_string() 774 })), 775 ) 776 .into_response(), 777 Err(e) => { 778 tracing::error!(error = %e, "Database error fetching order"); 779 ( 780 StatusCode::INTERNAL_SERVER_ERROR, 781 Json(serde_json::json!({ 782 "error": "Failed to fetch order", 783 "details": e.to_string() 784 })), 785 ) 786 .into_response() 787 } 788 } 789 } 790 791 // --------------------------------------------------------------------------- 792 // Metric helper functions 793 // --------------------------------------------------------------------------- 794 795 /// Records a checkout failure with the given reason. 796 /// 797 /// Increments the failure counter and records the duration histogram 798 /// with `outcome=failure` and a `failure.reason` attribute. 799 fn record_checkout_failure(reason: &str, start: &Instant) { 800 let duration = start.elapsed().as_secs_f64(); 801 metrics() 802 .checkout_failures 803 .add(1, &[KeyValue::new("failure.reason", reason.to_string())]); 804 metrics().checkout_duration.record( 805 duration, 806 &[ 807 KeyValue::new("outcome", "failure"), 808 KeyValue::new("failure.reason", reason.to_string()), 809 ], 810 ); 811 } 812 813 /// Produces a short hex hash of the input string (e.g. an email address) 814 /// for use as a PII-safe span attribute. 815 #[allow(dead_code)] 816 fn hash_short(input: &str) -> String { 817 use std::collections::hash_map::DefaultHasher; 818 use std::hash::{Hash, Hasher}; 819 let mut hasher = DefaultHasher::new(); 820 input.hash(&mut hasher); 821 format!("{:x}", hasher.finish()) 822 } 823 824 /// Converts a `BigDecimal` to `f64` for metric recording. 825 /// Falls back to 0.0 if the conversion is lossy. 826 fn bigdecimal_to_f64(value: &BigDecimal) -> f64 { 827 use std::str::FromStr; 828 f64::from_str(&value.to_string()).unwrap_or(0.0) 829 }