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:
Generated
+239
-4
@@ -178,6 +178,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
@@ -257,7 +263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitvec",
|
||||
"getrandom 0.2.17",
|
||||
"getrandom 0.3.4",
|
||||
@@ -301,6 +307,12 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
@@ -928,9 +940,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1130,6 +1144,23 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1138,13 +1169,22 @@ version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1361,7 +1401,7 @@ version = "11.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.2.17",
|
||||
"hmac 0.12.1",
|
||||
@@ -1388,13 +1428,15 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"jsonwebtoken",
|
||||
"mongodb",
|
||||
"quick-xml",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-http 0.7.1",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ts-rs",
|
||||
@@ -1444,6 +1486,12 @@ version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "macro_magic"
|
||||
version = "0.5.1"
|
||||
@@ -1601,7 +1649,7 @@ version = "3.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6466d4601ff396952e1895d88083d1ab03930b812277180eb8258bbe1a10bb0f"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags",
|
||||
"bson",
|
||||
"derive-where",
|
||||
@@ -1928,6 +1976,72 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -2031,6 +2145,15 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -2069,6 +2192,44 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http 0.6.11",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resolv-conf"
|
||||
version = "0.7.6"
|
||||
@@ -2119,6 +2280,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -2159,6 +2326,7 @@ version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "102f35f7de7b06d8c61f1cff6592f5253e0dea431c74867aad5dfd70f44663fb"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2504,6 +2672,9 @@ name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
@@ -2717,6 +2888,24 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.7.1"
|
||||
@@ -2818,6 +3007,12 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "12.0.1"
|
||||
@@ -2994,6 +3189,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
|
||||
dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -3021,6 +3225,17 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.130"
|
||||
@@ -3053,6 +3268,26 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.12"
|
||||
|
||||
@@ -10,6 +10,8 @@ axum-extra = { version = "0.12.6", features = ["cookie"] }
|
||||
dotenvy = "0.15.7"
|
||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
|
||||
mongodb = "3.10.0"
|
||||
quick-xml = { version = "0.42.0", features = ["serialize"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.154"
|
||||
thiserror = "2.0.21"
|
||||
|
||||
@@ -359,3 +359,15 @@ PATCH {{baseUrl}}/asset-transactions/BURAYA-ISLEM-ID
|
||||
Content-Type: application/json
|
||||
|
||||
{ "total": 3150000, "version": 1 }
|
||||
|
||||
### Portföy: varlıklar + güncel kur, değer, kâr/zarar + toplamlar + son kur çekimi
|
||||
GET {{baseUrl}}/portfolio
|
||||
|
||||
### Elle kur (TL × 1e6; tarih boşsa bugün). Aynı gün otomatik kur varsa elle girilen geçerli.
|
||||
POST {{baseUrl}}/rates
|
||||
Content-Type: application/json
|
||||
|
||||
{ "code": "XAU_CEYREK", "rate_e6": 10250000000 }
|
||||
|
||||
### Elle kuru sil → otomatik kura dön
|
||||
DELETE {{baseUrl}}/rates/BURAYA-KUR-ID
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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