From 3f60efdc3310fbbb8cf4cf4d103281a7c75e4617 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 13 Nov 2026 00:59:01 +0300 Subject: [PATCH] =?UTF-8?q?feat(birikim):=20otomatik=20ve=20manuel=20kurla?= =?UTF-8?q?r,=20portf=C3=B6y=20de=C4=9Ferlemesi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 3 + backend/Cargo.lock | 243 +++++++++++++++- backend/Cargo.toml | 2 + backend/api.http | 12 + backend/src/config.rs | 10 + backend/src/main.rs | 5 +- backend/src/models/mod.rs | 1 + backend/src/models/rate.rs | 30 ++ backend/src/routes/asset.rs | 2 +- backend/src/routes/mod.rs | 4 + backend/src/routes/rates.rs | 171 +++++++++++ backend/src/services/calendar.rs | 7 + backend/src/services/mod.rs | 2 + backend/src/services/rate_sources.rs | 214 ++++++++++++++ backend/src/services/rates.rs | 358 ++++++++++++++++++++++++ backend/tests/common/mod.rs | 1 + backend/tests/rates.rs | 287 +++++++++++++++++++ docs/DATA_MODEL.md | 4 +- docs/DEPLOY.md | 1 + docs/MEMORY.md | 3 +- docs/PHASES.md | 2 +- frontend/src/types/Portfolio.ts | 24 ++ frontend/src/types/PortfolioItem.ts | 17 ++ frontend/src/types/Rate.ts | 15 + frontend/src/types/RateCreateRequest.ts | 15 + 25 files changed, 1424 insertions(+), 9 deletions(-) create mode 100644 backend/src/models/rate.rs create mode 100644 backend/src/routes/rates.rs create mode 100644 backend/src/services/rate_sources.rs create mode 100644 backend/src/services/rates.rs create mode 100644 backend/tests/rates.rs create mode 100644 frontend/src/types/Portfolio.ts create mode 100644 frontend/src/types/PortfolioItem.ts create mode 100644 frontend/src/types/Rate.ts create mode 100644 frontend/src/types/RateCreateRequest.ts diff --git a/.env.example b/.env.example index bed03ef..65249f2 100644 --- a/.env.example +++ b/.env.example @@ -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). # Docker imajında /app/static olarak ayarlıdır. # STATIC_DIR= + +# Otomatik kur yenileme (dakika); 0 → kapalı (çevrimdışı geliştirme) +# RATES_REFRESH_MINUTES=60 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 24d984f..f5f236e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -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" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index bd1d46f..797871a 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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" diff --git a/backend/api.http b/backend/api.http index 52e020d..fbd2b59 100644 --- a/backend/api.http +++ b/backend/api.http @@ -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 diff --git a/backend/src/config.rs b/backend/src/config.rs index d7dfd4e..c0450d9 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -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, + /// 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, }) } diff --git a/backend/src/main.rs b/backend/src/main.rs index 2db179c..cde0275 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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); diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 98effb2..53ec19f 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -10,4 +10,5 @@ pub mod invite; pub mod note; pub mod period; pub mod plan_item; +pub mod rate; pub mod user; diff --git a/backend/src/models/rate.rs b/backend/src/models/rate.rs new file mode 100644 index 0000000..e6a4e26 --- /dev/null +++ b/backend/src/models/rate.rs @@ -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, + /// 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() + } +} diff --git a/backend/src/routes/asset.rs b/backend/src/routes/asset.rs index 92257aa..c0b7663 100644 --- a/backend/src/routes/asset.rs +++ b/backend/src/routes/asset.rs @@ -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, diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index c04839f..30a90f0 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -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", diff --git a/backend/src/routes/rates.rs b/backend/src/routes/rates.rs new file mode 100644 index 0000000..bef9929 --- /dev/null +++ b/backend/src/routes/rates.rs @@ -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 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, + /// Güncel değer (kuruş); kur yoksa `null` + #[ts(type = "number | null")] + value: Option, + /// Değer − eldeki maliyet (kuruş) + #[ts(type = "number | null")] + unrealized_pl: Option, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "Portfolio")] +pub struct PortfolioDto { + items: Vec, + /// 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, +} + +pub async fn portfolio( + State(s): State, + auth: AuthUser, +) -> AppResult> { + 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, +} + +pub async fn create( + State(s): State, + auth: AuthUser, + ValidJson(req): ValidJson, +) -> AppResult<(StatusCode, Json)> { + 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, + auth: AuthUser, + Path(id): Path, +) -> AppResult { + let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?; + rates::delete_manual(&s.db, auth.household_id, id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/services/calendar.rs b/backend/src/services/calendar.rs index 34c912e..e01d456 100644 --- a/backend/src/services/calendar.rs +++ b/backend/src/services/calendar.rs @@ -26,6 +26,13 @@ pub fn parse_date(s: &str) -> Option { } /// `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()) } diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 3472fea..17f3e55 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -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; diff --git a/backend/src/services/rate_sources.rs b/backend/src/services/rate_sources.rs new file mode 100644 index 0000000..03641c2 --- /dev/null +++ b/backend/src/services/rate_sources.rs @@ -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, +} + +#[derive(Deserialize)] +struct TcmbCurrency { + #[serde(rename = "@CurrencyCode")] + code: String, + #[serde(rename = "Unit")] + unit: Option, + #[serde(rename = "ForexBuying")] + forex_buying: Option, +} + +/// `"50.1691"` → `50_169_100` (ondalık noktalı metin → × 1e6, kayan nokta kullanmadan) +pub fn decimal_e6(s: &str) -> Option { + 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::().ok()?.checked_mul(1_000_000)? + frac6.parse::().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 { + 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 { + 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#" + + + + 1 + ABD DOLARI + US DOLLAR + 50.1691 + 50.2595 + 50.1340 + 50.3349 + + + + + 100 + JAPON YENİ + JAPENESE YEN + 32.4512 + 32.6661 + + + 1 + ÖZEL ÇEKME HAKKI (SDR) + SPECIAL DRAWING RIGHT (SDR) + 68.5521 + + + + 1 + + +"#; + + #[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("bakımda").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"); + } +} diff --git a/backend/src/services/rates.rs b/backend/src/services/rates.rs new file mode 100644 index 0000000..56f4e59 --- /dev/null +++ b/backend/src/services/rates.rs @@ -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>, + pub gold: Option>, +} + +async fn fetch_text(client: &reqwest::Client, url: &str) -> Result { + 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 { + 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::(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> { + Ok(db + .collection::(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> { + // 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::(col::EXCHANGE_RATES) + .aggregate(pipeline) + .with_type::() + .await?; + let mut out: HashMap = 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> { + Ok(db + .collection::(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 { + let code = code.trim().to_uppercase(); + let mut f = scope(household_id); + f.insert("code", &code); + let a = db + .collection::(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::(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::(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::(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, + /// Güncel değer (kuruş); kur yoksa `None` + pub value: Option, +} + +pub struct Portfolio { + pub items: Vec, + pub last_fetch: Option, +} + +/// 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 { + 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); + } +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index fb7db7c..8ae9208 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -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 } diff --git a/backend/tests/rates.rs b/backend/tests/rates.rs new file mode 100644 index 0000000..9e39e48 --- /dev/null +++ b/backend/tests/rates.rs @@ -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#" + + 150.169150.2595 + 10032.4512 + 168.5521 +"#; + +/// Sahte kaynak sunucusu: `/tcmb`, `/gold`. Bayraklarla hata verdirilebilir. +struct Mock { + src: Sources, + tcmb_down: Arc, + gold_price: Arc>, +} + +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::("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::("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() = "bakım".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; +} diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 5bec67a..45972f7 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -187,7 +187,9 @@ Renkler ve ikonlar frontend tasarım token'larında. rate_date: "2026-12-15", fetched_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` ```js diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index d9283b1..0636e03 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -24,6 +24,7 @@ | `APP_URL` | ✅ | `https://kakeibo.` | `https://` ile başlarsa cookie `Secure` olur | | `REGISTRATION` | – | `invite_only` | `invite_only`: ilk hane serbest, sonra yalnızca davet · `open`: herkes | | `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 | | `STATIC_DIR` | – | `/app/static` | İmajda ayarlı, değiştirmeyin | diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 41b8a0f..0087041 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ 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 ✅. ## 📌 Alınan Kararlar @@ -126,3 +126,4 @@ ### 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 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. diff --git a/docs/PHASES.md b/docs/PHASES.md index eb7a57b..b2db3b2 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -74,6 +74,6 @@ ## 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 -- ⏳ 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ı - ⏳ 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 diff --git a/frontend/src/types/Portfolio.ts b/frontend/src/types/Portfolio.ts new file mode 100644 index 0000000..6b57d1c --- /dev/null +++ b/frontend/src/types/Portfolio.ts @@ -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, +/** + * 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, }; diff --git a/frontend/src/types/PortfolioItem.ts b/frontend/src/types/PortfolioItem.ts new file mode 100644 index 0000000..f7f1a27 --- /dev/null +++ b/frontend/src/types/PortfolioItem.ts @@ -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, }; diff --git a/frontend/src/types/Rate.ts b/frontend/src/types/Rate.ts new file mode 100644 index 0000000..5987951 --- /dev/null +++ b/frontend/src/types/Rate.ts @@ -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, }; diff --git a/frontend/src/types/RateCreateRequest.ts b/frontend/src/types/RateCreateRequest.ts new file mode 100644 index 0000000..bd8a397 --- /dev/null +++ b/frontend/src/types/RateCreateRequest.ts @@ -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, };