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
+3
View File
@@ -24,3 +24,6 @@ RUST_LOG=kakeibo=debug,tower_http=info
# Derlenmiş arayüz klasörü. Geliştirmede BOŞ bırakın (arayüzü Vite sunar). # Derlenmiş arayüz klasörü. Geliştirmede BOŞ bırakın (arayüzü Vite sunar).
# Docker imajında /app/static olarak ayarlıdır. # Docker imajında /app/static olarak ayarlıdır.
# STATIC_DIR= # STATIC_DIR=
# Otomatik kur yenileme (dakika); 0 → kapalı (çevrimdışı geliştirme)
# RATES_REFRESH_MINUTES=60
+239 -4
View File
@@ -178,6 +178,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "base64ct" name = "base64ct"
version = "1.8.3" version = "1.8.3"
@@ -257,7 +263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e"
dependencies = [ dependencies = [
"ahash", "ahash",
"base64", "base64 0.22.1",
"bitvec", "bitvec",
"getrandom 0.2.17", "getrandom 0.2.17",
"getrandom 0.3.4", "getrandom 0.3.4",
@@ -301,6 +307,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.10.2" version = "0.10.2"
@@ -928,9 +940,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1", "rand_core 0.10.1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1130,6 +1144,23 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec",
"tokio", "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]] [[package]]
@@ -1138,13 +1169,22 @@ version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [ dependencies = [
"base64 0.23.1",
"bytes", "bytes",
"futures-channel",
"futures-util",
"http", "http",
"http-body", "http-body",
"httparse",
"hyper", "hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -1361,7 +1401,7 @@ version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"ed25519-dalek", "ed25519-dalek",
"getrandom 0.2.17", "getrandom 0.2.17",
"hmac 0.12.1", "hmac 0.12.1",
@@ -1388,13 +1428,15 @@ dependencies = [
"http-body-util", "http-body-util",
"jsonwebtoken", "jsonwebtoken",
"mongodb", "mongodb",
"quick-xml",
"reqwest",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror",
"time", "time",
"tokio", "tokio",
"tower", "tower",
"tower-http", "tower-http 0.7.1",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"ts-rs", "ts-rs",
@@ -1444,6 +1486,12 @@ version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "lru-slab"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]] [[package]]
name = "macro_magic" name = "macro_magic"
version = "0.5.1" version = "0.5.1"
@@ -1601,7 +1649,7 @@ version = "3.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6466d4601ff396952e1895d88083d1ab03930b812277180eb8258bbe1a10bb0f" checksum = "6466d4601ff396952e1895d88083d1ab03930b812277180eb8258bbe1a10bb0f"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bitflags", "bitflags",
"bson", "bson",
"derive-where", "derive-where",
@@ -1928,6 +1976,72 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.47" version = "1.0.47"
@@ -2031,6 +2145,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" 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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -2069,6 +2192,44 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" 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]] [[package]]
name = "resolv-conf" name = "resolv-conf"
version = "0.7.6" version = "0.7.6"
@@ -2119,6 +2280,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -2159,6 +2326,7 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "102f35f7de7b06d8c61f1cff6592f5253e0dea431c74867aad5dfd70f44663fb" checksum = "102f35f7de7b06d8c61f1cff6592f5253e0dea431c74867aad5dfd70f44663fb"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -2504,6 +2672,9 @@ name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]] [[package]]
name = "synstructure" name = "synstructure"
@@ -2717,6 +2888,24 @@ dependencies = [
"tracing", "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]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.7.1" version = "0.7.1"
@@ -2818,6 +3007,12 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]] [[package]]
name = "ts-rs" name = "ts-rs"
version = "12.0.1" version = "12.0.1"
@@ -2994,6 +3189,15 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
@@ -3021,6 +3225,17 @@ dependencies = [
"wasm-bindgen-shared", "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]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.130" version = "0.2.130"
@@ -3053,6 +3268,26 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.12" version = "1.0.12"
+2
View File
@@ -10,6 +10,8 @@ axum-extra = { version = "0.12.6", features = ["cookie"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] } jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
mongodb = "3.10.0" 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 = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154" serde_json = "1.0.154"
thiserror = "2.0.21" thiserror = "2.0.21"
+12
View File
@@ -359,3 +359,15 @@ PATCH {{baseUrl}}/asset-transactions/BURAYA-ISLEM-ID
Content-Type: application/json Content-Type: application/json
{ "total": 3150000, "version": 1 } { "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
+10
View File
@@ -12,6 +12,8 @@ pub struct Config {
/// Derlenmiş frontend klasörü (`STATIC_DIR`). Tanımlıysa Axum arayüzü de sunar /// 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. /// (canlıda tek servis). Geliştirmede boş: arayüzü Vite sunar.
pub static_dir: Option<PathBuf>, 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). /// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni).
@@ -69,6 +71,13 @@ impl Config {
Err(_) => 8080, 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 { Ok(Self {
mongodb_uri, mongodb_uri,
jwt_secret, jwt_secret,
@@ -76,6 +85,7 @@ impl Config {
port, port,
registration, registration,
static_dir, static_dir,
rates_refresh_minutes,
}) })
} }
+4 -1
View File
@@ -1,6 +1,6 @@
use std::{net::SocketAddr, process::ExitCode}; 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; use tracing_subscriber::EnvFilter;
#[tokio::main] #[tokio::main]
@@ -41,6 +41,9 @@ async fn main() -> ExitCode {
return ExitCode::FAILURE; 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 port = config.port;
let state = AppState::new(db, config); let state = AppState::new(db, config);
let app = routes::app(state); let app = routes::app(state);
+1
View File
@@ -10,4 +10,5 @@ pub mod invite;
pub mod note; pub mod note;
pub mod period; pub mod period;
pub mod plan_item; pub mod plan_item;
pub mod rate;
pub mod user; pub mod user;
+30
View File
@@ -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()
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ pub struct AssetDto {
} }
impl 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 { Self {
id: a.id.to_string(), id: a.id.to_string(),
kind: a.kind, kind: a.kind,
+4
View File
@@ -10,6 +10,7 @@ mod household;
mod note; mod note;
mod period; mod period;
mod plan; mod plan;
mod rates;
mod review; mod review;
mod spa; mod spa;
mod stats; mod stats;
@@ -100,6 +101,9 @@ pub fn router(state: AppState) -> Router {
.route("/entries/{id}", patch(entry::update).delete(entry::delete)) .route("/entries/{id}", patch(entry::update).delete(entry::delete))
.route("/entries/{id}/restore", post(entry::restore)) .route("/entries/{id}/restore", post(entry::restore))
.route("/assets", get(asset::list).post(asset::create)) .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}", patch(asset::update))
.route( .route(
"/assets/{id}/transactions", "/assets/{id}/transactions",
+171
View File
@@ -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)
}
+7
View File
@@ -26,6 +26,13 @@ pub fn parse_date(s: &str) -> Option<Date> {
} }
/// `Date` → `"2026-12-09"` /// `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 { pub fn format_date(d: Date) -> String {
format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day()) format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day())
} }
+2
View File
@@ -13,6 +13,8 @@ pub mod money;
pub mod note; pub mod note;
pub mod period; pub mod period;
pub mod plan; pub mod plan;
pub mod rate_sources;
pub mod rates;
pub mod review; pub mod review;
pub mod seed; pub mod seed;
pub mod stats; pub mod stats;
+214
View File
@@ -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");
}
}
+358
View File
@@ -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);
}
}
+1
View File
@@ -57,6 +57,7 @@ impl TestApp {
port: 0, port: 0,
registration, registration,
static_dir: None, static_dir: None,
rates_refresh_minutes: 0,
}; };
let router = routes::router(AppState::new(db.clone(), config)); let router = routes::router(AppState::new(db.clone(), config));
Self { router, db } 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;
}
+3 -1
View File
@@ -187,7 +187,9 @@ Renkler ve ikonlar frontend tasarım token'larında.
rate_date: "2026-12-15", fetched_at, rate_date: "2026-12-15", fetched_at,
created_at, ... } created_at, ... }
``` ```
- Güncel kur sırası: hanenin elle girdiği en yeni kur → en yeni otomatik kur. - 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.) Kur = 1 birimin TL karşılığı × 1e6; döviz **alış** kuru (değerleme).
- Otomatik kurlar gün başına tek kayıt (kod × kaynak × gün; aynı gün yeniden çekilince güncellenir) → günlük geçmiş birikir. TCMB `rate_date` = bülten tarihi; altın = Türkiye'de bugün.
- Altın: xaus.com gram (saf) USD × TCMB USD alış; çeyrek/yarım/tam = has gram (1,603 / 3,206 / 6,412) × gram, ons = 31,1034768 gr. İşçilik payı yok → gerekirse manuel.
### 3.15 `schema_migrations` ### 3.15 `schema_migrations`
```js ```js
+1
View File
@@ -24,6 +24,7 @@
| `APP_URL` | ✅ | `https://kakeibo.<alan-adı>` | `https://` ile başlarsa cookie `Secure` olur | | `APP_URL` | ✅ | `https://kakeibo.<alan-adı>` | `https://` ile başlarsa cookie `Secure` olur |
| `REGISTRATION` | – | `invite_only` | `invite_only`: ilk hane serbest, sonra yalnızca davet · `open`: herkes | | `REGISTRATION` | – | `invite_only` | `invite_only`: ilk hane serbest, sonra yalnızca davet · `open`: herkes |
| `RUST_LOG` | – | `kakeibo=info,tower_http=info` | Log seviyesi | | `RUST_LOG` | – | `kakeibo=info,tower_http=info` | Log seviyesi |
| `RATES_REFRESH_MINUTES` | – | `60` | Otomatik kur yenileme aralığı (dk); `0` → kapalı. Konteynerin internete çıkışı gerekir: `www.tcmb.gov.tr` (döviz), `xaus.com` (altın). Erişilemezse son kur kalır, elle kur girilebilir |
| `PORT` | – | `8080` | İmajda ayarlı, değiştirmeyin | | `PORT` | – | `8080` | İmajda ayarlı, değiştirmeyin |
| `STATIC_DIR` | – | `/app/static` | İmajda ayarlı, değiştirmeyin | | `STATIC_DIR` | – | `/app/static` | İmajda ayarlı, değiştirmeyin |
+2 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ Sıradaki Adım
**Faz 8 – Adım 2:** Kurlar (backend): `services/rates.rs` — TCMB `today.xml` ayrıştırıcı (USD/EUR/… **ForexBuying** = değerleme; quick-xml ya da basit ayrıştırma, gerekçe ver) + xaus.com `/api/v1/spot` (`per_gram_usd` × USD → XAU_GRAM; çeyrek/yarım/tam has gram katsayısı, ons = 31,1035 gr) → `exchange_rates` (`household_id: null`, `rate_e6`, `source`, `rate_date`, `fetched_at`); saatlik tokio arka plan görevi (zaman aşımı, hata → log, son kur kalır); `reqwest` gerekecek (gerekçe). Manuel kur `POST /rates` (hane). `GET /portfolio`: varlık başına güncel kur (manuel en yeni > otomatik en yeni), değer, kâr/zarar tutar/%, toplamlar, dağılım, kur zamanı. Testlerde ağ yok: ayrıştırıcılar örnek dosyayla, servis veritabanına elle yazılmış kurla. **Faz 8 – Adım 3:** Birikim sayfası (`/birikim`, `features/savings/`): `usePortfolio` (`GET /portfolio`) → özet (toplam değer, maliyet, gerçekleşmemiş + gerçekleşen kâr/zarar, `unpriced` uyarısı, son kur çekimi + eski kur uyarısı) + halka grafik (Recharts `PieChart`, lazy; renkler ham CSS değişkeni), varlık listesi (miktar `formatQuantity`, ortalama maliyet, kur + kaynak/tarih, değer, kâr/zarar ve %), varlık detayı: işlem geçmişi + alım/satım formu (miktar × birim fiyat → tutar önerisi, düzenlenebilir; TL'de yalnız tutar) + düzenle/sil, elle kur girme/silme, döviz ekleme, pasife alma. Kayıt/kur değişince `portfolio` + `assets` + `stats` yenilensin. Miktar/kur alanları için e6 metin ⇄ sayı yardımcıları (`lib/`) + test.
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅. **Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
## 📌 Alınan Kararlar ## 📌 Alınan Kararlar
@@ -126,3 +126,4 @@
### 2026-11-13 — Faz 8 ### 2026-11-13 — Faz 8
- **Adım 1:** Varlıklar backend: `models/asset.rs` (`AssetKind` cash/gold/fx, `TxType` buy/sell), `services/holding.rs` (saf: ağırlıklı ortalama maliyet, tam satışta artık maliyet sıfırlanır, `Oversold`, i128 `mul_div`; 5 birim test), `services/asset.rs` (liste + hesaplanmış durum, döviz ekleme 3 harf/tekil, ad/aktiflik, işlem ekle/düzenle/sil; her yazmada tarih sıralı yeniden denetim; TL: miktar = tutar), `routes/asset.rs` (7 uç nokta). Varsayılan 8 varlık `seed::assets` (kayıtta) + **migration v2** (eski haneler, idempotent → canlıdaki hane uygulama yeniden başlayınca alır). `tests/assets.rs` (4). Mutasyon 3/3 yakalandı. Backend 135 test. - **Adım 1:** Varlıklar backend: `models/asset.rs` (`AssetKind` cash/gold/fx, `TxType` buy/sell), `services/holding.rs` (saf: ağırlıklı ortalama maliyet, tam satışta artık maliyet sıfırlanır, `Oversold`, i128 `mul_div`; 5 birim test), `services/asset.rs` (liste + hesaplanmış durum, döviz ekleme 3 harf/tekil, ad/aktiflik, işlem ekle/düzenle/sil; her yazmada tarih sıralı yeniden denetim; TL: miktar = tutar), `routes/asset.rs` (7 uç nokta). Varsayılan 8 varlık `seed::assets` (kayıtta) + **migration v2** (eski haneler, idempotent → canlıdaki hane uygulama yeniden başlayınca alır). `tests/assets.rs` (4). Mutasyon 3/3 yakalandı. Backend 135 test.
- **Adım 2:** Kurlar: `reqwest` 0.12 (`rustls-tls` → MongoDB ile aynı `ring`; 0.13 `aws-lc-rs` getiriyordu), `quick-xml` (serialize). `services/rate_sources.rs` (saf: `parse_tcmb` ForexBuying ÷ Unit, boş alış atlanır, `decimal_e6` kayan noktasız; `parse_gold` xaus `per_gram_usd`; `gold_rates` has gram katsayıları; testler örnek metinle), `services/rates.rs` (`refresh`: iki kaynak bağımsız, TCMB düşerse DB'deki son USD; gün başına upsert; `spawn_refresher` main'de, `RATES_REFRESH_MINUTES` varsayılan 60 / 0 kapalı; `current`: aggregation kod×(oto|hane) en yeni → en yeni tarih, eşitlikte manuel; `set_manual`/`delete_manual`; `portfolio` + `value_of` yarım yukarı). `GET /portfolio`, `POST /rates`, `DELETE /rates/{id}`. `calendar::today_tr` (UTC+3). Testler sahte axum sunucusuyla (internet yok): `tests/rates.rs` (2) + birim (4). Mutasyon: öncelik, USD yedeği yakalandı; yuvarlama kaçtı → `value_of` birim testi eklendi, artık yakalıyor. Gerçek kaynak denemesi (test DB): TCMB 22 döviz, altın 5 kod (gram 6.253,82, çeyrek 10.024,88 ₺). Backend 145 test. DEPLOY §2 + `.env.example`: `RATES_REFRESH_MINUTES`, konteynerin internete çıkışı gerekir.
+1 -1
View File
@@ -74,6 +74,6 @@
## Faz 8 Adımları ## Faz 8 Adımları
- ✅ 1. Backend: varlıklar (hane başına varsayılanlar: TL, gram/çeyrek/yarım/tam altın, ons, USD, EUR; döviz eklenebilir) + alım/satım kayıtları (satış ≤ eldeki miktar) + varlık başına miktar, **ağırlıklı ortalama maliyet**, gerçekleşen kâr/zarar; testler - ✅ 1. Backend: varlıklar (hane başına varsayılanlar: TL, gram/çeyrek/yarım/tam altın, ons, USD, EUR; döviz eklenebilir) + alım/satım kayıtları (satış ≤ eldeki miktar) + varlık başına miktar, **ağırlıklı ortalama maliyet**, gerçekleşen kâr/zarar; testler
- ⏳ 2. Backend: kurlar — TCMB `today.xml` (döviz) + xaus.com ons fiyatı × TCMB USD (altın; çeyrek/yarım/tam has gram katsayısıyla), saatlik arka plan görevi + önbellek (`exchange_rates`), kaynak erişilemezse son kur + zamanı; **manuel kur** (hane); portföy değerleme uç noktası (güncel değer, kâr/zarar tutar ve %, toplamlar, dağılım); kaynak ayrıştırıcıları örnek dosyalarla testli - ✅ 2. Backend: kurlar — TCMB `today.xml` (döviz) + xaus.com ons fiyatı × TCMB USD (altın; çeyrek/yarım/tam has gram katsayısıyla), saatlik arka plan görevi + önbellek (`exchange_rates`), kaynak erişilemezse son kur + zamanı; **manuel kur** (hane); portföy değerleme uç noktası (güncel değer, kâr/zarar tutar ve %, toplamlar, dağılım); kaynak ayrıştırıcıları örnek dosyalarla testli
- ⏳ 3. Frontend: Birikim sayfası — portföy özeti + halka grafik, varlık listesi (miktar, ortalama maliyet, güncel kur, değer, kâr/zarar), alım/satım formu, işlem geçmişi (düzenle/sil), manuel kur, eski kur uyarısı - ⏳ 3. Frontend: Birikim sayfası — portföy özeti + halka grafik, varlık listesi (miktar, ortalama maliyet, güncel kur, değer, kâr/zarar), alım/satım formu, işlem geçmişi (düzenle/sil), manuel kur, eski kur uyarısı
- ⏳ 4. Kapanış entegrasyonu: birikime aktarılan tutar onayla TL varlığına eklenir (`source_period_id`, tekrar eklenmez); geçmiş kapanışlar için "bekleyen birikimler" listesi; Panel "Toplam birikim" → portföyün güncel değeri - ⏳ 4. Kapanış entegrasyonu: birikime aktarılan tutar onayla TL varlığına eklenir (`source_period_id`, tekrar eklenmez); geçmiş kapanışlar için "bekleyen birikimler" listesi; Panel "Toplam birikim" → portföyün güncel değeri
+24
View File
@@ -0,0 +1,24 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PortfolioItem } from "./PortfolioItem";
export type Portfolio = { items: Array<PortfolioItem>,
/**
* Eldeki maliyet toplamı (kuruş)
*/
cost_total: number,
/**
* Kuru olanların güncel değeri (kuruş)
*/
value_total: number,
/**
* Kuru olanlarda değer − maliyet
*/
unrealized_pl_total: number, realized_pl_total: number,
/**
* Elde olup kuru bulunmayan varlık sayısı (toplama girmedi)
*/
unpriced: number,
/**
* Son otomatik kur çekimi (ISO); hiç yoksa `null`
*/
last_fetch: string | null, };
+17
View File
@@ -0,0 +1,17 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Asset } from "./Asset";
import type { Rate } from "./Rate";
export type PortfolioItem = { asset: Asset,
/**
* TL'de ve kur yoksa `null`
*/
rate: Rate | null,
/**
* Güncel değer (kuruş); kur yoksa `null`
*/
value: number | null,
/**
* Değer − eldeki maliyet (kuruş)
*/
unrealized_pl: number | null, };
+15
View File
@@ -0,0 +1,15 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Rate = { id: string, code: string,
/**
* TL × 1e6, birim başına
*/
rate_e6: number,
/**
* `tcmb` · `xaus` · `manual`
*/
source: "tcmb" | "xaus" | "manual", rate_date: string,
/**
* ISO 8601
*/
fetched_at: string, is_manual: boolean, };
+15
View File
@@ -0,0 +1,15 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RateCreateRequest = {
/**
* Varlık kodu (`XAU_CEYREK`, `USD`…)
*/
code: string,
/**
* TL × 1e6, birim başına
*/
rate_e6: number,
/**
* Boşsa bugün
*/
rate_date?: string, };