Compare commits

...

24 Commits

Author SHA1 Message Date
suphicakir feb2e1d16c feat(raporlar): raporlar sayfası, CSV dışa aktarma ve yazdırma
- Filtreler (kapsam, grup, kategori, ödeme türü) adreste
- Gruplar, tüm kategoriler, ödeme türleri, aylar; bütçe sapması; gelir–gider ve birikim oranı; varlık performansı
- Her bölümde Excel uyumlu CSV (BOM, ;) ve tek bölüm yazdırma
- Yazdırmada açık tema, menüler gizli, kapsam satırı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:59:58 +03:00
suphicakir 87409590a6 feat(raporlar): harcama kırılımı uç noktası ve dönem istatistiklerinde birikim oranı verileri
- GET /reports/breakdown: grup, tüm kategoriler, ödeme türü ve ay kırılımı; dönem/tarih/grup/kategori/ödeme türü filtreleri
- /stats/periods: gelir, sabit gider, devirler, gerçekleşen birikim
- Faz 9 planı kullanıcı kararlarıyla güncellendi (ekstre içe aktarma, JSON geri dönüş noktası, aylık rapor)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:48:46 +03:00
suphicakir 8c9c3c2506 docs: Faz 9 adım planı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:29:30 +03:00
suphicakir 48554e0ff6 feat(birikim): dönem kapanışındaki birikimi varlıklara ekleme
- Kapatma sonucunda onayla "Birikime ekle" (TL alımı, dönemin son günü)
- Birikimler'de "Bekleyen birikimler" listesi
- Yeniden kapatmada bağlı kayıt yeni tutara güncellenir (0 → silinir), TL çıkışları aşılırsa reddedilir
- Panel "Toplam birikim" artık portföyün güncel değeri

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:27:07 +03:00
suphicakir 6b7b69c987 feat(birikim): birikimler sayfası, varlık ayrıntısı ve alım/satım
- Portföy özeti, kâr/zarar, dağılım halka grafiği, kur uyarıları
- Varlık ayrıntısı: alım/satım formu (tutar önerisi), geçmiş, düzenle/sil
- Elle kur girme/kaldırma, döviz ekleme, pasife alma
- Miktar/kur alanı (6 ondalık, Türkçe biçim)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:14:50 +03:00
suphicakir 3f60efdc33 feat(birikim): otomatik ve manuel kurlar, portföy değerlemesi
- TCMB (döviz alış kuru) ve xaus.com (altın) saatlik arka planda çekilir, gün başına saklanır
- Kaynak erişilemezse son kur geçerli; altın için son USD kuru kullanılır
- Elle kur girişi/silme; aynı gün elle girilen kur önceliklidir
- GET /portfolio: güncel değer, kâr/zarar, toplamlar, son kur çekimi
- RATES_REFRESH_MINUTES ayarı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 00:59:01 +03:00
suphicakir 267d292a90 feat(birikim): varlıklar ve alım/satım kayıtları (ortalama maliyet)
- Varsayılan varlıklar (TL, altınlar, USD, EUR) + migration v2 ile eski hanelere ekleme
- Döviz ekleme, varlık adı/aktifliği
- Alım/satım ekle, düzenle, sil; eldekinden fazla satış (geriye tarihli dahil) engellenir
- Ağırlıklı ortalama maliyet ve gerçekleşen kâr/zarar (hesaplanır, saklanmaz)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 00:35:23 +03:00
suphicakir 1ef28b80a3 docs: Faz 8 adım planı, kategori yönetimi Faz 10'a alındı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 00:24:02 +03:00
suphicakir 191c3e6db5 feat(panel): harcanabilir ve grup harcama grafikleri
- Günlük / Haftalık / Aylık sekmeli iki çizgi grafik (Recharts, ayrı parça olarak yüklenir)
- Kümülatif harcanabilir vs gerçekleşen; 4 grup (+ isteğe bağlı harcanabilir çizgisi)
- Dönem istatistiklerine grup kırılımı (spent_by_group)
- Grafik altında metin özeti, koyu tema uyumlu renkler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 00:14:55 +03:00
suphicakir 64c185ab96 feat(panel): özet kartları, anlık durum ve hızlı harcama ekleme
- Gelir, sabit gider, toplam birikim, devreden borç kartları
- Anlık durum: kalan harcanabilir ve günlük harcama önerisi
- Grup planlanan/gerçekleşen (Haftalık/Aylık)
- "Harcama Ekle": masaüstünde başlıkta, mobilde yüzen düğme
- Yükleniyor iskeleti, boş durumlar, açık dönem yokken son dönem özeti

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 23:54:32 +03:00
suphicakir 30136ed1d3 feat(panel): günlük harcama ve dönem istatistikleri uç noktaları
- GET /periods/{id}/daily: dönemin her günü için 4 grubun harcaması
- GET /stats/periods: son dönemlerin harcanabilir/harcanan tutarı + toplam birikim
- Faz 7 adım planı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 23:37:05 +03:00
suphicakir 861bfba85a feat(değerlendirme): geçmiş dönemler ve düzeltme için yeniden açma
- Defter, Plan ve Değerlendirme'de dönem seçici (?donem=)
- Kapalı dönem salt okunur; düzeltmedeki dönem düzenlenebilir ve yeniden kapatılır
- Hane sahibi için onaylı "Düzeltme için yeniden aç"
- Geçmiş dönemler listesi, dönem durum rozeti

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 23:11:04 +03:00
suphicakir 2c16cf4163 feat(değerlendirme): dönem kapatma sihirbazı
- /degerlendirme/kapat: Özet → Birikim → Onay → Sonuç
- Birikime aktarılacak tutar (varsayılan tamamı), kalan artı/açık devri önizlemesi
- Değerlendirme sayfasına "Dönemi Kapat" bölümü
- Yeni dönem önerisi son dönemin bitişinden başlar (erken kapatılan ay önerilmez)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 22:59:38 +03:00
suphicakir 311cfc8c60 feat(değerlendirme): ay sonu değerlendirmesi sayfası
- Kakeibo'nun 4 sorusu, nasıl hesaplandığıyla birlikte
- Grup bazında planlanan/gerçekleşen, en çok harcanan kategoriler, haftalık dağılım
- "Nasıl daha iyi yapabilirim?" (Markdown) ve yalnızca başlıkları listelenen notlar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:31:35 +03:00
suphicakir 62e92f552a feat(dönem): dönem kapatma, devir ve düzeltme için yeniden açma (backend)
- POST /periods/{id}/close: tek transaction; açık → borç, artı → birikim + devir
- Devir sonraki dönem açılınca ona bağlanır
- POST /periods/{id}/reopen (sahip): düzeltme modu, yeni dönem açık kalır

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:20:09 +03:00
suphicakir 8ec1fd6f25 feat(değerlendirme): ay sonu değerlendirmesi — 4 soru ve döküm (backend)
- GET /periods/{id}/review: param, hedef, harcanan, biriken; grup, kategori ve hafta bazında planlanan/gerçekleşen
- Dönemde "Nasıl daha iyi yapabilirim?" metni (reflection_md)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:54:57 +03:00
suphicakir e4c8d971fc feat(yedek): günlük MongoDB yedeği, geri yükleme ve bu bilgisayara kopya
- scripts/backup.sh: mongodump + bütünlük denetimi, son 14 yedek
- scripts/restore.sh: ayrı veritabanına geri yükleme ve sayım karşılaştırma
- scripts/backup-pull.sh + systemd zamanlayıcıları; kurulum docs/DEPLOY.md §6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:40:06 +03:00
suphicakir 16535ad7f3 feat(defter): haftanın girişleri, düzenle/sil + geri al, haftalık özet
- Girişler gün gün, gün toplamıyla; satırda kategori, grup, ödeme türü, açıklama
- Düzenleme aynı formda (sürüm kontrolü, pasif kategori korunur)
- Silmede "Geri al" bildirimi; grup bazlı bütçe / harcanan / kalan özeti

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:29:33 +03:00
suphicakir f930f2fbcc feat(defter): hafta seçici ve hızlı harcama giriş formu
- /defter: önceki/sonraki/bu hafta, haftanın harcanan/bütçe toplamı
- Form: tutar, 4 grup düğmesi, kategori ve ödeme türü seçimi, tarih, açıklama
- Son seçimler bu cihazda hatırlanır; sunucu hataları ilgili alana düşer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:20:18 +03:00
suphicakir 7143613ab2 feat(defter): özet ve bütçeye gerçekleşen harcama eklendi (backend)
- Dönem özeti: spent_total ve remaining (harcanabilir − harcanan)
- Bütçe görünümü: grup ve hafta bazında spent
- Değerler saklanmaz, kayıtlardan anlık toplanır; silinenler sayılmaz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:12:51 +03:00
suphicakir 2003b8fad7 feat(defter): harcama kayıtları — ekle, düzenle, sil, geri al (backend)
- /periods/{id}/entries (from/to filtresi), /entries/{id}, /entries/{id}/restore
- Grup kategoriden türetilir; kategori/ödeme türü hane ve aktiflik kontrolü
- Yalnızca açık dönem; soft delete + geri al; sürüm çakışmasında 409
- 6 entegrasyon testi (izolasyon, kapalı dönem, doğrulama, önceki dönem önerisi)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:07:51 +03:00
suphicakir eca0bb27a0 feat(plan): dönem notları — Markdown, yaz/önizle sekmeleri
- NotesSection: ekle/düzenle/sil (onaylı), yazar ve tarih, kapalı dönemde salt okunur
- Markdown gösterimi (react-markdown + remark-gfm): ham HTML yok, güvenli bağlantılar
- Çakışma (409) mesajı formda; Plan sayfasına bağlandı
- Dağıtım çubuğu boş dönemde "tamamı dağıtıldı" demiyor
- Faz 4 tamamlandı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:55:16 +03:00
suphicakir 9004a7cc42 feat(plan): plan ekranı — görüntüleme/düzenleme modu ve haftalık bütçe
- Salt okunur görünüm: özet kutucukları, gelir/gider, 4 grup + haftalık döküm
- Düzenle → taslak; Kaydet yalnızca değişenleri gönderir, Vazgeç geri alır
- Kaydedilmemiş değişiklikle çıkışta onay
- Hafta elle ayarlama / otomatiğe döndürme, canlı önizleme (backend ile aynı hesap)
- DistributionBar ve SuggestionPicker ortak bileşen; 9 yeni vitest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:47:00 +03:00
suphicakir ca0d3a505c feat(plan): ilk dönem sihirbazı (dönem, gelir, gider, birikim, bütçe)
- /plan/donem-ac?adim=1..5: her adım hemen kaydedilir, adım adreste
- Panel ve Plan'da açık dönem yoksa "Dönemi başlat" kartı
- MoneyInput (sayısal klavye, Türkçe biçim), moneyText/toKurus, period-dates
- lib/periods.ts: dönem, plan kalemi, özet, bütçe ve öneri kancaları
- SummaryTiles, PageLoading/PageError; 13 yeni vitest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:16:32 +03:00
205 changed files with 19559 additions and 87 deletions
+3
View File
@@ -24,3 +24,6 @@ RUST_LOG=kakeibo=debug,tower_http=info
# Derlenmiş arayüz klasörü. Geliştirmede BOŞ bırakın (arayüzü Vite sunar).
# Docker imajında /app/static olarak ayarlıdır.
# STATIC_DIR=
# Otomatik kur yenileme (dakika); 0 → kapalı (çevrimdışı geliştirme)
# RATES_REFRESH_MINUTES=60
+5 -1
View File
@@ -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.
+239 -4
View File
@@ -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"
+2
View File
@@ -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"
+123 -1
View File
@@ -235,9 +235,10 @@ DELETE {{baseUrl}}/notes/BURAYA-NOT-ID
# 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)
### 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)
@@ -263,3 +264,124 @@ Content-Type: application/json
### 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
+10
View File
@@ -12,6 +12,8 @@ pub struct Config {
/// Derlenmiş frontend klasörü (`STATIC_DIR`). Tanımlıysa Axum arayüzü de sunar
/// (canlıda tek servis). Geliştirmede boş: arayüzü Vite sunar.
pub static_dir: Option<PathBuf>,
/// Otomatik kur yenileme aralığı (dakika, `RATES_REFRESH_MINUTES`, varsayılan 60); 0 → kapalı
pub rates_refresh_minutes: u64,
}
/// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni).
@@ -69,6 +71,13 @@ impl Config {
Err(_) => 8080,
};
let rates_refresh_minutes = match env::var("RATES_REFRESH_MINUTES") {
Ok(m) if !m.trim().is_empty() => m.trim().parse().map_err(|_| {
ConfigError::Invalid("RATES_REFRESH_MINUTES", "dakika (sayı) olmalı")
})?,
_ => 60,
};
Ok(Self {
mongodb_uri,
jwt_secret,
@@ -76,6 +85,7 @@ impl Config {
port,
registration,
static_dir,
rates_refresh_minutes,
})
}
+4
View File
@@ -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 })],
+42 -5
View File
@@ -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
View File
@@ -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);
+74
View File
@@ -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,
}
+26
View File
@@ -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,
}
+3
View File
@@ -1,11 +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;
+20 -2
View File
@@ -1,6 +1,6 @@
//! `periods` koleksiyonu (DATA_MODEL.md §3.4).
use mongodb::bson::Uuid;
use mongodb::bson::{DateTime, Uuid};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -10,8 +10,12 @@ use ts_rs::TS;
pub enum PeriodStatus {
/// Girişler yapılabilir. Hanede en fazla bir tane.
Open,
/// Salt okunur (Faz 6: kapanış sihirbazı, sahip yeniden açabilir).
/// 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 {
@@ -19,8 +23,14 @@ impl PeriodStatus {
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)]
@@ -35,5 +45,13 @@ pub struct Period {
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,
}
+30
View File
@@ -0,0 +1,30 @@
//! `exchange_rates` — kurlar (DATA_MODEL.md §3.14). Değer: 1 birim varlık = `rate_e6` / 1e6 TL.
//!
//! `household_id: null` → otomatik (tüm haneler, TCMB / xaus.com); dolu → hanenin elle girdiği kur.
//! Otomatik kurlar gün başına tek kayıt (aynı gün yeniden çekilince güncellenir) → günlük geçmiş.
use mongodb::bson::{DateTime, Uuid};
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Rate {
#[serde(rename = "_id")]
pub id: Uuid,
#[serde(default)]
pub household_id: Option<Uuid>,
/// Varlık kodu: `USD`, `XAU_GRAM`…
pub code: String,
/// TL × 1e6, birim başına
pub rate_e6: i64,
/// `tcmb` · `xaus` · `manual`
pub source: String,
/// Kurun geçerli olduğu gün (TCMB: bülten tarihi)
pub rate_date: String,
pub fetched_at: DateTime,
}
impl Rate {
pub fn is_manual(&self) -> bool {
self.household_id.is_some()
}
}
+341
View File
@@ -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())))
}
+18 -2
View File
@@ -2,8 +2,8 @@
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods/{id}/summary` | gelir/gider/birikim/devir → harcanabilir |
//! | GET | `/api/periods/{id}/budget` | 4 grup: aylık + haftalık döküm |
//! | 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` |
@@ -81,6 +81,12 @@ pub struct SummaryDto {
/// 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)]
@@ -89,6 +95,9 @@ 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>,
@@ -105,6 +114,9 @@ struct WeekBudgetDto {
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 {
@@ -112,6 +124,7 @@ impl From<GroupBudget> for GroupBudgetDto {
Self {
group: g.group,
monthly: g.monthly,
spent: g.spent,
weekly_over: g.weekly_over,
weeks: g
.weeks
@@ -123,6 +136,7 @@ impl From<GroupBudget> for GroupBudgetDto {
days: w.days,
amount: w.amount,
is_manual: w.is_manual,
spent: w.spent,
})
.collect(),
}
@@ -182,6 +196,8 @@ pub async fn summary(
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,
}))
}
+203
View File
@@ -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()))
}
+33
View File
@@ -1,14 +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,
@@ -66,6 +72,8 @@ pub fn router(state: AppState) -> Router {
.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),
@@ -77,6 +85,10 @@ pub fn router(state: AppState) -> Router {
.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),
@@ -84,6 +96,27 @@ pub fn router(state: AppState) -> Router {
.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 });
+95 -1
View File
@@ -6,7 +6,10 @@
//! | 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 |
//! | 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,
@@ -25,6 +28,7 @@ use crate::{
models::period::{Period, PeriodStatus},
services::{
calendar::{days_between, format_date, parse_date, weeks},
closing,
period::{self, NewPeriod, PeriodChanges},
},
state::AppState,
@@ -54,6 +58,8 @@ pub struct UpdateRequest {
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>,
}
@@ -73,6 +79,13 @@ pub struct PeriodDto {
/// 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>,
}
@@ -102,6 +115,9 @@ impl From<Period> for PeriodDto {
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()
@@ -178,9 +194,87 @@ pub async fn update(
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()))
}
+171
View File
@@ -0,0 +1,171 @@
//! Kur ve portföy uç noktaları (hanenin tüm üyeleri):
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/portfolio` | varlıklar + güncel kur, değer, kâr/zarar + toplamlar + son kur çekimi |
//! | POST | `/api/rates` | elle kur (hanenin; aynı gün tekrar → güncellenir) |
//! | DELETE | `/api/rates/{id}` | elle kuru sil → otomatik kura dön |
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use mongodb::bson::{DateTime, Uuid};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
extract::ValidJson,
models::rate::Rate,
routes::asset::AssetDto,
services::rates,
};
#[derive(Serialize, TS)]
#[ts(export, rename = "Rate")]
pub struct RateDto {
id: String,
code: String,
/// TL × 1e6, birim başına
#[ts(type = "number")]
rate_e6: i64,
/// `tcmb` · `xaus` · `manual`
#[ts(type = "\"tcmb\" | \"xaus\" | \"manual\"")]
source: String,
rate_date: String,
/// ISO 8601
fetched_at: String,
is_manual: bool,
}
fn iso(d: DateTime) -> String {
d.try_to_rfc3339_string().unwrap_or_default()
}
impl From<Rate> for RateDto {
fn from(r: Rate) -> Self {
Self {
id: r.id.to_string(),
is_manual: r.is_manual(),
code: r.code,
rate_e6: r.rate_e6,
source: r.source,
rate_date: r.rate_date,
fetched_at: iso(r.fetched_at),
}
}
}
#[derive(Serialize, TS)]
#[ts(export, rename = "PortfolioItem")]
struct ItemDto {
asset: AssetDto,
/// TL'de ve kur yoksa `null`
rate: Option<RateDto>,
/// Güncel değer (kuruş); kur yoksa `null`
#[ts(type = "number | null")]
value: Option<i64>,
/// Değer − eldeki maliyet (kuruş)
#[ts(type = "number | null")]
unrealized_pl: Option<i64>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "Portfolio")]
pub struct PortfolioDto {
items: Vec<ItemDto>,
/// Eldeki maliyet toplamı (kuruş)
#[ts(type = "number")]
cost_total: i64,
/// Kuru olanların güncel değeri (kuruş)
#[ts(type = "number")]
value_total: i64,
/// Kuru olanlarda değer − maliyet
#[ts(type = "number")]
unrealized_pl_total: i64,
#[ts(type = "number")]
realized_pl_total: i64,
/// Elde olup kuru bulunmayan varlık sayısı (toplama girmedi)
unpriced: u32,
/// Son otomatik kur çekimi (ISO); hiç yoksa `null`
last_fetch: Option<String>,
}
pub async fn portfolio(
State(s): State<crate::state::AppState>,
auth: AuthUser,
) -> AppResult<Json<PortfolioDto>> {
let p = rates::portfolio(&s.db, auth.household_id).await?;
let mut dto = PortfolioDto {
items: Vec::with_capacity(p.items.len()),
cost_total: 0,
value_total: 0,
unrealized_pl_total: 0,
realized_pl_total: 0,
unpriced: 0,
last_fetch: p.last_fetch.map(iso),
};
for v in p.items {
let h = v.holding;
dto.cost_total += h.cost;
dto.realized_pl_total += h.realized_pl;
let unrealized = v.value.map(|x| x - h.cost);
match v.value {
Some(x) => {
dto.value_total += x;
dto.unrealized_pl_total += x - h.cost;
}
None if h.quantity_e6 > 0 => dto.unpriced += 1,
None => {}
}
dto.items.push(ItemDto {
asset: AssetDto::new(v.asset, h, v.tx_count),
rate: v.rate.map(RateDto::from),
value: v.value,
unrealized_pl: unrealized,
});
}
Ok(Json(dto))
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "RateCreateRequest", optional_fields)]
pub struct CreateRequest {
/// Varlık kodu (`XAU_CEYREK`, `USD`…)
code: String,
/// TL × 1e6, birim başına
#[ts(type = "number")]
rate_e6: i64,
/// Boşsa bugün
rate_date: Option<String>,
}
pub async fn create(
State(s): State<crate::state::AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<RateDto>)> {
let r = rates::set_manual(
&s.db,
auth.household_id,
&req.code,
req.rate_e6,
req.rate_date.as_deref(),
)
.await?;
Ok((StatusCode::CREATED, Json(r.into())))
}
pub async fn delete(
State(s): State<crate::state::AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
rates::delete_manual(&s.db, auth.household_id, id).await?;
Ok(StatusCode::NO_CONTENT)
}
+173
View File
@@ -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(),
}))
}
+152
View File
@@ -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,
}))
}
+135
View File
@@ -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(),
}))
}
+4
View File
@@ -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)
}
};
+621
View File
@@ -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),
))
}
+76 -1
View File
@@ -5,6 +5,8 @@
//! 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;
@@ -40,6 +42,10 @@ pub struct Summary {
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 {
@@ -49,11 +55,15 @@ pub struct WeekBudget {
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,
@@ -131,6 +141,16 @@ pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppR
.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,
@@ -140,6 +160,8 @@ pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppR
carryover_surplus,
spendable,
budget_total,
spent_total,
spent_by_group,
})
}
@@ -154,6 +176,7 @@ pub async fn view(
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 {
@@ -183,6 +206,13 @@ pub async fn view(
.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),
@@ -190,10 +220,11 @@ pub async fn view(
WeekBudget {
index: w.index,
start_date: key,
last_date: format_date(w.last),
last_date: last,
days: w.days,
amount,
is_manual,
spent: week_spent,
}
})
.collect();
@@ -201,6 +232,11 @@ pub async fn view(
out.push(GroupBudget {
group,
monthly,
spent: spent
.iter()
.filter(|(g, _, _)| *g == group)
.map(|(_, _, a)| a)
.sum(),
weeks,
weekly_over: manual_total > monthly,
});
@@ -451,6 +487,45 @@ async fn sum_by(
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);
+7
View File
@@ -26,6 +26,13 @@ pub fn parse_date(s: &str) -> Option<Date> {
}
/// `Date` → `"2026-12-09"`
/// Türkiye'de bugünün tarihi (UTC+3, yaz saati yok). Sunucu UTC'de çalışsa da gün doğru olsun.
pub fn today_tr() -> Date {
time::OffsetDateTime::now_utc()
.to_offset(time::UtcOffset::from_hms(3, 0, 0).expect("geçerli ofset"))
.date()
}
pub fn format_date(d: Date) -> String {
format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day())
}
+12
View File
@@ -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,
+280
View File
@@ -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());
}
}
+290
View File
@@ -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())
}
+164
View File
@@ -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
}
}
+9
View File
@@ -1,12 +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;
+61 -13
View File
@@ -41,9 +41,14 @@ pub struct PeriodChanges {
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)
}
@@ -77,7 +82,7 @@ pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Perio
/// 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 == PeriodStatus::Closed {
if !p.status.is_writable() {
return Err(closed());
}
Ok(p)
@@ -117,24 +122,51 @@ pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResul
d.insert("end_date", format_date(end));
d.insert("status", PeriodStatus::Open.as_str());
d.insert("savings_goal", savings_goal);
d.insert("reflection_md", "");
d.insert("reflection_md", Bson::Null);
d.insert("to_savings", Bson::Null);
d.insert(
"previous_period_id",
previous.map_or(Bson::Null, |p| p.id.into()),
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);
match db.collection::<Document>(col::PERIODS).insert_one(d).await {
Ok(_) => {}
// Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller
Err(e) if is_duplicate_key(&e) => {
return Err(AppError::Conflict(
"Zaten açık bir dönem var. Sayfayı yenileyin.".into(),
));
// 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());
}
Err(e) => return Err(e.into()),
}
coll(db)
@@ -150,7 +182,7 @@ pub async fn update(
ch: PeriodChanges,
) -> AppResult<Period> {
let current = get(db, household_id, id).await?;
if current.status == PeriodStatus::Closed {
if !current.status.is_writable() {
return Err(closed());
}
if ch.version.is_some_and(|v| v != current.version) {
@@ -177,6 +209,9 @@ pub async fn update(
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);
@@ -190,6 +225,19 @@ pub async fn update(
// ---------- 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"))?;
@@ -276,7 +324,7 @@ async fn ensure_entries_inside(
Ok(())
}
fn stale() -> AppError {
pub fn stale() -> AppError {
AppError::Conflict("Dönem başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
+214
View File
@@ -0,0 +1,214 @@
//! Kur kaynaklarının ayrıştırıcıları (saf; ağ yok → örnek metinle test edilir).
//!
//! - **TCMB** `today.xml`: dövizlerin **alış** kuru (`ForexBuying`) ÷ `Unit` (JPY 100 birim…).
//! Değerleme alış kuruyla (elindekini bugün bozdursan alacağın; kullanıcı kararı).
//! - **xaus.com** `/api/v1/spot`: 1 gr saf altının USD fiyatı (`per_gram_usd`) × TCMB USD alış.
//! Çeyrek/yarım/tam = içerdiği saf altın (has) gramı × gram fiyatı (kuyumcu işçilik payı hariç);
//! ons = 31,1034768 gr. Bu yaklaşık değer; gerekirse manuel kur girilir.
use serde::Deserialize;
use crate::services::calendar::{format_date, parse_date};
/// Altın kodları ve içerdiği saf altın (gr × 1e6)
pub const GOLD: &[(&str, i64)] = &[
("XAU_GRAM", 1_000_000),
("XAU_CEYREK", 1_603_000), // 1,75 gr × 0,916 (22 ayar)
("XAU_YARIM", 3_206_000), // 3,50 gr × 0,916
("XAU_TAM", 6_412_000), // 7,00 gr × 0,916
("XAU_ONS", 31_103_477), // 31,1034768 gr
];
#[derive(Debug, PartialEq, Eq)]
pub struct Bulletin {
/// `"YYYY-MM-DD"`
pub date: String,
/// (kod, TL × 1e6 / birim)
pub rates: Vec<(String, i64)>,
}
#[derive(Deserialize)]
struct TcmbXml {
#[serde(rename = "@Tarih")]
tarih: String,
#[serde(rename = "Currency", default)]
currencies: Vec<TcmbCurrency>,
}
#[derive(Deserialize)]
struct TcmbCurrency {
#[serde(rename = "@CurrencyCode")]
code: String,
#[serde(rename = "Unit")]
unit: Option<String>,
#[serde(rename = "ForexBuying")]
forex_buying: Option<String>,
}
/// `"50.1691"` → `50_169_100` (ondalık noktalı metin → × 1e6, kayan nokta kullanmadan)
pub fn decimal_e6(s: &str) -> Option<i64> {
let s = s.trim();
let (int, frac) = s.split_once('.').unwrap_or((s, ""));
if int.is_empty()
|| !int.bytes().all(|b| b.is_ascii_digit())
|| !frac.bytes().all(|b| b.is_ascii_digit())
{
return None;
}
let mut frac6: String = frac.chars().take(6).collect();
while frac6.len() < 6 {
frac6.push('0');
}
let v = int.parse::<i64>().ok()?.checked_mul(1_000_000)? + frac6.parse::<i64>().ok()?;
// 7. basamak yuvarlama
let round = frac.as_bytes().get(6).is_some_and(|b| *b >= b'5');
Some(v + i64::from(round))
}
pub fn parse_tcmb(xml: &str) -> Result<Bulletin, String> {
let doc: TcmbXml =
quick_xml::de::from_str(xml).map_err(|e| format!("TCMB XML okunamadı: {e}"))?;
// "12.11.2026" → "2026-11-12"
let parts: Vec<&str> = doc.tarih.split('.').collect();
let date = match parts.as_slice() {
[d, m, y] => format!("{y}-{m}-{d}"),
_ => return Err(format!("TCMB tarihi okunamadı: {}", doc.tarih)),
};
let date = parse_date(&date)
.map(format_date)
.ok_or_else(|| format!("TCMB tarihi geçersiz: {}", doc.tarih))?;
let rates = doc
.currencies
.into_iter()
.filter_map(|c| {
let unit: i64 = c
.unit
.as_deref()
.unwrap_or("1")
.trim()
.parse()
.ok()
.filter(|u| *u > 0)?;
let buying = decimal_e6(c.forex_buying.as_deref()?)?;
(buying > 0).then(|| (c.code.trim().to_uppercase(), buying / unit))
})
.collect();
Ok(Bulletin { date, rates })
}
#[derive(Deserialize)]
struct XausSpot {
per_gram_usd: f64,
}
/// 1 gr saf altın, USD × 1e6
pub fn parse_gold(json: &str) -> Result<i64, String> {
let s: XausSpot =
serde_json::from_str(json).map_err(|e| format!("altın fiyatı okunamadı: {e}"))?;
// JSON sayıyı ondalıklı verir; kurda 6 basamak yeterli
let v = (s.per_gram_usd * 1_000_000.0).round();
if !(v.is_finite() && v > 0.0 && v < 1e15) {
return Err(format!("altın fiyatı geçersiz: {}", s.per_gram_usd));
}
Ok(v as i64)
}
/// Gram USD fiyatı ve USD/TL alış → altın kodlarının TL kurları
pub fn gold_rates(per_gram_usd_e6: i64, usd_try_e6: i64) -> Vec<(&'static str, i64)> {
let gram_try_e6 = i128::from(per_gram_usd_e6) * i128::from(usd_try_e6) / 1_000_000;
GOLD.iter()
.map(|(code, has_e6)| {
(
*code,
(gram_try_e6 * i128::from(*has_e6) / 1_000_000) as i64,
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
pub const TCMB_SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="isokur.xsl"?>
<Tarih_Date Tarih="12.11.2026" Date="11/12/2026" Bulten_No="2026/213" >
<Currency CrossOrder="0" Kod="USD" CurrencyCode="USD">
<Unit>1</Unit>
<Isim>ABD DOLARI</Isim>
<CurrencyName>US DOLLAR</CurrencyName>
<ForexBuying>50.1691</ForexBuying>
<ForexSelling>50.2595</ForexSelling>
<BanknoteBuying>50.1340</BanknoteBuying>
<BanknoteSelling>50.3349</BanknoteSelling>
<CrossRateUSD/>
<CrossRateOther/>
</Currency>
<Currency CrossOrder="9" Kod="JPY" CurrencyCode="JPY">
<Unit>100</Unit>
<Isim>JAPON YENİ</Isim>
<CurrencyName>JAPENESE YEN</CurrencyName>
<ForexBuying>32.4512</ForexBuying>
<ForexSelling>32.6661</ForexSelling>
</Currency>
<Currency CrossOrder="19" Kod="XDR" CurrencyCode="XDR">
<Unit>1</Unit>
<Isim>ÖZEL ÇEKME HAKKI (SDR)</Isim>
<CurrencyName>SPECIAL DRAWING RIGHT (SDR)</CurrencyName>
<ForexBuying>68.5521</ForexBuying>
<ForexSelling/>
</Currency>
<Currency CrossOrder="20" Kod="IRR" CurrencyCode="IRR">
<Unit>1</Unit>
<ForexBuying/>
</Currency>
</Tarih_Date>"#;
#[test]
fn decimal_without_floats() {
assert_eq!(decimal_e6("50.1691"), Some(50_169_100));
assert_eq!(decimal_e6("7"), Some(7_000_000));
assert_eq!(
decimal_e6("0.1234567"),
Some(123_457),
"7. basamak yuvarlanır"
);
assert_eq!(decimal_e6(""), None);
assert_eq!(decimal_e6("1,5"), None);
assert_eq!(decimal_e6("-2.0"), None);
}
#[test]
fn tcmb_buying_rates_per_unit() {
let b = parse_tcmb(TCMB_SAMPLE).unwrap();
assert_eq!(b.date, "2026-11-12");
assert_eq!(
b.rates,
vec![
("USD".into(), 50_169_100),
("JPY".into(), 324_512), // 100 yen = 32,4512 TL
("XDR".into(), 68_552_100),
],
"alış kuru boş olan (IRR) atlanır"
);
assert!(parse_tcmb("<html>bakımda</html>").is_err());
}
#[test]
fn gold_from_usd() {
let g = parse_gold(
r#"{"xau":{"price":3874.1},"per_gram_usd":124.5552,"spot_usd_oz":3874.100098}"#,
)
.unwrap();
assert_eq!(g, 124_555_200);
assert!(parse_gold(r#"{"per_gram_usd":0}"#).is_err());
assert!(parse_gold("{}").is_err());
let r = gold_rates(g, 50_169_100);
// 124,5552 $ × 50,1691 TL = 6.248,8222843 TL/gr
assert_eq!(r[0], ("XAU_GRAM", 6_248_822_284));
assert_eq!(r[1].0, "XAU_CEYREK");
assert_eq!(r[1].1, 6_248_822_284 * 1_603 / 1_000);
assert_eq!(r[4].1, 194_360_100_187, "ons ≈ 194.360,10 TL");
}
}
+358
View File
@@ -0,0 +1,358 @@
//! Kurlar ve portföy değerlemesi (KAKEIBO_PROMPT §8.7, DATA_MODEL.md §3.14).
//!
//! - **Otomatik:** `refresh` TCMB'den dövizleri, xaus.com'dan altını çeker; `exchange_rates`'e
//! `household_id: null` ile gün başına tek kayıt yazar (aynı gün güncellenir). İki kaynak
//! birbirinden bağımsız: biri erişilemezse diğeri yine yazılır; altın için USD kuru o an
//! çekilemediyse veritabanındaki son USD kullanılır. Hata → log; son kur geçerli kalır.
//! - **Manuel:** hane kendi kurunu girer (gün başına tek; aynı gün tekrar girilirse güncellenir).
//! - **Güncel kur:** kod başına en yeni `rate_date`; aynı gün hem otomatik hem manuel varsa manuel.
//! (Eski bir manuel kur, daha yeni otomatik kurun önüne geçmez.)
//! - **Değer:** miktar × kur. TL varlığı kursuz (değeri = tutarı).
use std::{collections::HashMap, time::Duration};
use mongodb::{
Database,
bson::{DateTime, Document, Uuid, doc},
};
use crate::{
db::col,
error::{AppError, AppResult},
models::{
asset::{Asset, AssetKind},
common::{new_id, now, scope},
rate::Rate,
},
services::{
asset,
calendar::{format_date, parse_date, today_tr},
holding::Holding,
rate_sources,
},
};
/// Kaynak adresleri (testlerde sahte sunucu verilir)
#[derive(Debug, Clone)]
pub struct Sources {
pub tcmb_url: String,
pub gold_url: String,
}
impl Default for Sources {
fn default() -> Self {
Self {
tcmb_url: "https://www.tcmb.gov.tr/kurlar/today.xml".into(),
gold_url: "https://xaus.com/api/v1/spot".into(),
}
}
}
/// HTTP istemcisi: kısa zaman aşımı, genel kimlik (kişisel bilgi yok)
pub fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.user_agent("kakeibo/0.1 (ev finans defteri)")
.build()
.expect("HTTP istemcisi kurulamadı")
}
#[derive(Debug, Default)]
pub struct Report {
/// Yazılan döviz kuru sayısı ya da hata
pub fx: Option<Result<usize, String>>,
pub gold: Option<Result<usize, String>>,
}
async fn fetch_text(client: &reqwest::Client, url: &str) -> Result<String, String> {
let r = client
.get(url)
.send()
.await
.map_err(|e| format!("{url}: {e}"))?;
if !r.status().is_success() {
return Err(format!("{url}: HTTP {}", r.status()));
}
r.text().await.map_err(|e| format!("{url}: {e}"))
}
/// İki kaynağı da çeker ve yazar. Veritabanı hatası dışında hata döndürmez (rapora yazar).
pub async fn refresh(db: &Database, client: &reqwest::Client, src: &Sources) -> AppResult<Report> {
let mut report = Report::default();
let fetched_at = now();
// Döviz (TCMB)
let mut usd = None;
match fetch_text(client, &src.tcmb_url)
.await
.and_then(|x| rate_sources::parse_tcmb(&x))
{
Ok(b) => {
for (code, rate) in &b.rates {
upsert_auto(db, code, *rate, "tcmb", &b.date, fetched_at).await?;
}
usd = b.rates.iter().find(|(c, _)| c == "USD").map(|(_, r)| *r);
report.fx = Some(Ok(b.rates.len()));
}
Err(e) => {
tracing::warn!("TCMB kurları alınamadı: {e}");
report.fx = Some(Err(e));
}
}
// Altın (xaus.com × USD)
let usd = match usd {
Some(u) => Some(u),
None => latest_auto(db, "USD").await?.map(|r| r.rate_e6),
};
let gold = match usd {
None => Err("USD kuru yok; altın TL'ye çevrilemedi".to_string()),
Some(usd) => fetch_text(client, &src.gold_url)
.await
.and_then(|x| rate_sources::parse_gold(&x))
.map(|g| rate_sources::gold_rates(g, usd)),
};
match gold {
Ok(list) => {
let date = format_date(today_tr());
for (code, rate) in &list {
upsert_auto(db, code, *rate, "xaus", &date, fetched_at).await?;
}
report.gold = Some(Ok(list.len()));
}
Err(e) => {
tracing::warn!("Altın fiyatı alınamadı: {e}");
report.gold = Some(Err(e));
}
}
Ok(report)
}
/// Uygulama açılınca ve her `minutes` dakikada bir kurları yeniler (0 → kapalı).
pub fn spawn_refresher(db: Database, minutes: u64) {
if minutes == 0 {
tracing::info!("Otomatik kur yenileme kapalı (RATES_REFRESH_MINUTES=0)");
return;
}
tokio::spawn(async move {
let client = http_client();
let src = Sources::default();
let mut tick = tokio::time::interval(Duration::from_secs(minutes * 60));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tick.tick().await;
match refresh(&db, &client, &src).await {
Ok(r) => tracing::info!("Kurlar yenilendi: döviz {:?}, altın {:?}", r.fx, r.gold),
Err(e) => tracing::error!("Kurlar yazılamadı: {e}"),
}
}
});
}
async fn upsert_auto(
db: &Database,
code: &str,
rate_e6: i64,
source: &str,
rate_date: &str,
fetched_at: DateTime,
) -> AppResult<()> {
let ts = now();
db.collection::<Document>(col::EXCHANGE_RATES)
.update_one(
doc! { "household_id": null, "code": code, "source": source, "rate_date": rate_date, "deleted_at": null },
doc! {
"$set": { "rate_e6": rate_e6, "fetched_at": fetched_at, "updated_at": ts },
"$setOnInsert": { "_id": new_id(), "created_at": ts, "version": 1_i32 },
},
)
.upsert(true)
.await?;
Ok(())
}
async fn latest_auto(db: &Database, code: &str) -> AppResult<Option<Rate>> {
Ok(db
.collection::<Rate>(col::EXCHANGE_RATES)
.find_one(doc! { "household_id": null, "code": code, "deleted_at": null })
.sort(doc! { "rate_date": -1, "fetched_at": -1 })
.await?)
}
// ---------- Güncel kur ----------
/// Kod → güncel kur (hanenin manuel kurları + otomatik kurlar)
pub async fn current(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, Rate>> {
// Kod × (otomatik | bu hane) başına en yeni kayıt → en fazla 2 aday
let pipeline = vec![
doc! { "$match": {
"deleted_at": null,
"$or": [ { "household_id": null }, { "household_id": household_id } ],
} },
doc! { "$sort": { "rate_date": -1, "fetched_at": -1 } },
doc! { "$group": {
"_id": { "code": "$code", "hh": "$household_id" },
"doc": { "$first": "$$ROOT" },
} },
doc! { "$replaceRoot": { "newRoot": "$doc" } },
];
let mut cursor = db
.collection::<Document>(col::EXCHANGE_RATES)
.aggregate(pipeline)
.with_type::<Rate>()
.await?;
let mut out: HashMap<String, Rate> = HashMap::new();
while cursor.advance().await? {
let r = cursor.deserialize_current()?;
let better = match out.get(&r.code) {
None => true,
Some(cur) => {
(r.rate_date.as_str(), r.is_manual()) > (cur.rate_date.as_str(), cur.is_manual())
}
};
if better {
out.insert(r.code.clone(), r);
}
}
Ok(out)
}
/// En son otomatik çekim zamanı (kaynak sağlıklı mı?)
pub async fn last_fetch(db: &Database) -> AppResult<Option<DateTime>> {
Ok(db
.collection::<Rate>(col::EXCHANGE_RATES)
.find_one(doc! { "household_id": null, "deleted_at": null })
.sort(doc! { "fetched_at": -1 })
.await?
.map(|r| r.fetched_at))
}
// ---------- Manuel kur ----------
/// Hanenin varlıklarından birinin kodu için elle kur (aynı gün tekrar → güncellenir)
pub async fn set_manual(
db: &Database,
household_id: Uuid,
code: &str,
rate_e6: i64,
rate_date: Option<&str>,
) -> AppResult<Rate> {
let code = code.trim().to_uppercase();
let mut f = scope(household_id);
f.insert("code", &code);
let a = db
.collection::<Asset>(col::ASSETS)
.find_one(f)
.await?
.ok_or_else(|| AppError::BadRequest("Bu varlık bulunamadı".into()))?;
if a.kind == AssetKind::Cash {
return Err(AppError::BadRequest("TL için kur girilmez".into()));
}
if rate_e6 <= 0 || rate_e6 > 1_000_000_000_000_000 {
return Err(AppError::BadRequest("Kur sıfırdan büyük olmalı".into()));
}
let date = match rate_date.map(str::trim).filter(|d| !d.is_empty()) {
Some(d) => {
format_date(parse_date(d).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?)
}
None => format_date(today_tr()),
};
let ts = now();
let filter = doc! { "household_id": household_id, "code": &code, "rate_date": &date, "deleted_at": null };
db.collection::<Document>(col::EXCHANGE_RATES)
.update_one(
filter.clone(),
doc! {
"$set": { "rate_e6": rate_e6, "source": "manual", "fetched_at": ts, "updated_at": ts },
"$inc": { "version": 1 },
"$setOnInsert": { "_id": new_id(), "created_at": ts },
},
)
.upsert(true)
.await?;
db.collection::<Rate>(col::EXCHANGE_RATES)
.find_one(filter)
.await?
.ok_or_else(|| AppError::Internal("manuel kur okunamadı".into()))
}
/// Hanenin manuel kuru silinir (soft) → otomatik kura dönülür
pub async fn delete_manual(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
let ts = now();
let r = db
.collection::<Document>(col::EXCHANGE_RATES)
.update_one(
doc! { "_id": id, "household_id": household_id, "deleted_at": null },
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
if r.matched_count == 0 {
return Err(AppError::NotFound);
}
Ok(())
}
// ---------- Portföy ----------
pub struct Valued {
pub asset: Asset,
pub holding: Holding,
pub tx_count: usize,
/// TL varlığında `None` (kur gerekmez)
pub rate: Option<Rate>,
/// Güncel değer (kuruş); kur yoksa `None`
pub value: Option<i64>,
}
pub struct Portfolio {
pub items: Vec<Valued>,
pub last_fetch: Option<DateTime>,
}
/// miktar (× 1e6) × kur (TL × 1e6) → kuruş
pub fn value_of(quantity_e6: i64, rate_e6: i64) -> i64 {
let n = i128::from(quantity_e6) * i128::from(rate_e6);
((n + 5_000_000_000) / 10_000_000_000) as i64
}
pub async fn portfolio(db: &Database, household_id: Uuid) -> AppResult<Portfolio> {
let mut rates = current(db, household_id).await?;
let items = asset::list(db, household_id)
.await?
.into_iter()
.map(|(a, h, n)| {
let (rate, value) = if a.kind == AssetKind::Cash {
(None, Some(h.quantity_e6 / 10_000)) // TL: miktar = tutar
} else {
let r = rates.remove(&a.code);
let v = r.as_ref().map(|r| value_of(h.quantity_e6, r.rate_e6));
(r, v)
};
Valued {
asset: a,
holding: h,
tx_count: n,
rate,
value,
}
})
.collect();
Ok(Portfolio {
items,
last_fetch: last_fetch(db).await?,
})
}
#[cfg(test)]
mod tests {
use super::value_of;
#[test]
fn value_rounds_to_nearest_kurus() {
// 1 birim × 123,456789 TL = 12.345,6789 kuruş → 12.346
assert_eq!(value_of(1_000_000, 123_456_789), 12_346);
// 0,5 gr × 6.248,822284 TL = 3.124,411142 TL → 312.441 kuruş
assert_eq!(value_of(500_000, 6_248_822_284), 312_441);
assert_eq!(value_of(0, 6_248_822_284), 0);
}
}
+164
View File
@@ -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,
})
}
+105
View File
@@ -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)
}
+31 -1
View File
@@ -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()
+93
View File
@@ -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,
})
}
+339
View File
@@ -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;
}
+3 -1
View File
@@ -88,7 +88,9 @@ async fn summary_computes_spendable_with_carryovers() {
// 4.800.000 − 1.545.000 − 500.000 − 100.000 + 20.000
"spendable": 2_675_000,
"budget_total": 0,
"unallocated": 2_675_000
"unallocated": 2_675_000,
"spent_total": 0,
"remaining": 2_675_000
})
);
+245
View File
@@ -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;
}
+1
View File
@@ -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 }
+574
View File
@@ -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;
}
+287
View File
@@ -0,0 +1,287 @@
//! Kurlar: kaynaklardan çekme (sahte sunucuyla, internet yok), gün başına tek kayıt,
//! kaynak düşünce son kur, manuel kur önceliği, portföy değerlemesi ve hane izolasyonu.
mod common;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use axum::{Router, http::StatusCode, routing::get};
use common::{Session, TestApp};
use kakeibo::services::{
calendar::{format_date, today_tr},
rates::{self, Sources},
};
use mongodb::bson::{Document, doc};
use serde_json::{Value, json};
const TCMB: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Tarih_Date Tarih="12.11.2026" Date="11/12/2026" Bulten_No="2026/213">
<Currency CrossOrder="0" Kod="USD" CurrencyCode="USD"><Unit>1</Unit><ForexBuying>50.1691</ForexBuying><ForexSelling>50.2595</ForexSelling></Currency>
<Currency CrossOrder="9" Kod="JPY" CurrencyCode="JPY"><Unit>100</Unit><ForexBuying>32.4512</ForexBuying></Currency>
<Currency CrossOrder="19" Kod="XDR" CurrencyCode="XDR"><Unit>1</Unit><ForexBuying>68.5521</ForexBuying><ForexSelling/></Currency>
</Tarih_Date>"#;
/// Sahte kaynak sunucusu: `/tcmb`, `/gold`. Bayraklarla hata verdirilebilir.
struct Mock {
src: Sources,
tcmb_down: Arc<AtomicBool>,
gold_price: Arc<std::sync::Mutex<String>>,
}
async fn mock() -> Mock {
let tcmb_down = Arc::new(AtomicBool::new(false));
let gold_price = Arc::new(std::sync::Mutex::new(
r#"{"per_gram_usd":124.5552}"#.to_string(),
));
let (d, g) = (tcmb_down.clone(), gold_price.clone());
let app = Router::new()
.route(
"/tcmb",
get(move || {
let d = d.clone();
async move {
if d.load(Ordering::SeqCst) {
(StatusCode::SERVICE_UNAVAILABLE, String::new())
} else {
(StatusCode::OK, TCMB.to_string())
}
}
}),
)
.route(
"/gold",
get(move || {
let g = g.clone();
async move { g.lock().unwrap().clone() }
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
Mock {
src: Sources {
tcmb_url: format!("http://{addr}/tcmb"),
gold_url: format!("http://{addr}/gold"),
},
tcmb_down,
gold_price,
}
}
async fn auto_rate(app: &TestApp, code: &str) -> Document {
app.db
.collection::<Document>("exchange_rates")
.find_one(doc! { "household_id": null, "code": code })
.await
.unwrap()
.unwrap_or_else(|| panic!("{code} kuru yok"))
}
async fn count(app: &TestApp, filter: Document) -> u64 {
app.db
.collection::<Document>("exchange_rates")
.count_documents(filter)
.await
.unwrap()
}
#[tokio::test]
async fn refresh_writes_one_rate_per_day_and_survives_outages() {
let app = TestApp::new().await;
let m = mock().await;
let client = rates::http_client();
let r = rates::refresh(&app.db, &client, &m.src).await.unwrap();
assert_eq!(r.fx, Some(Ok(3)));
assert_eq!(r.gold, Some(Ok(5)));
let usd = auto_rate(&app, "USD").await;
assert_eq!(usd.get_i64("rate_e6").unwrap(), 50_169_100, "alış kuru");
assert_eq!(
usd.get_str("rate_date").unwrap(),
"2026-11-12",
"bülten tarihi"
);
assert_eq!(usd.get_str("source").unwrap(), "tcmb");
assert_eq!(
auto_rate(&app, "JPY").await.get_i64("rate_e6").unwrap(),
324_512,
"1 yen"
);
let gram = auto_rate(&app, "XAU_GRAM").await;
assert_eq!(gram.get_i64("rate_e6").unwrap(), 6_248_822_284);
assert_eq!(gram.get_str("rate_date").unwrap(), format_date(today_tr()));
assert_eq!(gram.get_str("source").unwrap(), "xaus");
assert_eq!(count(&app, doc! {}).await, 8);
// Aynı gün yeniden: kayıt sayısı değişmez
rates::refresh(&app.db, &client, &m.src).await.unwrap();
assert_eq!(count(&app, doc! {}).await, 8);
// TCMB düştü, altın değişti → altın veritabanındaki son USD ile hesaplanır
m.tcmb_down.store(true, Ordering::SeqCst);
*m.gold_price.lock().unwrap() = r#"{"per_gram_usd":130.0}"#.into();
let r = rates::refresh(&app.db, &client, &m.src).await.unwrap();
assert!(matches!(r.fx, Some(Err(_))), "{:?}", r.fx);
assert_eq!(r.gold, Some(Ok(5)));
assert_eq!(
auto_rate(&app, "XAU_GRAM")
.await
.get_i64("rate_e6")
.unwrap(),
6_521_983_000
);
assert_eq!(
auto_rate(&app, "USD").await.get_i64("rate_e6").unwrap(),
50_169_100,
"son kur kalır"
);
// Bozuk altın yanıtı: hata raporlanır, eski kur kalır
*m.gold_price.lock().unwrap() = "<html>bakım</html>".into();
let r = rates::refresh(&app.db, &client, &m.src).await.unwrap();
assert!(matches!(r.gold, Some(Err(_))));
assert_eq!(
auto_rate(&app, "XAU_GRAM")
.await
.get_i64("rate_e6")
.unwrap(),
6_521_983_000
);
app.cleanup().await;
}
async fn asset_id(app: &TestApp, s: &Session, code: &str) -> String {
let (_, list) = app.get("/assets", s).await;
list.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == code)
.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn buy(app: &TestApp, s: &Session, code: &str, quantity_e6: i64, total: i64) {
let id = asset_id(app, s, code).await;
let (st, b) = app
.post(
&format!("/assets/{id}/transactions"),
s,
json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": quantity_e6, "total": total }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
}
fn item<'a>(p: &'a Value, code: &str) -> &'a Value {
p["items"]
.as_array()
.unwrap()
.iter()
.find(|i| i["asset"]["code"] == code)
.unwrap()
}
#[tokio::test]
async fn portfolio_values_and_manual_rates() {
let app = TestApp::new().await;
let m = mock().await;
rates::refresh(&app.db, &rates::http_client(), &m.src)
.await
.unwrap();
let s = app.register("Test", None).await;
buy(&app, &s, "XAU_GRAM", 10_000_000, 6_000_000).await; // 10 gr, 60.000 TL
buy(&app, &s, "USD", 100_000_000, 450_000).await; // 100 $, 4.500 TL
buy(&app, &s, "TRY", 0, 100_000).await; // 1.000 TL
buy(&app, &s, "EUR", 10_000_000, 50_000).await; // kuru yok
let (st, p) = app.get("/portfolio", &s).await;
assert_eq!(st, StatusCode::OK, "{p}");
let g = item(&p, "XAU_GRAM");
assert_eq!(g["value"], 6_248_822, "10 × 6.248,82 TL");
assert_eq!(g["unrealized_pl"], 248_822);
assert_eq!(g["rate"]["source"], "xaus");
assert_eq!(item(&p, "USD")["value"], 501_691);
assert_eq!(item(&p, "TRY")["value"], 100_000);
assert_eq!(item(&p, "TRY")["rate"], Value::Null);
assert_eq!(item(&p, "EUR")["value"], Value::Null);
assert_eq!(p["cost_total"], 6_600_000);
assert_eq!(p["value_total"], 6_850_513);
assert_eq!(p["unrealized_pl_total"], 300_513);
assert_eq!(p["unpriced"], 1, "EUR elde ama kuru yok");
assert!(p["last_fetch"].is_string());
// Manuel, otomatikle aynı gün → manuel geçerli
let (st, man) = app
.post(
"/rates",
&s,
json!({ "code": "xau_gram", "rate_e6": 6_500_000_000_i64 }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{man}");
assert_eq!(man["source"], "manual");
let (_, p) = app.get("/portfolio", &s).await;
assert_eq!(item(&p, "XAU_GRAM")["value"], 6_500_000);
assert_eq!(item(&p, "XAU_GRAM")["rate"]["is_manual"], true);
// Eski tarihli manuel, daha yeni otomatiğin önüne geçmez
app.post(
"/rates",
&s,
json!({ "code": "USD", "rate_e6": 60_000_000, "rate_date": "2026-11-01" }),
)
.await;
let (_, p) = app.get("/portfolio", &s).await;
assert_eq!(item(&p, "USD")["value"], 501_691);
// Daha yeni manuel geçerli; aynı gün tekrar → güncellenir (çift kayıt yok)
app.post(
"/rates",
&s,
json!({ "code": "USD", "rate_e6": 60_000_000, "rate_date": "2026-12-31" }),
)
.await;
app.post(
"/rates",
&s,
json!({ "code": "USD", "rate_e6": 61_000_000, "rate_date": "2026-12-31" }),
)
.await;
let (_, p) = app.get("/portfolio", &s).await;
assert_eq!(item(&p, "USD")["value"], 610_000);
assert_eq!(
count(&app, doc! { "code": "USD", "rate_date": "2026-12-31" }).await,
1
);
// Başka hane: manuel kurları görmez, silemez
let other = app.register("Diger", None).await;
let (_, op) = app.get("/portfolio", &other).await;
assert_eq!(item(&op, "USD")["rate"]["rate_e6"], 50_169_100);
assert_eq!(item(&op, "XAU_GRAM")["rate"]["source"], "xaus");
let man_id = man["id"].as_str().unwrap();
let (st, _) = app.delete(&format!("/rates/{man_id}"), &other).await;
assert_eq!(st, StatusCode::NOT_FOUND);
// Manuel silinince otomatiğe dönülür
let (st, _) = app.delete(&format!("/rates/{man_id}"), &s).await;
assert_eq!(st, StatusCode::NO_CONTENT);
let (_, p) = app.get("/portfolio", &s).await;
assert_eq!(item(&p, "XAU_GRAM")["value"], 6_248_822);
// Hatalı girişler
for body in [
json!({ "code": "TRY", "rate_e6": 1_000_000 }),
json!({ "code": "XYZ", "rate_e6": 1_000_000 }),
json!({ "code": "USD", "rate_e6": 0 }),
json!({ "code": "USD", "rate_e6": 1, "rate_date": "2026-02-30" }),
] {
let (st, b) = app.post("/rates", &s, body.clone()).await;
assert_eq!(st, StatusCode::BAD_REQUEST, "{body} → {b}");
}
app.cleanup().await;
}
+191
View File
@@ -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;
}
+241
View File
@@ -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;
}
+223
View File
@@ -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;
}
+183
View File
@@ -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
+12
View File
@@ -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
+13
View File
@@ -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
+12
View File
@@ -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
View File
@@ -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.
+77 -2
View File
@@ -1,6 +1,6 @@
# 🚀 Canlıya Alma (Atlas Homeserver + Dokploy)
> Durum: ✅ Yayında — https://kakeibo.ta1sph.website (2026-11-12) · Yedekleme ⏳ (Faz 5 bitmeden)
> 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 |
@@ -95,4 +96,78 @@ Notlar:
## 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.
+47 -3
View File
@@ -1,9 +1,17 @@
# 🧠 Proje Hafızası
## ▶️ Sıradaki Adım
**Faz 4 – Adım 4:** Frontend ilk dönem sihirbazı: açık dönem yoksa (Panel/Plan) → adımlar: 1) tarihler (varsayılan: içinde bulunulan ay, ya da önceki dönemin bitişi), 2) sabit gelirler, 3) sabit giderler (+ önceki dönemden kopyala varsa), 4) birikim hedefi, 5) bütçe dağıtımı (öneriyle) + özet. `lib/format.ts` parseMoney ile tutar girişi (inputMode=decimal). TanStack Query anahtarları: period/current, plan-items, summary, budget.
**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).
@@ -47,10 +55,10 @@
## ❓ 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 yok** (Faz 3 Adım 5 ertelendi) → gerçek veri girişinden önce, en geç Faz 5 sonunda: günlük mongodump, 14 yedek, geri yükleme testi.
- **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.
@@ -91,3 +99,39 @@
- **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.
+43 -10
View File
@@ -8,13 +8,13 @@
| **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 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ı
@@ -49,6 +49,39 @@
- ✅ 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)
- ✅ 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
+1855 -3
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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",
+43
View File
@@ -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 }
+67
View File
@@ -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>
)
}
+22
View File
@@ -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>
)
}
+43
View File
@@ -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>
)
}
+31 -1
View File
@@ -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 (
+34
View File
@@ -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>
)
}
+6
View File
@@ -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)} />
}
+18
View File
@@ -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 }
+264
View File
@@ -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>
)
}
+77 -3
View File
@@ -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>
</>
)}
</>
)
}
+346
View File
@@ -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>
)
}
+152
View File
@@ -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>
)
}
+145 -3
View File
@@ -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')
})
})
+38
View File
@@ -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)
})
})
+17
View File
@@ -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>
)
}
@@ -0,0 +1,124 @@
import type { ReactNode } from 'react'
import { ArrowLeft, ArrowRight, Check, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
const s = t.wizard
/** Adım göstergesi: mobilde "Adım 2/5 · Gelirler" + çubuk, geniş ekranda numaralı liste. */
export function Stepper({
current,
steps = s.steps,
label = s.title,
}: {
current: number
steps?: readonly string[]
label?: string
}) {
const total = steps.length
return (
<nav aria-label={label} className="mb-6">
<p className="mb-2 text-sm font-medium sm:hidden">
{s.stepOf(current, total)} · {steps[current - 1]}
</p>
<div className="h-1.5 overflow-hidden rounded-full bg-muted sm:hidden" aria-hidden>
<div
className="h-full rounded-full bg-primary transition-[width]"
style={{ width: `${(current / total) * 100}%` }}
/>
</div>
<ol className="hidden gap-2 sm:flex">
{steps.map((name, i) => {
const n = i + 1
const done = n < current
const active = n === current
return (
<li
key={name}
aria-current={active ? 'step' : undefined}
className={cn(
'flex flex-1 items-center gap-2 rounded-lg border px-3 py-2 text-sm',
active && 'border-primary bg-primary/5 font-medium',
!active && !done && 'text-muted-foreground',
)}
>
<span
className={cn(
'grid size-6 shrink-0 place-items-center rounded-full text-xs',
done || active ? 'bg-primary text-primary-foreground' : 'bg-muted',
)}
>
{done ? <Check className="size-3.5" aria-hidden /> : n}
</span>
{name}
</li>
)
})}
</ol>
</nav>
)
}
/** Adım başlığı + açıklama */
export function StepIntro({ title, hint }: { title: string; hint: string }) {
return (
<div className="mb-5 space-y-1">
<h2 className="text-xl font-semibold">{title}</h2>
<p className="text-muted-foreground">{hint}</p>
</div>
)
}
/**
* Alt düğmeler. `onNext` verilmezse İleri düğmesi `type="submit"` olur (formu gönderir).
* Mobilde düğmeler tam genişlik, İleri sağda (başparmak erişimi).
*/
export function StepActions({
onBack,
onNext,
pending,
nextLabel = s.next,
form,
extra,
}: {
onBack?: () => void
onNext?: () => void
pending?: boolean
nextLabel?: string
/** Düğme form dışındaysa formun `id`'si */
form?: string
extra?: ReactNode
}) {
return (
<div className="mt-8 flex items-center gap-3 border-t pt-5">
{onBack && (
<Button type="button" variant="outline" size="lg" onClick={onBack} disabled={pending}>
<ArrowLeft aria-hidden />
{s.back}
</Button>
)}
{extra}
<Button
type={onNext ? 'button' : 'submit'}
form={form}
size="lg"
className="ml-auto min-w-32 flex-1 sm:flex-none"
onClick={onNext}
disabled={pending}
>
{pending ? (
<>
<Loader2 className="animate-spin" aria-hidden />
{s.saving}
</>
) : (
<>
{nextLabel}
<ArrowRight aria-hidden />
</>
)}
</Button>
</div>
)
}
@@ -0,0 +1,38 @@
import { useSearchParams } from 'react-router'
import { PERIOD_PARAM, useSelectedPeriod } from '@/lib/period-select'
import { t } from '@/lib/strings'
const s = t.periods
/**
* Sayfa başlığında dönem seçimi (birden fazla dönem varsa). Seçim adrese yazılır
* (`?donem=`); açık dönem varsayılan olduğundan onda parametre kalkar. Hafta vb. sıfırlanır.
*/
export function PeriodPicker() {
const { period, periods } = useSelectedPeriod()
const [, setParams] = useSearchParams()
if (periods.length < 2) return null
return (
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{s.picker}</span>
<select
name="period"
value={period?.id ?? ''}
onChange={(e) => {
const p = periods.find((x) => x.id === e.target.value)
if (p) setParams(p.status === 'open' ? {} : { [PERIOD_PARAM]: p.id })
}}
className="h-11 max-w-72 rounded-lg border bg-background px-3 text-sm"
>
{!period && <option value="">—</option>}
{periods.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.status !== 'open' ? ` · ${s.status[p.status]}` : ''}
</option>
))}
</select>
</label>
)
}
@@ -0,0 +1,30 @@
import type { ReactNode } from 'react'
import { PageError, PageLoading } from '@/components/page'
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
import { useSelectedPeriod } from '@/lib/period-select'
import { t } from '@/lib/strings'
import type { Period } from '@/types/Period'
/**
* Seçili dönemi (`?donem=` ya da açık dönem) yükler; yükleniyor / hata / bulunamadı /
* dönem yok durumlarını tek yerde gösterir. Dönem varsa `children(dönem, tüm dönemler)`.
*/
export function SelectedPeriod({
empty = <NoPeriodCard />,
children,
}: {
empty?: ReactNode
children: (period: Period, periods: Period[]) => ReactNode
}) {
const sel = useSelectedPeriod()
if (sel.error) return <PageError message={sel.error.message} onRetry={sel.refetch} />
if (sel.isPending) return <PageLoading />
if (sel.period === undefined)
return (
<p role="alert" className="rounded-xl bg-muted p-4 text-muted-foreground">
{t.periods.notFound}
</p>
)
if (sel.period === null) return empty
return children(sel.period, sel.periods)
}
@@ -0,0 +1,48 @@
import { formatMoney } from '@/lib/format'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
const s = t.wizard.budget
/** Dağıtılan / harcanabilir çubuğu + "dağıtılmadı / aşıldı / tamamı dağıtıldı" durumu. */
export function DistributionBar({
distributed,
spendable,
}: {
distributed: number
spendable: number
}) {
const remaining = spendable - distributed
const pct = spendable > 0 ? Math.min(100, (distributed / spendable) * 100) : 0
return (
<div className="space-y-2 rounded-xl border p-4" aria-live="polite">
<div className="flex flex-wrap justify-between gap-x-3 text-sm">
<span>{s.distributed}</span>
<span className="money font-medium">
{formatMoney(distributed)} / {formatMoney(spendable)}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden>
<div
className={cn('h-full rounded-full', remaining < 0 ? 'bg-warning' : 'bg-primary')}
style={{ width: `${pct}%` }}
/>
</div>
{/* Hiç dağıtılacak ve dağıtılmış tutar yoksa "tamamı dağıtıldı" yanıltıcı olur */}
{(distributed !== 0 || spendable !== 0) && (
<p
className={cn(
'text-sm',
remaining < 0 ? 'font-medium text-warning' : 'text-muted-foreground',
)}
>
{remaining < 0
? s.over(formatMoney(-remaining))
: remaining > 0
? s.unallocated(formatMoney(remaining))
: s.balanced}
</p>
)}
</div>
)
}
+72 -3
View File
@@ -1,11 +1,80 @@
import { ComingSoon, PageHeader } from '@/components/page'
import { useState } from 'react'
import { Pencil } from 'lucide-react'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { NotesSection } from '@/features/notes/notes-section'
import { CopyPrevious } from '@/features/period-wizard/step-items'
import { PeriodPicker } from '@/features/periods/period-picker'
import { SelectedPeriod } from '@/features/periods/selected-period'
import { isWritable } from '@/lib/period-select'
import { useBudget, usePlanItems, useSummary } from '@/lib/periods'
import { t } from '@/lib/strings'
import type { Period } from '@/types/Period'
import { PlanEdit } from './plan-edit'
import { BudgetRead, ItemsList, PeriodHeading } from './plan-read'
import { SummaryTiles } from './summary-tiles'
export function PlanningPage() {
return (
<>
<PageHeader title={t.pages.planning.title} description={t.pages.planning.description} />
<ComingSoon when="Faz 4'te" />
<PageHeader
title={t.pages.planning.title}
description={t.pages.planning.description}
actions={<PeriodPicker />}
/>
<SelectedPeriod>{(p) => <PlanView key={p.id} period={p} />}</SelectedPeriod>
</>
)
}
/** Varsayılan salt okunur; "Düzenle" → düzenleme modu (Kaydet / Vazgeç). */
function PlanView({ period }: { period: Period }) {
const items = usePlanItems(period.id)
const summary = useSummary(period.id)
const budget = useBudget(period.id)
const [editing, setEditing] = useState(false)
const failed = [items, summary, budget].find((q) => q.isError)
if (failed?.error) return <PageError message={failed.error.message} onRetry={failed.refetch} />
if (!items.data || !summary.data || !budget.data) return <PageLoading />
if (editing) {
return (
<PlanEdit
period={period}
items={items.data}
budget={budget.data}
summary={summary.data}
onClose={() => {
setEditing(false)
window.scrollTo({ top: 0 })
}}
/>
)
}
const editable = isWritable(period) // açık ya da düzeltmede
return (
<div className="space-y-6">
<PeriodHeading
period={period}
actions={
editable && (
<Button onClick={() => setEditing(true)}>
<Pencil aria-hidden />
{t.plan.edit}
</Button>
)
}
/>
<SummaryTiles summary={summary.data} />
<div className="grid gap-4 lg:grid-cols-2">
<ItemsList kind="income" list={items.data.filter((i) => i.kind === 'income')} />
<ItemsList kind="expense" list={items.data.filter((i) => i.kind === 'expense')} />
</div>
{period.status === 'open' && <CopyPrevious period={period} />}
<BudgetRead budget={budget.data} summary={summary.data} />
<NotesSection periodId={period.id} context="planning" readOnly={!editable} />
</div>
)
}
@@ -0,0 +1,476 @@
import { useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { CalendarCog, Loader2, Plus, RotateCcw, Save, Trash2, X } from 'lucide-react'
import {
Controller,
useFieldArray,
useForm,
useWatch,
type Control,
type FieldErrors,
type UseFormRegister,
type UseFormSetValue,
} from 'react-hook-form'
import { Link, useBlocker } from 'react-router'
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
import { paths } from '@/components/layout/nav-items'
import { MoneyInput } from '@/components/money-input'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { previewWeeks } from '@/lib/budget-preview'
import { formatDayMonth, formatMoney, formatNumber, parseMoney } from '@/lib/format'
import { group, GROUPS } from '@/lib/groups'
import { useSavePlan } from '@/lib/periods'
import { diffPlan, GROUP_CODES, hasChanges, moneyToText, toDraft } from '@/lib/plan-draft'
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'
import type { PlanItem } from '@/types/PlanItem'
import type { PlanItemKind } from '@/types/PlanItemKind'
import { DistributionBar } from './distribution-bar'
import { PeriodHeading, Section } from './plan-read'
import { planSchema, type PlanForm } from './schema'
import { SuggestionPicker } from './suggestion-picker'
import { SummaryTiles } from './summary-tiles'
const s = t.plan
const itemText = t.wizard.items
/** Metin → kuruş (geçersiz / boş / eksi → 0): önizleme hesapları için */
const k = (text: string | undefined) => Math.max(parseMoney(text ?? '') ?? 0, 0)
/**
* Düzenleme modu. Hiçbir şey anında kaydedilmez: "Kaydet" yalnızca değişenleri gönderir,
* "Vazgeç" taslağı atar. Kaydedilmemiş değişiklik varken sayfadan çıkılırsa onay istenir.
*/
export function PlanEdit({
period,
items,
budget,
summary,
onClose,
}: {
period: Period
items: PlanItem[]
budget: GroupBudget[]
summary: PeriodSummary
onClose: () => void
}) {
const save = useSavePlan(period)
const {
control,
register,
handleSubmit,
setValue,
formState: { errors, isDirty, isSubmitting },
} = useForm<PlanForm>({
resolver: zodResolver(planSchema(period.start_date, period.last_date)),
mode: 'onTouched',
defaultValues: toDraft(period, items, budget),
})
const v = useWatch({ control })
// ---- Canlı önizleme: kaydedilmiş özet + taslaktaki farklar (formül backend'de kalır)
const incomeTotal = (v.incomes ?? []).reduce((a, r) => a + k(r?.amount), 0)
const fixedTotal = (v.expenses ?? []).reduce((a, r) => a + k(r?.amount), 0)
const savings = k(v.savings)
const spendable =
summary.spendable +
(incomeTotal - summary.income_total) -
(fixedTotal - summary.fixed_expense_total) -
(savings - summary.savings_goal)
const budgetTotal = GROUP_CODES.reduce((a, g) => a + k(v.budget?.[g]), 0)
const preview: PeriodSummary = {
...summary,
income_total: incomeTotal,
fixed_expense_total: fixedTotal,
savings_goal: savings,
spendable,
budget_total: budgetTotal,
unallocated: spendable - budgetTotal,
}
// ---- Kaydedilmemiş değişiklik koruması
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
isDirty && !isSubmitting && currentLocation.pathname !== nextLocation.pathname,
)
useEffect(() => {
if (blocker.state !== 'blocked') return
if (window.confirm(s.leaveConfirm)) blocker.proceed()
else blocker.reset()
}, [blocker])
useEffect(() => {
if (!isDirty) return
const warn = (e: BeforeUnloadEvent) => e.preventDefault()
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [isDirty])
const onSubmit = handleSubmit(async (draft) => {
const ops = diffPlan(period, items, budget, draft)
if (!hasChanges(ops)) {
toast(s.noChanges)
return onClose()
}
try {
await save.mutateAsync(ops)
toast.success(s.saved)
} catch (e) {
toast.error(s.saveFailed(e instanceof Error ? e.message : String(e)))
}
onClose()
})
const actions = (
<>
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
<X aria-hidden />
{s.cancel}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="animate-spin" aria-hidden /> : <Save aria-hidden />}
{isSubmitting ? s.saving : s.save}
</Button>
</>
)
return (
<form onSubmit={onSubmit} noValidate className="space-y-6">
<div className="rounded-xl border border-primary/40 bg-primary/5 px-4 py-3 text-sm">
<span className="font-medium">✏️ {s.editing}</span>
<span className="text-muted-foreground"> · {s.editingHint}</span>
</div>
<PeriodHeading period={period} actions={actions} />
<SummaryTiles summary={preview} />
<div className="grid gap-4">
<ItemsEdit
kind="income"
period={period}
control={control}
register={register}
errors={errors}
/>
<ItemsEdit
kind="expense"
period={period}
control={control}
register={register}
errors={errors}
/>
</div>
<Section
title={s.savings}
action={
<Button asChild variant="ghost" size="sm">
<Link to={`${paths.periodWizard}?adim=1`}>
<CalendarCog aria-hidden />
{s.changeDates}
</Link>
</Button>
}
>
<FormField label={s.savings} error={errors.savings} className="max-w-xs">
{(f) => (
<Controller
control={control}
name="savings"
render={({ field }) => <MoneyInput {...f} {...field} placeholder="0,00" />}
/>
)}
</FormField>
</Section>
<BudgetEdit
period={period}
control={control}
setValue={setValue}
errors={errors}
values={v}
spendable={spendable}
budgetTotal={budgetTotal}
/>
<div className="flex justify-end gap-2 border-t pt-5">{actions}</div>
</form>
)
}
// ---------- Gelir / gider satırları ----------
function ItemsEdit({
kind,
period,
control,
register,
errors,
}: {
kind: PlanItemKind
period: Period
control: Control<PlanForm>
register: UseFormRegister<PlanForm>
errors: FieldErrors<PlanForm>
}) {
const name = kind === 'income' ? 'incomes' : 'expenses'
// keyName: kalemlerin kendi `id` alanı var, RHF'nin iç anahtarı onu ezmesin
const { fields, append, remove } = useFieldArray({ control, name, keyName: 'key' })
const rows = useWatch({ control, name })
const total = (rows ?? []).reduce((a, r) => a + k(r?.amount), 0)
const text = itemText[kind]
return (
<Section title={text.title}>
{fields.length === 0 && <p className="mb-3 text-sm text-muted-foreground">{text.empty}</p>}
<ul className="space-y-3">
{fields.map((field, i) => {
const e = errors[name]?.[i]
const messages = [e?.name?.message, e?.amount?.message, e?.date?.message].filter(Boolean)
const label = rows?.[i]?.name || text.placeholder
return (
<li
key={field.key}
className="grid grid-cols-2 gap-2 sm:grid-cols-[minmax(0,1fr)_10rem_10.5rem_auto]"
>
{/* Mobilde ad + sil aynı satırda; geniş ekranda `contents` → ızgaraya katılır */}
<div className="col-span-2 flex gap-2 sm:contents">
<Input
className="flex-1"
placeholder={text.placeholder}
aria-label={itemText.name}
aria-invalid={!!e?.name}
maxLength={60}
{...register(`${name}.${i}.name`)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="sm:order-last"
aria-label={itemText.remove(label)}
onClick={() => remove(i)}
>
<Trash2 className="text-muted-foreground" aria-hidden />
</Button>
</div>
<Controller
control={control}
name={`${name}.${i}.amount`}
render={({ field: f }) => (
<MoneyInput
{...f}
aria-label={itemText.amount}
aria-invalid={!!e?.amount}
placeholder="0,00"
/>
)}
/>
<Input
type="date"
min={period.start_date}
max={period.last_date}
aria-label={itemText.date}
aria-invalid={!!e?.date}
{...register(`${name}.${i}.date`)}
/>
{messages.length > 0 && (
<p className="col-span-2 text-sm text-expense sm:order-last sm:col-span-4">
{messages.join(' · ')}
</p>
)}
</li>
)
})}
</ul>
<Button
type="button"
variant="secondary"
className="mt-3"
onClick={() => append({ name: '', amount: '', date: '' }, { shouldFocus: true })}
>
<Plus aria-hidden />
{s.add[kind]}
</Button>
<div className="mt-3 flex justify-between border-t pt-3 font-medium">
<span>{text.total}</span>
<span className={cn('money', kind === 'income' ? 'text-income' : 'text-expense')}>
{formatMoney(total)}
</span>
</div>
</Section>
)
}
// ---------- Bütçe: aylık + haftalık ----------
function BudgetEdit({
period,
control,
setValue,
errors,
values,
spendable,
budgetTotal,
}: {
period: Period
control: Control<PlanForm>
setValue: UseFormSetValue<PlanForm>
errors: FieldErrors<PlanForm>
values: ReturnType<typeof useWatch<PlanForm>>
spendable: number
budgetTotal: number
}) {
return (
<Section title={s.budget}>
<div className="space-y-4">
<SuggestionPicker
periodId={period.id}
hint={s.suggestionHint}
onApply={(a) =>
GROUP_CODES.forEach((g) =>
setValue(`budget.${g}`, moneyToText(a[g]), {
shouldDirty: true,
shouldValidate: true,
}),
)
}
/>
<DistributionBar distributed={budgetTotal} spendable={spendable} />
<div className="grid items-start gap-4 md:grid-cols-2">
{GROUPS.map((g) => {
const weekTexts = values.weeks?.[g.code] ?? []
const pv = previewWeeks(
k(values.budget?.[g.code]),
period.weeks.map((w, i) => {
const txt = weekTexts[i] ?? ''
return { days: w.days, manual: txt.trim() === '' ? null : k(txt) }
}),
)
return (
<div key={g.code} className="overflow-hidden rounded-xl border">
<div className={cn('space-y-2 px-4 py-3', g.className.soft)}>
<FormField label={`${g.emoji} ${g.label}`} error={errors.budget?.[g.code]}>
{(f) => (
<Controller
control={control}
name={`budget.${g.code}`}
render={({ field }) => (
<MoneyInput
{...f}
{...field}
placeholder="0,00"
className="bg-background"
/>
)}
/>
)}
</FormField>
</div>
<details open={pv.weeks.some((w) => w.isManual)} className="group/weeks">
<summary className="cursor-pointer px-4 py-3 text-sm font-medium select-none">
{s.weekly}
</summary>
<p className="px-4 pb-2 text-xs text-muted-foreground">{s.weekHint}</p>
<ul className="divide-y text-sm">
{period.weeks.map((w, i) => (
<WeekRow
key={w.start_date}
control={control}
setValue={setValue}
groupCode={g.code}
index={i}
label={s.week(w.index)}
range={`${formatDayMonth(w.start_date)}–${formatDayMonth(w.last_date)}`}
days={w.days}
auto={pv.weeks[i]?.amount ?? 0}
manual={pv.weeks[i]?.isManual ?? false}
error={errors.weeks?.[g.code]?.[i]?.message}
/>
))}
</ul>
</details>
{pv.weeklyOver && (
<p role="status" className="border-t px-4 py-2 text-sm text-warning">
{s.weeklyOver}
</p>
)}
</div>
)
})}
</div>
</div>
</Section>
)
}
function WeekRow({
control,
setValue,
groupCode,
index,
label,
range,
days,
auto,
manual,
error,
}: {
control: Control<PlanForm>
setValue: UseFormSetValue<PlanForm>
groupCode: (typeof GROUP_CODES)[number]
index: number
label: string
range: string
days: number
auto: number
manual: boolean
error?: string
}) {
const fieldName = `weeks.${groupCode}.${index}` as const
return (
<li className="flex flex-wrap items-center gap-2 px-4 py-2">
<span className="min-w-0 flex-1">
<span className="block">{label}</span>
<span className="text-xs text-muted-foreground">
{range} · {s.weekDays(days)}
{manual && ` · ${s.manual}`}
</span>
</span>
<div className="w-36">
<Controller
control={control}
name={fieldName}
render={({ field }) => (
<MoneyInput
{...field}
aria-label={`${group(groupCode).label} · ${label} (${range})`}
aria-invalid={!!error}
// Boşsa otomatik tutar silik görünür; yazınca elle girilmiş olur
placeholder={formatNumber(auto / 100)}
/>
)}
/>
</div>
{manual ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={s.resetWeek(index + 1)}
onClick={() => setValue(fieldName, '', { shouldDirty: true, shouldValidate: true })}
>
<RotateCcw className="text-muted-foreground" aria-hidden />
</Button>
) : (
<span className="size-11" aria-hidden />
)}
{error && <p className="w-full text-sm text-expense">{error}</p>}
</li>
)
}
@@ -0,0 +1,151 @@
import type { ReactNode } from 'react'
import { formatDate, formatDayMonth, formatMoney } from '@/lib/format'
import { group, GROUPS } from '@/lib/groups'
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'
import type { PlanItem } from '@/types/PlanItem'
import type { PlanItemKind } from '@/types/PlanItemKind'
import { DistributionBar } from './distribution-bar'
const s = t.plan
const items = t.wizard.items
/** Dönem adı, tarihleri + sağda eylemler (Düzenle / Kaydet…) */
export function PeriodHeading({ period, actions }: { period: Period; actions?: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="flex flex-wrap items-center gap-2 text-lg font-semibold">
{period.name}
{period.status !== 'open' && (
<span
className={cn(
'rounded-full px-2.5 py-0.5 text-xs font-medium',
period.status === 'closed' ? 'bg-muted text-muted-foreground' : 'bg-warning/15',
)}
>
{t.periods.status[period.status]}
</span>
)}
</h2>
<p className="text-sm text-muted-foreground">
{formatDate(period.start_date)} – {formatDate(period.last_date)} ·{' '}
{s.periodInfo(period.days, period.weeks.length)}
</p>
</div>
{actions && <div className="flex flex-wrap gap-2">{actions}</div>}
</div>
)
}
/** Bölüm kutusu: başlık + (isteğe bağlı) sağ üst eylem */
export function Section({
title,
action,
children,
className,
}: {
title: string
action?: ReactNode
children: ReactNode
className?: string
}) {
return (
<section className={cn('rounded-2xl border bg-card p-4 sm:p-5', className)}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold">{title}</h3>
{action}
</div>
{children}
</section>
)
}
/** Salt okunur gelir / gider listesi */
export function ItemsList({ kind, list }: { kind: PlanItemKind; list: PlanItem[] }) {
const text = items[kind]
const total = list.reduce((a, i) => a + i.amount, 0)
return (
<Section title={text.title}>
{list.length === 0 ? (
<p className="text-sm text-muted-foreground">{text.empty}</p>
) : (
<ul className="divide-y">
{list.map((i) => (
<li key={i.id} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate">{i.name}</p>
{i.date && <p className="text-xs text-muted-foreground">{formatDate(i.date)}</p>}
</div>
<span className="money">{formatMoney(i.amount)}</span>
</li>
))}
</ul>
)}
<div className="mt-2 flex justify-between border-t pt-3 font-medium">
<span>{text.total}</span>
<span className={cn('money', kind === 'income' ? 'text-income' : 'text-expense')}>
{formatMoney(total)}
</span>
</div>
</Section>
)
}
/** Salt okunur bütçe: dağıtım çubuğu + 4 grup (aylık + haftalık döküm) */
export function BudgetRead({ budget, summary }: { budget: GroupBudget[]; summary: PeriodSummary }) {
return (
<Section title={s.budget}>
<div className="space-y-4">
<DistributionBar distributed={summary.budget_total} spendable={summary.spendable} />
{summary.budget_total === 0 && (
<p className="text-sm text-muted-foreground">{s.emptyBudget}</p>
)}
<div className="grid gap-4 md:grid-cols-2">
{GROUPS.map((g) => {
const b = budget.find((x) => x.group === g.code)
return b ? <GroupBudgetCard key={g.code} budget={b} /> : null
})}
</div>
</div>
</Section>
)
}
function GroupBudgetCard({ budget }: { budget: GroupBudget }) {
const g = group(budget.group)
return (
<div className="overflow-hidden rounded-xl border">
<div className={cn('flex items-center justify-between gap-2 px-4 py-3', g.className.soft)}>
<span className={cn('font-medium', g.className.text)}>
<span aria-hidden>{g.emoji} </span>
{g.label}
</span>
<span className="money font-semibold">{formatMoney(budget.monthly)}</span>
</div>
<ul className="divide-y text-sm" aria-label={`${g.label} · ${s.weekly}`}>
{budget.weeks.map((w) => (
<li key={w.start_date} className="flex items-center gap-2 px-4 py-2">
<span className="flex-1 text-muted-foreground">
{s.week(w.index)} · {formatDayMonth(w.start_date)}–{formatDayMonth(w.last_date)}
</span>
{w.is_manual && (
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{s.manual}
</span>
)}
<span className="money">{formatMoney(w.amount)}</span>
</li>
))}
</ul>
{budget.weekly_over && (
<p role="status" className="border-t px-4 py-2 text-sm text-warning">
{s.weeklyOver}
</p>
)}
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod'
import { itemSchema } from '@/features/period-wizard/schemas'
import { moneyText } from '@/lib/money-schema'
/** Plan ekranı düzenleme formu (`lib/plan-draft.ts` → `PlanDraft` ile aynı biçim). */
export function planSchema(start: string, last: string) {
const item = itemSchema(start, last).extend({
id: z.string().optional(),
version: z.number().optional(),
})
const money = moneyText('optional')
return z.object({
incomes: z.array(item),
expenses: z.array(item),
savings: money,
budget: z.object({ needs: money, wants: money, culture: money, unexpected: money }),
weeks: z.object({
needs: z.array(money),
wants: z.array(money),
culture: z.array(money),
unexpected: z.array(money),
}),
})
}
export type PlanForm = z.infer<ReturnType<typeof planSchema>>
@@ -0,0 +1,60 @@
import { useState } from 'react'
import { Loader2, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useSuggestion } from '@/lib/periods'
import { t } from '@/lib/strings'
import type { GroupAmounts } from '@/types/GroupAmounts'
import type { SuggestMethod } from '@/types/SuggestMethod'
const s = t.wizard.budget
const METHODS: SuggestMethod[] = ['default', 'previous', 'avg3']
/** Bütçe önerisi: yöntem seç → "Öneriyi uygula" (veri yoksa nedeni yazılır). */
export function SuggestionPicker({
periodId,
onApply,
hint,
}: {
periodId: string
onApply: (amounts: GroupAmounts) => void
hint?: string
}) {
const [method, setMethod] = useState<SuggestMethod>('default')
const suggestion = useSuggestion(periodId, method)
return (
<fieldset className="space-y-3 rounded-xl border p-4">
<legend className="px-1 text-sm font-medium">{s.method}</legend>
<div className="flex flex-wrap gap-2">
{METHODS.map((m) => (
<Button
key={m}
type="button"
size="sm"
variant={method === m ? 'default' : 'outline'}
aria-pressed={method === m}
onClick={() => setMethod(m)}
>
{s.methods[m]}
</Button>
))}
</div>
{suggestion.isPending ? (
<Loader2
className="size-5 animate-spin text-muted-foreground"
aria-label={t.common.loading}
/>
) : suggestion.data?.available ? (
<Button type="button" variant="secondary" onClick={() => onApply(suggestion.data.amounts)}>
<Sparkles aria-hidden />
{s.apply}
</Button>
) : (
<p className="text-sm text-muted-foreground">
{suggestion.data?.message ?? suggestion.error?.message}
</p>
)}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</fieldset>
)
}

Some files were not shown because too many files have changed in this diff Show More