Compare commits
28 Commits
f86cff4755
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| feb2e1d16c | |||
| 87409590a6 | |||
| 8c9c3c2506 | |||
| 48554e0ff6 | |||
| 6b7b69c987 | |||
| 3f60efdc33 | |||
| 267d292a90 | |||
| 1ef28b80a3 | |||
| 191c3e6db5 | |||
| 64c185ab96 | |||
| 30136ed1d3 | |||
| 861bfba85a | |||
| 2c16cf4163 | |||
| 311cfc8c60 | |||
| 62e92f552a | |||
| 8ec1fd6f25 | |||
| e4c8d971fc | |||
| 16535ad7f3 | |||
| f930f2fbcc | |||
| 7143613ab2 | |||
| 2003b8fad7 | |||
| eca0bb27a0 | |||
| 9004a7cc42 | |||
| ca0d3a505c | |||
| 95231cd4d6 | |||
| 7f3fde074c | |||
| b883530f76 | |||
| a2b4119f3e |
@@ -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
|
||||
|
||||
@@ -59,17 +59,21 @@ npm test # backend (cargo test, MongoDB açık olmalı) + frontend (
|
||||
npm run lint # clippy + oxlint
|
||||
npm run types # Rust DTO → frontend/src/types/*.ts (ts-rs; cargo test de üretir)
|
||||
npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonları (rsvg-convert + magick)
|
||||
npm run db:backup # kakeibo_dev → ./backups (canlı yedek: docs/DEPLOY.md §6)
|
||||
npm run db:restore -- <dosya|latest> <hedef_db> [--force]
|
||||
```
|
||||
- `npm run dev` öncesinde DB otomatik başlar (`predev`).
|
||||
- Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler).
|
||||
- Frontend lint: **oxlint** (Vite şablonu varsayılanı; ESLint yerine). `components/ui/**` shadcn dosyaları.
|
||||
- Frontend: Tailwind v4 (`src/index.css` token'ları), shadcn/ui (style `radix-nova`, `cn` paketi), `@/` → `src/`. Renkler: `bg-needs`/`text-needs-fg`/`bg-needs-soft` (wants, culture, unexpected aynı), `text-income`/`text-expense`/`text-warning`. Tutarlarda `money` sınıfı. Butonlar ≥44 px (shadcn boyutları büyütüldü).
|
||||
- Frontend: Tailwind v4 (`src/index.css` token'ları), shadcn/ui (style `radix-nova`, `cn` paketi), `@/` → `src/`. Renkler: `bg-needs`/`text-needs-fg`/`bg-needs-soft` (wants, culture, unexpected aynı), `text-income`/`text-expense`/`text-warning`. SVG/JS'te (Recharts vb.) ham değişken kullan: `var(--needs)`, `var(--primary)` — `@theme inline` yüzünden `--color-*` derlenmiş CSS'te olmayabilir. Tutarlarda `money` sınıfı. Butonlar ≥44 px (shadcn boyutları büyütüldü).
|
||||
- API: `lib/api.ts` (`api.get/post/...`, hata → `ApiError` Türkçe `message`), `lib/query.ts` (QueryClient + `queryKeys`), `lib/auth.ts` (`useMe` → undefined/null/MeResponse, `useLogin`/`useRegister`/`useLogout`).
|
||||
- Tipler: `frontend/src/types/` **elle düzenlenmez**; backend'de DTO'ya `#[derive(TS)] #[ts(export)]` ekle → `npm run types`. İstek yapılarında `optional_fields`.
|
||||
- Sayfalar: `src/router.tsx`; adresler `components/layout/nav-items.ts` (`paths`); sayfa bileşenleri `features/<alan>/page.tsx`; iskelet `components/layout/app-layout.tsx`. Sayfa başlığı `PageHeader` (sekme başlığını da ayarlar). Metinler `lib/strings.ts` (`t`).
|
||||
- Yönlendirme: `RequireAuth` (oturum yok → `/giris?donus=`), `RequireGuest` (oturum var → `donus` ya da panel; formlar ayrıca navigate etmez). `donus` daima `safeReturnPath` ile.
|
||||
- Geliştirmede tasarım önizlemesi: `/dev/tasarim`.
|
||||
- Formlar: react-hook-form + zod (`mode: 'onTouched'`), alanlar `components/form-field.tsx` (`FormField`, `PasswordInput`, `FormError`); sunucu hatası `setError` ile ilgili alana. Bildirim: `toast` (sonner, `components/toaster.tsx`).
|
||||
- Dönem verisi: `lib/periods.ts` (Query kancaları), tarih `lib/period-dates.ts` (arayüz son günü dahil gösterir, backend `end_date` hariç), tutar alanı `MoneyInput` + `moneyText`/`toKurus` (`lib/money-schema.ts`). İlk dönem sihirbazı `/plan/donem-ac?adim=`. Plan düzenleme: taslak → `diffPlan` → `useSavePlan`; haftalık önizleme `lib/budget-preview.ts` (backend hesabıyla aynı tutulmalı).
|
||||
- Defter: `/defter?hafta=N`, `features/ledger/` (hafta seçici, hızlı giriş), saf hesaplar `lib/ledger.ts`, kancalar `lib/entries.ts` + `lib/catalog.ts`. Kayıt değişince `queryKeys.period(id)` yenilenir (özet/bütçe `spent` dahil).
|
||||
- Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`.
|
||||
- API örnekleri: `backend/api.http`
|
||||
- Canlı: `Dockerfile` (tek imaj), `docs/DEPLOY.md` (ortam değişkenleri, Dokploy, MongoDB, yedek). `routes::app()` = API + `STATIC_DIR` varsa SPA (`routes/spa.rs`) + sıkıştırma + TraceLayer. Testler `routes::router()` kullanır.
|
||||
|
||||
Generated
+239
-4
@@ -178,6 +178,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
@@ -257,7 +263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitvec",
|
||||
"getrandom 0.2.17",
|
||||
"getrandom 0.3.4",
|
||||
@@ -301,6 +307,12 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
@@ -928,9 +940,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1130,6 +1144,23 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1138,13 +1169,22 @@ version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1361,7 +1401,7 @@ version = "11.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.2.17",
|
||||
"hmac 0.12.1",
|
||||
@@ -1388,13 +1428,15 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"jsonwebtoken",
|
||||
"mongodb",
|
||||
"quick-xml",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-http 0.7.1",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ts-rs",
|
||||
@@ -1444,6 +1486,12 @@ version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "macro_magic"
|
||||
version = "0.5.1"
|
||||
@@ -1601,7 +1649,7 @@ version = "3.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6466d4601ff396952e1895d88083d1ab03930b812277180eb8258bbe1a10bb0f"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags",
|
||||
"bson",
|
||||
"derive-where",
|
||||
@@ -1928,6 +1976,72 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -2031,6 +2145,15 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -2069,6 +2192,44 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http 0.6.11",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resolv-conf"
|
||||
version = "0.7.6"
|
||||
@@ -2119,6 +2280,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -2159,6 +2326,7 @@ version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "102f35f7de7b06d8c61f1cff6592f5253e0dea431c74867aad5dfd70f44663fb"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2504,6 +2672,9 @@ name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
@@ -2717,6 +2888,24 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.7.1"
|
||||
@@ -2818,6 +3007,12 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "12.0.1"
|
||||
@@ -2994,6 +3189,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
|
||||
dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -3021,6 +3225,17 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.130"
|
||||
@@ -3053,6 +3268,26 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.12"
|
||||
|
||||
@@ -10,6 +10,8 @@ axum-extra = { version = "0.12.6", features = ["cookie"] }
|
||||
dotenvy = "0.15.7"
|
||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
|
||||
mongodb = "3.10.0"
|
||||
quick-xml = { version = "0.42.0", features = ["serialize"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.154"
|
||||
thiserror = "2.0.21"
|
||||
|
||||
@@ -139,3 +139,249 @@ Content-Type: application/json
|
||||
{
|
||||
"ids": ["ID-1", "ID-2", "ID-3", "ID-4"]
|
||||
}
|
||||
|
||||
# ─────────────────────────── Dönemler ───────────────────────────
|
||||
# Bitiş tarihi HARİÇ: Aralık 2026 = "2026-12-01" → "2027-01-01". Hanede tek açık dönem.
|
||||
|
||||
### Açık dönem (yoksa null)
|
||||
GET {{baseUrl}}/periods/current
|
||||
|
||||
### Tüm dönemler (en yeni başta)
|
||||
GET {{baseUrl}}/periods
|
||||
|
||||
### Dönem aç — ad boşsa otomatik ("Aralık 2026"); savings_goal kuruş (5.000,00 ₺ = 500000)
|
||||
POST {{baseUrl}}/periods
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"start_date": "2026-12-01",
|
||||
"end_date": "2027-01-01",
|
||||
"savings_goal": 500000
|
||||
}
|
||||
|
||||
### Dönem detayı (haftalar dahil)
|
||||
GET {{baseUrl}}/periods/BURAYA-ID
|
||||
|
||||
### Dönemi düzenle — "version": son gördüğünüz sürüm (başkası değiştirdiyse 409)
|
||||
PATCH {{baseUrl}}/periods/BURAYA-ID
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"savings_goal": 750000,
|
||||
"version": 1
|
||||
}
|
||||
|
||||
# ─────────────────────────── Plan kalemleri (sabit gelir / gider) ───────────────────────────
|
||||
# kind: "income" | "expense" · amount kuruş (> 0) · date isteğe bağlı, dönem içinde
|
||||
|
||||
### Dönemin kalemleri (önce gelirler)
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items
|
||||
|
||||
### Kalem ekle
|
||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"kind": "expense",
|
||||
"name": "Kira",
|
||||
"amount": 1500000,
|
||||
"date": "2026-12-05"
|
||||
}
|
||||
|
||||
### Önceki dönemden kopyala (aynı tür + ad varsa atlanır; tarihler göreli günle taşınır)
|
||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items/copy-previous
|
||||
|
||||
### Kalemi düzenle ("date": "" → tarihi kaldır)
|
||||
PATCH {{baseUrl}}/plan-items/BURAYA-KALEM-ID
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"amount": 1600000,
|
||||
"version": 1
|
||||
}
|
||||
|
||||
### Kalemi sil
|
||||
DELETE {{baseUrl}}/plan-items/BURAYA-KALEM-ID
|
||||
|
||||
# ─────────────────────────── Notlar ───────────────────────────
|
||||
# context: "planning" (Plan sayfası) | "review" (Ay sonu değerlendirmesi) · body_md: Markdown
|
||||
|
||||
### Notlar (?context=planning ile filtre)
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/notes?context=planning
|
||||
|
||||
### Not ekle
|
||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/notes
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"context": "planning",
|
||||
"title": "Tatil birikimi",
|
||||
"body_md": "- Ocak'ta **5.000 ₺** ayır\n- Dışarıda yemeği azalt"
|
||||
}
|
||||
|
||||
### Notu düzenle
|
||||
PATCH {{baseUrl}}/notes/BURAYA-NOT-ID
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"body_md": "Güncel metin",
|
||||
"version": 1
|
||||
}
|
||||
|
||||
### Notu sil
|
||||
DELETE {{baseUrl}}/notes/BURAYA-NOT-ID
|
||||
|
||||
# ─────────────────────────── Bütçe ve özet ───────────────────────────
|
||||
# Gruplar: needs · wants · culture · unexpected · tutarlar kuruş
|
||||
|
||||
### Dönem özeti: gelir − sabit gider − birikim − devreden borç + devreden artı = harcanabilir
|
||||
### + spent_total (gerçekleşen harcama), remaining (harcanabilir − harcanan)
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/summary
|
||||
|
||||
### Bütçe: 4 grup, aylık + haftalık döküm (haftalar gün sayısına göre); her grup ve haftada `spent`
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/budget
|
||||
|
||||
### Aylık bütçeleri kaydet (4 grup birlikte)
|
||||
PUT {{baseUrl}}/periods/BURAYA-DONEM-ID/budget
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"needs": 1500000,
|
||||
"wants": 600000,
|
||||
"culture": 200000,
|
||||
"unexpected": 200000
|
||||
}
|
||||
|
||||
### Bir haftayı elle ayarla (week_start = haftanın ilk günü); "amount": null → otomatiğe döner
|
||||
PUT {{baseUrl}}/periods/BURAYA-DONEM-ID/budget/weeks
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"group": "needs",
|
||||
"week_start": "2026-12-07",
|
||||
"amount": 400000
|
||||
}
|
||||
|
||||
### Bütçe önerisi — method: default (varsayılan yüzdeler) | previous (önceki dönem) | avg3 (son 3 dönem)
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/budget/suggestion?method=default
|
||||
|
||||
# ─────────────────────── Günlük defter (harcamalar) ───────────────────────
|
||||
# Kategori / ödeme türü kimlikleri: GET /categories, GET /payment-methods · tutarlar kuruş
|
||||
|
||||
### Dönemin harcamaları (en yeni başta)
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/entries
|
||||
|
||||
### Bir haftanın harcamaları (from/to dahil)
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/entries?from=2026-12-07&to=2026-12-13
|
||||
|
||||
### Harcama ekle — amount kuruş (345,50 ₺ = 34550); grup kategoriden gelir; ödeme türü ve açıklama isteğe bağlı
|
||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/entries
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"date": "2026-12-09",
|
||||
"amount": 34550,
|
||||
"category_id": "BURAYA-KATEGORI-ID",
|
||||
"payment_method_id": "BURAYA-ODEME-TURU-ID",
|
||||
"description": "Haftalık market"
|
||||
}
|
||||
|
||||
### Harcamayı düzenle — "payment_method_id": "" / "description": "" → kaldır
|
||||
PATCH {{baseUrl}}/entries/BURAYA-KAYIT-ID
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"amount": 36000,
|
||||
"version": 1
|
||||
}
|
||||
|
||||
### Harcamayı sil
|
||||
DELETE {{baseUrl}}/entries/BURAYA-KAYIT-ID
|
||||
|
||||
### Silmeyi geri al ("↩️ Geri al")
|
||||
POST {{baseUrl}}/entries/BURAYA-KAYIT-ID/restore
|
||||
|
||||
# ─────────────────────────── Ay sonu değerlendirmesi ───────────────────────────
|
||||
|
||||
### Değerlendirme: 4 soru (available, savings_goal, spent_total, realized_savings) + grup/kategori/hafta
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/review
|
||||
|
||||
### "Nasıl daha iyi yapabilirim?" (Markdown; boş metin → kaldır; yalnızca açık dönem)
|
||||
PATCH {{baseUrl}}/periods/BURAYA-DONEM-ID
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"reflection_md": "- Dışarıda yemeği haftada 1'e indir\n- Market listesiyle alışveriş"
|
||||
}
|
||||
|
||||
### Dönemi kapat: to_savings verilmezse gerçekleşen birikimin tamamı birikime; kalanı artı, açık varsa borç devreder
|
||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/close
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"to_savings": 500000
|
||||
}
|
||||
|
||||
### Kapalı dönemi düzeltmeye aç (yalnızca hane sahibi) → status: correcting; düzeltince yeniden "close"
|
||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/reopen
|
||||
|
||||
# ─────────────────────────── Panel ───────────────────────────
|
||||
|
||||
### Günlük harcama: dönemin her günü, 4 grup (harcama yoksa 0)
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/daily
|
||||
|
||||
### Son dönemler (eskiden yeniye): harcanabilir, harcanan, birikime aktarılan + toplam birikim
|
||||
GET {{baseUrl}}/stats/periods?limit=12
|
||||
|
||||
# ─────────────────────────── Birikimler ───────────────────────────
|
||||
# Miktar/fiyat × 1e6 (5 gr → 5000000), tutar kuruş. TL varlığında yalnızca total yeter.
|
||||
|
||||
### Varlıklar + eldeki miktar, maliyet, ortalama maliyet, gerçekleşen kâr/zarar
|
||||
GET {{baseUrl}}/assets
|
||||
|
||||
### Döviz ekle
|
||||
POST {{baseUrl}}/assets
|
||||
Content-Type: application/json
|
||||
|
||||
{ "code": "GBP", "name": "İngiliz Sterlini", "unit_label": "£" }
|
||||
|
||||
### Alım (birim fiyat verilmezse tutar ÷ miktar)
|
||||
POST {{baseUrl}}/assets/BURAYA-VARLIK-ID/transactions
|
||||
Content-Type: application/json
|
||||
|
||||
{ "type": "buy", "date": "2026-11-13", "quantity_e6": 5000000, "total": 3130000, "note": "Kuyumcu" }
|
||||
|
||||
### İşlem geçmişi (en yeni başta)
|
||||
GET {{baseUrl}}/assets/BURAYA-VARLIK-ID/transactions
|
||||
|
||||
### İşlemi düzenle / sil
|
||||
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
|
||||
|
||||
### Kapanıp birikime henüz eklenmemiş dönemler
|
||||
GET {{baseUrl}}/savings/pending
|
||||
|
||||
### Dönemin kapanıştaki birikimini TL varlığına ekle (dönem başına bir kez)
|
||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/to-savings
|
||||
|
||||
# ─────────────────────────── Raporlar ───────────────────────────
|
||||
|
||||
### Harcama kırılımı: grup, tüm kategoriler, ödeme türü, ay (filtreler isteğe bağlı)
|
||||
GET {{baseUrl}}/reports/breakdown?from=2026-01-01&to=2026-12-31
|
||||
|
||||
### Bir dönemde, yalnızca Zorunlu, ödeme türü girilmemiş olanlar
|
||||
GET {{baseUrl}}/reports/breakdown?period_id=BURAYA-DONEM-ID&group=needs&payment_method_id=none
|
||||
|
||||
@@ -12,6 +12,8 @@ pub struct Config {
|
||||
/// Derlenmiş frontend klasörü (`STATIC_DIR`). Tanımlıysa Axum arayüzü de sunar
|
||||
/// (canlıda tek servis). Geliştirmede boş: arayüzü Vite sunar.
|
||||
pub static_dir: Option<PathBuf>,
|
||||
/// Otomatik kur yenileme aralığı (dakika, `RATES_REFRESH_MINUTES`, varsayılan 60); 0 → kapalı
|
||||
pub rates_refresh_minutes: u64,
|
||||
}
|
||||
|
||||
/// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni).
|
||||
@@ -69,6 +71,13 @@ impl Config {
|
||||
Err(_) => 8080,
|
||||
};
|
||||
|
||||
let rates_refresh_minutes = match env::var("RATES_REFRESH_MINUTES") {
|
||||
Ok(m) if !m.trim().is_empty() => m.trim().parse().map_err(|_| {
|
||||
ConfigError::Invalid("RATES_REFRESH_MINUTES", "dakika (sayı) olmalı")
|
||||
})?,
|
||||
_ => 60,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
mongodb_uri,
|
||||
jwt_secret,
|
||||
@@ -76,6 +85,7 @@ impl Config {
|
||||
port,
|
||||
registration,
|
||||
static_dir,
|
||||
rates_refresh_minutes,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
|
||||
col::CARRYOVERS,
|
||||
vec![idx(doc! { "household_id": 1, "to_period_id": 1 })],
|
||||
),
|
||||
(
|
||||
col::ASSETS,
|
||||
vec![idx(doc! { "household_id": 1, "sort_order": 1 })],
|
||||
),
|
||||
(
|
||||
col::ASSET_TRANSACTIONS,
|
||||
vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })],
|
||||
|
||||
@@ -24,11 +24,18 @@ struct Migration {
|
||||
}
|
||||
|
||||
/// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar.
|
||||
const MIGRATIONS: &[Migration] = &[Migration {
|
||||
version: 1,
|
||||
name: "initial",
|
||||
run: |db| Box::pin(m001_initial(db)),
|
||||
}];
|
||||
const MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial",
|
||||
run: |db| Box::pin(m001_initial(db)),
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "default_assets",
|
||||
run: |db| Box::pin(m002_default_assets(db)),
|
||||
},
|
||||
];
|
||||
|
||||
/// Uygulanmamış migration'ları sırayla çalıştırır.
|
||||
pub async fn run(db: &Database) -> Result<()> {
|
||||
@@ -66,6 +73,36 @@ async fn m001_initial(db: &Database) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v2: Faz 8'den önce açılmış hanelere varsayılan varlıklar (TL, altınlar, USD, EUR).
|
||||
/// Varlığı olan haneye dokunmaz → tekrar çalışsa da çift kayıt olmaz.
|
||||
async fn m002_default_assets(db: &Database) -> Result<()> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Household {
|
||||
#[serde(rename = "_id")]
|
||||
id: mongodb::bson::Uuid,
|
||||
}
|
||||
let assets = db.collection::<Document>(col::ASSETS);
|
||||
let mut cursor = db
|
||||
.collection::<Household>(col::HOUSEHOLDS)
|
||||
.find(doc! { "deleted_at": null })
|
||||
.projection(doc! { "_id": 1 })
|
||||
.await?;
|
||||
while cursor.advance().await? {
|
||||
let hid = cursor.deserialize_current()?.id;
|
||||
if assets
|
||||
.find_one(doc! { "household_id": hid })
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
assets
|
||||
.insert_many(crate::services::seed::assets(hid))
|
||||
.await?;
|
||||
tracing::info!("Varsayılan varlıklar eklendi: hane {hid}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
use std::{net::SocketAddr, process::ExitCode};
|
||||
|
||||
use kakeibo::{config::Config, db, routes, state::AppState};
|
||||
use kakeibo::{config::Config, db, routes, services, state::AppState};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -41,6 +41,9 @@ async fn main() -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
// Kurlar arka planda (açılışta hemen, sonra aralıkla); hata uygulamayı durdurmaz
|
||||
services::rates::spawn_refresher(db.clone(), config.rates_refresh_minutes);
|
||||
|
||||
let port = config.port;
|
||||
let state = AppState::new(db, config);
|
||||
let app = routes::app(state);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! `assets` ve `asset_transactions` — birikimler / varlıklar (DATA_MODEL.md §3.12, §3.13).
|
||||
//!
|
||||
//! Miktar ve birim fiyat **ölçekli tam sayı** (`_e6` = × 1.000.000); tutar kuruş.
|
||||
//! Örnek: 5 gr altın → `quantity_e6: 5_000_000`; 3.250,00 TL/gr → `unit_price_e6: 3_250_000_000`.
|
||||
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export)]
|
||||
pub enum AssetKind {
|
||||
/// Türk lirası (nakit birikim)
|
||||
Cash,
|
||||
Gold,
|
||||
/// Döviz
|
||||
Fx,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, rename = "AssetTxType")]
|
||||
pub enum TxType {
|
||||
Buy,
|
||||
Sell,
|
||||
}
|
||||
|
||||
impl TxType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
TxType::Buy => "buy",
|
||||
TxType::Sell => "sell",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Asset {
|
||||
#[serde(rename = "_id")]
|
||||
pub id: Uuid,
|
||||
pub kind: AssetKind,
|
||||
/// `TRY`, `XAU_GRAM`, `USD`… (hanede tekil)
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
/// `₺`, `gr`, `adet`, `$`…
|
||||
pub unit_label: String,
|
||||
pub sort_order: i32,
|
||||
pub is_active: bool,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AssetTx {
|
||||
#[serde(rename = "_id")]
|
||||
pub id: Uuid,
|
||||
pub asset_id: Uuid,
|
||||
#[serde(rename = "type")]
|
||||
pub tx_type: TxType,
|
||||
/// `"YYYY-MM-DD"`
|
||||
pub date: String,
|
||||
pub quantity_e6: i64,
|
||||
/// TL × 1e6, birim başına
|
||||
pub unit_price_e6: i64,
|
||||
/// Kuruş — girilen gerçek tutar (komisyon farkı olabilir); maliyet bundan hesaplanır
|
||||
pub total: i64,
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
/// Dönem kapanışından geldiyse
|
||||
#[serde(default)]
|
||||
pub source_period_id: Option<Uuid>,
|
||||
pub created_by: Uuid,
|
||||
pub version: i32,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! `entries` koleksiyonu — günlük defterdeki harcamalar (DATA_MODEL.md §3.9).
|
||||
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::models::group::Group;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Entry {
|
||||
#[serde(rename = "_id")]
|
||||
pub id: Uuid,
|
||||
pub period_id: Uuid,
|
||||
/// `"YYYY-MM-DD"`
|
||||
pub date: String,
|
||||
/// Kuruş, > 0
|
||||
pub amount: i64,
|
||||
/// Kategoriden türetilir (rapor ve bütçe toplamları için ayrıca saklanır)
|
||||
pub group: Group,
|
||||
pub category_id: Uuid,
|
||||
#[serde(default)]
|
||||
pub payment_method_id: Option<Uuid>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
pub created_by: Uuid,
|
||||
pub version: i32,
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
//! Veritabanı modelleri. Her koleksiyonun yapısı, onu kullanan adımda eklenir.
|
||||
//! Şema: `docs/DATA_MODEL.md`
|
||||
|
||||
pub mod asset;
|
||||
pub mod common;
|
||||
pub mod entry;
|
||||
pub mod group;
|
||||
pub mod household;
|
||||
pub mod invite;
|
||||
pub mod note;
|
||||
pub mod period;
|
||||
pub mod plan_item;
|
||||
pub mod rate;
|
||||
pub mod user;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! `notes` koleksiyonu (DATA_MODEL.md §3.11). Notlar döneme özeldir.
|
||||
|
||||
use mongodb::bson::{DateTime, Uuid};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Notun bağlı olduğu sayfa
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, rename = "NoteContext")]
|
||||
pub enum NoteContext {
|
||||
/// Aylık planlama
|
||||
Planning,
|
||||
/// Ay sonu değerlendirmesi
|
||||
Review,
|
||||
}
|
||||
|
||||
impl NoteContext {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
NoteContext::Planning => "planning",
|
||||
NoteContext::Review => "review",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Note {
|
||||
#[serde(rename = "_id")]
|
||||
pub id: Uuid,
|
||||
pub period_id: Uuid,
|
||||
pub context: NoteContext,
|
||||
pub title: String,
|
||||
pub body_md: String,
|
||||
pub created_by: Uuid,
|
||||
pub updated_at: DateTime,
|
||||
pub version: i32,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! `periods` koleksiyonu (DATA_MODEL.md §3.4).
|
||||
|
||||
use mongodb::bson::{DateTime, Uuid};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export)]
|
||||
pub enum PeriodStatus {
|
||||
/// Girişler yapılabilir. Hanede en fazla bir tane.
|
||||
Open,
|
||||
/// Salt okunur. Sahip yeniden açarsa `Correcting` olur.
|
||||
Closed,
|
||||
/// Düzeltmede: kapanmış dönem sahip tarafından düzeltme için açıldı.
|
||||
/// Yazılabilir, ama "açık dönem" sayılmaz (yeni dönem açıkken de olabilir).
|
||||
/// "Yeniden kapat" ile devir yeniden hesaplanır.
|
||||
Correcting,
|
||||
}
|
||||
|
||||
impl PeriodStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
PeriodStatus::Open => "open",
|
||||
PeriodStatus::Closed => "closed",
|
||||
PeriodStatus::Correcting => "correcting",
|
||||
}
|
||||
}
|
||||
|
||||
/// Kayıt eklenip değiştirilebilir mi?
|
||||
pub fn is_writable(self) -> bool {
|
||||
self != PeriodStatus::Closed
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Period {
|
||||
#[serde(rename = "_id")]
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
/// `"YYYY-MM-DD"`, dahil
|
||||
pub start_date: String,
|
||||
/// `"YYYY-MM-DD"`, **hariç**
|
||||
pub end_date: String,
|
||||
pub status: PeriodStatus,
|
||||
/// Kuruş
|
||||
pub savings_goal: i64,
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası)
|
||||
#[serde(default)]
|
||||
pub reflection_md: Option<String>,
|
||||
/// Kapanışta birikime aktarılan tutar (kuruş). Faz 8'de varlık hareketine dönüşecek.
|
||||
#[serde(default)]
|
||||
pub to_savings: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub closed_at: Option<DateTime>,
|
||||
pub version: i32,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! `plan_items` koleksiyonu — sabit gelir / gider kalemleri (DATA_MODEL.md §3.7).
|
||||
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, rename = "PlanItemKind")]
|
||||
pub enum PlanKind {
|
||||
Income,
|
||||
Expense,
|
||||
}
|
||||
|
||||
impl PlanKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
PlanKind::Income => "income",
|
||||
PlanKind::Expense => "expense",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PlanItem {
|
||||
#[serde(rename = "_id")]
|
||||
pub id: Uuid,
|
||||
pub period_id: Uuid,
|
||||
pub kind: PlanKind,
|
||||
pub name: String,
|
||||
/// Kuruş
|
||||
pub amount: i64,
|
||||
/// `"YYYY-MM-DD"` (isteğe bağlı)
|
||||
#[serde(default)]
|
||||
pub date: Option<String>,
|
||||
pub sort_order: i32,
|
||||
pub version: i32,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Birikim / varlık uç noktaları (hanenin tüm üyeleri):
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/assets` | varlıklar + eldeki miktar, maliyet, ortalama maliyet, gerçekleşen kâr/zarar |
|
||||
//! | POST | `/api/assets` | döviz ekle (ör. GBP) |
|
||||
//! | PATCH | `/api/assets/{id}` | ad / aktiflik |
|
||||
//! | GET | `/api/assets/{id}/transactions` | alım/satım geçmişi (en yeni başta) |
|
||||
//! | POST | `/api/assets/{id}/transactions` | alım / satım ekle |
|
||||
//! | PATCH | `/api/asset-transactions/{id}` | işlemi düzenle |
|
||||
//! | DELETE | `/api/asset-transactions/{id}` | işlemi sil (soft) |
|
||||
//! | GET | `/api/savings/pending` | kapanıp birikime henüz eklenmemiş dönemler |
|
||||
//! | POST | `/api/periods/{id}/to-savings` | dönemin birikimini TL varlığına alım olarak ekle |
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
extract::ValidJson,
|
||||
models::asset::{Asset, AssetKind, AssetTx, TxType},
|
||||
services::{
|
||||
asset::{self, NewTx, TxChanges},
|
||||
holding::Holding,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Asset")]
|
||||
pub struct AssetDto {
|
||||
id: String,
|
||||
kind: AssetKind,
|
||||
code: String,
|
||||
name: String,
|
||||
unit_label: String,
|
||||
sort_order: i32,
|
||||
is_active: bool,
|
||||
version: i32,
|
||||
/// Eldeki miktar (× 1e6)
|
||||
#[ts(type = "number")]
|
||||
quantity_e6: i64,
|
||||
/// Eldeki miktarın maliyeti (kuruş)
|
||||
#[ts(type = "number")]
|
||||
cost: i64,
|
||||
/// Birim başına ortalama maliyet (TL × 1e6); elde yoksa `null`
|
||||
#[ts(type = "number | null")]
|
||||
avg_cost_e6: Option<i64>,
|
||||
/// Satışlardan gerçekleşen kâr/zarar (kuruş)
|
||||
#[ts(type = "number")]
|
||||
realized_pl: i64,
|
||||
tx_count: u32,
|
||||
}
|
||||
|
||||
impl AssetDto {
|
||||
pub(crate) fn new(a: Asset, h: Holding, tx_count: usize) -> Self {
|
||||
Self {
|
||||
id: a.id.to_string(),
|
||||
kind: a.kind,
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
unit_label: a.unit_label,
|
||||
sort_order: a.sort_order,
|
||||
is_active: a.is_active,
|
||||
version: a.version,
|
||||
quantity_e6: h.quantity_e6,
|
||||
cost: h.cost,
|
||||
avg_cost_e6: h.avg_cost_e6,
|
||||
realized_pl: h.realized_pl,
|
||||
tx_count: tx_count as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "AssetTransaction")]
|
||||
pub struct TxDto {
|
||||
id: String,
|
||||
asset_id: String,
|
||||
#[serde(rename = "type")]
|
||||
#[ts(rename = "type")]
|
||||
tx_type: TxType,
|
||||
date: String,
|
||||
#[ts(type = "number")]
|
||||
quantity_e6: i64,
|
||||
/// TL × 1e6
|
||||
#[ts(type = "number")]
|
||||
unit_price_e6: i64,
|
||||
/// Kuruş
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
note: Option<String>,
|
||||
/// Dönem kapanışından geldiyse
|
||||
source_period_id: Option<String>,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
impl From<AssetTx> for TxDto {
|
||||
fn from(t: AssetTx) -> Self {
|
||||
Self {
|
||||
id: t.id.to_string(),
|
||||
asset_id: t.asset_id.to_string(),
|
||||
tx_type: t.tx_type,
|
||||
date: t.date,
|
||||
quantity_e6: t.quantity_e6,
|
||||
unit_price_e6: t.unit_price_e6,
|
||||
total: t.total,
|
||||
note: t.note,
|
||||
source_period_id: t.source_period_id.map(|u| u.to_string()),
|
||||
version: t.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "AssetCreateRequest", optional_fields)]
|
||||
pub struct CreateRequest {
|
||||
/// 3 harfli döviz kodu (ör. `GBP`)
|
||||
code: String,
|
||||
name: String,
|
||||
/// Boşsa kod
|
||||
unit_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "AssetUpdateRequest", optional_fields)]
|
||||
pub struct UpdateRequest {
|
||||
name: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "AssetTxCreateRequest", optional_fields)]
|
||||
pub struct TxCreateRequest {
|
||||
#[serde(rename = "type")]
|
||||
#[ts(rename = "type")]
|
||||
tx_type: TxType,
|
||||
date: String,
|
||||
/// × 1e6; TL varlığında yok sayılır
|
||||
#[ts(type = "number", optional)]
|
||||
quantity_e6: Option<i64>,
|
||||
/// TL × 1e6; verilmezse tutar ÷ miktar
|
||||
#[ts(type = "number", optional)]
|
||||
unit_price_e6: Option<i64>,
|
||||
/// Kuruş — ödenen / alınan gerçek tutar
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "AssetTxUpdateRequest", optional_fields)]
|
||||
pub struct TxUpdateRequest {
|
||||
date: Option<String>,
|
||||
#[ts(type = "number", optional)]
|
||||
quantity_e6: Option<i64>,
|
||||
#[ts(type = "number", optional)]
|
||||
unit_price_e6: Option<i64>,
|
||||
#[ts(type = "number", optional)]
|
||||
total: Option<i64>,
|
||||
/// Boş metin → notu kaldır
|
||||
note: Option<String>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
fn path_id(id: &str) -> AppResult<Uuid> {
|
||||
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn list(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<Vec<AssetDto>>> {
|
||||
let items = asset::list(&s.db, auth.household_id).await?;
|
||||
Ok(Json(
|
||||
items
|
||||
.into_iter()
|
||||
.map(|(a, h, n)| AssetDto::new(a, h, n))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<CreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<AssetDto>)> {
|
||||
let a = asset::create(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
&req.code,
|
||||
&req.name,
|
||||
req.unit_label.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(AssetDto::new(a, Holding::default(), 0)),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<UpdateRequest>,
|
||||
) -> AppResult<Json<AssetDto>> {
|
||||
let id = path_id(&id)?;
|
||||
asset::update(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
id,
|
||||
req.name,
|
||||
req.is_active,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
// Güncel durumla birlikte dön
|
||||
let (a, h, n) = asset::list(&s.db, auth.household_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|(a, _, _)| a.id == id)
|
||||
.ok_or(AppError::NotFound)?;
|
||||
Ok(Json(AssetDto::new(a, h, n)))
|
||||
}
|
||||
|
||||
pub async fn list_txs(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Vec<TxDto>>> {
|
||||
let items = asset::list_txs(&s.db, auth.household_id, path_id(&id)?).await?;
|
||||
Ok(Json(items.into_iter().map(TxDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn create_tx(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<TxCreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<TxDto>)> {
|
||||
let t = asset::create_tx(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
auth.user_id,
|
||||
path_id(&id)?,
|
||||
NewTx {
|
||||
tx_type: req.tx_type,
|
||||
date: req.date,
|
||||
quantity_e6: req.quantity_e6.unwrap_or(0),
|
||||
unit_price_e6: req.unit_price_e6,
|
||||
total: req.total,
|
||||
note: req.note,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(t.into())))
|
||||
}
|
||||
|
||||
pub async fn update_tx(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<TxUpdateRequest>,
|
||||
) -> AppResult<Json<TxDto>> {
|
||||
let t = asset::update_tx(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
path_id(&id)?,
|
||||
TxChanges {
|
||||
date: req.date,
|
||||
quantity_e6: req.quantity_e6,
|
||||
unit_price_e6: req.unit_price_e6,
|
||||
total: req.total,
|
||||
note: req.note,
|
||||
version: req.version,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(t.into()))
|
||||
}
|
||||
|
||||
pub async fn delete_tx(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<StatusCode> {
|
||||
asset::delete_tx(&s.db, auth.household_id, path_id(&id)?).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "PendingSavings")]
|
||||
pub struct PendingDto {
|
||||
period_id: String,
|
||||
name: String,
|
||||
/// Dönemin son günü (eklenecek alımın tarihi)
|
||||
last_date: String,
|
||||
/// Kuruş
|
||||
#[ts(type = "number")]
|
||||
to_savings: i64,
|
||||
}
|
||||
|
||||
pub async fn pending(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> AppResult<Json<Vec<PendingDto>>> {
|
||||
let list = asset::pending_period_savings(&s.db, auth.household_id).await?;
|
||||
Ok(Json(
|
||||
list.into_iter()
|
||||
.map(|p| {
|
||||
let end = crate::services::calendar::parse_date(&p.end_date)
|
||||
.expect("geçerli bitiş tarihi");
|
||||
PendingDto {
|
||||
period_id: p.id.to_string(),
|
||||
name: p.name,
|
||||
last_date: crate::services::calendar::format_date(
|
||||
end - time::Duration::days(1),
|
||||
),
|
||||
to_savings: p.to_savings.unwrap_or(0),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn add_period_savings(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<(StatusCode, Json<TxDto>)> {
|
||||
let t =
|
||||
asset::add_period_savings(&s.db, auth.household_id, auth.user_id, path_id(&id)?).await?;
|
||||
Ok((StatusCode::CREATED, Json(t.into())))
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Bütçe ve özet uç noktaları:
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/periods/{id}/summary` | gelir/gider/birikim/devir → harcanabilir, harcanan, kalan |
|
||||
//! | GET | `/api/periods/{id}/budget` | 4 grup: aylık + haftalık döküm, her birinde harcanan |
|
||||
//! | PUT | `/api/periods/{id}/budget` | 4 grubun aylık tutarları |
|
||||
//! | PUT | `/api/periods/{id}/budget/weeks` | bir haftayı elle ayarla / otomatiğe döndür |
|
||||
//! | GET | `/api/periods/{id}/budget/suggestion?method=…` | `default` · `previous` · `avg3` |
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
extract::ValidJson,
|
||||
models::group::Group,
|
||||
services::budget::{self, GroupBudget, SuggestMethod},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// Gruplara göre tutarlar (kuruş)
|
||||
#[derive(Serialize, Deserialize, Validate, TS, Clone, Copy)]
|
||||
#[ts(export)]
|
||||
pub struct GroupAmounts {
|
||||
#[ts(type = "number")]
|
||||
needs: i64,
|
||||
#[ts(type = "number")]
|
||||
wants: i64,
|
||||
#[ts(type = "number")]
|
||||
culture: i64,
|
||||
#[ts(type = "number")]
|
||||
unexpected: i64,
|
||||
}
|
||||
|
||||
impl From<budget::GroupAmounts> for GroupAmounts {
|
||||
fn from(a: budget::GroupAmounts) -> Self {
|
||||
Self {
|
||||
needs: a[0],
|
||||
wants: a[1],
|
||||
culture: a[2],
|
||||
unexpected: a[3],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GroupAmounts> for budget::GroupAmounts {
|
||||
fn from(a: GroupAmounts) -> Self {
|
||||
[a.needs, a.wants, a.culture, a.unexpected]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "PeriodSummary")]
|
||||
pub struct SummaryDto {
|
||||
#[ts(type = "number")]
|
||||
income_total: i64,
|
||||
#[ts(type = "number")]
|
||||
fixed_expense_total: i64,
|
||||
#[ts(type = "number")]
|
||||
savings_goal: i64,
|
||||
/// Önceki dönemden devreden borç
|
||||
#[ts(type = "number")]
|
||||
carryover_debt: i64,
|
||||
/// Önceki dönemden devreden artı bakiye
|
||||
#[ts(type = "number")]
|
||||
carryover_surplus: i64,
|
||||
/// gelir − sabit gider − birikim − borç + artı
|
||||
#[ts(type = "number")]
|
||||
spendable: i64,
|
||||
/// 4 grubun aylık bütçe toplamı
|
||||
#[ts(type = "number")]
|
||||
budget_total: i64,
|
||||
/// harcanabilir − dağıtılan (negatifse fazla dağıtılmış)
|
||||
#[ts(type = "number")]
|
||||
unallocated: i64,
|
||||
/// Dönemde gerçekleşen harcama
|
||||
#[ts(type = "number")]
|
||||
spent_total: i64,
|
||||
/// Kalan harcanabilir: harcanabilir − gerçekleşen (negatifse aşılmış)
|
||||
#[ts(type = "number")]
|
||||
remaining: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "GroupBudget")]
|
||||
pub struct GroupBudgetDto {
|
||||
group: Group,
|
||||
#[ts(type = "number")]
|
||||
monthly: i64,
|
||||
/// Dönem boyunca gerçekleşen harcama
|
||||
#[ts(type = "number")]
|
||||
spent: i64,
|
||||
/// Elle girilen haftaların toplamı aylık tutarı aşıyor
|
||||
weekly_over: bool,
|
||||
weeks: Vec<WeekBudgetDto>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "WeekBudget")]
|
||||
struct WeekBudgetDto {
|
||||
index: u32,
|
||||
start_date: String,
|
||||
last_date: String,
|
||||
days: u32,
|
||||
#[ts(type = "number")]
|
||||
amount: i64,
|
||||
/// Elle girildi (otomatik değil)
|
||||
is_manual: bool,
|
||||
/// Bu haftada gerçekleşen harcama
|
||||
#[ts(type = "number")]
|
||||
spent: i64,
|
||||
}
|
||||
|
||||
impl From<GroupBudget> for GroupBudgetDto {
|
||||
fn from(g: GroupBudget) -> Self {
|
||||
Self {
|
||||
group: g.group,
|
||||
monthly: g.monthly,
|
||||
spent: g.spent,
|
||||
weekly_over: g.weekly_over,
|
||||
weeks: g
|
||||
.weeks
|
||||
.into_iter()
|
||||
.map(|w| WeekBudgetDto {
|
||||
index: w.index,
|
||||
start_date: w.start_date,
|
||||
last_date: w.last_date,
|
||||
days: w.days,
|
||||
amount: w.amount,
|
||||
is_manual: w.is_manual,
|
||||
spent: w.spent,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "BudgetWeekRequest")]
|
||||
pub struct WeekRequest {
|
||||
group: Group,
|
||||
/// Haftanın ilk günü (`PeriodWeek.start_date`)
|
||||
week_start: String,
|
||||
/// Kuruş; `null` → otomatiğe döndür
|
||||
#[ts(type = "number | null")]
|
||||
amount: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Copy, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, rename = "SuggestMethod")]
|
||||
pub enum Method {
|
||||
Default,
|
||||
Previous,
|
||||
Avg3,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SuggestQuery {
|
||||
method: Option<Method>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "BudgetSuggestion")]
|
||||
pub struct SuggestionDto {
|
||||
/// `false` → bu yöntem için veri yok (`message` nedenini söyler)
|
||||
available: bool,
|
||||
message: Option<String>,
|
||||
amounts: GroupAmounts,
|
||||
}
|
||||
|
||||
fn parse_id(id: &str) -> AppResult<Uuid> {
|
||||
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn summary(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<SummaryDto>> {
|
||||
let x = budget::summary(&s.db, auth.household_id, parse_id(&id)?).await?;
|
||||
Ok(Json(SummaryDto {
|
||||
income_total: x.income_total,
|
||||
fixed_expense_total: x.fixed_expense_total,
|
||||
savings_goal: x.savings_goal,
|
||||
carryover_debt: x.carryover_debt,
|
||||
carryover_surplus: x.carryover_surplus,
|
||||
spendable: x.spendable,
|
||||
budget_total: x.budget_total,
|
||||
unallocated: x.spendable - x.budget_total,
|
||||
spent_total: x.spent_total,
|
||||
remaining: x.spendable - x.spent_total,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
|
||||
let groups = budget::view(&s.db, auth.household_id, parse_id(&id)?).await?;
|
||||
Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn set_monthly(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<GroupAmounts>,
|
||||
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
|
||||
let pid = parse_id(&id)?;
|
||||
budget::set_monthly(&s.db, auth.household_id, pid, req.into()).await?;
|
||||
let groups = budget::view(&s.db, auth.household_id, pid).await?;
|
||||
Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn set_week(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<WeekRequest>,
|
||||
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
|
||||
let pid = parse_id(&id)?;
|
||||
budget::set_week(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
pid,
|
||||
req.group,
|
||||
&req.week_start,
|
||||
req.amount,
|
||||
)
|
||||
.await?;
|
||||
let groups = budget::view(&s.db, auth.household_id, pid).await?;
|
||||
Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn suggestion(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Query(q): Query<SuggestQuery>,
|
||||
) -> AppResult<Json<SuggestionDto>> {
|
||||
let method = match q.method.unwrap_or(Method::Default) {
|
||||
Method::Default => SuggestMethod::Default,
|
||||
Method::Previous => SuggestMethod::Previous,
|
||||
Method::Avg3 => SuggestMethod::Avg3,
|
||||
};
|
||||
let x = budget::suggest(&s.db, auth.household_id, parse_id(&id)?, method).await?;
|
||||
Ok(Json(SuggestionDto {
|
||||
available: x.available,
|
||||
message: x.message,
|
||||
amounts: x.amounts.into(),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Günlük defter uç noktaları (hanenin tüm üyeleri):
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/periods/{id}/entries?from=&to=` | dönemin kayıtları (tarih aralığı isteğe bağlı, ikisi de dahil) |
|
||||
//! | POST | `/api/periods/{id}/entries` | harcama ekle |
|
||||
//! | PATCH | `/api/entries/{id}` | düzenle |
|
||||
//! | DELETE | `/api/entries/{id}` | sil (soft) |
|
||||
//! | POST | `/api/entries/{id}/restore` | silmeyi geri al |
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
extract::ValidJson,
|
||||
models::{entry::Entry, group::Group},
|
||||
services::entry::{self, EntryChanges, EntryFilter, NewEntry},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "EntryCreateRequest", optional_fields)]
|
||||
pub struct CreateRequest {
|
||||
/// `"YYYY-MM-DD"`, dönem içinde
|
||||
date: String,
|
||||
/// Kuruş, > 0
|
||||
#[ts(type = "number")]
|
||||
amount: i64,
|
||||
/// Grup kategoriden alınır
|
||||
category_id: String,
|
||||
payment_method_id: Option<String>,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "EntryUpdateRequest", optional_fields)]
|
||||
pub struct UpdateRequest {
|
||||
date: Option<String>,
|
||||
#[ts(type = "number", optional)]
|
||||
amount: Option<i64>,
|
||||
category_id: Option<String>,
|
||||
/// Boş metin → ödeme türünü kaldır
|
||||
payment_method_id: Option<String>,
|
||||
/// Boş metin → açıklamayı kaldır
|
||||
description: Option<String>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListQuery {
|
||||
from: Option<String>,
|
||||
to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Entry")]
|
||||
pub struct EntryDto {
|
||||
id: String,
|
||||
period_id: String,
|
||||
date: String,
|
||||
/// Kuruş
|
||||
#[ts(type = "number")]
|
||||
amount: i64,
|
||||
group: Group,
|
||||
category_id: String,
|
||||
payment_method_id: Option<String>,
|
||||
description: Option<String>,
|
||||
created_by: String,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
impl From<Entry> for EntryDto {
|
||||
fn from(e: Entry) -> Self {
|
||||
Self {
|
||||
id: e.id.to_string(),
|
||||
period_id: e.period_id.to_string(),
|
||||
date: e.date,
|
||||
amount: e.amount,
|
||||
group: e.group,
|
||||
category_id: e.category_id.to_string(),
|
||||
payment_method_id: e.payment_method_id.map(|u| u.to_string()),
|
||||
description: e.description,
|
||||
created_by: e.created_by.to_string(),
|
||||
version: e.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adresteki kimlik → geçersizse 404
|
||||
fn path_id(id: &str) -> AppResult<Uuid> {
|
||||
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Gövdedeki kimlik → geçersizse 400 (ör. kategori seçilmemiş)
|
||||
fn body_id(id: &str, what: &str) -> AppResult<Uuid> {
|
||||
Uuid::parse_str(id.trim()).map_err(|_| AppError::BadRequest(format!("{what} geçersiz")))
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(period_id): Path<String>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> AppResult<Json<Vec<EntryDto>>> {
|
||||
let items = entry::list(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
path_id(&period_id)?,
|
||||
EntryFilter {
|
||||
from: q.from,
|
||||
to: q.to,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(items.into_iter().map(EntryDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(period_id): Path<String>,
|
||||
ValidJson(req): ValidJson<CreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<EntryDto>)> {
|
||||
let payment_method_id = match req.payment_method_id.as_deref().map(str::trim) {
|
||||
None | Some("") => None,
|
||||
Some(id) => Some(body_id(id, "Ödeme türü")?),
|
||||
};
|
||||
let e = entry::create(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
auth.user_id,
|
||||
path_id(&period_id)?,
|
||||
NewEntry {
|
||||
date: req.date,
|
||||
amount: req.amount,
|
||||
category_id: body_id(&req.category_id, "Kategori")?,
|
||||
payment_method_id,
|
||||
description: req.description,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(e.into())))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<UpdateRequest>,
|
||||
) -> AppResult<Json<EntryDto>> {
|
||||
let payment_method_id = match req.payment_method_id.as_deref().map(str::trim) {
|
||||
None => None,
|
||||
Some("") => Some(None),
|
||||
Some(pm) => Some(Some(body_id(pm, "Ödeme türü")?)),
|
||||
};
|
||||
let category_id = req
|
||||
.category_id
|
||||
.as_deref()
|
||||
.map(|c| body_id(c, "Kategori"))
|
||||
.transpose()?;
|
||||
let e = entry::update(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
path_id(&id)?,
|
||||
EntryChanges {
|
||||
date: req.date,
|
||||
amount: req.amount,
|
||||
category_id,
|
||||
payment_method_id,
|
||||
description: req.description,
|
||||
version: req.version,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(e.into()))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<StatusCode> {
|
||||
entry::delete(&s.db, auth.household_id, path_id(&id)?).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn restore(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<EntryDto>> {
|
||||
let e = entry::restore(&s.db, auth.household_id, path_id(&id)?).await?;
|
||||
Ok(Json(e.into()))
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
|
||||
|
||||
mod asset;
|
||||
mod auth;
|
||||
mod budget;
|
||||
mod catalog;
|
||||
mod entry;
|
||||
mod health;
|
||||
mod household;
|
||||
mod note;
|
||||
mod period;
|
||||
mod plan;
|
||||
mod rates;
|
||||
mod reports;
|
||||
mod review;
|
||||
mod spa;
|
||||
mod stats;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -59,6 +69,54 @@ pub fn router(state: AppState) -> Router {
|
||||
"/payment-methods/{id}",
|
||||
patch(catalog::update_payment_method),
|
||||
)
|
||||
.route("/periods", get(period::list).post(period::create))
|
||||
.route("/periods/current", get(period::current))
|
||||
.route("/periods/{id}", get(period::get).patch(period::update))
|
||||
.route("/periods/{id}/close", post(period::close))
|
||||
.route("/periods/{id}/reopen", post(period::reopen))
|
||||
.route(
|
||||
"/periods/{id}/plan-items",
|
||||
get(plan::list).post(plan::create),
|
||||
)
|
||||
.route(
|
||||
"/periods/{id}/plan-items/copy-previous",
|
||||
post(plan::copy_previous),
|
||||
)
|
||||
.route("/plan-items/{id}", patch(plan::update).delete(plan::delete))
|
||||
.route("/periods/{id}/notes", get(note::list).post(note::create))
|
||||
.route("/periods/{id}/summary", get(budget::summary))
|
||||
.route("/periods/{id}/review", get(review::get))
|
||||
.route("/periods/{id}/daily", get(stats::daily))
|
||||
.route("/stats/periods", get(stats::periods))
|
||||
.route("/reports/breakdown", get(reports::breakdown))
|
||||
.route(
|
||||
"/periods/{id}/budget",
|
||||
get(budget::get).put(budget::set_monthly),
|
||||
)
|
||||
.route("/periods/{id}/budget/weeks", put(budget::set_week))
|
||||
.route("/periods/{id}/budget/suggestion", get(budget::suggestion))
|
||||
.route("/notes/{id}", patch(note::update).delete(note::delete))
|
||||
.route(
|
||||
"/periods/{id}/entries",
|
||||
get(entry::list).post(entry::create),
|
||||
)
|
||||
.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("/savings/pending", get(asset::pending))
|
||||
.route("/periods/{id}/to-savings", post(asset::add_period_savings))
|
||||
.route("/rates", post(rates::create))
|
||||
.route("/rates/{id}", delete(rates::delete))
|
||||
.route("/assets/{id}", patch(asset::update))
|
||||
.route(
|
||||
"/assets/{id}/transactions",
|
||||
get(asset::list_txs).post(asset::create_tx),
|
||||
)
|
||||
.route(
|
||||
"/asset-transactions/{id}",
|
||||
patch(asset::update_tx).delete(asset::delete_tx),
|
||||
)
|
||||
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
|
||||
.fallback(|| async { AppError::NotFound });
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Dönem notları:
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/periods/{id}/notes?context=planning` | notlar (sayfa filtresi isteğe bağlı) |
|
||||
//! | POST | `/api/periods/{id}/notes` | not ekle |
|
||||
//! | PATCH | `/api/notes/{id}` | düzenle |
|
||||
//! | DELETE | `/api/notes/{id}` | sil |
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
extract::ValidJson,
|
||||
models::note::{Note, NoteContext},
|
||||
services::note,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListQuery {
|
||||
context: Option<NoteContext>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "NoteCreateRequest")]
|
||||
pub struct CreateRequest {
|
||||
context: NoteContext,
|
||||
title: String,
|
||||
/// Markdown
|
||||
#[serde(default)]
|
||||
body_md: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "NoteUpdateRequest", optional_fields)]
|
||||
pub struct UpdateRequest {
|
||||
title: Option<String>,
|
||||
body_md: Option<String>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Note")]
|
||||
pub struct NoteDto {
|
||||
id: String,
|
||||
context: NoteContext,
|
||||
title: String,
|
||||
body_md: String,
|
||||
created_by: String,
|
||||
/// ISO 8601
|
||||
updated_at: String,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
impl From<Note> for NoteDto {
|
||||
fn from(n: Note) -> Self {
|
||||
Self {
|
||||
id: n.id.to_string(),
|
||||
context: n.context,
|
||||
title: n.title,
|
||||
body_md: n.body_md,
|
||||
created_by: n.created_by.to_string(),
|
||||
updated_at: n.updated_at.try_to_rfc3339_string().unwrap_or_default(),
|
||||
version: n.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_id(id: &str) -> AppResult<Uuid> {
|
||||
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(period_id): Path<String>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> AppResult<Json<Vec<NoteDto>>> {
|
||||
let notes = note::list(&s.db, auth.household_id, parse_id(&period_id)?, q.context).await?;
|
||||
Ok(Json(notes.into_iter().map(NoteDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(period_id): Path<String>,
|
||||
ValidJson(req): ValidJson<CreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<NoteDto>)> {
|
||||
let n = note::create(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
auth.user_id,
|
||||
parse_id(&period_id)?,
|
||||
req.context,
|
||||
&req.title,
|
||||
&req.body_md,
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(n.into())))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<UpdateRequest>,
|
||||
) -> AppResult<Json<NoteDto>> {
|
||||
let n = note::update(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
parse_id(&id)?,
|
||||
req.title.as_deref(),
|
||||
req.body_md.as_deref(),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(n.into()))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<StatusCode> {
|
||||
note::delete(&s.db, auth.household_id, parse_id(&id)?).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Dönem uç noktaları (hanenin tüm üyeleri):
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/periods` | tüm dönemler (en yeni başta) |
|
||||
//! | GET | `/api/periods/current` | açık dönem ya da `null` |
|
||||
//! | POST | `/api/periods` | dönem aç |
|
||||
//! | GET | `/api/periods/{id}` | dönem + haftalar |
|
||||
//! | PATCH | `/api/periods/{id}` | ad / tarihler / birikim hedefi / "Nasıl daha iyi…" |
|
||||
//! | POST | `/api/periods/{id}/close` | dönemi kapat: devir + birikime aktarılan (transaction) |
|
||||
//! | POST | `/api/periods/{id}/reopen` | **sahip:** kapalı dönemi düzeltmeye aç (`correcting`) |
|
||||
//! | POST | `/api/periods/{id}/to-savings` | kapanıştaki birikimi TL varlığına ekle (bkz. `routes/asset.rs`) |
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
extract::ValidJson,
|
||||
models::period::{Period, PeriodStatus},
|
||||
services::{
|
||||
calendar::{days_between, format_date, parse_date, weeks},
|
||||
closing,
|
||||
period::{self, NewPeriod, PeriodChanges},
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
// ---------- İstek / yanıt ----------
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "PeriodCreateRequest", optional_fields)]
|
||||
pub struct CreateRequest {
|
||||
/// `"YYYY-MM-DD"`, dahil
|
||||
start_date: String,
|
||||
/// `"YYYY-MM-DD"`, hariç (ör. Aralık için `2027-01-01`)
|
||||
end_date: String,
|
||||
/// Boşsa otomatik ("Aralık 2026")
|
||||
name: Option<String>,
|
||||
/// Kuruş
|
||||
#[ts(type = "number", optional)]
|
||||
savings_goal: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "PeriodUpdateRequest", optional_fields)]
|
||||
pub struct UpdateRequest {
|
||||
name: Option<String>,
|
||||
start_date: Option<String>,
|
||||
end_date: Option<String>,
|
||||
#[ts(type = "number", optional)]
|
||||
savings_goal: Option<i64>,
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır
|
||||
reflection_md: Option<String>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Period")]
|
||||
pub struct PeriodDto {
|
||||
id: String,
|
||||
name: String,
|
||||
/// Dahil
|
||||
start_date: String,
|
||||
/// Hariç (veritabanındaki değer)
|
||||
end_date: String,
|
||||
/// Son gün, dahil (gösterim için)
|
||||
last_date: String,
|
||||
days: u32,
|
||||
status: PeriodStatus,
|
||||
/// Kuruş
|
||||
#[ts(type = "number")]
|
||||
savings_goal: i64,
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown)
|
||||
reflection_md: Option<String>,
|
||||
/// Kapanışta birikime aktarılan (kuruş); kapanmamışsa `null`
|
||||
#[ts(type = "number | null")]
|
||||
to_savings: Option<i64>,
|
||||
/// Kapanış zamanı (ISO 8601)
|
||||
closed_at: Option<String>,
|
||||
version: i32,
|
||||
weeks: Vec<WeekDto>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "PeriodWeek")]
|
||||
struct WeekDto {
|
||||
/// 1'den başlar
|
||||
index: u32,
|
||||
start_date: String,
|
||||
/// Dahil
|
||||
last_date: String,
|
||||
days: u32,
|
||||
}
|
||||
|
||||
impl From<Period> for PeriodDto {
|
||||
fn from(p: Period) -> Self {
|
||||
// Veritabanındaki tarihler her zaman doğrulanmış biçimde yazılır
|
||||
let start = parse_date(&p.start_date).expect("geçerli başlangıç tarihi");
|
||||
let end = parse_date(&p.end_date).expect("geçerli bitiş tarihi");
|
||||
Self {
|
||||
id: p.id.to_string(),
|
||||
name: p.name,
|
||||
last_date: format_date(end - time::Duration::days(1)),
|
||||
days: days_between(start, end) as u32,
|
||||
start_date: p.start_date,
|
||||
end_date: p.end_date,
|
||||
status: p.status,
|
||||
savings_goal: p.savings_goal,
|
||||
reflection_md: p.reflection_md,
|
||||
to_savings: p.to_savings,
|
||||
closed_at: p.closed_at.and_then(|d| d.try_to_rfc3339_string().ok()),
|
||||
version: p.version,
|
||||
weeks: weeks(start, end)
|
||||
.into_iter()
|
||||
.map(|w| WeekDto {
|
||||
index: w.index,
|
||||
start_date: format_date(w.start),
|
||||
last_date: format_date(w.last),
|
||||
days: w.days,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_id(id: &str) -> AppResult<Uuid> {
|
||||
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||
}
|
||||
|
||||
// ---------- Uç noktalar ----------
|
||||
|
||||
pub async fn list(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<Vec<PeriodDto>>> {
|
||||
let items = period::list(&s.db, auth.household_id).await?;
|
||||
Ok(Json(items.into_iter().map(PeriodDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn current(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> AppResult<Json<Option<PeriodDto>>> {
|
||||
let p = period::current(&s.db, auth.household_id).await?;
|
||||
Ok(Json(p.map(PeriodDto::from)))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<CreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<PeriodDto>)> {
|
||||
let p = period::create(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
NewPeriod {
|
||||
start_date: req.start_date,
|
||||
end_date: req.end_date,
|
||||
name: req.name,
|
||||
savings_goal: req.savings_goal,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(p.into())))
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<PeriodDto>> {
|
||||
let p = period::get(&s.db, auth.household_id, parse_id(&id)?).await?;
|
||||
Ok(Json(p.into()))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<UpdateRequest>,
|
||||
) -> AppResult<Json<PeriodDto>> {
|
||||
let p = period::update(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
parse_id(&id)?,
|
||||
PeriodChanges {
|
||||
name: req.name,
|
||||
start_date: req.start_date,
|
||||
end_date: req.end_date,
|
||||
savings_goal: req.savings_goal,
|
||||
reflection_md: req.reflection_md,
|
||||
version: req.version,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(p.into()))
|
||||
}
|
||||
|
||||
// ---------- Kapatma / yeniden açma ----------
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "PeriodCloseRequest", optional_fields)]
|
||||
pub struct CloseRequest {
|
||||
/// Birikime aktarılacak (kuruş); verilmezse gerçekleşen birikimin tamamı. Kalanı artı olarak devreder.
|
||||
#[ts(type = "number", optional)]
|
||||
to_savings: Option<i64>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "PeriodCloseResult")]
|
||||
pub struct CloseResultDto {
|
||||
period: PeriodDto,
|
||||
/// Değerlendirmenin 4. sorusu (negatifse açık)
|
||||
#[ts(type = "number")]
|
||||
realized_savings: i64,
|
||||
#[ts(type = "number")]
|
||||
to_savings: i64,
|
||||
/// Sonraki döneme devreden; yoksa `null`
|
||||
carryover: Option<CarryoverDto>,
|
||||
/// Birikimlerde bu döneme bağlı kayıt var (yeniden kapatmada yeni tutara güncellendi).
|
||||
/// `false` ve `to_savings > 0` → arayüz "Birikime ekle" önerir.
|
||||
savings_linked: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Carryover")]
|
||||
struct CarryoverDto {
|
||||
/// `debt` (borç) · `surplus` (artı)
|
||||
#[ts(type = "\"debt\" | \"surplus\"")]
|
||||
kind: &'static str,
|
||||
#[ts(type = "number")]
|
||||
amount: i64,
|
||||
/// Sonraki dönem henüz açılmadıysa `null` (açılınca bağlanır)
|
||||
to_period_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn close(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<CloseRequest>,
|
||||
) -> AppResult<Json<CloseResultDto>> {
|
||||
let c = closing::close(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
auth.user_id,
|
||||
parse_id(&id)?,
|
||||
req.to_savings,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(CloseResultDto {
|
||||
period: c.period.into(),
|
||||
realized_savings: c.realized_savings,
|
||||
to_savings: c.to_savings,
|
||||
savings_linked: c.savings_linked,
|
||||
carryover: c.carryover.map(|x| CarryoverDto {
|
||||
kind: x.kind.as_str(),
|
||||
amount: x.amount,
|
||||
to_period_id: x.to_period_id.map(|u| u.to_string()),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn reopen(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<PeriodDto>> {
|
||||
auth.require_owner()?;
|
||||
let p = closing::reopen(&s.db, auth.household_id, auth.user_id, parse_id(&id)?).await?;
|
||||
Ok(Json(p.into()))
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Sabit gelir / gider kalemleri:
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/periods/{id}/plan-items` | dönemin kalemleri |
|
||||
//! | POST | `/api/periods/{id}/plan-items` | kalem ekle |
|
||||
//! | POST | `/api/periods/{id}/plan-items/copy-previous` | önceki dönemden kopyala |
|
||||
//! | PATCH | `/api/plan-items/{id}` | düzenle |
|
||||
//! | DELETE | `/api/plan-items/{id}` | sil |
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
extract::ValidJson,
|
||||
models::plan_item::{PlanItem, PlanKind},
|
||||
services::plan::{self, ItemChanges, NewItem},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "PlanItemCreateRequest", optional_fields)]
|
||||
pub struct CreateRequest {
|
||||
kind: PlanKind,
|
||||
name: String,
|
||||
/// Kuruş, > 0
|
||||
#[ts(type = "number")]
|
||||
amount: i64,
|
||||
/// `"YYYY-MM-DD"`, dönem içinde (isteğe bağlı)
|
||||
date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "PlanItemUpdateRequest", optional_fields)]
|
||||
pub struct UpdateRequest {
|
||||
name: Option<String>,
|
||||
#[ts(type = "number", optional)]
|
||||
amount: Option<i64>,
|
||||
/// Boş metin → tarihi kaldır
|
||||
date: Option<String>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "PlanItem")]
|
||||
pub struct PlanItemDto {
|
||||
id: String,
|
||||
kind: PlanKind,
|
||||
name: String,
|
||||
/// Kuruş
|
||||
#[ts(type = "number")]
|
||||
amount: i64,
|
||||
date: Option<String>,
|
||||
sort_order: i32,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct CopyPreviousResult {
|
||||
/// Eklenen kalem sayısı (zaten var olanlar atlanır)
|
||||
added: usize,
|
||||
}
|
||||
|
||||
impl From<PlanItem> for PlanItemDto {
|
||||
fn from(i: PlanItem) -> Self {
|
||||
Self {
|
||||
id: i.id.to_string(),
|
||||
kind: i.kind,
|
||||
name: i.name,
|
||||
amount: i.amount,
|
||||
date: i.date,
|
||||
sort_order: i.sort_order,
|
||||
version: i.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_id(id: &str) -> AppResult<Uuid> {
|
||||
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(period_id): Path<String>,
|
||||
) -> AppResult<Json<Vec<PlanItemDto>>> {
|
||||
let items = plan::list(&s.db, auth.household_id, parse_id(&period_id)?).await?;
|
||||
Ok(Json(items.into_iter().map(PlanItemDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(period_id): Path<String>,
|
||||
ValidJson(req): ValidJson<CreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<PlanItemDto>)> {
|
||||
let item = plan::create(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
parse_id(&period_id)?,
|
||||
NewItem {
|
||||
kind: req.kind,
|
||||
name: req.name,
|
||||
amount: req.amount,
|
||||
date: req.date,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(item.into())))
|
||||
}
|
||||
|
||||
pub async fn copy_previous(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(period_id): Path<String>,
|
||||
) -> AppResult<Json<CopyPreviousResult>> {
|
||||
let added = plan::copy_previous(&s.db, auth.household_id, parse_id(&period_id)?).await?;
|
||||
Ok(Json(CopyPreviousResult { added }))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<UpdateRequest>,
|
||||
) -> AppResult<Json<PlanItemDto>> {
|
||||
let item = plan::update(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
parse_id(&id)?,
|
||||
ItemChanges {
|
||||
name: req.name,
|
||||
amount: req.amount,
|
||||
date: req.date,
|
||||
version: req.version,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(item.into()))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<StatusCode> {
|
||||
plan::delete(&s.db, auth.household_id, parse_id(&id)?).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Rapor uç noktaları (hanenin tüm üyeleri):
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/reports/breakdown?period_id=&from=&to=&group=&category_id=&payment_method_id=` | harcama: toplam, grup, tüm kategoriler, ödeme türü, ay |
|
||||
//!
|
||||
//! `payment_method_id=none` → ödeme türü girilmemiş kayıtlar. Dönemler arası özet: `/api/stats/periods`.
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
models::group::Group,
|
||||
services::reports::{self, Filter},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BreakdownQuery {
|
||||
period_id: Option<String>,
|
||||
from: Option<String>,
|
||||
to: Option<String>,
|
||||
group: Option<String>,
|
||||
category_id: Option<String>,
|
||||
payment_method_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReportBreakdown")]
|
||||
pub struct BreakdownDto {
|
||||
/// Kuruş
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
#[ts(type = "number")]
|
||||
count: i64,
|
||||
/// 4 grup, sabit sırada
|
||||
groups: Vec<GroupRow>,
|
||||
/// Harcama olan **tüm** kategoriler, çoktan aza
|
||||
categories: Vec<CategoryRow>,
|
||||
/// Ödeme türleri; `null` = girilmemiş
|
||||
payment_methods: Vec<MethodRow>,
|
||||
/// Aylar (`"YYYY-MM"`), eskiden yeniye
|
||||
months: Vec<MonthRow>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReportGroupRow")]
|
||||
struct GroupRow {
|
||||
group: Group,
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
#[ts(type = "number")]
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReportCategoryRow")]
|
||||
struct CategoryRow {
|
||||
category_id: String,
|
||||
group: Group,
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
#[ts(type = "number")]
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReportMethodRow")]
|
||||
struct MethodRow {
|
||||
payment_method_id: Option<String>,
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
#[ts(type = "number")]
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReportMonthRow")]
|
||||
struct MonthRow {
|
||||
/// `"YYYY-MM"`
|
||||
month: String,
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
#[ts(type = "number")]
|
||||
count: i64,
|
||||
}
|
||||
|
||||
fn opt_id(v: Option<&str>, what: &str) -> AppResult<Option<Uuid>> {
|
||||
match v.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => Ok(None),
|
||||
Some(s) => Uuid::parse_str(s)
|
||||
.map(Some)
|
||||
.map_err(|_| AppError::BadRequest(format!("{what} geçersiz"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn breakdown(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<BreakdownQuery>,
|
||||
) -> AppResult<Json<BreakdownDto>> {
|
||||
let group = match q.group.as_deref().map(str::trim).filter(|g| !g.is_empty()) {
|
||||
None => None,
|
||||
Some(g) => Some(
|
||||
Group::ALL
|
||||
.into_iter()
|
||||
.find(|x| x.as_str() == g)
|
||||
.ok_or_else(|| AppError::BadRequest("Grup geçersiz".into()))?,
|
||||
),
|
||||
};
|
||||
let payment_method = match q.payment_method_id.as_deref().map(str::trim) {
|
||||
None | Some("") => None,
|
||||
Some("none") => Some(None),
|
||||
Some(id) => Some(opt_id(Some(id), "Ödeme türü")?),
|
||||
};
|
||||
let filter = Filter {
|
||||
period_id: opt_id(q.period_id.as_deref(), "Dönem")?,
|
||||
from: q.from.filter(|s| !s.trim().is_empty()),
|
||||
to: q.to.filter(|s| !s.trim().is_empty()),
|
||||
group,
|
||||
category_id: opt_id(q.category_id.as_deref(), "Kategori")?,
|
||||
payment_method,
|
||||
};
|
||||
let b = reports::breakdown(&s.db, auth.household_id, filter).await?;
|
||||
Ok(Json(BreakdownDto {
|
||||
total: b.total,
|
||||
count: b.count,
|
||||
groups: b
|
||||
.groups
|
||||
.into_iter()
|
||||
.map(|r| GroupRow {
|
||||
group: r.key,
|
||||
total: r.total,
|
||||
count: r.count,
|
||||
})
|
||||
.collect(),
|
||||
categories: b
|
||||
.categories
|
||||
.into_iter()
|
||||
.map(|r| CategoryRow {
|
||||
category_id: r.key.category_id.to_string(),
|
||||
group: r.key.group,
|
||||
total: r.total,
|
||||
count: r.count,
|
||||
})
|
||||
.collect(),
|
||||
payment_methods: b
|
||||
.methods
|
||||
.into_iter()
|
||||
.map(|r| MethodRow {
|
||||
payment_method_id: r.key.map(|u| u.to_string()),
|
||||
total: r.total,
|
||||
count: r.count,
|
||||
})
|
||||
.collect(),
|
||||
months: b
|
||||
.months
|
||||
.into_iter()
|
||||
.map(|r| MonthRow {
|
||||
month: r.key,
|
||||
total: r.total,
|
||||
count: r.count,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Ay sonu değerlendirmesi:
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/periods/{id}/review` | 4 soru, grup/kategori/hafta bazında planlanan–gerçekleşen |
|
||||
//!
|
||||
//! "Nasıl daha iyi yapabilirim?" metni: `PATCH /api/periods/{id}` → `reflection_md`.
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
models::group::Group,
|
||||
services::review,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "PeriodReview")]
|
||||
pub struct ReviewDto {
|
||||
#[ts(type = "number")]
|
||||
income_total: i64,
|
||||
#[ts(type = "number")]
|
||||
fixed_expense_total: i64,
|
||||
#[ts(type = "number")]
|
||||
carryover_debt: i64,
|
||||
#[ts(type = "number")]
|
||||
carryover_surplus: i64,
|
||||
/// 1. Ne kadar param vardı? (gelir − sabit gider − borç + artı)
|
||||
#[ts(type = "number")]
|
||||
available: i64,
|
||||
/// 2. Ne kadar biriktirmek istedim?
|
||||
#[ts(type = "number")]
|
||||
savings_goal: i64,
|
||||
/// 3. Ne kadar harcadım?
|
||||
#[ts(type = "number")]
|
||||
spent_total: i64,
|
||||
/// 4. Ne kadar biriktirdim? (negatifse açık var)
|
||||
#[ts(type = "number")]
|
||||
realized_savings: i64,
|
||||
/// Gerçekleşen − hedef birikim (negatifse hedefin altında)
|
||||
#[ts(type = "number")]
|
||||
savings_gap: i64,
|
||||
/// 4 grup, sabit sırada
|
||||
groups: Vec<ReviewGroupDto>,
|
||||
/// Harcama olan kategoriler, çoktan aza
|
||||
categories: Vec<ReviewCategoryDto>,
|
||||
/// Haftalar: 4 grubun toplamı
|
||||
weeks: Vec<ReviewWeekDto>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReviewGroup")]
|
||||
struct ReviewGroupDto {
|
||||
group: Group,
|
||||
/// Aylık bütçe
|
||||
#[ts(type = "number")]
|
||||
planned: i64,
|
||||
#[ts(type = "number")]
|
||||
spent: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReviewCategory")]
|
||||
struct ReviewCategoryDto {
|
||||
category_id: String,
|
||||
group: Group,
|
||||
#[ts(type = "number")]
|
||||
spent: i64,
|
||||
/// Kayıt sayısı
|
||||
#[ts(type = "number")]
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReviewWeek")]
|
||||
struct ReviewWeekDto {
|
||||
index: u32,
|
||||
start_date: String,
|
||||
last_date: String,
|
||||
#[ts(type = "number")]
|
||||
planned: i64,
|
||||
#[ts(type = "number")]
|
||||
spent: i64,
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<ReviewDto>> {
|
||||
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
|
||||
let r = review::review(&s.db, auth.household_id, id).await?;
|
||||
|
||||
// Haftalar: her grubun aynı sıradaki haftası toplanır (hepsi aynı takvimden gelir)
|
||||
let weeks = r
|
||||
.groups
|
||||
.first()
|
||||
.map(|g| {
|
||||
g.weeks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, w)| ReviewWeekDto {
|
||||
index: w.index,
|
||||
start_date: w.start_date.clone(),
|
||||
last_date: w.last_date.clone(),
|
||||
planned: r.groups.iter().map(|g| g.weeks[i].amount).sum(),
|
||||
spent: r.groups.iter().map(|g| g.weeks[i].spent).sum(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(ReviewDto {
|
||||
income_total: r.summary.income_total,
|
||||
fixed_expense_total: r.summary.fixed_expense_total,
|
||||
carryover_debt: r.summary.carryover_debt,
|
||||
carryover_surplus: r.summary.carryover_surplus,
|
||||
available: r.available,
|
||||
savings_goal: r.summary.savings_goal,
|
||||
spent_total: r.summary.spent_total,
|
||||
realized_savings: r.realized_savings,
|
||||
savings_gap: r.realized_savings - r.summary.savings_goal,
|
||||
groups: r
|
||||
.groups
|
||||
.iter()
|
||||
.map(|g| ReviewGroupDto {
|
||||
group: g.group,
|
||||
planned: g.monthly,
|
||||
spent: g.spent,
|
||||
})
|
||||
.collect(),
|
||||
categories: r
|
||||
.categories
|
||||
.into_iter()
|
||||
.map(|c| ReviewCategoryDto {
|
||||
category_id: c.category_id.to_string(),
|
||||
group: c.group,
|
||||
spent: c.spent,
|
||||
count: c.count,
|
||||
})
|
||||
.collect(),
|
||||
weeks,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Panel (Dashboard) uç noktaları (hanenin tüm üyeleri):
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/periods/{id}/daily` | dönemin her günü: 4 grubun harcaması |
|
||||
//! | GET | `/api/stats/periods?limit=12` | son dönemler (eskiden yeniye): gelir, sabit gider, devirler, harcanabilir, harcanan, gerçekleşen birikim, birikime aktarılan + toplam birikim |
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
models::period::PeriodStatus,
|
||||
routes::budget::GroupAmounts,
|
||||
services::{
|
||||
calendar::{format_date, parse_date},
|
||||
stats,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "DaySpent")]
|
||||
pub struct DayDto {
|
||||
/// `"YYYY-MM-DD"`
|
||||
date: String,
|
||||
/// O gün gruplara göre harcanan (kuruş)
|
||||
spent: GroupAmounts,
|
||||
}
|
||||
|
||||
pub async fn daily(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Vec<DayDto>>> {
|
||||
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
|
||||
let days = stats::daily(&s.db, auth.household_id, id).await?;
|
||||
Ok(Json(
|
||||
days.into_iter()
|
||||
.map(|d| DayDto {
|
||||
date: d.date,
|
||||
spent: d.spent.into(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StatsQuery {
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "PeriodStats")]
|
||||
pub struct StatsDto {
|
||||
/// Kapanışlarda birikime aktarılanların toplamı (kuruş)
|
||||
#[ts(type = "number")]
|
||||
total_saved: i64,
|
||||
/// Eskiden yeniye
|
||||
periods: Vec<PeriodStatDto>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "PeriodStat")]
|
||||
struct PeriodStatDto {
|
||||
id: String,
|
||||
name: String,
|
||||
start_date: String,
|
||||
/// Son gün, dahil
|
||||
last_date: String,
|
||||
status: PeriodStatus,
|
||||
#[ts(type = "number")]
|
||||
income_total: i64,
|
||||
#[ts(type = "number")]
|
||||
fixed_expense_total: i64,
|
||||
#[ts(type = "number")]
|
||||
savings_goal: i64,
|
||||
#[ts(type = "number")]
|
||||
carryover_debt: i64,
|
||||
#[ts(type = "number")]
|
||||
carryover_surplus: i64,
|
||||
#[ts(type = "number")]
|
||||
spendable: i64,
|
||||
#[ts(type = "number")]
|
||||
spent: i64,
|
||||
/// Harcanan, gruplara göre
|
||||
spent_by_group: GroupAmounts,
|
||||
/// Gelir − sabit − borç + artı − harcanan (negatifse açık). Birikim oranı = bu ÷ gelir
|
||||
#[ts(type = "number")]
|
||||
realized_savings: i64,
|
||||
/// Kapanmamışsa `null`
|
||||
#[ts(type = "number | null")]
|
||||
to_savings: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn periods(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<StatsQuery>,
|
||||
) -> AppResult<Json<StatsDto>> {
|
||||
let st = stats::periods(&s.db, auth.household_id, q.limit.unwrap_or(12)).await?;
|
||||
Ok(Json(StatsDto {
|
||||
total_saved: st.total_saved,
|
||||
periods: st
|
||||
.periods
|
||||
.into_iter()
|
||||
.map(|x| {
|
||||
let end = parse_date(&x.period.end_date).expect("geçerli bitiş tarihi");
|
||||
PeriodStatDto {
|
||||
realized_savings: x.realized_savings(),
|
||||
id: x.period.id.to_string(),
|
||||
name: x.period.name,
|
||||
start_date: x.period.start_date,
|
||||
last_date: format_date(end - time::Duration::days(1)),
|
||||
status: x.period.status,
|
||||
income_total: x.summary.income_total,
|
||||
fixed_expense_total: x.summary.fixed_expense_total,
|
||||
savings_goal: x.summary.savings_goal,
|
||||
carryover_debt: x.summary.carryover_debt,
|
||||
carryover_surplus: x.summary.carryover_surplus,
|
||||
spendable: x.summary.spendable,
|
||||
spent: x.summary.spent_total,
|
||||
spent_by_group: x.summary.spent_by_group.into(),
|
||||
to_savings: x.period.to_savings,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
@@ -120,6 +120,10 @@ async fn create_in_tx(
|
||||
.insert_many(seed::payment_methods(household_id))
|
||||
.session(&mut *session)
|
||||
.await?;
|
||||
docs(col::ASSETS)
|
||||
.insert_many(seed::assets(household_id))
|
||||
.session(&mut *session)
|
||||
.await?;
|
||||
(household_id, Role::Owner)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
//! Birikimler / varlıklar ve alım-satım kayıtları (KAKEIBO_PROMPT §8.7, DATA_MODEL.md §3.12–3.13).
|
||||
//!
|
||||
//! Kurallar:
|
||||
//! - Varsayılan varlıklar hane açılınca gelir (TL, altınlar, USD, EUR); kullanıcı **döviz** ekleyebilir.
|
||||
//! - Miktar/fiyat ölçekli tam sayı (`_e6`), tutar kuruş. TL varlığında miktar = tutar, birim fiyat 1.
|
||||
//! - Eldeki miktar, maliyet ve kâr/zarar saklanmaz; işlemlerden hesaplanır (`holding`).
|
||||
//! - Ekleme, düzenleme ve silmede tüm işlemler **tarih sırasıyla** yeniden denetlenir:
|
||||
//! hiçbir tarihte eldekinden fazla satılmış olamaz (geriye tarihli kayıt / alım silme dahil).
|
||||
//! - Silme soft delete. Hanenin tüm üyeleri yazabilir.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
options::ReturnDocument,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
asset::{Asset, AssetKind, AssetTx, TxType},
|
||||
common::{new_meta, now, scope},
|
||||
},
|
||||
services::{
|
||||
calendar::parse_date,
|
||||
holding::{self, Holding, Move, Oversold},
|
||||
period::MAX_AMOUNT,
|
||||
},
|
||||
};
|
||||
|
||||
/// Not üst sınırı (karakter)
|
||||
pub const MAX_NOTE: usize = 200;
|
||||
/// Miktar ve birim fiyat üst sınırı (e6): 1 milyar birim / 1 milyar TL
|
||||
const MAX_E6: i64 = 1_000_000_000_000_000;
|
||||
/// TL varlığında birim fiyat (1 TL)
|
||||
const ONE_E6: i64 = 1_000_000;
|
||||
|
||||
pub struct NewTx {
|
||||
pub tx_type: TxType,
|
||||
pub date: String,
|
||||
/// TL varlığında yok sayılır (tutardan türetilir)
|
||||
pub quantity_e6: i64,
|
||||
/// Verilmezse tutar ÷ miktar
|
||||
pub unit_price_e6: Option<i64>,
|
||||
pub total: i64,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TxChanges {
|
||||
pub date: Option<String>,
|
||||
pub quantity_e6: Option<i64>,
|
||||
pub unit_price_e6: Option<i64>,
|
||||
pub total: Option<i64>,
|
||||
/// `Some("")` → notu kaldır
|
||||
pub note: Option<String>,
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
fn assets(db: &Database) -> mongodb::Collection<Asset> {
|
||||
db.collection(col::ASSETS)
|
||||
}
|
||||
|
||||
fn txs(db: &Database) -> mongodb::Collection<AssetTx> {
|
||||
db.collection(col::ASSET_TRANSACTIONS)
|
||||
}
|
||||
|
||||
// ---------- Varlıklar ----------
|
||||
|
||||
/// Hanenin varlıkları (sıralı) ve her birinin hesaplanmış durumu + işlem sayısı
|
||||
pub async fn list(db: &Database, household_id: Uuid) -> AppResult<Vec<(Asset, Holding, usize)>> {
|
||||
let mut cursor = assets(db)
|
||||
.find(scope(household_id))
|
||||
.sort(doc! { "sort_order": 1, "_id": 1 })
|
||||
.await?;
|
||||
let mut list = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
list.push(cursor.deserialize_current()?);
|
||||
}
|
||||
|
||||
let mut by_asset: HashMap<Uuid, Vec<AssetTx>> = HashMap::new();
|
||||
for t in all_txs(db, household_id, None).await? {
|
||||
by_asset.entry(t.asset_id).or_default().push(t);
|
||||
}
|
||||
list.into_iter()
|
||||
.map(|a: Asset| {
|
||||
let ts = by_asset.remove(&a.id).unwrap_or_default();
|
||||
let h = holding::holding(&moves(&ts))
|
||||
.map_err(|_| AppError::Internal(format!("varlık {} eksi bakiyede", a.id)))?;
|
||||
Ok((a, h, ts.len()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Asset> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
assets(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Kullanıcının eklediği döviz (ör. GBP). Kod hanede tekil.
|
||||
pub async fn create(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
code: &str,
|
||||
name: &str,
|
||||
unit_label: Option<&str>,
|
||||
) -> AppResult<Asset> {
|
||||
let code = code.trim().to_uppercase();
|
||||
if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Döviz kodu 3 harf olmalı (ör. GBP)".into(),
|
||||
));
|
||||
}
|
||||
let name = clean_name(name)?;
|
||||
let mut f = scope(household_id);
|
||||
f.insert("code", &code);
|
||||
if assets(db).find_one(f).await?.is_some() {
|
||||
return Err(AppError::Conflict("Bu varlık zaten var".into()));
|
||||
}
|
||||
let last = assets(db)
|
||||
.find_one(scope(household_id))
|
||||
.sort(doc! { "sort_order": -1 })
|
||||
.await?
|
||||
.map_or(0, |a| a.sort_order);
|
||||
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("kind", "fx");
|
||||
d.insert("code", &code);
|
||||
d.insert("name", name);
|
||||
d.insert(
|
||||
"unit_label",
|
||||
unit_label
|
||||
.map(str::trim)
|
||||
.filter(|u| !u.is_empty())
|
||||
.unwrap_or(&code),
|
||||
);
|
||||
d.insert("sort_order", last + 10);
|
||||
d.insert("is_active", true);
|
||||
db.collection::<Document>(col::ASSETS).insert_one(d).await?;
|
||||
assets(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("eklenen varlık okunamadı".into()))
|
||||
}
|
||||
|
||||
/// Ad ve aktiflik. Pasif varlık listede kalır (geçmiş işlemler) ama yeni işlem alınmaz.
|
||||
pub async fn update(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
name: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
version: Option<i32>,
|
||||
) -> AppResult<Asset> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
if version.is_some_and(|v| v != current.version) {
|
||||
return Err(stale());
|
||||
}
|
||||
let mut set = doc! { "updated_at": now() };
|
||||
if let Some(n) = name {
|
||||
set.insert("name", clean_name(&n)?);
|
||||
}
|
||||
if let Some(a) = is_active {
|
||||
set.insert("is_active", a);
|
||||
}
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
f.insert("version", current.version);
|
||||
assets(db)
|
||||
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?
|
||||
.ok_or_else(stale)
|
||||
}
|
||||
|
||||
// ---------- İşlemler ----------
|
||||
|
||||
/// Varlığın işlemleri, en yeni başta
|
||||
pub async fn list_txs(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
asset_id: Uuid,
|
||||
) -> AppResult<Vec<AssetTx>> {
|
||||
get(db, household_id, asset_id).await?;
|
||||
let mut list = all_txs(db, household_id, Some(asset_id)).await?;
|
||||
list.reverse();
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
pub async fn create_tx(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
asset_id: Uuid,
|
||||
t: NewTx,
|
||||
) -> AppResult<AssetTx> {
|
||||
let asset = get(db, household_id, asset_id).await?;
|
||||
if !asset.is_active {
|
||||
return Err(AppError::BadRequest(
|
||||
"Bu varlık pasif; önce etkinleştirin".into(),
|
||||
));
|
||||
}
|
||||
let date = clean_date(&t.date)?;
|
||||
let (quantity_e6, unit_price_e6, total) =
|
||||
clean_amounts(&asset, t.quantity_e6, t.unit_price_e6, t.total)?;
|
||||
let note = clean_note(t.note.as_deref())?;
|
||||
|
||||
// Yeni kayıt dahil sıralı denetim (yeni kayıt aynı günün sonuna eklenir)
|
||||
let list = all_txs(db, household_id, Some(asset_id)).await?;
|
||||
let pos = list.partition_point(|x| x.date <= date);
|
||||
let mut ms = moves(&list);
|
||||
ms.insert(
|
||||
pos,
|
||||
Move {
|
||||
tx_type: t.tx_type,
|
||||
quantity_e6,
|
||||
total,
|
||||
},
|
||||
);
|
||||
check(&ms)?;
|
||||
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("asset_id", asset_id);
|
||||
d.insert("type", t.tx_type.as_str());
|
||||
d.insert("date", date);
|
||||
d.insert("quantity_e6", quantity_e6);
|
||||
d.insert("unit_price_e6", unit_price_e6);
|
||||
d.insert("total", total);
|
||||
d.insert("note", note.map_or(Bson::Null, Bson::String));
|
||||
d.insert("source_period_id", Bson::Null);
|
||||
d.insert("created_by", user_id);
|
||||
db.collection::<Document>(col::ASSET_TRANSACTIONS)
|
||||
.insert_one(d)
|
||||
.await?;
|
||||
txs(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("eklenen işlem okunamadı".into()))
|
||||
}
|
||||
|
||||
pub async fn update_tx(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
ch: TxChanges,
|
||||
) -> AppResult<AssetTx> {
|
||||
let current = get_tx(db, household_id, id).await?;
|
||||
if ch.version.is_some_and(|v| v != current.version) {
|
||||
return Err(stale());
|
||||
}
|
||||
let asset = get(db, household_id, current.asset_id).await?;
|
||||
let date = match ch.date {
|
||||
Some(d) => clean_date(&d)?,
|
||||
None => current.date.clone(),
|
||||
};
|
||||
// Miktar ya da tutar değiştiyse birim fiyat (verilmediyse) yeniden hesaplanır
|
||||
let amounts_changed = ch.quantity_e6.is_some() || ch.total.is_some();
|
||||
let (quantity_e6, unit_price_e6, total) = clean_amounts(
|
||||
&asset,
|
||||
ch.quantity_e6.unwrap_or(current.quantity_e6),
|
||||
ch.unit_price_e6
|
||||
.or((!amounts_changed).then_some(current.unit_price_e6)),
|
||||
ch.total.unwrap_or(current.total),
|
||||
)?;
|
||||
|
||||
// Değişmiş haliyle sıralı denetim
|
||||
let mut list: Vec<AssetTx> = all_txs(db, household_id, Some(current.asset_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|x| x.id != id)
|
||||
.collect();
|
||||
let mut changed = current.clone();
|
||||
changed.date = date.clone();
|
||||
changed.quantity_e6 = quantity_e6;
|
||||
changed.total = total;
|
||||
// MongoDB `_id` (UUID) sırası bayt sırasıdır
|
||||
let pos =
|
||||
list.partition_point(|x| (x.date.as_str(), x.id.bytes()) <= (date.as_str(), id.bytes()));
|
||||
list.insert(pos, changed);
|
||||
check(&moves(&list))?;
|
||||
|
||||
let mut set = doc! {
|
||||
"updated_at": now(),
|
||||
"date": date,
|
||||
"quantity_e6": quantity_e6,
|
||||
"unit_price_e6": unit_price_e6,
|
||||
"total": total,
|
||||
};
|
||||
if let Some(n) = ch.note {
|
||||
set.insert(
|
||||
"note",
|
||||
clean_note(Some(&n))?.map_or(Bson::Null, Bson::String),
|
||||
);
|
||||
}
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
f.insert("version", current.version);
|
||||
txs(db)
|
||||
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?
|
||||
.ok_or_else(stale)
|
||||
}
|
||||
|
||||
/// Soft delete. Bir alımı silmek sonraki satışları eksiye düşürecekse reddedilir.
|
||||
pub async fn delete_tx(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
|
||||
let current = get_tx(db, household_id, id).await?;
|
||||
let rest: Vec<AssetTx> = all_txs(db, household_id, Some(current.asset_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|x| x.id != id)
|
||||
.collect();
|
||||
if holding::holding(&moves(&rest)).is_err() {
|
||||
return Err(AppError::Conflict(
|
||||
"Bu alım silinirse sonraki satışlar eldeki miktarı aşar; önce satışı düzeltin".into(),
|
||||
));
|
||||
}
|
||||
let ts = now();
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
txs(db)
|
||||
.update_one(
|
||||
f,
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- yardımcılar ----------
|
||||
|
||||
async fn get_tx(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<AssetTx> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
txs(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Hanenin (ya da bir varlığın) işlemleri, **tarih sırasıyla** (aynı gün: ekleniş sırası;
|
||||
/// UUID v7 zamana göre sıralı)
|
||||
async fn all_txs(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
asset_id: Option<Uuid>,
|
||||
) -> AppResult<Vec<AssetTx>> {
|
||||
let mut f = scope(household_id);
|
||||
if let Some(a) = asset_id {
|
||||
f.insert("asset_id", a);
|
||||
}
|
||||
let mut cursor = txs(db).find(f).sort(doc! { "date": 1, "_id": 1 }).await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
out.push(cursor.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn moves(list: &[AssetTx]) -> Vec<Move> {
|
||||
list.iter()
|
||||
.map(|t| Move {
|
||||
tx_type: t.tx_type,
|
||||
quantity_e6: t.quantity_e6,
|
||||
total: t.total,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn check(ms: &[Move]) -> AppResult<()> {
|
||||
holding::holding(ms).map(|_| ()).map_err(|Oversold(_)| {
|
||||
AppError::BadRequest("Satış miktarı o tarihte eldeki miktarı aşıyor".into())
|
||||
})
|
||||
}
|
||||
|
||||
/// (miktar, birim fiyat, tutar). TL varlığında miktar = tutar, fiyat 1.
|
||||
fn clean_amounts(
|
||||
asset: &Asset,
|
||||
quantity_e6: i64,
|
||||
unit_price_e6: Option<i64>,
|
||||
total: i64,
|
||||
) -> AppResult<(i64, i64, i64)> {
|
||||
if total <= 0 || total > MAX_AMOUNT {
|
||||
return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into()));
|
||||
}
|
||||
if asset.kind == AssetKind::Cash {
|
||||
// kuruş → TL × 1e6
|
||||
return Ok((total * 10_000, ONE_E6, total));
|
||||
}
|
||||
if quantity_e6 <= 0 || quantity_e6 > MAX_E6 {
|
||||
return Err(AppError::BadRequest("Miktar sıfırdan büyük olmalı".into()));
|
||||
}
|
||||
let price = match unit_price_e6 {
|
||||
Some(p) if p <= 0 || p > MAX_E6 => {
|
||||
return Err(AppError::BadRequest("Birim fiyat geçersiz".into()));
|
||||
}
|
||||
Some(p) => p,
|
||||
None => holding::unit_price_e6(total, quantity_e6),
|
||||
};
|
||||
Ok((quantity_e6, price, total))
|
||||
}
|
||||
|
||||
fn clean_date(s: &str) -> AppResult<String> {
|
||||
let s = s.trim();
|
||||
parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
fn clean_name(s: &str) -> AppResult<String> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || s.chars().count() > 60 {
|
||||
return Err(AppError::BadRequest("Ad 1–60 karakter olmalı".into()));
|
||||
}
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
fn clean_note(n: Option<&str>) -> AppResult<Option<String>> {
|
||||
match n.map(str::trim) {
|
||||
None | Some("") => Ok(None),
|
||||
Some(s) if s.chars().count() > MAX_NOTE => Err(AppError::BadRequest(format!(
|
||||
"Not en fazla {MAX_NOTE} karakter olabilir"
|
||||
))),
|
||||
Some(s) => Ok(Some(s.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn stale() -> AppError {
|
||||
AppError::Conflict("Kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
||||
}
|
||||
|
||||
// ---------- Dönem kapanışından birikim ----------
|
||||
|
||||
/// Kapanışta birikime aktarılan tutarın TL varlığına eklenmiş kaydı (dönem başına tek)
|
||||
pub async fn period_savings_tx(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
) -> AppResult<Option<AssetTx>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("source_period_id", period_id);
|
||||
Ok(txs(db).find_one(f).await?)
|
||||
}
|
||||
|
||||
async fn cash_asset(db: &Database, household_id: Uuid) -> AppResult<Asset> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("kind", "cash");
|
||||
assets(db)
|
||||
.find_one(f)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("hanenin TL varlığı yok".into()))
|
||||
}
|
||||
|
||||
/// Kapalı dönemin `to_savings` tutarını TL varlığına **alım** olarak ekler (kullanıcı onayıyla).
|
||||
/// Tarih = dönemin son günü. Aynı dönem ikinci kez eklenemez.
|
||||
pub async fn add_period_savings(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
period_id: Uuid,
|
||||
) -> AppResult<AssetTx> {
|
||||
let p = crate::services::period::get(db, household_id, period_id).await?;
|
||||
if p.status != crate::models::period::PeriodStatus::Closed {
|
||||
return Err(AppError::BadRequest(
|
||||
"Dönem kapanmadan birikime eklenemez".into(),
|
||||
));
|
||||
}
|
||||
let amount = p.to_savings.unwrap_or(0);
|
||||
if amount <= 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Bu dönemde birikime aktarılan tutar yok".into(),
|
||||
));
|
||||
}
|
||||
if period_savings_tx(db, household_id, period_id)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::Conflict(
|
||||
"Bu dönemin birikimi zaten eklendi".into(),
|
||||
));
|
||||
}
|
||||
let cash = cash_asset(db, household_id).await?;
|
||||
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("asset_id", cash.id);
|
||||
d.insert("type", TxType::Buy.as_str());
|
||||
d.insert("date", last_day(&p.end_date)?);
|
||||
d.insert("quantity_e6", amount * 10_000);
|
||||
d.insert("unit_price_e6", ONE_E6);
|
||||
d.insert("total", amount);
|
||||
d.insert("note", format!("{} kapanışı", p.name));
|
||||
d.insert("source_period_id", period_id);
|
||||
d.insert("created_by", user_id);
|
||||
db.collection::<Document>(col::ASSET_TRANSACTIONS)
|
||||
.insert_one(d)
|
||||
.await?;
|
||||
txs(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("eklenen işlem okunamadı".into()))
|
||||
}
|
||||
|
||||
/// Kapalı, birikime aktarılan tutarı olan ama henüz eklenmemiş dönemler (en yeni başta)
|
||||
pub async fn pending_period_savings(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
) -> AppResult<Vec<crate::models::period::Period>> {
|
||||
let linked: Vec<Uuid> = {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("source_period_id", doc! { "$ne": null });
|
||||
let mut c = txs(db).find(f).await?;
|
||||
let mut v = Vec::new();
|
||||
while c.advance().await? {
|
||||
if let Some(p) = c.deserialize_current()?.source_period_id {
|
||||
v.push(p);
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
Ok(crate::services::period::list(db, household_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
p.status == crate::models::period::PeriodStatus::Closed
|
||||
&& p.to_savings.unwrap_or(0) > 0
|
||||
&& !linked.contains(&p.id)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Yeniden kapatmada bağlı kayda yapılacak iş (transaction'dan önce hazırlanıp denetlenir)
|
||||
pub enum SavingsSync {
|
||||
Update { id: Uuid, amount: i64, date: String },
|
||||
Delete { id: Uuid },
|
||||
}
|
||||
|
||||
/// Bağlı kayıt varsa yeni tutar/tarihe göre işi hazırlar. Tutar 0 → silinir.
|
||||
/// TL'de sonraki satışlar eldeki miktarı aşacaksa reddedilir (kapatma da yapılmaz).
|
||||
pub async fn plan_savings_sync(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
amount: i64,
|
||||
end_date: &str,
|
||||
) -> AppResult<Option<SavingsSync>> {
|
||||
let Some(linked) = period_savings_tx(db, household_id, period_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let date = last_day(end_date)?;
|
||||
let mut list: Vec<AssetTx> = all_txs(db, household_id, Some(linked.asset_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|x| x.id != linked.id)
|
||||
.collect();
|
||||
if amount > 0 {
|
||||
let mut changed = linked.clone();
|
||||
changed.date = date.clone();
|
||||
changed.quantity_e6 = amount * 10_000;
|
||||
changed.total = amount;
|
||||
let pos = list.partition_point(|x| {
|
||||
(x.date.as_str(), x.id.bytes()) <= (date.as_str(), linked.id.bytes())
|
||||
});
|
||||
list.insert(pos, changed);
|
||||
}
|
||||
if holding::holding(&moves(&list)).is_err() {
|
||||
return Err(AppError::Conflict(
|
||||
"Birikimden yapılan TL çıkışları yeni tutarı aşıyor; önce Birikimler'deki kayıtları düzeltin"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(Some(if amount > 0 {
|
||||
SavingsSync::Update {
|
||||
id: linked.id,
|
||||
amount,
|
||||
date,
|
||||
}
|
||||
} else {
|
||||
SavingsSync::Delete { id: linked.id }
|
||||
}))
|
||||
}
|
||||
|
||||
/// Hazırlanan işi kapatma transaction'ı içinde uygular
|
||||
pub async fn apply_savings_sync(
|
||||
db: &Database,
|
||||
session: &mut mongodb::ClientSession,
|
||||
household_id: Uuid,
|
||||
sync: &SavingsSync,
|
||||
) -> AppResult<()> {
|
||||
let ts = now();
|
||||
let (id, set) = match sync {
|
||||
SavingsSync::Update { id, amount, date } => (
|
||||
*id,
|
||||
doc! {
|
||||
"date": date,
|
||||
"quantity_e6": amount * 10_000,
|
||||
"unit_price_e6": ONE_E6,
|
||||
"total": amount,
|
||||
"updated_at": ts,
|
||||
},
|
||||
),
|
||||
SavingsSync::Delete { id } => (*id, doc! { "deleted_at": ts, "updated_at": ts }),
|
||||
};
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
db.collection::<Document>(col::ASSET_TRANSACTIONS)
|
||||
.update_one(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||
.session(session)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hariç bitiş → son gün (dahil)
|
||||
fn last_day(end_date: &str) -> AppResult<String> {
|
||||
let end =
|
||||
parse_date(end_date).ok_or_else(|| AppError::Internal("dönem bitişi geçersiz".into()))?;
|
||||
Ok(crate::services::calendar::format_date(
|
||||
end - time::Duration::days(1),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
//! Bütçe ve dönem özeti (KAKEIBO_PROMPT §8.3, DATA_MODEL.md §3.8, §5).
|
||||
//!
|
||||
//! - Her grup için **aylık** tutar (`budgets`, `scope: "month"`).
|
||||
//! - Haftalık tutar saklanmaz; aylık tutar hafta gün sayısına göre bölünür.
|
||||
//! Kullanıcı bir haftayı elle değiştirirse yalnızca o kayıt saklanır (`scope: "week"`);
|
||||
//! kalan tutar diğer haftalara gün oranında dağıtılır → haftaların toplamı = aylık.
|
||||
//! - Özet: gelir − sabit gider − birikim − devreden borç + devreden artı = **harcanabilir**.
|
||||
//! - Gerçekleşen harcama (`entries`) saklanmaz; her istekte toplanır:
|
||||
//! özette toplam + kalan, bütçe görünümünde grup ve hafta bazında.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
group::Group,
|
||||
period::{Period, PeriodStatus},
|
||||
},
|
||||
services::{
|
||||
calendar::{format_date, parse_date, weeks},
|
||||
money::split_proportional,
|
||||
period,
|
||||
},
|
||||
};
|
||||
|
||||
/// Gruplara göre tutarlar (kuruş), `Group::ALL` sırasıyla.
|
||||
pub type GroupAmounts = [i64; 4];
|
||||
|
||||
pub struct Summary {
|
||||
pub income_total: i64,
|
||||
pub fixed_expense_total: i64,
|
||||
pub savings_goal: i64,
|
||||
pub carryover_debt: i64,
|
||||
pub carryover_surplus: i64,
|
||||
pub spendable: i64,
|
||||
pub budget_total: i64,
|
||||
/// Dönemde gerçekleşen harcama (silinenler hariç)
|
||||
pub spent_total: i64,
|
||||
/// Aynısı gruplara göre (`Group::ALL` sırasıyla)
|
||||
pub spent_by_group: GroupAmounts,
|
||||
}
|
||||
|
||||
pub struct WeekBudget {
|
||||
pub index: u32,
|
||||
pub start_date: String,
|
||||
pub last_date: String,
|
||||
pub days: u32,
|
||||
pub amount: i64,
|
||||
pub is_manual: bool,
|
||||
/// Bu haftada bu grupta gerçekleşen harcama
|
||||
pub spent: i64,
|
||||
}
|
||||
|
||||
pub struct GroupBudget {
|
||||
pub group: Group,
|
||||
pub monthly: i64,
|
||||
/// Dönem boyunca bu grupta gerçekleşen harcama
|
||||
pub spent: i64,
|
||||
pub weeks: Vec<WeekBudget>,
|
||||
/// Elle girilen haftaların toplamı aylık tutarı aşıyor mu?
|
||||
pub weekly_over: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SuggestMethod {
|
||||
/// Hanenin varsayılan yüzdeleri × harcanabilir tutar
|
||||
Default,
|
||||
/// Önceki dönemde gerçekleşen harcamalar
|
||||
Previous,
|
||||
/// Son 3 dönemin gerçekleşen harcama ortalaması
|
||||
Avg3,
|
||||
}
|
||||
|
||||
pub struct Suggestion {
|
||||
pub available: bool,
|
||||
pub message: Option<String>,
|
||||
pub amounts: GroupAmounts,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BudgetDoc {
|
||||
group: Group,
|
||||
scope: String,
|
||||
#[serde(default)]
|
||||
week_start: Option<String>,
|
||||
amount: i64,
|
||||
}
|
||||
|
||||
// ---------- Özet ----------
|
||||
|
||||
pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Summary> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
|
||||
let mut income_total = 0;
|
||||
let mut fixed_expense_total = 0;
|
||||
for (kind, total) in sum_by(
|
||||
db,
|
||||
col::PLAN_ITEMS,
|
||||
household_id,
|
||||
doc! { "period_id": period_id },
|
||||
"$kind",
|
||||
)
|
||||
.await?
|
||||
{
|
||||
match kind.as_str() {
|
||||
"income" => income_total = total,
|
||||
"expense" => fixed_expense_total = total,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut carryover_debt = 0;
|
||||
let mut carryover_surplus = 0;
|
||||
for (kind, total) in sum_by(
|
||||
db,
|
||||
col::CARRYOVERS,
|
||||
household_id,
|
||||
doc! { "to_period_id": period_id },
|
||||
"$kind",
|
||||
)
|
||||
.await?
|
||||
{
|
||||
match kind.as_str() {
|
||||
"debt" => carryover_debt = total,
|
||||
"surplus" => carryover_surplus = total,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let budget_total = monthly_amounts(db, household_id, period_id)
|
||||
.await?
|
||||
.iter()
|
||||
.sum();
|
||||
let spendable =
|
||||
income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus;
|
||||
let by_group = sum_by(
|
||||
db,
|
||||
col::ENTRIES,
|
||||
household_id,
|
||||
doc! { "period_id": period_id },
|
||||
"$group",
|
||||
)
|
||||
.await?;
|
||||
let spent_by_group = Group::ALL.map(|g| by_group.get(g.as_str()).copied().unwrap_or(0));
|
||||
let spent_total = spent_by_group.iter().sum();
|
||||
|
||||
Ok(Summary {
|
||||
income_total,
|
||||
fixed_expense_total,
|
||||
savings_goal: p.savings_goal,
|
||||
carryover_debt,
|
||||
carryover_surplus,
|
||||
spendable,
|
||||
budget_total,
|
||||
spent_total,
|
||||
spent_by_group,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Bütçe görünümü ----------
|
||||
|
||||
pub async fn view(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
) -> AppResult<Vec<GroupBudget>> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
let docs = load(db, household_id, period_id).await?;
|
||||
let (start, end) = dates(&p)?;
|
||||
let period_weeks = weeks(start, end);
|
||||
let spent = spent_by_group_and_date(db, household_id, period_id).await?;
|
||||
|
||||
let mut out = Vec::with_capacity(4);
|
||||
for group in Group::ALL {
|
||||
let monthly = docs
|
||||
.iter()
|
||||
.find(|d| d.group == group && d.scope == "month")
|
||||
.map_or(0, |d| d.amount);
|
||||
// Elle girilmiş haftalar (dönem tarihleri değiştiyse eşleşmeyen eski kayıtlar yok sayılır)
|
||||
let manual: HashMap<String, i64> = docs
|
||||
.iter()
|
||||
.filter(|d| d.group == group && d.scope == "week")
|
||||
.filter_map(|d| d.week_start.clone().map(|w| (w, d.amount)))
|
||||
.collect();
|
||||
|
||||
let manual_total: i64 = period_weeks
|
||||
.iter()
|
||||
.filter_map(|w| manual.get(&format_date(w.start)))
|
||||
.sum();
|
||||
let auto_days: Vec<u64> = period_weeks
|
||||
.iter()
|
||||
.filter(|w| !manual.contains_key(&format_date(w.start)))
|
||||
.map(|w| u64::from(w.days))
|
||||
.collect();
|
||||
let mut auto = split_proportional(monthly - manual_total, &auto_days).into_iter();
|
||||
|
||||
let weeks = period_weeks
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let key = format_date(w.start);
|
||||
let last = format_date(w.last);
|
||||
// Tarihler "YYYY-MM-DD" → metin karşılaştırması tarih sırasıyla aynı
|
||||
let week_spent = spent
|
||||
.iter()
|
||||
.filter(|(g, d, _)| *g == group && *d >= key && *d <= last)
|
||||
.map(|(_, _, a)| a)
|
||||
.sum();
|
||||
let (amount, is_manual) = match manual.get(&key) {
|
||||
Some(&a) => (a, true),
|
||||
None => (auto.next().unwrap_or(0), false),
|
||||
};
|
||||
WeekBudget {
|
||||
index: w.index,
|
||||
start_date: key,
|
||||
last_date: last,
|
||||
days: w.days,
|
||||
amount,
|
||||
is_manual,
|
||||
spent: week_spent,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
out.push(GroupBudget {
|
||||
group,
|
||||
monthly,
|
||||
spent: spent
|
||||
.iter()
|
||||
.filter(|(g, _, _)| *g == group)
|
||||
.map(|(_, _, a)| a)
|
||||
.sum(),
|
||||
weeks,
|
||||
weekly_over: manual_total > monthly,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------- Yazma ----------
|
||||
|
||||
/// 4 grubun aylık tutarını birlikte kaydeder (elle girilmiş haftalar korunur).
|
||||
pub async fn set_monthly(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
amounts: GroupAmounts,
|
||||
) -> AppResult<()> {
|
||||
period::get_open(db, household_id, period_id).await?;
|
||||
for a in amounts {
|
||||
period::validate_amount(a)?;
|
||||
}
|
||||
for (group, amount) in Group::ALL.into_iter().zip(amounts) {
|
||||
upsert(db, household_id, period_id, group, "month", None, amount).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bir haftayı elle ayarlar; `amount = None` → otomatiğe döner.
|
||||
pub async fn set_week(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
group: Group,
|
||||
week_start: &str,
|
||||
amount: Option<i64>,
|
||||
) -> AppResult<()> {
|
||||
let p = period::get_open(db, household_id, period_id).await?;
|
||||
let (start, end) = dates(&p)?;
|
||||
let valid =
|
||||
parse_date(week_start).is_some_and(|d| weeks(start, end).iter().any(|w| w.start == d));
|
||||
if !valid {
|
||||
return Err(AppError::BadRequest("Hafta bu döneme ait değil".into()));
|
||||
}
|
||||
match amount {
|
||||
Some(a) => {
|
||||
period::validate_amount(a)?;
|
||||
upsert(
|
||||
db,
|
||||
household_id,
|
||||
period_id,
|
||||
group,
|
||||
"week",
|
||||
Some(week_start),
|
||||
a,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
f.insert("group", group.as_str());
|
||||
f.insert("scope", "week");
|
||||
f.insert("week_start", week_start);
|
||||
let ts = now();
|
||||
db.collection::<Document>(col::BUDGETS)
|
||||
.update_many(
|
||||
f,
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Öneri ----------
|
||||
|
||||
pub async fn suggest(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
method: SuggestMethod,
|
||||
) -> AppResult<Suggestion> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
match method {
|
||||
SuggestMethod::Default => {
|
||||
let s = summary(db, household_id, period_id).await?;
|
||||
if s.spendable <= 0 {
|
||||
return Ok(unavailable(
|
||||
"Harcanabilir tutar sıfır ya da negatif. Önce gelir, gider ve birikim hedefini girin.",
|
||||
));
|
||||
}
|
||||
let pct = split_percentages(db, household_id).await?;
|
||||
let parts = split_proportional(s.spendable, &pct);
|
||||
Ok(Suggestion {
|
||||
available: true,
|
||||
message: None,
|
||||
amounts: [parts[0], parts[1], parts[2], parts[3]],
|
||||
})
|
||||
}
|
||||
SuggestMethod::Previous | SuggestMethod::Avg3 => {
|
||||
let n = if method == SuggestMethod::Previous {
|
||||
1
|
||||
} else {
|
||||
3
|
||||
};
|
||||
let previous = previous_closed(db, household_id, &p, n).await?;
|
||||
if previous.is_empty() {
|
||||
return Ok(unavailable("Henüz kapanmış bir önceki dönem yok."));
|
||||
}
|
||||
let ids: Vec<Bson> = previous.iter().map(|id| Bson::from(*id)).collect();
|
||||
let sums = sum_by(
|
||||
db,
|
||||
col::ENTRIES,
|
||||
household_id,
|
||||
doc! { "period_id": { "$in": ids } },
|
||||
"$group",
|
||||
)
|
||||
.await?;
|
||||
let total: i64 = sums.values().sum();
|
||||
if total == 0 {
|
||||
return Ok(unavailable("Önceki dönemlerde henüz harcama kaydı yok."));
|
||||
}
|
||||
let count = previous.len() as i64;
|
||||
let avg = |g: Group| {
|
||||
let s = sums.get(g.as_str()).copied().unwrap_or(0);
|
||||
(s + count / 2) / count // yuvarlayarak bölme
|
||||
};
|
||||
Ok(Suggestion {
|
||||
available: true,
|
||||
message: None,
|
||||
amounts: Group::ALL.map(avg),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- yardımcılar ----------
|
||||
|
||||
fn dates(p: &Period) -> AppResult<(time::Date, time::Date)> {
|
||||
let bad = || AppError::Internal("dönem tarihi okunamadı".into());
|
||||
Ok((
|
||||
parse_date(&p.start_date).ok_or_else(bad)?,
|
||||
parse_date(&p.end_date).ok_or_else(bad)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn unavailable(msg: &str) -> Suggestion {
|
||||
Suggestion {
|
||||
available: false,
|
||||
message: Some(msg.into()),
|
||||
amounts: [0; 4],
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Vec<BudgetDoc>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
let mut cursor = db.collection::<BudgetDoc>(col::BUDGETS).find(f).await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
out.push(cursor.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn monthly_amounts(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
) -> AppResult<GroupAmounts> {
|
||||
let docs = load(db, household_id, period_id).await?;
|
||||
Ok(Group::ALL.map(|g| {
|
||||
docs.iter()
|
||||
.find(|d| d.group == g && d.scope == "month")
|
||||
.map_or(0, |d| d.amount)
|
||||
}))
|
||||
}
|
||||
|
||||
async fn upsert(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
group: Group,
|
||||
level: &str,
|
||||
week_start: Option<&str>,
|
||||
amount: i64,
|
||||
) -> AppResult<()> {
|
||||
let coll = db.collection::<Document>(col::BUDGETS);
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
f.insert("group", group.as_str());
|
||||
f.insert("scope", level);
|
||||
f.insert(
|
||||
"week_start",
|
||||
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
|
||||
);
|
||||
|
||||
let res = coll
|
||||
.update_one(
|
||||
f,
|
||||
doc! { "$set": { "amount": amount, "updated_at": now() }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
if res.matched_count == 0 {
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("period_id", period_id);
|
||||
d.insert("group", group.as_str());
|
||||
d.insert("scope", level);
|
||||
d.insert(
|
||||
"week_start",
|
||||
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
|
||||
);
|
||||
d.insert("amount", amount);
|
||||
coll.insert_one(d).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bir koleksiyonda `amount` alanını bir alana göre toplar: `{ "income": 450000, ... }`
|
||||
async fn sum_by(
|
||||
db: &Database,
|
||||
collection: &str,
|
||||
household_id: Uuid,
|
||||
filter: Document,
|
||||
key: &str,
|
||||
) -> AppResult<HashMap<String, i64>> {
|
||||
let mut m = scope(household_id);
|
||||
m.extend(filter);
|
||||
let pipeline = vec![
|
||||
doc! { "$match": m },
|
||||
doc! { "$group": { "_id": key, "total": { "$sum": "$amount" } } },
|
||||
];
|
||||
let mut cursor = db
|
||||
.collection::<Document>(collection)
|
||||
.aggregate(pipeline)
|
||||
.await?;
|
||||
let mut out = HashMap::new();
|
||||
while cursor.advance().await? {
|
||||
let d = cursor.deserialize_current()?;
|
||||
let k = d.get_str("_id").unwrap_or_default().to_string();
|
||||
let v = match d.get("total") {
|
||||
Some(Bson::Int64(v)) => *v,
|
||||
Some(Bson::Int32(v)) => i64::from(*v),
|
||||
_ => 0,
|
||||
};
|
||||
out.insert(k, v);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Dönemin harcamaları (grup, gün) bazında toplanmış: en fazla 4 × 62 satır.
|
||||
pub(crate) async fn spent_by_group_and_date(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
) -> AppResult<Vec<(Group, String, i64)>> {
|
||||
#[derive(Deserialize)]
|
||||
struct Key {
|
||||
group: Group,
|
||||
date: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Row {
|
||||
#[serde(rename = "_id")]
|
||||
key: Key,
|
||||
total: i64,
|
||||
}
|
||||
let mut m = scope(household_id);
|
||||
m.insert("period_id", period_id);
|
||||
let pipeline = vec![
|
||||
doc! { "$match": m },
|
||||
doc! { "$group": {
|
||||
"_id": { "group": "$group", "date": "$date" },
|
||||
"total": { "$sum": "$amount" },
|
||||
} },
|
||||
];
|
||||
let mut cursor = db
|
||||
.collection::<Document>(col::ENTRIES)
|
||||
.aggregate(pipeline)
|
||||
.with_type::<Row>()
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
let r = cursor.deserialize_current()?;
|
||||
out.push((r.key.group, r.key.date, r.total));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Hane ayarındaki varsayılan yüzdeler (yoksa 50/30/10/10).
|
||||
async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult<Vec<u64>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", household_id);
|
||||
let hh = db
|
||||
.collection::<Document>(col::HOUSEHOLDS)
|
||||
.find_one(f)
|
||||
.await?;
|
||||
let pct = hh
|
||||
.as_ref()
|
||||
.and_then(|h| h.get_document("settings").ok())
|
||||
.and_then(|s| s.get_document("budget_split_pct").ok());
|
||||
let default = [50u64, 30, 10, 10];
|
||||
Ok(Group::ALL
|
||||
.iter()
|
||||
.zip(default)
|
||||
.map(|(g, d)| {
|
||||
pct.and_then(|p| match p.get(g.as_str()) {
|
||||
Some(Bson::Int32(v)) => Some(*v as u64),
|
||||
Some(Bson::Int64(v)) => Some(*v as u64),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(d)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Bu dönemden önceki en yeni `n` kapalı dönemin kimlikleri.
|
||||
async fn previous_closed(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
p: &Period,
|
||||
n: i64,
|
||||
) -> AppResult<Vec<Uuid>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("start_date", doc! { "$lt": &p.start_date });
|
||||
f.insert("status", PeriodStatus::Closed.as_str());
|
||||
let mut cursor = db
|
||||
.collection::<Period>(col::PERIODS)
|
||||
.find(f)
|
||||
.sort(doc! { "start_date": -1 })
|
||||
.limit(n)
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
out.push(cursor.deserialize_current()?.id);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Takvim hesapları: tarih okuma/yazma, dönem haftaları, Türkçe ay adları.
|
||||
//!
|
||||
//! Tarihler veritabanında `"YYYY-MM-DD"` metni olarak tutulur (DATA_MODEL.md §1.3).
|
||||
//! Dönem bitişi **hariçtir**: `2026-12-01 – 2027-01-01` = 1–31 Aralık.
|
||||
|
||||
use time::{Date, Duration, Month};
|
||||
|
||||
const MONTHS: [&str; 12] = [
|
||||
"Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz", "Ağustos", "Eylül", "Ekim",
|
||||
"Kasım", "Aralık",
|
||||
];
|
||||
const MONTHS_SHORT: [&str; 12] = [
|
||||
"Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara",
|
||||
];
|
||||
|
||||
/// `"2026-12-09"` → `Date`. Biçim ya da tarih geçersizse `None` (ör. `2026-02-30`).
|
||||
pub fn parse_date(s: &str) -> Option<Date> {
|
||||
let b = s.as_bytes();
|
||||
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
|
||||
return None;
|
||||
}
|
||||
let year: i32 = s[0..4].parse().ok()?;
|
||||
let month: u8 = s[5..7].parse().ok()?;
|
||||
let day: u8 = s[8..10].parse().ok()?;
|
||||
Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
|
||||
}
|
||||
|
||||
/// `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())
|
||||
}
|
||||
|
||||
/// Bitişi hariç aralığın gün sayısı.
|
||||
pub fn days_between(start: Date, end_exclusive: Date) -> i64 {
|
||||
(end_exclusive - start).whole_days()
|
||||
}
|
||||
|
||||
/// Dönem içindeki bir hafta. Son gün dahildir (`last`), gösterim kolaylığı için.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Week {
|
||||
/// 1'den başlar
|
||||
pub index: u32,
|
||||
pub start: Date,
|
||||
pub last: Date,
|
||||
pub days: u32,
|
||||
}
|
||||
|
||||
/// Dönemi Pazartesi başlangıçlı haftalara böler.
|
||||
/// Baştaki ve sondaki kısmi haftalar ayrı hafta sayılır (KAKEIBO_PROMPT §6).
|
||||
pub fn weeks(start: Date, end_exclusive: Date) -> Vec<Week> {
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = start;
|
||||
while cursor < end_exclusive {
|
||||
// Bir sonraki Pazartesi (ya da dönem sonu, hangisi önce gelirse)
|
||||
let to_monday = 7 - i64::from(cursor.weekday().number_days_from_monday());
|
||||
let next = (cursor + Duration::days(to_monday)).min(end_exclusive);
|
||||
out.push(Week {
|
||||
index: out.len() as u32 + 1,
|
||||
start: cursor,
|
||||
last: next - Duration::days(1),
|
||||
days: days_between(cursor, next) as u32,
|
||||
});
|
||||
cursor = next;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Kullanıcı ad vermezse dönem adı:
|
||||
/// - takvim ayının tamamıysa: `"Aralık 2026"`
|
||||
/// - değilse: `"15 Ara 2026 – 14 Oca 2027"`
|
||||
pub fn default_period_name(start: Date, end_exclusive: Date) -> String {
|
||||
let last = end_exclusive - Duration::days(1);
|
||||
let full_month = start.day() == 1
|
||||
&& last.month() == start.month()
|
||||
&& last.year() == start.year()
|
||||
&& (last + Duration::days(1)).day() == 1;
|
||||
if full_month {
|
||||
return format!("{} {}", MONTHS[month_idx(start)], start.year());
|
||||
}
|
||||
format!(
|
||||
"{} {} {} – {} {} {}",
|
||||
start.day(),
|
||||
MONTHS_SHORT[month_idx(start)],
|
||||
start.year(),
|
||||
last.day(),
|
||||
MONTHS_SHORT[month_idx(last)],
|
||||
last.year()
|
||||
)
|
||||
}
|
||||
|
||||
fn month_idx(d: Date) -> usize {
|
||||
u8::from(d.month()) as usize - 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn d(s: &str) -> Date {
|
||||
parse_date(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_and_format() {
|
||||
assert_eq!(format_date(d("2026-12-09")), "2026-12-09");
|
||||
for bad in [
|
||||
"2026-02-30",
|
||||
"2026-13-01",
|
||||
"26-12-09",
|
||||
"2026/12/09",
|
||||
"",
|
||||
"2026-12-9",
|
||||
] {
|
||||
assert!(parse_date(bad).is_none(), "{bad} geçersiz olmalı");
|
||||
}
|
||||
assert!(parse_date("2028-02-29").is_some(), "artık yıl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn december_2026_weeks() {
|
||||
// 1 Aralık 2026 Salı → ilk hafta 6 gün (Sal–Paz), son hafta 4 gün (Pzt–Per)
|
||||
let w = weeks(d("2026-12-01"), d("2027-01-01"));
|
||||
let summary: Vec<(String, String, u32)> = w
|
||||
.iter()
|
||||
.map(|w| (format_date(w.start), format_date(w.last), w.days))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
summary,
|
||||
vec![
|
||||
("2026-12-01".into(), "2026-12-06".into(), 6),
|
||||
("2026-12-07".into(), "2026-12-13".into(), 7),
|
||||
("2026-12-14".into(), "2026-12-20".into(), 7),
|
||||
("2026-12-21".into(), "2026-12-27".into(), 7),
|
||||
("2026-12-28".into(), "2026-12-31".into(), 4),
|
||||
]
|
||||
);
|
||||
assert_eq!(w.iter().map(|w| w.days).sum::<u32>(), 31);
|
||||
assert_eq!(w.last().unwrap().index, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weeks_edge_cases() {
|
||||
// Pazartesi başlayan, tam 2 hafta
|
||||
let w = weeks(d("2026-11-30"), d("2026-12-14"));
|
||||
assert_eq!(w.len(), 2);
|
||||
assert!(w.iter().all(|w| w.days == 7));
|
||||
// Tek gün (Pazar)
|
||||
let w = weeks(d("2026-12-06"), d("2026-12-07"));
|
||||
assert_eq!((w.len(), w[0].days), (1, 1));
|
||||
// Boş aralık
|
||||
assert!(weeks(d("2026-12-07"), d("2026-12-07")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn period_names() {
|
||||
assert_eq!(
|
||||
default_period_name(d("2026-12-01"), d("2027-01-01")),
|
||||
"Aralık 2026"
|
||||
);
|
||||
assert_eq!(
|
||||
default_period_name(d("2027-02-01"), d("2027-03-01")),
|
||||
"Şubat 2027"
|
||||
);
|
||||
assert_eq!(
|
||||
default_period_name(d("2026-12-15"), d("2027-01-15")),
|
||||
"15 Ara 2026 – 14 Oca 2027"
|
||||
);
|
||||
// Ayın 1'inde başlayıp ay ortasında biten
|
||||
assert_eq!(
|
||||
default_period_name(d("2026-12-01"), d("2026-12-16")),
|
||||
"1 Ara 2026 – 15 Ara 2026"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,18 @@ fn coll(db: &Database, c: Catalog) -> mongodb::Collection<Item> {
|
||||
db.collection(c.collection())
|
||||
}
|
||||
|
||||
/// Hanenin tek kaydı (pasifler dahil). Başka hanenin ya da olmayan kayıt → `None`.
|
||||
pub async fn find(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
c: Catalog,
|
||||
id: Uuid,
|
||||
) -> AppResult<Option<Item>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
Ok(coll(db, c).find_one(f).await?)
|
||||
}
|
||||
|
||||
/// Hanenin kayıtları. Kategoriler grup sırasına, sonra `sort_order`'a göre sıralanır.
|
||||
pub async fn list(
|
||||
db: &Database,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Dönem kapatma ve yeniden açma (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.10, §6).
|
||||
//!
|
||||
//! Kapatma (tek transaction):
|
||||
//! 1. Dönem `closed` olur (`closed_at`, `closed_by`, `to_savings`).
|
||||
//! 2. Bu dönemden önceki devirler (yeniden kapatmada) soft delete edilir.
|
||||
//! 3. Gerçekleşen birikim (değerlendirmenin 4. sorusu) dağıtılır:
|
||||
//! - < 0 → açık **borç** olarak devreder (`debt`).
|
||||
//! - ≥ 0 → `to_savings` kadarı birikime (varsayılan: tamamı), kalanı **artı** olarak devreder (`surplus`).
|
||||
//! 4. Devir sonraki döneme bağlanır; sonraki dönem henüz yoksa `to_period_id: null` kalır
|
||||
//! ve o dönem açılınca bağlanır (`period::create`).
|
||||
//!
|
||||
//! Yeniden açma (yalnızca sahip): dönem `correcting` olur, yeni açık dönem varsa o da açık kalır.
|
||||
//! Mevcut devir, "yeniden kapat" denene kadar yerinde kalır (sonraki dönemin rakamları oynamaz).
|
||||
//! Ardından kapanmış dönem olan bir dönem açılamaz: sonraki devirler zincirleme bozulurdu.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
period::{Period, PeriodStatus},
|
||||
},
|
||||
services::{asset, period, review},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CarryKind {
|
||||
Debt,
|
||||
Surplus,
|
||||
}
|
||||
|
||||
impl CarryKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
CarryKind::Debt => "debt",
|
||||
CarryKind::Surplus => "surplus",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Carry {
|
||||
pub kind: CarryKind,
|
||||
pub amount: i64,
|
||||
pub to_period_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub struct Closing {
|
||||
pub period: Period,
|
||||
pub realized_savings: i64,
|
||||
pub to_savings: i64,
|
||||
pub carryover: Option<Carry>,
|
||||
/// Birikime eklenmiş kayıt var (yeniden kapatmada güncellendi)
|
||||
pub savings_linked: bool,
|
||||
}
|
||||
|
||||
/// Gerçekleşen birikimi birikim / devir olarak böler. `to_savings` verilmezse tamamı birikime.
|
||||
pub fn settle(
|
||||
realized: i64,
|
||||
to_savings: Option<i64>,
|
||||
) -> AppResult<(i64, Option<(CarryKind, i64)>)> {
|
||||
if realized < 0 {
|
||||
if to_savings.unwrap_or(0) != 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Bu dönemde birikim yok; açık, borç olarak sonraki döneme devreder".into(),
|
||||
));
|
||||
}
|
||||
return Ok((0, Some((CarryKind::Debt, -realized))));
|
||||
}
|
||||
let t = to_savings.unwrap_or(realized);
|
||||
if !(0..=realized).contains(&t) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Birikime aktarılacak tutar 0 ile gerçekleşen birikim arasında olmalı".into(),
|
||||
));
|
||||
}
|
||||
let surplus = realized - t;
|
||||
Ok((t, (surplus > 0).then_some((CarryKind::Surplus, surplus))))
|
||||
}
|
||||
|
||||
pub async fn close(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
period_id: Uuid,
|
||||
to_savings: Option<i64>,
|
||||
version: Option<i32>,
|
||||
) -> AppResult<Closing> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
if p.status == PeriodStatus::Closed {
|
||||
return Err(AppError::Conflict("Bu dönem zaten kapalı".into()));
|
||||
}
|
||||
if version.is_some_and(|v| v != p.version) {
|
||||
return Err(period::stale());
|
||||
}
|
||||
let realized = review::review(db, household_id, period_id)
|
||||
.await?
|
||||
.realized_savings;
|
||||
let (to_savings, carry) = settle(realized, to_savings)?;
|
||||
let next = next_period(db, household_id, &p).await?;
|
||||
// Yeniden kapatma: birikime eklenmiş kayıt varsa yeni tutara uyarlanır (önce denetlenir)
|
||||
let savings_sync =
|
||||
asset::plan_savings_sync(db, household_id, period_id, to_savings, &p.end_date).await?;
|
||||
let ts = now();
|
||||
|
||||
let mut session = db.client().start_session().await?;
|
||||
session.start_transaction().await?;
|
||||
let result = async {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", period_id);
|
||||
f.insert("version", p.version);
|
||||
let r = db
|
||||
.collection::<Document>(col::PERIODS)
|
||||
.update_one(
|
||||
f,
|
||||
doc! {
|
||||
"$set": {
|
||||
"status": PeriodStatus::Closed.as_str(),
|
||||
"closed_at": ts,
|
||||
"closed_by": user_id,
|
||||
"to_savings": to_savings,
|
||||
"updated_at": ts,
|
||||
},
|
||||
"$inc": { "version": 1 },
|
||||
},
|
||||
)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
if r.matched_count == 0 {
|
||||
return Err(period::stale());
|
||||
}
|
||||
|
||||
let mut old = scope(household_id);
|
||||
old.insert("from_period_id", period_id);
|
||||
db.collection::<Document>(col::CARRYOVERS)
|
||||
.update_many(
|
||||
old,
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
|
||||
if let Some(sync) = &savings_sync {
|
||||
asset::apply_savings_sync(db, &mut session, household_id, sync).await?;
|
||||
}
|
||||
|
||||
if let Some((kind, amount)) = carry {
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("from_period_id", period_id);
|
||||
d.insert(
|
||||
"to_period_id",
|
||||
next.as_ref().map_or(Bson::Null, |n| n.id.into()),
|
||||
);
|
||||
d.insert("kind", kind.as_str());
|
||||
d.insert("amount", amount);
|
||||
db.collection::<Document>(col::CARRYOVERS)
|
||||
.insert_one(d)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => session.commit_transaction().await?,
|
||||
Err(e) => {
|
||||
let _ = session.abort_transaction().await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Closing {
|
||||
savings_linked: matches!(savings_sync, Some(asset::SavingsSync::Update { .. })),
|
||||
period: period::get(db, household_id, period_id).await?,
|
||||
realized_savings: realized,
|
||||
to_savings,
|
||||
carryover: carry.map(|(kind, amount)| Carry {
|
||||
kind,
|
||||
amount,
|
||||
to_period_id: next.map(|n| n.id),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Kapalı dönemi düzeltme için açar (yalnızca sahip — rota denetler).
|
||||
pub async fn reopen(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
period_id: Uuid,
|
||||
) -> AppResult<Period> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
if p.status != PeriodStatus::Closed {
|
||||
return Err(AppError::Conflict(
|
||||
"Yalnızca kapalı bir dönem yeniden açılabilir".into(),
|
||||
));
|
||||
}
|
||||
let mut later = scope(household_id);
|
||||
later.insert("start_date", doc! { "$gte": &p.end_date });
|
||||
later.insert("status", doc! { "$ne": PeriodStatus::Open.as_str() });
|
||||
if db
|
||||
.collection::<Document>(col::PERIODS)
|
||||
.find_one(later)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::Conflict(
|
||||
"Bu dönemden sonra kapanmış bir dönem var. Yalnızca en son kapanan dönem yeniden açılabilir."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
let ts = now();
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", period_id);
|
||||
f.insert("version", p.version);
|
||||
let r = db
|
||||
.collection::<Document>(col::PERIODS)
|
||||
.update_one(
|
||||
f,
|
||||
doc! {
|
||||
"$set": {
|
||||
"status": PeriodStatus::Correcting.as_str(),
|
||||
"reopened_at": ts,
|
||||
"reopened_by": user_id,
|
||||
"updated_at": ts,
|
||||
},
|
||||
"$inc": { "version": 1 },
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if r.matched_count == 0 {
|
||||
return Err(period::stale());
|
||||
}
|
||||
period::get(db, household_id, period_id).await
|
||||
}
|
||||
|
||||
/// Bu dönemden hemen sonra gelen dönem (varsa)
|
||||
async fn next_period(db: &Database, household_id: Uuid, p: &Period) -> AppResult<Option<Period>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("start_date", doc! { "$gte": &p.end_date });
|
||||
Ok(db
|
||||
.collection::<Period>(col::PERIODS)
|
||||
.find_one(f)
|
||||
.sort(doc! { "start_date": 1 })
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settle_rules() {
|
||||
// Artı: varsayılan tamamı birikime
|
||||
assert_eq!(settle(700, None).unwrap(), (700, None));
|
||||
assert_eq!(
|
||||
settle(700, Some(500)).unwrap(),
|
||||
(500, Some((CarryKind::Surplus, 200)))
|
||||
);
|
||||
assert_eq!(
|
||||
settle(700, Some(0)).unwrap(),
|
||||
(0, Some((CarryKind::Surplus, 700)))
|
||||
);
|
||||
assert!(settle(700, Some(701)).is_err());
|
||||
assert!(settle(700, Some(-1)).is_err());
|
||||
// Sıfır: devir yok
|
||||
assert_eq!(settle(0, None).unwrap(), (0, None));
|
||||
// Açık: borç; birikim istenemez
|
||||
assert_eq!(settle(-50, None).unwrap(), (0, Some((CarryKind::Debt, 50))));
|
||||
assert_eq!(
|
||||
settle(-50, Some(0)).unwrap(),
|
||||
(0, Some((CarryKind::Debt, 50)))
|
||||
);
|
||||
assert!(settle(-50, Some(10)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Günlük defter: harcama kayıtları (KAKEIBO_PROMPT §8.4).
|
||||
//!
|
||||
//! Kurallar:
|
||||
//! - Tutar kuruş, > 0. Tarih kaydın döneminin içinde olmalı.
|
||||
//! - Grup **kategoriden** alınır (istemci grup göndermez) → kategori ile grup hiç çelişmez.
|
||||
//! - Kategori ve ödeme türü bu haneye ait olmalı; yeni seçimde **aktif** olmalı
|
||||
//! (pasife alınmış kategoride kalan eski kayıt düzenlenebilir).
|
||||
//! - Yalnızca açık dönemde yazma. Silme soft delete; "Geri al" için `restore`.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
options::ReturnDocument,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
entry::Entry,
|
||||
group::Group,
|
||||
period::Period,
|
||||
},
|
||||
services::{calendar::parse_date, catalog, period},
|
||||
};
|
||||
|
||||
/// Açıklama üst sınırı (karakter)
|
||||
pub const MAX_DESCRIPTION: usize = 200;
|
||||
|
||||
pub struct NewEntry {
|
||||
pub date: String,
|
||||
pub amount: i64,
|
||||
pub category_id: Uuid,
|
||||
pub payment_method_id: Option<Uuid>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EntryChanges {
|
||||
pub date: Option<String>,
|
||||
pub amount: Option<i64>,
|
||||
pub category_id: Option<Uuid>,
|
||||
/// `Some(None)` → ödeme türünü kaldır (istekte boş metin)
|
||||
pub payment_method_id: Option<Option<Uuid>>,
|
||||
/// `Some("")` → açıklamayı kaldır
|
||||
pub description: Option<String>,
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
/// Liste filtresi: tarih aralığı (ikisi de dahil, isteğe bağlı)
|
||||
#[derive(Default)]
|
||||
pub struct EntryFilter {
|
||||
pub from: Option<String>,
|
||||
pub to: Option<String>,
|
||||
}
|
||||
|
||||
fn coll(db: &Database) -> mongodb::Collection<Entry> {
|
||||
db.collection(col::ENTRIES)
|
||||
}
|
||||
|
||||
/// Dönemin kayıtları: en yeni tarih başta, aynı gün içinde en son eklenen başta.
|
||||
pub async fn list(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
filter: EntryFilter,
|
||||
) -> AppResult<Vec<Entry>> {
|
||||
period::get(db, household_id, period_id).await?; // dönem bu haneye ait mi?
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
let mut range = Document::new();
|
||||
if let Some(from) = filter.from {
|
||||
range.insert("$gte", valid_date(&from)?);
|
||||
}
|
||||
if let Some(to) = filter.to {
|
||||
range.insert("$lte", valid_date(&to)?);
|
||||
}
|
||||
if !range.is_empty() {
|
||||
f.insert("date", range);
|
||||
}
|
||||
// UUID v7 zamana göre sıralı → `_id` azalan = en son eklenen önce
|
||||
let mut cursor = coll(db)
|
||||
.find(f)
|
||||
.sort(doc! { "date": -1, "_id": -1 })
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
out.push(cursor.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
period_id: Uuid,
|
||||
e: NewEntry,
|
||||
) -> AppResult<Entry> {
|
||||
let p = period::get_open(db, household_id, period_id).await?;
|
||||
let date = clean_date(&e.date, &p)?;
|
||||
let amount = clean_amount(e.amount)?;
|
||||
let group = category_group(db, household_id, e.category_id, None).await?;
|
||||
if let Some(pm) = e.payment_method_id {
|
||||
check_payment_method(db, household_id, pm, None).await?;
|
||||
}
|
||||
let description = clean_description(e.description.as_deref())?;
|
||||
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("period_id", period_id);
|
||||
d.insert("date", date);
|
||||
d.insert("amount", amount);
|
||||
d.insert("group", group.as_str());
|
||||
d.insert("category_id", e.category_id);
|
||||
d.insert(
|
||||
"payment_method_id",
|
||||
e.payment_method_id.map_or(Bson::Null, Bson::from),
|
||||
);
|
||||
d.insert("description", description.map_or(Bson::Null, Bson::String));
|
||||
d.insert("created_by", user_id);
|
||||
db.collection::<Document>(col::ENTRIES)
|
||||
.insert_one(d)
|
||||
.await?;
|
||||
coll(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("eklenen kayıt okunamadı".into()))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
ch: EntryChanges,
|
||||
) -> AppResult<Entry> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
let p = period::get_open(db, household_id, current.period_id).await?;
|
||||
if ch.version.is_some_and(|v| v != current.version) {
|
||||
return Err(stale());
|
||||
}
|
||||
|
||||
let mut set = doc! { "updated_at": now() };
|
||||
if let Some(date) = ch.date {
|
||||
set.insert("date", clean_date(&date, &p)?);
|
||||
}
|
||||
if let Some(amount) = ch.amount {
|
||||
set.insert("amount", clean_amount(amount)?);
|
||||
}
|
||||
if let Some(cid) = ch.category_id {
|
||||
let group = category_group(db, household_id, cid, Some(current.category_id)).await?;
|
||||
set.insert("category_id", cid);
|
||||
set.insert("group", group.as_str());
|
||||
}
|
||||
if let Some(pm) = ch.payment_method_id {
|
||||
if let Some(pm) = pm {
|
||||
check_payment_method(db, household_id, pm, current.payment_method_id).await?;
|
||||
}
|
||||
set.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
|
||||
}
|
||||
if let Some(desc) = ch.description {
|
||||
set.insert(
|
||||
"description",
|
||||
clean_description(Some(&desc))?.map_or(Bson::Null, Bson::String),
|
||||
);
|
||||
}
|
||||
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
f.insert("version", current.version);
|
||||
coll(db)
|
||||
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?
|
||||
.ok_or_else(stale)
|
||||
}
|
||||
|
||||
/// Soft delete. Silinen kayıt `restore` ile geri getirilebilir ("Geri al").
|
||||
pub async fn delete(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
period::get_open(db, household_id, current.period_id).await?;
|
||||
let ts = now();
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
coll(db)
|
||||
.update_one(
|
||||
f,
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Silinmiş kaydı geri getirir. Silinmemiş ya da başka hanenin kaydı → 404.
|
||||
pub async fn restore(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Entry> {
|
||||
let f = doc! { "_id": id, "household_id": household_id, "deleted_at": { "$ne": null } };
|
||||
let deleted = coll(db)
|
||||
.find_one(f.clone())
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
period::get_open(db, household_id, deleted.period_id).await?;
|
||||
coll(db)
|
||||
.find_one_and_update(
|
||||
f,
|
||||
doc! { "$set": { "deleted_at": null, "updated_at": now() }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
// ---------- yardımcılar ----------
|
||||
|
||||
async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Entry> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Kategori bu haneye ait mi, aktif mi (ya da kaydın zaten kullandığı kategori mi)? → grubu
|
||||
async fn category_group(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
category_id: Uuid,
|
||||
current: Option<Uuid>,
|
||||
) -> AppResult<Group> {
|
||||
let c = catalog::find(db, household_id, catalog::Catalog::Categories, category_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("Kategori bulunamadı".into()))?;
|
||||
if !c.is_active && current != Some(category_id) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Bu kategori pasif; başka bir kategori seçin".into(),
|
||||
));
|
||||
}
|
||||
c.group
|
||||
.ok_or_else(|| AppError::Internal("kategorinin grubu yok".into()))
|
||||
}
|
||||
|
||||
async fn check_payment_method(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
current: Option<Uuid>,
|
||||
) -> AppResult<()> {
|
||||
let m = catalog::find(db, household_id, catalog::Catalog::PaymentMethods, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("Ödeme türü bulunamadı".into()))?;
|
||||
if !m.is_active && current != Some(id) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Bu ödeme türü pasif; başka birini seçin".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_date(s: &str) -> AppResult<String> {
|
||||
let s = s.trim();
|
||||
parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
fn clean_date(s: &str, p: &Period) -> AppResult<String> {
|
||||
let s = valid_date(s)?;
|
||||
if s < p.start_date || s >= p.end_date {
|
||||
return Err(AppError::BadRequest("Tarih dönem içinde olmalı".into()));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn clean_amount(v: i64) -> AppResult<i64> {
|
||||
if v <= 0 {
|
||||
return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into()));
|
||||
}
|
||||
period::validate_amount(v)
|
||||
}
|
||||
|
||||
fn clean_description(d: Option<&str>) -> AppResult<Option<String>> {
|
||||
match d.map(str::trim) {
|
||||
None | Some("") => Ok(None),
|
||||
Some(s) if s.chars().count() > MAX_DESCRIPTION => Err(AppError::BadRequest(format!(
|
||||
"Açıklama en fazla {MAX_DESCRIPTION} karakter olabilir"
|
||||
))),
|
||||
Some(s) => Ok(Some(s.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn stale() -> AppError {
|
||||
AppError::Conflict("Kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Bir varlığın eldeki durumu: işlemlerden **ağırlıklı ortalama maliyet** yöntemiyle hesaplanır
|
||||
//! (saklanmaz). Saf fonksiyon; veritabanı yok.
|
||||
//!
|
||||
//! - Alım: miktar += q, maliyet += toplam tutar.
|
||||
//! - Satış: çıkan maliyet = maliyet × q / miktar (ortalama maliyetten);
|
||||
//! gerçekleşen kâr/zarar += satış tutarı − çıkan maliyet.
|
||||
//! - Hiçbir anda miktar eksiye düşemez (eldekinden fazla satılamaz).
|
||||
|
||||
use crate::models::asset::TxType;
|
||||
|
||||
/// Hesap için işlem özeti; **tarih sırasıyla** verilmeli (aynı gün: ekleniş sırası).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Move {
|
||||
pub tx_type: TxType,
|
||||
pub quantity_e6: i64,
|
||||
/// Kuruş
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Holding {
|
||||
pub quantity_e6: i64,
|
||||
/// Eldeki miktarın maliyeti (kuruş)
|
||||
pub cost: i64,
|
||||
/// Birim başına ortalama maliyet (TL × 1e6); elde yoksa `None`
|
||||
pub avg_cost_e6: Option<i64>,
|
||||
/// Satışlardan gerçekleşen kâr/zarar (kuruş)
|
||||
pub realized_pl: i64,
|
||||
}
|
||||
|
||||
/// Miktarın eksiye düştüğü işlemin sırası (0'dan)
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Oversold(pub usize);
|
||||
|
||||
/// a × b / c, en yakına yuvarlanmış (taşma olmasın diye i128)
|
||||
fn mul_div(a: i64, b: i64, c: i64) -> i64 {
|
||||
let (a, b, c) = (i128::from(a), i128::from(b), i128::from(c));
|
||||
let n = a * b;
|
||||
let q = (2 * n + c) / (2 * c); // pozitif sayılar için yarım yukarı
|
||||
q as i64
|
||||
}
|
||||
|
||||
pub fn holding(moves: &[Move]) -> Result<Holding, Oversold> {
|
||||
let mut h = Holding::default();
|
||||
for (i, m) in moves.iter().enumerate() {
|
||||
match m.tx_type {
|
||||
TxType::Buy => {
|
||||
h.quantity_e6 += m.quantity_e6;
|
||||
h.cost += m.total;
|
||||
}
|
||||
TxType::Sell => {
|
||||
if m.quantity_e6 > h.quantity_e6 {
|
||||
return Err(Oversold(i));
|
||||
}
|
||||
let out = if m.quantity_e6 == h.quantity_e6 {
|
||||
h.cost // tamamı satıldı: yuvarlama artığı kalmasın
|
||||
} else {
|
||||
mul_div(h.cost, m.quantity_e6, h.quantity_e6)
|
||||
};
|
||||
h.realized_pl += m.total - out;
|
||||
h.cost -= out;
|
||||
h.quantity_e6 -= m.quantity_e6;
|
||||
}
|
||||
}
|
||||
}
|
||||
// kuruş → TL×1e6 için ×10.000, birim e6 → ×1e6 ⇒ maliyet × 1e10 / miktar_e6
|
||||
h.avg_cost_e6 = (h.quantity_e6 > 0).then(|| mul_div(h.cost, 10_000_000_000, h.quantity_e6));
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
/// Birim fiyat verilmezse toplam ÷ miktar (TL × 1e6)
|
||||
pub fn unit_price_e6(total: i64, quantity_e6: i64) -> i64 {
|
||||
mul_div(total, 10_000_000_000, quantity_e6)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const fn buy(q: i64, t: i64) -> Move {
|
||||
Move {
|
||||
tx_type: TxType::Buy,
|
||||
quantity_e6: q,
|
||||
total: t,
|
||||
}
|
||||
}
|
||||
const fn sell(q: i64, t: i64) -> Move {
|
||||
Move {
|
||||
tx_type: TxType::Sell,
|
||||
quantity_e6: q,
|
||||
total: t,
|
||||
}
|
||||
}
|
||||
const GR: i64 = 1_000_000;
|
||||
|
||||
#[test]
|
||||
fn weighted_average_cost() {
|
||||
// 10 gr × 3.000 TL + 10 gr × 4.000 TL → ort. 3.500 TL/gr
|
||||
let h = holding(&[buy(10 * GR, 3_000_000), buy(10 * GR, 4_000_000)]).unwrap();
|
||||
assert_eq!(h.quantity_e6, 20 * GR);
|
||||
assert_eq!(h.cost, 7_000_000);
|
||||
assert_eq!(h.avg_cost_e6, Some(3_500_000_000));
|
||||
assert_eq!(h.realized_pl, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sell_uses_average_cost() {
|
||||
// 5 gr 4.500'den satıldı: çıkan maliyet 5 × 3.500 = 17.500 → kâr 5.000
|
||||
let h = holding(&[
|
||||
buy(10 * GR, 3_000_000),
|
||||
buy(10 * GR, 4_000_000),
|
||||
sell(5 * GR, 2_250_000),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(h.quantity_e6, 15 * GR);
|
||||
assert_eq!(h.cost, 5_250_000);
|
||||
assert_eq!(h.realized_pl, 500_000);
|
||||
assert_eq!(
|
||||
h.avg_cost_e6,
|
||||
Some(3_500_000_000),
|
||||
"satış ortalamayı değiştirmez"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selling_everything_clears_cost() {
|
||||
// 3 parça: 1/3 maliyetler yuvarlanır ama sonunda artık kalmaz
|
||||
let h = holding(&[
|
||||
buy(3 * GR, 1_000),
|
||||
sell(GR, 300),
|
||||
sell(GR, 300),
|
||||
sell(GR, 300),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
h,
|
||||
Holding {
|
||||
quantity_e6: 0,
|
||||
cost: 0,
|
||||
avg_cost_e6: None,
|
||||
realized_pl: -100
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannot_sell_more_than_held() {
|
||||
assert_eq!(
|
||||
holding(&[buy(GR, 100), sell(2 * GR, 300)]),
|
||||
Err(Oversold(1))
|
||||
);
|
||||
assert_eq!(
|
||||
holding(&[sell(GR, 100), buy(GR, 100)]),
|
||||
Err(Oversold(0)),
|
||||
"sıra önemli"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_price_from_total() {
|
||||
assert_eq!(unit_price_e6(1_625_000, 5 * GR), 3_250_000_000); // 16.250 TL / 5 gr
|
||||
assert_eq!(unit_price_e6(100, 3 * GR), 333_333); // 1 TL / 3 → 0,333333
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
|
||||
|
||||
pub mod account;
|
||||
pub mod asset;
|
||||
pub mod budget;
|
||||
pub mod calendar;
|
||||
pub mod catalog;
|
||||
pub mod closing;
|
||||
pub mod entry;
|
||||
pub mod holding;
|
||||
pub mod invite;
|
||||
pub mod money;
|
||||
pub mod note;
|
||||
pub mod period;
|
||||
pub mod plan;
|
||||
pub mod rate_sources;
|
||||
pub mod rates;
|
||||
pub mod reports;
|
||||
pub mod review;
|
||||
pub mod seed;
|
||||
pub mod stats;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Para hesapları (kuruş, `i64`). Float yok.
|
||||
|
||||
/// `total` kuruşu ağırlıklara orantılı böler; **toplam her zaman `total`'a eşittir**.
|
||||
///
|
||||
/// "En büyük kalan" yöntemi: herkes tam payını alır, artan kuruşlar
|
||||
/// küsuratı en büyük olana (eşitlikte öndekine) birer birer verilir.
|
||||
/// Ağırlıkların hepsi 0 ise hepsine 0 döner (ve `total` dağıtılamaz → çağıran kontrol eder).
|
||||
pub fn split_proportional(total: i64, weights: &[u64]) -> Vec<i64> {
|
||||
let sum: u128 = weights.iter().map(|&w| u128::from(w)).sum();
|
||||
if sum == 0 || total <= 0 {
|
||||
return vec![0; weights.len()];
|
||||
}
|
||||
let total_u = total as u128;
|
||||
let mut parts: Vec<i64> = Vec::with_capacity(weights.len());
|
||||
let mut remainders: Vec<(u128, usize)> = Vec::with_capacity(weights.len());
|
||||
for (i, &w) in weights.iter().enumerate() {
|
||||
let exact = total_u * u128::from(w);
|
||||
parts.push((exact / sum) as i64);
|
||||
remainders.push((exact % sum, i));
|
||||
}
|
||||
let mut left = total - parts.iter().sum::<i64>();
|
||||
// Büyük kalan önce; eşitlikte küçük indeks önce
|
||||
remainders.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||
for (_, i) in remainders {
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
parts[i] += 1;
|
||||
left -= 1;
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sums_exactly_and_is_proportional() {
|
||||
// 1.000,00 ₺ Aralık haftalarına (6,7,7,7,4 gün)
|
||||
let p = split_proportional(100_000, &[6, 7, 7, 7, 4]);
|
||||
assert_eq!(p.iter().sum::<i64>(), 100_000);
|
||||
assert_eq!(p, vec![19_355, 22_581, 22_581, 22_580, 12_903]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_percentages() {
|
||||
// Tam paylar 617.283,5 / 370.370,1 / 123.456,7 / 123.456,7 → artan 2 kuruş
|
||||
// küsuratı en büyük olan ikisine (0,7'ler) gider
|
||||
assert_eq!(
|
||||
split_proportional(1_234_567, &[50, 30, 10, 10]),
|
||||
vec![617_283, 370_370, 123_457, 123_457]
|
||||
);
|
||||
assert_eq!(split_proportional(1, &[50, 30, 10, 10]), vec![1, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_cases() {
|
||||
assert_eq!(split_proportional(0, &[1, 2]), vec![0, 0]);
|
||||
assert_eq!(split_proportional(-500, &[1, 2]), vec![0, 0]);
|
||||
assert_eq!(split_proportional(100, &[0, 0]), vec![0, 0]);
|
||||
assert_eq!(split_proportional(100, &[]), Vec::<i64>::new());
|
||||
assert_eq!(split_proportional(7, &[0, 3, 0]), vec![0, 7, 0]);
|
||||
// Çok büyük tutarlarda taşma yok
|
||||
let big = 10_000_000_000_000;
|
||||
assert_eq!(split_proportional(big, &[31, 31]).iter().sum::<i64>(), big);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Dönem notları (KAKEIBO_PROMPT §8.6): başlık + Markdown metin, `planning` ya da `review`.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
options::ReturnDocument,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
note::{Note, NoteContext},
|
||||
},
|
||||
services::period,
|
||||
};
|
||||
|
||||
pub const MAX_BODY: usize = 20_000;
|
||||
|
||||
fn coll(db: &Database) -> mongodb::Collection<Note> {
|
||||
db.collection(col::NOTES)
|
||||
}
|
||||
|
||||
/// Dönemin notları (isteğe bağlı sayfa filtresiyle), en yeni değişen başta.
|
||||
pub async fn list(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
context: Option<NoteContext>,
|
||||
) -> AppResult<Vec<Note>> {
|
||||
period::get(db, household_id, period_id).await?;
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
if let Some(c) = context {
|
||||
f.insert("context", c.as_str());
|
||||
}
|
||||
let mut cursor = coll(db).find(f).sort(doc! { "updated_at": -1 }).await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
out.push(cursor.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
period_id: Uuid,
|
||||
context: NoteContext,
|
||||
title: &str,
|
||||
body_md: &str,
|
||||
) -> AppResult<Note> {
|
||||
period::get_open(db, household_id, period_id).await?;
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("period_id", period_id);
|
||||
d.insert("context", context.as_str());
|
||||
d.insert("title", clean_title(title)?);
|
||||
d.insert("body_md", clean_body(body_md)?);
|
||||
d.insert("created_by", user_id);
|
||||
db.collection::<Document>(col::NOTES).insert_one(d).await?;
|
||||
coll(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("eklenen not okunamadı".into()))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
title: Option<&str>,
|
||||
body_md: Option<&str>,
|
||||
version: Option<i32>,
|
||||
) -> AppResult<Note> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
period::get_open(db, household_id, current.period_id).await?;
|
||||
if version.is_some_and(|v| v != current.version) {
|
||||
return Err(stale());
|
||||
}
|
||||
let mut set = doc! { "updated_at": now() };
|
||||
if let Some(t) = title {
|
||||
set.insert("title", clean_title(t)?);
|
||||
}
|
||||
if let Some(b) = body_md {
|
||||
set.insert("body_md", clean_body(b)?);
|
||||
}
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
f.insert("version", current.version);
|
||||
coll(db)
|
||||
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?
|
||||
.ok_or_else(stale)
|
||||
}
|
||||
|
||||
pub async fn delete(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
period::get_open(db, household_id, current.period_id).await?;
|
||||
let ts = now();
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
db.collection::<Document>(col::NOTES)
|
||||
.update_one(
|
||||
f,
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Note> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
fn clean_title(t: &str) -> AppResult<String> {
|
||||
let t = t.trim();
|
||||
match t.chars().count() {
|
||||
0 => Err(AppError::BadRequest("Not başlığı boş olamaz".into())),
|
||||
1..=120 => Ok(t.to_string()),
|
||||
_ => Err(AppError::BadRequest(
|
||||
"Not başlığı en fazla 120 karakter olabilir".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_body(b: &str) -> AppResult<String> {
|
||||
if b.chars().count() > MAX_BODY {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Not metni en fazla {MAX_BODY} karakter olabilir"
|
||||
)));
|
||||
}
|
||||
Ok(b.to_string())
|
||||
}
|
||||
|
||||
fn stale() -> AppError {
|
||||
AppError::Conflict("Not başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Dönem iş mantığı (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.4).
|
||||
//!
|
||||
//! Kurallar:
|
||||
//! - Hanede aynı anda en fazla **bir açık** dönem (servis kontrolü + kısmi benzersiz indeks).
|
||||
//! - Dönemler çakışmaz. Bitiş tarihi hariçtir.
|
||||
//! - Kapalı dönem salt okunur (yeniden açma: Faz 6, yalnızca sahip).
|
||||
//! - Tarih aralığı daraltılırken dışarıda harcama kalıyorsa engellenir.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
options::ReturnDocument,
|
||||
};
|
||||
use time::Date;
|
||||
|
||||
use crate::{
|
||||
db::{col, is_duplicate_key},
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
period::{Period, PeriodStatus},
|
||||
},
|
||||
services::calendar::{days_between, default_period_name, format_date, parse_date},
|
||||
};
|
||||
|
||||
/// En uzun dönem (gün). Aylık defter için ~2 ay pay bırakır.
|
||||
pub const MAX_DAYS: i64 = 62;
|
||||
/// Birikim hedefi üst sınırı (kuruş) — yazım hatalarını yakalamak için: 100 milyar ₺
|
||||
pub const MAX_AMOUNT: i64 = 10_000_000_000_000;
|
||||
|
||||
pub struct NewPeriod {
|
||||
pub start_date: String,
|
||||
pub end_date: String,
|
||||
pub name: Option<String>,
|
||||
pub savings_goal: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PeriodChanges {
|
||||
pub name: Option<String>,
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
pub savings_goal: Option<i64>,
|
||||
/// `Some("")` → metni kaldır
|
||||
pub reflection_md: Option<String>,
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
/// "Nasıl daha iyi yapabilirim?" üst sınırı (karakter) — notlarla aynı
|
||||
pub const MAX_REFLECTION: usize = 20_000;
|
||||
|
||||
fn coll(db: &Database) -> mongodb::Collection<Period> {
|
||||
db.collection(col::PERIODS)
|
||||
}
|
||||
|
||||
pub async fn current(db: &Database, household_id: Uuid) -> AppResult<Option<Period>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("status", PeriodStatus::Open.as_str());
|
||||
Ok(coll(db).find_one(f).await?)
|
||||
}
|
||||
|
||||
/// En yeni dönem başta.
|
||||
pub async fn list(db: &Database, household_id: Uuid) -> AppResult<Vec<Period>> {
|
||||
let mut cursor = coll(db)
|
||||
.find(scope(household_id))
|
||||
.sort(doc! { "start_date": -1 })
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
out.push(cursor.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Yazma işlemleri için: dönem bu haneye ait ve **açık** olmalı.
|
||||
/// Plan kalemleri, notlar ve (Faz 5) harcamalar bunu kullanır.
|
||||
pub async fn get_open(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
|
||||
let p = get(db, household_id, id).await?;
|
||||
if !p.status.is_writable() {
|
||||
return Err(closed());
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
pub fn closed() -> AppError {
|
||||
AppError::Conflict(
|
||||
"Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResult<Period> {
|
||||
let (start, end) = validate_range(&p.start_date, &p.end_date)?;
|
||||
let savings_goal = validate_amount(p.savings_goal.unwrap_or(0))?;
|
||||
let name = clean_name(p.name.as_deref(), start, end)?;
|
||||
|
||||
if let Some(open) = current(db, household_id).await? {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Zaten açık bir dönem var ({}). Yeni dönem açmadan önce onu kapatın.",
|
||||
open.name
|
||||
)));
|
||||
}
|
||||
ensure_no_overlap(db, household_id, start, end, None).await?;
|
||||
|
||||
// Bir önceki dönem (devir ve "önceki aydan kopyala" için)
|
||||
let mut prev_f = scope(household_id);
|
||||
prev_f.insert("start_date", doc! { "$lt": format_date(start) });
|
||||
let previous = coll(db)
|
||||
.find_one(prev_f)
|
||||
.sort(doc! { "start_date": -1 })
|
||||
.await?;
|
||||
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("name", name);
|
||||
d.insert("start_date", format_date(start));
|
||||
d.insert("end_date", format_date(end));
|
||||
d.insert("status", PeriodStatus::Open.as_str());
|
||||
d.insert("savings_goal", savings_goal);
|
||||
d.insert("reflection_md", Bson::Null);
|
||||
d.insert("to_savings", Bson::Null);
|
||||
d.insert(
|
||||
"previous_period_id",
|
||||
previous.as_ref().map_or(Bson::Null, |p| p.id.into()),
|
||||
);
|
||||
d.insert("closed_at", Bson::Null);
|
||||
d.insert("closed_by", Bson::Null);
|
||||
d.insert("reopened_at", Bson::Null);
|
||||
|
||||
// Tek transaction: dönem + önceki dönemden bekleyen devrin bu döneme bağlanması
|
||||
let mut session = db.client().start_session().await?;
|
||||
session.start_transaction().await?;
|
||||
let result = async {
|
||||
db.collection::<Document>(col::PERIODS)
|
||||
.insert_one(d)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
if let Some(prev) = &previous {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("from_period_id", prev.id);
|
||||
f.insert("to_period_id", Bson::Null);
|
||||
db.collection::<Document>(col::CARRYOVERS)
|
||||
.update_many(
|
||||
f,
|
||||
doc! { "$set": { "to_period_id": id.clone(), "updated_at": now() }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
}
|
||||
Ok::<_, mongodb::error::Error>(())
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => session.commit_transaction().await?,
|
||||
Err(e) => {
|
||||
let _ = session.abort_transaction().await;
|
||||
// Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller
|
||||
if is_duplicate_key(&e) {
|
||||
return Err(AppError::Conflict(
|
||||
"Zaten açık bir dönem var. Sayfayı yenileyin.".into(),
|
||||
));
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
coll(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("açılan dönem okunamadı".into()))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
ch: PeriodChanges,
|
||||
) -> AppResult<Period> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
if !current.status.is_writable() {
|
||||
return Err(closed());
|
||||
}
|
||||
if ch.version.is_some_and(|v| v != current.version) {
|
||||
return Err(stale());
|
||||
}
|
||||
|
||||
let mut set = doc! { "updated_at": now() };
|
||||
|
||||
let start_s = ch.start_date.as_deref().unwrap_or(¤t.start_date);
|
||||
let end_s = ch.end_date.as_deref().unwrap_or(¤t.end_date);
|
||||
let (start, end) = validate_range(start_s, end_s)?;
|
||||
let dates_changed =
|
||||
format_date(start) != current.start_date || format_date(end) != current.end_date;
|
||||
if dates_changed {
|
||||
ensure_no_overlap(db, household_id, start, end, Some(id)).await?;
|
||||
ensure_entries_inside(db, household_id, id, start, end).await?;
|
||||
set.insert("start_date", format_date(start));
|
||||
set.insert("end_date", format_date(end));
|
||||
}
|
||||
|
||||
if let Some(name) = ch.name {
|
||||
set.insert("name", clean_name(Some(&name), start, end)?);
|
||||
}
|
||||
if let Some(goal) = ch.savings_goal {
|
||||
set.insert("savings_goal", validate_amount(goal)?);
|
||||
}
|
||||
if let Some(text) = ch.reflection_md {
|
||||
set.insert("reflection_md", clean_reflection(&text)?);
|
||||
}
|
||||
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
f.insert("version", current.version);
|
||||
coll(db)
|
||||
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?
|
||||
.ok_or_else(stale)
|
||||
}
|
||||
|
||||
// ---------- doğrulamalar ----------
|
||||
|
||||
fn clean_reflection(text: &str) -> AppResult<Bson> {
|
||||
let t = text.trim();
|
||||
if t.is_empty() {
|
||||
return Ok(Bson::Null);
|
||||
}
|
||||
if t.chars().count() > MAX_REFLECTION {
|
||||
return Err(AppError::BadRequest(
|
||||
"Metin en fazla 20.000 karakter olabilir".into(),
|
||||
));
|
||||
}
|
||||
Ok(Bson::String(t.to_string()))
|
||||
}
|
||||
|
||||
fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> {
|
||||
let bad = |m: &str| AppError::BadRequest(m.into());
|
||||
let start = parse_date(start).ok_or_else(|| bad("Başlangıç tarihi geçersiz"))?;
|
||||
let end = parse_date(end).ok_or_else(|| bad("Bitiş tarihi geçersiz"))?;
|
||||
let days = days_between(start, end);
|
||||
if days < 1 {
|
||||
return Err(bad("Bitiş tarihi başlangıçtan sonra olmalı"));
|
||||
}
|
||||
if days > MAX_DAYS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Dönem en fazla {MAX_DAYS} gün olabilir"
|
||||
)));
|
||||
}
|
||||
Ok((start, end))
|
||||
}
|
||||
|
||||
pub fn validate_amount(v: i64) -> AppResult<i64> {
|
||||
if !(0..=MAX_AMOUNT).contains(&v) {
|
||||
return Err(AppError::BadRequest("Tutar geçersiz".into()));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn clean_name(name: Option<&str>, start: Date, end: Date) -> AppResult<String> {
|
||||
match name.map(str::trim) {
|
||||
None | Some("") => Ok(default_period_name(start, end)),
|
||||
Some(n) if n.chars().count() > 60 => Err(AppError::BadRequest(
|
||||
"Dönem adı en fazla 60 karakter olabilir".into(),
|
||||
)),
|
||||
Some(n) => Ok(n.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Başka bir dönemle çakışma: `mevcut.başlangıç < yeni.bitiş && mevcut.bitiş > yeni.başlangıç`
|
||||
/// (`YYYY-MM-DD` metinleri sözlük sırasıyla karşılaştırılabilir).
|
||||
async fn ensure_no_overlap(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
start: Date,
|
||||
end: Date,
|
||||
exclude: Option<Uuid>,
|
||||
) -> AppResult<()> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("start_date", doc! { "$lt": format_date(end) });
|
||||
f.insert("end_date", doc! { "$gt": format_date(start) });
|
||||
if let Some(id) = exclude {
|
||||
f.insert("_id", doc! { "$ne": id });
|
||||
}
|
||||
if let Some(other) = coll(db).find_one(f).await? {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Bu tarihler \"{}\" dönemiyle çakışıyor",
|
||||
other.name
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dönem daraltılırken dışarıda harcama kalmasın.
|
||||
async fn ensure_entries_inside(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
start: Date,
|
||||
end: Date,
|
||||
) -> AppResult<()> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
f.insert(
|
||||
"$or",
|
||||
vec![
|
||||
doc! { "date": { "$lt": format_date(start) } },
|
||||
doc! { "date": { "$gte": format_date(end) } },
|
||||
],
|
||||
);
|
||||
let outside = db
|
||||
.collection::<Document>(col::ENTRIES)
|
||||
.count_documents(f)
|
||||
.await?;
|
||||
if outside > 0 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Yeni tarih aralığının dışında kalan {outside} harcama var. Önce onları taşıyın ya da silin."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stale() -> AppError {
|
||||
AppError::Conflict("Dönem başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn range_rules() {
|
||||
assert!(validate_range("2026-12-01", "2027-01-01").is_ok());
|
||||
assert!(
|
||||
validate_range("2026-12-01", "2026-12-01").is_err(),
|
||||
"boş aralık"
|
||||
);
|
||||
assert!(validate_range("2026-12-10", "2026-12-01").is_err(), "ters");
|
||||
assert!(
|
||||
validate_range("2026-12-01", "2027-02-02").is_err(),
|
||||
"63 gün"
|
||||
);
|
||||
assert!(validate_range("2026-12-01", "2027-02-01").is_ok(), "62 gün");
|
||||
assert!(validate_range("01.12.2026", "2027-01-01").is_err(), "biçim");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amount_rules() {
|
||||
assert!(validate_amount(0).is_ok());
|
||||
assert!(validate_amount(-1).is_err());
|
||||
assert!(validate_amount(MAX_AMOUNT + 1).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Sabit gelir / gider kalemleri (KAKEIBO_PROMPT §8.3).
|
||||
//!
|
||||
//! - Tutar kuruş, > 0. Tarih isteğe bağlı; verilirse dönem içinde olmalı.
|
||||
//! - Kapalı dönemde yazma yok.
|
||||
//! - "Önceki dönemden kopyala": kalemler göreli günüyle taşınır (5 Ara → 5 Oca);
|
||||
//! aynı tür + ad zaten varsa atlanır (iki kez basınca kopya oluşmaz).
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
options::ReturnDocument,
|
||||
};
|
||||
use time::Duration;
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
period::Period,
|
||||
plan_item::{PlanItem, PlanKind},
|
||||
},
|
||||
services::{
|
||||
calendar::{format_date, parse_date},
|
||||
period,
|
||||
},
|
||||
};
|
||||
|
||||
pub struct NewItem {
|
||||
pub kind: PlanKind,
|
||||
pub name: String,
|
||||
pub amount: i64,
|
||||
pub date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ItemChanges {
|
||||
pub name: Option<String>,
|
||||
pub amount: Option<i64>,
|
||||
/// `Some("")` → tarihi kaldır
|
||||
pub date: Option<String>,
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
fn coll(db: &Database) -> mongodb::Collection<PlanItem> {
|
||||
db.collection(col::PLAN_ITEMS)
|
||||
}
|
||||
|
||||
/// Dönemin kalemleri: önce gelirler, sonra giderler; her biri sıralı.
|
||||
pub async fn list(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Vec<PlanItem>> {
|
||||
period::get(db, household_id, period_id).await?; // dönem bu haneye ait mi?
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
let mut cursor = coll(db)
|
||||
.find(f)
|
||||
.sort(doc! { "kind": -1, "sort_order": 1 }) // "income" > "expense" → gelir önce
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
out.push(cursor.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
item: NewItem,
|
||||
) -> AppResult<PlanItem> {
|
||||
let p = period::get_open(db, household_id, period_id).await?;
|
||||
let name = clean_name(&item.name)?;
|
||||
let amount = clean_amount(item.amount)?;
|
||||
let date = clean_date(item.date.as_deref(), &p)?;
|
||||
let sort_order = next_sort_order(db, household_id, period_id, item.kind).await?;
|
||||
insert(
|
||||
db,
|
||||
household_id,
|
||||
period_id,
|
||||
item.kind,
|
||||
name,
|
||||
amount,
|
||||
date,
|
||||
sort_order,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
ch: ItemChanges,
|
||||
) -> AppResult<PlanItem> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
let p = period::get_open(db, household_id, current.period_id).await?;
|
||||
if ch.version.is_some_and(|v| v != current.version) {
|
||||
return Err(stale());
|
||||
}
|
||||
|
||||
let mut set = doc! { "updated_at": now() };
|
||||
if let Some(name) = ch.name {
|
||||
set.insert("name", clean_name(&name)?);
|
||||
}
|
||||
if let Some(amount) = ch.amount {
|
||||
set.insert("amount", clean_amount(amount)?);
|
||||
}
|
||||
if let Some(date) = ch.date {
|
||||
set.insert(
|
||||
"date",
|
||||
clean_date(Some(&date), &p)?.map_or(Bson::Null, Bson::String),
|
||||
);
|
||||
}
|
||||
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
f.insert("version", current.version);
|
||||
coll(db)
|
||||
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?
|
||||
.ok_or_else(stale)
|
||||
}
|
||||
|
||||
/// Soft delete (geçmiş/eşitleme için kayıt kalır).
|
||||
pub async fn delete(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
period::get_open(db, household_id, current.period_id).await?;
|
||||
let ts = now();
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
db.collection::<Document>(col::PLAN_ITEMS)
|
||||
.update_one(
|
||||
f,
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Önceki dönemin kalemlerini bu döneme kopyalar. Eklenen kalem sayısını döner.
|
||||
pub async fn copy_previous(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<usize> {
|
||||
let target = period::get_open(db, household_id, period_id).await?;
|
||||
|
||||
// Bir önceki dönem: başlangıcı bu dönemden önce olan en yeni dönem
|
||||
let mut pf = scope(household_id);
|
||||
pf.insert("start_date", doc! { "$lt": &target.start_date });
|
||||
let source = db
|
||||
.collection::<Period>(col::PERIODS)
|
||||
.find_one(pf)
|
||||
.sort(doc! { "start_date": -1 })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("Kopyalanacak önceki dönem yok".into()))?;
|
||||
|
||||
let existing = list(db, household_id, period_id).await?;
|
||||
let src_items = list(db, household_id, source.id).await?;
|
||||
|
||||
let src_start = parse_date(&source.start_date).ok_or_else(bad_period)?;
|
||||
let dst_start = parse_date(&target.start_date).ok_or_else(bad_period)?;
|
||||
let dst_last = parse_date(&target.end_date).ok_or_else(bad_period)? - Duration::days(1);
|
||||
|
||||
let mut added = 0;
|
||||
for item in src_items {
|
||||
let duplicate = existing
|
||||
.iter()
|
||||
.any(|e| e.kind == item.kind && e.name.to_lowercase() == item.name.to_lowercase());
|
||||
if duplicate {
|
||||
continue;
|
||||
}
|
||||
// Göreli gün korunur, dönem dışına taşarsa son güne çekilir
|
||||
let date = item.date.as_deref().and_then(parse_date).map(|d| {
|
||||
let shifted = dst_start + (d - src_start);
|
||||
format_date(shifted.clamp(dst_start, dst_last))
|
||||
});
|
||||
let sort_order = next_sort_order(db, household_id, period_id, item.kind).await?;
|
||||
insert(
|
||||
db,
|
||||
household_id,
|
||||
period_id,
|
||||
item.kind,
|
||||
item.name,
|
||||
item.amount,
|
||||
date,
|
||||
sort_order,
|
||||
Some(item.id),
|
||||
)
|
||||
.await?;
|
||||
added += 1;
|
||||
}
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
// ---------- yardımcılar ----------
|
||||
|
||||
async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<PlanItem> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn insert(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
kind: PlanKind,
|
||||
name: String,
|
||||
amount: i64,
|
||||
date: Option<String>,
|
||||
sort_order: i32,
|
||||
copied_from: Option<Uuid>,
|
||||
) -> AppResult<PlanItem> {
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("period_id", period_id);
|
||||
d.insert("kind", kind.as_str());
|
||||
d.insert("name", name);
|
||||
d.insert("amount", amount);
|
||||
d.insert("date", date.map_or(Bson::Null, Bson::String));
|
||||
d.insert("sort_order", sort_order);
|
||||
d.insert("copied_from_id", copied_from.map_or(Bson::Null, Bson::from));
|
||||
db.collection::<Document>(col::PLAN_ITEMS)
|
||||
.insert_one(d)
|
||||
.await?;
|
||||
coll(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("eklenen kalem okunamadı".into()))
|
||||
}
|
||||
|
||||
async fn next_sort_order(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
kind: PlanKind,
|
||||
) -> AppResult<i32> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
f.insert("kind", kind.as_str());
|
||||
let last = coll(db).find_one(f).sort(doc! { "sort_order": -1 }).await?;
|
||||
Ok(last.map_or(10, |i| i.sort_order + 10))
|
||||
}
|
||||
|
||||
fn clean_name(name: &str) -> AppResult<String> {
|
||||
let n = name.trim();
|
||||
match n.chars().count() {
|
||||
0 => Err(AppError::BadRequest("Kalem adı boş olamaz".into())),
|
||||
1..=60 => Ok(n.to_string()),
|
||||
_ => Err(AppError::BadRequest(
|
||||
"Kalem adı en fazla 60 karakter olabilir".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_amount(v: i64) -> AppResult<i64> {
|
||||
if v <= 0 {
|
||||
return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into()));
|
||||
}
|
||||
period::validate_amount(v)
|
||||
}
|
||||
|
||||
/// Boş → `None`; doluysa geçerli ve dönem içinde olmalı.
|
||||
fn clean_date(date: Option<&str>, p: &Period) -> AppResult<Option<String>> {
|
||||
match date.map(str::trim) {
|
||||
None | Some("") => Ok(None),
|
||||
Some(s) => {
|
||||
parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
|
||||
if s < p.start_date.as_str() || s >= p.end_date.as_str() {
|
||||
return Err(AppError::BadRequest("Tarih dönem içinde olmalı".into()));
|
||||
}
|
||||
Ok(Some(s.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stale() -> AppError {
|
||||
AppError::Conflict("Kalem başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
||||
}
|
||||
|
||||
fn bad_period() -> AppError {
|
||||
AppError::Internal("dönem tarihi okunamadı".into())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Raporlar (KAKEIBO_PROMPT §8.8). Harcama kırılımı tek aggregation (`$facet`) ile:
|
||||
//! grup, **tüm** kategoriler, ödeme türü, ay. Saklanmaz; her istekte hesaplanır.
|
||||
//!
|
||||
//! Filtre: dönem **ya da** tarih aralığı (ikisi de dahil), grup, kategori, ödeme türü
|
||||
//! (`Some(None)` → ödeme türü girilmemiş kayıtlar). Silinen kayıtlar sayılmaz.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{common::scope, group::Group},
|
||||
services::{calendar::parse_date, period},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Filter {
|
||||
pub period_id: Option<Uuid>,
|
||||
pub from: Option<String>,
|
||||
pub to: Option<String>,
|
||||
pub group: Option<Group>,
|
||||
pub category_id: Option<Uuid>,
|
||||
/// `Some(None)` → ödeme türü boş olanlar
|
||||
pub payment_method: Option<Option<Uuid>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Row<K> {
|
||||
#[serde(rename = "_id")]
|
||||
pub key: K,
|
||||
pub total: i64,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CategoryKey {
|
||||
pub category_id: Uuid,
|
||||
pub group: Group,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Facets {
|
||||
totals: Vec<Row<Bson>>,
|
||||
groups: Vec<Row<Group>>,
|
||||
categories: Vec<Row<CategoryKey>>,
|
||||
methods: Vec<Row<Option<Uuid>>>,
|
||||
months: Vec<Row<String>>,
|
||||
}
|
||||
|
||||
pub struct Breakdown {
|
||||
pub total: i64,
|
||||
pub count: i64,
|
||||
/// `Group::ALL` sırasıyla, harcama olmayan grup 0
|
||||
pub groups: Vec<Row<Group>>,
|
||||
/// Çoktan aza (eşitlikte kategori kimliği)
|
||||
pub categories: Vec<Row<CategoryKey>>,
|
||||
/// Çoktan aza; `None` = ödeme türü girilmemiş
|
||||
pub methods: Vec<Row<Option<Uuid>>>,
|
||||
/// `"YYYY-MM"`, eskiden yeniye
|
||||
pub months: Vec<Row<String>>,
|
||||
}
|
||||
|
||||
pub async fn breakdown(db: &Database, household_id: Uuid, f: Filter) -> AppResult<Breakdown> {
|
||||
let mut m = scope(household_id);
|
||||
if let Some(pid) = f.period_id {
|
||||
period::get(db, household_id, pid).await?; // bu haneye ait mi?
|
||||
m.insert("period_id", pid);
|
||||
}
|
||||
let mut range = Document::new();
|
||||
for (op, v) in [("$gte", &f.from), ("$lte", &f.to)] {
|
||||
if let Some(d) = v {
|
||||
let d = d.trim();
|
||||
parse_date(d).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
|
||||
range.insert(op, d);
|
||||
}
|
||||
}
|
||||
if let (Some(a), Some(b)) = (&f.from, &f.to)
|
||||
&& a.trim() > b.trim()
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Başlangıç tarihi bitişten sonra olamaz".into(),
|
||||
));
|
||||
}
|
||||
if !range.is_empty() {
|
||||
m.insert("date", range);
|
||||
}
|
||||
if let Some(g) = f.group {
|
||||
m.insert("group", g.as_str());
|
||||
}
|
||||
if let Some(c) = f.category_id {
|
||||
m.insert("category_id", c);
|
||||
}
|
||||
if let Some(pm) = f.payment_method {
|
||||
m.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
|
||||
}
|
||||
|
||||
let sum = || doc! { "total": { "$sum": "$amount" }, "count": { "$sum": Bson::Int64(1) } };
|
||||
let group_by = |key: Bson| {
|
||||
let mut g = doc! { "_id": key };
|
||||
g.extend(sum());
|
||||
doc! { "$group": g }
|
||||
};
|
||||
let pipeline = vec![
|
||||
doc! { "$match": m },
|
||||
doc! { "$facet": {
|
||||
"totals": [ group_by(Bson::Null) ],
|
||||
"groups": [ group_by(Bson::String("$group".into())) ],
|
||||
"categories": [
|
||||
group_by(Bson::Document(doc! { "category_id": "$category_id", "group": "$group" })),
|
||||
{ "$sort": { "total": -1, "_id.category_id": 1 } },
|
||||
],
|
||||
"methods": [
|
||||
// Alanı hiç olmayan eski kayıtlar da "girilmemiş" sayılsın
|
||||
group_by(Bson::Document(doc! { "$ifNull": ["$payment_method_id", null] })),
|
||||
{ "$sort": { "total": -1, "_id": 1 } },
|
||||
],
|
||||
"months": [
|
||||
group_by(Bson::Document(doc! { "$substrBytes": ["$date", 0, 7] })),
|
||||
{ "$sort": { "_id": 1 } },
|
||||
],
|
||||
} },
|
||||
];
|
||||
let mut cursor = db
|
||||
.collection::<Document>(col::ENTRIES)
|
||||
.aggregate(pipeline)
|
||||
.with_type::<Facets>()
|
||||
.await?;
|
||||
let f = if cursor.advance().await? {
|
||||
cursor.deserialize_current()?
|
||||
} else {
|
||||
return Err(AppError::Internal("rapor sorgusu boş döndü".into()));
|
||||
};
|
||||
|
||||
let (total, count) = f.totals.first().map_or((0, 0), |r| (r.total, r.count));
|
||||
let groups = Group::ALL
|
||||
.into_iter()
|
||||
.map(|g| {
|
||||
f.groups.iter().find(|r| r.key == g).map_or(
|
||||
Row {
|
||||
key: g,
|
||||
total: 0,
|
||||
count: 0,
|
||||
},
|
||||
|r| Row {
|
||||
key: g,
|
||||
total: r.total,
|
||||
count: r.count,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok(Breakdown {
|
||||
total,
|
||||
count,
|
||||
groups,
|
||||
categories: f.categories,
|
||||
methods: f.methods,
|
||||
months: f.months,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Ay sonu değerlendirmesi (KAKEIBO_PROMPT §8.5, DATA_MODEL.md §5).
|
||||
//!
|
||||
//! Kakeibo'nun dört sorusu — hepsi sorgu anında hesaplanır, saklanmaz:
|
||||
//! 1. Ne kadar param vardı? `available` = gelir − sabit gider − devreden borç + devreden artı
|
||||
//! 2. Ne kadar biriktirmek istedim? `savings_goal`
|
||||
//! 3. Ne kadar harcadım? `spent_total`
|
||||
//! 4. Ne kadar biriktirdim? `realized_savings` = available − spent_total
|
||||
//!
|
||||
//! Ek olarak: grup bazlı planlanan/gerçekleşen, kategori bazlı harcama, haftalık dağılım.
|
||||
//! Kapalı dönemde de okunabilir.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Document, Uuid, doc},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::AppResult,
|
||||
models::{common::scope, group::Group},
|
||||
services::budget::{self, GroupBudget, Summary},
|
||||
};
|
||||
|
||||
pub struct Review {
|
||||
pub summary: Summary,
|
||||
/// 1. soru
|
||||
pub available: i64,
|
||||
/// 4. soru (negatifse açık var → kapanışta borç olarak devreder)
|
||||
pub realized_savings: i64,
|
||||
/// Grup: aylık bütçe ve gerçekleşen (`Group::ALL` sırasıyla)
|
||||
pub groups: Vec<GroupBudget>,
|
||||
/// Harcama olan kategoriler, çoktan aza
|
||||
pub categories: Vec<CategorySpent>,
|
||||
}
|
||||
|
||||
pub struct CategorySpent {
|
||||
pub category_id: Uuid,
|
||||
pub group: Group,
|
||||
pub spent: i64,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
pub async fn review(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Review> {
|
||||
// `summary` dönemin bu haneye ait olduğunu da denetler (yoksa 404)
|
||||
let summary = budget::summary(db, household_id, period_id).await?;
|
||||
let groups = budget::view(db, household_id, period_id).await?;
|
||||
let categories = spent_by_category(db, household_id, period_id).await?;
|
||||
let available = summary.income_total - summary.fixed_expense_total - summary.carryover_debt
|
||||
+ summary.carryover_surplus;
|
||||
Ok(Review {
|
||||
realized_savings: available - summary.spent_total,
|
||||
available,
|
||||
summary,
|
||||
groups,
|
||||
categories,
|
||||
})
|
||||
}
|
||||
|
||||
async fn spent_by_category(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
) -> AppResult<Vec<CategorySpent>> {
|
||||
#[derive(Deserialize)]
|
||||
struct Key {
|
||||
category_id: Uuid,
|
||||
group: Group,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Row {
|
||||
#[serde(rename = "_id")]
|
||||
key: Key,
|
||||
total: i64,
|
||||
count: i64,
|
||||
}
|
||||
let mut m = scope(household_id);
|
||||
m.insert("period_id", period_id);
|
||||
let pipeline = vec![
|
||||
doc! { "$match": m },
|
||||
doc! { "$group": {
|
||||
"_id": { "category_id": "$category_id", "group": "$group" },
|
||||
"total": { "$sum": "$amount" },
|
||||
"count": { "$sum": 1_i64 },
|
||||
} },
|
||||
// Eşit tutarda sıra her seferinde aynı olsun
|
||||
doc! { "$sort": { "total": -1, "_id.category_id": 1 } },
|
||||
];
|
||||
let mut cursor = db
|
||||
.collection::<Document>(col::ENTRIES)
|
||||
.aggregate(pipeline)
|
||||
.with_type::<Row>()
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
let r = cursor.deserialize_current()?;
|
||||
out.push(CategorySpent {
|
||||
category_id: r.key.category_id,
|
||||
group: r.key.group,
|
||||
spent: r.total,
|
||||
count: r.count,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Yeni hane için örnek alt kategoriler ve ödeme türleri (DATA_MODEL.md §3.5, §3.6).
|
||||
//! Yeni hane için örnek alt kategoriler, ödeme türleri ve varsayılan varlıklar
|
||||
//! (DATA_MODEL.md §3.5, §3.6, §3.12).
|
||||
|
||||
use mongodb::bson::{Document, Uuid};
|
||||
|
||||
@@ -29,6 +30,35 @@ const PAYMENT_METHODS: &[(&str, &str)] = &[
|
||||
("Havale/EFT", "🔁"),
|
||||
];
|
||||
|
||||
/// (tür, kod, ad, birim) — kurlar Faz 8 Adım 2'de bu kodlarla eşleşir
|
||||
const ASSETS: &[(&str, &str, &str, &str)] = &[
|
||||
("cash", "TRY", "Türk Lirası", "₺"),
|
||||
("gold", "XAU_GRAM", "Gram Altın", "gr"),
|
||||
("gold", "XAU_CEYREK", "Çeyrek Altın", "adet"),
|
||||
("gold", "XAU_YARIM", "Yarım Altın", "adet"),
|
||||
("gold", "XAU_TAM", "Tam Altın", "adet"),
|
||||
("gold", "XAU_ONS", "Ons Altın", "ons"),
|
||||
("fx", "USD", "ABD Doları", "$"),
|
||||
("fx", "EUR", "Euro", "€"),
|
||||
];
|
||||
|
||||
pub fn assets(household_id: Uuid) -> Vec<Document> {
|
||||
ASSETS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (kind, code, name, unit))| {
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("kind", *kind);
|
||||
d.insert("code", *code);
|
||||
d.insert("name", *name);
|
||||
d.insert("unit_label", *unit);
|
||||
d.insert("sort_order", (i as i32 + 1) * 10);
|
||||
d.insert("is_active", true);
|
||||
d
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn categories(household_id: Uuid) -> Vec<Document> {
|
||||
CATEGORIES
|
||||
.iter()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Panel (Dashboard) verileri (KAKEIBO_PROMPT §8.2). Hiçbiri saklanmaz; her istekte toplanır.
|
||||
//!
|
||||
//! - `daily`: dönemin her günü için 4 grubun harcaması (harcama olmayan gün 0).
|
||||
//! Günlük/haftalık ve kümülatif seriler istemcide bundan türetilir.
|
||||
//! - `periods`: son N dönemin harcanabilir/harcanan tutarı, grup kırılımıyla (aylık grafik) ve
|
||||
//! **toplam birikim** = kapanışlarda birikime aktarılanların toplamı
|
||||
//! (varlık değerleri Faz 8'de bunun yerini alacak). Yeniden açılan dönemin son
|
||||
//! kapanıştaki tutarı, yeniden kapatılana kadar sayılır.
|
||||
|
||||
use mongodb::{Database, bson::Uuid};
|
||||
|
||||
use crate::{
|
||||
error::AppResult,
|
||||
models::period::Period,
|
||||
services::{
|
||||
budget::{self, GroupAmounts},
|
||||
calendar::{format_date, parse_date},
|
||||
period,
|
||||
},
|
||||
};
|
||||
|
||||
/// Aylık grafikte en fazla dönem sayısı
|
||||
pub const MAX_PERIODS: usize = 24;
|
||||
|
||||
pub struct DaySpent {
|
||||
/// `"YYYY-MM-DD"`
|
||||
pub date: String,
|
||||
/// `Group::ALL` sırasıyla
|
||||
pub spent: GroupAmounts,
|
||||
}
|
||||
|
||||
pub async fn daily(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Vec<DaySpent>> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
// Veritabanındaki tarihler her zaman doğrulanmış biçimde yazılır
|
||||
let start = parse_date(&p.start_date).expect("geçerli başlangıç tarihi");
|
||||
let end = parse_date(&p.end_date).expect("geçerli bitiş tarihi");
|
||||
|
||||
let mut days = Vec::new();
|
||||
let mut d = start;
|
||||
while d < end {
|
||||
days.push(DaySpent {
|
||||
date: format_date(d),
|
||||
spent: [0; 4],
|
||||
});
|
||||
d += time::Duration::days(1);
|
||||
}
|
||||
for (group, date, amount) in
|
||||
budget::spent_by_group_and_date(db, household_id, period_id).await?
|
||||
{
|
||||
// Dönem tarihleri sonradan daraltıldıysa dışarıda kalan kayıt grafiğe girmez
|
||||
if let Some(day) = days.iter_mut().find(|x| x.date == date) {
|
||||
day.spent[group.order()] += amount;
|
||||
}
|
||||
}
|
||||
Ok(days)
|
||||
}
|
||||
|
||||
pub struct PeriodStat {
|
||||
pub period: Period,
|
||||
/// Gelir, sabit gider, devirler, harcanabilir, harcanan (gruplara göre dahil)
|
||||
pub summary: budget::Summary,
|
||||
}
|
||||
|
||||
impl PeriodStat {
|
||||
/// Değerlendirmenin 4. sorusu: gelir − sabit − borç + artı − harcanan (`review` ile aynı)
|
||||
pub fn realized_savings(&self) -> i64 {
|
||||
let s = &self.summary;
|
||||
s.income_total - s.fixed_expense_total - s.carryover_debt + s.carryover_surplus
|
||||
- s.spent_total
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Stats {
|
||||
/// Kapanışlarda birikime aktarılanların toplamı (tüm dönemler)
|
||||
pub total_saved: i64,
|
||||
/// Son `limit` dönem, **eskiden yeniye**
|
||||
pub periods: Vec<PeriodStat>,
|
||||
}
|
||||
|
||||
pub async fn periods(db: &Database, household_id: Uuid, limit: usize) -> AppResult<Stats> {
|
||||
let list = period::list(db, household_id).await?; // en yeni başta
|
||||
let total_saved = list.iter().filter_map(|p| p.to_savings).sum();
|
||||
let mut out = Vec::new();
|
||||
for p in list.into_iter().take(limit.clamp(1, MAX_PERIODS)) {
|
||||
let summary = budget::summary(db, household_id, p.id).await?;
|
||||
out.push(PeriodStat { period: p, summary });
|
||||
}
|
||||
out.reverse();
|
||||
Ok(Stats {
|
||||
total_saved,
|
||||
periods: out,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Birikimler: varsayılan varlıklar, döviz ekleme, alım/satım, ortalama maliyet,
|
||||
//! eldekinden fazla satış kuralı (geriye tarihli kayıt, düzenleme, silme), migration v2.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use mongodb::bson::{Document, Uuid, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const GR: i64 = 1_000_000;
|
||||
|
||||
async fn assets(app: &TestApp, s: &Session) -> Vec<Value> {
|
||||
let (st, b) = app.get("/assets", s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
b.as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
async fn asset(app: &TestApp, s: &Session, code: &str) -> Value {
|
||||
assets(app, s)
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|a| a["code"] == code)
|
||||
.unwrap_or_else(|| panic!("{code} yok"))
|
||||
}
|
||||
|
||||
async fn tx(app: &TestApp, s: &Session, asset_id: &str, body: Value) -> (StatusCode, Value) {
|
||||
app.post(&format!("/assets/{asset_id}/transactions"), s, body)
|
||||
.await
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> String {
|
||||
v["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_assets_and_custom_fx() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let list = assets(&app, &s).await;
|
||||
let codes: Vec<&str> = list.iter().map(|a| a["code"].as_str().unwrap()).collect();
|
||||
assert_eq!(
|
||||
codes,
|
||||
[
|
||||
"TRY",
|
||||
"XAU_GRAM",
|
||||
"XAU_CEYREK",
|
||||
"XAU_YARIM",
|
||||
"XAU_TAM",
|
||||
"XAU_ONS",
|
||||
"USD",
|
||||
"EUR"
|
||||
]
|
||||
);
|
||||
assert_eq!(list[0]["kind"], "cash");
|
||||
assert_eq!(list[1]["unit_label"], "gr");
|
||||
assert_eq!(list[1]["quantity_e6"], 0);
|
||||
assert_eq!(list[1]["avg_cost_e6"], Value::Null);
|
||||
|
||||
let (st, gbp) = app
|
||||
.post(
|
||||
"/assets",
|
||||
&s,
|
||||
json!({ "code": " gbp ", "name": "İngiliz Sterlini", "unit_label": "£" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{gbp}");
|
||||
assert_eq!(gbp["code"], "GBP");
|
||||
assert_eq!(gbp["kind"], "fx");
|
||||
let (st, _) = app
|
||||
.post("/assets", &s, json!({ "code": "GBP", "name": "Tekrar" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
let (st, _) = app
|
||||
.post("/assets", &s, json!({ "code": "GB1", "name": "Hatalı" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
assets(&app, &s).await.last().unwrap()["code"],
|
||||
"GBP",
|
||||
"sona eklenir"
|
||||
);
|
||||
|
||||
// Başka hane: görmez, işlem ekleyemez
|
||||
let other = app.register("Diger", None).await;
|
||||
assert_eq!(assets(&app, &other).await.len(), 8);
|
||||
let (st, _) = tx(
|
||||
&app,
|
||||
&other,
|
||||
&id(&gbp),
|
||||
json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": GR, "total": 5_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = app
|
||||
.get(&format!("/assets/{}/transactions", id(&gbp)), &other)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn weighted_average_cost_and_oversell() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let gram = id(&asset(&app, &s, "XAU_GRAM").await);
|
||||
|
||||
// 10 gr 30.000 TL (birim fiyat hesaplanır), 10 gr 40.000 TL, 5 gr 22.500 TL'ye satış
|
||||
let (st, b1) = tx(
|
||||
&app,
|
||||
&s,
|
||||
&gram,
|
||||
json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": 10 * GR, "total": 3_000_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b1}");
|
||||
assert_eq!(b1["unit_price_e6"], 3_000_000_000_i64);
|
||||
let (st, b2) = tx(&app, &s, &gram, json!({ "type": "buy", "date": "2026-11-05", "quantity_e6": 10 * GR, "unit_price_e6": 3_990_000_000_i64, "total": 4_000_000, "note": " komisyon dahil " })).await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b2}");
|
||||
assert_eq!(
|
||||
b2["unit_price_e6"], 3_990_000_000_i64,
|
||||
"girilen birim fiyat korunur"
|
||||
);
|
||||
assert_eq!(b2["note"], "komisyon dahil");
|
||||
let (st, _) = tx(
|
||||
&app,
|
||||
&s,
|
||||
&gram,
|
||||
json!({ "type": "sell", "date": "2026-11-10", "quantity_e6": 5 * GR, "total": 2_250_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
let a = asset(&app, &s, "XAU_GRAM").await;
|
||||
assert_eq!(a["quantity_e6"], 15 * GR);
|
||||
assert_eq!(
|
||||
a["cost"], 5_250_000,
|
||||
"maliyet tutardan (birim fiyattan değil)"
|
||||
);
|
||||
assert_eq!(a["avg_cost_e6"], 3_500_000_000_i64);
|
||||
assert_eq!(a["realized_pl"], 500_000);
|
||||
assert_eq!(a["tx_count"], 3);
|
||||
|
||||
// Eldekinden fazla satış
|
||||
let (st, b) = tx(
|
||||
&app,
|
||||
&s,
|
||||
&gram,
|
||||
json!({ "type": "sell", "date": "2026-11-12", "quantity_e6": 16 * GR, "total": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
b["error"]["message"], "Satış miktarı o tarihte eldeki miktarı aşıyor",
|
||||
"{b}"
|
||||
);
|
||||
// Geriye tarihli: ilk alımdan önce satış
|
||||
let (st, _) = tx(
|
||||
&app,
|
||||
&s,
|
||||
&gram,
|
||||
json!({ "type": "sell", "date": "2026-10-31", "quantity_e6": GR, "total": 300_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
// Aynı gün: alımdan sonra eklenen satış geçerli (15 gr elde)
|
||||
let (st, _) = tx(
|
||||
&app,
|
||||
&s,
|
||||
&gram,
|
||||
json!({ "type": "sell", "date": "2026-11-01", "quantity_e6": 10 * GR, "total": 3_100_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
// Hatalı girişler
|
||||
for body in [
|
||||
json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": 0, "total": 100 }),
|
||||
json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": GR, "total": 0 }),
|
||||
json!({ "type": "buy", "date": "2026-02-30", "quantity_e6": GR, "total": 100 }),
|
||||
json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": GR, "unit_price_e6": -1, "total": 100 }),
|
||||
] {
|
||||
let (st, b) = tx(&app, &s, &gram, body.clone()).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{body} → {b}");
|
||||
}
|
||||
|
||||
// TL: miktar = tutar, birim fiyat 1
|
||||
let try_id = id(&asset(&app, &s, "TRY").await);
|
||||
let (st, t) = tx(
|
||||
&app,
|
||||
&s,
|
||||
&try_id,
|
||||
json!({ "type": "buy", "date": "2026-11-30", "quantity_e6": 999, "total": 250_050 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{t}");
|
||||
assert_eq!(t["quantity_e6"], 2_500_500_000_i64, "2.500,50 TL");
|
||||
assert_eq!(t["unit_price_e6"], GR);
|
||||
let a = asset(&app, &s, "TRY").await;
|
||||
assert_eq!(a["cost"], 250_050);
|
||||
assert_eq!(a["avg_cost_e6"], GR);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edits_and_deletes_keep_history_consistent() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let usd = id(&asset(&app, &s, "USD").await);
|
||||
let (_, b1) = tx(
|
||||
&app,
|
||||
&s,
|
||||
&usd,
|
||||
json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": 100 * GR, "total": 4_100_000 }),
|
||||
)
|
||||
.await;
|
||||
let (_, b2) = tx(
|
||||
&app,
|
||||
&s,
|
||||
&usd,
|
||||
json!({ "type": "buy", "date": "2026-11-03", "quantity_e6": 100 * GR, "total": 4_200_000 }),
|
||||
)
|
||||
.await;
|
||||
let (_, sell) = tx(&app, &s, &usd, json!({ "type": "sell", "date": "2026-11-05", "quantity_e6": 150 * GR, "total": 6_400_000 })).await;
|
||||
|
||||
// Geçmiş: en yeni başta
|
||||
let (_, list) = app.get(&format!("/assets/{usd}/transactions"), &s).await;
|
||||
let dates: Vec<&str> = list
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t["date"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(dates, ["2026-11-05", "2026-11-03", "2026-11-01"]);
|
||||
|
||||
// Alımlardan biri silinirse satış eksiye düşer → reddedilir
|
||||
let (st, b) = app
|
||||
.delete(&format!("/asset-transactions/{}", id(&b1)), &s)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "{b}");
|
||||
// Satışı büyütmek → eksi
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/asset-transactions/{}", id(&sell)),
|
||||
&s,
|
||||
json!({ "quantity_e6": 250 * GR }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
// 2. alımı satıştan sonraya taşımak → satış anında yalnızca 100 $ var
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/asset-transactions/{}", id(&b2)),
|
||||
&s,
|
||||
json!({ "date": "2026-11-06" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
// Sürüm çakışması
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/asset-transactions/{}", id(&b2)),
|
||||
&s,
|
||||
json!({ "note": "x", "version": 99 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
// Geçerli düzenleme: satış 100 $ → sonra 2. alım silinebilir
|
||||
let (st, e) = app
|
||||
.patch(
|
||||
&format!("/asset-transactions/{}", id(&sell)),
|
||||
&s,
|
||||
json!({ "quantity_e6": 100 * GR, "total": 4_300_000, "version": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{e}");
|
||||
assert_eq!(
|
||||
e["unit_price_e6"], 430_000_000,
|
||||
"43.000 TL / 100 → 430 TL, birim fiyat yeniden hesaplandı"
|
||||
);
|
||||
assert_eq!(e["version"], 2);
|
||||
let (st, _) = app
|
||||
.delete(&format!("/asset-transactions/{}", id(&b2)), &s)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
let a = asset(&app, &s, "USD").await;
|
||||
assert_eq!(a["quantity_e6"], 0);
|
||||
assert_eq!(a["cost"], 0);
|
||||
assert_eq!(a["realized_pl"], 200_000, "43.000 − 41.000");
|
||||
assert_eq!(a["tx_count"], 2, "silinen sayılmaz");
|
||||
|
||||
// Pasif varlığa işlem eklenmez, ama listede kalır
|
||||
let (st, _) = app
|
||||
.patch(&format!("/assets/{usd}"), &s, json!({ "is_active": false }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
let (st, _) = tx(
|
||||
&app,
|
||||
&s,
|
||||
&usd,
|
||||
json!({ "type": "buy", "date": "2026-11-07", "quantity_e6": GR, "total": 4_300 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(asset(&app, &s, "USD").await["is_active"], false);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_adds_defaults_to_existing_households_once() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let hid = Uuid::parse_str(&s.household_id).unwrap();
|
||||
// Faz 8 öncesi hane: varlık yok, v2 uygulanmamış
|
||||
app.db
|
||||
.collection::<Document>("assets")
|
||||
.delete_many(doc! { "household_id": hid })
|
||||
.await
|
||||
.unwrap();
|
||||
app.db
|
||||
.collection::<Document>("schema_migrations")
|
||||
.delete_one(doc! { "_id": 2 })
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(assets(&app, &s).await.is_empty());
|
||||
|
||||
kakeibo::db::migrations::run(&app.db).await.unwrap();
|
||||
assert_eq!(assets(&app, &s).await.len(), 8);
|
||||
// Tekrar çalışsa da çift kayıt yok
|
||||
app.db
|
||||
.collection::<Document>("schema_migrations")
|
||||
.delete_one(doc! { "_id": 2 })
|
||||
.await
|
||||
.unwrap();
|
||||
kakeibo::db::migrations::run(&app.db).await.unwrap();
|
||||
assert_eq!(assets(&app, &s).await.len(), 8);
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Bütçe ve dönem özeti.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use mongodb::bson::{Document, Uuid, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open_december(app: &TestApp, s: &Session, savings_goal: i64) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": savings_goal }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn add_item(app: &TestApp, s: &Session, pid: &str, kind: &str, amount: i64) {
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
s,
|
||||
json!({ "kind": kind, "name": format!("{kind}-{amount}"), "amount": amount }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
|
||||
fn group<'a>(budget: &'a Value, code: &str) -> &'a Value {
|
||||
budget
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|g| g["group"] == code)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn week_amounts(g: &Value) -> Vec<i64> {
|
||||
g["weeks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|w| w["amount"].as_i64().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn summary_computes_spendable_with_carryovers() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_december(&app, &s, 500_000).await;
|
||||
|
||||
// Boş dönem: gelir yok → harcanabilir = -birikim
|
||||
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||
assert_eq!(sum["spendable"], -500_000);
|
||||
|
||||
add_item(&app, &s, &pid, "income", 4_500_000).await;
|
||||
add_item(&app, &s, &pid, "income", 300_000).await;
|
||||
add_item(&app, &s, &pid, "expense", 1_500_000).await;
|
||||
add_item(&app, &s, &pid, "expense", 45_000).await;
|
||||
|
||||
// Önceki dönemden devir (Faz 6'da kapanış sihirbazı yazacak)
|
||||
let hid = Uuid::parse_str(&s.household_id).unwrap();
|
||||
let to = Uuid::parse_str(&pid).unwrap();
|
||||
app.db
|
||||
.collection::<Document>("carryovers")
|
||||
.insert_many([
|
||||
doc! { "household_id": hid, "to_period_id": to, "kind": "debt", "amount": 100_000_i64, "deleted_at": null },
|
||||
doc! { "household_id": hid, "to_period_id": to, "kind": "surplus", "amount": 20_000_i64, "deleted_at": null },
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (st, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert_eq!(
|
||||
sum,
|
||||
json!({
|
||||
"income_total": 4_800_000,
|
||||
"fixed_expense_total": 1_545_000,
|
||||
"savings_goal": 500_000,
|
||||
"carryover_debt": 100_000,
|
||||
"carryover_surplus": 20_000,
|
||||
// 4.800.000 − 1.545.000 − 500.000 − 100.000 + 20.000
|
||||
"spendable": 2_675_000,
|
||||
"budget_total": 0,
|
||||
"unallocated": 2_675_000,
|
||||
"spent_total": 0,
|
||||
"remaining": 2_675_000
|
||||
})
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_suggestion_splits_spendable() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_december(&app, &s, 0).await;
|
||||
let url = format!("/periods/{pid}/budget/suggestion?method=default");
|
||||
|
||||
// Gelir yokken öneri yok
|
||||
let (_, sug) = app.get(&url, &s).await;
|
||||
assert_eq!(sug["available"], false);
|
||||
assert!(sug["message"].as_str().unwrap().contains("Harcanabilir"));
|
||||
|
||||
add_item(&app, &s, &pid, "income", 1_234_567).await;
|
||||
let (_, sug) = app.get(&url, &s).await;
|
||||
assert_eq!(sug["available"], true);
|
||||
assert_eq!(
|
||||
sug["amounts"],
|
||||
json!({ "needs": 617_283, "wants": 370_370, "culture": 123_457, "unexpected": 123_457 })
|
||||
);
|
||||
|
||||
// Önceki dönem yokken previous/avg3 → veri yok
|
||||
for m in ["previous", "avg3"] {
|
||||
let (_, sug) = app
|
||||
.get(&format!("/periods/{pid}/budget/suggestion?method={m}"), &s)
|
||||
.await;
|
||||
assert_eq!(sug["available"], false, "{m}");
|
||||
}
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn monthly_budget_is_split_into_weeks_by_days() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_december(&app, &s, 0).await;
|
||||
add_item(&app, &s, &pid, "income", 300_000).await;
|
||||
let url = format!("/periods/{pid}/budget");
|
||||
|
||||
// Başta hepsi 0
|
||||
let (_, b) = app.get(&url, &s).await;
|
||||
assert_eq!(b.as_array().unwrap().len(), 4);
|
||||
assert_eq!(week_amounts(group(&b, "needs")), vec![0; 5]);
|
||||
|
||||
let (st, b) = app
|
||||
.put(
|
||||
&url,
|
||||
&s,
|
||||
json!({ "needs": 100_000, "wants": 50_000, "culture": 31, "unexpected": 0 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
let needs = group(&b, "needs");
|
||||
assert_eq!(needs["monthly"], 100_000);
|
||||
// Aralık: 6,7,7,7,4 gün → toplam tam 100.000
|
||||
assert_eq!(
|
||||
week_amounts(needs),
|
||||
vec![19_355, 22_581, 22_581, 22_580, 12_903]
|
||||
);
|
||||
assert_eq!(week_amounts(group(&b, "culture")), vec![6, 7, 7, 7, 4]);
|
||||
for g in b.as_array().unwrap() {
|
||||
assert_eq!(
|
||||
week_amounts(g).iter().sum::<i64>(),
|
||||
g["monthly"].as_i64().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
// Özet: 150.031 dağıtıldı, harcanabilir 300.000 → kalan 149.969
|
||||
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||
assert_eq!(
|
||||
(sum["budget_total"].as_i64(), sum["unallocated"].as_i64()),
|
||||
(Some(150_031), Some(149_969))
|
||||
);
|
||||
|
||||
// Fazla dağıtım → kalan negatif (arayüz uyarı gösterir)
|
||||
app.put(
|
||||
&url,
|
||||
&s,
|
||||
json!({ "needs": 400_000, "wants": 0, "culture": 0, "unexpected": 0 }),
|
||||
)
|
||||
.await;
|
||||
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||
assert_eq!(sum["unallocated"], -100_000);
|
||||
|
||||
// Negatif tutar → 400
|
||||
let (st, _) = app
|
||||
.put(
|
||||
&url,
|
||||
&s,
|
||||
json!({ "needs": -1, "wants": 0, "culture": 0, "unexpected": 0 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_week_override_redistributes_the_rest() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_december(&app, &s, 0).await;
|
||||
app.put(
|
||||
&format!("/periods/{pid}/budget"),
|
||||
&s,
|
||||
json!({ "needs": 100_000, "wants": 0, "culture": 0, "unexpected": 0 }),
|
||||
)
|
||||
.await;
|
||||
let weeks_url = format!("/periods/{pid}/budget/weeks");
|
||||
|
||||
// 2. hafta (7–13 Ara) elle 50.000 → kalan 50.000 diğer haftalara (6+7+7+4 = 24 gün)
|
||||
let (st, b) = app
|
||||
.put(
|
||||
&weeks_url,
|
||||
&s,
|
||||
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 50_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
let needs = group(&b, "needs");
|
||||
assert_eq!(
|
||||
week_amounts(needs),
|
||||
vec![12_500, 50_000, 14_584, 14_583, 8_333]
|
||||
);
|
||||
assert_eq!(week_amounts(needs).iter().sum::<i64>(), 100_000);
|
||||
let manual: Vec<bool> = needs["weeks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|w| w["is_manual"].as_bool().unwrap())
|
||||
.collect();
|
||||
assert_eq!(manual, vec![false, true, false, false, false]);
|
||||
assert_eq!(needs["weekly_over"], false);
|
||||
|
||||
// Aylık tutar değişince elle girilen hafta korunur
|
||||
let (_, b) = app
|
||||
.put(
|
||||
&format!("/periods/{pid}/budget"),
|
||||
&s,
|
||||
json!({ "needs": 74_000, "wants": 0, "culture": 0, "unexpected": 0 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(week_amounts(group(&b, "needs"))[1], 50_000);
|
||||
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
|
||||
|
||||
// Elle girilenler aylığı aşarsa uyarı; diğer haftalar 0
|
||||
let (_, b) = app
|
||||
.put(
|
||||
&weeks_url,
|
||||
&s,
|
||||
json!({ "group": "needs", "week_start": "2026-12-14", "amount": 30_000 }),
|
||||
)
|
||||
.await;
|
||||
let needs = group(&b, "needs");
|
||||
assert_eq!(needs["weekly_over"], true);
|
||||
assert_eq!(week_amounts(needs), vec![0, 50_000, 30_000, 0, 0]);
|
||||
|
||||
// null → otomatiğe dön
|
||||
app.put(
|
||||
&weeks_url,
|
||||
&s,
|
||||
json!({ "group": "needs", "week_start": "2026-12-14", "amount": null }),
|
||||
)
|
||||
.await;
|
||||
let (_, b) = app
|
||||
.put(
|
||||
&weeks_url,
|
||||
&s,
|
||||
json!({ "group": "needs", "week_start": "2026-12-07", "amount": null }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
|
||||
assert!(
|
||||
group(&b, "needs")["weeks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|w| w["is_manual"] == false)
|
||||
);
|
||||
|
||||
// Haftanın ilk günü olmayan tarih → 400
|
||||
let (st, _) = app
|
||||
.put(
|
||||
&weeks_url,
|
||||
&s,
|
||||
json!({ "group": "needs", "week_start": "2026-12-08", "amount": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn previous_and_avg3_suggestions_use_actual_spending() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let hid = Uuid::parse_str(&s.household_id).unwrap();
|
||||
|
||||
// Ekim ve Kasım kapalı, harcamaları var (Faz 5'te defterden gelecek)
|
||||
let mut closed = Vec::new();
|
||||
for (start, end) in [("2026-10-01", "2026-11-01"), ("2026-11-01", "2026-12-01")] {
|
||||
let (_, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": start, "end_date": end }),
|
||||
)
|
||||
.await;
|
||||
let id = Uuid::parse_str(p["id"].as_str().unwrap()).unwrap();
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
|
||||
.await
|
||||
.unwrap();
|
||||
closed.push(id);
|
||||
}
|
||||
let entry = |pid: Uuid, group: &str, amount: i64| {
|
||||
doc! { "household_id": hid, "period_id": pid, "group": group, "amount": amount, "date": "2026-10-10", "deleted_at": null }
|
||||
};
|
||||
app.db
|
||||
.collection::<Document>("entries")
|
||||
.insert_many([
|
||||
entry(closed[0], "needs", 100_000),
|
||||
entry(closed[0], "wants", 41_000),
|
||||
entry(closed[1], "needs", 200_000),
|
||||
entry(closed[1], "needs", 50_000),
|
||||
entry(closed[1], "culture", 10_000),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pid = open_december(&app, &s, 0).await;
|
||||
let (_, prev) = app
|
||||
.get(
|
||||
&format!("/periods/{pid}/budget/suggestion?method=previous"),
|
||||
&s,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(prev["available"], true, "{prev}");
|
||||
assert_eq!(
|
||||
prev["amounts"],
|
||||
json!({ "needs": 250_000, "wants": 0, "culture": 10_000, "unexpected": 0 })
|
||||
);
|
||||
|
||||
// avg3: 2 kapalı dönem var → ikiye bölünür (yuvarlayarak)
|
||||
let (_, avg) = app
|
||||
.get(&format!("/periods/{pid}/budget/suggestion?method=avg3"), &s)
|
||||
.await;
|
||||
assert_eq!(
|
||||
avg["amounts"],
|
||||
json!({ "needs": 175_000, "wants": 20_500, "culture": 5_000, "unexpected": 0 })
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budget_closed_period_and_isolation() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("A", None).await;
|
||||
let b = app.register("B", None).await;
|
||||
let pid = open_december(&app, &a, 0).await;
|
||||
let body = json!({ "needs": 1, "wants": 0, "culture": 0, "unexpected": 0 });
|
||||
|
||||
// B erişemez
|
||||
let checks = [
|
||||
app.get(&format!("/periods/{pid}/summary"), &b).await.0,
|
||||
app.get(&format!("/periods/{pid}/budget"), &b).await.0,
|
||||
app.put(&format!("/periods/{pid}/budget"), &b, body.clone())
|
||||
.await
|
||||
.0,
|
||||
app.put(
|
||||
&format!("/periods/{pid}/budget/weeks"),
|
||||
&b,
|
||||
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 1 }),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.get(&format!("/periods/{pid}/budget/suggestion"), &b)
|
||||
.await
|
||||
.0,
|
||||
];
|
||||
assert!(
|
||||
checks.iter().all(|s| *s == StatusCode::NOT_FOUND),
|
||||
"{checks:?}"
|
||||
);
|
||||
|
||||
// Kapalı dönem: okuma serbest, yazma 409
|
||||
let id = Uuid::parse_str(&pid).unwrap();
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.get(&format!("/periods/{pid}/budget"), &a).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
app.put(&format!("/periods/{pid}/budget"), &a, body).await.0,
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Dönem kapatma, devir ve yeniden açma (düzeltme modu).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open_period(app: &TestApp, s: &Session, start: &str, end: &str, goal: i64) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": start, "end_date": end, "savings_goal": goal }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn income(app: &TestApp, s: &Session, pid: &str, amount: i64) {
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": amount }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
|
||||
async fn needs_category(app: &TestApp, s: &Session) -> String {
|
||||
let (_, cats) = app.get("/categories", s).await;
|
||||
cats.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["group"] == "needs")
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64) -> StatusCode {
|
||||
let cat = needs_category(app, s).await;
|
||||
app.post(
|
||||
&format!("/periods/{pid}/entries"),
|
||||
s,
|
||||
json!({ "date": date, "amount": amount, "category_id": cat }),
|
||||
)
|
||||
.await
|
||||
.0
|
||||
}
|
||||
|
||||
async fn close(app: &TestApp, s: &Session, pid: &str, body: Value) -> (StatusCode, Value) {
|
||||
app.post(&format!("/periods/{pid}/close"), s, body).await
|
||||
}
|
||||
|
||||
async fn summary(app: &TestApp, s: &Session, pid: &str) -> Value {
|
||||
app.get(&format!("/periods/{pid}/summary"), s).await.1
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn surplus_split_and_carryover_to_next_period() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let nov = open_period(&app, &s, "2026-11-01", "2026-12-01", 200_000).await;
|
||||
income(&app, &s, &nov, 1_000_000).await;
|
||||
assert_eq!(
|
||||
spend(&app, &s, &nov, "2026-11-10", 300_000).await,
|
||||
StatusCode::CREATED
|
||||
);
|
||||
|
||||
// Gerçekleşen birikim 700.000: 500.000 birikime, 200.000 artı olarak devreder
|
||||
let (st, c) = close(&app, &s, &nov, json!({ "to_savings": 500_000 })).await;
|
||||
assert_eq!(st, StatusCode::OK, "{c}");
|
||||
assert_eq!(c["realized_savings"], 700_000);
|
||||
assert_eq!(c["to_savings"], 500_000);
|
||||
assert_eq!(
|
||||
c["carryover"],
|
||||
json!({ "kind": "surplus", "amount": 200_000, "to_period_id": null })
|
||||
);
|
||||
assert_eq!(c["period"]["status"], "closed");
|
||||
assert_eq!(c["period"]["to_savings"], 500_000);
|
||||
assert!(c["period"]["closed_at"].is_string());
|
||||
|
||||
// Kapalı dönem salt okunur; açık dönem yok
|
||||
assert_eq!(
|
||||
spend(&app, &s, &nov, "2026-11-11", 1).await,
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
let (_, cur) = app.get("/periods/current", &s).await;
|
||||
assert_eq!(cur, Value::Null);
|
||||
let (st, _) = close(&app, &s, &nov, json!({})).await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "iki kez kapatılamaz");
|
||||
|
||||
// Sonraki dönem açılınca devir ona bağlanır → harcanabilir tutara eklenir
|
||||
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01", 0).await;
|
||||
income(&app, &s, &dec, 1_000_000).await;
|
||||
let sum = summary(&app, &s, &dec).await;
|
||||
assert_eq!(
|
||||
(sum["carryover_surplus"].as_i64(), sum["spendable"].as_i64()),
|
||||
(Some(200_000), Some(1_200_000))
|
||||
);
|
||||
let (_, r) = app.get(&format!("/periods/{dec}/review"), &s).await;
|
||||
assert_eq!(r["available"], 1_200_000);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deficit_carries_over_as_debt_and_validation() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let nov = open_period(&app, &s, "2026-11-01", "2026-12-01", 0).await;
|
||||
income(&app, &s, &nov, 100_000).await;
|
||||
spend(&app, &s, &nov, "2026-11-10", 150_000).await;
|
||||
|
||||
let (st, b) = close(&app, &s, &nov, json!({ "to_savings": 10 })).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
b["error"]["message"],
|
||||
"Bu dönemde birikim yok; açık, borç olarak sonraki döneme devreder"
|
||||
);
|
||||
let (st, _) = close(&app, &s, &nov, json!({ "version": 99 })).await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "eski sürüm");
|
||||
|
||||
let (st, c) = close(&app, &s, &nov, json!({})).await;
|
||||
assert_eq!(st, StatusCode::OK, "{c}");
|
||||
assert_eq!(c["carryover"]["kind"], "debt");
|
||||
assert_eq!(c["carryover"]["amount"], 50_000);
|
||||
|
||||
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01", 0).await;
|
||||
let sum = summary(&app, &s, &dec).await;
|
||||
assert_eq!(sum["carryover_debt"], 50_000);
|
||||
assert_eq!(sum["spendable"], -50_000);
|
||||
|
||||
// Artıda: birikim tutarı sınırların dışında → 400
|
||||
income(&app, &s, &dec, 300_000).await;
|
||||
for bad in [-1, 250_001] {
|
||||
let (st, b) = close(&app, &s, &dec, json!({ "to_savings": bad })).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{bad}: {b}");
|
||||
}
|
||||
// Varsayılan: tamamı birikime, devir yok
|
||||
let (_, c) = close(&app, &s, &dec, json!({})).await;
|
||||
assert_eq!(
|
||||
(c["to_savings"].as_i64(), &c["carryover"]),
|
||||
(Some(250_000), &Value::Null)
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reopen_for_correction_while_next_period_is_open() {
|
||||
let app = TestApp::new().await;
|
||||
let owner = app.register("Sahip", None).await;
|
||||
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
|
||||
let member = app
|
||||
.register("Uye", Some(inv["code"].as_str().unwrap()))
|
||||
.await;
|
||||
|
||||
let nov = open_period(&app, &owner, "2026-11-01", "2026-12-01", 0).await;
|
||||
income(&app, &owner, &nov, 500_000).await;
|
||||
spend(&app, &owner, &nov, "2026-11-05", 100_000).await;
|
||||
close(&app, &owner, &nov, json!({ "to_savings": 0 })).await; // 400.000 artı
|
||||
let dec = open_period(&app, &owner, "2026-12-01", "2027-01-01", 0).await;
|
||||
assert_eq!(
|
||||
summary(&app, &owner, &dec).await["carryover_surplus"],
|
||||
400_000
|
||||
);
|
||||
|
||||
// Açık dönem yeniden açılamaz; üye yeniden açamaz
|
||||
let (st, _) = app
|
||||
.post(&format!("/periods/{dec}/reopen"), &owner, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
let (st, _) = app
|
||||
.post(&format!("/periods/{nov}/reopen"), &member, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::FORBIDDEN);
|
||||
|
||||
// Sahip: Kasım düzeltmeye açılır, Aralık açık kalır
|
||||
let (st, p) = app
|
||||
.post(&format!("/periods/{nov}/reopen"), &owner, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{p}");
|
||||
assert_eq!(p["status"], "correcting");
|
||||
let (_, cur) = app.get("/periods/current", &owner).await;
|
||||
assert_eq!(cur["id"], dec.as_str());
|
||||
// Düzeltme: Kasım'a unutulan harcama eklenir (üye de ekleyebilir)
|
||||
assert_eq!(
|
||||
spend(&app, &member, &nov, "2026-11-20", 150_000).await,
|
||||
StatusCode::CREATED
|
||||
);
|
||||
// Yeniden kapatılana kadar Aralık'ın devri değişmez
|
||||
assert_eq!(
|
||||
summary(&app, &owner, &dec).await["carryover_surplus"],
|
||||
400_000
|
||||
);
|
||||
|
||||
// Yeniden kapat → devir yeniden hesaplanır ve doğrudan Aralık'a bağlanır
|
||||
let (st, c) = close(&app, &owner, &nov, json!({ "to_savings": 0 })).await;
|
||||
assert_eq!(st, StatusCode::OK, "{c}");
|
||||
assert_eq!(
|
||||
c["carryover"],
|
||||
json!({ "kind": "surplus", "amount": 250_000, "to_period_id": dec })
|
||||
);
|
||||
let sum = summary(&app, &owner, &dec).await;
|
||||
assert_eq!(
|
||||
sum["carryover_surplus"], 250_000,
|
||||
"eski devir silindi, yenisi geçerli"
|
||||
);
|
||||
|
||||
// Aralık kapanınca Kasım artık yeniden açılamaz (zincir bozulurdu)
|
||||
close(&app, &owner, &dec, json!({})).await;
|
||||
let (st, b) = app
|
||||
.post(&format!("/periods/{nov}/reopen"), &owner, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "{b}");
|
||||
let (st, _) = app
|
||||
.post(&format!("/periods/{dec}/reopen"), &owner, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closing_is_isolated_between_households() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("Ahane", None).await;
|
||||
let b = app.register("Bhane", None).await;
|
||||
let pid = open_period(&app, &a, "2026-11-01", "2026-12-01", 0).await;
|
||||
|
||||
let (st, _) = close(&app, &b, &pid, json!({})).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
close(&app, &a, &pid, json!({})).await;
|
||||
let (st, _) = app
|
||||
.post(&format!("/periods/{pid}/reopen"), &b, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -57,6 +57,7 @@ impl TestApp {
|
||||
port: 0,
|
||||
registration,
|
||||
static_dir: None,
|
||||
rates_refresh_minutes: 0,
|
||||
};
|
||||
let router = routes::router(AppState::new(db.clone(), config));
|
||||
Self { router, db }
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Günlük defter: harcama kayıtları.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use mongodb::bson::{Document, Uuid, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open_period(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": start, "end_date": end }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Hanenin ilk (örnek) kategorisi, verilen grupta
|
||||
async fn category(app: &TestApp, s: &Session, group: &str) -> String {
|
||||
let (_, cats) = app.get("/categories", s).await;
|
||||
cats.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["group"] == group)
|
||||
.unwrap_or_else(|| panic!("{group} grubunda kategori yok"))["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn payment_method(app: &TestApp, s: &Session) -> String {
|
||||
let (_, pms) = app.get("/payment-methods", s).await;
|
||||
pms[0]["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn add(app: &TestApp, s: &Session, pid: &str, body: Value) -> (StatusCode, Value) {
|
||||
app.post(&format!("/periods/{pid}/entries"), s, body).await
|
||||
}
|
||||
|
||||
async fn close(app: &TestApp, pid: &str) {
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(
|
||||
doc! { "_id": Uuid::parse_str(pid).unwrap() },
|
||||
doc! { "$set": { "status": "closed" } },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn ids(list: &Value) -> Vec<&str> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e["description"].as_str().unwrap_or(""))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crud_group_from_category_and_ordering() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let wants = category(&app, &s, "wants").await;
|
||||
let pm = payment_method(&app, &s).await;
|
||||
|
||||
let (st, e) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-09", "amount": 34_550, "category_id": needs,
|
||||
"payment_method_id": pm, "description": " Haftalık market " }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||
assert_eq!(e["group"], "needs", "grup kategoriden gelir");
|
||||
assert_eq!(e["description"], "Haftalık market", "boşluklar kırpılır");
|
||||
assert_eq!(e["payment_method_id"], pm.as_str());
|
||||
assert_eq!(e["version"], 1);
|
||||
let id = e["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Ödeme türü ve açıklama isteğe bağlı
|
||||
for (date, desc) in [
|
||||
("2026-12-09", "aynı gün, sonra"),
|
||||
("2026-12-02", "erken"),
|
||||
("2026-12-20", "geç"),
|
||||
] {
|
||||
let (st, b) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": date, "amount": 1_000, "category_id": wants, "description": desc }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
assert!(b["payment_method_id"].is_null());
|
||||
}
|
||||
|
||||
// Sıra: yeni tarih önce; aynı gün içinde son eklenen önce
|
||||
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||
assert_eq!(
|
||||
ids(&list),
|
||||
["geç", "aynı gün, sonra", "Haftalık market", "erken"]
|
||||
);
|
||||
|
||||
// Tarih aralığı (hafta) filtresi: iki uç dahil
|
||||
let (_, week) = app
|
||||
.get(
|
||||
&format!("/periods/{pid}/entries?from=2026-12-07&to=2026-12-13"),
|
||||
&s,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(ids(&week), ["aynı gün, sonra", "Haftalık market"]);
|
||||
let (st, _) = app
|
||||
.get(&format!("/periods/{pid}/entries?from=2026-13-01"), &s)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
// Düzenle: kategori değişince grup da değişir; boş metin → kaldır
|
||||
let (st, u) = app
|
||||
.patch(
|
||||
&format!("/entries/{id}"),
|
||||
&s,
|
||||
json!({ "amount": 40_000, "category_id": wants, "payment_method_id": "",
|
||||
"description": "", "version": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{u}");
|
||||
assert_eq!(u["amount"], 40_000);
|
||||
assert_eq!(u["group"], "wants");
|
||||
assert!(u["payment_method_id"].is_null());
|
||||
assert!(u["description"].is_null());
|
||||
assert_eq!(u["version"], 2);
|
||||
assert_eq!(u["date"], "2026-12-09", "gönderilmeyen alan değişmez");
|
||||
|
||||
// Eski sürümle düzenleme → 409
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/entries/{id}"),
|
||||
&s,
|
||||
json!({ "amount": 1, "version": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
// Sil → listede yok
|
||||
let (st, _) = app.delete(&format!("/entries/{id}"), &s).await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||
assert_eq!(list.as_array().unwrap().len(), 3);
|
||||
let (st, _) = app
|
||||
.patch(&format!("/entries/{id}"), &s, json!({ "amount": 5 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND, "silinmiş kayıt düzenlenemez");
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_and_restore_undo() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let (_, e) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-03", "amount": 2_500, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
let id = e["id"].as_str().unwrap();
|
||||
|
||||
// Silinmemiş kayıt geri alınamaz
|
||||
let (st, _) = app
|
||||
.post(&format!("/entries/{id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
app.delete(&format!("/entries/{id}"), &s).await;
|
||||
let (st, r) = app
|
||||
.post(&format!("/entries/{id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{r}");
|
||||
assert_eq!(r["amount"], 2_500);
|
||||
assert_eq!(r["version"], 3, "sil + geri al iki değişiklik");
|
||||
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||
assert_eq!(
|
||||
list.as_array().unwrap().len(),
|
||||
1,
|
||||
"geri gelen kayıt listede"
|
||||
);
|
||||
|
||||
// İkinci kez geri alma → 404
|
||||
let (st, _) = app
|
||||
.post(&format!("/entries/{id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_errors_are_turkish_400s() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let base = json!({ "date": "2026-12-05", "amount": 1_000, "category_id": needs });
|
||||
let with = |k: &str, v: Value| {
|
||||
let mut b = base.clone();
|
||||
b[k] = v;
|
||||
b
|
||||
};
|
||||
|
||||
let cases = [
|
||||
(with("amount", json!(0)), "Tutar sıfırdan büyük olmalı"),
|
||||
(with("amount", json!(-5)), "Tutar sıfırdan büyük olmalı"),
|
||||
(
|
||||
with("date", json!("2027-01-01")),
|
||||
"Tarih dönem içinde olmalı",
|
||||
),
|
||||
(with("date", json!("05.12.2026")), "Tarih geçersiz"),
|
||||
(with("category_id", json!("")), "Kategori geçersiz"),
|
||||
(
|
||||
with("category_id", json!(Uuid::new().to_string())),
|
||||
"Kategori bulunamadı",
|
||||
),
|
||||
(
|
||||
with("payment_method_id", json!("xyz")),
|
||||
"Ödeme türü geçersiz",
|
||||
),
|
||||
(
|
||||
with("payment_method_id", json!(Uuid::new().to_string())),
|
||||
"Ödeme türü bulunamadı",
|
||||
),
|
||||
(
|
||||
with("description", json!("a".repeat(201))),
|
||||
"Açıklama en fazla 200 karakter olabilir",
|
||||
),
|
||||
];
|
||||
for (body, msg) in cases {
|
||||
let (st, b) = add(&app, &s, &pid, body.clone()).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{body} → {b}");
|
||||
assert_eq!(b["error"]["message"], msg, "{body}");
|
||||
}
|
||||
|
||||
// 200 karakter (emoji = 1 karakter) kabul
|
||||
let (st, _) = add(&app, &s, &pid, with("description", json!("🛒".repeat(200)))).await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
// Pasif kategori: yeni kayıtta olmaz, onu zaten kullanan kayıt düzenlenebilir
|
||||
let (_, e) = add(&app, &s, &pid, base.clone()).await;
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/categories/{needs}"),
|
||||
&s,
|
||||
json!({ "is_active": false }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
let (st, b) = add(&app, &s, &pid, base.clone()).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
b["error"]["message"],
|
||||
"Bu kategori pasif; başka bir kategori seçin"
|
||||
);
|
||||
let (st, b) = app
|
||||
.patch(
|
||||
&format!("/entries/{}", e["id"].as_str().unwrap()),
|
||||
&s,
|
||||
json!({ "amount": 2_000, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::OK,
|
||||
"eski kaydın kendi kategorisi kalabilir: {b}"
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closed_period_is_read_only() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let (_, e) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1_000, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
let (_, gone) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-06", "amount": 500, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
let id = e["id"].as_str().unwrap();
|
||||
let gone_id = gone["id"].as_str().unwrap();
|
||||
app.delete(&format!("/entries/{gone_id}"), &s).await;
|
||||
close(&app, &pid).await;
|
||||
|
||||
let (st, _) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-07", "amount": 1, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
let (st, _) = app
|
||||
.patch(&format!("/entries/{id}"), &s, json!({ "amount": 5 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
let (st, _) = app.delete(&format!("/entries/{id}"), &s).await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
let (st, _) = app
|
||||
.post(&format!("/entries/{gone_id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
// Okuma serbest
|
||||
let (st, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert_eq!(list.as_array().unwrap().len(), 1);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn entries_are_isolated_between_households() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("Ali", None).await;
|
||||
let b = app.register("Banu", None).await;
|
||||
let pid = open_period(&app, &a, "2026-12-01", "2027-01-01").await;
|
||||
let b_pid = open_period(&app, &b, "2026-12-01", "2027-01-01").await;
|
||||
let a_cat = category(&app, &a, "needs").await;
|
||||
let a_pm = payment_method(&app, &a).await;
|
||||
let (_, e) = add(
|
||||
&app,
|
||||
&a,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1_000, "category_id": a_cat }),
|
||||
)
|
||||
.await;
|
||||
let id = e["id"].as_str().unwrap();
|
||||
|
||||
// B, A'nın dönemini/kaydını göremez, değiştiremez
|
||||
let (st, _) = app.get(&format!("/periods/{pid}/entries"), &b).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = add(
|
||||
&app,
|
||||
&b,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1, "category_id": a_cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = app
|
||||
.patch(&format!("/entries/{id}"), &b, json!({ "amount": 5 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = app.delete(&format!("/entries/{id}"), &b).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
app.delete(&format!("/entries/{id}"), &a).await;
|
||||
let (st, _) = app
|
||||
.post(&format!("/entries/{id}/restore"), &b, json!({}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::NOT_FOUND,
|
||||
"başkasının silinmiş kaydı geri alınamaz"
|
||||
);
|
||||
|
||||
// B kendi dönemine A'nın kategorisini / ödeme türünü kullanamaz
|
||||
let (st, body) = add(
|
||||
&app,
|
||||
&b,
|
||||
&b_pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1, "category_id": a_cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["message"], "Kategori bulunamadı");
|
||||
let b_cat = category(&app, &b, "needs").await;
|
||||
let (st, body) = add(
|
||||
&app,
|
||||
&b,
|
||||
&b_pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1, "category_id": b_cat, "payment_method_id": a_pm }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["message"], "Ödeme türü bulunamadı");
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
/// Bütçe önerisi (önceki dönem) artık API'den girilen gerçek kayıtları kullanır.
|
||||
#[tokio::test]
|
||||
async fn previous_suggestion_uses_real_entries() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let nov = open_period(&app, &s, "2026-11-01", "2026-12-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let culture = category(&app, &s, "culture").await;
|
||||
for (cat, amount) in [(&needs, 120_000), (&needs, 30_000), (&culture, 15_000)] {
|
||||
let (st, b) = add(
|
||||
&app,
|
||||
&s,
|
||||
&nov,
|
||||
json!({ "date": "2026-11-10", "amount": amount, "category_id": cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
// Silinen kayıt toplama girmez
|
||||
let (_, del) = add(
|
||||
&app,
|
||||
&s,
|
||||
&nov,
|
||||
json!({ "date": "2026-11-11", "amount": 999_999, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
app.delete(&format!("/entries/{}", del["id"].as_str().unwrap()), &s)
|
||||
.await;
|
||||
close(&app, &nov).await;
|
||||
|
||||
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let (_, sug) = app
|
||||
.get(
|
||||
&format!("/periods/{dec}/budget/suggestion?method=previous"),
|
||||
&s,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sug["available"], true, "{sug}");
|
||||
assert_eq!(
|
||||
sug["amounts"],
|
||||
json!({ "needs": 150_000, "wants": 0, "culture": 15_000, "unexpected": 0 })
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
fn spent_by_week(budget: &Value, group: &str) -> (i64, Vec<i64>) {
|
||||
let g = budget
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|g| g["group"] == group)
|
||||
.unwrap();
|
||||
let weeks = g["weeks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|w| w["spent"].as_i64().unwrap())
|
||||
.collect();
|
||||
(g["spent"].as_i64().unwrap(), weeks)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_spending_in_summary_and_budget() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
// Kasım'da bir harcama: Aralık toplamlarına girmemeli
|
||||
let nov = open_period(&app, &s, "2026-11-01", "2026-12-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let wants = category(&app, &s, "wants").await;
|
||||
let culture = category(&app, &s, "culture").await;
|
||||
let (st, _) = add(
|
||||
&app,
|
||||
&s,
|
||||
&nov,
|
||||
json!({ "date": "2026-11-30", "amount": 1_000_000, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
close(&app, &nov).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 300_000 }),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Aralık 2026 haftaları: 1–6 · 7–13 · 14–20 · 21–27 · 28–31
|
||||
for (date, amount, cat) in [
|
||||
("2026-12-01", 1_000, &needs),
|
||||
("2026-12-06", 2_000, &needs), // 1. haftanın son günü
|
||||
("2026-12-07", 500, &needs), // 2. haftanın ilk günü
|
||||
("2026-12-31", 300, &wants), // dönemin son günü
|
||||
] {
|
||||
let (st, b) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": date, "amount": amount, "category_id": cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
// Kategori değişince harcama diğer gruba geçer
|
||||
let (_, moved) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-08", "amount": 400, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
app.patch(
|
||||
&format!("/entries/{}", moved["id"].as_str().unwrap()),
|
||||
&s,
|
||||
json!({ "category_id": wants }),
|
||||
)
|
||||
.await;
|
||||
// Silinen harcama sayılmaz
|
||||
let (_, gone) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-15", "amount": 700, "category_id": culture }),
|
||||
)
|
||||
.await;
|
||||
let gone_id = gone["id"].as_str().unwrap();
|
||||
app.delete(&format!("/entries/{gone_id}"), &s).await;
|
||||
|
||||
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||
// 1.000 + 2.000 + 500 + 300 + 400 = 4.200
|
||||
assert_eq!(
|
||||
(sum["spent_total"].as_i64(), sum["remaining"].as_i64()),
|
||||
(Some(4_200), Some(295_800)),
|
||||
"{sum}"
|
||||
);
|
||||
|
||||
let (st, b) = app.get(&format!("/periods/{pid}/budget"), &s).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert_eq!(
|
||||
spent_by_week(&b, "needs"),
|
||||
(3_500, vec![3_000, 500, 0, 0, 0])
|
||||
);
|
||||
assert_eq!(spent_by_week(&b, "wants"), (700, vec![0, 400, 0, 0, 300]));
|
||||
assert_eq!(spent_by_week(&b, "culture"), (0, vec![0; 5]));
|
||||
|
||||
// Geri al → yeniden sayılır
|
||||
app.post(&format!("/entries/{gone_id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
let (_, b) = app.get(&format!("/periods/{pid}/budget"), &s).await;
|
||||
assert_eq!(spent_by_week(&b, "culture"), (700, vec![0, 0, 700, 0, 0]));
|
||||
|
||||
// Harcanabilir aşılırsa kalan negatif
|
||||
add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-20", "amount": 300_000, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||
assert_eq!(sum["remaining"], 300_000 - 304_900);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Dönem kuralları (KAKEIBO_PROMPT §6).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::TestApp;
|
||||
use mongodb::bson::{Document, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open_december(app: &TestApp, s: &common::Session) -> Value {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": 500000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_period_returns_weeks_and_default_name() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
|
||||
// Başta açık dönem yok → null
|
||||
let (st, cur) = app.get("/periods/current", &s).await;
|
||||
assert_eq!((st, cur), (StatusCode::OK, Value::Null));
|
||||
|
||||
let p = open_december(&app, &s).await;
|
||||
assert_eq!(p["name"], "Aralık 2026");
|
||||
assert_eq!(p["status"], "open");
|
||||
assert_eq!(p["days"], 31);
|
||||
assert_eq!(p["last_date"], "2026-12-31");
|
||||
assert_eq!(p["savings_goal"], 500000);
|
||||
let days: Vec<i64> = p["weeks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|w| w["days"].as_i64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
days,
|
||||
vec![6, 7, 7, 7, 4],
|
||||
"Pazartesi başlangıçlı, kısmi haftalar ayrı"
|
||||
);
|
||||
|
||||
let (_, cur) = app.get("/periods/current", &s).await;
|
||||
assert_eq!(cur["id"], p["id"]);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_one_open_period_and_no_overlap() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let dec = open_december(&app, &s).await;
|
||||
|
||||
// İkinci açık dönem → 409
|
||||
let (st, body) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2027-01-01", "end_date": "2027-02-01" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
assert!(
|
||||
body["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Aralık 2026")
|
||||
);
|
||||
|
||||
// Aralık'ı kapat (Faz 6'da sihirbaz yapacak) → çakışan yeni dönem yine 409
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(
|
||||
doc! { "name": "Aralık 2026" },
|
||||
doc! { "$set": { "status": "closed" } },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (st, body) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-12-20", "end_date": "2027-01-20" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "{body}");
|
||||
assert!(
|
||||
body["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("çakışıyor")
|
||||
);
|
||||
|
||||
// Bitişik dönem (bitiş hariç olduğu için 1 Ocak serbest) → 201
|
||||
let (st, jan) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2027-01-01", "end_date": "2027-02-01" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{jan}");
|
||||
assert_eq!(jan["name"], "Ocak 2027");
|
||||
|
||||
// Liste: en yeni başta
|
||||
let (_, list) = app.get("/periods", &s).await;
|
||||
let names: Vec<&str> = list
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["Ocak 2027", "Aralık 2026"]);
|
||||
|
||||
// Kapalı dönem salt okunur
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/periods/{}", dec["id"].as_str().unwrap()),
|
||||
&s,
|
||||
json!({ "savings_goal": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_errors() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
for (start, end) in [
|
||||
("2026-12-10", "2026-12-01"), // ters
|
||||
("2026-12-01", "2026-12-01"), // boş
|
||||
("2026-02-30", "2026-03-30"), // geçersiz gün
|
||||
("01.12.2026", "2027-01-01"), // biçim
|
||||
("2026-12-01", "2027-03-01"), // 90 gün
|
||||
] {
|
||||
let (st, _) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": start, "end_date": end }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{start} → {end}");
|
||||
}
|
||||
let (st, _) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": -5 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "negatif birikim");
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_dates_name_goal_and_version() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let p = open_december(&app, &s).await;
|
||||
let path = format!("/periods/{}", p["id"].as_str().unwrap());
|
||||
|
||||
// Maaş 15'inde → dönemi kaydır; ad otomatik güncellenmez, istenirse verilir
|
||||
let (st, upd) = app
|
||||
.patch(
|
||||
&path,
|
||||
&s,
|
||||
json!({ "start_date": "2026-12-15", "end_date": "2027-01-15", "name": "", "savings_goal": 750000, "version": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{upd}");
|
||||
assert_eq!(upd["name"], "15 Ara 2026 – 14 Oca 2027");
|
||||
assert_eq!(upd["savings_goal"], 750000);
|
||||
assert_eq!(upd["version"], 2);
|
||||
|
||||
// Eski sürümle → 409
|
||||
let (st, _) = app
|
||||
.patch(&path, &s, json!({ "name": "X", "version": 1 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
// Aralığın dışında kalacak bir harcama varsa daraltma engellenir
|
||||
let hid = mongodb::bson::Uuid::parse_str(&s.household_id).unwrap();
|
||||
let pid = mongodb::bson::Uuid::parse_str(p["id"].as_str().unwrap()).unwrap();
|
||||
app.db
|
||||
.collection::<Document>("entries")
|
||||
.insert_one(doc! { "household_id": hid, "period_id": pid, "date": "2027-01-10", "deleted_at": null })
|
||||
.await
|
||||
.unwrap();
|
||||
let (st, body) = app
|
||||
.patch(&path, &s, json!({ "end_date": "2027-01-05" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "{body}");
|
||||
assert!(
|
||||
body["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("1 harcama")
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periods_are_isolated_between_households() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("A", None).await;
|
||||
let b = app.register("B", None).await;
|
||||
let p = open_december(&app, &a).await;
|
||||
let path = format!("/periods/{}", p["id"].as_str().unwrap());
|
||||
|
||||
// B, A'nın dönemini göremez/değiştiremez; kendi açık dönemi yok
|
||||
assert_eq!(app.get(&path, &b).await.0, StatusCode::NOT_FOUND);
|
||||
assert_eq!(
|
||||
app.patch(&path, &b, json!({ "name": "ele geçirildi" }))
|
||||
.await
|
||||
.0,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(app.get("/periods/current", &b).await.1, Value::Null);
|
||||
assert_eq!(app.get("/periods", &b).await.1, json!([]));
|
||||
|
||||
// A'nın açık dönemi B'nin dönem açmasını engellemez (açık dönem kuralı hane bazlı)
|
||||
let (st, _) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&b,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Plan kalemleri ve notlar.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use mongodb::bson::{Document, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open_period(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": start, "end_date": end }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn close_period(app: &TestApp, id: &str) {
|
||||
let uuid = mongodb::bson::Uuid::parse_str(id).unwrap();
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(
|
||||
doc! { "_id": uuid },
|
||||
doc! { "$set": { "status": "closed" } },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn names(list: &Value) -> Vec<String> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| {
|
||||
format!(
|
||||
"{}:{}",
|
||||
i["kind"].as_str().unwrap(),
|
||||
i["name"].as_str().unwrap()
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_items_crud_and_validation() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let items = format!("/periods/{pid}/plan-items");
|
||||
|
||||
// Gider önce eklense de liste gelirle başlar
|
||||
let (st, rent) = app
|
||||
.post(
|
||||
&items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{rent}");
|
||||
app.post(
|
||||
&items,
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000 }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "İnternet", "amount": 45_000 }),
|
||||
)
|
||||
.await;
|
||||
let (_, list) = app.get(&items, &s).await;
|
||||
assert_eq!(
|
||||
names(&list),
|
||||
vec!["income:Maaş", "expense:Kira", "expense:İnternet"]
|
||||
);
|
||||
assert_eq!(list[0]["date"], Value::Null);
|
||||
|
||||
// Doğrulamalar
|
||||
for bad in [
|
||||
json!({ "kind": "expense", "name": "X", "amount": 0 }),
|
||||
json!({ "kind": "expense", "name": " ", "amount": 100 }),
|
||||
json!({ "kind": "expense", "name": "X", "amount": 100, "date": "2027-01-01" }), // dönem dışı (bitiş hariç)
|
||||
json!({ "kind": "expense", "name": "X", "amount": 100, "date": "05.12.2026" }),
|
||||
json!({ "kind": "gift", "name": "X", "amount": 100 }),
|
||||
] {
|
||||
let (st, _) = app.post(&items, &s, bad.clone()).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{bad}");
|
||||
}
|
||||
|
||||
// Düzenle: tutar + tarihi kaldır; eski sürüm → 409
|
||||
let rent_path = format!("/plan-items/{}", rent["id"].as_str().unwrap());
|
||||
let (st, upd) = app
|
||||
.patch(
|
||||
&rent_path,
|
||||
&s,
|
||||
json!({ "amount": 1_600_000, "date": "", "version": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{upd}");
|
||||
assert_eq!(
|
||||
(upd["amount"].as_i64(), &upd["date"]),
|
||||
(Some(1_600_000), &Value::Null)
|
||||
);
|
||||
let (st, _) = app
|
||||
.patch(&rent_path, &s, json!({ "name": "Y", "version": 1 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
// Sil → listeden çıkar
|
||||
assert_eq!(app.delete(&rent_path, &s).await.0, StatusCode::NO_CONTENT);
|
||||
let (_, list) = app.get(&items, &s).await;
|
||||
assert_eq!(names(&list), vec!["income:Maaş", "expense:İnternet"]);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_previous_shifts_dates_and_skips_duplicates() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let dec_items = format!("/periods/{dec}/plan-items");
|
||||
app.post(
|
||||
&dec_items,
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000, "date": "2026-12-15" }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&dec_items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&dec_items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Aidat", "amount": 80_000 }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&dec_items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Fatura", "amount": 90_000, "date": "2026-12-31" }),
|
||||
)
|
||||
.await;
|
||||
close_period(&app, &dec).await;
|
||||
|
||||
let jan = open_period(&app, &s, "2027-01-01", "2027-02-01").await;
|
||||
let jan_items = format!("/periods/{jan}/plan-items");
|
||||
// Kullanıcı kirayı elle zaten eklemiş (farklı tutarla) → kopyada atlanmalı
|
||||
app.post(
|
||||
&jan_items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "kira", "amount": 1_700_000 }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (st, res) = app
|
||||
.post(&format!("{jan_items}/copy-previous"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{res}");
|
||||
assert_eq!(res["added"], 3);
|
||||
|
||||
let (_, list) = app.get(&jan_items, &s).await;
|
||||
let by_name = |n: &str| {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["name"] == n)
|
||||
.unwrap()
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(by_name("Maaş")["date"], "2027-01-15", "göreli gün korunur");
|
||||
assert_eq!(by_name("Aidat")["date"], Value::Null);
|
||||
assert_eq!(by_name("kira")["amount"], 1_700_000, "elle eklenen korunur");
|
||||
|
||||
assert_eq!(by_name("Fatura")["date"], "2027-01-31");
|
||||
|
||||
// Ocak → Şubat (28 gün): 31 Ocak'taki fatura dönem dışına taşar → son güne (28 Şubat) çekilir
|
||||
close_period(&app, &jan).await;
|
||||
let feb = open_period(&app, &s, "2027-02-01", "2027-03-01").await;
|
||||
let feb_items = format!("/periods/{feb}/plan-items");
|
||||
app.post(&format!("{feb_items}/copy-previous"), &s, json!({}))
|
||||
.await;
|
||||
let (_, feb_list) = app.get(&feb_items, &s).await;
|
||||
let fatura = feb_list
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["name"] == "Fatura")
|
||||
.unwrap();
|
||||
assert_eq!(fatura["date"], "2027-02-28");
|
||||
|
||||
// İkinci kez → hiçbir şey eklenmez (Ocak kapalı olduğu için Şubat'ta deniyoruz)
|
||||
let (_, res) = app
|
||||
.post(&format!("{feb_items}/copy-previous"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(res["added"], 0);
|
||||
|
||||
// İlk dönemde önceki yok → 400
|
||||
let (st, _) = app
|
||||
.post(&format!("{dec_items}/copy-previous"), &s, json!({}))
|
||||
.await;
|
||||
assert!(
|
||||
st == StatusCode::BAD_REQUEST || st == StatusCode::CONFLICT,
|
||||
"{st}"
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closed_period_is_read_only() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let (_, item) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 100 }),
|
||||
)
|
||||
.await;
|
||||
let (_, note) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/notes"),
|
||||
&s,
|
||||
json!({ "context": "planning", "title": "Hedef" }),
|
||||
)
|
||||
.await;
|
||||
close_period(&app, &pid).await;
|
||||
|
||||
let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap());
|
||||
let note_path = format!("/notes/{}", note["id"].as_str().unwrap());
|
||||
let checks = [
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "X", "amount": 1 }),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.patch(&item_path, &s, json!({ "amount": 5 })).await.0,
|
||||
app.delete(&item_path, &s).await.0,
|
||||
app.post(
|
||||
&format!("/periods/{pid}/notes"),
|
||||
&s,
|
||||
json!({ "context": "review", "title": "X" }),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.patch(¬e_path, &s, json!({ "title": "Y" })).await.0,
|
||||
app.delete(¬e_path, &s).await.0,
|
||||
];
|
||||
assert!(
|
||||
checks.iter().all(|st| *st == StatusCode::CONFLICT),
|
||||
"{checks:?}"
|
||||
);
|
||||
|
||||
// Okuma serbest
|
||||
assert_eq!(
|
||||
app.get(&format!("/periods/{pid}/plan-items"), &s).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
app.get(&format!("/periods/{pid}/notes"), &s).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notes_crud_and_context_filter() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let notes = format!("/periods/{pid}/notes");
|
||||
|
||||
let (st, n) = app
|
||||
.post(¬es, &s, json!({ "context": "planning", "title": " Tatil birikimi ", "body_md": "- Ocak'ta **5.000 ₺**" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{n}");
|
||||
assert_eq!(n["title"], "Tatil birikimi");
|
||||
app.post(¬es, &s, json!({ "context": "review", "title": "Ders" }))
|
||||
.await;
|
||||
|
||||
let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await;
|
||||
assert_eq!(planning.as_array().unwrap().len(), 1);
|
||||
let (_, all) = app.get(¬es, &s).await;
|
||||
assert_eq!(all.as_array().unwrap().len(), 2);
|
||||
|
||||
let (st, _) = app
|
||||
.post(¬es, &s, json!({ "context": "planning", "title": "" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "boş başlık");
|
||||
|
||||
let path = format!("/notes/{}", n["id"].as_str().unwrap());
|
||||
let (st, upd) = app
|
||||
.patch(&path, &s, json!({ "body_md": "Güncel", "version": 1 }))
|
||||
.await;
|
||||
assert_eq!(
|
||||
(st, upd["body_md"].as_str()),
|
||||
(StatusCode::OK, Some("Güncel"))
|
||||
);
|
||||
assert_eq!(app.delete(&path, &s).await.0, StatusCode::NO_CONTENT);
|
||||
let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await;
|
||||
assert_eq!(planning, json!([]));
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_items_and_notes_are_isolated() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("A", None).await;
|
||||
let b = app.register("B", None).await;
|
||||
let pid = open_period(&app, &a, "2026-12-01", "2027-01-01").await;
|
||||
let (_, item) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&a,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 100 }),
|
||||
)
|
||||
.await;
|
||||
let (_, note) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/notes"),
|
||||
&a,
|
||||
json!({ "context": "planning", "title": "Gizli" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap());
|
||||
let note_path = format!("/notes/{}", note["id"].as_str().unwrap());
|
||||
let checks = [
|
||||
app.get(&format!("/periods/{pid}/plan-items"), &b).await.0,
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&b,
|
||||
json!({ "kind": "income", "name": "X", "amount": 1 }),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items/copy-previous"),
|
||||
&b,
|
||||
json!({}),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.patch(&item_path, &b, json!({ "amount": 1 })).await.0,
|
||||
app.delete(&item_path, &b).await.0,
|
||||
app.get(&format!("/periods/{pid}/notes"), &b).await.0,
|
||||
app.patch(¬e_path, &b, json!({ "title": "X" })).await.0,
|
||||
app.delete(¬e_path, &b).await.0,
|
||||
];
|
||||
assert!(
|
||||
checks.iter().all(|st| *st == StatusCode::NOT_FOUND),
|
||||
"{checks:?}"
|
||||
);
|
||||
|
||||
// A'nın verisi değişmedi
|
||||
let (_, list) = app.get(&format!("/periods/{pid}/plan-items"), &a).await;
|
||||
assert_eq!(list[0]["amount"], 100);
|
||||
assert_eq!(list[0]["version"], 1);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Raporlar: harcama kırılımı (grup, tüm kategoriler, ödeme türü, ay) ve filtreler.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn id_by_name(app: &TestApp, s: &Session, path: &str, name: &str) -> String {
|
||||
let (_, list) = app.get(path, s).await;
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["name"] == name)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn spend(
|
||||
app: &TestApp,
|
||||
s: &Session,
|
||||
pid: &str,
|
||||
date: &str,
|
||||
amount: i64,
|
||||
cat: &str,
|
||||
pm: Option<&str>,
|
||||
) -> Value {
|
||||
let mut body = json!({ "date": date, "amount": amount, "category_id": cat });
|
||||
if let Some(pm) = pm {
|
||||
body["payment_method_id"] = json!(pm);
|
||||
}
|
||||
let (st, e) = app.post(&format!("/periods/{pid}/entries"), s, body).await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||
e
|
||||
}
|
||||
|
||||
async fn report(app: &TestApp, s: &Session, query: &str) -> Value {
|
||||
let (st, b) = app.get(&format!("/reports/breakdown{query}"), s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{query} → {b}");
|
||||
b
|
||||
}
|
||||
|
||||
fn totals(list: &Value, key: &str) -> Vec<(String, i64)> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r[key].as_str().unwrap_or("-").to_string(),
|
||||
r["total"].as_i64().unwrap(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn breakdown_with_filters() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let market = id_by_name(&app, &s, "/categories", "Market").await;
|
||||
let giyim = id_by_name(&app, &s, "/categories", "Giyim").await;
|
||||
let kitap = id_by_name(&app, &s, "/categories", "Kitap").await;
|
||||
let nakit = id_by_name(&app, &s, "/payment-methods", "Nakit").await;
|
||||
let kart = id_by_name(&app, &s, "/payment-methods", "Kredi Kartı").await;
|
||||
|
||||
let (_, oct) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
|
||||
)
|
||||
.await;
|
||||
let oct = oct["id"].as_str().unwrap().to_string();
|
||||
spend(&app, &s, &oct, "2026-10-05", 100_000, &market, Some(&nakit)).await;
|
||||
spend(&app, &s, &oct, "2026-10-20", 50_000, &giyim, None).await;
|
||||
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
|
||||
.await;
|
||||
let (_, nov) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
|
||||
)
|
||||
.await;
|
||||
let nov = nov["id"].as_str().unwrap().to_string();
|
||||
spend(&app, &s, &nov, "2026-11-02", 30_000, &market, Some(&kart)).await;
|
||||
spend(&app, &s, &nov, "2026-11-03", 20_000, &kitap, Some(&nakit)).await;
|
||||
spend(&app, &s, &nov, "2026-11-04", 10_000, &market, Some(&nakit)).await;
|
||||
let gone = spend(&app, &s, &nov, "2026-11-05", 99_000, &kitap, None).await;
|
||||
app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
|
||||
.await;
|
||||
|
||||
// Filtresiz: tüm zamanlar
|
||||
let r = report(&app, &s, "").await;
|
||||
assert_eq!(r["total"], 210_000);
|
||||
assert_eq!(r["count"], 5, "silinen sayılmaz");
|
||||
assert_eq!(
|
||||
totals(&r["groups"], "group"),
|
||||
[
|
||||
("needs".into(), 140_000),
|
||||
("wants".into(), 50_000),
|
||||
("culture".into(), 20_000),
|
||||
("unexpected".into(), 0)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
totals(&r["categories"], "category_id"),
|
||||
[
|
||||
(market.clone(), 140_000),
|
||||
(giyim.clone(), 50_000),
|
||||
(kitap.clone(), 20_000)
|
||||
]
|
||||
);
|
||||
assert_eq!(r["categories"][0]["count"], 3);
|
||||
assert_eq!(r["categories"][0]["group"], "needs");
|
||||
assert_eq!(
|
||||
totals(&r["payment_methods"], "payment_method_id"),
|
||||
[
|
||||
(nakit.clone(), 130_000),
|
||||
("-".into(), 50_000),
|
||||
(kart.clone(), 30_000)
|
||||
],
|
||||
"ödeme türü girilmemiş → null"
|
||||
);
|
||||
assert_eq!(
|
||||
totals(&r["months"], "month"),
|
||||
[("2026-10".into(), 150_000), ("2026-11".into(), 60_000)]
|
||||
);
|
||||
|
||||
// Dönem
|
||||
assert_eq!(
|
||||
report(&app, &s, &format!("?period_id={nov}")).await["total"],
|
||||
60_000
|
||||
);
|
||||
// Tarih aralığı iki dönemi keser (ikisi de dahil)
|
||||
let r = report(&app, &s, "?from=2026-10-15&to=2026-11-03").await;
|
||||
assert_eq!(
|
||||
(r["total"].as_i64(), r["count"].as_i64()),
|
||||
(Some(100_000), Some(3))
|
||||
);
|
||||
// Grup, kategori, ödeme türü (+ girilmemiş)
|
||||
assert_eq!(report(&app, &s, "?group=needs").await["total"], 140_000);
|
||||
assert_eq!(
|
||||
report(&app, &s, &format!("?category_id={kitap}")).await["total"],
|
||||
20_000
|
||||
);
|
||||
assert_eq!(
|
||||
report(&app, &s, &format!("?payment_method_id={nakit}")).await["total"],
|
||||
130_000
|
||||
);
|
||||
assert_eq!(
|
||||
report(&app, &s, "?payment_method_id=none").await["total"],
|
||||
50_000
|
||||
);
|
||||
// Birleşik
|
||||
let r = report(
|
||||
&app,
|
||||
&s,
|
||||
&format!("?period_id={nov}&group=needs&payment_method_id={nakit}"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
(r["total"].as_i64(), r["count"].as_i64()),
|
||||
(Some(10_000), Some(1))
|
||||
);
|
||||
|
||||
// Hatalı filtreler
|
||||
for q in [
|
||||
"?from=2026-11-10&to=2026-11-01",
|
||||
"?from=2026-02-30",
|
||||
"?group=xyz",
|
||||
"?category_id=abc",
|
||||
] {
|
||||
let (st, b) = app.get(&format!("/reports/breakdown{q}"), &s).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{q} → {b}");
|
||||
}
|
||||
|
||||
// Başka hane: kendi verisi (boş); bu hanenin dönemi → 404
|
||||
let other = app.register("Diger", None).await;
|
||||
let r = report(&app, &other, "").await;
|
||||
assert_eq!(r["total"], 0);
|
||||
assert_eq!(r["groups"].as_array().unwrap().len(), 4);
|
||||
assert_eq!(r["categories"], json!([]));
|
||||
let (st, _) = app
|
||||
.get(&format!("/reports/breakdown?period_id={nov}"), &other)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Ay sonu değerlendirmesi: dört soru, grup/kategori/hafta dökümü, "Nasıl daha iyi yapabilirim?".
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use mongodb::bson::{Document, Uuid, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open_december(app: &TestApp, s: &Session, savings_goal: i64) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": savings_goal }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Gruptaki n'inci (0'dan) örnek kategori
|
||||
async fn category(app: &TestApp, s: &Session, group: &str, n: usize) -> String {
|
||||
let (_, cats) = app.get("/categories", s).await;
|
||||
cats.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|c| c["group"] == group)
|
||||
.nth(n)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64, cat: &str) -> Value {
|
||||
let (st, e) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/entries"),
|
||||
s,
|
||||
json!({ "date": date, "amount": amount, "category_id": cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||
e
|
||||
}
|
||||
|
||||
async fn plan(app: &TestApp, s: &Session, pid: &str, kind: &str, amount: i64) {
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
s,
|
||||
json!({ "kind": kind, "name": format!("{kind}-{amount}"), "amount": amount }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
|
||||
fn column(list: &Value, key: &str) -> Vec<i64> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| x[key].as_i64().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_questions_and_breakdowns() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_december(&app, &s, 500_000).await;
|
||||
plan(&app, &s, &pid, "income", 4_800_000).await;
|
||||
plan(&app, &s, &pid, "expense", 1_545_000).await;
|
||||
app.db
|
||||
.collection::<Document>("carryovers")
|
||||
.insert_one(doc! {
|
||||
"household_id": Uuid::parse_str(&s.household_id).unwrap(),
|
||||
"to_period_id": Uuid::parse_str(&pid).unwrap(),
|
||||
"kind": "debt", "amount": 100_000_i64, "deleted_at": null,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
app.put(
|
||||
&format!("/periods/{pid}/budget"),
|
||||
&s,
|
||||
json!({ "needs": 1_000_000, "wants": 500_000, "culture": 200_000, "unexpected": 100_000 }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (market, rent) = (
|
||||
category(&app, &s, "needs", 0).await,
|
||||
category(&app, &s, "needs", 1).await,
|
||||
);
|
||||
let wants = category(&app, &s, "wants", 0).await;
|
||||
let culture = category(&app, &s, "culture", 0).await;
|
||||
// Aralık 2026 haftaları: 1–6 · 7–13 · 14–20 · 21–27 · 28–31
|
||||
spend(&app, &s, &pid, "2026-12-01", 100_000, &market).await;
|
||||
spend(&app, &s, &pid, "2026-12-08", 50_000, &market).await;
|
||||
spend(&app, &s, &pid, "2026-12-08", 300_000, &rent).await;
|
||||
spend(&app, &s, &pid, "2026-12-15", 40_000, &wants).await;
|
||||
spend(&app, &s, &pid, "2026-12-31", 10_000, &culture).await;
|
||||
let gone = spend(&app, &s, &pid, "2026-12-15", 99_999, &wants).await;
|
||||
app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
|
||||
.await;
|
||||
|
||||
let (st, r) = app.get(&format!("/periods/{pid}/review"), &s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{r}");
|
||||
// 1: 4.800.000 − 1.545.000 − 100.000 = 3.155.000 · 3: 500.000 · 4: 3.155.000 − 500.000
|
||||
for (k, v) in [
|
||||
("available", 3_155_000),
|
||||
("savings_goal", 500_000),
|
||||
("spent_total", 500_000),
|
||||
("realized_savings", 2_655_000),
|
||||
("savings_gap", 2_155_000),
|
||||
("carryover_debt", 100_000),
|
||||
] {
|
||||
assert_eq!(r[k], v, "{k}: {r}");
|
||||
}
|
||||
assert_eq!(
|
||||
r["groups"],
|
||||
json!([
|
||||
{ "group": "needs", "planned": 1_000_000, "spent": 450_000 },
|
||||
{ "group": "wants", "planned": 500_000, "spent": 40_000 },
|
||||
{ "group": "culture", "planned": 200_000, "spent": 10_000 },
|
||||
{ "group": "unexpected", "planned": 100_000, "spent": 0 },
|
||||
])
|
||||
);
|
||||
// Kategoriler çoktan aza; silinen kayıt sayılmaz
|
||||
let cats = r["categories"].as_array().unwrap();
|
||||
assert_eq!(
|
||||
column(&r["categories"], "spent"),
|
||||
vec![300_000, 150_000, 40_000, 10_000]
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
cats[0]["category_id"].as_str(),
|
||||
cats[1]["category_id"].as_str()
|
||||
),
|
||||
(Some(rent.as_str()), Some(market.as_str()))
|
||||
);
|
||||
assert_eq!(
|
||||
(cats[1]["count"].as_i64(), cats[1]["group"].as_str()),
|
||||
(Some(2), Some("needs"))
|
||||
);
|
||||
// Haftalar: 4 grubun toplamı; planlananların toplamı aylık bütçelerin toplamı
|
||||
assert_eq!(
|
||||
column(&r["weeks"], "spent"),
|
||||
vec![100_000, 350_000, 40_000, 0, 10_000]
|
||||
);
|
||||
assert_eq!(
|
||||
column(&r["weeks"], "planned").iter().sum::<i64>(),
|
||||
1_800_000
|
||||
);
|
||||
assert_eq!(r["weeks"][4]["last_date"], "2026-12-31");
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overspending_gives_negative_savings() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_december(&app, &s, 100_000).await;
|
||||
plan(&app, &s, &pid, "income", 200_000).await;
|
||||
let needs = category(&app, &s, "needs", 0).await;
|
||||
spend(&app, &s, &pid, "2026-12-10", 250_000, &needs).await;
|
||||
|
||||
let (_, r) = app.get(&format!("/periods/{pid}/review"), &s).await;
|
||||
assert_eq!(
|
||||
(
|
||||
r["available"].as_i64(),
|
||||
r["realized_savings"].as_i64(),
|
||||
r["savings_gap"].as_i64()
|
||||
),
|
||||
(Some(200_000), Some(-50_000), Some(-150_000))
|
||||
);
|
||||
// Hiç bütçe yoksa: gruplar 0 planlı, haftalar yine dönem takviminden
|
||||
assert_eq!(column(&r["groups"], "planned"), vec![0; 4]);
|
||||
assert_eq!(r["weeks"].as_array().unwrap().len(), 5);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflection_text_and_access_rules() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let other = app.register("Diger", None).await;
|
||||
let pid = open_december(&app, &s, 0).await;
|
||||
let url = format!("/periods/{pid}");
|
||||
|
||||
let (st, p) = app
|
||||
.patch(
|
||||
&url,
|
||||
&s,
|
||||
json!({ "reflection_md": " **Dışarıda yemeği** azalt. " }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{p}");
|
||||
assert_eq!(p["reflection_md"], "**Dışarıda yemeği** azalt.");
|
||||
let (_, p) = app.get(&url, &s).await;
|
||||
assert_eq!(p["reflection_md"], "**Dışarıda yemeği** azalt.");
|
||||
|
||||
// Türkçe karakterler tek sayılır; sınırın üstü 400
|
||||
let (st, _) = app
|
||||
.patch(&url, &s, json!({ "reflection_md": "ş".repeat(20_000) }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
let (st, b) = app
|
||||
.patch(&url, &s, json!({ "reflection_md": "a".repeat(20_001) }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
b["error"]["message"],
|
||||
"Metin en fazla 20.000 karakter olabilir"
|
||||
);
|
||||
|
||||
// Boş metin → kaldırılır
|
||||
let (_, p) = app.patch(&url, &s, json!({ "reflection_md": "" })).await;
|
||||
assert_eq!(p["reflection_md"], Value::Null);
|
||||
|
||||
// Başka hane göremez
|
||||
let (st, _) = app.get(&format!("{url}/review"), &other).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
// Kapalı dönem: değerlendirme okunur, metin değiştirilemez
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(
|
||||
doc! { "_id": Uuid::parse_str(&pid).unwrap() },
|
||||
doc! { "$set": { "status": "closed" } },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (st, _) = app.get(&format!("{url}/review"), &s).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
let (st, _) = app.patch(&url, &s, json!({ "reflection_md": "geç" })).await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Dönem kapanışı → birikim: onayla TL varlığına ekleme, bekleyenler, yeniden kapatmada
|
||||
//! bağlı kaydın güncellenmesi / silinmesi, birikimden çıkış varsa reddedilmesi.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Ekim: gelir 10.000, harcama 3.000 → gerçekleşen 7.000; `to_savings` ile kapatılır
|
||||
async fn october(app: &TestApp, s: &Session, to_savings: i64) -> String {
|
||||
let (_, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
|
||||
)
|
||||
.await;
|
||||
let id = p["id"].as_str().unwrap().to_string();
|
||||
app.post(
|
||||
&format!("/periods/{id}/plan-items"),
|
||||
s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
|
||||
)
|
||||
.await;
|
||||
let (_, cats) = app.get("/categories", s).await;
|
||||
let cat = cats[0]["id"].as_str().unwrap();
|
||||
app.post(
|
||||
&format!("/periods/{id}/entries"),
|
||||
s,
|
||||
json!({ "date": "2026-10-10", "amount": 300_000, "category_id": cat }),
|
||||
)
|
||||
.await;
|
||||
close(app, s, &id, to_savings).await;
|
||||
id
|
||||
}
|
||||
|
||||
async fn close(app: &TestApp, s: &Session, id: &str, to_savings: i64) -> Value {
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{id}/close"),
|
||||
s,
|
||||
json!({ "to_savings": to_savings }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
b
|
||||
}
|
||||
|
||||
async fn reopen(app: &TestApp, s: &Session, id: &str) {
|
||||
let (st, b) = app
|
||||
.post(&format!("/periods/{id}/reopen"), s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
}
|
||||
|
||||
async fn cash(app: &TestApp, s: &Session) -> Value {
|
||||
let (_, list) = app.get("/assets", s).await;
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|a| a["code"] == "TRY")
|
||||
.unwrap()
|
||||
.clone()
|
||||
}
|
||||
|
||||
async fn cash_txs(app: &TestApp, s: &Session) -> Vec<Value> {
|
||||
let id = cash(app, s).await["id"].as_str().unwrap().to_string();
|
||||
let (_, list) = app.get(&format!("/assets/{id}/transactions"), s).await;
|
||||
list.as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_closing_savings_once() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let oct = october(&app, &s, 500_000).await;
|
||||
|
||||
let (_, pending) = app.get("/savings/pending", &s).await;
|
||||
assert_eq!(
|
||||
pending,
|
||||
json!([{ "period_id": oct, "name": "Ekim 2026", "last_date": "2026-10-31", "to_savings": 500_000 }])
|
||||
);
|
||||
|
||||
let (st, t) = app
|
||||
.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{t}");
|
||||
assert_eq!(t["type"], "buy");
|
||||
assert_eq!(t["total"], 500_000);
|
||||
assert_eq!(t["date"], "2026-10-31");
|
||||
assert_eq!(t["note"], "Ekim 2026 kapanışı");
|
||||
assert_eq!(t["source_period_id"], oct.as_str());
|
||||
assert_eq!(cash(&app, &s).await["cost"], 500_000);
|
||||
|
||||
let (st, b) = app
|
||||
.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "{b}");
|
||||
let (_, pending) = app.get("/savings/pending", &s).await;
|
||||
assert_eq!(pending, json!([]));
|
||||
|
||||
// Açık dönem eklenemez; başka hane göremez
|
||||
let (_, nov) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
|
||||
)
|
||||
.await;
|
||||
let (st, _) = app
|
||||
.post(
|
||||
&format!("/periods/{}/to-savings", nov["id"].as_str().unwrap()),
|
||||
&s,
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
let other = app.register("Diger", None).await;
|
||||
let (st, _) = app
|
||||
.post(&format!("/periods/{oct}/to-savings"), &other, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (_, op) = app.get("/savings/pending", &other).await;
|
||||
assert_eq!(op, json!([]));
|
||||
|
||||
// Silinen kayıt → dönem yeniden "bekleyen" olur
|
||||
let (st, _) = app
|
||||
.delete(
|
||||
&format!("/asset-transactions/{}", t["id"].as_str().unwrap()),
|
||||
&s,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
let (_, pending) = app.get("/savings/pending", &s).await;
|
||||
assert_eq!(pending.as_array().unwrap().len(), 1);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclosing_updates_or_removes_linked_savings() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let oct = october(&app, &s, 500_000).await;
|
||||
app.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
|
||||
.await;
|
||||
|
||||
// Yeni tutarla yeniden kapat → kayıt güncellenir
|
||||
reopen(&app, &s, &oct).await;
|
||||
let b = close(&app, &s, &oct, 400_000).await;
|
||||
assert_eq!(b["savings_linked"], true);
|
||||
let txs = cash_txs(&app, &s).await;
|
||||
assert_eq!(txs.len(), 1);
|
||||
assert_eq!(txs[0]["total"], 400_000);
|
||||
assert_eq!(txs[0]["version"], 2);
|
||||
assert_eq!(cash(&app, &s).await["cost"], 400_000);
|
||||
|
||||
// Birikim 0 → kayıt silinir; bekleyen de yok
|
||||
reopen(&app, &s, &oct).await;
|
||||
let b = close(&app, &s, &oct, 0).await;
|
||||
assert_eq!(b["savings_linked"], false);
|
||||
assert!(cash_txs(&app, &s).await.is_empty());
|
||||
let (_, pending) = app.get("/savings/pending", &s).await;
|
||||
assert_eq!(pending, json!([]));
|
||||
|
||||
// Kayıt yokken yeniden kapat → otomatik eklenmez, bekleyenlere düşer
|
||||
reopen(&app, &s, &oct).await;
|
||||
let b = close(&app, &s, &oct, 600_000).await;
|
||||
assert_eq!(b["savings_linked"], false);
|
||||
assert!(cash_txs(&app, &s).await.is_empty());
|
||||
let (_, pending) = app.get("/savings/pending", &s).await;
|
||||
assert_eq!(pending[0]["to_savings"], 600_000);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclosing_is_refused_when_savings_were_spent() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let oct = october(&app, &s, 500_000).await;
|
||||
app.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
|
||||
.await;
|
||||
// Birikimden 4.500 TL çıkış
|
||||
let cash_id = cash(&app, &s).await["id"].as_str().unwrap().to_string();
|
||||
let (st, _) = app
|
||||
.post(
|
||||
&format!("/assets/{cash_id}/transactions"),
|
||||
&s,
|
||||
json!({ "type": "sell", "date": "2026-11-05", "total": 450_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
reopen(&app, &s, &oct).await;
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{oct}/close"),
|
||||
&s,
|
||||
json!({ "to_savings": 300_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "{b}");
|
||||
assert!(
|
||||
b["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("TL çıkışları"),
|
||||
"{b}"
|
||||
);
|
||||
// Hiçbir şey değişmedi: dönem düzeltmede, kayıt 5.000 TL
|
||||
let (_, p) = app.get(&format!("/periods/{oct}"), &s).await;
|
||||
assert_eq!(p["status"], "correcting");
|
||||
let buy = cash_txs(&app, &s)
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|t| t["type"] == "buy")
|
||||
.unwrap();
|
||||
assert_eq!(buy["total"], 500_000);
|
||||
// Yeterli tutarla kapanır
|
||||
let b = close(&app, &s, &oct, 450_000).await;
|
||||
assert_eq!(b["savings_linked"], true);
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Panel verileri: günlük harcama ve dönem istatistikleri (toplam birikim dahil).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open(app: &TestApp, s: &Session, start: &str, end: &str, goal: i64) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": start, "end_date": end, "savings_goal": goal }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn category(app: &TestApp, s: &Session, group: &str) -> String {
|
||||
let (_, cats) = app.get("/categories", s).await;
|
||||
cats.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["group"] == group)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64, cat: &str) -> Value {
|
||||
let (st, e) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/entries"),
|
||||
s,
|
||||
json!({ "date": date, "amount": amount, "category_id": cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||
e
|
||||
}
|
||||
|
||||
async fn income(app: &TestApp, s: &Session, pid: &str, amount: i64) {
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": amount }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
|
||||
/// Kasım: gelir 10.000, harcama 3.000, 5.000 birikime → 2.000 artı Aralık'a.
|
||||
/// Aralık: gelir 20.000, hedef 1.000 → harcanabilir 21.000.
|
||||
async fn two_periods(app: &TestApp, s: &Session) -> (String, String) {
|
||||
let needs = category(app, s, "needs").await;
|
||||
let nov = open(app, s, "2026-11-01", "2026-12-01", 0).await;
|
||||
income(app, s, &nov, 1_000_000).await;
|
||||
spend(app, s, &nov, "2026-11-30", 300_000, &needs).await;
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{nov}/close"),
|
||||
s,
|
||||
json!({ "to_savings": 500_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
let dec = open(app, s, "2026-12-01", "2027-01-01", 100_000).await;
|
||||
income(app, s, &dec, 2_000_000).await;
|
||||
(nov, dec)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn daily_spending_by_group() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let (_, dec) = two_periods(&app, &s).await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let wants = category(&app, &s, "wants").await;
|
||||
spend(&app, &s, &dec, "2026-12-01", 100_000, &needs).await;
|
||||
spend(&app, &s, &dec, "2026-12-01", 50_000, &wants).await;
|
||||
spend(&app, &s, &dec, "2026-12-07", 20_000, &needs).await;
|
||||
spend(&app, &s, &dec, "2026-12-31", 7_000, &needs).await;
|
||||
let gone = spend(&app, &s, &dec, "2026-12-02", 99_000, &needs).await;
|
||||
let (st, _) = app
|
||||
.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
|
||||
let (st, days) = app.get(&format!("/periods/{dec}/daily"), &s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{days}");
|
||||
let days = days.as_array().unwrap();
|
||||
assert_eq!(days.len(), 31, "her gün, harcama olmasa da");
|
||||
assert_eq!(days[0]["date"], "2026-12-01");
|
||||
assert_eq!(
|
||||
days[0]["spent"],
|
||||
json!({ "needs": 100_000, "wants": 50_000, "culture": 0, "unexpected": 0 })
|
||||
);
|
||||
assert_eq!(days[1]["spent"]["needs"], 0, "silinen kayıt sayılmaz");
|
||||
assert_eq!(days[6]["spent"]["needs"], 20_000);
|
||||
assert_eq!(days[30]["date"], "2026-12-31");
|
||||
assert_eq!(days[30]["spent"]["needs"], 7_000, "son gün dahil");
|
||||
// Kasım'ın 30.11 harcaması Aralık'a karışmaz
|
||||
let total: i64 = days
|
||||
.iter()
|
||||
.map(|d| d["spent"]["needs"].as_i64().unwrap())
|
||||
.sum();
|
||||
assert_eq!(total, 127_000);
|
||||
|
||||
// Başka hane göremez
|
||||
let other = app.register("Diger", None).await;
|
||||
let (st, _) = app.get(&format!("/periods/{dec}/daily"), &other).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn period_stats_and_total_savings() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let (nov, dec) = two_periods(&app, &s).await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let culture = category(&app, &s, "culture").await;
|
||||
spend(&app, &s, &dec, "2026-12-05", 40_000, &needs).await;
|
||||
spend(&app, &s, &dec, "2026-12-06", 2_500, &culture).await;
|
||||
|
||||
let (st, b) = app.get("/stats/periods", &s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
assert_eq!(b["total_saved"], 500_000);
|
||||
let list = b["periods"].as_array().unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
// Eskiden yeniye
|
||||
assert_eq!(list[0]["id"], nov.as_str());
|
||||
assert_eq!(list[0]["status"], "closed");
|
||||
assert_eq!(list[0]["spendable"], 1_000_000);
|
||||
assert_eq!(list[0]["spent"], 300_000);
|
||||
assert_eq!(
|
||||
list[0]["spent_by_group"],
|
||||
json!({ "needs": 300_000, "wants": 0, "culture": 0, "unexpected": 0 })
|
||||
);
|
||||
assert_eq!(list[0]["to_savings"], 500_000);
|
||||
assert_eq!(list[0]["last_date"], "2026-11-30");
|
||||
assert_eq!(list[1]["id"], dec.as_str());
|
||||
assert_eq!(
|
||||
list[1]["spendable"], 2_100_000,
|
||||
"20.000 − 1.000 hedef + 2.000 artı"
|
||||
);
|
||||
assert_eq!(list[1]["spent"], 42_500);
|
||||
assert_eq!(list[1]["spent_by_group"]["needs"], 40_000);
|
||||
assert_eq!(list[1]["spent_by_group"]["culture"], 2_500);
|
||||
assert_eq!(list[1]["income_total"], 2_000_000);
|
||||
assert_eq!(list[1]["savings_goal"], 100_000);
|
||||
assert_eq!(list[1]["carryover_surplus"], 200_000);
|
||||
assert_eq!(
|
||||
list[0]["realized_savings"], 700_000,
|
||||
"Kasım: 10.000 − 3.000"
|
||||
);
|
||||
assert_eq!(
|
||||
list[1]["realized_savings"], 2_157_500,
|
||||
"20.000 + 2.000 artı − 425 harcanan (hedef düşülmez)"
|
||||
);
|
||||
assert_eq!(list[1]["to_savings"], Value::Null);
|
||||
|
||||
// limit: en yeni N dönem
|
||||
let (_, b) = app.get("/stats/periods?limit=1", &s).await;
|
||||
let list = b["periods"].as_array().unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0]["id"], dec.as_str());
|
||||
assert_eq!(
|
||||
b["total_saved"], 500_000,
|
||||
"toplam birikim limitten bağımsız"
|
||||
);
|
||||
|
||||
// Başka hane: boş
|
||||
let other = app.register("Diger", None).await;
|
||||
let (_, b) = app.get("/stats/periods", &other).await;
|
||||
assert_eq!(b["total_saved"], 0);
|
||||
assert_eq!(b["periods"], json!([]));
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Bu bilgisayar: ~/.config/systemd/user/kakeibo-backup-pull.service
|
||||
[Unit]
|
||||
Description=Kakeibo yedeklerini sunucudan çek
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# ~/.ssh/config'teki sunucu adı "atlas" değilse başındaki # işaretini kaldırıp düzeltin
|
||||
#Environment=KAKEIBO_BACKUP_HOST=atlas
|
||||
ExecStart=%h/Developer/Rust/kakeibo/scripts/backup-pull.sh
|
||||
@@ -0,0 +1,12 @@
|
||||
# Bu bilgisayar: ~/.config/systemd/user/kakeibo-backup-pull.timer
|
||||
[Unit]
|
||||
Description=Kakeibo yedeklerini her gün çek
|
||||
|
||||
[Timer]
|
||||
# Sunucudaki yedekten sonra; bilgisayar kapalıysa açıldıktan 10 dk sonra
|
||||
OnCalendar=*-*-* 09:00:00
|
||||
OnStartupSec=10min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,13 @@
|
||||
# Atlas (sunucu): /etc/systemd/system/kakeibo-backup.service
|
||||
[Unit]
|
||||
Description=Kakeibo MongoDB günlük yedeği
|
||||
Wants=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Yedeği çeken SSH kullanıcısı root değilse: başındaki # işaretini kaldırıp grubunu yazın.
|
||||
#Environment=BACKUP_GROUP=<ssh-kullanıcınız>
|
||||
ExecStart=/usr/local/lib/kakeibo/backup.sh
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
@@ -0,0 +1,12 @@
|
||||
# Atlas (sunucu): /etc/systemd/system/kakeibo-backup.timer
|
||||
[Unit]
|
||||
Description=Kakeibo yedeği her gece 03:30
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:30:00
|
||||
# Sunucu o saatte kapalıysa açılınca hemen çalışır
|
||||
Persistent=true
|
||||
RandomizedDelaySec=5m
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
+16
-8
@@ -81,16 +81,17 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
```js
|
||||
{ _id, household_id, name /* "Aralık 2026" */,
|
||||
start_date: "2026-12-01", end_date: "2027-01-01", // bitiş HARİÇ
|
||||
status: "open" | "closed",
|
||||
status: "open" | "closed" | "correcting",
|
||||
savings_goal: 500000, // kuruş — birikim hedefi
|
||||
reflection_md: "...", // "Nasıl daha iyi yapabilirim?"
|
||||
closed_at, closed_by, reopened_at,
|
||||
to_savings: 450000, // kapanışta birikime aktarılan (Faz 8'de varlık hareketi)
|
||||
closed_at, closed_by, reopened_at, reopened_by,
|
||||
previous_period_id,
|
||||
created_at, updated_at, deleted_at, version }
|
||||
```
|
||||
- **Bitiş tarihi hariç:** `01.12.2026 – 01.01.2027` → 1–31 Aralık. Dönemler arka arkaya boşluksuz ve çakışmasız eklenir.
|
||||
- Hanede en fazla **bir** `open` dönem (servis kontrolü + kısmi benzersiz indeks).
|
||||
- Yeniden açılan dönemin durumu tekrar `open` olur. O sırada başka açık dönem varsa önce o kapatılmalı mı, yoksa geçici olarak iki açık dönem mi olsun? → **Açık konu, Faz 6'da netleşecek.**
|
||||
- **Yeniden açma (karar 2026-11-12, "düzeltme modu"):** sahip kapalı dönemi açınca durum `correcting` olur; yazılabilir ama "açık dönem" sayılmaz → yeni dönem açık kalabilir (tek `open` kuralı korunur). Mevcut devir "yeniden kapat"a kadar yerinde kalır; yeniden kapatınca eskisi soft delete, yenisi hesaplanır. Ardından kapanmış dönem varsa açılamaz (zincirleme devirler bozulmasın).
|
||||
- **Haftalar saklanmaz.** `start_date`/`end_date`'ten hesaplanır (Pazartesi başlangıçlı; ilk/son kısmi haftalar ayrı).
|
||||
|
||||
### 3.5 `categories` — Alt kategoriler
|
||||
@@ -143,7 +144,8 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
kind: "debt" | "surplus", amount: 120000,
|
||||
created_at, ... }
|
||||
```
|
||||
- Dönem kapanışında oluşur. Dönem yeniden açılırsa soft delete edilir ve kapanışta yeniden hesaplanır.
|
||||
- Dönem kapanışında oluşur. Sonraki dönem henüz yoksa `to_period_id: null`; o dönem açılınca (aynı transaction'da) bağlanır.
|
||||
- Yeniden kapatmada eskisi soft delete edilir, yenisi yazılır (düzeltme süresince eskisi geçerli kalır).
|
||||
|
||||
### 3.11 `notes`
|
||||
```js
|
||||
@@ -158,8 +160,9 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
kind: "cash" | "gold" | "fx",
|
||||
code: "TRY" | "XAU_GRAM" | "XAU_CEYREK" | "XAU_YARIM" | "XAU_TAM" | "XAU_ONS" | "USD" | "EUR" | ...,
|
||||
name: "Gram Altın", unit_label: "gr",
|
||||
is_active, created_at, ... }
|
||||
sort_order, is_active, created_at, ... }
|
||||
```
|
||||
- Varsayılanlar hane açılınca gelir (migration v2 eski hanelere ekler): TRY, XAU_GRAM, XAU_CEYREK, XAU_YARIM, XAU_TAM, XAU_ONS, USD, EUR. Kullanıcı yalnızca **döviz** (`fx`, 3 harfli kod) ekler; kod hanede tekil. Pasif varlık listede kalır, yeni işlem almaz.
|
||||
|
||||
### 3.13 `asset_transactions` — Alım / satım
|
||||
```js
|
||||
@@ -172,7 +175,10 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
note, source_period_id /* dönem kapanışından geldiyse */,
|
||||
created_at, ... }
|
||||
```
|
||||
- Ortalama maliyet ve kâr/zarar bu kayıtlardan hesaplanır, saklanmaz.
|
||||
- Ortalama maliyet ve kâr/zarar bu kayıtlardan hesaplanır, saklanmaz: **ağırlıklı ortalama maliyet** (satışta çıkan maliyet = maliyet × satılan / eldeki; gerçekleşen kâr/zarar = satış tutarı − çıkan maliyet). Maliyet `total`'dan hesaplanır (birim fiyat bilgi amaçlı; verilmezse `total ÷ quantity`).
|
||||
- Tarih sırasıyla (aynı gün: ekleniş sırası) hiçbir anda eldeki miktar eksiye düşemez; ekleme, düzenleme ve silmede yeniden denetlenir.
|
||||
- TL varlığında `quantity_e6 = total × 10.000`, `unit_price_e6 = 1.000.000`.
|
||||
- **Kapanıştan birikim:** kullanıcı onaylayınca (`POST /periods/{id}/to-savings`) dönemin `to_savings` tutarı TL varlığına `buy` olarak eklenir: tarih = dönemin son günü, `source_period_id` = dönem; dönem başına tek. Eklenmemiş kapalı dönemler `GET /savings/pending`. **Yeniden kapatmada** bağlı kayıt yeni tutar ve tarihe güncellenir (tutar 0 → silinir), kapatma transaction'ı içinde; TL'den sonraki çıkışlar yeni tutarı aşacaksa kapatma reddedilir (kullanıcı kararı "a", 2026-11-13). Bağlı kayıt yoksa otomatik eklenmez (onay gerekir).
|
||||
|
||||
### 3.14 `exchange_rates` — Kurlar
|
||||
```js
|
||||
@@ -182,7 +188,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
|
||||
@@ -223,7 +231,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
## 6. Dönem Kapanışı (tek transaction)
|
||||
1. `periods.status = "closed"`, `closed_at`, `closed_by`
|
||||
2. `carryovers` kaydı (borç veya artı) → sonraki dönem
|
||||
3. (Onaylıysa) gerçekleşen birikim → `asset_transactions` (`TRY`, `buy`, `source_period_id`)
|
||||
3. Gerçekleşen birikimin `to_savings` kadarı birikime: şimdilik `periods.to_savings`; Faz 8'de → `asset_transactions` (`TRY`, `buy`, `source_period_id`)
|
||||
4. (İstenirse) yeni dönem: `plan_items` ve aylık `budgets` kopyalanarak açılır
|
||||
|
||||
Transaction'ın bir adımı hata verirse önceki adımlar da geri alınır, hiçbir şey değişmez.
|
||||
|
||||
+99
-3
@@ -1,6 +1,6 @@
|
||||
# 🚀 Canlıya Alma (Atlas Homeserver + Dokploy)
|
||||
|
||||
> Durum: Faz 3 — Adım 1 ✅ (Docker imajı) · Adım 2–5 devam ediyor
|
||||
> Durum: ✅ Yayında — https://kakeibo.ta1sph.website (2026-11-12) · Yedekleme: betikler hazır, sunucuda kurulum bekliyor (§6)
|
||||
|
||||
## 1. Mimari
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
@@ -70,8 +71,103 @@ Uygulama zaten kullanıcı/şifreyle bağlandığı için auth açıldığında
|
||||
|
||||
## 5. Dokploy kurulumu
|
||||
|
||||
> Faz 3 – Adım 3'te birlikte doldurulacak.
|
||||
Proje `Kakeibo` → Create Service → **Application** `kakeibo-app` (iç ad: `kakeibo-kakeiboapp-nd5kek`).
|
||||
|
||||
| Bölüm | Ayar |
|
||||
|---|---|
|
||||
| General → Provider | **Gitea** sekmesi (varsayılan GitHub sekmesi seçili kalırsa: `Github Provider not found`) → repo `suphicakir/kakeibo`, branch `main`, Build Path `/` → **Save** |
|
||||
| General → Build Type | **Dockerfile**, dosya `Dockerfile`, context `.` |
|
||||
| Environment | §2'deki değişkenler (`MONGODB_URI`, `JWT_SECRET`, `APP_URL=https://kakeibo.ta1sph.website`, `REGISTRATION=invite_only`, `RUST_LOG`) |
|
||||
| Domains | Host `kakeibo.ta1sph.website`, Path `/`, **Container Port `8080`**, HTTPS açık, Let's Encrypt |
|
||||
| Auto Deploy | Açık → Gitea'ya push = otomatik yayın |
|
||||
|
||||
Notlar:
|
||||
- İlk derleme ~6–10 dk (Rust bağımlılıkları); sonrakiler katman önbelleğiyle daha hızlı.
|
||||
- Derleme hatası **Deployments** → ilgili deploy logunda görünür; **Logs** sekmesi yalnızca çalışan konteyneri gösterir.
|
||||
- Dokploy, Gitea'daki son commit'i derler → değişiklikler önce push edilmeli.
|
||||
- Geri dönüş (rollback): Deployments listesinden önceki başarılı deploy seçilerek yeniden yayınlanır.
|
||||
- DNS: `kakeibo.ta1sph.website` → ev dış IP'si; modemde 80/443 → Atlas yönlendirmesi Let's Encrypt için gerekli.
|
||||
- Sade metin `404 page not found` + "Not secure" = Traefik'in kendi yanıtı (bu host için router yok): domain eklenip **Redeploy** yapılmamış ya da konteyner çalışmıyor demektir.
|
||||
|
||||
### İlk yayın kontrolü (2026-11-12) ✅
|
||||
- `/api/health` → `{"status":"ok","db":"ok"}`, servis `1/1` healthy, migration v1 + indeksler canlı DB'de oluştu.
|
||||
- TLS: Let's Encrypt (otomatik yenileme), `/kayit` doğrudan açılıyor (SPA).
|
||||
- İlk hesap açıldı → `GET /api/auth/registration` = `new_household_allowed: false` (yalnızca davet).
|
||||
|
||||
## 6. Yedekleme ve geri yükleme
|
||||
|
||||
> Faz 3 – Adım 5'te doldurulacak (günlük `mongodump`, son 14 yedek).
|
||||
```
|
||||
Atlas 03:30 backup.sh ──▶ /var/backups/kakeibo/kakeibo-YYYY-MM-DD_HHMM.archive.gz (son 14)
|
||||
│ rsync (SSH)
|
||||
Bu bilgisayar 09:00 / açılışta ──▶ ~/Yedekler/kakeibo/ (son 60, 2 günden eskiyse masaüstü uyarısı)
|
||||
```
|
||||
|
||||
| Dosya | Nerede çalışır | İş |
|
||||
|---|---|---|
|
||||
| `scripts/backup.sh` | Atlas → `/usr/local/lib/kakeibo/` | `mongodump --archive --gzip` → gzip + `mongorestore --dryRun` denetimi → son 14'ü tutar |
|
||||
| `scripts/restore.sh` | Atlas (ve yerelde `npm run db:restore`) | yedeği **başka adla** geri yükler, kayıt sayılarını gösterir/karşılaştırır |
|
||||
| `scripts/backup-pull.sh` | bu bilgisayar | yeni yedekleri çeker, eskiyse uyarır |
|
||||
| `deploy/systemd/*` | ikisi | zamanlayıcılar (`Persistent=true`: kapalıyken kaçan çalışma açılınca yapılır) |
|
||||
|
||||
Yerel deneme: `npm run db:backup` (kakeibo_dev → `./backups`, git'e girmez) ·
|
||||
`COMPARE_DB=kakeibo_dev npm run db:restore -- latest kakeibo_restore_test`.
|
||||
|
||||
### 6.1 Atlas'ta kurulum (bir kez)
|
||||
Bu bilgisayarda, proje klasöründe:
|
||||
```bash
|
||||
scp scripts/backup.sh scripts/restore.sh deploy/systemd/kakeibo-backup.service deploy/systemd/kakeibo-backup.timer atlas:/tmp/
|
||||
ssh atlas
|
||||
```
|
||||
Atlas'ta:
|
||||
```bash
|
||||
sudo install -Dm755 /tmp/backup.sh /usr/local/lib/kakeibo/backup.sh
|
||||
sudo install -Dm755 /tmp/restore.sh /usr/local/lib/kakeibo/restore.sh
|
||||
sudo install -m644 /tmp/kakeibo-backup.service /tmp/kakeibo-backup.timer /etc/systemd/system/
|
||||
```
|
||||
- SSH'ye **root olmayan** bir kullanıcıyla bağlanıyorsanız: `sudo systemctl edit --full kakeibo-backup.service` →
|
||||
`#Environment=BACKUP_GROUP=…` satırının başındaki `#`'i kaldırıp kendi kullanıcı grubunuzu yazın (yoksa yedekleri çekemezsiniz).
|
||||
|
||||
İlk yedek ve kontrol:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl start kakeibo-backup.service
|
||||
journalctl -u kakeibo-backup -n 20 --no-pager # "tamam: …" görülmeli
|
||||
```
|
||||
|
||||
### 6.2 Geri yükleme denemesi (kurulumda bir kez, sonra ayda bir önerilir)
|
||||
Canlı veriye dokunmaz; ayrı `kakeibo_restore_test` veritabanına yükler ve sayıları karşılaştırır:
|
||||
```bash
|
||||
sudo COMPARE_DB=kakeibo /usr/local/lib/kakeibo/restore.sh latest kakeibo_restore_test
|
||||
# "✅ kakeibo ile aynı" görülmeli. Sonra deneme veritabanını silin:
|
||||
sudo docker exec $(sudo docker ps -q -f name=kakeibo-kakeibomongo-fibzhc) mongosh --quiet kakeibo_restore_test --eval 'db.dropDatabase()'
|
||||
```
|
||||
Sonra zamanlayıcıyı açın:
|
||||
```bash
|
||||
sudo systemctl enable --now kakeibo-backup.timer
|
||||
systemctl list-timers kakeibo-backup.timer # bir sonraki çalışma saati
|
||||
```
|
||||
|
||||
### 6.3 Bu bilgisayarda (sunucu dışı kopya)
|
||||
Önce şifresiz SSH çalışmalı: `ssh -o BatchMode=yes atlas ls /var/backups/kakeibo` yedek dosyasını listelemeli.
|
||||
```bash
|
||||
scripts/backup-pull.sh # elle bir kez: ~/Yedekler/kakeibo/
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp deploy/systemd/kakeibo-backup-pull.service deploy/systemd/kakeibo-backup-pull.timer ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now kakeibo-backup-pull.timer
|
||||
```
|
||||
- SSH'de sunucunun adı `atlas` değilse: `systemctl --user edit --full kakeibo-backup-pull.service` → `KAKEIBO_BACKUP_HOST` satırı.
|
||||
- Günlük: `journalctl --user -u kakeibo-backup-pull -n 20`.
|
||||
|
||||
### 6.4 Felaket sonrası geri yükleme (canlı veritabanının yerine)
|
||||
1. Dokploy → `kakeibo-app` → **Stop** (yükleme sırasında yazma olmasın).
|
||||
2. Gerekirse yedeği sunucuya kopyalayın: `scp ~/Yedekler/kakeibo/<dosya> atlas:/tmp/`.
|
||||
3. Atlas'ta: `sudo /usr/local/lib/kakeibo/restore.sh /tmp/<dosya> kakeibo --force` (önce mevcut `kakeibo` silinir).
|
||||
4. `kakeibo-app` → **Start**, `/api/health` ve giriş kontrolü.
|
||||
- MongoDB servisi tamamen yeniden kurulduysa önce §4'teki kullanıcıları (`kakeibo_app`) yeniden oluşturun.
|
||||
- Yedekte kullanıcı şifre özetleri (argon2) ve oturum bilgisi bulunur → yedek dosyalarını paylaşmayın.
|
||||
|
||||
### Notlar
|
||||
- MongoDB'de kimlik doğrulaması zorunlu olmadığından (§4) `mongodump` konteynerin içinden şifresiz çalışır.
|
||||
Auth açıldığında yedek için ayrı bir `backup` rolü kullanıcısı ve betiğe `--config` ile şifre dosyası eklenmeli.
|
||||
- Yedek boyutu şu an birkaç KB–MB; 14 + 60 dosya önemsiz yer tutar.
|
||||
|
||||
+61
-2
@@ -1,9 +1,17 @@
|
||||
# 🧠 Proje Hafızası
|
||||
|
||||
## ▶️ Sıradaki Adım
|
||||
**Faz 3 – Adım 3:** Dokploy Application (`kakeibo-app`, Gitea repo, Dockerfile), ortam değişkenleri, domain `kakeibo.ta1sph.website` (HTTPS), deploy, health kontrolü. Kullanıcı süreçten yoruldu → kısa ve net adımlar, gereksiz tur yok.
|
||||
**Faz 9 – Adım 3:** Uygulamanın kendi CSV'si (harcamalar, plan kalemleri, varlık işlemleri): dışa aktarma (backend ya da frontend; `lib/csv.ts` biçimi: BOM, `;`, `1250,50`, GG.AA.YYYY) + içe aktarma (önizleme → satır satır hata → geçerliler aktarılır; kategori/ödeme türü **adla** eşleşir; dönem tarihten bulunur, yazılamaz dönem → satır hatası). Adım 4 (ekstre) bunun üstüne kurulacak: ortak satır doğrulama/aktarma servisi tasarla.
|
||||
**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
|
||||
- **Faz 9 kararları (kullanıcı, 2026-11-13):** (1) Aylık rapor biçimini **Claude tasarlar**: müşteri gözüyle ne yaptım / ne yapılabilirdi / ne yapılmalıydı. (2) JSON yedek: Claude best practice analizi yapıp kullanıcıyı üzmeyen çözüm üretir (öneri: sunucuda otomatik geri dönüş noktası + "geri al", önizleme, sahip). (3) **Tüm kredi kartı ve nakit/banka hesabı ekstreleri** içe aktarılabilsin; amaç **yıl başından beri** veriyi toplayıp sonraki aylara referans; grup/kategori eşleştirmesi kullanıcıya bırakılabilir → ayrı adım (Faz 9 Adım 4), geçmiş dönemler gerekir.
|
||||
- **Yeniden kapatmada birikim (kullanıcı "a", 2026-11-13):** bağlı TL alımı yeni tutara güncellenir (0 → silinir).
|
||||
- **Faz 8 kararları (kullanıcı, 2026-11-13):** kur kaynakları TCMB (döviz) + xaus.com ons×TCMB USD (altın; çeyrek/yarım/tam has gram ile) + manuel; değerleme **alış kuru**; kapanış birikimi onayla TL varlığına alım (tekrar eklenmez), geçmiş kapanışlar "bekleyen birikimler".
|
||||
- **Kategori yönetimi ekranı (kullanıcı, 2026-11-13): Faz 10'da** (seçenek b). Faz 8 önce.
|
||||
- **Kur kaynağı araştırması (2026-11-13):** TCMB `today.xml` döviz var, **altın yok** (USD ForexBuying/Selling, 22 kod). xaus.com `/api/v1/spot` anahtarsız çalışıyor (`spot_usd_oz`, `per_gram_usd`), resmi değil → yedek: son kur + manuel. Truncgil doğrulanamadı.
|
||||
- **Kategoriler (kullanıcı, 2026-11-12):** 3. kat alt kategori **yok** (grup → kategori). Şartı: raporlarda **tüm** kategorilerin dağılımı görünmeli (yalnızca ilk 5 değil). Amaç "market ne için?" gibi kırılım → kategoriyi bölerek (ör. Market · Gıda / Temizlik); bunun için Ayarlar'da kategori yönetimi ekranı gerekli.
|
||||
- **Canlı sürüm eski (2026-11-12 kontrol):** push'lar Gitea'da ama Dokploy yeniden derlemiyor (Faz 4+ yok, `/api/stats/periods` 404). Auto Deploy için Gitea webhook'u gerekiyor → kullanıcıya adım adım anlatıldı.
|
||||
- **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`).
|
||||
- Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`.
|
||||
- Her adım sonunda Claude commit atar ve haber verir; push'u kullanıcı kendi uygulamasıyla yapar (Claude `git push` çalıştırmaz).
|
||||
@@ -36,12 +44,21 @@
|
||||
- Canlı imaj: node:26-alpine → rust:1.99-slim-trixie → debian:trixie-slim (ca-certificates + curl, uid 10001 `kakeibo`), 174 MB. Docker katman önbelleği için sahte lib/main ile bağımlılık derlemesi; kalıntılar `*kakeibo*` + `.fingerprint` silinir, gerçek kaynak `touch`lanır (yoksa sahte lib kullanılıyordu).
|
||||
- Önbellek: HTML `no-cache`, `/assets/*` 1 yıl immutable, diğer statik 1 saat. Bilinmeyen `/api/*` → JSON 404.
|
||||
- Canlı: domain `kakeibo.ta1sph.website` (DNS → 159.146.61.151, ev dış IP'si olmalı), Gitea Dokploy'a bağlı. Canlı MongoDB = ayrı Dokploy servisi `kakeibo-kakeibomongo-fibzhc` (rs0), kullanıcı `kakeibo_app` (readWrite/kakeibo).
|
||||
- Dönem: bitiş hariç, en fazla 62 gün, hane başına tek açık, çakışma yok (metin karşılaştırması), kapalı salt okunur, daraltmada dışarıda harcama kalırsa 409. Ad boşsa otomatik (`calendar::default_period_name`). Tüm üyeler açabilir/düzenleyebilir.
|
||||
- ts-rs: `i64` tutarlar `#[ts(type = "number")]`; isteklerde `Option<i64>` için `#[ts(type = "number", optional)]` (yoksa `?` kayboluyor).
|
||||
- Plan kalemleri: tarih isteğe bağlı (dönem içinde), tutar > 0, kopyalama göreli gün + dönem sonuna kırpma, aynı tür+ad (büyük/küçük harf duyarsız) atlanır. Notlar: başlık 1–120, metin ≤ 20.000. Kapalı dönemde hepsi 409 (`period::get_open`). Silme = soft delete.
|
||||
- İzolasyon iki katmanlı: kayıt okuma `scope()` + dönem `get_open(hid, period_id)` + yazma filtresi `scope()`. Tek katmanı bozmak açık oluşturmuyor (mutasyon denemesiyle görüldü).
|
||||
- Bütçe: aylık = `budgets{scope:"month"}`; haftalık otomatik = (aylık − elle girilenler) gün oranında, `money::split_proportional` (en büyük kalan, toplam tam eşit). Elle hafta = `scope:"week", week_start`; `null` → soft delete. Aylık değişince elle haftalar korunur; elle toplam > aylık → `weekly_over`.
|
||||
- Özet: spendable = gelir − sabit gider − birikim − borç + artı (`carryovers.to_period_id`). `unallocated` = spendable − bütçe toplamı (negatif = fazla dağıtım).
|
||||
- Öneri: default = hane `settings.budget_split_pct` × spendable (≤0 → available:false); previous/avg3 = önceki kapalı dönem(ler)in `entries` toplamı / dönem sayısı (yuvarlayarak).
|
||||
- Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7, Tailwind 4.3, shadcn 4.23, Vitest 5, react-router 8, TanStack Query 5.
|
||||
|
||||
## ❓ Açık Konular
|
||||
- Geliştirme makinesi: CachyOS nodejs 26.13.0-2 ↔ simdjson .so.36 uyumsuzluğu → simdjson 2:5.0.5-1'e düşürüldü (2026-11-12). Düzeltilmiş nodejs gelene kadar `pacman -Syu --ignore simdjson`. `node: libsimdjson.so.34` hatası görülürse sebep budur.
|
||||
- **Canlı yedekleme kurulumu bekliyor** (betikler hazır, `docs/DEPLOY.md` §6) → gerçek veri girişinden önce.
|
||||
- **Canlı MongoDB auth kapalı** (Dokploy replica set betiği keyFile kullanmıyor, özel Command'ı yok sayıyor). İç ağda, port kapalı. Ayrıntı: `docs/DEPLOY.md` §4.
|
||||
- Üye çıkarma / rol değiştirme / sahipliği devretme henüz yok (Ayarlar ekranıyla birlikte, Faz 2+).
|
||||
- Kapalı dönem yeniden açılırken başka açık dönem varsa davranış (Faz 6).
|
||||
- ~~Kapalı dönem yeniden açma~~ → çözüldü: düzeltme modu (`correcting`). Arayüzde düzeltmedeki dönem için Defter/Plan dönem seçimi Faz 6 Adım 5'te.
|
||||
- Altın kuru için veri kaynağı seçimi (Faz 8).
|
||||
- Aylık değerlendirme çıktı formatı kullanıcıdan gelecek (Faz 9).
|
||||
- Canlı MongoDB kurulum şekli (konteyner mi, doğrudan mı) Faz 3'te birlikte kontrol edilecek.
|
||||
@@ -76,3 +93,45 @@
|
||||
### 2026-11-12 — Faz 3
|
||||
- **Adım 1:** tower-http (fs, compression gzip/br, trace). `routes::app()` + `routes/spa.rs` (ServeDir + index.html fallback + cache middleware), API fallback JSON 404, `STATIC_DIR` config, graceful shutdown (SIGTERM/Ctrl+C). `Dockerfile`, `.dockerignore`, `docs/DEPLOY.md` başlatıldı. İmaj yerelde çalıştırıldı (host network, 8090): 10 kontrol + tarayıcı ekran görüntüsü geçti. İlk derlemede Docker önbellek hilesi hatası (sahte lib.rs) bulundu, düzeltildi.
|
||||
- **Adım 2:** Mevcut tek MongoDB (butcem-mongo) Bütçem'e ait → kullanıcı ayrı MongoDB seçti. Dokploy MongoDB (replica set) kuruldu, `kakeibo_app` oluşturuldu, port yayını yok (iki servis de `null`). Sorunlar: kullanıcı şifreyi komuta düz yazıp paylaştı; Dokploy betiği root şifresini servis Args'ına düz yazıyor → iki şifre de mongosh içinde üretilerek yenilendi. passwordPrompt yapıştırma hatası (U_STRINGPREP). Auth açma denemesi: Dokploy özel Command'ı uygulamadı → auth kapalı kabul edildi (açık konu).
|
||||
- **Adım 3–4:** Dokploy Application `kakeibo-app` (iç ad `kakeibo-kakeiboapp-nd5kek`). Sorunlar: push yapılmamıştı (Dockerfile yok); Provider GitHub sekmesinde kalmıştı (`Github Provider not found`, kullanıcı çözdü); Traefik 404 (kullanıcı çözdü, büyük olasılıkla domain sonrası redeploy). **Yayında:** https://kakeibo.ta1sph.website — health ok, Let's Encrypt, ilk hesap açıldı. **Faz 3 tamamlandı (yedekleme hariç).**
|
||||
|
||||
### 2026-11-12 — Faz 4
|
||||
- **Adım 1:** `services/calendar.rs` (parse/format, `weeks` Pazartesi başlangıçlı + kısmi haftalar, Türkçe ay adları; 4 test), `models/period.rs`, `services/period.rs` (2 test), `routes/period.rs` (list/current/create/get/patch; DTO'da `last_date`, `days`, `weeks`). `tests/periods.rs` (5) — çakışma kontrolü bilerek bozulunca test yakaladı. Backend toplam 58 test.
|
||||
- **Adım 2:** `models/plan_item.rs`, `models/note.rs`, `services/plan.rs` (list/create/update/delete/copy_previous), `services/note.rs`, `routes/plan.rs`, `routes/note.rs`, `period::get_open` + `closed()`. `tests/plan_notes.rs` (5): CRUD + doğrulama, kopyalama (göreli gün, 28 Şubat'a kırpma, tekrar yok), kapalı dönem salt okunur, not filtresi, izolasyon. Backend 72 test.
|
||||
- **Adım 3:** `services/money.rs` (`split_proportional` + 3 test; beklenen değer hatası testteydi, elle hesapla düzeltildi), `services/budget.rs` (summary, view, set_monthly, set_week, suggest), `routes/budget.rs` (5 uç nokta, 9 TS tipi). `tests/budget.rs` (6) — özet formülü bilerek bozulunca yakaladı. Backend 88 test.
|
||||
- **Adım 4:** İlk dönem sihirbazı `/plan/donem-ac?adim=1..5` (`features/period-wizard/`): Dönem → Gelirler → Giderler → Birikim → Bütçe. Dönem 1. adımda açılır, her adım hemen kaydedilir; adım adreste (yenileme/geri tuşu çalışır). `lib/periods.ts` (Query kancaları; değişince `queryKeys.period(id)` öneki yenilenir), `lib/period-dates.ts` (son gün dahil ↔ backend bitiş hariç), `lib/money-schema.ts` (`moneyText`, `toKurus`), `components/money-input.tsx` (inputMode=decimal, blur'da `1.250,50`), `PageLoading`/`PageError`, `SummaryTiles`, `NoPeriodCard` (Panel + Plan). Frontend 60 test; CDP e2e 43 kontrol geçti, test verisi demo hanesinden silindi.
|
||||
- Not: üretim JS paketi ~620 kB (gzip 195 kB) → Faz 10'da sayfa bazlı bölme (lazy route).
|
||||
- **Adım 5:** Plan ekranı (`features/planning/`): `plan-read.tsx` (salt okunur: kalemler, 4 grup + haftalık döküm, "elle" etiketi), `plan-edit.tsx` (taslak formu, Kaydet/Vazgeç, `useBlocker` + beforeunload ile kaydedilmemiş değişiklik onayı). `lib/plan-draft.ts` (`toDraft`/`diffPlan`: yalnızca değişenler gönderilir), `lib/budget-preview.ts` (backend `split_proportional` + `view` ile birebir; aynı test örnekleri), `useSavePlan` (sıralı istekler; hata olursa yeniden yükler, transaction değil). Ortak: `DistributionBar`, `SuggestionPicker` (sihirbaz da kullanıyor). Frontend 69 test.
|
||||
- E2E artık ayrı sunucuda: `REGISTRATION=open PORT=8091 STATIC_DIR=frontend/dist MONGODB_URI=…/kakeibo_test_e2e` (kullanıcının dev verisine dokunulmaz; bitince DB silinir). 31 kontrol geçti; ekran görüntüsüyle 3 düzen sorunu bulundu/düzeltildi (dar satırlar, mobil tutar/tarih, hafta etiketi).
|
||||
- **Adım 6:** Notlar: `features/notes/` (`NotesSection` — Plan'da `context="planning"`, Değerlendirme'de `review` ile yeniden kullanılacak; `NoteEditor` Yaz/Önizle sekmeleri; zod şeması karakteri backend gibi sayar), `lib/notes.ts` (+ `useHousehold` yazar adı için), `components/markdown.tsx` (ham HTML yok, bağlantılar yeni sekme), `components/ui/textarea.tsx`, `index.css` `.markdown` stilleri. Yeni bağımlılık: react-markdown 10, remark-gfm 4. E2E 16 kontrol (XSS, 409 çakışma dahil). `DistributionBar` 0/0 durumunda "tamamı dağıtıldı" demiyor. **Faz 4 tamamlandı.**
|
||||
- Not: JS paketi 820 kB (gzip 254 kB; markdown ~200 kB) → Faz 10'da route bazlı lazy + Markdown'u ayrı parçaya al. `npm audit` uyarıları shadcn CLI bağımlılıklarından (deepmerge, minimatch), uygulamaya girmiyor; `--force` shadcn'i 1.0'a düşürür, yapılmadı.
|
||||
|
||||
### 2026-11-12 — Faz 5
|
||||
- **Adım 1:** Harcamalar backend: `models/entry.rs`, `services/entry.rs` (list + `from`/`to` filtresi, sıra `date` ↓ `_id` ↓; create/update/delete soft/`restore`), `routes/entry.rs` (5 uç nokta), `catalog::find`. Grup **kategoriden türetilir** (istemci göndermez). Pasif kategori/ödeme türü yeni seçimde reddedilir, kaydın mevcut değeri olarak kalabilir. Ödeme türü isteğe bağlı; düzenlemede `""` → kaldır (projedeki "boş metin = kaldır" kuralı; ts-rs `deserialize_with` desteklemediği için çift-Option yerine). `tests/entries.rs` (6) — `previous` önerisi artık gerçek kayıtla test edildi. Mutasyon: grup türetme bozulunca yakalandı; restore'da hane filtresi tek başına kaldırılınca `get_open` ikinci katman olarak korudu, ikisi birden kaldırılınca 2 test yakaladı. Backend 97 test.
|
||||
- **Adım 2:** Gerçekleşen harcama (saklanmaz, her istekte aggregation): `GET /summary` → `spent_total`, `remaining` (= harcanabilir − harcanan, negatifse aşılmış). `GET /budget` → grupta `spent` (dönem), haftada `spent`. `spent_by_group_and_date` (grup×gün toplanır, Rust'ta haftalara dağıtılır; tarih metin karşılaştırması). Silinen kayıt sayılmaz, kategori değişince harcama yeni gruba geçer. Test `actual_spending_in_summary_and_budget` (hafta sınırları 6/7 Aralık, dönem son günü, önceki dönem hariç, geri al). Mutasyon: hafta sınırı `<=`→`<` ve silinenleri sayma → ikisi de yakalandı. Backend 98 test.
|
||||
- **Adım 3:** Defter ekranı (`features/ledger/`): `WeekPicker` (◀ ▶, "Bu hafta", haftanın harcanan/bütçe toplamı), `EntryForm` (büyük `MoneyInput`, 4 renkli grup düğmesi, kategori/ödeme türü çipleri, tarih, açıklama; kayıttan sonra tutar+açıklama temizlenir, odak tutara döner), `schema.ts` (`entrySchema(start,last)`, `fieldForError`: sunucu mesajı → alan). Hafta adreste: `/defter?hafta=N` (bugünün haftasında parametre yok); başka haftaya tarihli kayıt → o haftaya geçer; hafta değişince tarih o haftaya çekilir. `lib/ledger.ts` (`weekIndexFor`, `defaultEntryDate`, `parseWeekParam`, son seçim localStorage `kakeibo.ledger.last`: grup + **her grubun son kategorisi** + ödeme türü; pasifleşen seçim düşer). `lib/catalog.ts` (`useCategories`, `usePaymentMethods`, 5 dk staleTime), `lib/entries.ts` (`useEntries`, `useCreateEntry`), `queryKeys.entries`. Frontend 79 test; e2e 31 kontrol (ayrı 8091 sunucu, DB silindi). Mobilde "Beklenmeyen" taşması düzeltildi.
|
||||
- **Adım 4:** Defter listesi ve özet: `entry-list.tsx` (gün gün `groupByDay`, gün adı `formatWeekday` = Intl tr-TR UTC; satır: kategori emojisi grup renginde, grup · ödeme · açıklama; düzenle/sil 44 px), sil → sonner `action` "↩️ Geri al" (8 sn) → `useRestoreEntry`. Düzenleme aynı `EntryForm` (`entry` + `key`, Güncelle/Vazgeç; `version` gönderilir, 409 Türkçe root hatası; `''` → ödeme/açıklama kaldır). Katalog **pasifler dahil** okunur (eski kayıtların adı; seçenekte yalnız aktifler + kaydın mevcut değeri). `week-summary.tsx` grup bazlı bütçe/harcanan/kalan + çubuk, aşımda "Aşıldı" kırmızı. Masaüstü iki sütun (form solda yapışkan); dar sütunda taşmaya karşı **container query** (`@container`, `@lg:grid-cols-4`). Frontend 81 test; e2e 29 kontrol (+ Adım 3'ün 31'i yeniden geçti). **Not:** e2e betikleri aynı DB'yi paylaşırsa birbirini bozar → her biri temiz DB ile.
|
||||
- **Adım 5:** Yedekleme betikleri: `scripts/backup.sh` (Swarm etiketiyle konteyneri bulur, `mongodump --archive --gzip` → `.partial` → `gzip -t` + `mongorestore --dryRun` → ad verilir; son `KEEP=14`, `BACKUP_GROUP` ile okuma izni), `scripts/restore.sh` (dosya adından kaynak DB, `--nsFrom/--nsTo` ile **başka adla**; mevcut hedefe yalnızca `--force` + `--drop`; `COMPARE_DB` ile sayım karşılaştırma), `scripts/backup-pull.sh` (rsync SSH BatchMode, `~/Yedekler/kakeibo`, son 60, 48 saatten eski → `notify-send`). `deploy/systemd/` (sunucu 03:30, bu bilgisayar 09:00 + açılış, `Persistent=true`). Kullanıcı kararı: sunucu dışı kopya **bu bilgisayara** (S3 değil). `npm run db:backup` / `db:restore` (yerel, `backups/` gitignore). Yerel test: yedek → geri yükleme sayıları kakeibo_dev ile aynı, koruma, bozuk dosya hatası, saklama ✅.
|
||||
|
||||
### 2026-11-12 — Faz 6
|
||||
- **Adım 1:** Değerlendirme backend: `services/review.rs` (`budget::summary` + `budget::view` + kategori aggregation), `routes/review.rs` → `GET /periods/{id}/review` (`PeriodReview`: `available` = gelir − sabit − borç + artı, `savings_goal`, `spent_total`, `realized_savings` = available − harcanan, `savings_gap`; `groups` planned/spent, `categories` çoktan aza (+count), `weeks` 4 grubun toplamı). Kapalı dönemde okunur. `periods.reflection_md` (PATCH, ≤ 20.000 karakter, `""` → null, yalnızca açık dönem). `tests/review.rs` (3). Mutasyon: 4. soruya hedef düşmek / silinenleri saymak → yakalandı. Not: `TestApp::register` adı e-postaya çevirir → Türkçe karakterli ad kullanma.
|
||||
- **Adım 2:** Kapatma backend: `services/closing.rs` — `settle(realized, to_savings)` (< 0 → borç, birikim istenemez; ≥ 0 → varsayılan tamamı birikime, kalan artı), `close` (tek transaction: dönem `closed` + `closed_at/by` + `to_savings`, bu dönemden eski devirler soft delete, yeni devir; `to_period_id` = sonraki dönem ya da null), `reopen` (sahip; `closed` → **`correcting`**; ardından kapalı/düzeltmede dönem varsa 409). `PeriodStatus::Correcting` + `is_writable()` → `get_open`/`update` buna bakar; `current()` yalnız `open`. `period::create` artık transaction: önceki dönemin bağsız devrini bağlar. `POST /periods/{id}/close` (`PeriodCloseRequest`/`PeriodCloseResult`/`Carryover`), `POST /periods/{id}/reopen`. Period DTO: `to_savings`, `closed_at`. `tests/closing.rs` (4) + `settle` birim testi. Mutasyon 3/3 yakalandı; hata zorlanınca transaction geri aldı (dönem açık, sürüm aynı). Backend 113 test. Karar: yeniden açma = düzeltme modu (kullanıcı "a").
|
||||
- **Adım 3:** Değerlendirme sayfası (`features/review/`): `Questions` (4 kart; 1. kart hesap açıklaması, 3. bütçe yüzdesi, 4. hedefe göre durum `lib/review.ts` → `savingsStatus`: above/below/deficit), `breakdowns.tsx` (`GroupComparison`, `TopCategories` ilk 5 + "Tümünü göster", `WeeklyDistribution`; ortak `Bar`, `ratio`), `Reflection` (görüntüle/düzenle, `useUpdatePeriod` + `version`, 409 formda; şema `features/review/schema.ts`), `NotesSection collapsed` (yalnız başlık, `aria-expanded` ile açılır). Ortak `components/markdown-tabs.tsx` (Yaz/Önizle, `useId` kimlikler; `NoteEditor` da kullanıyor). `useReview` + `queryKeys.review` (dönem önekinde → kayıt değişince yenilenir). Dönem sürüyorsa bilgi satırı. Frontend 84 test; e2e 24 kontrol (Ekim kapatılıp Kasım'a artı devri dahil). Mobil haftalık satır sıkıştırıldı.
|
||||
- **Adım 4:** Kapatma sihirbazı `/degerlendirme/kapat` (`features/review/close-page.tsx`): Özet (4 soru, erken kapatma uyarısı, "Nasıl daha iyi" boşsa uyarı + bağlantı) → Birikim (artıda `MoneyInput` varsayılan tamamı, Tamamı/Hiçbiri, canlı önizleme; açıkta/sıfırda yalnız bilgi) → Onay (`useClosePeriod`, `version`; 409 formda, `onSettled` tüm `['period']` + liste yenilenir → ikinci deneme güncel sürümle) → Sonuç (sayfa durumunda; kapanınca `current` null). `lib/review.ts` → `settlePreview` (backend `settle` ile aynı tutulmalı), şema `close-schema.ts` (0..gerçekleşen). `Stepper` artık `steps`/`label` alıyor. Dönem açma sihirbazı tarih önerisi: `suggestRanges(today, prevEnd)` / `rangeAfter` — son dönemin bitişinden başlar, çakışan ay önerilmez (erken kapatılan ay tekrar önerilmezdi hatası önlendi). Not: `moneyToText(0)` → `''` (plan taslağı için); 0 göstermek gerekiyorsa `formatNumber`. Frontend 89 test; e2e 33 kontrol (DB silindi, 8091 kapatıldı).
|
||||
- **Adım 5:** Dönem seçimi: `lib/period-select.ts` (`useSelectedPeriod`: `?donem=` → listeden; yoksa açık dönem, o da yoksa düzeltmedeki; `pickPeriod`, `isWritable`, `canReopen` = backend kuralı, `periodPath`), `features/periods/` (`PeriodPicker` başlıkta `<select>`, açık döneme dönünce parametre kalkar; `SelectedPeriod` yükleniyor/hata/bulunamadı/yok kapısı). Defter, Plan, Değerlendirme, kapatma sihirbazı buna geçti (Panel ve dönem sihirbazı hâlâ `useCurrentPeriod`). Defter kapalı dönemde salt okunur (form yok, `EntryList readOnly`); hafta değişimi `donem`'i korur. Plan: düzenleme `isWritable`, kopyalama yalnız açık. `PeriodHeading` durum rozeti. Değerlendirme sonu `ClosingSection` (açık → Kapat · düzeltmede → Defter'de/Plan'da düzelt + Yeniden Kapat · kapalı → kapanış bilgisi + **sahip** için satır içi onaylı "Düzeltme için yeniden aç") + `PeriodHistory`. `useReopenPeriod`; `useUpdatePeriod` liste önbelleğini de günceller (seçili dönem listeden → art arda kayıtta 409 olmasın). **Ders:** başarıda bileşen kalkıyorsa `mutate(…, {onSuccess})` çalışmaz → `mutateAsync` kullan. Frontend 92 test; e2e 36 + kapatma 33 yeniden (DB silindi). Faz 6 ✅.
|
||||
|
||||
### 2026-11-12 — Faz 7
|
||||
- Kararlar (kullanıcı onayı): toplam birikim Faz 8'e kadar = kapanışlarda birikime aktarılanların toplamı; sekmeler Günlük/Haftalık = bu dönem (kümülatif, harcanabilir günlere eşit dağıtılır), Aylık = son 12 dönem; açık dönem yoksa son dönem salt okunur.
|
||||
- **Adım 1:** `services/stats.rs` + `routes/stats.rs`: `GET /periods/{id}/daily` (`DaySpent[]`: dönemin her günü, `spent` = `GroupAmounts`, harcama yoksa 0; `budget::spent_by_group_and_date` artık `pub(crate)`), `GET /stats/periods?limit=12` (`PeriodStats`: `total_saved` tüm dönemlerin `to_savings` toplamı — limitten bağımsız, yeniden açılan dönemin eski tutarı sayılır; `periods` eskiden yeniye, her biri `budget::summary` ile → dönem başına ~5 sorgu, en fazla 24). `tests/stats.rs` (2). Mutasyon 2/2 yakalandı. Backend 118 test.
|
||||
- **Adım 2:** Panel (`features/dashboard/`): `page.tsx` (açık dönem; yoksa `NoPeriodCard` + son dönem salt okunur, `lib/dashboard.ts` → `dashboardPeriod`), `overview.tsx` (kartlar: gelir, sabit, toplam birikim `useStats().total_saved`, borç yalnızca varsa — 3 kartta ızgara 3 sütun; **Anlık durum** DOM'da önce/mobilde üstte, masaüstünde sağda: kalan harcanabilir + `dailyAllowance` = kalan ÷ kalan gün (bugün dahil, kuruş aşağı); gruplar `WeekSummary` artık `index: number | 'month'`, Haftalık/Aylık geçişi; boş durumlar: bütçe dağıtılmamış → Plan, harcama yok → Defter). "Harcama Ekle": masaüstünde başlıkta, mobilde yüzen (alt menünün üstü) → `/defter` `state.focus` → `EntryForm autoFocus`. `components/skeleton.tsx`. `queryKeys.stats = ['periods','stats']` (kapatma/plan ile yenilenir), kayıt mutasyonları da stats'ı yeniler. Frontend 95 test; e2e 22 kontrol (DB silindi). Canlı yayın 2026-11-12 güncellendi (kullanıcı Deploy yaptı; `/api/stats/periods` 401).
|
||||
- **Adım 3:** Grafikler (`features/dashboard/charts.tsx`, Recharts 3, **lazy** ayrı parça ~110 KB gz): Günlük/Haftalık/Aylık (`Segmented` — `components/segmented.tsx`, Panel grup kartı da kullanıyor). Seriler `lib/charts.ts` (`dailySeries`: harcanabilir günlere eşit + kümülatif harcanan, gelecek gün `null`; `weeklySeries`: hafta sonu planı, içinde bulunulan hafta bugüne kadar; `monthlySeries`: `useStats` dönemleri; `lastPoint`) + test. Backend: `budget::Summary.spent_by_group` → `PeriodStat.spent_by_group` (aylık grup grafiği). Grup grafiğinde harcanabilir çizgisi varsayılan kapalı (ölçeği ezip grup çizgilerini sıfıra yapıştırıyordu), `aria-pressed` düğmeyle açılır. Lejant/ipucu sırası `itemSorter` ile sabit (Recharts 3 varsayılanı alfabetik). Grafik `aria-hidden`, altında metin özeti (figcaption). `formatCompact` (eksen, bölünmez boşluk). **Ders:** `--color-*` değişkenleri `@theme inline` yüzünden derlenmiş CSS'te yok → SVG'de ham `var(--needs)` (CLAUDE.md'ye eklendi); e2e'de çizgi rengi `getComputedStyle` ile denetlenir. Tam sayfa ekran görüntüsü (`captureBeyondViewport`) ResponsiveContainer'ı küçültebilir → grafik için görünen alan görüntüsü al. Frontend 101, backend 118 test; e2e 18 kontrol. `npm audit` uyarıları shadcn CLI'dan (geliştirme aracı; `--force` shadcn'i 1.0'a düşürür → uygulanmadı). **Faz 7 ✅.**
|
||||
|
||||
### 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.
|
||||
- **Adım 3:** Birikim sayfası (`features/savings/`): `page.tsx` (özet: toplam değer + son kur zamanı, maliyet, kâr/zarar `stacked`, gerçekleşen; uyarılar: kur hiç yok / 6 saattir çekilemedi / kursuz varlık; dağılım halka grafiği `allocation-chart.tsx` lazy 6 KB, renkli liste = metin özeti; varlık listesi: elde/geçmişi olanlar + `<details>` "Diğer varlıklar"; `add-currency.tsx`), `asset-page.tsx` (`/birikim/:id`: durum kartları, `RateBox` elle kur gir/kaldır, `TxFormView` `tx-form.tsx` alım/satım: miktar × birim fiyat → tutar önerisi, elle değişen tutar korunur, yeni alımda birim fiyat = güncel kur (kuruşa yuvarlı), TL'de yalnız tutar, satışta eldeki sınırı; geçmiş: düzenle + iki adımlı sil; pasife al/etkinleştir). `lib/decimal.ts` (`parseE6`: virgül ondalık, tek nokta + 3 rakam binlik, `formatE6`, `formatRate`, `valueKurus` BigInt = backend), `lib/savings.ts` (`plRatio`, `formatPlPercent` `-%0,6` ve ≈0 gizli, `isFetchStale` 6 saat, `rateAgeDays`, `splitAssets`, `allocation`, `kindEmoji`, `qty`), `lib/assets.ts` (kancalar, hepsi `['portfolio']` yeniler), `components/decimal-input.tsx`, `formatDateTime`. Frontend 114 test; e2e 37 kontrol (kurlar DB'ye elle, `RATES_REFRESH_MINUTES=0`). Düzeltilen: `%-0,0`, TL satırında tekrar, mobil özet taşması; Tailwind keyfi alt seçici (`[&>*]`) `lg:` ile ezilemedi → sınıflar öğenin kendisine.
|
||||
- **Adım 4:** Kapanış → birikim. Backend (`services/asset.rs`): `add_period_savings` (yalnız `closed`, `to_savings > 0`, dönem başına tek → 409; TL varlığı pasif olsa da; not "<ad> kapanışı", tarih son gün), `pending_period_savings`, `plan_savings_sync` (transaction öncesi: bağlı kayıt + TL geçmişi denetimi → `SavingsSync::Update/Delete`) + `apply_savings_sync` (kapatma transaction'ı içinde). `PeriodCloseResult.savings_linked`. `GET /savings/pending`, `POST /periods/{id}/to-savings`. `tests/savings_link.rs` (3), mutasyon 3/3. Frontend: kapatma sonucunda `AddToSavings` (onay düğmesi → "Birikime eklendi" + bağlantı; yeniden kapatmada "kayıt güncellendi"), `features/savings/pending.tsx` (Birikimler üstünde), Panel "Toplam birikim" = `portfolio.value_total` (`stats.total_saved` artık arayüzde kullanılmıyor; backend'de duruyor). `useClosePeriod` portföyü de yeniler. Backend 149, frontend 114 test; e2e 14 kontrol. **Faz 8 ✅.**
|
||||
|
||||
### 2026-11-13 — Faz 9
|
||||
- **Adım 1:** Rapor verileri. `/stats/periods` → `PeriodStat` artık `summary: budget::Summary` taşır; DTO'ya `income_total`, `fixed_expense_total`, `savings_goal`, `carryover_debt/surplus`, `realized_savings` (= review 4. soru) eklendi. `services/reports.rs` + `GET /reports/breakdown` (tek `$facet`: toplam, 4 grup sabit sırada (0 dahil), **tüm** kategoriler çoktan aza, ödeme türü (`$ifNull` → `null` = girilmemiş), ay `$substrBytes`; filtre dönem/tarih aralığı (dahil, başlangıç>bitiş 400)/grup/kategori/ödeme türü (`none`)). Bütçe sapması için yeni uç nokta yok (mevcut `/budget`). `tests/reports.rs` (1, kapsamlı) + stats testi genişledi. Mutasyon 2/2. Backend 155 test.
|
||||
- **Adım 2:** Raporlar sayfası (`features/reports/`): `page.tsx` (filtre adreste: `donem`/`baslangic`/`bitis`/`grup`/`kategori`/`odeme` (`yok` = girilmemiş), dönem varsa tarih yok sayılır), `filters.tsx` (Kapsam: tüm zamanlar / dönem / tarih aralığı (yıl başı → bugün varsayılan), grup seçilince kategori listesi daralır, mobilde katlanır), `section.tsx` (`ReportSection`: `data-report`, CSV indir + bölüm yazdır; `ShareTable`: satır içi CSS pay çubukları — yazdırmada da görünür), `sections.tsx` (gruplar, ödeme türleri, **tüm** kategoriler, aylar = filtreli; bütçe sapması = seçili/açık/son dönem, grup + hafta; gelir–gider + birikim oranı = son 12 dönem; varlıklar), `charts.tsx` lazy (bar + çizgi). `lib/csv.ts` (BOM, `;`, CRLF, tırnaklama, `csvMoney` binliksiz), `lib/reports.ts` (`parseFilter`/`filterToParams`/`filterToQuery`, `describeFilter` → yazdırmada kapsam satırı, `savingsRate`, `deviation`, `formatMonth`), `lib/print.ts` (`installPrintTheme`: beforeprint'te koyu tema kaldırılır; `printSection`: `data-print-one` + `data-print-target`), index.css `@media print`, layout `print:hidden`. Kayıt değişince `['reports']` yenilenir. Frontend 121 test; e2e 34 kontrol (CSV baytları, yazdırma hedefi, tema, filtreler). Not: `Blob.text()` BOM'u siler → BOM'u baytlardan doğrula.
|
||||
|
||||
+52
-11
@@ -7,14 +7,14 @@
|
||||
| **Faz 0** | Ortam kurulumu, VS Code yapılandırması, monorepo iskeleti, hafıza dosyaları, yerel MongoDB, veri modeli onayı | `npm run dev` ile boş uygulama tarayıcıda açılıyor | ✅ |
|
||||
| **Faz 1** | Backend temeli: config, DB bağlantısı, kimlik doğrulama, hane, kategoriler, ödeme türleri, health check | API `.http` dosyasıyla test edilebiliyor | ✅ |
|
||||
| **Faz 2** | Frontend temeli: tasarım sistemi, tema, logo/ikonlar, layout, navigasyon, giriş/kayıt | Giriş yapılabilen, temalı iskelet uygulama | ✅ |
|
||||
| **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | 🔄 |
|
||||
| **Faz 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ⏳ |
|
||||
| **Faz 5** | Günlük defter | Harcama girişi | ⏳ |
|
||||
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ⏳ |
|
||||
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ⏳ |
|
||||
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ⏳ |
|
||||
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | ⏳ |
|
||||
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası | Telefona kurulabilir web uygulaması | ⏳ |
|
||||
| **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | ✅ |
|
||||
| **Faz 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ✅ |
|
||||
| **Faz 5** | Günlük defter | Harcama girişi | 🔄 |
|
||||
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ✅ |
|
||||
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ |
|
||||
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ |
|
||||
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | 🔄 |
|
||||
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | ⏳ |
|
||||
| **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) | Masaüstü ve mobil uygulamalar | ⏳ |
|
||||
|
||||
## Faz 0 Adımları
|
||||
@@ -41,6 +41,47 @@
|
||||
## Faz 3 Adımları
|
||||
- ✅ 1. Dockerfile (çok aşamalı), Axum arayüzü sunar (SPA yönlendirmesi, önbellek başlıkları, sıkıştırma), graceful shutdown, yerelde imaj testi
|
||||
- ✅ 2. Canlı MongoDB kontrolü (§12.3): kurulum şekli, replica set, ayrı kullanıcı, dış erişim
|
||||
- 🔄 3. Dokploy: Gitea bağlantısı, ortam değişkenleri, alan adı + HTTPS, ilk yayın
|
||||
- ⏳ 4. Canlıda ilk hesap + kontrol listesi
|
||||
- ⏳ 5. Yedekleme (mongodump, 14 gün) + geri yükleme denemesi
|
||||
- ✅ 3. Dokploy: Gitea bağlantısı, ortam değişkenleri, alan adı + HTTPS, ilk yayın
|
||||
- ✅ 4. Canlıda ilk hesap + kontrol listesi
|
||||
- ⏳ 5. Yedekleme (mongodump, 14 gün) + geri yükleme denemesi — **ertelendi, Faz 5 bitmeden yapılacak**
|
||||
|
||||
## Faz 4 Adımları
|
||||
- ✅ 1. Backend: dönem (aç, açık dönem, liste, detay + haftalar, düzenle), takvim hesapları, testler
|
||||
- ✅ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar
|
||||
- ✅ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
|
||||
- ✅ 4. Frontend: ilk dönem sihirbazı
|
||||
- ✅ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı)
|
||||
- ✅ 6. Frontend: notlar (Markdown, düzenle/önizle)
|
||||
|
||||
## Faz 5 Adımları (taslak)
|
||||
- ✅ 1. Backend: harcama kayıtları (ekle/düzenle/sil + geri al, hafta/dönem filtresi, kategori–grup tutarlılığı, yalnızca açık dönem), testler
|
||||
- ✅ 2. Backend: haftalık/aylık gerçekleşen (grup bazlı bütçe–harcanan–kalan), özet ve önerilere gerçekleşen verisi
|
||||
- ✅ 3. Frontend: Defter — hafta seçici + hızlı giriş formu (tutar, 4 grup düğmesi, kategori, ödeme türü, tarih, açıklama; son seçim hatırlanır)
|
||||
- ✅ 4. Frontend: haftanın girişleri (gün gün), düzenle/sil + "Geri al", haftalık özet
|
||||
- 🔄 5. Canlı yedekleme (betikler + test ✅, sunucuda kurulum kullanıcıda) (mongodump günlük, 14 gün) + geri yükleme denemesi
|
||||
|
||||
## Faz 6 Adımları (taslak)
|
||||
- ✅ 1. Backend: değerlendirme (`GET /periods/{id}/review`: 4 soru, grup/kategori/hafta) + `reflection_md`
|
||||
- ✅ 2. Backend: dönem kapatma (transaction: durum, devir, birikime aktarılan tutar) + yeni dönemde devrin bağlanması + yeniden açma (sahip)
|
||||
- ✅ 3. Frontend: Değerlendirme sayfası (4 soru, planlanan/gerçekleşen, en çok harcanan kategoriler, haftalık dağılım, "Nasıl daha iyi yapabilirim?", notlar)
|
||||
- ✅ 4. Frontend: dönem kapatma sihirbazı (+ yeni dönemi kopyalayarak açma)
|
||||
- ✅ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip) + düzeltmedeki dönemi Defter/Plan/Değerlendirme'de seçme
|
||||
|
||||
## Faz 7 Adımları
|
||||
- ✅ 1. Backend: `GET /periods/{id}/daily` (gün × grup harcama; günlük/haftalık grafik verisi) + `GET /stats/periods` (son 12 dönem: harcanabilir, harcanan, birikime aktarılan; aylık grafik ve toplam birikim)
|
||||
- ✅ 2. Frontend: özet kartları (gelir, sabit gider, toplam birikim, devreden borç, grup planlanan/gerçekleşen haftalık–aylık, **anlık durum** en belirgin) + "➕ Harcama Ekle" (mobilde yüzen düğme) + skeleton ve boş durumlar
|
||||
- ✅ 3. Frontend: grafikler (Recharts) — kümülatif harcanabilir vs. gerçekleşen; 4 grup + harcanabilir; Günlük / Haftalık / Aylık sekmeleri, koyu tema, erişilebilir özet
|
||||
|
||||
## 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
|
||||
- ✅ 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
|
||||
|
||||
## Faz 9 Adımları
|
||||
- ✅ 1. Backend: rapor verileri — dönem istatistiklerine gelir/sabit gider/devir/gerçekleşen birikim (birikim oranı için) + `GET /reports/breakdown` (grup / **tüm kategoriler** / ödeme türü / ay; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü). Bütçe sapması mevcut `budget` verisinden
|
||||
- ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
|
||||
- ⏳ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
|
||||
- ⏳ 4. **Banka / kredi kartı ekstresi içe aktarma** (kullanıcı isteği): CSV/Excel, sütun eşleştirme (banka profili kaydedilir), harcama/gelir yönü, tekrar eden satır tespiti, açıklamadan kategori önerisi (öğrenen kural), grup/kategori eşleştirmesi kullanıcıda; eksik aylar için **geçmiş (arşiv) dönemler** — amaç yıl başından beri veri → sonraki aylara referans (ortalama önerileri)
|
||||
- ⏳ 5. JSON tam hane yedeği: sürümlü dışa aktarma; geri yüklemede önizleme + onay + sunucuda otomatik geri dönüş noktası ("geri yüklemeyi geri al"); başka haneden taşıma
|
||||
- ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF
|
||||
|
||||
Generated
+1855
-3
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,10 @@
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.92.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^8.5.1",
|
||||
"recharts": "^3.10.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.23.0",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwindcss": "^4.3.5",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { formatE6, parseE6 } from '@/lib/decimal'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Input>, 'value' | 'onChange' | 'type'> & {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
/** Sağda gösterilen birim: `gr`, `$`, `₺`… */
|
||||
suffix: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Miktar / kur alanı (6 ondalığa kadar). `MoneyInput` gibi: sayısal klavye, alandan çıkınca
|
||||
* Türkçe biçim (`5.5` → `5,5`, `1000` → `1.000`). Değer metin; `parseE6` ile × 1e6'ya çevrilir.
|
||||
*/
|
||||
export function DecimalInput({ value, onChange, onBlur, suffix, className, ...props }: Props) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...props}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const v = parseE6(value)
|
||||
if (v !== null && value.trim() !== '') onChange(formatE6(v))
|
||||
onBlur?.(e)
|
||||
}}
|
||||
className={cn('money text-right', className)}
|
||||
style={{ paddingRight: `${Math.max(2.25, suffix.length * 0.6 + 1.25)}rem` }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-muted-foreground"
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,14 +19,14 @@ export function AppLayout() {
|
||||
<Sidebar />
|
||||
|
||||
{/* Mobil üst çubuk */}
|
||||
<header className="sticky top-0 z-20 flex h-14 items-center border-b bg-background/90 px-4 backdrop-blur md:hidden">
|
||||
<header className="sticky top-0 z-20 flex h-14 items-center border-b bg-background/90 px-4 backdrop-blur md:hidden print:hidden">
|
||||
<Logo size={28} withText />
|
||||
</header>
|
||||
|
||||
{/* md:pl-64 → kenar çubuğu kadar boşluk; içerik bu alanda ortalanır.
|
||||
pb-28 → mobilde alt menünün altında içerik kalmasın. */}
|
||||
<div className="md:pl-64">
|
||||
<main className="mx-auto w-full max-w-5xl px-4 pt-6 pb-28 md:px-8 md:pt-8 md:pb-10">
|
||||
<div className="md:pl-64 print:pl-0">
|
||||
<main className="mx-auto w-full max-w-5xl px-4 pt-6 pb-28 md:px-8 md:pt-8 md:pb-10 print:max-w-none print:p-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@ export function AppLayout() {
|
||||
|
||||
function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-sidebar md:flex">
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-sidebar md:flex print:hidden">
|
||||
<div className="flex h-16 items-center px-5">
|
||||
<Logo size={32} withText />
|
||||
</div>
|
||||
@@ -78,7 +78,7 @@ function BottomNav() {
|
||||
return (
|
||||
<nav
|
||||
aria-label={t.nav.main}
|
||||
className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
|
||||
className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden print:hidden"
|
||||
>
|
||||
<ul className="grid grid-cols-5">
|
||||
{bottomNav.map(({ to, label, icon: Icon }) => (
|
||||
|
||||
@@ -18,15 +18,24 @@ export const paths = {
|
||||
dashboard: '/',
|
||||
ledger: '/defter',
|
||||
planning: '/plan',
|
||||
/** İlk dönem sihirbazı; `?adim=1..5` */
|
||||
periodWizard: '/plan/donem-ac',
|
||||
savings: '/birikim',
|
||||
/** Varlık ayrıntısı; adres için `assetPath(id)` */
|
||||
asset: '/birikim/:id',
|
||||
more: '/diger',
|
||||
review: '/degerlendirme',
|
||||
/** Dönem kapatma sihirbazı */
|
||||
closePeriod: '/degerlendirme/kapat',
|
||||
reports: '/raporlar',
|
||||
settings: '/ayarlar',
|
||||
login: '/giris',
|
||||
register: '/kayit',
|
||||
} as const
|
||||
|
||||
/** Varlık ayrıntı sayfası adresi */
|
||||
export const assetPath = (id: string) => `/birikim/${id}`
|
||||
|
||||
const dashboard: NavItem = { to: paths.dashboard, label: t.nav.dashboard, icon: LayoutDashboard }
|
||||
const ledger: NavItem = { to: paths.ledger, label: t.nav.ledger, icon: NotebookPen }
|
||||
const planning: NavItem = { to: paths.planning, label: t.nav.planning, icon: CalendarDays }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { Markdown } from '@/components/markdown'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Markdown alanı için "Yaz / Önizle" sekmeleri. `children` = yazma paneli (genelde `Textarea`).
|
||||
* Kimlikler `useId` ile üretilir → aynı sayfada birden çok editör olabilir.
|
||||
*/
|
||||
export function MarkdownTabs({
|
||||
label,
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
/** Sekme listesinin erişilebilir adı */
|
||||
label: string
|
||||
/** Önizlenecek metin */
|
||||
value: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const id = useId()
|
||||
const [tab, setTab] = useState<'write' | 'preview'>('write')
|
||||
|
||||
const tabButton = (v: typeof tab, text: string) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${id}-tab-${v}`}
|
||||
aria-selected={tab === v}
|
||||
aria-controls={`${id}-panel-${v}`}
|
||||
onClick={() => setTab(v)}
|
||||
className={cn(
|
||||
'h-11 rounded-md px-4 text-sm font-medium transition-colors',
|
||||
tab === v ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div role="tablist" aria-label={label} className="inline-flex rounded-lg bg-muted p-1">
|
||||
{tabButton('write', t.notes.write)}
|
||||
{tabButton('preview', t.notes.preview)}
|
||||
</div>
|
||||
{tab === 'write' ? (
|
||||
<div id={`${id}-panel-write`} role="tabpanel" aria-labelledby={`${id}-tab-write`}>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
id={`${id}-panel-preview`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${id}-tab-preview`}
|
||||
className="min-h-40 rounded-lg border p-3"
|
||||
>
|
||||
{value.trim() ? (
|
||||
<Markdown>{value}</Markdown>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t.notes.previewEmpty}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Not metnini Markdown olarak gösterir (GFM: tablo, ~~üstü çizili~~, `- [ ]` yapılacaklar).
|
||||
* Güvenlik: ham HTML işlenmez (react-markdown varsayılanı); `javascript:` gibi
|
||||
* tehlikeli bağlantılar kütüphane tarafından temizlenir. Bağlantılar yeni sekmede açılır.
|
||||
*/
|
||||
const components: Components = {
|
||||
a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
||||
}
|
||||
|
||||
export function Markdown({ children, className }: { children: string; className?: string }) {
|
||||
return (
|
||||
<div className={cn('markdown', className)}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { formatNumber, parseMoney } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Input>, 'value' | 'onChange' | 'type'> & {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Tutar alanı: telefonda sayısal klavye açar, sağda `₺` gösterir.
|
||||
* Alandan çıkınca yazılanı Türkçe biçime getirir: `1250,5` → `1.250,50`.
|
||||
* Değer metin olarak tutulur; kuruşa çevirme `toKurus` ile gönderirken yapılır.
|
||||
*
|
||||
* react-hook-form ile `Controller` üzerinden kullanılır (kontrollü bileşen).
|
||||
*/
|
||||
export function MoneyInput({ value, onChange, onBlur, className, ...props }: Props) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...props}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const k = parseMoney(value)
|
||||
if (k !== null && value.trim() !== '') onChange(formatNumber(k / 100))
|
||||
onBlur?.(e)
|
||||
}}
|
||||
className={cn('money pr-9 text-right', className)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-muted-foreground"
|
||||
>
|
||||
₺
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Construction } from 'lucide-react'
|
||||
import { Construction, Loader2, RotateCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { t } from '@/lib/strings'
|
||||
import { useDocumentTitle } from '@/lib/use-document-title'
|
||||
|
||||
@@ -25,6 +26,35 @@ export function PageHeader({
|
||||
)
|
||||
}
|
||||
|
||||
/** Sayfa içi yükleniyor göstergesi */
|
||||
export function PageLoading() {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center justify-center gap-2 p-10 text-muted-foreground"
|
||||
>
|
||||
<Loader2 className="size-5 animate-spin" aria-hidden />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Veri yüklenemedi: Türkçe mesaj + tekrar dene */
|
||||
export function PageError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center gap-3 rounded-xl border border-expense/30 bg-expense/5 p-8 text-center"
|
||||
>
|
||||
<p className="text-expense">{message}</p>
|
||||
<Button variant="outline" onClick={onRetry}>
|
||||
<RotateCw aria-hidden />
|
||||
{t.common.retry}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Henüz geliştirilmemiş ekranlar için yer tutucu. `when`: "Faz 4'te" gibi (Türkçe ek uyumu için tam ifade). */
|
||||
export function ComingSoon({ when }: { when: string }) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Tek seçimlik düğme grubu (ör. Haftalık / Aylık). Her düğme ≥ 44 px yüksek. */
|
||||
export function Segmented<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
/** Ekran okuyucu için grup adı */
|
||||
label: string
|
||||
value: T
|
||||
options: readonly { value: T; label: string }[]
|
||||
onChange: (value: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="flex rounded-lg border p-0.5">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
aria-pressed={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'min-h-11 rounded-md px-3 text-sm font-medium sm:px-4',
|
||||
value === o.value ? 'bg-primary text-primary-foreground' : 'hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Yükleniyor yer tutucusu: içeriğin şeklinde gri, nabız gibi atan kutu */
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div aria-hidden className={cn('animate-pulse rounded-xl bg-muted', className)} />
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
// shadcn textarea (Input ile aynı görünüm); Kakeibo: mobilde text-base → iOS yakınlaştırmaz
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-40 w-full rounded-lg border border-input bg-transparent px-3 py-2.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { PageError } from '@/components/page'
|
||||
import { Segmented } from '@/components/segmented'
|
||||
import { Skeleton } from '@/components/skeleton'
|
||||
import {
|
||||
dailySeries,
|
||||
lastPoint,
|
||||
monthlySeries,
|
||||
weeklySeries,
|
||||
type ChartPoint,
|
||||
type ChartRange,
|
||||
} from '@/lib/charts'
|
||||
import { formatCompact, formatMoney, toIsoDate } from '@/lib/format'
|
||||
import { GROUPS } from '@/lib/groups'
|
||||
import { useDaily, useStats } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Period } from '@/types/Period'
|
||||
|
||||
const s = t.charts
|
||||
|
||||
/**
|
||||
* Renkler CSS değişkenlerinden → açık/koyu tema kendiliğinden. Ham `:root` değişkenleri
|
||||
* (`--needs`, `--primary`…) kullanılır: `@theme inline` olduğundan `--color-*` derlenmiş
|
||||
* CSS'te ancak bir yerde kullanılmışsa bulunur.
|
||||
*/
|
||||
const color = {
|
||||
plan: 'var(--muted-foreground)',
|
||||
spent: 'var(--primary)',
|
||||
grid: 'var(--border)',
|
||||
axis: 'var(--muted-foreground)',
|
||||
}
|
||||
|
||||
/** Lejant ve ipucunda sabit sıra (Recharts varsayılanı alfabetik): harcanabilir, harcanan, 4 grup */
|
||||
const ORDER = ['plan', 'spent', ...GROUPS.map((g) => g.code)]
|
||||
/** Nokta: içi kart renginde (koyu temada beyaz nokta olmasın), kenarı çizgi renginde */
|
||||
const dot = { r: 3, strokeWidth: 2, fill: 'var(--card)' }
|
||||
const byOrder = (item: { dataKey?: unknown }) => ORDER.indexOf(String(item.dataKey))
|
||||
|
||||
/**
|
||||
* Panel grafikleri (Recharts, ayrı parça olarak yüklenir):
|
||||
* 1) harcanabilir vs gerçekleşen, 2) 4 grup + harcanabilir. Günlük / Haftalık / Aylık.
|
||||
*/
|
||||
export default function DashboardCharts({
|
||||
period,
|
||||
spendable,
|
||||
}: {
|
||||
period: Period
|
||||
spendable: number
|
||||
}) {
|
||||
const [range, setRange] = useState<ChartRange>('daily')
|
||||
const [today] = useState(() => toIsoDate(new Date()))
|
||||
// Harcanabilir çizgisi grup çizgilerini sıfıra ezer (ölçek) → isteğe bağlı
|
||||
const [showPlan, setShowPlan] = useState(false)
|
||||
const daily = useDaily(period.id)
|
||||
const stats = useStats()
|
||||
|
||||
const q = range === 'monthly' ? stats : daily
|
||||
let points: ChartPoint[] | undefined
|
||||
if (range === 'monthly') points = stats.data && monthlySeries(stats.data.periods)
|
||||
else if (daily.data)
|
||||
points =
|
||||
range === 'daily'
|
||||
? dailySeries(daily.data, spendable, today)
|
||||
: weeklySeries(daily.data, period.weeks, spendable, today)
|
||||
|
||||
return (
|
||||
<section aria-labelledby="charts-title" className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 id="charts-title" className="font-semibold">
|
||||
<span aria-hidden>📈</span> {s.title}
|
||||
</h2>
|
||||
<Segmented
|
||||
label={s.title}
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
options={[
|
||||
{ value: 'daily', label: s.daily },
|
||||
{ value: 'weekly', label: s.weekly },
|
||||
{ value: 'monthly', label: s.monthly },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{q.isError ? (
|
||||
<PageError message={q.error.message} onRetry={q.refetch} />
|
||||
) : !points ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Skeleton className="h-80" />
|
||||
<Skeleton className="h-80" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard
|
||||
title={range === 'monthly' ? s.planTitleMonthly : s.planTitle}
|
||||
summary={planSummary(points, range)}
|
||||
>
|
||||
<Chart points={points} dots={range !== 'daily'}>
|
||||
<Line
|
||||
dataKey="plan"
|
||||
name={s.plan}
|
||||
stroke={color.plan}
|
||||
strokeDasharray="6 4"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="spent"
|
||||
name={s.spent}
|
||||
stroke={color.spent}
|
||||
strokeWidth={3}
|
||||
dot={range !== 'daily' && dot}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</Chart>
|
||||
</ChartCard>
|
||||
<ChartCard
|
||||
title={s.groupsTitle}
|
||||
summary={groupSummary(points, range)}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={showPlan}
|
||||
onClick={() => setShowPlan((v) => !v)}
|
||||
className={cn(
|
||||
'min-h-11 rounded-lg border px-3 text-xs font-medium',
|
||||
showPlan ? 'border-primary bg-primary/10' : 'hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
{s.togglePlan}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<Chart points={points} dots={range !== 'daily'}>
|
||||
{showPlan && (
|
||||
<Line
|
||||
dataKey="plan"
|
||||
name={s.plan}
|
||||
stroke={color.plan}
|
||||
strokeDasharray="6 4"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
{GROUPS.map((g) => (
|
||||
<Line
|
||||
key={g.code}
|
||||
dataKey={g.code}
|
||||
name={`${g.emoji} ${g.label}`}
|
||||
stroke={`var(--${g.code})`}
|
||||
strokeWidth={2.5}
|
||||
dot={range !== 'daily' && dot}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</Chart>
|
||||
</ChartCard>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartCard({
|
||||
title,
|
||||
summary,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
summary: string
|
||||
action?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<figure className="rounded-2xl border bg-card p-4">
|
||||
<div className="mb-3 flex min-h-11 flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{action}
|
||||
</div>
|
||||
{/* Grafik görsel; içeriği alttaki özet anlatır (ekran okuyucu özeti okur) */}
|
||||
<div className="h-80 sm:h-72" aria-hidden>
|
||||
{children}
|
||||
</div>
|
||||
<figcaption className="mt-3 text-sm text-muted-foreground">{summary}</figcaption>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
function Chart({
|
||||
points,
|
||||
dots,
|
||||
children,
|
||||
}: {
|
||||
points: ChartPoint[]
|
||||
dots: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={points} margin={{ top: 5, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid stroke={color.grid} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
stroke={color.axis}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
minTickGap={dots ? 4 : 16}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={color.axis}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={(v: number) => formatCompact(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
itemSorter={byOrder}
|
||||
formatter={(v) => (typeof v === 'number' ? formatMoney(v) : '—')}
|
||||
contentStyle={{
|
||||
background: 'var(--popover)',
|
||||
color: 'var(--popover-foreground)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} itemSorter={byOrder} />
|
||||
{children}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function planSummary(points: ChartPoint[], range: ChartRange): string {
|
||||
const p = lastPoint(points)
|
||||
if (!p || p.spent === null) return s.noData
|
||||
return range === 'monthly'
|
||||
? s.monthlySummary(p.label, formatMoney(p.plan), formatMoney(p.spent))
|
||||
: s.cumulativeSummary(formatMoney(p.spent), formatMoney(p.plan), range === 'weekly')
|
||||
}
|
||||
|
||||
function groupSummary(points: ChartPoint[], range: ChartRange): string {
|
||||
const p = lastPoint(points)
|
||||
if (!p || !p.spent) return s.noData
|
||||
const top = GROUPS.map((g) => ({ g, v: p[g.code] ?? 0 })).sort((a, b) => b.v - a.v)[0]
|
||||
return s.groupSummary(
|
||||
range === 'monthly' ? p.label : s.untilNow,
|
||||
`${top.g.emoji} ${top.g.label}`,
|
||||
formatMoney(top.v),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { lazy, Suspense, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { paths } from '@/components/layout/nav-items'
|
||||
import { PageError } from '@/components/page'
|
||||
import { Segmented } from '@/components/segmented'
|
||||
import { Skeleton } from '@/components/skeleton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { WeekSummary } from '@/features/ledger/week-summary'
|
||||
import { PeriodHeading } from '@/features/planning/plan-read'
|
||||
import { dailyAllowance } from '@/lib/dashboard'
|
||||
import { formatDayMonth, formatMoney, toIsoDate } from '@/lib/format'
|
||||
import { weekIndexFor } from '@/lib/ledger'
|
||||
import { periodPath } from '@/lib/period-select'
|
||||
import { usePortfolio } from '@/lib/assets'
|
||||
import { useBudget, useSummary } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GroupBudget } from '@/types/GroupBudget'
|
||||
import type { Period } from '@/types/Period'
|
||||
import type { PeriodSummary } from '@/types/PeriodSummary'
|
||||
|
||||
const s = t.dashboard
|
||||
|
||||
/** Recharts büyük → grafikler ayrı parça, Panel açılınca arkadan yüklenir */
|
||||
const DashboardCharts = lazy(() => import('./charts'))
|
||||
|
||||
/** Özet kartları: gelir, sabit gider, toplam birikim, devreden borç, anlık durum, gruplar */
|
||||
export function Overview({ period, writable }: { period: Period; writable: boolean }) {
|
||||
const summary = useSummary(period.id)
|
||||
const budget = useBudget(period.id)
|
||||
const portfolio = usePortfolio()
|
||||
const [today] = useState(() => toIsoDate(new Date()))
|
||||
|
||||
const failed = [summary, budget].find((q) => q.isError)
|
||||
if (failed?.error) return <PageError message={failed.error.message} onRetry={failed.refetch} />
|
||||
if (!summary.data || !budget.data) return <DashboardSkeleton />
|
||||
const sum = summary.data
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PeriodHeading period={period} />
|
||||
{/* Borç kartı yalnızca varsa: 3 kartta mobilde sonuncusu tam genişlik, masaüstünde 3 sütun */}
|
||||
<dl
|
||||
className={cn(
|
||||
'grid grid-cols-2 gap-3',
|
||||
sum.carryover_debt > 0
|
||||
? 'lg:grid-cols-4'
|
||||
: 'lg:grid-cols-3 [&>*:last-child]:col-span-2 lg:[&>*:last-child]:col-span-1',
|
||||
)}
|
||||
>
|
||||
<Tile emoji="💰" label={s.income} amount={sum.income_total} tone="text-income" />
|
||||
<Tile emoji="📌" label={s.fixed} amount={sum.fixed_expense_total} />
|
||||
<Tile
|
||||
emoji="🐷"
|
||||
label={s.saved}
|
||||
amount={portfolio.data?.value_total}
|
||||
hint={s.savedHint}
|
||||
tone="text-income"
|
||||
/>
|
||||
{sum.carryover_debt > 0 && (
|
||||
<Tile
|
||||
emoji="⚠️"
|
||||
label={s.debt}
|
||||
amount={sum.carryover_debt}
|
||||
hint={s.debtHint}
|
||||
tone="text-expense"
|
||||
/>
|
||||
)}
|
||||
</dl>
|
||||
{/* Anlık durum DOM'da önce (ekran okuyucu ve mobilde ilk), masaüstünde sağ sütunda */}
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[3fr_2fr]">
|
||||
<StatusCard period={period} summary={sum} today={today} writable={writable} />
|
||||
<GroupsCard
|
||||
period={period}
|
||||
budget={budget.data}
|
||||
summary={sum}
|
||||
today={today}
|
||||
writable={writable}
|
||||
/>
|
||||
</div>
|
||||
<Suspense fallback={<Skeleton className="h-96" />}>
|
||||
<DashboardCharts period={period} spendable={sum.spendable} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Tile({
|
||||
emoji,
|
||||
label,
|
||||
amount,
|
||||
hint,
|
||||
tone,
|
||||
}: {
|
||||
emoji: string
|
||||
label: string
|
||||
/** `undefined` → yükleniyor */
|
||||
amount: number | undefined
|
||||
hint?: string
|
||||
tone?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border bg-card p-4">
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
<span aria-hidden>{emoji}</span> {label}
|
||||
</dt>
|
||||
<dd className={cn('money mt-1 text-lg font-semibold sm:text-xl', tone)}>
|
||||
{amount === undefined ? <Skeleton className="h-7 w-28" /> : formatMoney(amount)}
|
||||
</dd>
|
||||
{hint && <dd className="mt-1 text-xs text-muted-foreground">{hint}</dd>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** En belirgin kart: kalan harcanabilir + günlük öneri */
|
||||
function StatusCard({
|
||||
period,
|
||||
summary,
|
||||
today,
|
||||
writable,
|
||||
}: {
|
||||
period: Period
|
||||
summary: PeriodSummary
|
||||
today: string
|
||||
writable: boolean
|
||||
}) {
|
||||
const over = summary.remaining < 0
|
||||
const { daysLeft, perDay } = dailyAllowance(summary.remaining, period, today)
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="status-title"
|
||||
className={cn(
|
||||
'rounded-2xl border-2 p-5 lg:col-start-2 lg:row-start-1',
|
||||
over ? 'border-expense/50 bg-expense/5' : 'border-primary/40 bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<h2 id="status-title" className="text-sm font-medium text-muted-foreground">
|
||||
<span aria-hidden>📍</span> {s.statusTitle}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm">{over ? s.overspent : s.remaining}</p>
|
||||
<p
|
||||
className={cn(
|
||||
'money text-4xl font-bold tracking-tight',
|
||||
over ? 'text-expense' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{formatMoney(over ? -summary.remaining : summary.remaining)}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{s.statusDetail(formatMoney(summary.spendable), formatMoney(summary.spent_total))}
|
||||
</p>
|
||||
{writable &&
|
||||
(daysLeft === 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-sm">{s.ended}</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link to={periodPath(paths.review, period)}>{s.toReview}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm font-medium">
|
||||
{perDay !== null ? s.perDay(daysLeft, formatMoney(perDay)) : s.daysLeft(daysLeft)}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** 4 grup: planlanan / gerçekleşen; Haftalık (bugünün haftası) ↔ Aylık (tüm dönem) */
|
||||
function GroupsCard({
|
||||
period,
|
||||
budget,
|
||||
summary,
|
||||
today,
|
||||
writable,
|
||||
}: {
|
||||
period: Period
|
||||
budget: GroupBudget[]
|
||||
summary: PeriodSummary
|
||||
today: string
|
||||
writable: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<'week' | 'month'>('week')
|
||||
const index = weekIndexFor(period.weeks, today)
|
||||
const week = period.weeks[index - 1]
|
||||
const range = (a: string, b: string) => `${formatDayMonth(a)}–${formatDayMonth(b)}`
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="groups-title"
|
||||
className="rounded-2xl border bg-card p-4 sm:p-5 lg:col-start-1 lg:row-start-1"
|
||||
>
|
||||
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 id="groups-title" className="font-semibold">
|
||||
<span aria-hidden>🎯</span> {s.groupsTitle}
|
||||
</h2>
|
||||
<Segmented
|
||||
label={s.groupsTitle}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'week', label: s.weekly },
|
||||
{ value: 'month', label: s.monthly },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
{mode === 'week' && week
|
||||
? s.weekLabel(index, range(week.start_date, week.last_date))
|
||||
: s.monthLabel(range(period.start_date, period.last_date))}
|
||||
</p>
|
||||
|
||||
{summary.budget_total === 0 ? (
|
||||
<Empty text={s.noBudget} to={periodPath(paths.planning, period)} action={s.toPlan} />
|
||||
) : (
|
||||
<WeekSummary budget={budget} index={mode === 'week' ? index : 'month'} />
|
||||
)}
|
||||
{writable && summary.spent_total === 0 && summary.budget_total > 0 && (
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
<Link to={paths.ledger} state={{ focus: true }} className="underline">
|
||||
{s.noEntries}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty({ text, to, action }: { text: string; to: string; action: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-3 rounded-xl bg-muted p-4">
|
||||
<p className="text-sm text-muted-foreground">{text}</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link to={to}>{action}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Yükleniyor: kartların şeklinde yer tutucular */
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div role="status" aria-label={t.common.loading} className="space-y-4">
|
||||
<Skeleton className="h-12 w-56" />
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-[3fr_2fr]">
|
||||
<Skeleton className="h-72" />
|
||||
<Skeleton className="h-48" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,85 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Link } from 'react-router'
|
||||
import { paths } from '@/components/layout/nav-items'
|
||||
import { PageError, PageHeader } from '@/components/page'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
||||
import { dashboardPeriod } from '@/lib/dashboard'
|
||||
import { useCurrentPeriod, usePeriods } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import { DashboardSkeleton, Overview } from './overview'
|
||||
|
||||
const s = t.dashboard
|
||||
|
||||
/** Defter'e git ve tutar alanına odaklan */
|
||||
const addEntryLink = { to: paths.ledger, state: { focus: true } }
|
||||
|
||||
/**
|
||||
* Panel: açık dönemin özeti. Açık dönem yoksa "Dönemi başlat" çağrısı ve
|
||||
* (varsa) son dönemin özeti salt okunur. "Harcama Ekle": masaüstünde başlıkta,
|
||||
* mobilde sağ altta yüzen düğme.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const current = useCurrentPeriod()
|
||||
const periods = usePeriods()
|
||||
const failed = [current, periods].find((q) => q.isError)
|
||||
const ready = current.isSuccess && periods.isSuccess
|
||||
const period = ready ? dashboardPeriod(periods.data, current.data) : undefined
|
||||
const canAdd = !!current.data
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.dashboard.title} description={t.pages.dashboard.description} />
|
||||
<ComingSoon when="Faz 7'de" />
|
||||
<PageHeader
|
||||
title={t.pages.dashboard.title}
|
||||
description={t.pages.dashboard.description}
|
||||
actions={
|
||||
canAdd && (
|
||||
<Button asChild size="lg" className="hidden md:inline-flex">
|
||||
<Link {...addEntryLink}>
|
||||
<Plus aria-hidden />
|
||||
{s.addEntry}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{failed?.error ? (
|
||||
<PageError
|
||||
message={failed.error.message}
|
||||
onRetry={() => void Promise.all([current.refetch(), periods.refetch()])}
|
||||
/>
|
||||
) : period === undefined ? (
|
||||
<DashboardSkeleton />
|
||||
) : period === null ? (
|
||||
<NoPeriodCard />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{!current.data && (
|
||||
<>
|
||||
<NoPeriodCard />
|
||||
<p className="text-sm text-muted-foreground">{s.lastPeriod(period.name)}</p>
|
||||
</>
|
||||
)}
|
||||
<Overview period={period} writable={canAdd} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canAdd && (
|
||||
<>
|
||||
{/* Yüzen düğme son içeriği örtmesin */}
|
||||
<div className="h-16 md:hidden" aria-hidden />
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="fixed right-4 bottom-[calc(5rem+env(safe-area-inset-bottom))] z-20 h-14 rounded-full px-5 shadow-lg md:hidden"
|
||||
>
|
||||
<Link {...addEntryLink}>
|
||||
<Plus aria-hidden />
|
||||
{s.addEntry}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { useEffect } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Controller, useForm, useWatch } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { FormError, FormField } from '@/components/form-field'
|
||||
import { MoneyInput } from '@/components/money-input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCreateEntry, useUpdateEntry } from '@/lib/entries'
|
||||
import { formatMoney } from '@/lib/format'
|
||||
import { GROUPS, type GroupCode } from '@/lib/groups'
|
||||
import { defaultEntryDate, loadLastChoice, saveLastChoice } from '@/lib/ledger'
|
||||
import { toKurus } from '@/lib/money-schema'
|
||||
import { moneyToText } from '@/lib/plan-draft'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CatalogItem } from '@/types/CatalogItem'
|
||||
import type { Entry } from '@/types/Entry'
|
||||
import type { Period } from '@/types/Period'
|
||||
import type { PeriodWeek } from '@/types/PeriodWeek'
|
||||
import { entrySchema, fieldForError, MAX_DESCRIPTION, type EntryForm } from './schema'
|
||||
|
||||
const s = t.ledger
|
||||
|
||||
/** Formun ilk değerleri: düzenlemede kaydın kendisi, yoksa son seçimler (hâlâ aktifse) */
|
||||
function initialValues(
|
||||
categories: CatalogItem[],
|
||||
methods: CatalogItem[],
|
||||
date: string,
|
||||
entry?: Entry,
|
||||
): EntryForm {
|
||||
if (entry) {
|
||||
return {
|
||||
amount: moneyToText(entry.amount),
|
||||
group: entry.group,
|
||||
category_id: entry.category_id,
|
||||
payment_method_id: entry.payment_method_id ?? '',
|
||||
date: entry.date,
|
||||
description: entry.description ?? '',
|
||||
}
|
||||
}
|
||||
const last = loadLastChoice()
|
||||
const group = last.group
|
||||
const cat = group && categories.find((c) => c.is_active && c.id === last.categoryByGroup[group])
|
||||
const pm = methods.find((m) => m.is_active && m.id === last.paymentMethodId)
|
||||
return {
|
||||
amount: '',
|
||||
group: (cat ? group : '') as GroupCode,
|
||||
category_id: cat ? cat.id : '',
|
||||
payment_method_id: pm ? pm.id : '',
|
||||
date,
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Seçenek olarak sunulabilir mi: aktif ya da kaydın zaten kullandığı (pasifleşmiş olabilir) */
|
||||
const usable = (c: CatalogItem, current?: string | null) => c.is_active || c.id === current
|
||||
|
||||
/**
|
||||
* Hızlı giriş: tutar → grup → kategori → (ödeme türü, tarih, açıklama) → Kaydet.
|
||||
* Kayıttan sonra tutar ve açıklama temizlenir, diğer seçimler kalır (art arda giriş için).
|
||||
*
|
||||
* `entry` verilirse düzenleme modu (Güncelle / Vazgeç). Başka bir kaydı düzenlemek için
|
||||
* bileşen `key` ile yeniden oluşturulur.
|
||||
*/
|
||||
export function EntryForm({
|
||||
period,
|
||||
week,
|
||||
today,
|
||||
categories,
|
||||
methods,
|
||||
entry,
|
||||
autoFocus = false,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
period: Period
|
||||
week: PeriodWeek
|
||||
today: string
|
||||
/** Pasifler dahil (eski kaydın değeri için); yeni seçimde yalnızca aktifler sunulur */
|
||||
categories: CatalogItem[]
|
||||
methods: CatalogItem[]
|
||||
/** Düzenlenen kayıt */
|
||||
entry?: Entry
|
||||
/** Yeni kayıtta açılışta tutar alanına odaklan */
|
||||
autoFocus?: boolean
|
||||
/** Kaydedilen kaydın tarihi (o haftaya geçmek için) */
|
||||
onSaved: (date: string) => void
|
||||
onCancel?: () => void
|
||||
}) {
|
||||
const create = useCreateEntry(period.id)
|
||||
const update = useUpdateEntry(period.id)
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
getValues,
|
||||
setError,
|
||||
setFocus,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<EntryForm>({
|
||||
resolver: zodResolver(entrySchema(period.start_date, period.last_date)),
|
||||
mode: 'onTouched',
|
||||
defaultValues: initialValues(categories, methods, defaultEntryDate(week, today), entry),
|
||||
})
|
||||
const group = useWatch({ control, name: 'group' })
|
||||
const groupCategories = categories.filter(
|
||||
(c) => c.group === group && usable(c, entry?.category_id),
|
||||
)
|
||||
const methodOptions = methods.filter((m) => usable(m, entry?.payment_method_id))
|
||||
|
||||
// Başka haftaya geçilince tarih o haftaya çekilir (seçili tarih zaten oradaysa dokunulmaz)
|
||||
useEffect(() => {
|
||||
if (entry) return
|
||||
const d = getValues('date')
|
||||
if (!(d >= week.start_date && d <= week.last_date)) {
|
||||
setValue('date', defaultEntryDate(week, today))
|
||||
}
|
||||
}, [entry, week, today, getValues, setValue])
|
||||
|
||||
// Düzenlemeye geçince (ya da Panel'den gelince) tutara odaklan
|
||||
useEffect(() => {
|
||||
if (entry || autoFocus) setFocus('amount')
|
||||
}, [entry, autoFocus, setFocus])
|
||||
|
||||
function pickGroup(code: GroupCode) {
|
||||
if (code === getValues('group')) return
|
||||
const inGroup = categories.filter((c) => c.group === code && usable(c, entry?.category_id))
|
||||
const remembered = inGroup.find((c) => c.id === loadLastChoice().categoryByGroup[code])
|
||||
setValue('group', code, { shouldValidate: true })
|
||||
setValue('category_id', remembered?.id ?? (inGroup.length === 1 ? inGroup[0].id : ''), {
|
||||
shouldValidate: !!errors.category_id,
|
||||
})
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit(async (v) => {
|
||||
try {
|
||||
if (entry) {
|
||||
const e = await update.mutateAsync({
|
||||
id: entry.id,
|
||||
version: entry.version,
|
||||
date: v.date,
|
||||
amount: toKurus(v.amount),
|
||||
category_id: v.category_id,
|
||||
payment_method_id: v.payment_method_id, // '' → kaldır
|
||||
description: v.description.trim(), // '' → kaldır
|
||||
})
|
||||
toast.success(s.updated)
|
||||
onSaved(e.date)
|
||||
return
|
||||
}
|
||||
const e = await create.mutateAsync({
|
||||
date: v.date,
|
||||
amount: toKurus(v.amount),
|
||||
category_id: v.category_id,
|
||||
payment_method_id: v.payment_method_id || undefined,
|
||||
description: v.description.trim() || undefined,
|
||||
})
|
||||
const last = loadLastChoice()
|
||||
saveLastChoice({
|
||||
group: v.group,
|
||||
categoryByGroup: { ...last.categoryByGroup, [v.group]: v.category_id },
|
||||
paymentMethodId: v.payment_method_id,
|
||||
})
|
||||
const name = categories.find((c) => c.id === v.category_id)?.name ?? ''
|
||||
toast.success(s.saved(formatMoney(e.amount), name))
|
||||
reset({ ...v, amount: '', description: '' })
|
||||
onSaved(e.date)
|
||||
setFocus('amount')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(fieldForError(message), { message })
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="@container space-y-5">
|
||||
<Controller
|
||||
control={control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormField label={s.amount} error={errors.amount}>
|
||||
{(f) => (
|
||||
<MoneyInput
|
||||
{...f}
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="0,00"
|
||||
className="h-14 text-2xl font-semibold md:text-2xl"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-sm font-medium">{s.group}</legend>
|
||||
<div className="grid grid-cols-2 gap-2 @lg:grid-cols-4">
|
||||
{GROUPS.map((g) => {
|
||||
const selected = group === g.code
|
||||
return (
|
||||
<button
|
||||
key={g.code}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => pickGroup(g.code)}
|
||||
className={cn(
|
||||
'flex min-h-14 min-w-0 items-center gap-1.5 rounded-xl border-2 px-2.5 py-2 text-left text-sm font-medium transition-colors @lg:gap-2 @lg:px-3 @lg:text-base',
|
||||
selected
|
||||
? cn(g.className.soft, g.className.text, 'border-current')
|
||||
: 'border-border hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="text-lg @lg:text-xl">
|
||||
{g.emoji}
|
||||
</span>
|
||||
{g.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{errors.group && <p className="mt-2 text-sm text-expense">{errors.group.message}</p>}
|
||||
</fieldset>
|
||||
|
||||
{group && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="category_id"
|
||||
render={({ field }) => (
|
||||
<Chips
|
||||
legend={s.category}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
options={groupCategories.map((c) => ({ value: c.id, label: label(c) }))}
|
||||
empty={s.noCategory}
|
||||
error={errors.category_id?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{methodOptions.length > 0 && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="payment_method_id"
|
||||
render={({ field }) => (
|
||||
<Chips
|
||||
legend={s.paymentMethod}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
options={[
|
||||
{ value: '', label: s.noPaymentMethod },
|
||||
...methodOptions.map((m) => ({ value: m.id, label: label(m) })),
|
||||
]}
|
||||
error={errors.payment_method_id?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 @md:grid-cols-[12rem_1fr]">
|
||||
<FormField label={s.date} error={errors.date}>
|
||||
{(f) => (
|
||||
<Input
|
||||
{...f}
|
||||
type="date"
|
||||
min={period.start_date}
|
||||
max={period.last_date}
|
||||
{...register('date')}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField label={s.description} error={errors.description} hint={s.descriptionHint}>
|
||||
{(f) => <Input {...f} maxLength={MAX_DESCRIPTION * 2} {...register('description')} />}
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormError message={errors.root?.message} />
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
||||
{entry && (
|
||||
<Button type="button" variant="outline" size="lg" onClick={onCancel}>
|
||||
{s.cancel}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" size="lg" className="w-full sm:w-auto" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{isSubmitting ? s.saving : entry ? s.update : s.save}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const label = (c: CatalogItem) => (c.emoji ? `${c.emoji} ${c.name}` : c.name)
|
||||
|
||||
/** Tek seçimlik düğme listesi (kategori, ödeme türü) */
|
||||
function Chips({
|
||||
legend,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
empty,
|
||||
error,
|
||||
}: {
|
||||
legend: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: { value: string; label: string }[]
|
||||
empty?: string
|
||||
error?: string
|
||||
}) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-sm font-medium">{legend}</legend>
|
||||
{options.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{empty}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
aria-pressed={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'min-h-11 rounded-full border px-4 text-sm transition-colors',
|
||||
value === o.value
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="mt-2 text-sm text-expense">{error}</p>}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useDeleteEntry, useRestoreEntry } from '@/lib/entries'
|
||||
import { formatDate, formatMoney, formatWeekday } from '@/lib/format'
|
||||
import { group } from '@/lib/groups'
|
||||
import { groupByDay } from '@/lib/ledger'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CatalogItem } from '@/types/CatalogItem'
|
||||
import type { Entry } from '@/types/Entry'
|
||||
|
||||
const s = t.ledger
|
||||
|
||||
/**
|
||||
* Haftanın girişleri, gün gün. Sil → bildirimde "Geri al" (sunucuda soft delete + restore).
|
||||
* Kategori ve ödeme türü adları pasifler dahil listeden bulunur (eski kayıtlar da adıyla görünür).
|
||||
*/
|
||||
export function EntryList({
|
||||
periodId,
|
||||
entries,
|
||||
categories,
|
||||
methods,
|
||||
editingId,
|
||||
readOnly = false,
|
||||
onEdit,
|
||||
onDeleted,
|
||||
}: {
|
||||
periodId: string
|
||||
entries: Entry[]
|
||||
categories: CatalogItem[]
|
||||
methods: CatalogItem[]
|
||||
editingId?: string
|
||||
/** Kapalı dönem: düzenle/sil yok */
|
||||
readOnly?: boolean
|
||||
onEdit: (e: Entry) => void
|
||||
onDeleted: (id: string) => void
|
||||
}) {
|
||||
const del = useDeleteEntry(periodId)
|
||||
const restore = useRestoreEntry(periodId)
|
||||
const catName = (id: string) => categories.find((c) => c.id === id)
|
||||
const pmName = (id: string | null) => (id ? methods.find((m) => m.id === id)?.name : undefined)
|
||||
|
||||
function remove(e: Entry, name: string) {
|
||||
del.mutate(e.id, {
|
||||
onSuccess: () => {
|
||||
onDeleted(e.id)
|
||||
toast(s.deleted, {
|
||||
description: `${formatMoney(e.amount)} · ${name}`,
|
||||
duration: 8000,
|
||||
action: {
|
||||
label: s.undo,
|
||||
onClick: () =>
|
||||
restore.mutate(e.id, {
|
||||
onSuccess: () => toast.success(s.restored),
|
||||
onError: (err) => toast.error(err.message),
|
||||
}),
|
||||
},
|
||||
})
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{s.empty}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{groupByDay(entries).map((day) => (
|
||||
<section key={day.date} aria-label={`${formatWeekday(day.date)} ${formatDate(day.date)}`}>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2 border-b pb-1 text-sm">
|
||||
<h4 className="font-medium">
|
||||
{formatWeekday(day.date)}{' '}
|
||||
<span className="text-muted-foreground">{formatDate(day.date)}</span>
|
||||
</h4>
|
||||
<span className="money text-muted-foreground" title={s.dayTotal}>
|
||||
{formatMoney(day.total)}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{day.entries.map((e) => {
|
||||
const g = group(e.group)
|
||||
const cat = catName(e.category_id)
|
||||
const name = cat?.name ?? '—'
|
||||
const editing = e.id === editingId
|
||||
const details = [g.label, pmName(e.payment_method_id), e.description].filter(Boolean)
|
||||
return (
|
||||
<li
|
||||
key={e.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2 py-2',
|
||||
editing && '-mx-2 rounded-lg bg-muted px-2',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full text-lg',
|
||||
g.className.soft,
|
||||
)}
|
||||
>
|
||||
{cat?.emoji ?? g.emoji}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
{name}
|
||||
{editing && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
· {s.editing}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
<span className={g.className.text}>{details[0]}</span>
|
||||
{details.slice(1).map((d) => ` · ${d}`)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="money shrink-0 font-medium">{formatMoney(e.amount)}</span>
|
||||
{!readOnly && (
|
||||
<div className="flex shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={s.edit(name)}
|
||||
aria-pressed={editing}
|
||||
onClick={() => onEdit(e)}
|
||||
>
|
||||
<Pencil aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={s.remove(name)}
|
||||
className="text-muted-foreground hover:text-expense"
|
||||
disabled={del.isPending && del.variables === e.id}
|
||||
onClick={() => remove(e, name)}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,153 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useLocation, useSearchParams } from 'react-router'
|
||||
import { Info } from 'lucide-react'
|
||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||
import { PeriodPicker } from '@/features/periods/period-picker'
|
||||
import { SelectedPeriod } from '@/features/periods/selected-period'
|
||||
import { Section } from '@/features/planning/plan-read'
|
||||
import { useCategories, usePaymentMethods } from '@/lib/catalog'
|
||||
import { useEntries } from '@/lib/entries'
|
||||
import { toIsoDate } from '@/lib/format'
|
||||
import { parseWeekParam, weekIndexFor } from '@/lib/ledger'
|
||||
import { isWritable } from '@/lib/period-select'
|
||||
import { useBudget } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { Entry } from '@/types/Entry'
|
||||
import type { Period } from '@/types/Period'
|
||||
import { EntryForm } from './entry-form'
|
||||
import { EntryList } from './entry-list'
|
||||
import { WeekPicker } from './week-picker'
|
||||
import { WeekSummary } from './week-summary'
|
||||
|
||||
export function LedgerPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.ledger.title} description={t.pages.ledger.description} />
|
||||
<ComingSoon when="Faz 5'te" />
|
||||
<PageHeader
|
||||
title={t.pages.ledger.title}
|
||||
description={t.pages.ledger.description}
|
||||
actions={<PeriodPicker />}
|
||||
/>
|
||||
<SelectedPeriod>{(p) => <LedgerView key={p.id} period={p} />}</SelectedPeriod>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hafta adreste tutulur (`?hafta=2`): yenileyince / geri gelince aynı hafta açılır.
|
||||
* Düzen: hafta seçici → form → haftalık özet → girişler (masaüstünde form solda, diğerleri sağda).
|
||||
*/
|
||||
function LedgerView({ period }: { period: Period }) {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [today] = useState(() => toIsoDate(new Date()))
|
||||
const [editing, setEditing] = useState<Entry | null>(null)
|
||||
// Panel'deki "Harcama Ekle" → tutar alanına odaklan
|
||||
const focusAmount = (useLocation().state as { focus?: boolean } | null)?.focus === true
|
||||
const formRef = useRef<HTMLDivElement>(null)
|
||||
const inPeriod = today >= period.start_date && today <= period.last_date
|
||||
const todayIndex = inPeriod ? weekIndexFor(period.weeks, today) : null
|
||||
const index =
|
||||
parseWeekParam(params.get('hafta'), period.weeks.length) ?? weekIndexFor(period.weeks, today)
|
||||
const week = period.weeks[index - 1]
|
||||
const readOnly = !isWritable(period)
|
||||
|
||||
const budget = useBudget(period.id)
|
||||
const categories = useCategories(true)
|
||||
const methods = usePaymentMethods(true)
|
||||
const entries = useEntries(period.id, { from: week.start_date, to: week.last_date })
|
||||
|
||||
const goTo = (n: number) => {
|
||||
setEditing(null)
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev) // seçili dönem (`donem`) korunur
|
||||
if (n === todayIndex) next.delete('hafta')
|
||||
else next.set('hafta', String(n))
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
|
||||
function edit(e: Entry) {
|
||||
setEditing(e)
|
||||
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
const catalogFailed = [categories, methods].find((q) => q.isError)
|
||||
const catalog = categories.data && methods.data ? { c: categories.data, m: methods.data } : null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="mx-auto max-w-3xl lg:max-w-none">
|
||||
<WeekPicker
|
||||
weeks={period.weeks}
|
||||
index={index}
|
||||
todayIndex={todayIndex}
|
||||
budget={budget.data}
|
||||
onChange={goTo}
|
||||
/>
|
||||
</div>
|
||||
{readOnly && (
|
||||
<p className="flex items-start gap-2 rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
{t.ledger.readOnly}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||
{!readOnly && (
|
||||
<div ref={formRef} className="scroll-mt-4 lg:sticky lg:top-4">
|
||||
<Section title={editing ? t.ledger.editTitle : t.ledger.formTitle}>
|
||||
{catalogFailed?.error ? (
|
||||
<PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} />
|
||||
) : !catalog ? (
|
||||
<PageLoading />
|
||||
) : (
|
||||
<EntryForm
|
||||
key={editing?.id ?? 'new'}
|
||||
period={period}
|
||||
week={week}
|
||||
today={today}
|
||||
categories={catalog.c}
|
||||
methods={catalog.m}
|
||||
entry={editing ?? undefined}
|
||||
autoFocus={focusAmount}
|
||||
onSaved={(date) => goTo(weekIndexFor(period.weeks, date))}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
<div className={readOnly ? 'space-y-4 lg:col-span-2' : 'space-y-4'}>
|
||||
<Section title={t.ledger.summaryTitle}>
|
||||
{budget.isError ? (
|
||||
<PageError message={budget.error.message} onRetry={budget.refetch} />
|
||||
) : budget.data ? (
|
||||
<WeekSummary budget={budget.data} index={index} />
|
||||
) : (
|
||||
<PageLoading />
|
||||
)}
|
||||
</Section>
|
||||
<Section title={t.ledger.listTitle}>
|
||||
{entries.isError ? (
|
||||
<PageError message={entries.error.message} onRetry={entries.refetch} />
|
||||
) : entries.data && catalog ? (
|
||||
<EntryList
|
||||
periodId={period.id}
|
||||
entries={entries.data}
|
||||
categories={catalog.c}
|
||||
methods={catalog.m}
|
||||
editingId={editing?.id}
|
||||
readOnly={readOnly}
|
||||
onEdit={edit}
|
||||
onDeleted={(id) => editing?.id === id && setEditing(null)}
|
||||
/>
|
||||
) : (
|
||||
<PageLoading />
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { entrySchema, fieldForError } from './schema'
|
||||
|
||||
const schema = entrySchema('2026-12-01', '2026-12-31')
|
||||
const ok = {
|
||||
amount: '1.250,50',
|
||||
group: 'needs',
|
||||
category_id: 'c1',
|
||||
payment_method_id: '',
|
||||
date: '2026-12-31',
|
||||
description: '',
|
||||
}
|
||||
const errorsOf = (v: object) =>
|
||||
schema.safeParse({ ...ok, ...v }).error?.issues.map((i) => [i.path[0], i.message]) ?? []
|
||||
|
||||
describe('entry form', () => {
|
||||
it('accepts a valid entry', () => {
|
||||
expect(schema.safeParse(ok).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects missing and out-of-range values with Turkish messages', () => {
|
||||
expect(errorsOf({ amount: '' })).toEqual([['amount', 'Tutarı yazın']])
|
||||
expect(errorsOf({ amount: '0' })).toEqual([['amount', 'Tutar sıfırdan büyük olmalı']])
|
||||
expect(errorsOf({ group: '' })).toEqual([['group', 'Ana grubu seçin']])
|
||||
expect(errorsOf({ category_id: '' })).toEqual([['category_id', 'Kategori seçin']])
|
||||
expect(errorsOf({ date: '2027-01-01' })).toEqual([['date', 'Tarih dönem içinde olmalı']])
|
||||
expect(errorsOf({ date: '' })).toEqual([['date', 'Tarih geçersiz']])
|
||||
})
|
||||
|
||||
it('counts description characters like the backend', () => {
|
||||
expect(errorsOf({ description: '🛒'.repeat(200) })).toEqual([])
|
||||
expect(errorsOf({ description: 'a'.repeat(201) })[0][0]).toBe('description')
|
||||
})
|
||||
|
||||
it('maps server messages to fields', () => {
|
||||
expect(fieldForError('Tarih dönem içinde olmalı')).toBe('date')
|
||||
expect(fieldForError('Bu kategori pasif; başka bir kategori seçin')).toBe('category_id')
|
||||
expect(fieldForError('Bu ödeme türü pasif; başka birini seçin')).toBe('payment_method_id')
|
||||
expect(fieldForError('Bağlantı kurulamadı')).toBe('root')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod'
|
||||
import { isIsoDate } from '@/lib/period-dates'
|
||||
import { moneyText } from '@/lib/money-schema'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
const s = t.ledger
|
||||
|
||||
/** Backend ile aynı: `services/entry.rs` → `MAX_DESCRIPTION` (Unicode karakter) */
|
||||
export const MAX_DESCRIPTION = 200
|
||||
|
||||
/** Hızlı giriş formu. `start`–`last`: dönemin ilk ve son günü (dahil). */
|
||||
export function entrySchema(start: string, last: string) {
|
||||
return z.object({
|
||||
amount: moneyText('positive'),
|
||||
group: z.enum(['needs', 'wants', 'culture', 'unexpected'], { error: s.groupRequired }),
|
||||
category_id: z.string().min(1, s.categoryRequired),
|
||||
/** `''` → belirtilmedi */
|
||||
payment_method_id: z.string(),
|
||||
date: z
|
||||
.string()
|
||||
.refine(isIsoDate, s.dateInvalid)
|
||||
.refine((d) => !isIsoDate(d) || (d >= start && d <= last), s.dateOutside),
|
||||
description: z
|
||||
.string()
|
||||
.refine((v) => [...v.trim()].length <= MAX_DESCRIPTION, s.descriptionTooLong),
|
||||
})
|
||||
}
|
||||
export type EntryForm = z.infer<ReturnType<typeof entrySchema>>
|
||||
|
||||
/** Sunucu hatası hangi alana ait? (mesajlar `services/entry.rs`'den, Türkçe) */
|
||||
export function fieldForError(message: string): keyof EntryForm | 'root' {
|
||||
if (message.startsWith('Tutar')) return 'amount'
|
||||
if (message.startsWith('Tarih')) return 'date'
|
||||
if (/kategori/i.test(message)) return 'category_id'
|
||||
if (message.startsWith('Ödeme') || message.startsWith('Bu ödeme')) return 'payment_method_id'
|
||||
if (message.startsWith('Açıklama')) return 'description'
|
||||
return 'root'
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDayMonth, formatMoney } from '@/lib/format'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { GroupBudget } from '@/types/GroupBudget'
|
||||
import type { PeriodWeek } from '@/types/PeriodWeek'
|
||||
|
||||
const s = t.ledger
|
||||
|
||||
/** ◀ önceki | 2. hafta / 5 · 07.12 – 13.12 | sonraki ▶ (+ "Bu hafta", haftanın toplamı) */
|
||||
export function WeekPicker({
|
||||
weeks,
|
||||
index,
|
||||
todayIndex,
|
||||
budget,
|
||||
onChange,
|
||||
}: {
|
||||
weeks: readonly PeriodWeek[]
|
||||
index: number
|
||||
/** Bugünün haftası; bugün dönem dışındaysa `null` */
|
||||
todayIndex: number | null
|
||||
budget?: GroupBudget[]
|
||||
onChange: (index: number) => void
|
||||
}) {
|
||||
const week = weeks[index - 1]
|
||||
const spent = budget?.reduce((a, g) => a + (g.weeks[index - 1]?.spent ?? 0), 0)
|
||||
const planned = budget?.reduce((a, g) => a + (g.weeks[index - 1]?.amount ?? 0), 0)
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border bg-card p-2 sm:p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={s.prevWeek}
|
||||
disabled={index <= 1}
|
||||
onClick={() => onChange(index - 1)}
|
||||
>
|
||||
<ChevronLeft aria-hidden />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1 text-center" aria-live="polite">
|
||||
<p className="font-semibold">{s.week(index, weeks.length)}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDayMonth(week.start_date)} – {formatDayMonth(week.last_date)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={s.nextWeek}
|
||||
disabled={index >= weeks.length}
|
||||
onClick={() => onChange(index + 1)}
|
||||
>
|
||||
<ChevronRight aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center justify-between gap-2 border-t px-2 pt-2 text-sm">
|
||||
{spent !== undefined && planned !== undefined ? (
|
||||
<p className="text-muted-foreground">
|
||||
{s.weekSpent}:{' '}
|
||||
<span className="money font-medium text-foreground">{formatMoney(spent)}</span> /{' '}
|
||||
{s.weekBudget} <span className="money">{formatMoney(planned)}</span>
|
||||
</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{todayIndex !== null && todayIndex !== index && (
|
||||
<Button variant="outline" size="sm" onClick={() => onChange(todayIndex)}>
|
||||
{s.thisWeek}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { formatMoney } from '@/lib/format'
|
||||
import { group } from '@/lib/groups'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GroupBudget } from '@/types/GroupBudget'
|
||||
|
||||
const s = t.ledger
|
||||
|
||||
/**
|
||||
* Grup bazlı bütçe / harcanan / kalan (+ ilerleme çubuğu): seçili hafta ya da `'month'` → tüm dönem.
|
||||
* Defter (haftalık) ve Panel (haftalık/aylık) kullanır.
|
||||
*/
|
||||
export function WeekSummary({ budget, index }: { budget: GroupBudget[]; index: number | 'month' }) {
|
||||
return (
|
||||
<div className="@container">
|
||||
<ul className="grid gap-3 @lg:grid-cols-2">
|
||||
{budget.map((b) => {
|
||||
const g = group(b.group)
|
||||
const w = index === 'month' ? null : b.weeks[index - 1]
|
||||
const planned = index === 'month' ? b.monthly : (w?.amount ?? 0)
|
||||
const spent = index === 'month' ? b.spent : (w?.spent ?? 0)
|
||||
const left = planned - spent
|
||||
const over = left < 0
|
||||
const ratio = planned > 0 ? Math.min(spent / planned, 1) : spent > 0 ? 1 : 0
|
||||
return (
|
||||
<li key={b.group} className="rounded-xl border p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-2">
|
||||
<span className={cn('font-medium whitespace-nowrap', g.className.text)}>
|
||||
<span aria-hidden>{g.emoji}</span> {g.label}
|
||||
</span>
|
||||
<span className={cn('money text-sm font-semibold', over && 'text-expense')}>
|
||||
{over ? `${s.over} ${formatMoney(-left)}` : `${s.left} ${formatMoney(left)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="my-2 h-2 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-label={`${g.label}: ${s.spent}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(ratio * 100)}
|
||||
>
|
||||
<div
|
||||
className={cn('h-full rounded-full', over ? 'bg-expense' : g.className.fill)}
|
||||
style={{ width: `${ratio * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.spent} <span className="money">{formatMoney(spent)}</span> / {s.budget}{' '}
|
||||
<span className="money">{formatMoney(planned)}</span>
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { FormError, FormField } from '@/components/form-field'
|
||||
import { MarkdownTabs } from '@/components/markdown-tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { t } from '@/lib/strings'
|
||||
import { noteSchema, type NoteForm } from './schema'
|
||||
|
||||
const s = t.notes
|
||||
|
||||
/**
|
||||
* Not formu: başlık + Markdown metin, "Yaz / Önizle" sekmeleri.
|
||||
* `onSave` hata fırlatırsa mesaj formda gösterilir (ör. 409: başkası değiştirmiş).
|
||||
*/
|
||||
export function NoteEditor({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: NoteForm
|
||||
onSave: (v: NoteForm) => Promise<void>
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<NoteForm>({
|
||||
resolver: zodResolver(noteSchema),
|
||||
mode: 'onTouched',
|
||||
defaultValues: initial ?? { title: '', body_md: '' },
|
||||
})
|
||||
const body = useWatch({ control, name: 'body_md' })
|
||||
|
||||
const onSubmit = handleSubmit(async (v) => {
|
||||
try {
|
||||
await onSave(v)
|
||||
} catch (e) {
|
||||
setError('root', { message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-4 rounded-xl border bg-card p-4">
|
||||
<FormField label={s.noteTitle} error={errors.title}>
|
||||
{(f) => (
|
||||
<Input
|
||||
{...f}
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
placeholder={s.titlePlaceholder}
|
||||
{...register('title')}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<MarkdownTabs label={s.body} value={body}>
|
||||
<FormField label={s.body} error={errors.body_md} hint={s.markdownHint}>
|
||||
{(f) => <Textarea {...f} placeholder={s.bodyPlaceholder} {...register('body_md')} />}
|
||||
</FormField>
|
||||
</MarkdownTabs>
|
||||
|
||||
<FormError message={errors.root?.message} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
{s.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{isSubmitting ? s.saving : s.save}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Markdown } from '@/components/markdown'
|
||||
import { PageError, PageLoading } from '@/components/page'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { useCreateNote, useDeleteNote, useHousehold, useNotes, useUpdateNote } from '@/lib/notes'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Note } from '@/types/Note'
|
||||
import type { NoteContext } from '@/types/NoteContext'
|
||||
import { NoteEditor } from './note-editor'
|
||||
|
||||
const s = t.notes
|
||||
|
||||
/**
|
||||
* Döneme özel notlar (Plan ve Değerlendirme sayfası).
|
||||
* `readOnly`: kapanmış dönemde ekleme/düzenleme yok.
|
||||
* `collapsed`: yalnızca başlıklar listelenir, tıklayınca metin açılır (Değerlendirme, §8.5).
|
||||
*/
|
||||
export function NotesSection({
|
||||
periodId,
|
||||
context,
|
||||
readOnly = false,
|
||||
collapsed = false,
|
||||
}: {
|
||||
periodId: string
|
||||
context: NoteContext
|
||||
readOnly?: boolean
|
||||
collapsed?: boolean
|
||||
}) {
|
||||
const notes = useNotes(periodId, context)
|
||||
const household = useHousehold()
|
||||
const create = useCreateNote(periodId, context)
|
||||
const update = useUpdateNote(periodId, context)
|
||||
const remove = useDeleteNote(periodId, context)
|
||||
/** `'new'` → yeni not formu; not kimliği → o not düzenleniyor */
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
/** `collapsed` modunda açık olan notlar */
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set())
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((cur) => {
|
||||
const next = new Set(cur)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const author = (id: string) => household.data?.members.find((m) => m.id === id)?.name
|
||||
|
||||
const onRemove = (note: Note) => {
|
||||
if (!window.confirm(s.confirmRemove(note.title))) return
|
||||
remove.mutate(note.id, {
|
||||
onSuccess: () => toast.success(s.removed),
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="notes-title" className="rounded-2xl border bg-card p-4 sm:p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 id="notes-title" className="font-semibold">
|
||||
🗒️ {s.title}
|
||||
</h3>
|
||||
{!readOnly && editing !== 'new' && (
|
||||
<Button variant="secondary" onClick={() => setEditing('new')}>
|
||||
<Plus aria-hidden />
|
||||
{s.add}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{editing === 'new' && (
|
||||
<NoteEditor
|
||||
onCancel={() => setEditing(null)}
|
||||
onSave={async (v) => {
|
||||
await create.mutateAsync(v)
|
||||
toast.success(s.saved)
|
||||
setEditing(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{notes.isPending ? (
|
||||
<PageLoading />
|
||||
) : notes.isError ? (
|
||||
<PageError message={notes.error.message} onRetry={notes.refetch} />
|
||||
) : notes.data.length === 0 && editing !== 'new' ? (
|
||||
<p className="text-sm text-muted-foreground">{s.empty}</p>
|
||||
) : (
|
||||
notes.data.map((note) =>
|
||||
editing === note.id ? (
|
||||
<NoteEditor
|
||||
key={note.id}
|
||||
initial={{ title: note.title, body_md: note.body_md }}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSave={async (v) => {
|
||||
await update.mutateAsync({ id: note.id, version: note.version, ...v })
|
||||
toast.success(s.saved)
|
||||
setEditing(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<article key={note.id} className="rounded-xl border p-4">
|
||||
<header className="mb-2 flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-medium break-words">
|
||||
{collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded.has(note.id)}
|
||||
aria-controls={`note-body-${note.id}`}
|
||||
onClick={() => toggle(note.id)}
|
||||
className="-my-2 flex min-h-11 w-full items-center gap-2 text-left hover:underline"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
!expanded.has(note.id) && '-rotate-90',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{note.title}
|
||||
</button>
|
||||
) : (
|
||||
note.title
|
||||
)}
|
||||
</h4>
|
||||
<p className={cn('text-xs text-muted-foreground', collapsed && 'pl-6')}>
|
||||
{s.meta(formatDate(note.updated_at), author(note.created_by))}
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="-mt-2 -mr-2 flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={s.editNote(note.title)}
|
||||
onClick={() => setEditing(note.id)}
|
||||
>
|
||||
<Pencil className="text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={s.removeNote(note.title)}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => onRemove(note)}
|
||||
>
|
||||
<Trash2 className="text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{note.body_md.trim() && (!collapsed || expanded.has(note.id)) && (
|
||||
<div id={`note-body-${note.id}`} className={cn(collapsed && 'pl-6')}>
|
||||
<Markdown>{note.body_md}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { t } from '@/lib/strings'
|
||||
import { noteSchema } from './schema'
|
||||
|
||||
const err = (title: string, body_md = '') => {
|
||||
const r = noteSchema.safeParse({ title, body_md })
|
||||
return r.success ? null : r.error.issues[0].message
|
||||
}
|
||||
|
||||
describe('noteSchema', () => {
|
||||
it('accepts a note with an empty body', () => {
|
||||
expect(err('Hedefler')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects empty and too long titles', () => {
|
||||
expect(err(' ')).toBe(t.notes.titleRequired)
|
||||
expect(err('x'.repeat(121))).toBe(t.notes.titleTooLong)
|
||||
})
|
||||
|
||||
it('counts characters like the backend (emoji = 1)', () => {
|
||||
expect(err('🎯'.repeat(120))).toBeNull() // UTF-16'da 240 birim
|
||||
expect(err('Not', '🎯'.repeat(20_000))).toBeNull()
|
||||
expect(err('Not', 'a'.repeat(20_001))).toBe(t.notes.bodyTooLong)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
const s = t.notes
|
||||
|
||||
/** Karakter sayısı backend gibi Unicode karakteri sayar (emoji = 1), `length` gibi UTF-16 değil */
|
||||
const chars = (v: string) => [...v].length
|
||||
|
||||
export const noteSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, s.titleRequired)
|
||||
.refine((v) => chars(v) <= 120, s.titleTooLong),
|
||||
body_md: z.string().refine((v) => chars(v) <= 20_000, s.bodyTooLong),
|
||||
})
|
||||
export type NoteForm = z.infer<typeof noteSchema>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CalendarPlus } from 'lucide-react'
|
||||
import { Link } from 'react-router'
|
||||
import { paths } from '@/components/layout/nav-items'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
const s = t.noPeriod
|
||||
|
||||
/** Açık dönem yokken Panel ve Plan'da görünür → sihirbaza götürür. */
|
||||
export function NoPeriodCard() {
|
||||
return (
|
||||
<section className="flex flex-col items-center gap-4 rounded-2xl border bg-card p-8 text-center">
|
||||
<div className="grid size-14 place-items-center rounded-full bg-primary/10 text-primary">
|
||||
<CalendarPlus className="size-7" aria-hidden />
|
||||
</div>
|
||||
<div className="max-w-md space-y-2">
|
||||
<h2 className="text-lg font-semibold">{s.title}</h2>
|
||||
<p className="text-muted-foreground">{s.description}</p>
|
||||
</div>
|
||||
<Button asChild size="lg">
|
||||
<Link to={paths.periodWizard}>{s.action}</Link>
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { paths } from '@/components/layout/nav-items'
|
||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||
import { useCurrentPeriod } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import { StepBudget } from './step-budget'
|
||||
import { StepDates } from './step-dates'
|
||||
import { StepItems } from './step-items'
|
||||
import { StepSavings } from './step-savings'
|
||||
import { Stepper } from './wizard-ui'
|
||||
|
||||
const s = t.wizard
|
||||
const LAST_STEP = s.steps.length
|
||||
|
||||
/**
|
||||
* İlk dönem sihirbazı: Dönem → Gelirler → Giderler → Birikim → Bütçe.
|
||||
*
|
||||
* Dönem 1. adımda açılır; sonraki her adım hemen kaydedilir. Adım adreste tutulur
|
||||
* (`?adim=3`): sayfa yenilense ya da yarıda bırakılsa da kalınan yerden devam edilir,
|
||||
* tarayıcının geri tuşu bir önceki adıma döner.
|
||||
*/
|
||||
export function PeriodWizardPage() {
|
||||
const current = useCurrentPeriod()
|
||||
const [params, setParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const header = <PageHeader title={s.title} description={s.description} />
|
||||
if (current.isPending)
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<PageLoading />
|
||||
</>
|
||||
)
|
||||
if (current.isError) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<PageError message={current.error.message} onRetry={current.refetch} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const period = current.data
|
||||
const requested = Number(params.get('adim'))
|
||||
// Dönem yoksa yalnızca 1. adım; varsa adres yoksa 2. adımdan devam
|
||||
const step = !period
|
||||
? 1
|
||||
: Number.isInteger(requested) && requested >= 1 && requested <= LAST_STEP
|
||||
? requested
|
||||
: 2
|
||||
|
||||
const go = (n: number) => {
|
||||
setParams({ adim: String(n) })
|
||||
window.scrollTo({ top: 0 })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{header}
|
||||
<Stepper current={step} />
|
||||
{step === 1 && <StepDates period={period} onDone={() => go(2)} />}
|
||||
{period && step === 2 && (
|
||||
<StepItems period={period} kind="income" onBack={() => go(1)} onNext={() => go(3)} />
|
||||
)}
|
||||
{period && step === 3 && (
|
||||
<StepItems period={period} kind="expense" onBack={() => go(2)} onNext={() => go(4)} />
|
||||
)}
|
||||
{period && step === 4 && (
|
||||
<StepSavings period={period} onBack={() => go(3)} onNext={() => go(5)} />
|
||||
)}
|
||||
{period && step === 5 && (
|
||||
<StepBudget
|
||||
period={period}
|
||||
onBack={() => go(4)}
|
||||
onDone={() => {
|
||||
toast.success(s.budget.done)
|
||||
navigate(paths.planning)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { t } from '@/lib/strings'
|
||||
import { datesSchema, itemSchema } from './schemas'
|
||||
|
||||
const s = t.wizard
|
||||
|
||||
function datesError(start: string, last: string, name = '') {
|
||||
const r = datesSchema.safeParse({ start, last, name })
|
||||
return r.success ? null : r.error.issues[0].message
|
||||
}
|
||||
|
||||
describe('datesSchema', () => {
|
||||
it('accepts a calendar month and a salary-day period', () => {
|
||||
expect(datesError('2026-12-01', '2026-12-31')).toBeNull()
|
||||
expect(datesError('2026-12-15', '2027-01-14', 'Maaş dönemi')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects missing, reversed and too long ranges', () => {
|
||||
expect(datesError('', '2026-12-31')).toBe(s.dates.startRequired)
|
||||
expect(datesError('2026-12-01', '')).toBe(s.dates.lastRequired)
|
||||
expect(datesError('2026-12-10', '2026-12-09')).toBe(s.dates.lastBeforeStart)
|
||||
expect(datesError('2026-12-01', '2027-02-01')).toBe(s.dates.tooLong(62))
|
||||
expect(datesError('2026-12-01', '2027-01-31')).toBeNull() // tam 62 gün
|
||||
expect(datesError('2026-12-01', '2026-12-31', 'x'.repeat(61))).toBe(s.dates.nameTooLong)
|
||||
})
|
||||
})
|
||||
|
||||
describe('itemSchema', () => {
|
||||
const schema = itemSchema('2026-12-01', '2026-12-31')
|
||||
const err = (v: { name: string; amount: string; date: string }) => {
|
||||
const r = schema.safeParse(v)
|
||||
return r.success ? null : r.error.issues[0].message
|
||||
}
|
||||
|
||||
it('accepts items with or without a date', () => {
|
||||
expect(err({ name: 'Maaş', amount: '45.000', date: '' })).toBeNull()
|
||||
expect(err({ name: 'Kira', amount: '15000,50', date: '2026-12-05' })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects empty names and dates outside the period', () => {
|
||||
expect(err({ name: ' ', amount: '10', date: '' })).toBe(s.items.nameRequired)
|
||||
expect(err({ name: 'Kira', amount: '10', date: '2027-01-01' })).toBe(s.items.dateOutside)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { z } from 'zod'
|
||||
import { moneyText } from '@/lib/money-schema'
|
||||
import { daysInclusive, isIsoDate, MAX_PERIOD_DAYS } from '@/lib/period-dates'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
const s = t.wizard
|
||||
|
||||
/** 1. adım: dönem tarihleri. `last` = son gün (dahil). */
|
||||
export const datesSchema = z
|
||||
.object({
|
||||
start: z.string(),
|
||||
last: z.string(),
|
||||
name: z.string().trim().max(60, s.dates.nameTooLong),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (!isIsoDate(v.start)) {
|
||||
ctx.addIssue({ code: 'custom', path: ['start'], message: s.dates.startRequired })
|
||||
}
|
||||
if (!isIsoDate(v.last)) {
|
||||
ctx.addIssue({ code: 'custom', path: ['last'], message: s.dates.lastRequired })
|
||||
return
|
||||
}
|
||||
if (!isIsoDate(v.start)) return
|
||||
if (v.last < v.start) {
|
||||
ctx.addIssue({ code: 'custom', path: ['last'], message: s.dates.lastBeforeStart })
|
||||
} else if (daysInclusive(v.start, v.last) > MAX_PERIOD_DAYS) {
|
||||
ctx.addIssue({ code: 'custom', path: ['last'], message: s.dates.tooLong(MAX_PERIOD_DAYS) })
|
||||
}
|
||||
})
|
||||
export type DatesForm = z.infer<typeof datesSchema>
|
||||
|
||||
/** 2–3. adım: gelir / gider kalemi. Tarih boş olabilir; doluysa dönem içinde olmalı. */
|
||||
export function itemSchema(start: string, last: string) {
|
||||
return z.object({
|
||||
name: z.string().trim().min(1, s.items.nameRequired).max(60, s.items.nameTooLong),
|
||||
amount: moneyText('positive'),
|
||||
date: z
|
||||
.string()
|
||||
.refine((d) => d === '' || (isIsoDate(d) && d >= start && d <= last), s.items.dateOutside),
|
||||
})
|
||||
}
|
||||
export type ItemForm = z.infer<ReturnType<typeof itemSchema>>
|
||||
|
||||
/** 4. adım */
|
||||
export const savingsSchema = z.object({ goal: moneyText('optional') })
|
||||
export type SavingsForm = z.infer<typeof savingsSchema>
|
||||
|
||||
/** 5. adım: 4 grubun aylık bütçesi */
|
||||
export const budgetSchema = z.object({
|
||||
needs: moneyText('optional'),
|
||||
wants: moneyText('optional'),
|
||||
culture: moneyText('optional'),
|
||||
unexpected: moneyText('optional'),
|
||||
})
|
||||
export type BudgetForm = z.infer<typeof budgetSchema>
|
||||
@@ -0,0 +1,151 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Controller, useForm, useWatch } from 'react-hook-form'
|
||||
import { FormError, FormField } from '@/components/form-field'
|
||||
import { MoneyInput } from '@/components/money-input'
|
||||
import { PageError, PageLoading } from '@/components/page'
|
||||
import { DistributionBar } from '@/features/planning/distribution-bar'
|
||||
import { SuggestionPicker } from '@/features/planning/suggestion-picker'
|
||||
import { SummaryTiles } from '@/features/planning/summary-tiles'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { parseMoney } from '@/lib/format'
|
||||
import { GROUPS } from '@/lib/groups'
|
||||
import { toKurus } from '@/lib/money-schema'
|
||||
import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods'
|
||||
import { moneyToText } from '@/lib/plan-draft'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GroupAmounts } from '@/types/GroupAmounts'
|
||||
import type { Period } from '@/types/Period'
|
||||
import type { PeriodSummary } from '@/types/PeriodSummary'
|
||||
import { budgetSchema, type BudgetForm } from './schemas'
|
||||
import { StepActions, StepIntro } from './wizard-ui'
|
||||
|
||||
const s = t.wizard.budget
|
||||
|
||||
function toForm(a: GroupAmounts): BudgetForm {
|
||||
return {
|
||||
needs: moneyToText(a.needs),
|
||||
wants: moneyToText(a.wants),
|
||||
culture: moneyToText(a.culture),
|
||||
unexpected: moneyToText(a.unexpected),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. adım: harcanabilir tutarı 4 gruba dağıt.
|
||||
* Daha önce bütçe girilmişse o gösterilir; yoksa varsayılan öneriyle doldurulur.
|
||||
*/
|
||||
export function StepBudget({
|
||||
period,
|
||||
onBack,
|
||||
onDone,
|
||||
}: {
|
||||
period: Period
|
||||
onBack: () => void
|
||||
onDone: () => void
|
||||
}) {
|
||||
const summary = useSummary(period.id)
|
||||
const budget = useBudget(period.id)
|
||||
const suggestion = useSuggestion(period.id, 'default')
|
||||
|
||||
const queries = [summary, budget, suggestion]
|
||||
const failed = queries.find((q) => q.isError)
|
||||
if (failed?.error) return <PageError message={failed.error.message} onRetry={failed.refetch} />
|
||||
if (!summary.data || !budget.data || !suggestion.data) return <PageLoading />
|
||||
|
||||
const saved = Object.fromEntries(budget.data.map((g) => [g.group, g.monthly])) as GroupAmounts
|
||||
const hasSaved = budget.data.some((g) => g.monthly > 0)
|
||||
|
||||
return (
|
||||
<BudgetStepForm
|
||||
period={period}
|
||||
summary={summary.data}
|
||||
initial={toForm(hasSaved ? saved : suggestion.data.amounts)}
|
||||
onBack={onBack}
|
||||
onDone={onDone}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BudgetStepForm({
|
||||
period,
|
||||
summary,
|
||||
initial,
|
||||
onBack,
|
||||
onDone,
|
||||
}: {
|
||||
period: Period
|
||||
summary: PeriodSummary
|
||||
initial: BudgetForm
|
||||
onBack: () => void
|
||||
onDone: () => void
|
||||
}) {
|
||||
const setBudget = useSetBudget(period.id)
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<BudgetForm>({
|
||||
resolver: zodResolver(budgetSchema),
|
||||
mode: 'onTouched',
|
||||
defaultValues: initial,
|
||||
})
|
||||
const values = useWatch({ control })
|
||||
const distributed = GROUPS.reduce(
|
||||
(sum, g) => sum + Math.max(parseMoney(values[g.code] ?? '') ?? 0, 0),
|
||||
0,
|
||||
)
|
||||
|
||||
const onSubmit = handleSubmit(async (v) => {
|
||||
try {
|
||||
await setBudget.mutateAsync({
|
||||
needs: toKurus(v.needs),
|
||||
wants: toKurus(v.wants),
|
||||
culture: toKurus(v.culture),
|
||||
unexpected: toKurus(v.unexpected),
|
||||
})
|
||||
onDone()
|
||||
} catch (e) {
|
||||
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<StepIntro title={s.title} hint={s.hint} />
|
||||
<SummaryTiles summary={summary} />
|
||||
|
||||
<div className="mt-6">
|
||||
<SuggestionPicker periodId={period.id} onApply={(a) => reset(toForm(a))} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
{GROUPS.map((g) => (
|
||||
<FormField key={g.code} label={`${g.emoji} ${g.label}`} error={errors[g.code]}>
|
||||
{(f) => (
|
||||
<div className={cn('rounded-xl p-3', g.className.soft)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name={g.code}
|
||||
render={({ field }) => (
|
||||
<MoneyInput {...f} {...field} placeholder="0,00" className="bg-background" />
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">{g.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<DistributionBar distributed={distributed} spendable={summary.spendable} />
|
||||
</div>
|
||||
|
||||
<FormError message={errors.root?.message} />
|
||||
<StepActions onBack={onBack} pending={isSubmitting} nextLabel={s.finish} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { FormError, FormField } from '@/components/form-field'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { PageError, PageLoading } from '@/components/page'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { daysInclusive, isIsoDate, lastToEnd, suggestRanges } from '@/lib/period-dates'
|
||||
import { useCreatePeriod, usePeriods, useUpdatePeriod } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { Period } from '@/types/Period'
|
||||
import { datesSchema, type DatesForm } from './schemas'
|
||||
import { StepActions, StepIntro } from './wizard-ui'
|
||||
|
||||
const s = t.wizard.dates
|
||||
|
||||
const monthName = new Intl.DateTimeFormat('tr-TR', { month: 'long', year: 'numeric' })
|
||||
|
||||
/** Hızlı seçim etiketi: takvim ayıysa "Aralık 2026", değilse "15.11.2026 – 14.12.2026" */
|
||||
function rangeLabel(r: { start: string; last: string }) {
|
||||
const [y, m, d] = r.start.split('-').map(Number)
|
||||
const monthEnd = new Date(Date.UTC(y, m, 0)).toISOString().slice(0, 10)
|
||||
return d === 1 && r.last === monthEnd
|
||||
? monthName.format(new Date(y, m - 1, 1))
|
||||
: `${formatDate(r.start)} – ${formatDate(r.last)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. adım: dönem yoksa açar, varsa (geri gelindiyse) günceller.
|
||||
* Öneriler son dönemin bitişinden başlar (erken kapatılan ay tekrar önerilmez).
|
||||
*/
|
||||
export function StepDates({ period, onDone }: { period: Period | null; onDone: () => void }) {
|
||||
const periods = usePeriods()
|
||||
if (periods.isPending) return <PageLoading />
|
||||
if (periods.isError)
|
||||
return <PageError message={periods.error.message} onRetry={periods.refetch} />
|
||||
// Liste en yeni başta; sihirbazda açık dönem varsa (geri gelindi) o hariç tutulur
|
||||
const previous = periods.data.find((p) => p.id !== period?.id)
|
||||
return <DatesStepForm period={period} prevEnd={previous?.end_date} onDone={onDone} />
|
||||
}
|
||||
|
||||
function DatesStepForm({
|
||||
period,
|
||||
prevEnd,
|
||||
onDone,
|
||||
}: {
|
||||
period: Period | null
|
||||
prevEnd?: string
|
||||
onDone: () => void
|
||||
}) {
|
||||
const create = useCreatePeriod()
|
||||
const update = useUpdatePeriod()
|
||||
|
||||
const [today] = useState(() => new Date()) // render başına değil, bir kez
|
||||
const presets = suggestRanges(today, prevEnd).map((range) => ({
|
||||
label: rangeLabel(range),
|
||||
range,
|
||||
}))
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<DatesForm>({
|
||||
resolver: zodResolver(datesSchema),
|
||||
mode: 'onTouched',
|
||||
defaultValues: period
|
||||
? { start: period.start_date, last: period.last_date, name: period.name }
|
||||
: { ...presets[0].range, name: '' },
|
||||
})
|
||||
|
||||
const [start, last] = useWatch({ control, name: ['start', 'last'] })
|
||||
const days =
|
||||
isIsoDate(start) && isIsoDate(last) && last >= start ? daysInclusive(start, last) : null
|
||||
|
||||
const onSubmit = handleSubmit(async (v) => {
|
||||
const end = lastToEnd(v.last)
|
||||
try {
|
||||
if (!period) {
|
||||
await create.mutateAsync({ start_date: v.start, end_date: end, name: v.name || undefined })
|
||||
toast.success(s.created)
|
||||
} else if (
|
||||
v.start !== period.start_date ||
|
||||
end !== period.end_date ||
|
||||
v.name !== period.name
|
||||
) {
|
||||
await update.mutateAsync({
|
||||
id: period.id,
|
||||
version: period.version,
|
||||
start_date: v.start,
|
||||
end_date: end,
|
||||
name: v.name, // boş → backend tarihlere göre otomatik ad verir
|
||||
})
|
||||
}
|
||||
onDone()
|
||||
} catch (e) {
|
||||
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<StepIntro title={s.title} hint={s.hint} />
|
||||
|
||||
<div className="space-y-5">
|
||||
<div role="group" aria-label={s.presetsLabel} className="flex flex-wrap gap-2">
|
||||
{presets.map((p) => {
|
||||
const selected = start === p.range.start && last === p.range.last
|
||||
return (
|
||||
<Button
|
||||
key={p.label}
|
||||
type="button"
|
||||
variant={selected ? 'default' : 'outline'}
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
setValue('start', p.range.start, { shouldValidate: true })
|
||||
setValue('last', p.range.last, { shouldValidate: true })
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField label={s.start} error={errors.start}>
|
||||
{(f) => <Input {...f} type="date" {...register('start')} />}
|
||||
</FormField>
|
||||
<FormField label={s.last} error={errors.last}>
|
||||
{(f) => <Input {...f} type="date" min={start || undefined} {...register('last')} />}
|
||||
</FormField>
|
||||
</div>
|
||||
{days !== null && (
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{s.days(days)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<FormField label={s.name} error={errors.name} hint={s.nameHint}>
|
||||
{(f) => <Input {...f} maxLength={60} {...register('name')} />}
|
||||
</FormField>
|
||||
|
||||
<FormError message={errors.root?.message} />
|
||||
</div>
|
||||
|
||||
<StepActions pending={isSubmitting} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Copy, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { FormError, FormField } from '@/components/form-field'
|
||||
import { MoneyInput } from '@/components/money-input'
|
||||
import { PageError, PageLoading } from '@/components/page'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { formatDate, formatMoney } from '@/lib/format'
|
||||
import { toKurus } from '@/lib/money-schema'
|
||||
import {
|
||||
useCopyPrevious,
|
||||
useCreatePlanItem,
|
||||
useDeletePlanItem,
|
||||
usePeriods,
|
||||
usePlanItems,
|
||||
} from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Period } from '@/types/Period'
|
||||
import type { PlanItem } from '@/types/PlanItem'
|
||||
import type { PlanItemKind } from '@/types/PlanItemKind'
|
||||
import { itemSchema, type ItemForm } from './schemas'
|
||||
import { StepActions, StepIntro } from './wizard-ui'
|
||||
|
||||
const s = t.wizard.items
|
||||
|
||||
/** 2–3. adım: sabit gelir / gider kalemleri. Her ekleme ve silme hemen kaydedilir. */
|
||||
export function StepItems({
|
||||
period,
|
||||
kind,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
period: Period
|
||||
kind: PlanItemKind
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
}) {
|
||||
const items = usePlanItems(period.id)
|
||||
const text = s[kind]
|
||||
|
||||
if (items.isPending) return <PageLoading />
|
||||
if (items.isError) return <PageError message={items.error.message} onRetry={items.refetch} />
|
||||
|
||||
const list = items.data.filter((i) => i.kind === kind)
|
||||
const total = list.reduce((sum, i) => sum + i.amount, 0)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StepIntro title={text.title} hint={text.hint} />
|
||||
<CopyPrevious period={period} />
|
||||
|
||||
<AddItemForm period={period} kind={kind} />
|
||||
|
||||
<section aria-label={text.title} className="mt-6">
|
||||
{list.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed p-6 text-center text-muted-foreground">
|
||||
{text.empty}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border">
|
||||
{list.map((item) => (
|
||||
<ItemRow key={item.id} item={item} periodId={period.id} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-3 flex justify-between px-1 font-medium">
|
||||
<span>{text.total}</span>
|
||||
<span className={cn('money', kind === 'income' ? 'text-income' : 'text-expense')}>
|
||||
{formatMoney(total)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<StepActions onBack={onBack} onNext={onNext} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddItemForm({ period, kind }: { period: Period; kind: PlanItemKind }) {
|
||||
const create = useCreatePlanItem(period.id)
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setFocus,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ItemForm>({
|
||||
resolver: zodResolver(itemSchema(period.start_date, period.last_date)),
|
||||
mode: 'onTouched',
|
||||
defaultValues: { name: '', amount: '', date: '' },
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit(async (v) => {
|
||||
try {
|
||||
await create.mutateAsync({
|
||||
kind,
|
||||
name: v.name,
|
||||
amount: toKurus(v.amount),
|
||||
date: v.date || undefined,
|
||||
})
|
||||
reset()
|
||||
setFocus('name') // art arda hızlı giriş
|
||||
} catch (e) {
|
||||
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-4 rounded-xl border bg-card p-4">
|
||||
<FormField label={s.name} error={errors.name}>
|
||||
{(f) => (
|
||||
<Input {...f} maxLength={60} placeholder={s[kind].placeholder} {...register('name')} />
|
||||
)}
|
||||
</FormField>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField label={s.amount} error={errors.amount}>
|
||||
{(f) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="amount"
|
||||
render={({ field }) => <MoneyInput {...f} {...field} placeholder="0,00" />}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField label={s.date} error={errors.date}>
|
||||
{(f) => (
|
||||
<Input
|
||||
{...f}
|
||||
type="date"
|
||||
min={period.start_date}
|
||||
max={period.last_date}
|
||||
{...register('date')}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
<FormError message={errors.root?.message} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="animate-spin" aria-hidden /> : <Plus aria-hidden />}
|
||||
{s.add}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemRow({ item, periodId }: { item: PlanItem; periodId: string }) {
|
||||
const remove = useDeletePlanItem(periodId)
|
||||
return (
|
||||
<li className="flex items-center gap-3 py-2 pr-1 pl-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{item.name}</p>
|
||||
{item.date && <p className="text-sm text-muted-foreground">{formatDate(item.date)}</p>}
|
||||
</div>
|
||||
<span className="money font-medium">{formatMoney(item.amount)}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={s.remove(item.name)}
|
||||
disabled={remove.isPending}
|
||||
onClick={() =>
|
||||
remove.mutate(item.id, {
|
||||
onSuccess: () => toast.success(s.removed(item.name)),
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
}
|
||||
>
|
||||
{remove.isPending ? (
|
||||
<Loader2 className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Trash2 className="text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** Önceki dönem varsa görünür; gelir ve giderleri birlikte kopyalar. */
|
||||
export function CopyPrevious({ period }: { period: Period }) {
|
||||
const periods = usePeriods()
|
||||
const copy = useCopyPrevious(period.id)
|
||||
const hasPrevious = periods.data?.some((p) => p.start_date < period.start_date)
|
||||
if (!hasPrevious) return null
|
||||
|
||||
return (
|
||||
<div className="mb-5 flex flex-wrap items-center gap-3 rounded-xl bg-muted/50 p-3">
|
||||
<p className="flex-1 text-sm text-muted-foreground">{s.copyHint}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={copy.isPending}
|
||||
onClick={() =>
|
||||
copy.mutate(undefined, {
|
||||
onSuccess: (r) => toast.success(s.copied(r.added)),
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
}
|
||||
>
|
||||
{copy.isPending ? <Loader2 className="animate-spin" aria-hidden /> : <Copy aria-hidden />}
|
||||
{s.copy}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Controller, useForm, useWatch } from 'react-hook-form'
|
||||
import { FormError, FormField } from '@/components/form-field'
|
||||
import { MoneyInput } from '@/components/money-input'
|
||||
import { PageError, PageLoading } from '@/components/page'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { formatMoney, formatNumber, parseMoney } from '@/lib/format'
|
||||
import { toKurus } from '@/lib/money-schema'
|
||||
import { useSummary, useUpdatePeriod } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Period } from '@/types/Period'
|
||||
import { savingsSchema, type SavingsForm } from './schemas'
|
||||
import { StepActions, StepIntro } from './wizard-ui'
|
||||
|
||||
const s = t.wizard.savings
|
||||
|
||||
/** 4. adım: birikim hedefi (harcamadan önce ayrılır). */
|
||||
export function StepSavings({
|
||||
period,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
period: Period
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
}) {
|
||||
const summary = useSummary(period.id)
|
||||
const update = useUpdatePeriod()
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<SavingsForm>({
|
||||
resolver: zodResolver(savingsSchema),
|
||||
mode: 'onTouched',
|
||||
defaultValues: { goal: period.savings_goal ? formatNumber(period.savings_goal / 100) : '' },
|
||||
})
|
||||
const goalText = useWatch({ control, name: 'goal' })
|
||||
|
||||
if (summary.isPending) return <PageLoading />
|
||||
if (summary.isError)
|
||||
return <PageError message={summary.error.message} onRetry={summary.refetch} />
|
||||
|
||||
// Birikimden önceki kalan = harcanabilir + (kayıtlı) birikim hedefi → formül yalnızca backend'de
|
||||
const available = summary.data.spendable + summary.data.savings_goal
|
||||
const goal = parseMoney(goalText) ?? 0
|
||||
const after = available - Math.max(goal, 0)
|
||||
|
||||
const onSubmit = handleSubmit(async (v) => {
|
||||
const kurus = toKurus(v.goal)
|
||||
try {
|
||||
if (kurus !== period.savings_goal) {
|
||||
await update.mutateAsync({ id: period.id, version: period.version, savings_goal: kurus })
|
||||
}
|
||||
onNext()
|
||||
} catch (e) {
|
||||
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<StepIntro title={s.title} hint={s.hint} />
|
||||
|
||||
<div className="space-y-5">
|
||||
<Row label={s.available} amount={available} />
|
||||
<FormField label={s.goal} error={errors.goal}>
|
||||
{(f) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="goal"
|
||||
render={({ field }) => (
|
||||
<MoneyInput {...f} {...field} placeholder="0,00" className="h-12 text-lg" />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<Row label={s.after} amount={after} strong />
|
||||
{after < 0 && (
|
||||
<p role="status" className="text-sm text-warning">
|
||||
{s.negative}
|
||||
</p>
|
||||
)}
|
||||
<FormError message={errors.root?.message} />
|
||||
</div>
|
||||
|
||||
<StepActions onBack={onBack} pending={isSubmitting} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, amount, strong }: { label: string; amount: number; strong?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-xl border px-4 py-3',
|
||||
strong && 'border-primary/40 bg-primary/5',
|
||||
)}
|
||||
aria-live={strong ? 'polite' : undefined}
|
||||
>
|
||||
<span className={cn(strong ? 'font-medium' : 'text-muted-foreground')}>{label}</span>
|
||||
<span className={cn('money', strong && 'text-lg font-bold', amount < 0 && 'text-expense')}>
|
||||
{formatMoney(amount)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user