//! Kur kaynaklarının ayrıştırıcıları (saf; ağ yok → örnek metinle test edilir). //! //! - **TCMB** `today.xml`: dövizlerin **alış** kuru (`ForexBuying`) ÷ `Unit` (JPY 100 birim…). //! Değerleme alış kuruyla (elindekini bugün bozdursan alacağın; kullanıcı kararı). //! - **xaus.com** `/api/v1/spot`: 1 gr saf altının USD fiyatı (`per_gram_usd`) × TCMB USD alış. //! Çeyrek/yarım/tam = içerdiği saf altın (has) gramı × gram fiyatı (kuyumcu işçilik payı hariç); //! ons = 31,1034768 gr. Bu yaklaşık değer; gerekirse manuel kur girilir. use serde::Deserialize; use crate::services::calendar::{format_date, parse_date}; /// Altın kodları ve içerdiği saf altın (gr × 1e6) pub const GOLD: &[(&str, i64)] = &[ ("XAU_GRAM", 1_000_000), ("XAU_CEYREK", 1_603_000), // 1,75 gr × 0,916 (22 ayar) ("XAU_YARIM", 3_206_000), // 3,50 gr × 0,916 ("XAU_TAM", 6_412_000), // 7,00 gr × 0,916 ("XAU_ONS", 31_103_477), // 31,1034768 gr ]; #[derive(Debug, PartialEq, Eq)] pub struct Bulletin { /// `"YYYY-MM-DD"` pub date: String, /// (kod, TL × 1e6 / birim) pub rates: Vec<(String, i64)>, } #[derive(Deserialize)] struct TcmbXml { #[serde(rename = "@Tarih")] tarih: String, #[serde(rename = "Currency", default)] currencies: Vec, } #[derive(Deserialize)] struct TcmbCurrency { #[serde(rename = "@CurrencyCode")] code: String, #[serde(rename = "Unit")] unit: Option, #[serde(rename = "ForexBuying")] forex_buying: Option, } /// `"50.1691"` → `50_169_100` (ondalık noktalı metin → × 1e6, kayan nokta kullanmadan) pub fn decimal_e6(s: &str) -> Option { let s = s.trim(); let (int, frac) = s.split_once('.').unwrap_or((s, "")); if int.is_empty() || !int.bytes().all(|b| b.is_ascii_digit()) || !frac.bytes().all(|b| b.is_ascii_digit()) { return None; } let mut frac6: String = frac.chars().take(6).collect(); while frac6.len() < 6 { frac6.push('0'); } let v = int.parse::().ok()?.checked_mul(1_000_000)? + frac6.parse::().ok()?; // 7. basamak yuvarlama let round = frac.as_bytes().get(6).is_some_and(|b| *b >= b'5'); Some(v + i64::from(round)) } pub fn parse_tcmb(xml: &str) -> Result { let doc: TcmbXml = quick_xml::de::from_str(xml).map_err(|e| format!("TCMB XML okunamadı: {e}"))?; // "12.11.2026" → "2026-11-12" let parts: Vec<&str> = doc.tarih.split('.').collect(); let date = match parts.as_slice() { [d, m, y] => format!("{y}-{m}-{d}"), _ => return Err(format!("TCMB tarihi okunamadı: {}", doc.tarih)), }; let date = parse_date(&date) .map(format_date) .ok_or_else(|| format!("TCMB tarihi geçersiz: {}", doc.tarih))?; let rates = doc .currencies .into_iter() .filter_map(|c| { let unit: i64 = c .unit .as_deref() .unwrap_or("1") .trim() .parse() .ok() .filter(|u| *u > 0)?; let buying = decimal_e6(c.forex_buying.as_deref()?)?; (buying > 0).then(|| (c.code.trim().to_uppercase(), buying / unit)) }) .collect(); Ok(Bulletin { date, rates }) } #[derive(Deserialize)] struct XausSpot { per_gram_usd: f64, } /// 1 gr saf altın, USD × 1e6 pub fn parse_gold(json: &str) -> Result { let s: XausSpot = serde_json::from_str(json).map_err(|e| format!("altın fiyatı okunamadı: {e}"))?; // JSON sayıyı ondalıklı verir; kurda 6 basamak yeterli let v = (s.per_gram_usd * 1_000_000.0).round(); if !(v.is_finite() && v > 0.0 && v < 1e15) { return Err(format!("altın fiyatı geçersiz: {}", s.per_gram_usd)); } Ok(v as i64) } /// Gram USD fiyatı ve USD/TL alış → altın kodlarının TL kurları pub fn gold_rates(per_gram_usd_e6: i64, usd_try_e6: i64) -> Vec<(&'static str, i64)> { let gram_try_e6 = i128::from(per_gram_usd_e6) * i128::from(usd_try_e6) / 1_000_000; GOLD.iter() .map(|(code, has_e6)| { ( *code, (gram_try_e6 * i128::from(*has_e6) / 1_000_000) as i64, ) }) .collect() } #[cfg(test)] mod tests { use super::*; pub const TCMB_SAMPLE: &str = r#" 1 ABD DOLARI US DOLLAR 50.1691 50.2595 50.1340 50.3349 100 JAPON YENİ JAPENESE YEN 32.4512 32.6661 1 ÖZEL ÇEKME HAKKI (SDR) SPECIAL DRAWING RIGHT (SDR) 68.5521 1 "#; #[test] fn decimal_without_floats() { assert_eq!(decimal_e6("50.1691"), Some(50_169_100)); assert_eq!(decimal_e6("7"), Some(7_000_000)); assert_eq!( decimal_e6("0.1234567"), Some(123_457), "7. basamak yuvarlanır" ); assert_eq!(decimal_e6(""), None); assert_eq!(decimal_e6("1,5"), None); assert_eq!(decimal_e6("-2.0"), None); } #[test] fn tcmb_buying_rates_per_unit() { let b = parse_tcmb(TCMB_SAMPLE).unwrap(); assert_eq!(b.date, "2026-11-12"); assert_eq!( b.rates, vec![ ("USD".into(), 50_169_100), ("JPY".into(), 324_512), // 100 yen = 32,4512 TL ("XDR".into(), 68_552_100), ], "alış kuru boş olan (IRR) atlanır" ); assert!(parse_tcmb("bakımda").is_err()); } #[test] fn gold_from_usd() { let g = parse_gold( r#"{"xau":{"price":3874.1},"per_gram_usd":124.5552,"spot_usd_oz":3874.100098}"#, ) .unwrap(); assert_eq!(g, 124_555_200); assert!(parse_gold(r#"{"per_gram_usd":0}"#).is_err()); assert!(parse_gold("{}").is_err()); let r = gold_rates(g, 50_169_100); // 124,5552 $ × 50,1691 TL = 6.248,8222843 TL/gr assert_eq!(r[0], ("XAU_GRAM", 6_248_822_284)); assert_eq!(r[1].0, "XAU_CEYREK"); assert_eq!(r[1].1, 6_248_822_284 * 1_603 / 1_000); assert_eq!(r[4].1, 194_360_100_187, "ons ≈ 194.360,10 TL"); } }