zakat_providers/
pricing.rs

1//! Pricing module for Zakat calculations.
2//!
3//! This module provides abstractions for fetching metal prices from various sources.
4//! The core `PriceProvider` trait supports async price fetching, enabling integration
5//! with live APIs, databases, or static test data.
6//!
7//! ## Platform Support
8//! - **Native**: Uses `reqwest` for HTTP requests and `std::time::Instant` for caching
9//! - **WASM**: Uses `gloo-net` for HTTP requests and `web-time` for caching
10
11use 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/// Represents current market prices for metals used in Zakat calculations.
24#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
25pub struct Prices {
26    /// Gold price per gram in local currency.
27    pub gold_per_gram: Decimal,
28    /// Silver price per gram in local currency.
29    pub silver_per_gram: Decimal,
30}
31
32impl Prices {
33    /// Creates a new Prices instance.
34    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/// Trait for fetching current metal prices.
59///
60/// Implementors can fetch prices from various sources:
61/// - Static values for testing
62/// - Environment variables
63/// - REST APIs (Gold API, XE, etc.)
64/// - Databases
65///
66/// ## Platform Notes
67/// - On native platforms, implementors must be `Send + Sync`
68/// - On WASM, these bounds are relaxed since WASM is single-threaded
69#[cfg(not(target_arch = "wasm32"))]
70#[async_trait::async_trait]
71pub trait PriceProvider: Send + Sync {
72    /// Fetches current metal prices.
73    async fn get_prices(&self) -> Result<Prices, ZakatError>;
74    
75    /// Returns a name for this provider (used in logging).
76    fn name(&self) -> &str {
77        "PriceProvider"
78    }
79}
80
81#[cfg(target_arch = "wasm32")]
82#[async_trait::async_trait(?Send)]
83pub trait PriceProvider {
84    /// Fetches current metal prices.
85    async fn get_prices(&self) -> Result<Prices, ZakatError>;
86    
87    /// Returns a name for this provider (used in logging).
88    fn name(&self) -> &str {
89        "PriceProvider"
90    }
91}
92
93/// A static price provider for testing and development.
94///
95/// Useful when you want to:
96/// - Run unit tests with fixed prices
97/// - Demonstrate functionality without live APIs
98/// - Use user-provided prices directly
99#[derive(Debug, Clone)]
100pub struct StaticPriceProvider {
101    prices: Prices,
102    name: String,
103}
104
105impl StaticPriceProvider {
106    /// Creates a new StaticPriceProvider with the given prices.
107    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    /// Creates a StaticPriceProvider from an existing Prices instance.
118    pub fn from_prices(prices: Prices) -> Self {
119        Self { 
120            prices,
121            name: "StaticPriceProvider".to_string(),
122        }
123    }
124    
125    /// Sets a custom name for this provider.
126    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// =============================================================================
159// Feature: Historical Pricing (Qada Support)
160// =============================================================================
161
162/// Trait for fetching historical metal prices.
163/// 
164/// Primarily used by the Qada (Missed Zakat) engine to determine Nishab thresholds
165/// for past years. Implementation might vary from in-memory caches to database
166/// lookups or web-based historical financial APIs.
167#[cfg(not(target_arch = "wasm32"))]
168#[async_trait::async_trait]
169pub trait HistoricalPriceProvider: Send + Sync {
170    /// Fetches metal prices on a specific Gregorian date.
171    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/// A seedable, in-memory provider for historical prices.
181/// 
182/// Designed for testing or scenarios where a small subset of historical data 
183/// is provided upfront (e.g., from a JSON configuration).
184#[derive(Debug, Clone)]
185pub struct StaticHistoricalPriceProvider {
186    prices: std::collections::HashMap<chrono::NaiveDate, Prices>,
187    default_price: Option<Prices>,
188}
189
190impl StaticHistoricalPriceProvider {
191    /// Creates a new empty `StaticHistoricalPriceProvider`.
192    pub fn new() -> Self {
193        Self {
194            prices: std::collections::HashMap::new(),
195            default_price: None,
196        }
197    }
198
199    /// Appends a specific price data point for a given date.
200    pub fn with_price(mut self, date: chrono::NaiveDate, prices: Prices) -> Self {
201        self.prices.insert(date, prices);
202        self
203    }
204    
205    /// Sets a default price to be returned if a specific date is not found.
206    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/// A resilient price provider that tries multiple providers in sequence.
242/// 
243/// If provider A fails, it logs a warning and tries provider B.
244/// If all providers fail, it returns the last error encountered.
245/// 
246/// # Example
247/// ```rust,ignore
248/// use zakat_providers::pricing::{FailoverPriceProvider, StaticPriceProvider, BinancePriceProvider};
249/// 
250/// let failover = FailoverPriceProvider::new()
251///     .add_provider(BinancePriceProvider::default())
252///     .add_provider(StaticPriceProvider::new(65, 1)?);
253/// 
254/// let prices = failover.get_prices().await?;
255/// ```
256#[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    /// Creates a new empty FailoverPriceProvider.
264    pub fn new() -> Self {
265        Self {
266            providers: Vec::new(),
267        }
268    }
269    
270    /// Adds a price provider to the failover chain.
271    /// Providers are tried in the order they are added.
272    pub fn add_provider<P: PriceProvider + 'static>(mut self, provider: P) -> Self {
273        self.providers.push(Box::new(provider));
274        self
275    }
276    
277    /// Returns the number of providers in the chain.
278    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        // All providers failed - return the last error
332        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// =============================================================================
343// Feature 3: Best Effort Price Provider (Primary + Fallback)
344// =============================================================================
345
346/// A "best effort" price provider that uses a primary provider with a static fallback.
347///
348/// This provides the simplest possible resilient pricing:
349/// 1. Try the primary provider (e.g., live API).
350/// 2. If it fails, log a warning and return the fallback prices.
351/// 3. Only fail if BOTH providers fail.
352///
353/// # Use Case
354/// Perfect for production apps where you want live prices when available,
355/// but need guaranteed availability with user-defined fallback prices.
356///
357/// # Example
358/// ```rust,ignore
359/// use zakat_providers::pricing::{BestEffortPriceProvider, BinancePriceProvider, Prices};
360/// 
361/// // User provides their own fallback prices
362/// let fallback = Prices::new(85, 1)?;
363/// let provider = BestEffortPriceProvider::new(
364///     BinancePriceProvider::default(),
365///     fallback
366/// );
367/// 
368/// // Will use Binance if available, otherwise fallback to static prices
369/// let prices = provider.get_prices().await?;
370/// ```
371#[cfg(not(target_arch = "wasm32"))]
372pub struct BestEffortPriceProvider<P: PriceProvider> {
373    primary: P,
374    fallback: Prices,
375    /// Optional: Cache the last successfully fetched prices from primary
376    last_known_good: Arc<RwLock<Option<Prices>>>,
377}
378
379#[cfg(not(target_arch = "wasm32"))]
380impl<P: PriceProvider> BestEffortPriceProvider<P> {
381    /// Creates a new BestEffortPriceProvider with a primary provider and static fallback.
382    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    /// Creates a BestEffortPriceProvider using the last known good prices as fallback.
391    /// 
392    /// If the primary provider has never succeeded, the static fallback is used.
393    pub fn with_cached_fallback(primary: P, initial_fallback: Prices) -> Self {
394        Self::new(primary, initial_fallback)
395    }
396
397    /// Returns the current fallback prices.
398    pub fn fallback_prices(&self) -> &Prices {
399        &self.fallback
400    }
401
402    /// Updates the fallback prices.
403    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                // Cache this as the last known good
415                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                // Try to use last known good prices first
428                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                // Fall back to static prices
436                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// WASM version of BestEffortPriceProvider
452#[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    /// Creates a new BestEffortPriceProvider with a primary provider and static fallback.
462    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    /// Returns the current fallback prices.
471    pub fn fallback_prices(&self) -> &Prices {
472        &self.fallback
473    }
474
475    /// Updates the fallback prices.
476    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                // Try cached prices first
494                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// WASM version
510#[cfg(target_arch = "wasm32")]
511pub struct FailoverPriceProvider {
512    providers: Vec<Box<dyn PriceProvider>>,
513}
514
515#[cfg(target_arch = "wasm32")]
516impl FailoverPriceProvider {
517    /// Creates a new empty FailoverPriceProvider.
518    pub fn new() -> Self {
519        Self {
520            providers: Vec::new(),
521        }
522    }
523    
524    /// Adds a price provider to the failover chain.
525    pub fn add_provider<P: PriceProvider + 'static>(mut self, provider: P) -> Self {
526        self.providers.push(Box::new(provider));
527        self
528    }
529    
530    /// Returns the number of providers in the chain.
531    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/// A decorator that caches prices for a specified duration.
579///
580/// This prevents API rate limiting by reusing fetched prices until the TTL expires.
581#[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    /// Creates a new CachedPriceProvider.
590    ///
591    /// # Arguments
592    /// * `inner` - The price provider to decorate.
593    /// * `ttl_seconds` - Time-to-live for the cache in seconds.
594    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        // fast path: check read lock
608        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        // Slow path: fetch and update
617        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        // fast path: check read lock
632        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        // Slow path: fetch and update
641        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/// Network configuration for live price providers.
652#[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, // Defaults to cloudflare if not set
669        }
670    }
671}
672
673// =============================================================================
674// Native Implementation (using reqwest)
675// =============================================================================
676
677#[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/// A price provider that fetches live gold prices from Binance Public API.
686///
687/// Use this for testing "live" data without needing an API key.
688/// Note: This provider does not support Silver prices (returns 0.0).
689///
690/// ## Network Resilience
691/// This provider implements a 3-tier DNS resolution strategy:
692/// 1. **Standard DNS** - Normal system DNS resolution
693/// 2. **DNS-over-HTTPS (DoH)** - Fallback to Cloudflare/Google DoH if standard DNS fails
694/// 3. **Hardcoded IP** - Final fallback to a known Binance API IP address
695///
696/// A simple circuit breaker tracks failures and skips to hardcoded IP after 3 consecutive failures.
697#[cfg(all(feature = "live-pricing", not(target_arch = "wasm32")))]
698pub struct BinancePriceProvider {
699    client: reqwest::Client,
700    /// Circuit breaker: tracks consecutive DNS resolution failures
701    failure_count: std::sync::atomic::AtomicUsize,
702}
703
704#[cfg(all(feature = "live-pricing", not(target_arch = "wasm32")))]
705impl BinancePriceProvider {
706    /// Maximum failures before circuit breaker trips to hardcoded IP
707    const CIRCUIT_BREAKER_THRESHOLD: usize = 3;
708
709    /// Creates a new provider with resilient connection logic.
710    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    /// 3-tier DNS resolution: System DNS -> DoH -> Fail
728    fn resolve_with_fallback(config: &NetworkConfig) -> Option<std::net::IpAddr> {
729        // If user provided an explicit IP, use it directly
730        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        // Tier 1: Standard DNS resolution
736        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        // Tier 2: DNS-over-HTTPS (DoH)
747        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    /// Resolves a domain via DNS-over-HTTPS
758    fn resolve_via_doh(domain: &str, doh_endpoint: &str) -> Option<std::net::IpAddr> {
759        // Using blocking HTTP call since this runs during initialization
760        // (before async runtime is started in typical usage)
761        let url = format!(
762            "{}?name={}&type=A",
763            doh_endpoint,
764            domain
765        );
766        
767        // Use a simple blocking client with short timeout for DoH
768        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        // Parse Cloudflare DoH JSON response
781        // Format: { "Answer": [{ "data": "1.2.3.4", ... }] }
782        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    /// Records a failure for the circuit breaker
795    fn record_failure(&self) {
796        self.failure_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
797    }
798    
799    /// Resets the circuit breaker on success
800    fn record_success(&self) {
801        self.failure_count.store(0, std::sync::atomic::Ordering::SeqCst);
802    }
803    
804    /// Checks if the circuit breaker has tripped
805    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        // Check circuit breaker - if open, return early with network error
822        if self.is_circuit_open() {
823            tracing::warn!("Circuit breaker open - too many failures, using cached/fallback data recommended");
824        }
825        
826        // 1 Troy Ounce = 31.1034768 Grams
827        const OUNCE_TO_GRAM: rust_decimal::Decimal = rust_decimal_macros::dec!(31.1034768);
828        
829        // Fetch Gold Price (PAXG/USDT)
830        // Fetch Gold Price (PAXG/USDT)
831        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); // Default 60s if parse fails
847                        
848                        let wait_time = std::time::Duration::from_secs(retry_after.min(60)); // Cap at 60s
849                        tracing::warn!("Binance 429 Too Many Requests. Waiting {:?} before retry...", wait_time);
850                        tokio::time::sleep(wait_time).await;
851                        // Don't count as an attempt failure necessarily, or just continue loop? 
852                        // Instructions say "parse Retry-After... sleep... before retrying".
853                        // Logic below will consume an attempt if we continue.
854                        // Let's decrement attempts so we don't exhaust retries on forced waits, 
855                        // OR just respect the loop. Let's start fresh retry.
856                        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); // Exponential backoff
870                }
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// =============================================================================
899// WASM Implementation (using gloo-net)
900// =============================================================================
901
902#[cfg(target_arch = "wasm32")]
903#[derive(serde::Deserialize)]
904struct BinanceTickerWasm {
905    #[allow(dead_code)]
906    symbol: String,
907    price: String,
908}
909
910/// A price provider that fetches live gold prices from Binance Public API (WASM version).
911///
912/// Uses browser's Fetch API through gloo-net for WASM compatibility.
913#[cfg(target_arch = "wasm32")]
914pub struct BinancePriceProvider;
915
916#[cfg(target_arch = "wasm32")]
917impl BinancePriceProvider {
918    /// Creates a new WASM-compatible Binance price provider.
919    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        // 1 Troy Ounce = 31.1034768 Grams
938        const OUNCE_TO_GRAM: rust_decimal::Decimal = rust_decimal_macros::dec!(31.1034768);
939        
940        // Fetch Gold Price (PAXG/USDT)
941        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    // =============================================================================
1008    // Failover Price Provider Tests
1009    // =============================================================================
1010    
1011    /// A mock provider that always fails.
1012    #[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        // First provider succeeds - should use its prices
1040        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)); // First provider's price
1049        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        // First provider fails, second succeeds
1056        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)); // Fallback provider's price
1065    }
1066    
1067    #[cfg(not(target_arch = "wasm32"))]
1068    #[tokio::test]
1069    async fn test_failover_provider_all_fail() {
1070        // All providers fail - should return last error
1071        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")); // Last provider's error
1083        } 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    // =============================================================================
1099    // Best Effort Price Provider Tests (Feature 3)
1100    // =============================================================================
1101
1102    #[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)); // Primary price
1112        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        // Should succeed with fallback prices
1124        let prices = provider.get_prices().await.unwrap();
1125        assert_eq!(prices.gold_per_gram, dec!(75)); // Fallback price
1126        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        // Use a provider that succeeds first, then we'll check cache behavior
1133        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        // First call should populate the cache
1139        let prices1 = provider.get_prices().await.unwrap();
1140        assert_eq!(prices1.gold_per_gram, dec!(120));
1141        
1142        // Cache should be populated
1143        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}