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:
@@ -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
|
||||||
|
|||||||
Generated
+239
-4
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//! `exchange_rates` — kurlar (DATA_MODEL.md §3.14). Değer: 1 birim varlık = `rate_e6` / 1e6 TL.
|
||||||
|
//!
|
||||||
|
//! `household_id: null` → otomatik (tüm haneler, TCMB / xaus.com); dolu → hanenin elle girdiği kur.
|
||||||
|
//! Otomatik kurlar gün başına tek kayıt (aynı gün yeniden çekilince güncellenir) → günlük geçmiş.
|
||||||
|
|
||||||
|
use mongodb::bson::{DateTime, Uuid};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Rate {
|
||||||
|
#[serde(rename = "_id")]
|
||||||
|
pub id: Uuid,
|
||||||
|
#[serde(default)]
|
||||||
|
pub household_id: Option<Uuid>,
|
||||||
|
/// Varlık kodu: `USD`, `XAU_GRAM`…
|
||||||
|
pub code: String,
|
||||||
|
/// TL × 1e6, birim başına
|
||||||
|
pub rate_e6: i64,
|
||||||
|
/// `tcmb` · `xaus` · `manual`
|
||||||
|
pub source: String,
|
||||||
|
/// Kurun geçerli olduğu gün (TCMB: bülten tarihi)
|
||||||
|
pub rate_date: String,
|
||||||
|
pub fetched_at: DateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rate {
|
||||||
|
pub fn is_manual(&self) -> bool {
|
||||||
|
self.household_id.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ pub struct AssetDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AssetDto {
|
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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//! Kur ve portföy uç noktaları (hanenin tüm üyeleri):
|
||||||
|
//!
|
||||||
|
//! | Yöntem | Yol | İş |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | GET | `/api/portfolio` | varlıklar + güncel kur, değer, kâr/zarar + toplamlar + son kur çekimi |
|
||||||
|
//! | POST | `/api/rates` | elle kur (hanenin; aynı gün tekrar → güncellenir) |
|
||||||
|
//! | DELETE | `/api/rates/{id}` | elle kuru sil → otomatik kura dön |
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use mongodb::bson::{DateTime, Uuid};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthUser,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
extract::ValidJson,
|
||||||
|
models::rate::Rate,
|
||||||
|
routes::asset::AssetDto,
|
||||||
|
services::rates,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "Rate")]
|
||||||
|
pub struct RateDto {
|
||||||
|
id: String,
|
||||||
|
code: String,
|
||||||
|
/// TL × 1e6, birim başına
|
||||||
|
#[ts(type = "number")]
|
||||||
|
rate_e6: i64,
|
||||||
|
/// `tcmb` · `xaus` · `manual`
|
||||||
|
#[ts(type = "\"tcmb\" | \"xaus\" | \"manual\"")]
|
||||||
|
source: String,
|
||||||
|
rate_date: String,
|
||||||
|
/// ISO 8601
|
||||||
|
fetched_at: String,
|
||||||
|
is_manual: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iso(d: DateTime) -> String {
|
||||||
|
d.try_to_rfc3339_string().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Rate> for RateDto {
|
||||||
|
fn from(r: Rate) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id.to_string(),
|
||||||
|
is_manual: r.is_manual(),
|
||||||
|
code: r.code,
|
||||||
|
rate_e6: r.rate_e6,
|
||||||
|
source: r.source,
|
||||||
|
rate_date: r.rate_date,
|
||||||
|
fetched_at: iso(r.fetched_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "PortfolioItem")]
|
||||||
|
struct ItemDto {
|
||||||
|
asset: AssetDto,
|
||||||
|
/// TL'de ve kur yoksa `null`
|
||||||
|
rate: Option<RateDto>,
|
||||||
|
/// Güncel değer (kuruş); kur yoksa `null`
|
||||||
|
#[ts(type = "number | null")]
|
||||||
|
value: Option<i64>,
|
||||||
|
/// Değer − eldeki maliyet (kuruş)
|
||||||
|
#[ts(type = "number | null")]
|
||||||
|
unrealized_pl: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "Portfolio")]
|
||||||
|
pub struct PortfolioDto {
|
||||||
|
items: Vec<ItemDto>,
|
||||||
|
/// Eldeki maliyet toplamı (kuruş)
|
||||||
|
#[ts(type = "number")]
|
||||||
|
cost_total: i64,
|
||||||
|
/// Kuru olanların güncel değeri (kuruş)
|
||||||
|
#[ts(type = "number")]
|
||||||
|
value_total: i64,
|
||||||
|
/// Kuru olanlarda değer − maliyet
|
||||||
|
#[ts(type = "number")]
|
||||||
|
unrealized_pl_total: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
realized_pl_total: i64,
|
||||||
|
/// Elde olup kuru bulunmayan varlık sayısı (toplama girmedi)
|
||||||
|
unpriced: u32,
|
||||||
|
/// Son otomatik kur çekimi (ISO); hiç yoksa `null`
|
||||||
|
last_fetch: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn portfolio(
|
||||||
|
State(s): State<crate::state::AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> AppResult<Json<PortfolioDto>> {
|
||||||
|
let p = rates::portfolio(&s.db, auth.household_id).await?;
|
||||||
|
let mut dto = PortfolioDto {
|
||||||
|
items: Vec::with_capacity(p.items.len()),
|
||||||
|
cost_total: 0,
|
||||||
|
value_total: 0,
|
||||||
|
unrealized_pl_total: 0,
|
||||||
|
realized_pl_total: 0,
|
||||||
|
unpriced: 0,
|
||||||
|
last_fetch: p.last_fetch.map(iso),
|
||||||
|
};
|
||||||
|
for v in p.items {
|
||||||
|
let h = v.holding;
|
||||||
|
dto.cost_total += h.cost;
|
||||||
|
dto.realized_pl_total += h.realized_pl;
|
||||||
|
let unrealized = v.value.map(|x| x - h.cost);
|
||||||
|
match v.value {
|
||||||
|
Some(x) => {
|
||||||
|
dto.value_total += x;
|
||||||
|
dto.unrealized_pl_total += x - h.cost;
|
||||||
|
}
|
||||||
|
None if h.quantity_e6 > 0 => dto.unpriced += 1,
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
dto.items.push(ItemDto {
|
||||||
|
asset: AssetDto::new(v.asset, h, v.tx_count),
|
||||||
|
rate: v.rate.map(RateDto::from),
|
||||||
|
value: v.value,
|
||||||
|
unrealized_pl: unrealized,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Json(dto))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, TS)]
|
||||||
|
#[ts(export, rename = "RateCreateRequest", optional_fields)]
|
||||||
|
pub struct CreateRequest {
|
||||||
|
/// Varlık kodu (`XAU_CEYREK`, `USD`…)
|
||||||
|
code: String,
|
||||||
|
/// TL × 1e6, birim başına
|
||||||
|
#[ts(type = "number")]
|
||||||
|
rate_e6: i64,
|
||||||
|
/// Boşsa bugün
|
||||||
|
rate_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
State(s): State<crate::state::AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
ValidJson(req): ValidJson<CreateRequest>,
|
||||||
|
) -> AppResult<(StatusCode, Json<RateDto>)> {
|
||||||
|
let r = rates::set_manual(
|
||||||
|
&s.db,
|
||||||
|
auth.household_id,
|
||||||
|
&req.code,
|
||||||
|
req.rate_e6,
|
||||||
|
req.rate_date.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(r.into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(
|
||||||
|
State(s): State<crate::state::AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
|
||||||
|
rates::delete_manual(&s.db, auth.household_id, id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
@@ -26,6 +26,13 @@ pub fn parse_date(s: &str) -> Option<Date> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `Date` → `"2026-12-09"`
|
/// `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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
//! Kur kaynaklarının ayrıştırıcıları (saf; ağ yok → örnek metinle test edilir).
|
||||||
|
//!
|
||||||
|
//! - **TCMB** `today.xml`: dövizlerin **alış** kuru (`ForexBuying`) ÷ `Unit` (JPY 100 birim…).
|
||||||
|
//! Değerleme alış kuruyla (elindekini bugün bozdursan alacağın; kullanıcı kararı).
|
||||||
|
//! - **xaus.com** `/api/v1/spot`: 1 gr saf altının USD fiyatı (`per_gram_usd`) × TCMB USD alış.
|
||||||
|
//! Çeyrek/yarım/tam = içerdiği saf altın (has) gramı × gram fiyatı (kuyumcu işçilik payı hariç);
|
||||||
|
//! ons = 31,1034768 gr. Bu yaklaşık değer; gerekirse manuel kur girilir.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::services::calendar::{format_date, parse_date};
|
||||||
|
|
||||||
|
/// Altın kodları ve içerdiği saf altın (gr × 1e6)
|
||||||
|
pub const GOLD: &[(&str, i64)] = &[
|
||||||
|
("XAU_GRAM", 1_000_000),
|
||||||
|
("XAU_CEYREK", 1_603_000), // 1,75 gr × 0,916 (22 ayar)
|
||||||
|
("XAU_YARIM", 3_206_000), // 3,50 gr × 0,916
|
||||||
|
("XAU_TAM", 6_412_000), // 7,00 gr × 0,916
|
||||||
|
("XAU_ONS", 31_103_477), // 31,1034768 gr
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct Bulletin {
|
||||||
|
/// `"YYYY-MM-DD"`
|
||||||
|
pub date: String,
|
||||||
|
/// (kod, TL × 1e6 / birim)
|
||||||
|
pub rates: Vec<(String, i64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TcmbXml {
|
||||||
|
#[serde(rename = "@Tarih")]
|
||||||
|
tarih: String,
|
||||||
|
#[serde(rename = "Currency", default)]
|
||||||
|
currencies: Vec<TcmbCurrency>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TcmbCurrency {
|
||||||
|
#[serde(rename = "@CurrencyCode")]
|
||||||
|
code: String,
|
||||||
|
#[serde(rename = "Unit")]
|
||||||
|
unit: Option<String>,
|
||||||
|
#[serde(rename = "ForexBuying")]
|
||||||
|
forex_buying: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `"50.1691"` → `50_169_100` (ondalık noktalı metin → × 1e6, kayan nokta kullanmadan)
|
||||||
|
pub fn decimal_e6(s: &str) -> Option<i64> {
|
||||||
|
let s = s.trim();
|
||||||
|
let (int, frac) = s.split_once('.').unwrap_or((s, ""));
|
||||||
|
if int.is_empty()
|
||||||
|
|| !int.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
|| !frac.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut frac6: String = frac.chars().take(6).collect();
|
||||||
|
while frac6.len() < 6 {
|
||||||
|
frac6.push('0');
|
||||||
|
}
|
||||||
|
let v = int.parse::<i64>().ok()?.checked_mul(1_000_000)? + frac6.parse::<i64>().ok()?;
|
||||||
|
// 7. basamak yuvarlama
|
||||||
|
let round = frac.as_bytes().get(6).is_some_and(|b| *b >= b'5');
|
||||||
|
Some(v + i64::from(round))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_tcmb(xml: &str) -> Result<Bulletin, String> {
|
||||||
|
let doc: TcmbXml =
|
||||||
|
quick_xml::de::from_str(xml).map_err(|e| format!("TCMB XML okunamadı: {e}"))?;
|
||||||
|
// "12.11.2026" → "2026-11-12"
|
||||||
|
let parts: Vec<&str> = doc.tarih.split('.').collect();
|
||||||
|
let date = match parts.as_slice() {
|
||||||
|
[d, m, y] => format!("{y}-{m}-{d}"),
|
||||||
|
_ => return Err(format!("TCMB tarihi okunamadı: {}", doc.tarih)),
|
||||||
|
};
|
||||||
|
let date = parse_date(&date)
|
||||||
|
.map(format_date)
|
||||||
|
.ok_or_else(|| format!("TCMB tarihi geçersiz: {}", doc.tarih))?;
|
||||||
|
let rates = doc
|
||||||
|
.currencies
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|c| {
|
||||||
|
let unit: i64 = c
|
||||||
|
.unit
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("1")
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
.filter(|u| *u > 0)?;
|
||||||
|
let buying = decimal_e6(c.forex_buying.as_deref()?)?;
|
||||||
|
(buying > 0).then(|| (c.code.trim().to_uppercase(), buying / unit))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Bulletin { date, rates })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct XausSpot {
|
||||||
|
per_gram_usd: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1 gr saf altın, USD × 1e6
|
||||||
|
pub fn parse_gold(json: &str) -> Result<i64, String> {
|
||||||
|
let s: XausSpot =
|
||||||
|
serde_json::from_str(json).map_err(|e| format!("altın fiyatı okunamadı: {e}"))?;
|
||||||
|
// JSON sayıyı ondalıklı verir; kurda 6 basamak yeterli
|
||||||
|
let v = (s.per_gram_usd * 1_000_000.0).round();
|
||||||
|
if !(v.is_finite() && v > 0.0 && v < 1e15) {
|
||||||
|
return Err(format!("altın fiyatı geçersiz: {}", s.per_gram_usd));
|
||||||
|
}
|
||||||
|
Ok(v as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gram USD fiyatı ve USD/TL alış → altın kodlarının TL kurları
|
||||||
|
pub fn gold_rates(per_gram_usd_e6: i64, usd_try_e6: i64) -> Vec<(&'static str, i64)> {
|
||||||
|
let gram_try_e6 = i128::from(per_gram_usd_e6) * i128::from(usd_try_e6) / 1_000_000;
|
||||||
|
GOLD.iter()
|
||||||
|
.map(|(code, has_e6)| {
|
||||||
|
(
|
||||||
|
*code,
|
||||||
|
(gram_try_e6 * i128::from(*has_e6) / 1_000_000) as i64,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub const TCMB_SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<?xml-stylesheet type="text/xsl" href="isokur.xsl"?>
|
||||||
|
<Tarih_Date Tarih="12.11.2026" Date="11/12/2026" Bulten_No="2026/213" >
|
||||||
|
<Currency CrossOrder="0" Kod="USD" CurrencyCode="USD">
|
||||||
|
<Unit>1</Unit>
|
||||||
|
<Isim>ABD DOLARI</Isim>
|
||||||
|
<CurrencyName>US DOLLAR</CurrencyName>
|
||||||
|
<ForexBuying>50.1691</ForexBuying>
|
||||||
|
<ForexSelling>50.2595</ForexSelling>
|
||||||
|
<BanknoteBuying>50.1340</BanknoteBuying>
|
||||||
|
<BanknoteSelling>50.3349</BanknoteSelling>
|
||||||
|
<CrossRateUSD/>
|
||||||
|
<CrossRateOther/>
|
||||||
|
</Currency>
|
||||||
|
<Currency CrossOrder="9" Kod="JPY" CurrencyCode="JPY">
|
||||||
|
<Unit>100</Unit>
|
||||||
|
<Isim>JAPON YENİ</Isim>
|
||||||
|
<CurrencyName>JAPENESE YEN</CurrencyName>
|
||||||
|
<ForexBuying>32.4512</ForexBuying>
|
||||||
|
<ForexSelling>32.6661</ForexSelling>
|
||||||
|
</Currency>
|
||||||
|
<Currency CrossOrder="19" Kod="XDR" CurrencyCode="XDR">
|
||||||
|
<Unit>1</Unit>
|
||||||
|
<Isim>ÖZEL ÇEKME HAKKI (SDR)</Isim>
|
||||||
|
<CurrencyName>SPECIAL DRAWING RIGHT (SDR)</CurrencyName>
|
||||||
|
<ForexBuying>68.5521</ForexBuying>
|
||||||
|
<ForexSelling/>
|
||||||
|
</Currency>
|
||||||
|
<Currency CrossOrder="20" Kod="IRR" CurrencyCode="IRR">
|
||||||
|
<Unit>1</Unit>
|
||||||
|
<ForexBuying/>
|
||||||
|
</Currency>
|
||||||
|
</Tarih_Date>"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decimal_without_floats() {
|
||||||
|
assert_eq!(decimal_e6("50.1691"), Some(50_169_100));
|
||||||
|
assert_eq!(decimal_e6("7"), Some(7_000_000));
|
||||||
|
assert_eq!(
|
||||||
|
decimal_e6("0.1234567"),
|
||||||
|
Some(123_457),
|
||||||
|
"7. basamak yuvarlanır"
|
||||||
|
);
|
||||||
|
assert_eq!(decimal_e6(""), None);
|
||||||
|
assert_eq!(decimal_e6("1,5"), None);
|
||||||
|
assert_eq!(decimal_e6("-2.0"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tcmb_buying_rates_per_unit() {
|
||||||
|
let b = parse_tcmb(TCMB_SAMPLE).unwrap();
|
||||||
|
assert_eq!(b.date, "2026-11-12");
|
||||||
|
assert_eq!(
|
||||||
|
b.rates,
|
||||||
|
vec![
|
||||||
|
("USD".into(), 50_169_100),
|
||||||
|
("JPY".into(), 324_512), // 100 yen = 32,4512 TL
|
||||||
|
("XDR".into(), 68_552_100),
|
||||||
|
],
|
||||||
|
"alış kuru boş olan (IRR) atlanır"
|
||||||
|
);
|
||||||
|
assert!(parse_tcmb("<html>bakımda</html>").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gold_from_usd() {
|
||||||
|
let g = parse_gold(
|
||||||
|
r#"{"xau":{"price":3874.1},"per_gram_usd":124.5552,"spot_usd_oz":3874.100098}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(g, 124_555_200);
|
||||||
|
assert!(parse_gold(r#"{"per_gram_usd":0}"#).is_err());
|
||||||
|
assert!(parse_gold("{}").is_err());
|
||||||
|
|
||||||
|
let r = gold_rates(g, 50_169_100);
|
||||||
|
// 124,5552 $ × 50,1691 TL = 6.248,8222843 TL/gr
|
||||||
|
assert_eq!(r[0], ("XAU_GRAM", 6_248_822_284));
|
||||||
|
assert_eq!(r[1].0, "XAU_CEYREK");
|
||||||
|
assert_eq!(r[1].1, 6_248_822_284 * 1_603 / 1_000);
|
||||||
|
assert_eq!(r[4].1, 194_360_100_187, "ons ≈ 194.360,10 TL");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
//! Kurlar ve portföy değerlemesi (KAKEIBO_PROMPT §8.7, DATA_MODEL.md §3.14).
|
||||||
|
//!
|
||||||
|
//! - **Otomatik:** `refresh` TCMB'den dövizleri, xaus.com'dan altını çeker; `exchange_rates`'e
|
||||||
|
//! `household_id: null` ile gün başına tek kayıt yazar (aynı gün güncellenir). İki kaynak
|
||||||
|
//! birbirinden bağımsız: biri erişilemezse diğeri yine yazılır; altın için USD kuru o an
|
||||||
|
//! çekilemediyse veritabanındaki son USD kullanılır. Hata → log; son kur geçerli kalır.
|
||||||
|
//! - **Manuel:** hane kendi kurunu girer (gün başına tek; aynı gün tekrar girilirse güncellenir).
|
||||||
|
//! - **Güncel kur:** kod başına en yeni `rate_date`; aynı gün hem otomatik hem manuel varsa manuel.
|
||||||
|
//! (Eski bir manuel kur, daha yeni otomatik kurun önüne geçmez.)
|
||||||
|
//! - **Değer:** miktar × kur. TL varlığı kursuz (değeri = tutarı).
|
||||||
|
|
||||||
|
use std::{collections::HashMap, time::Duration};
|
||||||
|
|
||||||
|
use mongodb::{
|
||||||
|
Database,
|
||||||
|
bson::{DateTime, Document, Uuid, doc},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::col,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{
|
||||||
|
asset::{Asset, AssetKind},
|
||||||
|
common::{new_id, now, scope},
|
||||||
|
rate::Rate,
|
||||||
|
},
|
||||||
|
services::{
|
||||||
|
asset,
|
||||||
|
calendar::{format_date, parse_date, today_tr},
|
||||||
|
holding::Holding,
|
||||||
|
rate_sources,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Kaynak adresleri (testlerde sahte sunucu verilir)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Sources {
|
||||||
|
pub tcmb_url: String,
|
||||||
|
pub gold_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Sources {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
tcmb_url: "https://www.tcmb.gov.tr/kurlar/today.xml".into(),
|
||||||
|
gold_url: "https://xaus.com/api/v1/spot".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP istemcisi: kısa zaman aşımı, genel kimlik (kişisel bilgi yok)
|
||||||
|
pub fn http_client() -> reqwest::Client {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(15))
|
||||||
|
.user_agent("kakeibo/0.1 (ev finans defteri)")
|
||||||
|
.build()
|
||||||
|
.expect("HTTP istemcisi kurulamadı")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Report {
|
||||||
|
/// Yazılan döviz kuru sayısı ya da hata
|
||||||
|
pub fx: Option<Result<usize, String>>,
|
||||||
|
pub gold: Option<Result<usize, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_text(client: &reqwest::Client, url: &str) -> Result<String, String> {
|
||||||
|
let r = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{url}: {e}"))?;
|
||||||
|
if !r.status().is_success() {
|
||||||
|
return Err(format!("{url}: HTTP {}", r.status()));
|
||||||
|
}
|
||||||
|
r.text().await.map_err(|e| format!("{url}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// İki kaynağı da çeker ve yazar. Veritabanı hatası dışında hata döndürmez (rapora yazar).
|
||||||
|
pub async fn refresh(db: &Database, client: &reqwest::Client, src: &Sources) -> AppResult<Report> {
|
||||||
|
let mut report = Report::default();
|
||||||
|
let fetched_at = now();
|
||||||
|
|
||||||
|
// Döviz (TCMB)
|
||||||
|
let mut usd = None;
|
||||||
|
match fetch_text(client, &src.tcmb_url)
|
||||||
|
.await
|
||||||
|
.and_then(|x| rate_sources::parse_tcmb(&x))
|
||||||
|
{
|
||||||
|
Ok(b) => {
|
||||||
|
for (code, rate) in &b.rates {
|
||||||
|
upsert_auto(db, code, *rate, "tcmb", &b.date, fetched_at).await?;
|
||||||
|
}
|
||||||
|
usd = b.rates.iter().find(|(c, _)| c == "USD").map(|(_, r)| *r);
|
||||||
|
report.fx = Some(Ok(b.rates.len()));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("TCMB kurları alınamadı: {e}");
|
||||||
|
report.fx = Some(Err(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Altın (xaus.com × USD)
|
||||||
|
let usd = match usd {
|
||||||
|
Some(u) => Some(u),
|
||||||
|
None => latest_auto(db, "USD").await?.map(|r| r.rate_e6),
|
||||||
|
};
|
||||||
|
let gold = match usd {
|
||||||
|
None => Err("USD kuru yok; altın TL'ye çevrilemedi".to_string()),
|
||||||
|
Some(usd) => fetch_text(client, &src.gold_url)
|
||||||
|
.await
|
||||||
|
.and_then(|x| rate_sources::parse_gold(&x))
|
||||||
|
.map(|g| rate_sources::gold_rates(g, usd)),
|
||||||
|
};
|
||||||
|
match gold {
|
||||||
|
Ok(list) => {
|
||||||
|
let date = format_date(today_tr());
|
||||||
|
for (code, rate) in &list {
|
||||||
|
upsert_auto(db, code, *rate, "xaus", &date, fetched_at).await?;
|
||||||
|
}
|
||||||
|
report.gold = Some(Ok(list.len()));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Altın fiyatı alınamadı: {e}");
|
||||||
|
report.gold = Some(Err(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uygulama açılınca ve her `minutes` dakikada bir kurları yeniler (0 → kapalı).
|
||||||
|
pub fn spawn_refresher(db: Database, minutes: u64) {
|
||||||
|
if minutes == 0 {
|
||||||
|
tracing::info!("Otomatik kur yenileme kapalı (RATES_REFRESH_MINUTES=0)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let client = http_client();
|
||||||
|
let src = Sources::default();
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_secs(minutes * 60));
|
||||||
|
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
match refresh(&db, &client, &src).await {
|
||||||
|
Ok(r) => tracing::info!("Kurlar yenilendi: döviz {:?}, altın {:?}", r.fx, r.gold),
|
||||||
|
Err(e) => tracing::error!("Kurlar yazılamadı: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_auto(
|
||||||
|
db: &Database,
|
||||||
|
code: &str,
|
||||||
|
rate_e6: i64,
|
||||||
|
source: &str,
|
||||||
|
rate_date: &str,
|
||||||
|
fetched_at: DateTime,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let ts = now();
|
||||||
|
db.collection::<Document>(col::EXCHANGE_RATES)
|
||||||
|
.update_one(
|
||||||
|
doc! { "household_id": null, "code": code, "source": source, "rate_date": rate_date, "deleted_at": null },
|
||||||
|
doc! {
|
||||||
|
"$set": { "rate_e6": rate_e6, "fetched_at": fetched_at, "updated_at": ts },
|
||||||
|
"$setOnInsert": { "_id": new_id(), "created_at": ts, "version": 1_i32 },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.upsert(true)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn latest_auto(db: &Database, code: &str) -> AppResult<Option<Rate>> {
|
||||||
|
Ok(db
|
||||||
|
.collection::<Rate>(col::EXCHANGE_RATES)
|
||||||
|
.find_one(doc! { "household_id": null, "code": code, "deleted_at": null })
|
||||||
|
.sort(doc! { "rate_date": -1, "fetched_at": -1 })
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Güncel kur ----------
|
||||||
|
|
||||||
|
/// Kod → güncel kur (hanenin manuel kurları + otomatik kurlar)
|
||||||
|
pub async fn current(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, Rate>> {
|
||||||
|
// Kod × (otomatik | bu hane) başına en yeni kayıt → en fazla 2 aday
|
||||||
|
let pipeline = vec![
|
||||||
|
doc! { "$match": {
|
||||||
|
"deleted_at": null,
|
||||||
|
"$or": [ { "household_id": null }, { "household_id": household_id } ],
|
||||||
|
} },
|
||||||
|
doc! { "$sort": { "rate_date": -1, "fetched_at": -1 } },
|
||||||
|
doc! { "$group": {
|
||||||
|
"_id": { "code": "$code", "hh": "$household_id" },
|
||||||
|
"doc": { "$first": "$$ROOT" },
|
||||||
|
} },
|
||||||
|
doc! { "$replaceRoot": { "newRoot": "$doc" } },
|
||||||
|
];
|
||||||
|
let mut cursor = db
|
||||||
|
.collection::<Document>(col::EXCHANGE_RATES)
|
||||||
|
.aggregate(pipeline)
|
||||||
|
.with_type::<Rate>()
|
||||||
|
.await?;
|
||||||
|
let mut out: HashMap<String, Rate> = HashMap::new();
|
||||||
|
while cursor.advance().await? {
|
||||||
|
let r = cursor.deserialize_current()?;
|
||||||
|
let better = match out.get(&r.code) {
|
||||||
|
None => true,
|
||||||
|
Some(cur) => {
|
||||||
|
(r.rate_date.as_str(), r.is_manual()) > (cur.rate_date.as_str(), cur.is_manual())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if better {
|
||||||
|
out.insert(r.code.clone(), r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// En son otomatik çekim zamanı (kaynak sağlıklı mı?)
|
||||||
|
pub async fn last_fetch(db: &Database) -> AppResult<Option<DateTime>> {
|
||||||
|
Ok(db
|
||||||
|
.collection::<Rate>(col::EXCHANGE_RATES)
|
||||||
|
.find_one(doc! { "household_id": null, "deleted_at": null })
|
||||||
|
.sort(doc! { "fetched_at": -1 })
|
||||||
|
.await?
|
||||||
|
.map(|r| r.fetched_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Manuel kur ----------
|
||||||
|
|
||||||
|
/// Hanenin varlıklarından birinin kodu için elle kur (aynı gün tekrar → güncellenir)
|
||||||
|
pub async fn set_manual(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
code: &str,
|
||||||
|
rate_e6: i64,
|
||||||
|
rate_date: Option<&str>,
|
||||||
|
) -> AppResult<Rate> {
|
||||||
|
let code = code.trim().to_uppercase();
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("code", &code);
|
||||||
|
let a = db
|
||||||
|
.collection::<Asset>(col::ASSETS)
|
||||||
|
.find_one(f)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::BadRequest("Bu varlık bulunamadı".into()))?;
|
||||||
|
if a.kind == AssetKind::Cash {
|
||||||
|
return Err(AppError::BadRequest("TL için kur girilmez".into()));
|
||||||
|
}
|
||||||
|
if rate_e6 <= 0 || rate_e6 > 1_000_000_000_000_000 {
|
||||||
|
return Err(AppError::BadRequest("Kur sıfırdan büyük olmalı".into()));
|
||||||
|
}
|
||||||
|
let date = match rate_date.map(str::trim).filter(|d| !d.is_empty()) {
|
||||||
|
Some(d) => {
|
||||||
|
format_date(parse_date(d).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?)
|
||||||
|
}
|
||||||
|
None => format_date(today_tr()),
|
||||||
|
};
|
||||||
|
let ts = now();
|
||||||
|
let filter = doc! { "household_id": household_id, "code": &code, "rate_date": &date, "deleted_at": null };
|
||||||
|
db.collection::<Document>(col::EXCHANGE_RATES)
|
||||||
|
.update_one(
|
||||||
|
filter.clone(),
|
||||||
|
doc! {
|
||||||
|
"$set": { "rate_e6": rate_e6, "source": "manual", "fetched_at": ts, "updated_at": ts },
|
||||||
|
"$inc": { "version": 1 },
|
||||||
|
"$setOnInsert": { "_id": new_id(), "created_at": ts },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.upsert(true)
|
||||||
|
.await?;
|
||||||
|
db.collection::<Rate>(col::EXCHANGE_RATES)
|
||||||
|
.find_one(filter)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::Internal("manuel kur okunamadı".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hanenin manuel kuru silinir (soft) → otomatik kura dönülür
|
||||||
|
pub async fn delete_manual(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
|
||||||
|
let ts = now();
|
||||||
|
let r = db
|
||||||
|
.collection::<Document>(col::EXCHANGE_RATES)
|
||||||
|
.update_one(
|
||||||
|
doc! { "_id": id, "household_id": household_id, "deleted_at": null },
|
||||||
|
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if r.matched_count == 0 {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Portföy ----------
|
||||||
|
|
||||||
|
pub struct Valued {
|
||||||
|
pub asset: Asset,
|
||||||
|
pub holding: Holding,
|
||||||
|
pub tx_count: usize,
|
||||||
|
/// TL varlığında `None` (kur gerekmez)
|
||||||
|
pub rate: Option<Rate>,
|
||||||
|
/// Güncel değer (kuruş); kur yoksa `None`
|
||||||
|
pub value: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Portfolio {
|
||||||
|
pub items: Vec<Valued>,
|
||||||
|
pub last_fetch: Option<DateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// miktar (× 1e6) × kur (TL × 1e6) → kuruş
|
||||||
|
pub fn value_of(quantity_e6: i64, rate_e6: i64) -> i64 {
|
||||||
|
let n = i128::from(quantity_e6) * i128::from(rate_e6);
|
||||||
|
((n + 5_000_000_000) / 10_000_000_000) as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn portfolio(db: &Database, household_id: Uuid) -> AppResult<Portfolio> {
|
||||||
|
let mut rates = current(db, household_id).await?;
|
||||||
|
let items = asset::list(db, household_id)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(a, h, n)| {
|
||||||
|
let (rate, value) = if a.kind == AssetKind::Cash {
|
||||||
|
(None, Some(h.quantity_e6 / 10_000)) // TL: miktar = tutar
|
||||||
|
} else {
|
||||||
|
let r = rates.remove(&a.code);
|
||||||
|
let v = r.as_ref().map(|r| value_of(h.quantity_e6, r.rate_e6));
|
||||||
|
(r, v)
|
||||||
|
};
|
||||||
|
Valued {
|
||||||
|
asset: a,
|
||||||
|
holding: h,
|
||||||
|
tx_count: n,
|
||||||
|
rate,
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Portfolio {
|
||||||
|
items,
|
||||||
|
last_fetch: last_fetch(db).await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::value_of;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_rounds_to_nearest_kurus() {
|
||||||
|
// 1 birim × 123,456789 TL = 12.345,6789 kuruş → 12.346
|
||||||
|
assert_eq!(value_of(1_000_000, 123_456_789), 12_346);
|
||||||
|
// 0,5 gr × 6.248,822284 TL = 3.124,411142 TL → 312.441 kuruş
|
||||||
|
assert_eq!(value_of(500_000, 6_248_822_284), 312_441);
|
||||||
|
assert_eq!(value_of(0, 6_248_822_284), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ impl TestApp {
|
|||||||
port: 0,
|
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 }
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
Reference in New Issue
Block a user