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:
2026-11-13 00:59:01 +03:00
parent 267d292a90
commit 3f60efdc33
25 changed files with 1424 additions and 9 deletions
+1
View File
@@ -57,6 +57,7 @@ impl TestApp {
port: 0,
registration,
static_dir: None,
rates_refresh_minutes: 0,
};
let router = routes::router(AppState::new(db.clone(), config));
Self { router, db }
+287
View File
@@ -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;
}