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:
@@ -0,0 +1,287 @@
|
||||
//! Kurlar: kaynaklardan çekme (sahte sunucuyla, internet yok), gün başına tek kayıt,
|
||||
//! kaynak düşünce son kur, manuel kur önceliği, portföy değerlemesi ve hane izolasyonu.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
use axum::{Router, http::StatusCode, routing::get};
|
||||
use common::{Session, TestApp};
|
||||
use kakeibo::services::{
|
||||
calendar::{format_date, today_tr},
|
||||
rates::{self, Sources},
|
||||
};
|
||||
use mongodb::bson::{Document, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const TCMB: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Tarih_Date Tarih="12.11.2026" Date="11/12/2026" Bulten_No="2026/213">
|
||||
<Currency CrossOrder="0" Kod="USD" CurrencyCode="USD"><Unit>1</Unit><ForexBuying>50.1691</ForexBuying><ForexSelling>50.2595</ForexSelling></Currency>
|
||||
<Currency CrossOrder="9" Kod="JPY" CurrencyCode="JPY"><Unit>100</Unit><ForexBuying>32.4512</ForexBuying></Currency>
|
||||
<Currency CrossOrder="19" Kod="XDR" CurrencyCode="XDR"><Unit>1</Unit><ForexBuying>68.5521</ForexBuying><ForexSelling/></Currency>
|
||||
</Tarih_Date>"#;
|
||||
|
||||
/// Sahte kaynak sunucusu: `/tcmb`, `/gold`. Bayraklarla hata verdirilebilir.
|
||||
struct Mock {
|
||||
src: Sources,
|
||||
tcmb_down: Arc<AtomicBool>,
|
||||
gold_price: Arc<std::sync::Mutex<String>>,
|
||||
}
|
||||
|
||||
async fn mock() -> Mock {
|
||||
let tcmb_down = Arc::new(AtomicBool::new(false));
|
||||
let gold_price = Arc::new(std::sync::Mutex::new(
|
||||
r#"{"per_gram_usd":124.5552}"#.to_string(),
|
||||
));
|
||||
let (d, g) = (tcmb_down.clone(), gold_price.clone());
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/tcmb",
|
||||
get(move || {
|
||||
let d = d.clone();
|
||||
async move {
|
||||
if d.load(Ordering::SeqCst) {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, String::new())
|
||||
} else {
|
||||
(StatusCode::OK, TCMB.to_string())
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/gold",
|
||||
get(move || {
|
||||
let g = g.clone();
|
||||
async move { g.lock().unwrap().clone() }
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
Mock {
|
||||
src: Sources {
|
||||
tcmb_url: format!("http://{addr}/tcmb"),
|
||||
gold_url: format!("http://{addr}/gold"),
|
||||
},
|
||||
tcmb_down,
|
||||
gold_price,
|
||||
}
|
||||
}
|
||||
|
||||
async fn auto_rate(app: &TestApp, code: &str) -> Document {
|
||||
app.db
|
||||
.collection::<Document>("exchange_rates")
|
||||
.find_one(doc! { "household_id": null, "code": code })
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{code} kuru yok"))
|
||||
}
|
||||
|
||||
async fn count(app: &TestApp, filter: Document) -> u64 {
|
||||
app.db
|
||||
.collection::<Document>("exchange_rates")
|
||||
.count_documents(filter)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_writes_one_rate_per_day_and_survives_outages() {
|
||||
let app = TestApp::new().await;
|
||||
let m = mock().await;
|
||||
let client = rates::http_client();
|
||||
|
||||
let r = rates::refresh(&app.db, &client, &m.src).await.unwrap();
|
||||
assert_eq!(r.fx, Some(Ok(3)));
|
||||
assert_eq!(r.gold, Some(Ok(5)));
|
||||
let usd = auto_rate(&app, "USD").await;
|
||||
assert_eq!(usd.get_i64("rate_e6").unwrap(), 50_169_100, "alış kuru");
|
||||
assert_eq!(
|
||||
usd.get_str("rate_date").unwrap(),
|
||||
"2026-11-12",
|
||||
"bülten tarihi"
|
||||
);
|
||||
assert_eq!(usd.get_str("source").unwrap(), "tcmb");
|
||||
assert_eq!(
|
||||
auto_rate(&app, "JPY").await.get_i64("rate_e6").unwrap(),
|
||||
324_512,
|
||||
"1 yen"
|
||||
);
|
||||
let gram = auto_rate(&app, "XAU_GRAM").await;
|
||||
assert_eq!(gram.get_i64("rate_e6").unwrap(), 6_248_822_284);
|
||||
assert_eq!(gram.get_str("rate_date").unwrap(), format_date(today_tr()));
|
||||
assert_eq!(gram.get_str("source").unwrap(), "xaus");
|
||||
assert_eq!(count(&app, doc! {}).await, 8);
|
||||
|
||||
// Aynı gün yeniden: kayıt sayısı değişmez
|
||||
rates::refresh(&app.db, &client, &m.src).await.unwrap();
|
||||
assert_eq!(count(&app, doc! {}).await, 8);
|
||||
|
||||
// TCMB düştü, altın değişti → altın veritabanındaki son USD ile hesaplanır
|
||||
m.tcmb_down.store(true, Ordering::SeqCst);
|
||||
*m.gold_price.lock().unwrap() = r#"{"per_gram_usd":130.0}"#.into();
|
||||
let r = rates::refresh(&app.db, &client, &m.src).await.unwrap();
|
||||
assert!(matches!(r.fx, Some(Err(_))), "{:?}", r.fx);
|
||||
assert_eq!(r.gold, Some(Ok(5)));
|
||||
assert_eq!(
|
||||
auto_rate(&app, "XAU_GRAM")
|
||||
.await
|
||||
.get_i64("rate_e6")
|
||||
.unwrap(),
|
||||
6_521_983_000
|
||||
);
|
||||
assert_eq!(
|
||||
auto_rate(&app, "USD").await.get_i64("rate_e6").unwrap(),
|
||||
50_169_100,
|
||||
"son kur kalır"
|
||||
);
|
||||
|
||||
// Bozuk altın yanıtı: hata raporlanır, eski kur kalır
|
||||
*m.gold_price.lock().unwrap() = "<html>bakım</html>".into();
|
||||
let r = rates::refresh(&app.db, &client, &m.src).await.unwrap();
|
||||
assert!(matches!(r.gold, Some(Err(_))));
|
||||
assert_eq!(
|
||||
auto_rate(&app, "XAU_GRAM")
|
||||
.await
|
||||
.get_i64("rate_e6")
|
||||
.unwrap(),
|
||||
6_521_983_000
|
||||
);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
async fn asset_id(app: &TestApp, s: &Session, code: &str) -> String {
|
||||
let (_, list) = app.get("/assets", s).await;
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|a| a["code"] == code)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn buy(app: &TestApp, s: &Session, code: &str, quantity_e6: i64, total: i64) {
|
||||
let id = asset_id(app, s, code).await;
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/assets/{id}/transactions"),
|
||||
s,
|
||||
json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": quantity_e6, "total": total }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
|
||||
fn item<'a>(p: &'a Value, code: &str) -> &'a Value {
|
||||
p["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["asset"]["code"] == code)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn portfolio_values_and_manual_rates() {
|
||||
let app = TestApp::new().await;
|
||||
let m = mock().await;
|
||||
rates::refresh(&app.db, &rates::http_client(), &m.src)
|
||||
.await
|
||||
.unwrap();
|
||||
let s = app.register("Test", None).await;
|
||||
buy(&app, &s, "XAU_GRAM", 10_000_000, 6_000_000).await; // 10 gr, 60.000 TL
|
||||
buy(&app, &s, "USD", 100_000_000, 450_000).await; // 100 $, 4.500 TL
|
||||
buy(&app, &s, "TRY", 0, 100_000).await; // 1.000 TL
|
||||
buy(&app, &s, "EUR", 10_000_000, 50_000).await; // kuru yok
|
||||
|
||||
let (st, p) = app.get("/portfolio", &s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{p}");
|
||||
let g = item(&p, "XAU_GRAM");
|
||||
assert_eq!(g["value"], 6_248_822, "10 × 6.248,82 TL");
|
||||
assert_eq!(g["unrealized_pl"], 248_822);
|
||||
assert_eq!(g["rate"]["source"], "xaus");
|
||||
assert_eq!(item(&p, "USD")["value"], 501_691);
|
||||
assert_eq!(item(&p, "TRY")["value"], 100_000);
|
||||
assert_eq!(item(&p, "TRY")["rate"], Value::Null);
|
||||
assert_eq!(item(&p, "EUR")["value"], Value::Null);
|
||||
assert_eq!(p["cost_total"], 6_600_000);
|
||||
assert_eq!(p["value_total"], 6_850_513);
|
||||
assert_eq!(p["unrealized_pl_total"], 300_513);
|
||||
assert_eq!(p["unpriced"], 1, "EUR elde ama kuru yok");
|
||||
assert!(p["last_fetch"].is_string());
|
||||
|
||||
// Manuel, otomatikle aynı gün → manuel geçerli
|
||||
let (st, man) = app
|
||||
.post(
|
||||
"/rates",
|
||||
&s,
|
||||
json!({ "code": "xau_gram", "rate_e6": 6_500_000_000_i64 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{man}");
|
||||
assert_eq!(man["source"], "manual");
|
||||
let (_, p) = app.get("/portfolio", &s).await;
|
||||
assert_eq!(item(&p, "XAU_GRAM")["value"], 6_500_000);
|
||||
assert_eq!(item(&p, "XAU_GRAM")["rate"]["is_manual"], true);
|
||||
|
||||
// Eski tarihli manuel, daha yeni otomatiğin önüne geçmez
|
||||
app.post(
|
||||
"/rates",
|
||||
&s,
|
||||
json!({ "code": "USD", "rate_e6": 60_000_000, "rate_date": "2026-11-01" }),
|
||||
)
|
||||
.await;
|
||||
let (_, p) = app.get("/portfolio", &s).await;
|
||||
assert_eq!(item(&p, "USD")["value"], 501_691);
|
||||
// Daha yeni manuel geçerli; aynı gün tekrar → güncellenir (çift kayıt yok)
|
||||
app.post(
|
||||
"/rates",
|
||||
&s,
|
||||
json!({ "code": "USD", "rate_e6": 60_000_000, "rate_date": "2026-12-31" }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
"/rates",
|
||||
&s,
|
||||
json!({ "code": "USD", "rate_e6": 61_000_000, "rate_date": "2026-12-31" }),
|
||||
)
|
||||
.await;
|
||||
let (_, p) = app.get("/portfolio", &s).await;
|
||||
assert_eq!(item(&p, "USD")["value"], 610_000);
|
||||
assert_eq!(
|
||||
count(&app, doc! { "code": "USD", "rate_date": "2026-12-31" }).await,
|
||||
1
|
||||
);
|
||||
|
||||
// Başka hane: manuel kurları görmez, silemez
|
||||
let other = app.register("Diger", None).await;
|
||||
let (_, op) = app.get("/portfolio", &other).await;
|
||||
assert_eq!(item(&op, "USD")["rate"]["rate_e6"], 50_169_100);
|
||||
assert_eq!(item(&op, "XAU_GRAM")["rate"]["source"], "xaus");
|
||||
let man_id = man["id"].as_str().unwrap();
|
||||
let (st, _) = app.delete(&format!("/rates/{man_id}"), &other).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
// Manuel silinince otomatiğe dönülür
|
||||
let (st, _) = app.delete(&format!("/rates/{man_id}"), &s).await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
let (_, p) = app.get("/portfolio", &s).await;
|
||||
assert_eq!(item(&p, "XAU_GRAM")["value"], 6_248_822);
|
||||
|
||||
// Hatalı girişler
|
||||
for body in [
|
||||
json!({ "code": "TRY", "rate_e6": 1_000_000 }),
|
||||
json!({ "code": "XYZ", "rate_e6": 1_000_000 }),
|
||||
json!({ "code": "USD", "rate_e6": 0 }),
|
||||
json!({ "code": "USD", "rate_e6": 1, "rate_date": "2026-02-30" }),
|
||||
] {
|
||||
let (st, b) = app.post("/rates", &s, body.clone()).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{body} → {b}");
|
||||
}
|
||||
app.cleanup().await;
|
||||
}
|
||||
Reference in New Issue
Block a user