1use rust_decimal::Decimal;
12use std::sync::{Arc, RwLock};
13
14#[cfg(not(target_arch = "wasm32"))]
15use std::time::{Duration, Instant};
16
17#[cfg(target_arch = "wasm32")]
18use web_time::{Duration, Instant};
19
20use zakat_core::types::{ZakatError, InvalidInputDetails, ErrorDetails};
21use zakat_core::inputs::IntoZakatDecimal;
22
23#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
25pub struct Prices {
26 pub gold_per_gram: Decimal,
28 pub silver_per_gram: Decimal,
30}
31
32impl Prices {
33 pub fn new(
35 gold_per_gram: impl IntoZakatDecimal,
36 silver_per_gram: impl IntoZakatDecimal,
37 ) -> Result<Self, ZakatError> {
38 let gold = gold_per_gram.into_zakat_decimal()?;
39 let silver = silver_per_gram.into_zakat_decimal()?;
40
41 if gold < Decimal::ZERO || silver < Decimal::ZERO {
42 return Err(ZakatError::InvalidInput(Box::new(InvalidInputDetails {
43 field: "prices".to_string(),
44 value: "negative".to_string(),
45 reason_key: "error-prices-negative".to_string(),
46 suggestion: Some("Prices must be positive values.".to_string()),
47 ..Default::default()
48 })));
49 }
50
51 Ok(Self {
52 gold_per_gram: gold,
53 silver_per_gram: silver,
54 })
55 }
56}
57
58#[cfg(not(target_arch = "wasm32"))]
70#[async_trait::async_trait]
71pub trait PriceProvider: Send + Sync {
72 async fn get_prices(&self) -> Result<Prices, ZakatError>;
74
75 fn name(&self) -> &str {
77 "PriceProvider"
78 }
79}
80
81#[cfg(target_arch = "wasm32")]
82#[async_trait::async_trait(?Send)]
83pub trait PriceProvider {
84 async fn get_prices(&self) -> Result<Prices, ZakatError>;
86
87 fn name(&self) -> &str {
89 "PriceProvider"
90 }
91}
92
93#[derive(Debug, Clone)]
100pub struct StaticPriceProvider {
101 prices: Prices,
102 name: String,
103}
104
105impl StaticPriceProvider {
106 pub fn new(
108 gold_per_gram: impl IntoZakatDecimal,
109 silver_per_gram: impl IntoZakatDecimal,
110 ) -> Result<Self, ZakatError> {
111 Ok(Self {
112 prices: Prices::new(gold_per_gram, silver_per_gram)?,
113 name: "StaticPriceProvider".to_string(),
114 })
115 }
116
117 pub fn from_prices(prices: Prices) -> Self {
119 Self {
120 prices,
121 name: "StaticPriceProvider".to_string(),
122 }
123 }
124
125 pub fn with_name(mut self, name: impl Into<String>) -> Self {
127 self.name = name.into();
128 self
129 }
130}
131
132#[cfg(not(target_arch = "wasm32"))]
133#[async_trait::async_trait]
134impl PriceProvider for StaticPriceProvider {
135 async fn get_prices(&self) -> Result<Prices, ZakatError> {
136 Ok(self.prices.clone())
137 }
138
139 fn name(&self) -> &str {
140 &self.name
141 }
142}
143
144#[cfg(target_arch = "wasm32")]
145#[async_trait::async_trait(?Send)]
146impl PriceProvider for StaticPriceProvider {
147 async fn get_prices(&self) -> Result<Prices, ZakatError> {
148 Ok(self.prices.clone())
149 }
150
151 fn name(&self) -> &str {
152 &self.name
153 }
154}
155
156
157
158#[cfg(not(target_arch = "wasm32"))]
168#[async_trait::async_trait]
169pub trait HistoricalPriceProvider: Send + Sync {
170 async fn get_prices_on(&self, date: chrono::NaiveDate) -> Result<Prices, ZakatError>;
172}
173
174#[cfg(target_arch = "wasm32")]
175#[async_trait::async_trait(?Send)]
176pub trait HistoricalPriceProvider {
177 async fn get_prices_on(&self, date: chrono::NaiveDate) -> Result<Prices, ZakatError>;
178}
179
180#[derive(Debug, Clone)]
185pub struct StaticHistoricalPriceProvider {
186 prices: std::collections::HashMap<chrono::NaiveDate, Prices>,
187 default_price: Option<Prices>,
188}
189
190impl StaticHistoricalPriceProvider {
191 pub fn new() -> Self {
193 Self {
194 prices: std::collections::HashMap::new(),
195 default_price: None,
196 }
197 }
198
199 pub fn with_price(mut self, date: chrono::NaiveDate, prices: Prices) -> Self {
201 self.prices.insert(date, prices);
202 self
203 }
204
205 pub fn with_default(mut self, prices: Prices) -> Self {
207 self.default_price = Some(prices);
208 self
209 }
210}
211
212#[cfg(not(target_arch = "wasm32"))]
213#[async_trait::async_trait]
214impl HistoricalPriceProvider for StaticHistoricalPriceProvider {
215 async fn get_prices_on(&self, date: chrono::NaiveDate) -> Result<Prices, ZakatError> {
216 if let Some(p) = self.prices.get(&date) {
217 Ok(p.clone())
218 } else if let Some(d) = &self.default_price {
219 Ok(d.clone())
220 } else {
221 Err(ZakatError::NetworkError(format!("No historical price found for {}", date)))
222 }
223 }
224}
225
226#[cfg(target_arch = "wasm32")]
227#[async_trait::async_trait(?Send)]
228impl HistoricalPriceProvider for StaticHistoricalPriceProvider {
229 async fn get_prices_on(&self, date: chrono::NaiveDate) -> Result<Prices, ZakatError> {
230 if let Some(p) = self.prices.get(&date) {
231 Ok(p.clone())
232 } else if let Some(d) = &self.default_price {
233 Ok(d.clone())
234 } else {
235 Err(ZakatError::NetworkError(format!("No historical price found for {}", date)))
236 }
237 }
238}
239
240
241#[cfg(not(target_arch = "wasm32"))]
257pub struct FailoverPriceProvider {
258 providers: Vec<Box<dyn PriceProvider>>,
259}
260
261#[cfg(not(target_arch = "wasm32"))]
262impl FailoverPriceProvider {
263 pub fn new() -> Self {
265 Self {
266 providers: Vec::new(),
267 }
268 }
269
270 pub fn add_provider<P: PriceProvider + 'static>(mut self, provider: P) -> Self {
273 self.providers.push(Box::new(provider));
274 self
275 }
276
277 pub fn provider_count(&self) -> usize {
279 self.providers.len()
280 }
281}
282
283#[cfg(not(target_arch = "wasm32"))]
284impl Default for FailoverPriceProvider {
285 fn default() -> Self {
286 Self::new()
287 }
288}
289
290#[cfg(not(target_arch = "wasm32"))]
291#[async_trait::async_trait]
292impl PriceProvider for FailoverPriceProvider {
293 async fn get_prices(&self) -> Result<Prices, ZakatError> {
294 if self.providers.is_empty() {
295 return Err(ZakatError::ConfigurationError(Box::new(ErrorDetails {
296 code: zakat_core::types::ZakatErrorCode::ConfigError,
297 reason_key: "error-no-price-providers".to_string(),
298 source_label: Some("FailoverPriceProvider".to_string()),
299 suggestion: Some("Add at least one price provider using add_provider().".to_string()),
300 ..Default::default()
301 })));
302 }
303
304 let mut last_error: Option<ZakatError> = None;
305
306 for (index, provider) in self.providers.iter().enumerate() {
307 match provider.get_prices().await {
308 Ok(prices) => {
309 if index > 0 {
310 tracing::info!(
311 "Price fetch succeeded using fallback provider '{}' (attempt {})",
312 provider.name(),
313 index + 1
314 );
315 }
316 return Ok(prices);
317 }
318 Err(e) => {
319 tracing::warn!(
320 "Price provider '{}' failed (attempt {}/{}): {}",
321 provider.name(),
322 index + 1,
323 self.providers.len(),
324 e
325 );
326 last_error = Some(e);
327 }
328 }
329 }
330
331 Err(last_error.unwrap_or_else(|| {
333 ZakatError::NetworkError("All price providers failed".to_string())
334 }))
335 }
336
337 fn name(&self) -> &str {
338 "FailoverPriceProvider"
339 }
340}
341
342#[cfg(not(target_arch = "wasm32"))]
372pub struct BestEffortPriceProvider<P: PriceProvider> {
373 primary: P,
374 fallback: Prices,
375 last_known_good: Arc<RwLock<Option<Prices>>>,
377}
378
379#[cfg(not(target_arch = "wasm32"))]
380impl<P: PriceProvider> BestEffortPriceProvider<P> {
381 pub fn new(primary: P, fallback: Prices) -> Self {
383 Self {
384 primary,
385 fallback,
386 last_known_good: Arc::new(RwLock::new(None)),
387 }
388 }
389
390 pub fn with_cached_fallback(primary: P, initial_fallback: Prices) -> Self {
394 Self::new(primary, initial_fallback)
395 }
396
397 pub fn fallback_prices(&self) -> &Prices {
399 &self.fallback
400 }
401
402 pub fn set_fallback(&mut self, prices: Prices) {
404 self.fallback = prices;
405 }
406}
407
408#[cfg(not(target_arch = "wasm32"))]
409#[async_trait::async_trait]
410impl<P: PriceProvider + Send + Sync> PriceProvider for BestEffortPriceProvider<P> {
411 async fn get_prices(&self) -> Result<Prices, ZakatError> {
412 match self.primary.get_prices().await {
413 Ok(prices) => {
414 if let Ok(mut guard) = self.last_known_good.write() {
416 *guard = Some(prices.clone());
417 }
418 Ok(prices)
419 }
420 Err(e) => {
421 tracing::warn!(
422 "Primary price provider '{}' failed: {}. Using fallback prices.",
423 self.primary.name(),
424 e
425 );
426
427 if let Ok(guard) = self.last_known_good.read() {
429 if let Some(cached) = &*guard {
430 tracing::info!("Using last known good prices from cache");
431 return Ok(cached.clone());
432 }
433 }
434
435 tracing::info!(
437 "Using static fallback prices: Gold={}, Silver={}",
438 self.fallback.gold_per_gram,
439 self.fallback.silver_per_gram
440 );
441 Ok(self.fallback.clone())
442 }
443 }
444 }
445
446 fn name(&self) -> &str {
447 "BestEffortPriceProvider"
448 }
449}
450
451#[cfg(target_arch = "wasm32")]
453pub struct BestEffortPriceProvider<P: PriceProvider> {
454 primary: P,
455 fallback: Prices,
456 last_known_good: Arc<RwLock<Option<Prices>>>,
457}
458
459#[cfg(target_arch = "wasm32")]
460impl<P: PriceProvider> BestEffortPriceProvider<P> {
461 pub fn new(primary: P, fallback: Prices) -> Self {
463 Self {
464 primary,
465 fallback,
466 last_known_good: Arc::new(RwLock::new(None)),
467 }
468 }
469
470 pub fn fallback_prices(&self) -> &Prices {
472 &self.fallback
473 }
474
475 pub fn set_fallback(&mut self, prices: Prices) {
477 self.fallback = prices;
478 }
479}
480
481#[cfg(target_arch = "wasm32")]
482#[async_trait::async_trait(?Send)]
483impl<P: PriceProvider> PriceProvider for BestEffortPriceProvider<P> {
484 async fn get_prices(&self) -> Result<Prices, ZakatError> {
485 match self.primary.get_prices().await {
486 Ok(prices) => {
487 if let Ok(mut guard) = self.last_known_good.write() {
488 *guard = Some(prices.clone());
489 }
490 Ok(prices)
491 }
492 Err(_e) => {
493 if let Ok(guard) = self.last_known_good.read() {
495 if let Some(cached) = &*guard {
496 return Ok(cached.clone());
497 }
498 }
499 Ok(self.fallback.clone())
500 }
501 }
502 }
503
504 fn name(&self) -> &str {
505 "BestEffortPriceProvider"
506 }
507}
508
509#[cfg(target_arch = "wasm32")]
511pub struct FailoverPriceProvider {
512 providers: Vec<Box<dyn PriceProvider>>,
513}
514
515#[cfg(target_arch = "wasm32")]
516impl FailoverPriceProvider {
517 pub fn new() -> Self {
519 Self {
520 providers: Vec::new(),
521 }
522 }
523
524 pub fn add_provider<P: PriceProvider + 'static>(mut self, provider: P) -> Self {
526 self.providers.push(Box::new(provider));
527 self
528 }
529
530 pub fn provider_count(&self) -> usize {
532 self.providers.len()
533 }
534}
535
536#[cfg(target_arch = "wasm32")]
537impl Default for FailoverPriceProvider {
538 fn default() -> Self {
539 Self::new()
540 }
541}
542
543#[cfg(target_arch = "wasm32")]
544#[async_trait::async_trait(?Send)]
545impl PriceProvider for FailoverPriceProvider {
546 async fn get_prices(&self) -> Result<Prices, ZakatError> {
547 if self.providers.is_empty() {
548 return Err(ZakatError::ConfigurationError(Box::new(ErrorDetails {
549 code: zakat_core::types::ZakatErrorCode::ConfigError,
550 reason_key: "error-no-price-providers".to_string(),
551 source_label: Some("FailoverPriceProvider".to_string()),
552 suggestion: Some("Add at least one price provider.".to_string()),
553 ..Default::default()
554 })));
555 }
556
557 let mut last_error: Option<ZakatError> = None;
558
559 for provider in &self.providers {
560 match provider.get_prices().await {
561 Ok(prices) => return Ok(prices),
562 Err(e) => {
563 last_error = Some(e);
564 }
565 }
566 }
567
568 Err(last_error.unwrap_or_else(|| {
569 ZakatError::NetworkError("All price providers failed".to_string())
570 }))
571 }
572
573 fn name(&self) -> &str {
574 "FailoverPriceProvider"
575 }
576}
577
578#[derive(Debug, Clone)]
582pub struct CachedPriceProvider<P> {
583 inner: P,
584 cache: Arc<RwLock<Option<(Instant, Prices)>>>,
585 ttl: Duration,
586}
587
588impl<P> CachedPriceProvider<P> {
589 pub fn new(inner: P, ttl_seconds: u64) -> Self {
595 Self {
596 inner,
597 cache: Arc::new(RwLock::new(None)),
598 ttl: Duration::from_secs(ttl_seconds),
599 }
600 }
601}
602
603#[cfg(not(target_arch = "wasm32"))]
604#[async_trait::async_trait]
605impl<P: PriceProvider + Send + Sync> PriceProvider for CachedPriceProvider<P> {
606 async fn get_prices(&self) -> Result<Prices, ZakatError> {
607 if let Ok(guard) = self.cache.read() {
609 if let Some((timestamp, prices)) = &*guard {
610 if timestamp.elapsed() < self.ttl {
611 return Ok(prices.clone());
612 }
613 }
614 }
615
616 let new_prices = self.inner.get_prices().await?;
618
619 if let Ok(mut guard) = self.cache.write() {
620 *guard = Some((Instant::now(), new_prices.clone()));
621 }
622
623 Ok(new_prices)
624 }
625}
626
627#[cfg(target_arch = "wasm32")]
628#[async_trait::async_trait(?Send)]
629impl<P: PriceProvider> PriceProvider for CachedPriceProvider<P> {
630 async fn get_prices(&self) -> Result<Prices, ZakatError> {
631 if let Ok(guard) = self.cache.read() {
633 if let Some((timestamp, prices)) = &*guard {
634 if timestamp.elapsed() < self.ttl {
635 return Ok(prices.clone());
636 }
637 }
638 }
639
640 let new_prices = self.inner.get_prices().await?;
642
643 if let Ok(mut guard) = self.cache.write() {
644 *guard = Some((Instant::now(), new_prices.clone()));
645 }
646
647 Ok(new_prices)
648 }
649}
650
651#[derive(Debug, Clone)]
653pub struct NetworkConfig {
654 pub timeout_seconds: u64,
655 #[cfg(not(target_arch = "wasm32"))]
656 pub binance_api_ip: Option<std::net::IpAddr>,
657 #[cfg(not(target_arch = "wasm32"))]
658 pub dns_over_https_url: Option<String>,
659}
660
661impl Default for NetworkConfig {
662 fn default() -> Self {
663 Self {
664 timeout_seconds: 10,
665 #[cfg(not(target_arch = "wasm32"))]
666 binance_api_ip: None,
667 #[cfg(not(target_arch = "wasm32"))]
668 dns_over_https_url: None, }
670 }
671}
672
673#[cfg(all(feature = "live-pricing", not(target_arch = "wasm32")))]
678#[derive(serde::Deserialize)]
679struct BinanceTicker {
680 #[allow(dead_code)]
681 symbol: String,
682 price: String,
683}
684
685#[cfg(all(feature = "live-pricing", not(target_arch = "wasm32")))]
698pub struct BinancePriceProvider {
699 client: reqwest::Client,
700 failure_count: std::sync::atomic::AtomicUsize,
702}
703
704#[cfg(all(feature = "live-pricing", not(target_arch = "wasm32")))]
705impl BinancePriceProvider {
706 const CIRCUIT_BREAKER_THRESHOLD: usize = 3;
708
709 pub fn new(config: &NetworkConfig) -> Self {
711 let resolved_ip = Self::resolve_with_fallback(config);
712
713 let mut builder = reqwest::Client::builder()
714 .timeout(std::time::Duration::from_secs(config.timeout_seconds));
715
716 if let Some(ip) = resolved_ip {
717 let socket = std::net::SocketAddr::new(ip, 443);
718 builder = builder.resolve("api.binance.com", socket);
719 }
720
721 Self {
722 client: builder.build().unwrap_or_default(),
723 failure_count: std::sync::atomic::AtomicUsize::new(0),
724 }
725 }
726
727 fn resolve_with_fallback(config: &NetworkConfig) -> Option<std::net::IpAddr> {
729 if let Some(ip) = config.binance_api_ip {
731 tracing::info!("Using user-provided Binance API IP: {}", ip);
732 return Some(ip);
733 }
734
735 use std::net::ToSocketAddrs;
737 if let Ok(mut addrs) = ("api.binance.com", 443).to_socket_addrs() {
738 if let Some(addr) = addrs.next() {
739 tracing::debug!("Standard DNS resolved Binance API: {}", addr.ip());
740 return Some(addr.ip());
741 }
742 }
743
744 tracing::warn!("Standard DNS failed for api.binance.com, trying DoH...");
745
746 let doh_url = config.dns_over_https_url.as_deref().unwrap_or("https://cloudflare-dns.com/dns-query");
748 if let Some(ip) = Self::resolve_via_doh("api.binance.com", doh_url) {
749 tracing::info!("DoH resolved Binance API: {}", ip);
750 return Some(ip);
751 }
752
753 tracing::warn!("DoH resolution failed. No fallback IP available (security/maintenance risk removed).");
754 None
755 }
756
757 fn resolve_via_doh(domain: &str, doh_endpoint: &str) -> Option<std::net::IpAddr> {
759 let url = format!(
762 "{}?name={}&type=A",
763 doh_endpoint,
764 domain
765 );
766
767 let client = reqwest::blocking::Client::builder()
769 .timeout(std::time::Duration::from_secs(5))
770 .build()
771 .ok()?;
772
773 let response = client.get(&url)
774 .header("Accept", "application/dns-json")
775 .send()
776 .ok()?;
777
778 let json: serde_json::Value = response.json().ok()?;
779
780 let answer = json.get("Answer")?.as_array()?;
783 for record in answer {
784 if let Some(data) = record.get("data").and_then(|d: &serde_json::Value| d.as_str()) {
785 if let Ok(ip) = data.parse::<std::net::IpAddr>() {
786 return Some(ip);
787 }
788 }
789 }
790
791 None
792 }
793
794 fn record_failure(&self) {
796 self.failure_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
797 }
798
799 fn record_success(&self) {
801 self.failure_count.store(0, std::sync::atomic::Ordering::SeqCst);
802 }
803
804 fn is_circuit_open(&self) -> bool {
806 self.failure_count.load(std::sync::atomic::Ordering::SeqCst) >= Self::CIRCUIT_BREAKER_THRESHOLD
807 }
808}
809
810#[cfg(all(feature = "live-pricing", not(target_arch = "wasm32")))]
811impl Default for BinancePriceProvider {
812 fn default() -> Self {
813 Self::new(&NetworkConfig::default())
814 }
815}
816
817#[cfg(all(feature = "live-pricing", not(target_arch = "wasm32")))]
818#[async_trait::async_trait]
819impl PriceProvider for BinancePriceProvider {
820 async fn get_prices(&self) -> Result<Prices, ZakatError> {
821 if self.is_circuit_open() {
823 tracing::warn!("Circuit breaker open - too many failures, using cached/fallback data recommended");
824 }
825
826 const OUNCE_TO_GRAM: rust_decimal::Decimal = rust_decimal_macros::dec!(31.1034768);
828
829 let url = "https://api.binance.com/api/v3/ticker/price?symbol=PAXGUSDT";
832
833 let mut attempts = 0;
834 let max_retries = 3;
835 let mut backoff = std::time::Duration::from_millis(500);
836
837 let response = loop {
838 attempts += 1;
839 match self.client.get(url).send().await {
840 Ok(resp) => {
841 if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
842 let retry_after = resp.headers()
843 .get(reqwest::header::RETRY_AFTER)
844 .and_then(|val| val.to_str().ok())
845 .and_then(|s| s.parse::<u64>().ok())
846 .unwrap_or(60); let wait_time = std::time::Duration::from_secs(retry_after.min(60)); tracing::warn!("Binance 429 Too Many Requests. Waiting {:?} before retry...", wait_time);
850 tokio::time::sleep(wait_time).await;
851 continue;
857 }
858 self.record_success();
859 break resp;
860 }
861 Err(e) => {
862 if attempts > max_retries {
863 self.record_failure();
864 return Err(ZakatError::NetworkError(format!("Binance API error after {} attempts: {}", attempts, e)));
865 }
866
867 tracing::warn!("Binance API request failed (attempt {}/{}): {}. Retrying in {:?}...", attempts, max_retries + 1, e, backoff);
868 tokio::time::sleep(backoff).await;
869 backoff = backoff.checked_mul(2).unwrap_or(backoff); }
871 }
872 };
873
874 let ticker: BinanceTicker = response.json()
875 .await
876 .map_err(|e| ZakatError::NetworkError(format!("Failed to parse Binance response: {}", e)))?;
877
878 let price_per_ounce = rust_decimal::Decimal::from_str_exact(&ticker.price)
879 .map_err(|e| ZakatError::CalculationError(Box::new(ErrorDetails {
880 code: zakat_core::types::ZakatErrorCode::CalculationError,
881 reason_key: "error-calculation-failed".to_string(),
882 args: Some(std::collections::HashMap::from([("details".to_string(), format!("Failed to parse price decimal: {}", e))])),
883 suggestion: Some("The price API returned an invalid number format.".to_string()),
884 ..Default::default()
885 })))?;
886
887 let gold_per_gram = price_per_ounce / OUNCE_TO_GRAM;
888
889 tracing::warn!("BinancePriceProvider does not support live Silver prices; using fallback/zero");
890
891 Ok(Prices {
892 gold_per_gram,
893 silver_per_gram: rust_decimal::Decimal::ZERO,
894 })
895 }
896}
897
898#[cfg(target_arch = "wasm32")]
903#[derive(serde::Deserialize)]
904struct BinanceTickerWasm {
905 #[allow(dead_code)]
906 symbol: String,
907 price: String,
908}
909
910#[cfg(target_arch = "wasm32")]
914pub struct BinancePriceProvider;
915
916#[cfg(target_arch = "wasm32")]
917impl BinancePriceProvider {
918 pub fn new(_config: &NetworkConfig) -> Self {
920 Self
921 }
922}
923
924#[cfg(target_arch = "wasm32")]
925impl Default for BinancePriceProvider {
926 fn default() -> Self {
927 Self
928 }
929}
930
931#[cfg(target_arch = "wasm32")]
932#[async_trait::async_trait(?Send)]
933impl PriceProvider for BinancePriceProvider {
934 async fn get_prices(&self) -> Result<Prices, ZakatError> {
935 use gloo_net::http::Request;
936
937 const OUNCE_TO_GRAM: rust_decimal::Decimal = rust_decimal_macros::dec!(31.1034768);
939
940 let url = "https://api.binance.com/api/v3/ticker/price?symbol=PAXGUSDT";
942
943 let response = Request::get(url)
944 .send()
945 .await
946 .map_err(|e| ZakatError::NetworkError(format!("Binance API error: {}", e)))?;
947
948 let ticker: BinanceTickerWasm = response.json()
949 .await
950 .map_err(|e| ZakatError::NetworkError(format!("Failed to parse Binance response: {}", e)))?;
951
952 let price_per_ounce = rust_decimal::Decimal::from_str_exact(&ticker.price)
953 .map_err(|e| ZakatError::CalculationError(Box::new(ErrorDetails {
954 code: zakat_core::types::ZakatErrorCode::CalculationError,
955 reason_key: "error-calculation-failed".to_string(),
956 args: Some(std::collections::HashMap::from([("details".to_string(), format!("Failed to parse price decimal: {}", e))])),
957 suggestion: Some("The price API returned an invalid number format.".to_string()),
958 ..Default::default()
959 })))?;
960
961 let gold_per_gram = price_per_ounce / OUNCE_TO_GRAM;
962
963 Ok(Prices {
964 gold_per_gram,
965 silver_per_gram: rust_decimal::Decimal::ZERO,
966 })
967 }
968}
969
970#[cfg(test)]
971mod tests {
972 use super::*;
973 use rust_decimal_macros::dec;
974
975 #[test]
976 fn test_prices_creation() {
977 let prices = Prices::new(65, 1).unwrap();
978 assert_eq!(prices.gold_per_gram, dec!(65));
979 assert_eq!(prices.silver_per_gram, dec!(1));
980 }
981
982 #[test]
983 fn test_prices_rejects_negative() {
984 let result = Prices::new(-10, 1);
985 assert!(result.is_err());
986 }
987
988 #[test]
989 fn test_static_provider_creation() {
990 let provider = StaticPriceProvider::new(100, 2).unwrap();
991 assert_eq!(provider.prices.gold_per_gram, dec!(100));
992 }
993
994 #[cfg(not(target_arch = "wasm32"))]
995 #[tokio::test]
996 async fn test_cached_provider() {
997 let static_provider = StaticPriceProvider::new(100, 2).unwrap();
998 let cached_provider = CachedPriceProvider::new(static_provider, 1);
999
1000 let prices1 = cached_provider.get_prices().await.unwrap();
1001 assert_eq!(prices1.gold_per_gram, dec!(100));
1002
1003 let prices2 = cached_provider.get_prices().await.unwrap();
1004 assert_eq!(prices2.gold_per_gram, dec!(100));
1005 }
1006
1007 #[cfg(not(target_arch = "wasm32"))]
1013 struct MockFailingProvider {
1014 name: String,
1015 }
1016
1017 #[cfg(not(target_arch = "wasm32"))]
1018 impl MockFailingProvider {
1019 fn new(name: impl Into<String>) -> Self {
1020 Self { name: name.into() }
1021 }
1022 }
1023
1024 #[cfg(not(target_arch = "wasm32"))]
1025 #[async_trait::async_trait]
1026 impl PriceProvider for MockFailingProvider {
1027 async fn get_prices(&self) -> Result<Prices, ZakatError> {
1028 Err(ZakatError::NetworkError(format!("{} failed", self.name)))
1029 }
1030
1031 fn name(&self) -> &str {
1032 &self.name
1033 }
1034 }
1035
1036 #[cfg(not(target_arch = "wasm32"))]
1037 #[tokio::test]
1038 async fn test_failover_provider_uses_first_successful() {
1039 let provider1 = StaticPriceProvider::new(100, 2).unwrap().with_name("Provider1");
1041 let provider2 = StaticPriceProvider::new(200, 4).unwrap().with_name("Provider2");
1042
1043 let failover = FailoverPriceProvider::new()
1044 .add_provider(provider1)
1045 .add_provider(provider2);
1046
1047 let prices = failover.get_prices().await.unwrap();
1048 assert_eq!(prices.gold_per_gram, dec!(100)); assert_eq!(prices.silver_per_gram, dec!(2));
1050 }
1051
1052 #[cfg(not(target_arch = "wasm32"))]
1053 #[tokio::test]
1054 async fn test_failover_provider_falls_back_on_failure() {
1055 let failing = MockFailingProvider::new("FailingAPI");
1057 let success = StaticPriceProvider::new(50, 1).unwrap().with_name("FallbackStatic");
1058
1059 let failover = FailoverPriceProvider::new()
1060 .add_provider(failing)
1061 .add_provider(success);
1062
1063 let prices = failover.get_prices().await.unwrap();
1064 assert_eq!(prices.gold_per_gram, dec!(50)); }
1066
1067 #[cfg(not(target_arch = "wasm32"))]
1068 #[tokio::test]
1069 async fn test_failover_provider_all_fail() {
1070 let failing1 = MockFailingProvider::new("API1");
1072 let failing2 = MockFailingProvider::new("API2");
1073
1074 let failover = FailoverPriceProvider::new()
1075 .add_provider(failing1)
1076 .add_provider(failing2);
1077
1078 let result = failover.get_prices().await;
1079 assert!(result.is_err());
1080
1081 if let Err(ZakatError::NetworkError(msg)) = result {
1082 assert!(msg.contains("API2")); } else {
1084 panic!("Expected NetworkError");
1085 }
1086 }
1087
1088 #[cfg(not(target_arch = "wasm32"))]
1089 #[tokio::test]
1090 async fn test_failover_provider_empty_returns_error() {
1091 let failover = FailoverPriceProvider::new();
1092
1093 let result = failover.get_prices().await;
1094 assert!(result.is_err());
1095 assert!(matches!(result, Err(ZakatError::ConfigurationError(_))));
1096 }
1097
1098 #[cfg(not(target_arch = "wasm32"))]
1103 #[tokio::test]
1104 async fn test_best_effort_uses_primary_when_available() {
1105 let primary = StaticPriceProvider::new(100, 2).unwrap().with_name("Primary");
1106 let fallback = Prices::new(50, 1).unwrap();
1107
1108 let provider = BestEffortPriceProvider::new(primary, fallback);
1109
1110 let prices = provider.get_prices().await.unwrap();
1111 assert_eq!(prices.gold_per_gram, dec!(100)); assert_eq!(prices.silver_per_gram, dec!(2));
1113 }
1114
1115 #[cfg(not(target_arch = "wasm32"))]
1116 #[tokio::test]
1117 async fn test_best_effort_uses_fallback_on_primary_failure() {
1118 let failing = MockFailingProvider::new("FailingPrimary");
1119 let fallback = Prices::new(75, 1).unwrap();
1120
1121 let provider = BestEffortPriceProvider::new(failing, fallback);
1122
1123 let prices = provider.get_prices().await.unwrap();
1125 assert_eq!(prices.gold_per_gram, dec!(75)); assert_eq!(prices.silver_per_gram, dec!(1));
1127 }
1128
1129 #[cfg(not(target_arch = "wasm32"))]
1130 #[tokio::test]
1131 async fn test_best_effort_caches_last_good_prices() {
1132 let primary = StaticPriceProvider::new(120, 3).unwrap();
1134 let fallback = Prices::new(50, 1).unwrap();
1135
1136 let provider = BestEffortPriceProvider::new(primary, fallback);
1137
1138 let prices1 = provider.get_prices().await.unwrap();
1140 assert_eq!(prices1.gold_per_gram, dec!(120));
1141
1142 let guard = provider.last_known_good.read().unwrap();
1144 assert!(guard.is_some());
1145 assert_eq!(guard.as_ref().unwrap().gold_per_gram, dec!(120));
1146 }
1147}