feat(birikim): otomatik ve manuel kurlar, portföy değerlemesi
- TCMB (döviz alış kuru) ve xaus.com (altın) saatlik arka planda çekilir, gün başına saklanır - Kaynak erişilemezse son kur geçerli; altın için son USD kuru kullanılır - Elle kur girişi/silme; aynı gün elle girilen kur önceliklidir - GET /portfolio: güncel değer, kâr/zarar, toplamlar, son kur çekimi - RATES_REFRESH_MINUTES ayarı Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,8 @@ pub struct Config {
|
||||
/// Derlenmiş frontend klasörü (`STATIC_DIR`). Tanımlıysa Axum arayüzü de sunar
|
||||
/// (canlıda tek servis). Geliştirmede boş: arayüzü Vite sunar.
|
||||
pub static_dir: Option<PathBuf>,
|
||||
/// Otomatik kur yenileme aralığı (dakika, `RATES_REFRESH_MINUTES`, varsayılan 60); 0 → kapalı
|
||||
pub rates_refresh_minutes: u64,
|
||||
}
|
||||
|
||||
/// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni).
|
||||
@@ -69,6 +71,13 @@ impl Config {
|
||||
Err(_) => 8080,
|
||||
};
|
||||
|
||||
let rates_refresh_minutes = match env::var("RATES_REFRESH_MINUTES") {
|
||||
Ok(m) if !m.trim().is_empty() => m.trim().parse().map_err(|_| {
|
||||
ConfigError::Invalid("RATES_REFRESH_MINUTES", "dakika (sayı) olmalı")
|
||||
})?,
|
||||
_ => 60,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
mongodb_uri,
|
||||
jwt_secret,
|
||||
@@ -76,6 +85,7 @@ impl Config {
|
||||
port,
|
||||
registration,
|
||||
static_dir,
|
||||
rates_refresh_minutes,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
use std::{net::SocketAddr, process::ExitCode};
|
||||
|
||||
use kakeibo::{config::Config, db, routes, state::AppState};
|
||||
use kakeibo::{config::Config, db, routes, services, state::AppState};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -41,6 +41,9 @@ async fn main() -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
// Kurlar arka planda (açılışta hemen, sonra aralıkla); hata uygulamayı durdurmaz
|
||||
services::rates::spawn_refresher(db.clone(), config.rates_refresh_minutes);
|
||||
|
||||
let port = config.port;
|
||||
let state = AppState::new(db, config);
|
||||
let app = routes::app(state);
|
||||
|
||||
@@ -10,4 +10,5 @@ pub mod invite;
|
||||
pub mod note;
|
||||
pub mod period;
|
||||
pub mod plan_item;
|
||||
pub mod rate;
|
||||
pub mod user;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! `exchange_rates` — kurlar (DATA_MODEL.md §3.14). Değer: 1 birim varlık = `rate_e6` / 1e6 TL.
|
||||
//!
|
||||
//! `household_id: null` → otomatik (tüm haneler, TCMB / xaus.com); dolu → hanenin elle girdiği kur.
|
||||
//! Otomatik kurlar gün başına tek kayıt (aynı gün yeniden çekilince güncellenir) → günlük geçmiş.
|
||||
|
||||
use mongodb::bson::{DateTime, Uuid};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Rate {
|
||||
#[serde(rename = "_id")]
|
||||
pub id: Uuid,
|
||||
#[serde(default)]
|
||||
pub household_id: Option<Uuid>,
|
||||
/// Varlık kodu: `USD`, `XAU_GRAM`…
|
||||
pub code: String,
|
||||
/// TL × 1e6, birim başına
|
||||
pub rate_e6: i64,
|
||||
/// `tcmb` · `xaus` · `manual`
|
||||
pub source: String,
|
||||
/// Kurun geçerli olduğu gün (TCMB: bülten tarihi)
|
||||
pub rate_date: String,
|
||||
pub fetched_at: DateTime,
|
||||
}
|
||||
|
||||
impl Rate {
|
||||
pub fn is_manual(&self) -> bool {
|
||||
self.household_id.is_some()
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ pub struct AssetDto {
|
||||
}
|
||||
|
||||
impl AssetDto {
|
||||
fn new(a: Asset, h: Holding, tx_count: usize) -> Self {
|
||||
pub(crate) fn new(a: Asset, h: Holding, tx_count: usize) -> Self {
|
||||
Self {
|
||||
id: a.id.to_string(),
|
||||
kind: a.kind,
|
||||
|
||||
@@ -10,6 +10,7 @@ mod household;
|
||||
mod note;
|
||||
mod period;
|
||||
mod plan;
|
||||
mod rates;
|
||||
mod review;
|
||||
mod spa;
|
||||
mod stats;
|
||||
@@ -100,6 +101,9 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/entries/{id}", patch(entry::update).delete(entry::delete))
|
||||
.route("/entries/{id}/restore", post(entry::restore))
|
||||
.route("/assets", get(asset::list).post(asset::create))
|
||||
.route("/portfolio", get(rates::portfolio))
|
||||
.route("/rates", post(rates::create))
|
||||
.route("/rates/{id}", delete(rates::delete))
|
||||
.route("/assets/{id}", patch(asset::update))
|
||||
.route(
|
||||
"/assets/{id}/transactions",
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Kur ve portföy uç noktaları (hanenin tüm üyeleri):
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/portfolio` | varlıklar + güncel kur, değer, kâr/zarar + toplamlar + son kur çekimi |
|
||||
//! | POST | `/api/rates` | elle kur (hanenin; aynı gün tekrar → güncellenir) |
|
||||
//! | DELETE | `/api/rates/{id}` | elle kuru sil → otomatik kura dön |
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use mongodb::bson::{DateTime, Uuid};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
extract::ValidJson,
|
||||
models::rate::Rate,
|
||||
routes::asset::AssetDto,
|
||||
services::rates,
|
||||
};
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Rate")]
|
||||
pub struct RateDto {
|
||||
id: String,
|
||||
code: String,
|
||||
/// TL × 1e6, birim başına
|
||||
#[ts(type = "number")]
|
||||
rate_e6: i64,
|
||||
/// `tcmb` · `xaus` · `manual`
|
||||
#[ts(type = "\"tcmb\" | \"xaus\" | \"manual\"")]
|
||||
source: String,
|
||||
rate_date: String,
|
||||
/// ISO 8601
|
||||
fetched_at: String,
|
||||
is_manual: bool,
|
||||
}
|
||||
|
||||
fn iso(d: DateTime) -> String {
|
||||
d.try_to_rfc3339_string().unwrap_or_default()
|
||||
}
|
||||
|
||||
impl From<Rate> for RateDto {
|
||||
fn from(r: Rate) -> Self {
|
||||
Self {
|
||||
id: r.id.to_string(),
|
||||
is_manual: r.is_manual(),
|
||||
code: r.code,
|
||||
rate_e6: r.rate_e6,
|
||||
source: r.source,
|
||||
rate_date: r.rate_date,
|
||||
fetched_at: iso(r.fetched_at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "PortfolioItem")]
|
||||
struct ItemDto {
|
||||
asset: AssetDto,
|
||||
/// TL'de ve kur yoksa `null`
|
||||
rate: Option<RateDto>,
|
||||
/// Güncel değer (kuruş); kur yoksa `null`
|
||||
#[ts(type = "number | null")]
|
||||
value: Option<i64>,
|
||||
/// Değer − eldeki maliyet (kuruş)
|
||||
#[ts(type = "number | null")]
|
||||
unrealized_pl: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Portfolio")]
|
||||
pub struct PortfolioDto {
|
||||
items: Vec<ItemDto>,
|
||||
/// Eldeki maliyet toplamı (kuruş)
|
||||
#[ts(type = "number")]
|
||||
cost_total: i64,
|
||||
/// Kuru olanların güncel değeri (kuruş)
|
||||
#[ts(type = "number")]
|
||||
value_total: i64,
|
||||
/// Kuru olanlarda değer − maliyet
|
||||
#[ts(type = "number")]
|
||||
unrealized_pl_total: i64,
|
||||
#[ts(type = "number")]
|
||||
realized_pl_total: i64,
|
||||
/// Elde olup kuru bulunmayan varlık sayısı (toplama girmedi)
|
||||
unpriced: u32,
|
||||
/// Son otomatik kur çekimi (ISO); hiç yoksa `null`
|
||||
last_fetch: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn portfolio(
|
||||
State(s): State<crate::state::AppState>,
|
||||
auth: AuthUser,
|
||||
) -> AppResult<Json<PortfolioDto>> {
|
||||
let p = rates::portfolio(&s.db, auth.household_id).await?;
|
||||
let mut dto = PortfolioDto {
|
||||
items: Vec::with_capacity(p.items.len()),
|
||||
cost_total: 0,
|
||||
value_total: 0,
|
||||
unrealized_pl_total: 0,
|
||||
realized_pl_total: 0,
|
||||
unpriced: 0,
|
||||
last_fetch: p.last_fetch.map(iso),
|
||||
};
|
||||
for v in p.items {
|
||||
let h = v.holding;
|
||||
dto.cost_total += h.cost;
|
||||
dto.realized_pl_total += h.realized_pl;
|
||||
let unrealized = v.value.map(|x| x - h.cost);
|
||||
match v.value {
|
||||
Some(x) => {
|
||||
dto.value_total += x;
|
||||
dto.unrealized_pl_total += x - h.cost;
|
||||
}
|
||||
None if h.quantity_e6 > 0 => dto.unpriced += 1,
|
||||
None => {}
|
||||
}
|
||||
dto.items.push(ItemDto {
|
||||
asset: AssetDto::new(v.asset, h, v.tx_count),
|
||||
rate: v.rate.map(RateDto::from),
|
||||
value: v.value,
|
||||
unrealized_pl: unrealized,
|
||||
});
|
||||
}
|
||||
Ok(Json(dto))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "RateCreateRequest", optional_fields)]
|
||||
pub struct CreateRequest {
|
||||
/// Varlık kodu (`XAU_CEYREK`, `USD`…)
|
||||
code: String,
|
||||
/// TL × 1e6, birim başına
|
||||
#[ts(type = "number")]
|
||||
rate_e6: i64,
|
||||
/// Boşsa bugün
|
||||
rate_date: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(s): State<crate::state::AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<CreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<RateDto>)> {
|
||||
let r = rates::set_manual(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
&req.code,
|
||||
req.rate_e6,
|
||||
req.rate_date.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(r.into())))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
State(s): State<crate::state::AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
|
||||
rates::delete_manual(&s.db, auth.household_id, id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -26,6 +26,13 @@ pub fn parse_date(s: &str) -> Option<Date> {
|
||||
}
|
||||
|
||||
/// `Date` → `"2026-12-09"`
|
||||
/// Türkiye'de bugünün tarihi (UTC+3, yaz saati yok). Sunucu UTC'de çalışsa da gün doğru olsun.
|
||||
pub fn today_tr() -> Date {
|
||||
time::OffsetDateTime::now_utc()
|
||||
.to_offset(time::UtcOffset::from_hms(3, 0, 0).expect("geçerli ofset"))
|
||||
.date()
|
||||
}
|
||||
|
||||
pub fn format_date(d: Date) -> String {
|
||||
format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day())
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ pub mod money;
|
||||
pub mod note;
|
||||
pub mod period;
|
||||
pub mod plan;
|
||||
pub mod rate_sources;
|
||||
pub mod rates;
|
||||
pub mod review;
|
||||
pub mod seed;
|
||||
pub mod stats;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! 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<TcmbCurrency>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TcmbCurrency {
|
||||
#[serde(rename = "@CurrencyCode")]
|
||||
code: String,
|
||||
#[serde(rename = "Unit")]
|
||||
unit: Option<String>,
|
||||
#[serde(rename = "ForexBuying")]
|
||||
forex_buying: Option<String>,
|
||||
}
|
||||
|
||||
/// `"50.1691"` → `50_169_100` (ondalık noktalı metin → × 1e6, kayan nokta kullanmadan)
|
||||
pub fn decimal_e6(s: &str) -> Option<i64> {
|
||||
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::<i64>().ok()?.checked_mul(1_000_000)? + frac6.parse::<i64>().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<Bulletin, String> {
|
||||
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<i64, String> {
|
||||
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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml-stylesheet type="text/xsl" href="isokur.xsl"?>
|
||||
<Tarih_Date Tarih="12.11.2026" Date="11/12/2026" Bulten_No="2026/213" >
|
||||
<Currency CrossOrder="0" Kod="USD" CurrencyCode="USD">
|
||||
<Unit>1</Unit>
|
||||
<Isim>ABD DOLARI</Isim>
|
||||
<CurrencyName>US DOLLAR</CurrencyName>
|
||||
<ForexBuying>50.1691</ForexBuying>
|
||||
<ForexSelling>50.2595</ForexSelling>
|
||||
<BanknoteBuying>50.1340</BanknoteBuying>
|
||||
<BanknoteSelling>50.3349</BanknoteSelling>
|
||||
<CrossRateUSD/>
|
||||
<CrossRateOther/>
|
||||
</Currency>
|
||||
<Currency CrossOrder="9" Kod="JPY" CurrencyCode="JPY">
|
||||
<Unit>100</Unit>
|
||||
<Isim>JAPON YENİ</Isim>
|
||||
<CurrencyName>JAPENESE YEN</CurrencyName>
|
||||
<ForexBuying>32.4512</ForexBuying>
|
||||
<ForexSelling>32.6661</ForexSelling>
|
||||
</Currency>
|
||||
<Currency CrossOrder="19" Kod="XDR" CurrencyCode="XDR">
|
||||
<Unit>1</Unit>
|
||||
<Isim>ÖZEL ÇEKME HAKKI (SDR)</Isim>
|
||||
<CurrencyName>SPECIAL DRAWING RIGHT (SDR)</CurrencyName>
|
||||
<ForexBuying>68.5521</ForexBuying>
|
||||
<ForexSelling/>
|
||||
</Currency>
|
||||
<Currency CrossOrder="20" Kod="IRR" CurrencyCode="IRR">
|
||||
<Unit>1</Unit>
|
||||
<ForexBuying/>
|
||||
</Currency>
|
||||
</Tarih_Date>"#;
|
||||
|
||||
#[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("<html>bakımda</html>").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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
//! Kurlar ve portföy değerlemesi (KAKEIBO_PROMPT §8.7, DATA_MODEL.md §3.14).
|
||||
//!
|
||||
//! - **Otomatik:** `refresh` TCMB'den dövizleri, xaus.com'dan altını çeker; `exchange_rates`'e
|
||||
//! `household_id: null` ile gün başına tek kayıt yazar (aynı gün güncellenir). İki kaynak
|
||||
//! birbirinden bağımsız: biri erişilemezse diğeri yine yazılır; altın için USD kuru o an
|
||||
//! çekilemediyse veritabanındaki son USD kullanılır. Hata → log; son kur geçerli kalır.
|
||||
//! - **Manuel:** hane kendi kurunu girer (gün başına tek; aynı gün tekrar girilirse güncellenir).
|
||||
//! - **Güncel kur:** kod başına en yeni `rate_date`; aynı gün hem otomatik hem manuel varsa manuel.
|
||||
//! (Eski bir manuel kur, daha yeni otomatik kurun önüne geçmez.)
|
||||
//! - **Değer:** miktar × kur. TL varlığı kursuz (değeri = tutarı).
|
||||
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{DateTime, Document, Uuid, doc},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
asset::{Asset, AssetKind},
|
||||
common::{new_id, now, scope},
|
||||
rate::Rate,
|
||||
},
|
||||
services::{
|
||||
asset,
|
||||
calendar::{format_date, parse_date, today_tr},
|
||||
holding::Holding,
|
||||
rate_sources,
|
||||
},
|
||||
};
|
||||
|
||||
/// Kaynak adresleri (testlerde sahte sunucu verilir)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sources {
|
||||
pub tcmb_url: String,
|
||||
pub gold_url: String,
|
||||
}
|
||||
|
||||
impl Default for Sources {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tcmb_url: "https://www.tcmb.gov.tr/kurlar/today.xml".into(),
|
||||
gold_url: "https://xaus.com/api/v1/spot".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP istemcisi: kısa zaman aşımı, genel kimlik (kişisel bilgi yok)
|
||||
pub fn http_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.user_agent("kakeibo/0.1 (ev finans defteri)")
|
||||
.build()
|
||||
.expect("HTTP istemcisi kurulamadı")
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Report {
|
||||
/// Yazılan döviz kuru sayısı ya da hata
|
||||
pub fx: Option<Result<usize, String>>,
|
||||
pub gold: Option<Result<usize, String>>,
|
||||
}
|
||||
|
||||
async fn fetch_text(client: &reqwest::Client, url: &str) -> Result<String, String> {
|
||||
let r = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("{url}: {e}"))?;
|
||||
if !r.status().is_success() {
|
||||
return Err(format!("{url}: HTTP {}", r.status()));
|
||||
}
|
||||
r.text().await.map_err(|e| format!("{url}: {e}"))
|
||||
}
|
||||
|
||||
/// İki kaynağı da çeker ve yazar. Veritabanı hatası dışında hata döndürmez (rapora yazar).
|
||||
pub async fn refresh(db: &Database, client: &reqwest::Client, src: &Sources) -> AppResult<Report> {
|
||||
let mut report = Report::default();
|
||||
let fetched_at = now();
|
||||
|
||||
// Döviz (TCMB)
|
||||
let mut usd = None;
|
||||
match fetch_text(client, &src.tcmb_url)
|
||||
.await
|
||||
.and_then(|x| rate_sources::parse_tcmb(&x))
|
||||
{
|
||||
Ok(b) => {
|
||||
for (code, rate) in &b.rates {
|
||||
upsert_auto(db, code, *rate, "tcmb", &b.date, fetched_at).await?;
|
||||
}
|
||||
usd = b.rates.iter().find(|(c, _)| c == "USD").map(|(_, r)| *r);
|
||||
report.fx = Some(Ok(b.rates.len()));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("TCMB kurları alınamadı: {e}");
|
||||
report.fx = Some(Err(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Altın (xaus.com × USD)
|
||||
let usd = match usd {
|
||||
Some(u) => Some(u),
|
||||
None => latest_auto(db, "USD").await?.map(|r| r.rate_e6),
|
||||
};
|
||||
let gold = match usd {
|
||||
None => Err("USD kuru yok; altın TL'ye çevrilemedi".to_string()),
|
||||
Some(usd) => fetch_text(client, &src.gold_url)
|
||||
.await
|
||||
.and_then(|x| rate_sources::parse_gold(&x))
|
||||
.map(|g| rate_sources::gold_rates(g, usd)),
|
||||
};
|
||||
match gold {
|
||||
Ok(list) => {
|
||||
let date = format_date(today_tr());
|
||||
for (code, rate) in &list {
|
||||
upsert_auto(db, code, *rate, "xaus", &date, fetched_at).await?;
|
||||
}
|
||||
report.gold = Some(Ok(list.len()));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Altın fiyatı alınamadı: {e}");
|
||||
report.gold = Some(Err(e));
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Uygulama açılınca ve her `minutes` dakikada bir kurları yeniler (0 → kapalı).
|
||||
pub fn spawn_refresher(db: Database, minutes: u64) {
|
||||
if minutes == 0 {
|
||||
tracing::info!("Otomatik kur yenileme kapalı (RATES_REFRESH_MINUTES=0)");
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
let client = http_client();
|
||||
let src = Sources::default();
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(minutes * 60));
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tick.tick().await;
|
||||
match refresh(&db, &client, &src).await {
|
||||
Ok(r) => tracing::info!("Kurlar yenilendi: döviz {:?}, altın {:?}", r.fx, r.gold),
|
||||
Err(e) => tracing::error!("Kurlar yazılamadı: {e}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn upsert_auto(
|
||||
db: &Database,
|
||||
code: &str,
|
||||
rate_e6: i64,
|
||||
source: &str,
|
||||
rate_date: &str,
|
||||
fetched_at: DateTime,
|
||||
) -> AppResult<()> {
|
||||
let ts = now();
|
||||
db.collection::<Document>(col::EXCHANGE_RATES)
|
||||
.update_one(
|
||||
doc! { "household_id": null, "code": code, "source": source, "rate_date": rate_date, "deleted_at": null },
|
||||
doc! {
|
||||
"$set": { "rate_e6": rate_e6, "fetched_at": fetched_at, "updated_at": ts },
|
||||
"$setOnInsert": { "_id": new_id(), "created_at": ts, "version": 1_i32 },
|
||||
},
|
||||
)
|
||||
.upsert(true)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn latest_auto(db: &Database, code: &str) -> AppResult<Option<Rate>> {
|
||||
Ok(db
|
||||
.collection::<Rate>(col::EXCHANGE_RATES)
|
||||
.find_one(doc! { "household_id": null, "code": code, "deleted_at": null })
|
||||
.sort(doc! { "rate_date": -1, "fetched_at": -1 })
|
||||
.await?)
|
||||
}
|
||||
|
||||
// ---------- Güncel kur ----------
|
||||
|
||||
/// Kod → güncel kur (hanenin manuel kurları + otomatik kurlar)
|
||||
pub async fn current(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, Rate>> {
|
||||
// Kod × (otomatik | bu hane) başına en yeni kayıt → en fazla 2 aday
|
||||
let pipeline = vec![
|
||||
doc! { "$match": {
|
||||
"deleted_at": null,
|
||||
"$or": [ { "household_id": null }, { "household_id": household_id } ],
|
||||
} },
|
||||
doc! { "$sort": { "rate_date": -1, "fetched_at": -1 } },
|
||||
doc! { "$group": {
|
||||
"_id": { "code": "$code", "hh": "$household_id" },
|
||||
"doc": { "$first": "$$ROOT" },
|
||||
} },
|
||||
doc! { "$replaceRoot": { "newRoot": "$doc" } },
|
||||
];
|
||||
let mut cursor = db
|
||||
.collection::<Document>(col::EXCHANGE_RATES)
|
||||
.aggregate(pipeline)
|
||||
.with_type::<Rate>()
|
||||
.await?;
|
||||
let mut out: HashMap<String, Rate> = HashMap::new();
|
||||
while cursor.advance().await? {
|
||||
let r = cursor.deserialize_current()?;
|
||||
let better = match out.get(&r.code) {
|
||||
None => true,
|
||||
Some(cur) => {
|
||||
(r.rate_date.as_str(), r.is_manual()) > (cur.rate_date.as_str(), cur.is_manual())
|
||||
}
|
||||
};
|
||||
if better {
|
||||
out.insert(r.code.clone(), r);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// En son otomatik çekim zamanı (kaynak sağlıklı mı?)
|
||||
pub async fn last_fetch(db: &Database) -> AppResult<Option<DateTime>> {
|
||||
Ok(db
|
||||
.collection::<Rate>(col::EXCHANGE_RATES)
|
||||
.find_one(doc! { "household_id": null, "deleted_at": null })
|
||||
.sort(doc! { "fetched_at": -1 })
|
||||
.await?
|
||||
.map(|r| r.fetched_at))
|
||||
}
|
||||
|
||||
// ---------- Manuel kur ----------
|
||||
|
||||
/// Hanenin varlıklarından birinin kodu için elle kur (aynı gün tekrar → güncellenir)
|
||||
pub async fn set_manual(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
code: &str,
|
||||
rate_e6: i64,
|
||||
rate_date: Option<&str>,
|
||||
) -> AppResult<Rate> {
|
||||
let code = code.trim().to_uppercase();
|
||||
let mut f = scope(household_id);
|
||||
f.insert("code", &code);
|
||||
let a = db
|
||||
.collection::<Asset>(col::ASSETS)
|
||||
.find_one(f)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("Bu varlık bulunamadı".into()))?;
|
||||
if a.kind == AssetKind::Cash {
|
||||
return Err(AppError::BadRequest("TL için kur girilmez".into()));
|
||||
}
|
||||
if rate_e6 <= 0 || rate_e6 > 1_000_000_000_000_000 {
|
||||
return Err(AppError::BadRequest("Kur sıfırdan büyük olmalı".into()));
|
||||
}
|
||||
let date = match rate_date.map(str::trim).filter(|d| !d.is_empty()) {
|
||||
Some(d) => {
|
||||
format_date(parse_date(d).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?)
|
||||
}
|
||||
None => format_date(today_tr()),
|
||||
};
|
||||
let ts = now();
|
||||
let filter = doc! { "household_id": household_id, "code": &code, "rate_date": &date, "deleted_at": null };
|
||||
db.collection::<Document>(col::EXCHANGE_RATES)
|
||||
.update_one(
|
||||
filter.clone(),
|
||||
doc! {
|
||||
"$set": { "rate_e6": rate_e6, "source": "manual", "fetched_at": ts, "updated_at": ts },
|
||||
"$inc": { "version": 1 },
|
||||
"$setOnInsert": { "_id": new_id(), "created_at": ts },
|
||||
},
|
||||
)
|
||||
.upsert(true)
|
||||
.await?;
|
||||
db.collection::<Rate>(col::EXCHANGE_RATES)
|
||||
.find_one(filter)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("manuel kur okunamadı".into()))
|
||||
}
|
||||
|
||||
/// Hanenin manuel kuru silinir (soft) → otomatik kura dönülür
|
||||
pub async fn delete_manual(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
|
||||
let ts = now();
|
||||
let r = db
|
||||
.collection::<Document>(col::EXCHANGE_RATES)
|
||||
.update_one(
|
||||
doc! { "_id": id, "household_id": household_id, "deleted_at": null },
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
if r.matched_count == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- Portföy ----------
|
||||
|
||||
pub struct Valued {
|
||||
pub asset: Asset,
|
||||
pub holding: Holding,
|
||||
pub tx_count: usize,
|
||||
/// TL varlığında `None` (kur gerekmez)
|
||||
pub rate: Option<Rate>,
|
||||
/// Güncel değer (kuruş); kur yoksa `None`
|
||||
pub value: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct Portfolio {
|
||||
pub items: Vec<Valued>,
|
||||
pub last_fetch: Option<DateTime>,
|
||||
}
|
||||
|
||||
/// miktar (× 1e6) × kur (TL × 1e6) → kuruş
|
||||
pub fn value_of(quantity_e6: i64, rate_e6: i64) -> i64 {
|
||||
let n = i128::from(quantity_e6) * i128::from(rate_e6);
|
||||
((n + 5_000_000_000) / 10_000_000_000) as i64
|
||||
}
|
||||
|
||||
pub async fn portfolio(db: &Database, household_id: Uuid) -> AppResult<Portfolio> {
|
||||
let mut rates = current(db, household_id).await?;
|
||||
let items = asset::list(db, household_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(a, h, n)| {
|
||||
let (rate, value) = if a.kind == AssetKind::Cash {
|
||||
(None, Some(h.quantity_e6 / 10_000)) // TL: miktar = tutar
|
||||
} else {
|
||||
let r = rates.remove(&a.code);
|
||||
let v = r.as_ref().map(|r| value_of(h.quantity_e6, r.rate_e6));
|
||||
(r, v)
|
||||
};
|
||||
Valued {
|
||||
asset: a,
|
||||
holding: h,
|
||||
tx_count: n,
|
||||
rate,
|
||||
value,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(Portfolio {
|
||||
items,
|
||||
last_fetch: last_fetch(db).await?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::value_of;
|
||||
|
||||
#[test]
|
||||
fn value_rounds_to_nearest_kurus() {
|
||||
// 1 birim × 123,456789 TL = 12.345,6789 kuruş → 12.346
|
||||
assert_eq!(value_of(1_000_000, 123_456_789), 12_346);
|
||||
// 0,5 gr × 6.248,822284 TL = 3.124,411142 TL → 312.441 kuruş
|
||||
assert_eq!(value_of(500_000, 6_248_822_284), 312_441);
|
||||
assert_eq!(value_of(0, 6_248_822_284), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user