Compare commits

..

44 Commits

Author SHA1 Message Date
suphicakir d3e012f50b fix(geliştirme): npm run dev açılışında yetim kalmış eski backend sürecini kapat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 04:59:55 +03:00
suphicakir 4f072634b1 style: prettier
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 04:48:23 +03:00
suphicakir dbed3da1b1 feat(borç): aylık planda bu ayın borcu, borç ödemesi etiketi, taksit önerisi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 04:47:59 +03:00
suphicakir c063186e80 feat(borç): borç takibi ekranları (Panel kartı, /borc, /taksitler)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 04:40:51 +03:00
suphicakir 11402ba660 feat(borç): borç takibi backend (ölçümler, taksit planları, yol haritası, dönem borcu, içe aktarma)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 04:27:48 +03:00
suphicakir a2eff1a984 feat(güvenlik): giriş ve kayıtta Cloudflare Turnstile, IP başına 3 hatalı denemede 15 dk kilit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 00:29:00 +03:00
suphicakir 5cbf97d884 feat(pwa): telefona kurulum, uygulama kabuğu önbelleği, güncelleme bildirimi, çevrimdışı uyarısı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 23:45:58 +03:00
suphicakir 8ee3672d57 perf(arayüz): sayfa bazlı kod bölme, oturum isteği paralel, ana sayfaları önden yükleme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 23:19:57 +03:00
suphicakir 57272a4c7f fix(arayüz): erişilebilirlik ve telefon düzeni taraması (kontrast, dokunma alanı, taşma, %80 bütçe uyarısı, boş durumlar)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 22:58:21 +03:00
suphicakir 77d7a081b3 feat(ayarlar): hane ve üyeler, davet bağlantısı, hesap ve şifre, bütçe varsayılanları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 22:32:28 +03:00
suphicakir 9daeff3eb5 feat(ayarlar): kategori ve ödeme türü yönetimi, ayarlar menüsü
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 22:04:22 +03:00
suphicakir c3ec91b2af feat(rapor): aylık rapor ekranı (görüntüle, yazdır/PDF, Markdown indir)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 21:54:37 +03:00
suphicakir dc0d783e46 feat(rapor): aylık değerlendirme raporu backend (şablon, yorumlar, öneriler)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 21:36:52 +03:00
suphicakir b974525cf6 feat(yedek): JSON yedeği arayüzü (indir, önizle, geri yükle, geri al)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:56:51 +03:00
suphicakir 0d70576df7 feat(yedek): JSON tam hane yedeği backend (önizleme, geri yükleme, geri dönüş noktaları)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:49:13 +03:00
suphicakir 6e1000afd2 fix(defter): haftalar ayın günlerine göre (1–8, 9–15, 16–22, 23–son), defter bugünün haftasıyla açılır
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:31:15 +03:00
suphicakir 233907e3d9 feat(ekstre): banka/kart ekstresi içe aktarma ekranı
- Ayarlar → Ekstre içe aktar: Excel (.xlsx/.xls) ve CSV, dosya tarayıcıda okunur
- Başlık satırı ve sütunlar otomatik bulunur; tek sütun / Borç-Alacak; eşleştirme profil olarak saklanır
- Gözden geçirme: tür + kategori önerisi, aynı açıklamalara otomatik yayılma, tekrar/engelli satırlar
- Öğrenilen kurallarda ilk kelime yedeği (MIGROS İSTANBUL → MIGROS KADIKÖY)
- Defterde iade etiketi; iade düzenlemede eksi kalır
- SheetJS resmi paketi frontend/vendor altında (npm 12 uzak paket indirmeyi engeller)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:58:04 +03:00
suphicakir d1a24afc3c feat(ekstre): banka/kart ekstresi içe aktarma altyapısı (arşiv dönemleri, iade, öğrenen kurallar)
- Arşiv dönemi durumu: geçmiş aylar için; kapatılmaz, devir zincirinde atlanır, bütçe önerilerini besler
- POST /bank-import/preview ve /commit: harcama / iade (eksi) / gelir (plan) / yoksay
- Tekrar tespiti (import_key), açıklamadan öğrenen kategori kuralları, ödeme/virman ipuçları
- Kapalı dönem ve gelecek tarih satır hatası; iade düzenlemede eksi kalır

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:33:07 +03:00
suphicakir 4d764689e2 feat(veri): CSV dışa ve içe aktarma (harcamalar, plan kalemleri, varlık işlemleri)
- Excel uyumlu CSV indirme
- İçe aktarma: önce deneme raporu (geçerli / zaten var / satır satır hata), onayla geçerli satırlar
- Sütunlar adla, esnek tutar/tarih yazımı, ; ve , ayırıcı, Windows-1254 desteği
- Varlıkta tarih sırasına göre eldekinden fazla satış denetimi
- Ayarlar sayfasında Veri bölümü

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 02:21:44 +03:00
suphicakir 33f64a0459 fix(arayüz): sunucuya ulaşılamadığında açıklayıcı hata mesajı
- Vekil 502/503/504 döndüğünde "Beklenmeyen hata" yerine "Sunucu şu an yanıt vermiyor"
- Yerel geliştirmede backend'in kapalı kalması durumu MEMORY'ye not edildi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 02:05:31 +03:00
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
337 changed files with 41179 additions and 397 deletions
+1
View File
@@ -9,3 +9,4 @@
backups/ backups/
.vscode/ .vscode/
*.log *.log
samples/
+11
View File
@@ -24,3 +24,14 @@ RUST_LOG=kakeibo=debug,tower_http=info
# Derlenmiş arayüz klasörü. Geliştirmede BOŞ bırakın (arayüzü Vite sunar). # Derlenmiş arayüz klasörü. Geliştirmede BOŞ bırakın (arayüzü Vite sunar).
# Docker imajında /app/static olarak ayarlıdır. # Docker imajında /app/static olarak ayarlıdır.
# STATIC_DIR= # STATIC_DIR=
# Otomatik kur yenileme (dakika); 0 → kapalı (çevrimdışı geliştirme)
# RATES_REFRESH_MINUTES=60
# Bot doğrulaması (Cloudflare Turnstile). İkisi de boşsa kapalı (geliştirme).
# Yerel deneme için Cloudflare test anahtarları (her zaman geçer):
# TURNSTILE_SITE_KEY=1x00000000000000000000AA
# TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
# Ters vekil (Dokploy/Traefik) arkasında gerçek IP X-Forwarded-For'dan; vekil yoksa false
# TRUST_PROXY=true
+3
View File
@@ -17,3 +17,6 @@ dist/
# Yedekler # Yedekler
backups/ backups/
# Örnek ekstreler (kişisel veri; depoya girmez)
samples/
+17 -4
View File
@@ -43,7 +43,7 @@ scripts/ seed, reset, backup
- Her sorgu `household_id` ile filtrelenir (ortak middleware). - Her sorgu `household_id` ile filtrelenir (ortak middleware).
- Hesaplanan değerler saklanmaz; aggregation ile hesaplanır. - Hesaplanan değerler saklanmaz; aggregation ile hesaplanır.
- Format: `1.250,00 ₺`, tarih `GG.AA.YYYY`; tümü `lib/format.ts` üzerinden. - Format: `1.250,00 ₺`, tarih `GG.AA.YYYY`; tümü `lib/format.ts` üzerinden.
- Haftalar Pazartesi başlar; kısmi haftalar gün sayısına göre orantılanır. - Haftalar dönemin günlerine göre: 1–8, 9–15, 16–22, 23–son gün (28–31 günlük dönem = 4 hafta; genel: `max(1, gün/7)` hafta, ilk 8 gün, ara 7, son kalan). Bütçe haftalara gün sayısıyla orantılanır. Tek kaynak: `calendar::weeks`.
- Hatalar kullanıcıya Türkçe ve sade; teknik detay yalnızca logda. - Hatalar kullanıcıya Türkçe ve sade; teknik detay yalnızca logda.
- Commit mesajları Türkçe: `feat(defter): haftalık giriş listesi eklendi` - Commit mesajları Türkçe: `feat(defter): haftalık giriş listesi eklendi`
@@ -59,23 +59,36 @@ npm test # backend (cargo test, MongoDB açık olmalı) + frontend (
npm run lint # clippy + oxlint npm run lint # clippy + oxlint
npm run types # Rust DTO → frontend/src/types/*.ts (ts-rs; cargo test de üretir) 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 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`). - `npm run dev` öncesinde DB otomatik başlar (`predev`); `dev:api` önce `scripts/stop-stale-api.sh` ile 8080'de yetim kalmış eski `kakeibo` sürecini kapatır (yalnız adı kakeibo ise). Belirti: yeni uç nokta "Kayıt bulunamadı" (404) → eski backend çalışıyor.
- Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler). - 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 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`). - 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`. - 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`). - Sayfalar: `src/router.tsx` — her sayfa **ayrı JS parçası** (`lazy: page(() => import(...), 'XPage')`; yeni sayfa ekleyince statik import etme). `main.tsx` `prefetchMe` (oturum isteği sayfa koduyla paralel), `AppLayout` alt menü sayfalarını boşta önden indirir + gezinmede ince ilerleme çizgisi. İlk yükleme yedeği `Splash`.
- Sayfa adresleri: 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. - 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`. - 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`). - 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` her açılışta bugünün haftası (hafta adrese yazılmaz), `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`. - 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` - 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. - 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.
- Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_<uuid>` DB, `tests/common` yardımcıları). - Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_<uuid>` DB, `tests/common` yardımcıları).
- Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`. - Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`.
- Excel okuma: SheetJS **yerel paket** `frontend/vendor/xlsx-0.20.3.tgz` (npm 12 uzak tarball'u engeller; npm'deki `xlsx` eski/açıklı). Güncellerken: cdn.sheetjs.com'dan indir, içeriği/sha256'yı kontrol et, `npm i ./vendor/…`; Dockerfile `vendor/`'u `npm ci`'den önce kopyalar. Lazy yüklenir.
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç. - Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
- JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. Arayüz: `features/settings/backup-section.tsx`, `lib/backup.ts` (geri yükleme/geri alma sonrası tüm sorgular yenilenir).
- Aylık rapor: `services/monthly_report.rs` (`gather` → `Facts` → saf `analyze` → `render`), şablon `templates/monthly_report.md` (`include_str!`; Dockerfile `templates/`'u kopyalar). Filtreler `tl`/`pct`/`cell`. Şablonda ham HTML yok (arayüz göstermez). Arayüz `features/review/report-page.tsx` (`/degerlendirme/rapor?donem=`), `lib/monthly-report.ts`; `api.getText`; `Markdown` tabloları `.table-wrap` içinde kayar; yazdırma `@page A4` + `PageHeader printHidden`. Cümlelerde sayıdan sonra ek kullanma ("%429'u" gibi ünlü uyumu gerekir) → "X / Y", "kullanımı %N".
- Ayarlar: `/ayarlar` menü (+ tema) → alt sayfalar `SettingsSubpage` ("‹ Ayarlar"): `/ayarlar/kategoriler`, `/odeme-turleri`, `/butce` (varsayılan yüzdeler + öneri yöntemi), `/hane` (ad, üyeler, davet bağlantısı `/kayit?davet=`), `/hesap` (ad, şifre), `/veri` (yedek, CSV, ekstre). Kancalar `lib/household.ts`. Şifre değişince `users.sessions_valid_from` → eski jetonlar 401 (`AuthUser`). Katalog yönetimi `features/settings/catalog-editor.tsx` + `lib/catalog.ts` (`useCreateCatalog`/`useUpdateCatalog`/`useReorderCatalog`, `moveId`); sıralama isteği grubun **tüm** kimlikleri (pasifler dahil); grup sonradan değişmez.
- Erişilebilirlik kuralları: `--muted-foreground` açık temada 0.52 (gri zeminde AA); kart bileşenleri (`Section`, rapor bölümü) `min-w-0` — ızgara öğesi içeriğine uzayıp telefonda taşmasın; `Button` `sm`/`icon-sm` dokunmatikte (`pointer-coarse`) 44 px; tutarlar `formatMoney` ile **bölünmez boşluklu** (`1.250,00\u00a0₺`; testlerde `\u00a0`); bütçe çubuğu `budgetLevel` (≥ %80 turuncu + "Bütçe kullanımı %N", aşım kırmızı); `Markdown shiftHeadings` (sayfada tek h1). Cümlelerde sayıdan sonra ek yok.
- PWA: `vite.config.ts` `VitePWA` (registerType `prompt`, `/api` asla önbelleğe alınmaz, `navigateFallbackDenylist /api`, xlsx + yabancı font alt kümeleri ön önbellekte yok); `lib/pwa.tsx` (`PwaUpdater` "Yeni sürüm hazır → Yenile", saatlik yoklama; `OfflineBanner`), `lib/online.ts`. `main.tsx` `onlineManager.setOnline(navigator.onLine)`. Oturum sorgusu ağ hatası/duraklama → `RequireAuth` giriş sayfasına **atmaz**, "Tekrar dene" gösterir. Geliştirmede SW yok. DEPLOY §7.
- Giriş güvenliği: `auth/guard.rs` (IP başına üst üste 3 hata → giriş+kayıt 15 dk kilit, bellek içi; e-postaya göre kilit YOK), `auth/captcha.rs` (Cloudflare Turnstile; anahtar yoksa kapalı; testte `Captcha::Fixed`), `auth/client_ip.rs` (`TRUST_PROXY` → en sağdaki X-Forwarded-For). Hata kodları `locked` (429 + Retry-After), `captcha` (400), `invalid_credentials` (kalan hak mesajda). `GET /auth/guard`. Arayüz `features/auth/guard.tsx` (`useAuthSecurity`), `lock-notice.tsx`, `components/turnstile.tsx`. Testler `TestApp::with_captcha`, `send_with` (X-Forwarded-For). DEPLOY §8.
- Borç takibi (docs/BORC_TAKIBI.md): `services/debt.rs` (saf: `total`, `installment_load`, `remaining_after`, `summarize`, `period_debt`; içe aktarma `parse_import` kaynak belge JSON + `asOf`, TL → kuruş `tl_to_kurus` kayan noktasız), `routes/debt.rs` (`/debt*`, `/periods/{id}/debt`, `/periods/{id}/opening-debt`). Koleksiyonlar `debt_snapshots`, `installment_plans` (`first_month`), `debt_targets`; toplam saklanmaz. Kakeibo `carryovers` ile karıştırma. Kişisel seed verisi repoya girmez. Arayüz `features/debt/` (`debt-card.tsx` Panel en üstü + /borc, `debt-chart.tsx` lazy Recharts, `snapshot-form.tsx` kalan taksit önerisi `remainingAfter`, `history.tsx`, `targets-editor.tsx`, `import-section.tsx`, `installments-page.tsx`), `lib/debt.ts` (saf hesaplar backend ile aynı). Adresler `/borc`, `/taksitler`; menüde "Borç" (masaüstü + Diğer). Plan: `features/debt/period-debt.tsx` (`PeriodDebtSection`: ay başı [elle düzelt], devreden banka borcu, borç ödemesi, taksit, hedef ay sonu + yol haritası, dönem içi ilerleme, "Taksitler" sabit gideri önerisi `findInstallmentItem`); borç > 0 iken `SummaryTiles debtPayment` → "Borç ödemesi" (Plan, düzenleme, sihirbaz). Kakeibo'nun "Devreden borç"u ile karışmasın diye banka borcu "Devreden banka borcu".
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404. - Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
## Ortam ## Ortam
+4
View File
@@ -6,6 +6,8 @@ FROM node:26-alpine AS web
WORKDIR /app/frontend WORKDIR /app/frontend
# Önce yalnızca paket dosyaları → bağımlılık katmanı önbellekte kalır # Önce yalnızca paket dosyaları → bağımlılık katmanı önbellekte kalır
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
# Yerel paketler (SheetJS resmi dağıtımı; npm 12 uzak adres indirmeyi engeller)
COPY frontend/vendor/ ./vendor/
RUN npm ci --no-audit --no-fund RUN npm ci --no-audit --no-fund
COPY frontend/ ./ COPY frontend/ ./
RUN npm run build RUN npm run build
@@ -18,6 +20,8 @@ COPY backend/Cargo.toml backend/Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs && touch src/lib.rs \ RUN mkdir src && echo 'fn main() {}' > src/main.rs && touch src/lib.rs \
&& cargo build --release --locked \ && cargo build --release --locked \
&& rm -rf src target/release/deps/*kakeibo* target/release/.fingerprint/kakeibo-* target/release/kakeibo* && rm -rf src target/release/deps/*kakeibo* target/release/.fingerprint/kakeibo-* target/release/kakeibo*
# Aylık rapor şablonu derleme anında ikiliye gömülür (include_str!)
COPY templates/ /app/templates/
COPY backend/src ./src COPY backend/src ./src
# touch: sahte lib.rs/main.rs'ten daha yeni olsun → Cargo gerçek kodu kesinlikle yeniden derler # touch: sahte lib.rs/main.rs'ten daha yeni olsun → Cargo gerçek kodu kesinlikle yeniden derler
RUN touch src/lib.rs src/main.rs && cargo build --release --locked RUN touch src/lib.rs src/main.rs && cargo build --release --locked
+1 -1
View File
@@ -162,7 +162,7 @@ kakeibo/
- Dönem **başlangıç ve bitiş tarihiyle** açılır. Örnek: `01.12.2026` – `01.01.2027`. - Dönem **başlangıç ve bitiş tarihiyle** açılır. Örnek: `01.12.2026` – `01.01.2027`.
- Aynı anda yalnızca **bir açık dönem** olur; girişler yalnızca açık döneme yapılır. - Aynı anda yalnızca **bir açık dönem** olur; girişler yalnızca açık döneme yapılır.
- **Haftalar:** Pazartesi başlangıçlı. Dönemin başı/sonundaki kısmi haftalar ayrı hafta sayılır ve bütçeleri gün sayısına göre orantılanır. - **Haftalar:** (kullanıcı kararı 2026-11-13, eski "Pazartesi başlangıçlı" kuralının yerine) dönemin 1–8. günü 1. hafta, 9–15 2., 16–22 3., 23–son gün 4. hafta. Bütçeleri gün sayısına göre orantılanır.
- **Dönem kapatma** (sihirbaz adımları): - **Dönem kapatma** (sihirbaz adımları):
1. Özet ve planlanan/gerçekleşen karşılaştırması gösterilir. 1. Özet ve planlanan/gerçekleşen karşılaştırması gösterilir.
2. Kalan bakiye ve borç hesaplanır. 2. Kalan bakiye ve borç hesaplanır.
+251 -4
View File
@@ -178,6 +178,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "base64ct" name = "base64ct"
version = "1.8.3" version = "1.8.3"
@@ -257,7 +263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e"
dependencies = [ dependencies = [
"ahash", "ahash",
"base64", "base64 0.22.1",
"bitvec", "bitvec",
"getrandom 0.2.17", "getrandom 0.2.17",
"getrandom 0.3.4", "getrandom 0.3.4",
@@ -301,6 +307,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.10.2" version = "0.10.2"
@@ -928,9 +940,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1", "rand_core 0.10.1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1130,6 +1144,23 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec",
"tokio", "tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -1138,13 +1169,22 @@ version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [ dependencies = [
"base64 0.23.1",
"bytes", "bytes",
"futures-channel",
"futures-util",
"http", "http",
"http-body", "http-body",
"httparse",
"hyper", "hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -1361,7 +1401,7 @@ version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"ed25519-dalek", "ed25519-dalek",
"getrandom 0.2.17", "getrandom 0.2.17",
"hmac 0.12.1", "hmac 0.12.1",
@@ -1385,16 +1425,21 @@ dependencies = [
"axum", "axum",
"axum-extra", "axum-extra",
"dotenvy", "dotenvy",
"flate2",
"http-body-util", "http-body-util",
"jsonwebtoken", "jsonwebtoken",
"minijinja",
"mongodb", "mongodb",
"quick-xml",
"reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9",
"thiserror", "thiserror",
"time", "time",
"tokio", "tokio",
"tower", "tower",
"tower-http", "tower-http 0.7.1",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"ts-rs", "ts-rs",
@@ -1444,6 +1489,12 @@ version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "lru-slab"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]] [[package]]
name = "macro_magic" name = "macro_magic"
version = "0.5.1" version = "0.5.1"
@@ -1539,6 +1590,15 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minijinja"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "509eeb2603a84eccabb4d75f6ec8ba23b46bbf441aaeb863d6b83a5887fd51e4"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.9.1" version = "0.9.1"
@@ -1601,7 +1661,7 @@ version = "3.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6466d4601ff396952e1895d88083d1ab03930b812277180eb8258bbe1a10bb0f" checksum = "6466d4601ff396952e1895d88083d1ab03930b812277180eb8258bbe1a10bb0f"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bitflags", "bitflags",
"bson", "bson",
"derive-where", "derive-where",
@@ -1928,6 +1988,72 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quick-xml"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "quinn"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.3",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.47" version = "1.0.47"
@@ -2031,6 +2157,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -2069,6 +2204,44 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]] [[package]]
name = "resolv-conf" name = "resolv-conf"
version = "0.7.6" version = "0.7.6"
@@ -2119,6 +2292,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -2159,6 +2338,7 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "102f35f7de7b06d8c61f1cff6592f5253e0dea431c74867aad5dfd70f44663fb" checksum = "102f35f7de7b06d8c61f1cff6592f5253e0dea431c74867aad5dfd70f44663fb"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -2504,6 +2684,9 @@ name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]] [[package]]
name = "synstructure" name = "synstructure"
@@ -2717,6 +2900,24 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
"url",
]
[[package]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.7.1" version = "0.7.1"
@@ -2818,6 +3019,12 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]] [[package]]
name = "ts-rs" name = "ts-rs"
version = "12.0.1" version = "12.0.1"
@@ -2994,6 +3201,15 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "want"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
dependencies = [
"try-lock",
]
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
@@ -3021,6 +3237,17 @@ dependencies = [
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.130" version = "0.2.130"
@@ -3053,6 +3280,26 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "web-sys"
version = "0.3.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.12" version = "1.0.12"
+5
View File
@@ -8,10 +8,15 @@ argon2 = "0.6.0"
axum = "0.8.9" axum = "0.8.9"
axum-extra = { version = "0.12.6", features = ["cookie"] } axum-extra = { version = "0.12.6", features = ["cookie"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
flate2 = "1"
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] } jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
minijinja = { version = "3.1.0", features = ["serde"] }
mongodb = "3.10.0" mongodb = "3.10.0"
quick-xml = { version = "0.42.0", features = ["serialize"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154" serde_json = "1.0.154"
sha2 = "0.10"
thiserror = "2.0.21" thiserror = "2.0.21"
time = "0.3.55" time = "0.3.55"
tokio = { version = "1.54.1", features = ["full"] } tokio = { version = "1.54.1", features = ["full"] }
+207 -1
View File
@@ -235,9 +235,10 @@ DELETE {{baseUrl}}/notes/BURAYA-NOT-ID
# Gruplar: needs · wants · culture · unexpected · tutarlar kuruş # Gruplar: needs · wants · culture · unexpected · tutarlar kuruş
### Dönem özeti: gelir − sabit gider − birikim − devreden borç + devreden artı = harcanabilir ### 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 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 GET {{baseUrl}}/periods/BURAYA-DONEM-ID/budget
### Aylık bütçeleri kaydet (4 grup birlikte) ### Aylık bütçeleri kaydet (4 grup birlikte)
@@ -263,3 +264,208 @@ Content-Type: application/json
### Bütçe önerisi — method: default (varsayılan yüzdeler) | previous (önceki dönem) | avg3 (son 3 dönem) ### 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 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
# ─────────────────────────── CSV dışa / içe aktarma ───────────────────────────
### Harcamaları CSV olarak indir (tarih aralığı isteğe bağlı)
GET {{baseUrl}}/export/entries.csv?from=2026-01-01&to=2026-12-31
### İçe aktarma denemesi (hiçbir şey yazılmaz); commit=true → geçerli satırlar yazılır
POST {{baseUrl}}/import/entries?commit=false
Content-Type: text/csv; charset=utf-8
Tarih;Tutar;Kategori;Açıklama
13.11.2026;45,90;Market;Ekmek
# ─────────────────────────── JSON tam hane yedeği ───────────────────────────
### Yedek dosyasını indir (her üye)
GET {{baseUrl}}/backup/download
### Son yedek zamanı + geri dönüş noktaları (noktaları yalnız sahip görür)
GET {{baseUrl}}/backup/status
### Önizleme (sahip; hiçbir şey yazılmaz) — gövde: indirilen dosyanın içeriği
POST {{baseUrl}}/backup/preview
Content-Type: application/json
< ./kakeibo-yedek.json
### Geri yükle (sahip): önce geri dönüş noktası, sonra tam değiştirme
POST {{baseUrl}}/backup/restore
Content-Type: application/json
< ./kakeibo-yedek.json
### Geri al: geri dönüş noktasına dön (bu da yeni bir nokta bırakır)
POST {{baseUrl}}/backup/restore-points/{{pointId}}/restore
# ─────────────────────────── Aylık değerlendirme raporu ───────────────────────────
### Dönemin raporu (Markdown; açık dönem → ara rapor)
GET {{baseUrl}}/periods/{{periodId}}/report.md
# ─────────────────────────── Hesap ve hane ayarları ───────────────────────────
### Profil adı
PATCH {{baseUrl}}/auth/me
Content-Type: application/json
{ "name": "Suphi" }
### Şifre değiştir (diğer cihazlarda oturum kapanır, bu cihaza yeni çerez)
POST {{baseUrl}}/auth/me/password
Content-Type: application/json
{ "current_password": "…", "new_password": "…" }
### Hane adı + bütçe varsayılanları (sahip; toplam 100)
PATCH {{baseUrl}}/household
Content-Type: application/json
{ "name": "Çakır Hanesi", "budget_split_pct": { "needs": 50, "wants": 30, "culture": 10, "unexpected": 10 }, "budget_suggestion": "avg3" }
### Üyeyi çıkar (sahip)
DELETE {{baseUrl}}/household/members/{{memberId}}
# ─────────────────────────── Borç takibi ───────────────────────────
### Özet (son ölçüm, değişim, ilerleme, taksitler, hedefler, 6 aylık yük)
GET {{baseUrl}}/debt
### Yeni ölçüm (kuruş)
POST {{baseUrl}}/debt/snapshots
Content-Type: application/json
{ "date": "2026-11-30", "kmh": 0, "positive_balance": 0, "card_balance": 0, "remaining_installments": 0, "note": "maaş sonrası" }
### İçe aktarma ön izleme (commit=true → yazar); gövde: docs/BORC_TAKIBI.md biçimi + asOf
POST {{baseUrl}}/debt/import?commit=false
Content-Type: application/json
{ "asOf": "2026-11", "debtSnapshots": [], "installmentPlans": [], "roadmap": [] }
### Dönemin borcu
GET {{baseUrl}}/periods/{{periodId}}/debt
+115
View File
@@ -0,0 +1,115 @@
//! Bot doğrulaması: Cloudflare Turnstile (kullanıcı kararı 2026-11-14).
//!
//! Tarayıcı Cloudflare'den tek kullanımlık bir jeton alır; sunucu bunu gizli anahtarla
//! `siteverify` uç noktasına sorar. Anahtar tanımlı değilse (geliştirme, testler) kapalıdır.
//! Cloudflare'e ulaşılamazsa güvenlik için **reddedilir** (deneme hakkından düşmez).
use std::time::Duration;
use serde::Deserialize;
use crate::error::AppError;
const VERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
pub enum Captcha {
Disabled,
Turnstile {
secret: String,
client: reqwest::Client,
},
/// Testler için: ağ olmadan her zaman geçer (`true`) ya da kalır (`false`)
Fixed(bool),
}
/// Doğrulama sonucu: `Rejected` deneme hakkından düşer, `Unavailable` düşmez
#[derive(Debug)]
pub enum CaptchaError {
Rejected,
Unavailable,
}
impl CaptchaError {
pub fn into_app_error(self) -> AppError {
AppError::Captcha(match self {
Self::Rejected => {
"Robot olmadığını doğrulayamadık. Doğrulama kutusunu yeniden tamamlayıp tekrar dene."
.into()
}
Self::Unavailable => {
"Doğrulama servisine şu an ulaşılamıyor. Birkaç dakika sonra tekrar dene.".into()
}
})
}
}
#[derive(Deserialize)]
struct VerifyResponse {
success: bool,
#[serde(default, rename = "error-codes")]
error_codes: Vec<String>,
}
impl Captcha {
pub fn from_secret(secret: Option<&str>) -> Self {
match secret {
Some(s) => Self::Turnstile {
secret: s.to_string(),
client: reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.build()
.unwrap_or_default(),
},
None => Self::Disabled,
}
}
pub fn enabled(&self) -> bool {
!matches!(self, Self::Disabled)
}
pub async fn verify(&self, token: Option<&str>, ip: &str) -> Result<(), CaptchaError> {
let token = token.map(str::trim).filter(|t| !t.is_empty());
match self {
Self::Disabled => Ok(()),
Self::Fixed(ok) => {
if *ok && token.is_some() {
Ok(())
} else {
Err(CaptchaError::Rejected)
}
}
Self::Turnstile { secret, client } => {
// Jeton en fazla 2048 karakter (Cloudflare belgesi)
let Some(token) = token.filter(|t| t.len() <= 2048) else {
return Err(CaptchaError::Rejected);
};
let mut form = vec![("secret", secret.as_str()), ("response", token)];
if ip.parse::<std::net::IpAddr>().is_ok() {
form.push(("remoteip", ip));
}
let res = client.post(VERIFY_URL).form(&form).send().await;
let body: VerifyResponse = match res {
Ok(r) => r.json().await.map_err(|e| {
tracing::error!("Turnstile yanıtı okunamadı: {e}");
CaptchaError::Unavailable
})?,
Err(e) => {
tracing::error!("Turnstile'a ulaşılamadı: {e}");
return Err(CaptchaError::Unavailable);
}
};
if body.success {
Ok(())
} else {
tracing::warn!(ip, codes = ?body.error_codes, "Turnstile doğrulaması başarısız");
if body.error_codes.iter().any(|c| c == "internal-error") {
Err(CaptchaError::Unavailable)
} else {
Err(CaptchaError::Rejected)
}
}
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
//! İstemcinin IP adresi (deneme kilidi ve bot doğrulaması için).
//!
//! Canlıda uygulama Dokploy'un Traefik vekilinin arkasındadır: bağlantı adresi vekilinkidir.
//! `TRUST_PROXY` açıkken (varsayılan) gerçek adres `X-Forwarded-For`'un **en sağdaki** girdisidir —
//! onu vekil ekler; istemcinin kendi yazdığı (sahte) girdiler solda kalır ve dikkate alınmaz.
//! Vekil yoksa `TRUST_PROXY=false`: bağlantı adresi kullanılır.
use std::net::{IpAddr, SocketAddr};
use axum::{
extract::{ConnectInfo, FromRequestParts},
http::{HeaderMap, request::Parts},
};
use crate::state::AppState;
#[derive(Debug, Clone)]
pub struct ClientIp(pub String);
/// `X-Forwarded-For: sahte, gerçek` → `gerçek`
pub fn from_forwarded(headers: &HeaderMap) -> Option<IpAddr> {
headers
.get_all("x-forwarded-for")
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(','))
.filter_map(|p| p.trim().parse::<IpAddr>().ok())
.next_back()
}
impl FromRequestParts<AppState> for ClientIp {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let forwarded = state
.config
.trust_proxy
.then(|| from_forwarded(&parts.headers))
.flatten();
let socket = parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0.ip());
Ok(Self(forwarded.or(socket).map_or_else(
|| "bilinmiyor".to_string(),
|ip| ip.to_string(),
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rightmost_forwarded_address_wins() {
let mut h = HeaderMap::new();
assert_eq!(from_forwarded(&h), None);
h.insert("x-forwarded-for", "6.6.6.6, 203.0.113.7".parse().unwrap());
assert_eq!(from_forwarded(&h), Some("203.0.113.7".parse().unwrap()));
h.append("x-forwarded-for", "198.51.100.1".parse().unwrap());
assert_eq!(from_forwarded(&h), Some("198.51.100.1".parse().unwrap()));
let mut bad = HeaderMap::new();
bad.insert("x-forwarded-for", "sahte".parse().unwrap());
assert_eq!(from_forwarded(&bad), None);
}
}
+130
View File
@@ -0,0 +1,130 @@
//! Giriş / kayıt deneme kilidi (IP başına, kullanıcı kararı 2026-11-14).
//!
//! Üst üste `max` hatalı deneme (yanlış şifre, geçersiz davet kodu, bot doğrulaması…) →
//! o IP için giriş **ve** kayıt `lock` süresince kapanır. Başarılı girişte sayaç sıfırlanır.
//! IP'ye göre olması bilinçli: e-postaya göre kilit, adresi bilen birinin hane sahibini
//! dışarıda bırakmasına izin verirdi. Bellek içi (tek sunucu); yeniden başlatınca sıfırlanır.
use std::{
collections::HashMap,
sync::Mutex,
time::{Duration, Instant},
};
pub const MAX_FAILURES: u32 = 3;
pub const LOCK_FOR: Duration = Duration::from_secs(15 * 60);
/// Kilitsiz ve bu kadar süredir dokunulmamış kayıtlar silinir (bellek büyümesin)
const FORGET_AFTER: Duration = Duration::from_secs(60 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
/// Kilide kalan deneme
Remaining(u32),
/// Kilitlendi; kalan süre (sn)
Locked(u64),
}
struct Entry {
failures: u32,
locked_until: Option<Instant>,
last: Instant,
}
pub struct AttemptGuard {
map: Mutex<HashMap<String, Entry>>,
max: u32,
lock: Duration,
}
impl Default for AttemptGuard {
fn default() -> Self {
Self::new(MAX_FAILURES, LOCK_FOR)
}
}
impl AttemptGuard {
pub fn new(max: u32, lock: Duration) -> Self {
Self {
map: Mutex::new(HashMap::new()),
max,
lock,
}
}
/// Kilitliyse kalan süre (sn, en az 1)
pub fn locked_for(&self, key: &str) -> Option<u64> {
let map = self.map.lock().unwrap_or_else(|e| e.into_inner());
map.get(key)
.and_then(|e| e.locked_until)
.and_then(|until| until.checked_duration_since(Instant::now()))
.map(|left| left.as_secs().max(1))
}
pub fn failure(&self, key: &str) -> Outcome {
let now = Instant::now();
let mut map = self.map.lock().unwrap_or_else(|e| e.into_inner());
map.retain(|_, e| {
e.locked_until.is_some_and(|u| u > now) || now.duration_since(e.last) < FORGET_AFTER
});
let e = map.entry(key.to_string()).or_insert(Entry {
failures: 0,
locked_until: None,
last: now,
});
// Süresi biten kilit: sayaç baştan
if e.locked_until.is_some_and(|u| u <= now) {
e.locked_until = None;
e.failures = 0;
}
e.failures += 1;
e.last = now;
if e.failures >= self.max {
e.locked_until = Some(now + self.lock);
e.failures = 0;
Outcome::Locked(self.lock.as_secs())
} else {
Outcome::Remaining(self.max - e.failures)
}
}
pub fn success(&self, key: &str) {
self.map
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(key);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn three_consecutive_failures_lock() {
let g = AttemptGuard::default();
assert_eq!(g.failure("1.2.3.4"), Outcome::Remaining(2));
assert_eq!(g.failure("1.2.3.4"), Outcome::Remaining(1));
assert!(g.locked_for("1.2.3.4").is_none());
assert_eq!(g.failure("1.2.3.4"), Outcome::Locked(900));
assert!(g.locked_for("1.2.3.4").is_some_and(|s| s > 890));
assert!(g.locked_for("5.6.7.8").is_none(), "başka IP etkilenmez");
}
#[test]
fn success_resets_and_lock_expires() {
let g = AttemptGuard::new(3, Duration::from_millis(50));
g.failure("ip");
g.failure("ip");
g.success("ip");
assert_eq!(
g.failure("ip"),
Outcome::Remaining(2),
"başarı sayacı sıfırlar"
);
g.failure("ip");
assert!(matches!(g.failure("ip"), Outcome::Locked(_)));
std::thread::sleep(Duration::from_millis(60));
assert!(g.locked_for("ip").is_none(), "süre dolunca açılır");
assert_eq!(g.failure("ip"), Outcome::Remaining(2), "yeni tur");
}
}
+4 -1
View File
@@ -1,5 +1,8 @@
//! Kimlik doğrulama: şifre hash'leme, JWT, oturum cookie'si, giriş deneme sınırı. //! Kimlik doğrulama: şifre hash'leme, JWT, oturum cookie'si, deneme kilidi (IP), bot doğrulaması.
pub mod captcha;
pub mod client_ip;
pub mod guard;
pub mod jwt; pub mod jwt;
pub mod limiter; pub mod limiter;
pub mod password; pub mod password;
+12 -2
View File
@@ -3,7 +3,8 @@
//! Bir route parametrelerine `user: AuthUser` eklediğinde, Axum isteği işlemeden önce: //! Bir route parametrelerine `user: AuthUser` eklediğinde, Axum isteği işlemeden önce:
//! 1. cookie'deki JWT'nin imzasını ve süresini doğrular, //! 1. cookie'deki JWT'nin imzasını ve süresini doğrular,
//! 2. kullanıcının veritabanında hâlâ var olduğunu ve aynı haneye ait olduğunu kontrol eder //! 2. kullanıcının veritabanında hâlâ var olduğunu ve aynı haneye ait olduğunu kontrol eder
//! (silinen kullanıcının token'ı anında geçersiz olur, rol değişikliği hemen etkili olur). //! (silinen kullanıcının token'ı anında geçersiz olur, rol değişikliği hemen etkili olur),
//! 3. jeton şifre değişikliğinden önce verilmişse reddeder (`sessions_valid_from`).
//! //!
//! Herhangi biri başarısızsa route hiç çalışmaz, 401 döner. //! Herhangi biri başarısızsa route hiç çalışmaz, 401 döner.
//! **Hane izolasyonu:** route'lar hane kimliğini her zaman `AuthUser.household_id`'den alır, //! **Hane izolasyonu:** route'lar hane kimliğini her zaman `AuthUser.household_id`'den alır,
@@ -67,6 +68,9 @@ impl AuthUser {
#[derive(Deserialize)] #[derive(Deserialize)]
struct RoleOnly { struct RoleOnly {
role: Role, role: Role,
/// Şifre değişince "şimdi": bundan önce verilmiş jetonlar geçersiz
#[serde(default)]
sessions_valid_from: Option<i64>,
} }
impl FromRequestParts<AppState> for AuthUser { impl FromRequestParts<AppState> for AuthUser {
@@ -85,9 +89,15 @@ impl FromRequestParts<AppState> for AuthUser {
.db .db
.collection::<RoleOnly>(col::USERS) .collection::<RoleOnly>(col::USERS)
.find_one(doc! { "_id": user_id, "household_id": household_id, "deleted_at": null }) .find_one(doc! { "_id": user_id, "household_id": household_id, "deleted_at": null })
.projection(doc! { "role": 1 }) .projection(doc! { "role": 1, "sessions_valid_from": 1 })
.await? .await?
.ok_or(AppError::Unauthorized)?; .ok_or(AppError::Unauthorized)?;
if current
.sessions_valid_from
.is_some_and(|from| (claims.iat as i64) < from)
{
return Err(AppError::Unauthorized);
}
Ok(Self { Ok(Self {
user_id, user_id,
+43
View File
@@ -12,6 +12,13 @@ pub struct Config {
/// Derlenmiş frontend klasörü (`STATIC_DIR`). Tanımlıysa Axum arayüzü de sunar /// Derlenmiş frontend klasörü (`STATIC_DIR`). Tanımlıysa Axum arayüzü de sunar
/// (canlıda tek servis). Geliştirmede boş: arayüzü Vite sunar. /// (canlıda tek servis). Geliştirmede boş: arayüzü Vite sunar.
pub static_dir: Option<PathBuf>, pub static_dir: Option<PathBuf>,
/// Otomatik kur yenileme aralığı (dakika, `RATES_REFRESH_MINUTES`, varsayılan 60); 0 → kapalı
pub rates_refresh_minutes: u64,
/// Cloudflare Turnstile (`TURNSTILE_SITE_KEY` + `TURNSTILE_SECRET_KEY`); ikisi de yoksa kapalı
pub turnstile_site_key: Option<String>,
pub turnstile_secret_key: Option<String>,
/// Ters vekil arkasında (Dokploy/Traefik) gerçek IP `X-Forwarded-For`'dan (`TRUST_PROXY`, varsayılan açık)
pub trust_proxy: bool,
} }
/// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni). /// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni).
@@ -69,6 +76,38 @@ impl Config {
Err(_) => 8080, Err(_) => 8080,
}; };
let rates_refresh_minutes = match env::var("RATES_REFRESH_MINUTES") {
Ok(m) if !m.trim().is_empty() => m.trim().parse().map_err(|_| {
ConfigError::Invalid("RATES_REFRESH_MINUTES", "dakika (sayı) olmalı")
})?,
_ => 60,
};
let opt = |k: &str| {
env::var(k)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
};
let turnstile_site_key = opt("TURNSTILE_SITE_KEY");
let turnstile_secret_key = opt("TURNSTILE_SECRET_KEY");
if turnstile_site_key.is_some() != turnstile_secret_key.is_some() {
return Err(ConfigError::Invalid(
"TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY",
"ikisi birlikte tanımlanmalı (ya da ikisi de boş)",
));
}
let trust_proxy = match env::var("TRUST_PROXY").as_deref().map(str::trim) {
Err(_) | Ok("") | Ok("true") | Ok("1") => true,
Ok("false") | Ok("0") => false,
Ok(_) => {
return Err(ConfigError::Invalid(
"TRUST_PROXY",
"\"true\" veya \"false\" olmalı",
));
}
};
Ok(Self { Ok(Self {
mongodb_uri, mongodb_uri,
jwt_secret, jwt_secret,
@@ -76,6 +115,10 @@ impl Config {
port, port,
registration, registration,
static_dir, static_dir,
rates_refresh_minutes,
turnstile_site_key,
turnstile_secret_key,
trust_proxy,
}) })
} }
+26
View File
@@ -69,16 +69,42 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
vec![ vec![
idx(doc! { "household_id": 1, "period_id": 1, "date": 1 }), idx(doc! { "household_id": 1, "period_id": 1, "date": 1 }),
idx(doc! { "household_id": 1, "date": 1 }), idx(doc! { "household_id": 1, "date": 1 }),
// Ekstre içe aktarmada tekrar tespiti
idx(doc! { "household_id": 1, "import_key": 1 }),
], ],
), ),
( (
col::CARRYOVERS, col::CARRYOVERS,
vec![idx(doc! { "household_id": 1, "to_period_id": 1 })], vec![idx(doc! { "household_id": 1, "to_period_id": 1 })],
), ),
(
col::ASSETS,
vec![idx(doc! { "household_id": 1, "sort_order": 1 })],
),
( (
col::ASSET_TRANSACTIONS, col::ASSET_TRANSACTIONS,
vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })], vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })],
), ),
(
col::IMPORT_RULES,
vec![idx(doc! { "household_id": 1, "pattern": 1 })],
),
(
col::DEBT_SNAPSHOTS,
vec![idx(doc! { "household_id": 1, "date": -1 })],
),
(
col::INSTALLMENT_PLANS,
vec![idx(doc! { "household_id": 1, "first_month": 1 })],
),
(
col::DEBT_TARGETS,
vec![idx(doc! { "household_id": 1, "month": 1 })],
),
(
col::RESTORE_POINTS,
vec![idx(doc! { "household_id": 1, "created_at": -1 })],
),
( (
col::EXCHANGE_RATES, col::EXCHANGE_RATES,
vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })], vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })],
+85 -5
View File
@@ -24,11 +24,33 @@ struct Migration {
} }
/// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar. /// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar.
const MIGRATIONS: &[Migration] = &[Migration { const MIGRATIONS: &[Migration] = &[
version: 1, Migration {
name: "initial", version: 1,
run: |db| Box::pin(m001_initial(db)), name: "initial",
}]; run: |db| Box::pin(m001_initial(db)),
},
Migration {
version: 2,
name: "default_assets",
run: |db| Box::pin(m002_default_assets(db)),
},
Migration {
version: 3,
name: "restore_points",
run: |db| Box::pin(m003_restore_points(db)),
},
Migration {
version: 4,
name: "debt_tracking",
run: |db| Box::pin(m004_debt_tracking(db)),
},
];
/// En son şema sürümü (JSON yedeğine yazılır; daha yeni yedek reddedilir).
pub fn latest_version() -> i32 {
MIGRATIONS.last().map_or(0, |m| m.version)
}
/// Uygulanmamış migration'ları sırayla çalıştırır. /// Uygulanmamış migration'ları sırayla çalıştırır.
pub async fn run(db: &Database) -> Result<()> { pub async fn run(db: &Database) -> Result<()> {
@@ -66,6 +88,64 @@ async fn m001_initial(db: &Database) -> Result<()> {
Ok(()) 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(())
}
/// v3: geri dönüş noktaları koleksiyonu (transaction içinde ilk yazımdan önce var olmalı).
async fn m003_restore_points(db: &Database) -> Result<()> {
if !db
.list_collection_names()
.await?
.iter()
.any(|n| n == col::RESTORE_POINTS)
{
db.create_collection(col::RESTORE_POINTS).await?;
}
Ok(())
}
/// v4: borç takibi koleksiyonları (transaction içinde ilk yazımdan önce var olmalı).
async fn m004_debt_tracking(db: &Database) -> Result<()> {
let existing = db.list_collection_names().await?;
for name in [
col::DEBT_SNAPSHOTS,
col::INSTALLMENT_PLANS,
col::DEBT_TARGETS,
] {
if !existing.iter().any(|n| n == name) {
db.create_collection(name).await?;
}
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+14
View File
@@ -70,7 +70,17 @@ pub mod col {
pub const ASSETS: &str = "assets"; pub const ASSETS: &str = "assets";
pub const ASSET_TRANSACTIONS: &str = "asset_transactions"; pub const ASSET_TRANSACTIONS: &str = "asset_transactions";
pub const EXCHANGE_RATES: &str = "exchange_rates"; pub const EXCHANGE_RATES: &str = "exchange_rates";
/// Ekstre içe aktarmada öğrenilen açıklama → tür/kategori kuralları
pub const IMPORT_RULES: &str = "import_rules";
pub const SCHEMA_MIGRATIONS: &str = "schema_migrations"; pub const SCHEMA_MIGRATIONS: &str = "schema_migrations";
/// JSON geri yüklemeden önce alınan, sıkıştırılmış hane kopyaları ("geri al")
pub const RESTORE_POINTS: &str = "restore_points";
/// Banka borcu ölçümleri (KMH, kart, kalan taksit, artı bakiye)
pub const DEBT_SNAPSHOTS: &str = "debt_snapshots";
/// Taksit planları (ilk taksit ayı + adet → aylık yük kendiliğinden)
pub const INSTALLMENT_PLANS: &str = "installment_plans";
/// Borç yol haritası: ay sonu toplam borç hedefleri
pub const DEBT_TARGETS: &str = "debt_targets";
/// Hane verisi taşıyan koleksiyonlar (`household_id` alanı var). /// Hane verisi taşıyan koleksiyonlar (`household_id` alanı var).
pub const HOUSEHOLD_SCOPED: &[&str] = &[ pub const HOUSEHOLD_SCOPED: &[&str] = &[
@@ -87,5 +97,9 @@ pub mod col {
NOTES, NOTES,
ASSETS, ASSETS,
ASSET_TRANSACTIONS, ASSET_TRANSACTIONS,
IMPORT_RULES,
DEBT_SNAPSHOTS,
INSTALLMENT_PLANS,
DEBT_TARGETS,
]; ];
} }
+24 -2
View File
@@ -35,6 +35,15 @@ pub enum AppError {
Unauthorized, Unauthorized,
#[error("E-posta veya şifre hatalı")] #[error("E-posta veya şifre hatalı")]
InvalidCredentials, InvalidCredentials,
/// Hatalı giriş; kilide kalan deneme sayısı mesajda
#[error("E-posta veya şifre hatalı. Kalan deneme hakkı: {0}")]
InvalidCredentialsLeft(u32),
/// IP kilidi (üst üste hatalı deneme); saniye cinsinden kalan süre
#[error("Çok fazla hatalı deneme yapıldı. Güvenlik için giriş ve kayıt {} dakika kapalı.", .0.div_ceil(60))]
Locked(u64),
/// Bot doğrulaması (Turnstile) geçilemedi
#[error("{0}")]
Captcha(String),
#[error("Bu işlem için yetkiniz yok")] #[error("Bu işlem için yetkiniz yok")]
Forbidden, Forbidden,
#[error("Yeni hane kaydı kapalı. Katılmak için hane sahibinden davet bağlantısı isteyin.")] #[error("Yeni hane kaydı kapalı. Katılmak için hane sahibinden davet bağlantısı isteyin.")]
@@ -55,7 +64,11 @@ impl AppError {
Self::NotFound => (StatusCode::NOT_FOUND, "not_found"), Self::NotFound => (StatusCode::NOT_FOUND, "not_found"),
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"), Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
Self::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"), Self::InvalidCredentials | Self::InvalidCredentialsLeft(_) => {
(StatusCode::UNAUTHORIZED, "invalid_credentials")
}
Self::Locked(_) => (StatusCode::TOO_MANY_REQUESTS, "locked"),
Self::Captcha(_) => (StatusCode::BAD_REQUEST, "captcha"),
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"), Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
Self::RegistrationClosed => (StatusCode::FORBIDDEN, "registration_closed"), Self::RegistrationClosed => (StatusCode::FORBIDDEN, "registration_closed"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"), Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
@@ -70,6 +83,10 @@ impl AppError {
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, code) = self.status_and_code(); let (status, code) = self.status_and_code();
let retry_after = match &self {
Self::Locked(secs) => Some(*secs),
_ => None,
};
let message = if status.is_server_error() { let message = if status.is_server_error() {
// Teknik detay sadece logda; kullanıcıya genel mesaj. // Teknik detay sadece logda; kullanıcıya genel mesaj.
@@ -85,7 +102,12 @@ impl IntoResponse for AppError {
message, message,
}, },
}; };
(status, Json(body)).into_response() let mut res = (status, Json(body)).into_response();
if let Some(secs) = retry_after {
res.headers_mut()
.insert(axum::http::header::RETRY_AFTER, secs.into());
}
res
} }
} }
+27 -4
View File
@@ -1,6 +1,6 @@
use std::{net::SocketAddr, process::ExitCode}; use std::{net::SocketAddr, process::ExitCode};
use kakeibo::{config::Config, db, routes, state::AppState}; use kakeibo::{config::Config, db, routes, services, state::AppState};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
#[tokio::main] #[tokio::main]
@@ -41,6 +41,25 @@ async fn main() -> ExitCode {
return ExitCode::FAILURE; return ExitCode::FAILURE;
} }
// Kurlar arka planda (açılışta hemen, sonra aralıkla); hata uygulamayı durdurmaz
services::rates::spawn_refresher(db.clone(), config.rates_refresh_minutes);
if config.turnstile_secret_key.is_some() {
tracing::info!("Bot doğrulaması (Cloudflare Turnstile) açık");
} else if config.is_https() {
tracing::warn!(
"Bot doğrulaması KAPALI: TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY tanımlayın (docs/DEPLOY.md)"
);
}
tracing::info!(
"İstemci IP'si: {}",
if config.trust_proxy {
"X-Forwarded-For (vekil arkasında, TRUST_PROXY=true)"
} else {
"bağlantı adresi (TRUST_PROXY=false)"
}
);
let port = config.port; let port = config.port;
let state = AppState::new(db, config); let state = AppState::new(db, config);
let app = routes::app(state); let app = routes::app(state);
@@ -57,9 +76,13 @@ async fn main() -> ExitCode {
}; };
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}"); tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
if let Err(e) = axum::serve(listener, app) // ConnectInfo: vekil yoksa istemci IP'si bağlantı adresinden (deneme kilidi)
.with_graceful_shutdown(shutdown_signal()) if let Err(e) = axum::serve(
.await listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await
{ {
tracing::error!("Sunucu hatası: {e}"); tracing::error!("Sunucu hatası: {e}");
return ExitCode::FAILURE; return ExitCode::FAILURE;
+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,
}
+10
View File
@@ -34,6 +34,16 @@ impl Group {
} }
} }
/// Türkçe ad (CSV dışa/içe aktarma; arayüz metinleri `frontend/src/lib/groups.ts`)
pub fn label(self) -> &'static str {
match self {
Group::Needs => "Zorunlu",
Group::Wants => "İstek",
Group::Culture => "Kültürel",
Group::Unexpected => "Beklenmeyen",
}
}
/// Ekranda gösterim sırası /// Ekranda gösterim sırası
pub fn order(self) -> usize { pub fn order(self) -> usize {
Self::ALL.iter().position(|g| *g == self).unwrap_or(0) Self::ALL.iter().position(|g| *g == self).unwrap_or(0)
+3
View File
@@ -1,11 +1,14 @@
//! Veritabanı modelleri. Her koleksiyonun yapısı, onu kullanan adımda eklenir. //! Veritabanı modelleri. Her koleksiyonun yapısı, onu kullanan adımda eklenir.
//! Şema: `docs/DATA_MODEL.md` //! Şema: `docs/DATA_MODEL.md`
pub mod asset;
pub mod common; pub mod common;
pub mod entry;
pub mod group; pub mod group;
pub mod household; pub mod household;
pub mod invite; pub mod invite;
pub mod note; pub mod note;
pub mod period; pub mod period;
pub mod plan_item; pub mod plan_item;
pub mod rate;
pub mod user; pub mod user;
+28 -2
View File
@@ -1,6 +1,6 @@
//! `periods` koleksiyonu (DATA_MODEL.md §3.4). //! `periods` koleksiyonu (DATA_MODEL.md §3.4).
use mongodb::bson::Uuid; use mongodb::bson::{DateTime, Uuid};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS; use ts_rs::TS;
@@ -10,8 +10,16 @@ use ts_rs::TS;
pub enum PeriodStatus { pub enum PeriodStatus {
/// Girişler yapılabilir. Hanede en fazla bir tane. /// Girişler yapılabilir. Hanede en fazla bir tane.
Open, Open,
/// Salt okunur (Faz 6: kapanış sihirbazı, sahip yeniden açabilir). /// Salt okunur. Sahip yeniden açarsa `Correcting` olur.
Closed, 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,
/// Geçmiş (arşiv): ekstre içe aktarımıyla yıl başından beri olan aylar için açılır.
/// Yalnızca kayıt tutar: plan/bütçe zorunlu değil, kapatılmaz, devir üretmez ve
/// devir zincirinde atlanır; bütçe önerilerini ("önceki dönem", "son 3 ay") besler.
Archived,
} }
impl PeriodStatus { impl PeriodStatus {
@@ -19,8 +27,15 @@ impl PeriodStatus {
match self { match self {
PeriodStatus::Open => "open", PeriodStatus::Open => "open",
PeriodStatus::Closed => "closed", PeriodStatus::Closed => "closed",
PeriodStatus::Correcting => "correcting",
PeriodStatus::Archived => "archived",
} }
} }
/// Kayıt eklenip değiştirilebilir mi?
pub fn is_writable(self) -> bool {
self != PeriodStatus::Closed
}
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -35,5 +50,16 @@ pub struct Period {
pub status: PeriodStatus, pub status: PeriodStatus,
/// Kuruş /// Kuruş
pub savings_goal: i64, 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>,
/// Elle girilen ay başı toplam banka borcu (kuruş); yoksa son ölçümden (borç takibi)
#[serde(default)]
pub opening_debt: Option<i64>,
pub version: i32, 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())))
}
+161 -13
View File
@@ -1,5 +1,11 @@
//! Kimlik doğrulama uç noktaları: //! Kimlik doğrulama uç noktaları:
//! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me` //! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`,
//! `PATCH /api/auth/me` (ad), `POST /api/auth/me/password` (şifre değiştir → diğer oturumlar kapanır),
//! `GET /api/auth/guard` (bot doğrulama anahtarı + bu IP'nin kilidi).
//!
//! **Güvenlik (giriş + kayıt):** Cloudflare Turnstile + IP başına üst üste 3 hatada 15 dk kilit
//! (`auth::guard`). Doğrulama hataları (`ValidJson`) sayılmaz; yanlış şifre, geçersiz davet,
//! kapalı kayıt, kayıtlı e-posta ve başarısız bot doğrulaması sayılır.
use axum::{Json, extract::State, http::StatusCode}; use axum::{Json, extract::State, http::StatusCode};
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
@@ -10,8 +16,12 @@ use validator::Validate;
use crate::{ use crate::{
auth::{ auth::{
AuthUser, password, AuthUser,
session::{removal_cookie, session_cookie, ttl}, captcha::CaptchaError,
client_ip::ClientIp,
guard::Outcome,
password,
session::{COOKIE_NAME, removal_cookie, session_cookie, ttl},
}, },
db::col, db::col,
error::{AppError, AppResult}, error::{AppError, AppResult},
@@ -41,6 +51,8 @@ pub struct RegisterRequest {
/// Verilirse yeni hane kurulmaz, davet eden haneye `member` olarak katılınır. /// Verilirse yeni hane kurulmaz, davet eden haneye `member` olarak katılınır.
#[validate(length(max = 20, message = "Davet kodu geçersiz"))] #[validate(length(max = 20, message = "Davet kodu geçersiz"))]
invite_code: Option<String>, invite_code: Option<String>,
/// Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
captcha_token: Option<String>,
} }
#[derive(Deserialize, Validate, TS)] #[derive(Deserialize, Validate, TS)]
@@ -52,6 +64,10 @@ pub struct LoginRequest {
password: String, password: String,
#[serde(default)] #[serde(default)]
remember_me: bool, remember_me: bool,
/// Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
#[serde(default)]
#[ts(optional)]
captcha_token: Option<String>,
} }
#[derive(Serialize, TS)] #[derive(Serialize, TS)]
@@ -77,13 +93,83 @@ struct HouseholdDto {
name: String, name: String,
} }
#[derive(Deserialize, Validate, TS)]
#[ts(export)]
pub struct ProfileRequest {
#[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))]
name: String,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export)]
pub struct PasswordChangeRequest {
#[validate(length(min = 1, message = "Mevcut şifreyi girin"))]
current_password: String,
#[validate(length(min = 8, max = 128, message = "Yeni şifre en az 8 karakter olmalı"))]
new_password: String,
}
/// Giriş/kayıt sayfası: bot doğrulaması açık mı, bu IP kilitli mi?
#[derive(Serialize, TS)]
#[ts(export)]
pub struct AuthGuardInfo {
/// Turnstile site anahtarı (herkese açık); `null` → doğrulama kapalı
captcha_site_key: Option<String>,
/// Kilit varsa kalan süre (sn); yoksa 0
#[ts(type = "number")]
locked_seconds: u64,
}
// ---------- Deneme kilidi + bot doğrulaması (giriş ve kayıt ortak) ----------
/// Kilitliyse 429; değilse bot doğrulaması (başarısızsa hata sayılır)
async fn precheck(state: &AppState, ip: &str, token: Option<&str>) -> AppResult<()> {
if let Some(secs) = state.guard.locked_for(ip) {
return Err(AppError::Locked(secs));
}
match state.captcha.verify(token, ip).await {
Ok(()) => Ok(()),
Err(e @ CaptchaError::Rejected) => {
if let Outcome::Locked(secs) = state.guard.failure(ip) {
tracing::warn!(ip, "deneme kilidi: bot doğrulaması");
return Err(AppError::Locked(secs));
}
Err(e.into_app_error())
}
Err(e) => Err(e.into_app_error()),
}
}
/// Hatalı denemeyi say; kilide ulaşıldıysa kilit hatası, değilse kalan hakla birlikte döner
fn count_failure(state: &AppState, ip: &str, what: &str) -> Result<u32, AppError> {
match state.guard.failure(ip) {
Outcome::Remaining(n) => Ok(n),
Outcome::Locked(secs) => {
tracing::warn!(ip, "deneme kilidi: {what}");
Err(AppError::Locked(secs))
}
}
}
// ---------- Uç noktalar ---------- // ---------- Uç noktalar ----------
pub async fn guard_info(
State(state): State<AppState>,
ClientIp(ip): ClientIp,
) -> Json<AuthGuardInfo> {
Json(AuthGuardInfo {
captcha_site_key: state.config.turnstile_site_key.clone(),
locked_seconds: state.guard.locked_for(&ip).unwrap_or(0),
})
}
pub async fn register( pub async fn register(
State(state): State<AppState>, State(state): State<AppState>,
ClientIp(ip): ClientIp,
jar: CookieJar, jar: CookieJar,
ValidJson(req): ValidJson<RegisterRequest>, ValidJson(req): ValidJson<RegisterRequest>,
) -> AppResult<(StatusCode, CookieJar, Json<MeResponse>)> { ) -> AppResult<(StatusCode, CookieJar, Json<MeResponse>)> {
precheck(&state, &ip, req.captcha_token.as_deref()).await?;
let name = req.name.trim().to_string(); let name = req.name.trim().to_string();
if name.is_empty() { if name.is_empty() {
return Err(AppError::BadRequest("Ad boş olamaz".into())); return Err(AppError::BadRequest("Ad boş olamaz".into()));
@@ -101,7 +187,7 @@ pub async fn register(
.filter(|c| !c.is_empty()); .filter(|c| !c.is_empty());
let password_hash = password::hash(req.password).await?; let password_hash = password::hash(req.password).await?;
let created = account::register( let created = match account::register(
&state.db, &state.db,
state.config.registration, state.config.registration,
NewAccount { NewAccount {
@@ -112,7 +198,19 @@ pub async fn register(
invite_code, invite_code,
}, },
) )
.await?; .await
{
Ok(c) => c,
// Davet kodu tahmini, kapalı kayıt denemesi, kayıtlı e-posta yoklama → hata sayılır
Err(
e @ (AppError::RegistrationClosed | AppError::BadRequest(_) | AppError::Conflict(_)),
) => {
count_failure(&state, &ip, "kayıt")?;
return Err(e);
}
Err(e) => return Err(e),
};
state.guard.success(&ip);
let token = state.jwt.issue( let token = state.jwt.issue(
created.user_id, created.user_id,
@@ -128,16 +226,13 @@ pub async fn register(
pub async fn login( pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
ClientIp(ip): ClientIp,
jar: CookieJar, jar: CookieJar,
ValidJson(req): ValidJson<LoginRequest>, ValidJson(req): ValidJson<LoginRequest>,
) -> AppResult<(CookieJar, Json<MeResponse>)> { ) -> AppResult<(CookieJar, Json<MeResponse>)> {
precheck(&state, &ip, req.captcha_token.as_deref()).await?;
let email = normalize_email(&req.email); let email = normalize_email(&req.email);
if !state.login_limiter.is_allowed(&email) {
tracing::warn!(%email, "giriş deneme sınırı aşıldı");
return Err(AppError::TooManyRequests);
}
let user = state let user = state
.db .db
.collection::<User>(col::USERS) .collection::<User>(col::USERS)
@@ -146,12 +241,12 @@ pub async fn login(
let hash = user.as_ref().map(|u| u.password_hash.clone()); let hash = user.as_ref().map(|u| u.password_hash.clone());
if !password::verify(req.password, hash).await { if !password::verify(req.password, hash).await {
state.login_limiter.record_failure(&email); let left = count_failure(&state, &ip, "giriş")?;
return Err(AppError::InvalidCredentials); return Err(AppError::InvalidCredentialsLeft(left));
} }
// verify() true döndüyse kullanıcı kesin vardır // verify() true döndüyse kullanıcı kesin vardır
let user = user.ok_or(AppError::InvalidCredentials)?; let user = user.ok_or(AppError::InvalidCredentials)?;
state.login_limiter.reset(&email); state.guard.success(&ip);
state state
.db .db
@@ -200,6 +295,59 @@ pub async fn me(State(state): State<AppState>, auth: AuthUser) -> AppResult<Json
.map(Json) .map(Json)
} }
pub async fn update_me(
State(state): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<ProfileRequest>,
) -> AppResult<Json<MeResponse>> {
account::update_name(&state.db, auth.household_id, auth.user_id, &req.name).await?;
load_me(&state, auth.user_id, auth.household_id)
.await
.map(Json)
}
/// Şifre değiştir: mevcut şifre doğrulanır (deneme sınırlı), diğer cihazlardaki oturumlar
/// kapanır, bu cihaza yeni oturum çerezi verilir ("beni hatırla" durumu korunur).
pub async fn change_password(
State(state): State<AppState>,
jar: CookieJar,
auth: AuthUser,
ValidJson(req): ValidJson<PasswordChangeRequest>,
) -> AppResult<(StatusCode, CookieJar)> {
let key = format!("password:{}", auth.user_id);
if !state.login_limiter.is_allowed(&key) {
return Err(AppError::TooManyRequests);
}
let remember = jar
.get(COOKIE_NAME)
.and_then(|c| state.jwt.verify(c.value()))
.is_some_and(|c| c.exp.saturating_sub(c.iat) > ttl(false).as_secs());
match account::change_password(
&state.db,
auth.household_id,
auth.user_id,
req.current_password,
req.new_password,
)
.await
{
Ok(()) => state.login_limiter.reset(&key),
Err(e) => {
if matches!(&e, AppError::BadRequest(m) if m.contains("Mevcut şifre")) {
state.login_limiter.record_failure(&key);
}
return Err(e);
}
}
let token = state
.jwt
.issue(auth.user_id, auth.household_id, auth.role, ttl(remember))?;
let jar = jar.add(session_cookie(token, remember, state.config.is_https()));
Ok((StatusCode::NO_CONTENT, jar))
}
// ---------- Yardımcılar ---------- // ---------- Yardımcılar ----------
fn normalize_email(email: &str) -> String { fn normalize_email(email: &str) -> String {
+219
View File
@@ -0,0 +1,219 @@
//! JSON tam hane yedeği (`services::backup`).
//!
//! | Yöntem | Yol | Kim | İş |
//! |---|---|---|---|
//! | GET | `/api/backup/download` | üye | yedek dosyası (indirme) |
//! | GET | `/api/backup/status` | üye | son yedek zamanı + geri dönüş noktaları (yalnız sahip görür) |
//! | POST | `/api/backup/preview` | sahip | gövde: yedek dosyası → ne değişecek (hiçbir şey yazılmaz) |
//! | POST | `/api/backup/restore` | sahip | gövde: yedek dosyası → hane verisi değiştirilir |
//! | POST | `/api/backup/restore-points/{id}/restore` | sahip | geri dönüş noktasına dön ("geri al") |
use axum::{
Json,
body::Bytes,
extract::{Path, State},
http::header,
response::IntoResponse,
};
use mongodb::bson::DateTime;
use serde::Serialize;
use ts_rs::TS;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
models::user::Role,
services::{
backup::{self, Reason},
calendar::{format_date, today_tr},
},
state::AppState,
};
fn iso(d: DateTime) -> String {
d.try_to_rfc3339_string().unwrap_or_default()
}
fn parse_id(id: &str) -> AppResult<mongodb::bson::Uuid> {
mongodb::bson::Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
pub async fn download(State(s): State<AppState>, auth: AuthUser) -> AppResult<impl IntoResponse> {
let snap = backup::collect(&s.db, auth.household_id).await?;
let body = backup::to_json(&snap)?;
backup::mark_exported(&s.db, auth.household_id).await?;
let file = format!(
"kakeibo-yedek-{}-{}.json",
backup::slug(&snap.household_name),
format_date(today_tr())
);
Ok((
[
(
header::CONTENT_TYPE,
"application/json; charset=utf-8".to_string(),
),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{file}\""),
),
(header::CACHE_CONTROL, "no-store".to_string()),
],
body,
))
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct RestorePoint {
id: String,
/// RFC 3339
created_at: String,
/// `before_restore`: dosyadan geri yüklemeden önce · `before_undo`: geri almadan önce
#[ts(type = "\"before_restore\" | \"before_undo\"")]
reason: String,
#[ts(type = "number")]
periods: i64,
#[ts(type = "number")]
entries: i64,
/// Sıkıştırılmış boyut (bayt)
#[ts(type = "number")]
size: i64,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct BackupStatus {
/// Son dışa aktarma (RFC 3339); hiç alınmadıysa `null`
last_backup_at: Option<String>,
/// En yeniden eskiye; sahip olmayana boş
restore_points: Vec<RestorePoint>,
keep_points: u32,
keep_days: u32,
}
pub async fn status(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<BackupStatus>> {
let points = if auth.role == Role::Owner {
backup::points(&s.db, auth.household_id).await?
} else {
Vec::new()
};
Ok(Json(BackupStatus {
last_backup_at: backup::last_exported(&s.db, auth.household_id)
.await?
.map(iso),
restore_points: points
.into_iter()
.map(|p| RestorePoint {
id: p.id.to_string(),
created_at: iso(p.created_at),
reason: p.reason,
periods: p.periods,
entries: p.entries,
size: p.size,
})
.collect(),
keep_points: backup::KEEP_POINTS as u32,
keep_days: backup::KEEP_DAYS as u32,
}))
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct BackupCount {
/// Koleksiyon adı (`entries`, `periods`, …)
kind: String,
/// Türkçe ad
label: String,
#[ts(type = "number")]
backup: i64,
#[ts(type = "number")]
current: i64,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct BackupPreview {
source_name: String,
same_household: bool,
/// RFC 3339
exported_at: String,
app_version: String,
counts: Vec<BackupCount>,
/// Yedekteki dönemlerin ilk ve son günü
first_date: Option<String>,
last_date: Option<String>,
/// Aynı hane: yedekten sonra eklenen/değişen (kaybolacak) kayıt
#[ts(type = "number")]
changed_since: i64,
warnings: Vec<String>,
}
pub async fn preview(
State(s): State<AppState>,
auth: AuthUser,
body: Bytes,
) -> AppResult<Json<BackupPreview>> {
auth.require_owner()?;
let snap = backup::parse(&body)?;
let p = backup::preview(&s.db, auth.household_id, &snap).await?;
Ok(Json(BackupPreview {
source_name: p.source_name,
same_household: p.same_household,
exported_at: iso(p.exported_at),
app_version: p.app_version,
counts: p
.counts
.into_iter()
.map(|c| BackupCount {
kind: c.kind.into(),
label: c.label.into(),
backup: c.backup,
current: c.current,
})
.collect(),
first_date: p.first_date,
last_date: p.last_date,
changed_since: p.changed_since,
warnings: p.warnings,
}))
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct RestoreResult {
/// İşlemden önceki durumun geri dönüş noktası ("Geri al" bununla)
restore_point_id: String,
}
pub async fn restore(
State(s): State<AppState>,
auth: AuthUser,
body: Bytes,
) -> AppResult<Json<RestoreResult>> {
auth.require_owner()?;
let snap = backup::parse(&body)?;
let id = backup::restore(
&s.db,
auth.household_id,
auth.user_id,
&snap,
Reason::BeforeRestore,
)
.await?;
Ok(Json(RestoreResult {
restore_point_id: id.to_string(),
}))
}
pub async fn undo(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<RestoreResult>> {
auth.require_owner()?;
let id = backup::undo(&s.db, auth.household_id, auth.user_id, parse_id(&id)?).await?;
Ok(Json(RestoreResult {
restore_point_id: id.to_string(),
}))
}
+221
View File
@@ -0,0 +1,221 @@
//! Banka / kart ekstresi içe aktarma (hanenin tüm üyeleri). Dosya tarayıcıda okunur;
//! buraya eşleştirilmiş satırlar gelir.
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | POST | `/api/bank-import/preview` | satır başına öneri (tür, kategori), tekrar, dönem; açılacak arşiv dönemleri |
//! | POST | `/api/bank-import/commit` | kullanıcının seçimiyle aktar; seçimleri kural olarak öğren |
use axum::{Json, extract::State};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
extract::ValidJson,
services::{
bank_import::{self, Direction, Kind, Row, Source, Target},
calendar::{default_period_name, format_date, parse_date},
},
state::AppState,
};
#[derive(Deserialize, TS)]
#[ts(export, rename = "BankRowIn")]
pub struct RowIn {
/// `"YYYY-MM-DD"`
date: String,
description: String,
/// Kuruş, > 0
#[ts(type = "number")]
amount: i64,
#[ts(type = "\"in\" | \"out\"")]
direction: Direction,
/// Yalnızca aktarmada (kullanıcının seçimi)
#[ts(type = "\"expense\" | \"refund\" | \"income\" | \"ignore\"", optional)]
kind: Option<Kind>,
#[ts(optional)]
category_id: Option<String>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "BankPreviewRequest")]
pub struct PreviewRequest {
#[ts(type = "\"card\" | \"account\"")]
source: Source,
rows: Vec<RowIn>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "BankCommitRequest", optional_fields)]
pub struct CommitRequest {
/// Kayıtlara yazılacak ödeme türü (ör. Kredi Kartı)
payment_method_id: Option<String>,
rows: Vec<RowIn>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "BankPreviewRow")]
struct PreviewRowDto {
#[ts(type = "\"expense\" | \"refund\" | \"income\" | \"ignore\"")]
kind: &'static str,
category_id: Option<String>,
/// Öneri hanenin öğrendiği kuraldan
from_rule: bool,
/// Daha önce aktarılmış (atlanacak)
duplicate: bool,
/// `existing` · `archive` · `blocked`
#[ts(type = "\"existing\" | \"archive\" | \"blocked\"")]
period: &'static str,
/// Dönem adı (var olan ya da açılacak arşiv)
period_name: Option<String>,
/// Engel nedeni
message: Option<String>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "BankPreview")]
pub struct PreviewDto {
rows: Vec<PreviewRowDto>,
/// Açılacak arşiv dönemleri
archives: Vec<ArchiveDto>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "BankArchive")]
struct ArchiveDto {
name: String,
start_date: String,
/// Son gün, dahil
last_date: String,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "BankImportReport")]
pub struct ReportDto {
entries: u32,
refunds: u32,
incomes: u32,
ignored: u32,
duplicates: u32,
/// Açılan arşiv dönemleri
archives: Vec<String>,
errors: Vec<RowError>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "BankRowError")]
struct RowError {
/// Satır sırası (0'dan)
index: u32,
message: String,
}
fn rows_of(rows: Vec<RowIn>) -> AppResult<Vec<Row>> {
rows.into_iter()
.map(|r| {
let category_id = match r.category_id.as_deref().map(str::trim) {
None | Some("") => None,
Some(id) => Some(
Uuid::parse_str(id)
.map_err(|_| AppError::BadRequest("Kategori geçersiz".into()))?,
),
};
Ok(Row {
date: r.date.trim().to_string(),
description: r.description,
amount: r.amount,
direction: r.direction,
kind: r.kind,
category_id,
})
})
.collect()
}
fn archive_name(start: &str, end: &str) -> String {
match (parse_date(start), parse_date(end)) {
(Some(s), Some(e)) => default_period_name(s, e),
_ => start.to_string(),
}
}
pub async fn preview(
State(s): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<PreviewRequest>,
) -> AppResult<Json<PreviewDto>> {
let rows = rows_of(req.rows)?;
let p = bank_import::preview(&s.db, auth.household_id, req.source, &rows).await?;
Ok(Json(PreviewDto {
rows: p
.rows
.into_iter()
.map(|r| {
let (period, period_name, message) = match r.target {
Target::Existing { name, .. } => ("existing", Some(name), None),
Target::Archive { start, end } => {
("archive", Some(archive_name(&start, &end)), None)
}
Target::Blocked(m) => ("blocked", None, Some(m)),
};
PreviewRowDto {
kind: r.kind.as_str(),
category_id: r.category_id.map(|c| c.to_string()),
from_rule: r.from_rule,
duplicate: r.duplicate,
period,
period_name,
message,
}
})
.collect(),
archives: p
.archives
.into_iter()
.map(|(start, end)| {
let last = parse_date(&end)
.map_or(end.clone(), |e| format_date(e - time::Duration::days(1)));
ArchiveDto {
name: archive_name(&start, &end),
start_date: start,
last_date: last,
}
})
.collect(),
}))
}
pub async fn commit(
State(s): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<CommitRequest>,
) -> AppResult<Json<ReportDto>> {
let pm = match req.payment_method_id.as_deref().map(str::trim) {
None | Some("") => None,
Some(id) => Some(
Uuid::parse_str(id).map_err(|_| AppError::BadRequest("Ödeme türü geçersiz".into()))?,
),
};
let rows = rows_of(req.rows)?;
let r = bank_import::commit(&s.db, auth.household_id, auth.user_id, pm, &rows).await?;
Ok(Json(ReportDto {
entries: r.entries as u32,
refunds: r.refunds as u32,
incomes: r.incomes as u32,
ignored: r.ignored as u32,
duplicates: r.duplicates as u32,
archives: r.archives,
errors: r
.errors
.into_iter()
.map(|(i, message)| RowError {
index: i as u32,
message,
})
.collect(),
}))
}
+18 -2
View File
@@ -2,8 +2,8 @@
//! //!
//! | Yöntem | Yol | İş | //! | Yöntem | Yol | İş |
//! |---|---|---| //! |---|---|---|
//! | GET | `/api/periods/{id}/summary` | gelir/gider/birikim/devir → harcanabilir | //! | 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 | //! | 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` | 4 grubun aylık tutarları |
//! | PUT | `/api/periods/{id}/budget/weeks` | bir haftayı elle ayarla / otomatiğe döndür | //! | 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` | //! | 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ış) /// harcanabilir − dağıtılan (negatifse fazla dağıtılmış)
#[ts(type = "number")] #[ts(type = "number")]
unallocated: i64, 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)] #[derive(Serialize, TS)]
@@ -89,6 +95,9 @@ pub struct GroupBudgetDto {
group: Group, group: Group,
#[ts(type = "number")] #[ts(type = "number")]
monthly: i64, monthly: i64,
/// Dönem boyunca gerçekleşen harcama
#[ts(type = "number")]
spent: i64,
/// Elle girilen haftaların toplamı aylık tutarı aşıyor /// Elle girilen haftaların toplamı aylık tutarı aşıyor
weekly_over: bool, weekly_over: bool,
weeks: Vec<WeekBudgetDto>, weeks: Vec<WeekBudgetDto>,
@@ -105,6 +114,9 @@ struct WeekBudgetDto {
amount: i64, amount: i64,
/// Elle girildi (otomatik değil) /// Elle girildi (otomatik değil)
is_manual: bool, is_manual: bool,
/// Bu haftada gerçekleşen harcama
#[ts(type = "number")]
spent: i64,
} }
impl From<GroupBudget> for GroupBudgetDto { impl From<GroupBudget> for GroupBudgetDto {
@@ -112,6 +124,7 @@ impl From<GroupBudget> for GroupBudgetDto {
Self { Self {
group: g.group, group: g.group,
monthly: g.monthly, monthly: g.monthly,
spent: g.spent,
weekly_over: g.weekly_over, weekly_over: g.weekly_over,
weeks: g weeks: g
.weeks .weeks
@@ -123,6 +136,7 @@ impl From<GroupBudget> for GroupBudgetDto {
days: w.days, days: w.days,
amount: w.amount, amount: w.amount,
is_manual: w.is_manual, is_manual: w.is_manual,
spent: w.spent,
}) })
.collect(), .collect(),
} }
@@ -182,6 +196,8 @@ pub async fn summary(
spendable: x.spendable, spendable: x.spendable,
budget_total: x.budget_total, budget_total: x.budget_total,
unallocated: x.spendable - x.budget_total, unallocated: x.spendable - x.budget_total,
spent_total: x.spent_total,
remaining: x.spendable - x.spent_total,
})) }))
} }
+486
View File
@@ -0,0 +1,486 @@
//! Borç takibi (`services::debt`, docs/BORC_TAKIBI.md). Hanenin tüm üyeleri.
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/debt` | özet: son ölçüm, değişim, ilerleme, tahmini bitiş, ölçümler, taksitler, hedefler, 6 aylık yük |
//! | POST | `/api/debt/snapshots` · PATCH/DELETE `/api/debt/snapshots/{id}` | ölçüm |
//! | POST | `/api/debt/installments` · PATCH/DELETE `/api/debt/installments/{id}` | taksit planı |
//! | PUT | `/api/debt/targets` | yol haritası (liste olduğu gibi kaydedilir) |
//! | POST | `/api/debt/import?commit=false\|true` | kaynak belgedeki JSON → ön izleme / aktarma |
//! | GET | `/api/periods/{id}/debt` | dönemin ay başı borcu, devreden, taksit yükü, hedef |
//! | PUT | `/api/periods/{id}/opening-debt` | elle ay başı toplam borç (`null` → son ölçümden) |
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{
auth::AuthUser,
db::col,
error::{AppError, AppResult},
services::{
calendar::{format_date, parse_date, today_tr},
debt::{self, InstallmentPlan, PlanInput, Snapshot, SnapshotInput},
period,
},
state::AppState,
};
fn parse_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
// ---------- DTO ----------
#[derive(Serialize, TS, Clone)]
#[ts(export, rename = "DebtSnapshot")]
pub struct SnapshotDto {
id: String,
date: String,
#[ts(type = "number")]
kmh: i64,
#[ts(type = "number")]
positive_balance: i64,
#[ts(type = "number")]
card_balance: i64,
#[ts(type = "number")]
remaining_installments: i64,
/// Hesaplanır: KMH + kart + kalan taksit − artı bakiye
#[ts(type = "number")]
total: i64,
/// Bir önceki ölçüme göre
#[ts(type = "number | null")]
change: Option<i64>,
note: Option<String>,
source: String,
version: i32,
}
fn snap_dto(s: &Snapshot, prev: Option<&Snapshot>) -> SnapshotDto {
SnapshotDto {
id: s.id.to_string(),
date: s.date.clone(),
kmh: s.kmh,
positive_balance: s.positive_balance,
card_balance: s.card_balance,
remaining_installments: s.remaining_installments,
total: s.total(),
change: prev.map(|p| s.total() - p.total()),
note: s.note.clone(),
source: s.source.clone(),
version: s.version,
}
}
#[derive(Serialize, TS)]
#[ts(export, rename = "InstallmentPlan")]
pub struct PlanDto {
id: String,
merchant: String,
#[ts(type = "number")]
total_amount: i64,
#[ts(type = "number")]
monthly_amount: i64,
total_count: i32,
/// `"YYYY-MM"`
first_month: String,
last_month: String,
/// Bu ay dahil ekstreye gelen
paid_count: i32,
remaining_count: i32,
/// Kalan taksitlerin toplamı (bu aydan sonra)
#[ts(type = "number")]
remaining_amount: i64,
version: i32,
}
fn plan_dto(p: &InstallmentPlan, month: i32) -> PlanDto {
PlanDto {
id: p.id.to_string(),
merchant: p.merchant.clone(),
total_amount: p.total_amount,
monthly_amount: p.monthly_amount,
total_count: p.total_count,
first_month: p.first_month.clone(),
last_month: p.last_month().unwrap_or_default(),
paid_count: p.paid_count(month),
remaining_count: p.remaining_count(month),
remaining_amount: i64::from(p.remaining_count(month)) * p.monthly_amount,
version: p.version,
}
}
#[derive(Serialize, Deserialize, TS)]
#[ts(export, rename = "DebtTarget")]
pub struct TargetDto {
/// `"YYYY-MM"`
month: String,
/// Ay sonu toplam borç hedefi (kuruş)
#[ts(type = "number")]
closing_target: i64,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "MonthLoad")]
pub struct MonthLoadDto {
month: String,
#[ts(type = "number")]
amount: i64,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct DebtSummary {
/// En yeni önce
snapshots: Vec<SnapshotDto>,
latest: Option<SnapshotDto>,
/// Son ölçüm − önceki ayın son ölçümü (eksi → azaldı)
#[ts(type = "number | null")]
change_vs_last_month: Option<i64>,
/// İlk ölçüm (yolculuğun başı)
start: Option<SnapshotDto>,
/// 0..1
progress: Option<f64>,
/// `"YYYY-MM"`
estimated_end: Option<String>,
installments: Vec<PlanDto>,
targets: Vec<TargetDto>,
/// Bu ay dahil 6 ay
schedule: Vec<MonthLoadDto>,
/// Bu ay (`"YYYY-MM"`)
this_month: String,
}
// ---------- Özet ----------
pub async fn summary(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<DebtSummary>> {
let today = format_date(today_tr());
let x = debt::summary(&s.db, auth.household_id, &today).await?;
let list: Vec<SnapshotDto> = x
.snapshots
.iter()
.enumerate()
.map(|(i, sn)| snap_dto(sn, i.checked_sub(1).map(|j| &x.snapshots[j])))
.rev()
.collect();
Ok(Json(DebtSummary {
latest: list.first().cloned(),
start: x.start.as_ref().map(|s0| snap_dto(s0, None)),
snapshots: list,
change_vs_last_month: x.change_vs_last_month,
progress: x.progress,
estimated_end: x.estimated_end,
installments: x.plans.iter().map(|p| plan_dto(p, x.this_month)).collect(),
targets: x
.targets
.into_iter()
.map(|t| TargetDto {
month: t.month,
closing_target: t.closing_target,
})
.collect(),
schedule: x
.schedule
.into_iter()
.map(|(month, amount)| MonthLoadDto { month, amount })
.collect(),
this_month: debt::month_name(x.this_month),
}))
}
// ---------- Ölçümler ----------
#[derive(Deserialize, TS)]
#[ts(export, rename = "DebtSnapshotRequest", optional_fields)]
pub struct SnapshotRequest {
/// `"YYYY-MM-DD"`
date: Option<String>,
#[ts(type = "number")]
kmh: Option<i64>,
#[ts(type = "number")]
positive_balance: Option<i64>,
#[ts(type = "number")]
card_balance: Option<i64>,
#[ts(type = "number")]
remaining_installments: Option<i64>,
/// Boş metin → notu kaldır
note: Option<String>,
version: Option<i32>,
}
impl From<SnapshotRequest> for SnapshotInput {
fn from(r: SnapshotRequest) -> Self {
SnapshotInput {
date: r.date,
kmh: r.kmh,
positive_balance: r.positive_balance,
card_balance: r.card_balance,
remaining_installments: r.remaining_installments,
note: r.note.map(Some),
version: r.version,
}
}
}
pub async fn create_snapshot(
State(s): State<AppState>,
auth: AuthUser,
Json(req): Json<SnapshotRequest>,
) -> AppResult<(StatusCode, Json<SnapshotDto>)> {
let sn = debt::create_snapshot(&s.db, auth.household_id, req.into(), "manual").await?;
Ok((StatusCode::CREATED, Json(snap_dto(&sn, None))))
}
pub async fn update_snapshot(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
Json(req): Json<SnapshotRequest>,
) -> AppResult<Json<SnapshotDto>> {
let sn = debt::update_snapshot(&s.db, auth.household_id, parse_id(&id)?, req.into()).await?;
Ok(Json(snap_dto(&sn, None)))
}
pub async fn delete_snapshot(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
debt::delete(
&s.db,
col::DEBT_SNAPSHOTS,
auth.household_id,
parse_id(&id)?,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ---------- Taksitler ----------
#[derive(Deserialize, TS)]
#[ts(export, rename = "InstallmentRequest", optional_fields)]
pub struct PlanRequest {
merchant: Option<String>,
#[ts(type = "number")]
total_amount: Option<i64>,
#[ts(type = "number")]
monthly_amount: Option<i64>,
total_count: Option<i32>,
/// `"YYYY-MM"`
first_month: Option<String>,
version: Option<i32>,
}
impl From<PlanRequest> for PlanInput {
fn from(r: PlanRequest) -> Self {
PlanInput {
merchant: r.merchant,
total_amount: r.total_amount,
monthly_amount: r.monthly_amount,
total_count: r.total_count,
first_month: r.first_month,
version: r.version,
}
}
}
fn this_month() -> i32 {
debt::month_index(debt::month_of(&format_date(today_tr()))).unwrap_or(0)
}
pub async fn create_plan(
State(s): State<AppState>,
auth: AuthUser,
Json(req): Json<PlanRequest>,
) -> AppResult<(StatusCode, Json<PlanDto>)> {
let p = debt::create_plan(&s.db, auth.household_id, req.into()).await?;
Ok((StatusCode::CREATED, Json(plan_dto(&p, this_month()))))
}
pub async fn update_plan(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
Json(req): Json<PlanRequest>,
) -> AppResult<Json<PlanDto>> {
let p = debt::update_plan(&s.db, auth.household_id, parse_id(&id)?, req.into()).await?;
Ok(Json(plan_dto(&p, this_month())))
}
pub async fn delete_plan(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
debt::delete(
&s.db,
col::INSTALLMENT_PLANS,
auth.household_id,
parse_id(&id)?,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ---------- Hedefler ----------
#[derive(Deserialize, TS)]
#[ts(export, rename = "DebtTargetsRequest")]
pub struct TargetsRequest {
targets: Vec<TargetDto>,
}
pub async fn set_targets(
State(s): State<AppState>,
auth: AuthUser,
Json(req): Json<TargetsRequest>,
) -> AppResult<StatusCode> {
debt::set_targets(
&s.db,
auth.household_id,
req.targets
.into_iter()
.map(|t| (t.month, t.closing_target))
.collect(),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ---------- İçe aktarma ----------
#[derive(Deserialize)]
pub struct ImportQuery {
#[serde(default)]
commit: bool,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "DebtImportRow")]
pub struct ImportRowDto {
/// `snapshot` | `installment` | `target`
kind: String,
label: String,
#[ts(type = "number")]
amount: i64,
/// `new` | `exists` | `update` | `error`
status: String,
message: Option<String>,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct DebtImportReport {
committed: bool,
rows: Vec<ImportRowDto>,
errors: Vec<String>,
}
pub async fn import(
State(s): State<AppState>,
auth: AuthUser,
Query(q): Query<ImportQuery>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<DebtImportReport>> {
let (rows, errors) = debt::import(&s.db, auth.household_id, &body, q.commit).await?;
Ok(Json(DebtImportReport {
committed: q.commit,
rows: rows
.into_iter()
.map(|r| ImportRowDto {
kind: r.kind.into(),
label: r.label,
amount: r.amount,
status: r.status.into(),
message: r.message,
})
.collect(),
errors,
}))
}
// ---------- Dönem ----------
#[derive(Serialize, TS)]
#[ts(export)]
pub struct PeriodDebt {
/// Ay başı toplam borç
#[ts(type = "number | null")]
opening_total: Option<i64>,
/// `manual` | `snapshot` | `none`
opening_source: String,
opening_date: Option<String>,
/// Devreden borç = ay başı toplam − kalan taksitler
#[ts(type = "number | null")]
carried: Option<i64>,
#[ts(type = "number")]
installment_load: i64,
/// Borç ödemesi (dönemin birikim hedefi)
#[ts(type = "number")]
payment: i64,
/// Ay başı − borç ödemesi − taksit yükü
#[ts(type = "number | null")]
closing_target: Option<i64>,
#[ts(type = "number | null")]
roadmap_target: Option<i64>,
latest_date: Option<String>,
#[ts(type = "number | null")]
latest_total: Option<i64>,
}
pub async fn period_debt(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<PeriodDebt>> {
let hid = auth.household_id;
let p = period::get(&s.db, hid, parse_id(&id)?).await?;
let last = parse_date(&p.end_date)
.map(|d| format_date(d - time::Duration::days(1)))
.unwrap_or_else(|| p.end_date.clone());
let d = debt::period_debt(
&p,
&last,
&debt::snapshots(&s.db, hid).await?,
&debt::plans(&s.db, hid).await?,
&debt::targets(&s.db, hid).await?,
);
Ok(Json(PeriodDebt {
opening_total: d.opening_total,
opening_source: d.opening_source.into(),
opening_date: d.opening_date,
carried: d.carried,
installment_load: d.installment_load,
payment: d.payment,
closing_target: d.closing_target,
roadmap_target: d.roadmap_target,
latest_date: d.latest_in_period.as_ref().map(|x| x.0.clone()),
latest_total: d.latest_in_period.map(|x| x.1),
}))
}
#[derive(Deserialize, TS)]
#[ts(export, rename = "OpeningDebtRequest")]
pub struct OpeningRequest {
/// Kuruş; `null` → son ölçümden
#[ts(type = "number | null")]
amount: Option<i64>,
}
pub async fn set_opening(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
Json(req): Json<OpeningRequest>,
) -> AppResult<StatusCode> {
let pid = parse_id(&id)?;
period::get_open(&s.db, auth.household_id, pid).await?; // kapalı dönem değişmez
debt::set_opening(&s.db, auth.household_id, pid, req.amount).await?;
Ok(StatusCode::NO_CONTENT)
}
+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()))
}
+105 -2
View File
@@ -3,6 +3,8 @@
//! - `GET /api/household/invites` → aktif davetler (yalnızca owner) //! - `GET /api/household/invites` → aktif davetler (yalnızca owner)
//! - `POST /api/household/invites` → yeni davet kodu (yalnızca owner) //! - `POST /api/household/invites` → yeni davet kodu (yalnızca owner)
//! - `DELETE /api/household/invites/{id}` → daveti iptal et (yalnızca owner) //! - `DELETE /api/household/invites/{id}` → daveti iptal et (yalnızca owner)
//! - `PATCH /api/household` → ad + bütçe varsayılanları (yalnızca owner)
//! - `DELETE /api/household/members/{id}` → üyeyi çıkar (yalnızca owner)
use axum::{ use axum::{
Json, Json,
@@ -12,13 +14,15 @@ use axum::{
use mongodb::bson::{DateTime, Uuid, doc}; use mongodb::bson::{DateTime, Uuid, doc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS; use ts_rs::TS;
use validator::Validate;
use crate::{ use crate::{
auth::AuthUser, auth::AuthUser,
db::col, db::col,
error::{AppError, AppResult}, error::{AppError, AppResult},
extract::ValidJson,
models::{common::scope, household::Household, invite::Invite, user::Role}, models::{common::scope, household::Household, invite::Invite, user::Role},
services::invite, services::{account, invite},
state::AppState, state::AppState,
}; };
@@ -28,6 +32,41 @@ pub struct HouseholdResponse {
id: String, id: String,
name: String, name: String,
members: Vec<MemberDto>, members: Vec<MemberDto>,
settings: SettingsDto,
}
#[derive(Serialize, Deserialize, Validate, TS, Clone, Copy)]
#[ts(export, rename = "BudgetSplitPct")]
pub struct SplitDto {
#[validate(range(max = 100))]
needs: u32,
#[validate(range(max = 100))]
wants: u32,
#[validate(range(max = 100))]
culture: u32,
#[validate(range(max = 100))]
unexpected: u32,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "HouseholdSettings")]
struct SettingsDto {
/// Varsayılan bütçe dağılımı (yüzde, toplam 100)
budget_split_pct: SplitDto,
/// Plan ekranında önce seçili öneri yöntemi
#[ts(type = "\"default\" | \"previous\" | \"avg3\"")]
budget_suggestion: String,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, optional_fields)]
pub struct HouseholdUpdateRequest {
#[validate(length(min = 1, max = 80, message = "Hane adı 1–80 karakter olmalı"))]
name: Option<String>,
#[validate(nested)]
budget_split_pct: Option<SplitDto>,
#[ts(type = "\"default\" | \"previous\" | \"avg3\"", optional)]
budget_suggestion: Option<String>,
} }
#[derive(Serialize, TS)] #[derive(Serialize, TS)]
@@ -78,9 +117,16 @@ pub async fn get(
let household = state let household = state
.db .db
.collection::<Household>(col::HOUSEHOLDS) .collection::<Household>(col::HOUSEHOLDS)
.find_one(hh_filter) .find_one(hh_filter.clone())
.await? .await?
.ok_or(AppError::NotFound)?; .ok_or(AppError::NotFound)?;
let settings = state
.db
.collection::<mongodb::bson::Document>(col::HOUSEHOLDS)
.find_one(hh_filter)
.await?
.and_then(|d| d.get_document("settings").ok().cloned())
.unwrap_or_default();
let mut cursor = state let mut cursor = state
.db .db
@@ -105,9 +151,66 @@ pub async fn get(
id: hid.to_string(), id: hid.to_string(),
name: household.name, name: household.name,
members, members,
settings: settings_dto(&settings),
})) }))
} }
fn settings_dto(s: &mongodb::bson::Document) -> SettingsDto {
let pct = s.get_document("budget_split_pct").ok();
let v = |k: &str, d: u32| {
pct.and_then(|p| match p.get(k) {
Some(mongodb::bson::Bson::Int32(v)) => u32::try_from(*v).ok(),
Some(mongodb::bson::Bson::Int64(v)) => u32::try_from(*v).ok(),
_ => None,
})
.unwrap_or(d)
};
SettingsDto {
budget_split_pct: SplitDto {
needs: v("needs", 50),
wants: v("wants", 30),
culture: v("culture", 10),
unexpected: v("unexpected", 10),
},
budget_suggestion: s
.get_str("budget_suggestion")
.unwrap_or("default")
.to_string(),
}
}
pub async fn update(
State(state): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<HouseholdUpdateRequest>,
) -> AppResult<Json<HouseholdResponse>> {
auth.require_owner()?;
account::update_household(
&state.db,
auth.household_id,
account::HouseholdChanges {
name: req.name,
split_pct: req
.budget_split_pct
.map(|p| [p.needs, p.wants, p.culture, p.unexpected]),
suggestion: req.budget_suggestion,
},
)
.await?;
get(State(state), auth).await
}
pub async fn remove_member(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
auth.require_owner()?;
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
account::remove_member(&state.db, auth.household_id, auth.user_id, id).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn list_invites( pub async fn list_invites(
State(state): State<AppState>, State(state): State<AppState>,
auth: AuthUser, auth: AuthUser,
+95 -2
View File
@@ -1,17 +1,29 @@
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında. //! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
mod asset;
mod auth; mod auth;
mod backup;
mod bank_import;
mod budget; mod budget;
mod catalog; mod catalog;
mod debt;
mod entry;
mod health; mod health;
mod household; mod household;
mod monthly_report;
mod note; mod note;
mod period; mod period;
mod plan; mod plan;
mod rates;
mod reports;
mod review;
mod spa; mod spa;
mod stats;
mod transfer;
use axum::{ use axum::{
Router, Router,
extract::DefaultBodyLimit,
routing::{delete, get, patch, post, put}, routing::{delete, get, patch, post, put},
}; };
use tower_http::{compression::CompressionLayer, trace::TraceLayer}; use tower_http::{compression::CompressionLayer, trace::TraceLayer};
@@ -36,10 +48,13 @@ pub fn router(state: AppState) -> Router {
.route("/health", get(health::health)) .route("/health", get(health::health))
.route("/auth/register", post(auth::register)) .route("/auth/register", post(auth::register))
.route("/auth/registration", get(auth::registration_info)) .route("/auth/registration", get(auth::registration_info))
.route("/auth/guard", get(auth::guard_info))
.route("/auth/login", post(auth::login)) .route("/auth/login", post(auth::login))
.route("/auth/logout", post(auth::logout)) .route("/auth/logout", post(auth::logout))
.route("/auth/me", get(auth::me)) .route("/auth/me", get(auth::me).patch(auth::update_me))
.route("/household", get(household::get)) .route("/household", get(household::get).patch(household::update))
.route("/household/members/{id}", delete(household::remove_member))
.route("/auth/me/password", post(auth::change_password))
.route( .route(
"/household/invites", "/household/invites",
get(household::list_invites).post(household::create_invite), get(household::list_invites).post(household::create_invite),
@@ -66,6 +81,8 @@ pub fn router(state: AppState) -> Router {
.route("/periods", get(period::list).post(period::create)) .route("/periods", get(period::list).post(period::create))
.route("/periods/current", get(period::current)) .route("/periods/current", get(period::current))
.route("/periods/{id}", get(period::get).patch(period::update)) .route("/periods/{id}", get(period::get).patch(period::update))
.route("/periods/{id}/close", post(period::close))
.route("/periods/{id}/reopen", post(period::reopen))
.route( .route(
"/periods/{id}/plan-items", "/periods/{id}/plan-items",
get(plan::list).post(plan::create), get(plan::list).post(plan::create),
@@ -77,6 +94,56 @@ pub fn router(state: AppState) -> Router {
.route("/plan-items/{id}", patch(plan::update).delete(plan::delete)) .route("/plan-items/{id}", patch(plan::update).delete(plan::delete))
.route("/periods/{id}/notes", get(note::list).post(note::create)) .route("/periods/{id}/notes", get(note::list).post(note::create))
.route("/periods/{id}/summary", get(budget::summary)) .route("/periods/{id}/summary", get(budget::summary))
.route("/periods/{id}/review", get(review::get))
.route("/periods/{id}/debt", get(debt::period_debt))
.route("/periods/{id}/opening-debt", put(debt::set_opening))
.route("/debt", get(debt::summary))
.route("/debt/snapshots", post(debt::create_snapshot))
.route(
"/debt/snapshots/{id}",
patch(debt::update_snapshot).delete(debt::delete_snapshot),
)
.route("/debt/installments", post(debt::create_plan))
.route(
"/debt/installments/{id}",
patch(debt::update_plan).delete(debt::delete_plan),
)
.route("/debt/targets", put(debt::set_targets))
.route("/debt/import", post(debt::import))
.route("/periods/{id}/report.md", get(monthly_report::get))
.route("/periods/{id}/daily", get(stats::daily))
.route("/stats/periods", get(stats::periods))
.route("/reports/breakdown", get(reports::breakdown))
.route("/backup/download", get(backup::download))
.route("/backup/status", get(backup::status))
.route(
"/backup/preview",
post(backup::preview).layer(DefaultBodyLimit::max(backup_limit())),
)
.route(
"/backup/restore",
post(backup::restore).layer(DefaultBodyLimit::max(backup_limit())),
)
.route("/backup/restore-points/{id}/restore", post(backup::undo))
.route("/export/entries.csv", get(transfer::export_entries))
.route("/export/plan-items.csv", get(transfer::export_plan_items))
.route(
"/export/asset-transactions.csv",
get(transfer::export_asset_txs),
)
.route(
"/bank-import/preview",
post(bank_import::preview).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
)
.route(
"/bank-import/commit",
post(bank_import::commit).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
)
.route(
"/import/{kind}",
// Büyük CSV (≤ 20.000 satır) için gövde sınırı 5 MB
post(transfer::import).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
)
.route( .route(
"/periods/{id}/budget", "/periods/{id}/budget",
get(budget::get).put(budget::set_monthly), get(budget::get).put(budget::set_monthly),
@@ -84,8 +151,34 @@ pub fn router(state: AppState) -> Router {
.route("/periods/{id}/budget/weeks", put(budget::set_week)) .route("/periods/{id}/budget/weeks", put(budget::set_week))
.route("/periods/{id}/budget/suggestion", get(budget::suggestion)) .route("/periods/{id}/budget/suggestion", get(budget::suggestion))
.route("/notes/{id}", patch(note::update).delete(note::delete)) .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) // Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
.fallback(|| async { AppError::NotFound }); .fallback(|| async { AppError::NotFound });
Router::new().nest("/api", api).with_state(state) Router::new().nest("/api", api).with_state(state)
} }
/// JSON yedeği gövde sınırı (`services::backup::MAX_BYTES`)
fn backup_limit() -> usize {
crate::services::backup::MAX_BYTES
}
+44
View File
@@ -0,0 +1,44 @@
//! Aylık değerlendirme raporu (`services::monthly_report`, şablon `templates/monthly_report.md`).
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods/{id}/report.md` | dönemin raporu (Markdown); açık dönem → ara rapor |
use axum::{
extract::{Path, State},
http::header,
response::IntoResponse,
};
use mongodb::bson::Uuid;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
services::{backup::slug, monthly_report, period},
state::AppState,
};
pub async fn get(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<impl IntoResponse> {
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
let p = period::get(&s.db, auth.household_id, id).await?;
let body = monthly_report::markdown(&s.db, auth.household_id, id).await?;
let file = format!("kakeibo-rapor-{}.md", slug(&p.name));
Ok((
[
(
header::CONTENT_TYPE,
"text/markdown; charset=utf-8".to_string(),
),
(
header::CONTENT_DISPOSITION,
format!("inline; filename=\"{file}\""),
),
(header::CACHE_CONTROL, "no-store".to_string()),
],
body,
))
}
+95 -1
View File
@@ -6,7 +6,10 @@
//! | GET | `/api/periods/current` | açık dönem ya da `null` | //! | GET | `/api/periods/current` | açık dönem ya da `null` |
//! | POST | `/api/periods` | dönem aç | //! | POST | `/api/periods` | dönem aç |
//! | GET | `/api/periods/{id}` | dönem + haftalar | //! | 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::{ use axum::{
Json, Json,
@@ -25,6 +28,7 @@ use crate::{
models::period::{Period, PeriodStatus}, models::period::{Period, PeriodStatus},
services::{ services::{
calendar::{days_between, format_date, parse_date, weeks}, calendar::{days_between, format_date, parse_date, weeks},
closing,
period::{self, NewPeriod, PeriodChanges}, period::{self, NewPeriod, PeriodChanges},
}, },
state::AppState, state::AppState,
@@ -54,6 +58,8 @@ pub struct UpdateRequest {
end_date: Option<String>, end_date: Option<String>,
#[ts(type = "number", optional)] #[ts(type = "number", optional)]
savings_goal: Option<i64>, savings_goal: Option<i64>,
/// "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır
reflection_md: Option<String>,
version: Option<i32>, version: Option<i32>,
} }
@@ -73,6 +79,13 @@ pub struct PeriodDto {
/// Kuruş /// Kuruş
#[ts(type = "number")] #[ts(type = "number")]
savings_goal: i64, 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, version: i32,
weeks: Vec<WeekDto>, weeks: Vec<WeekDto>,
} }
@@ -102,6 +115,9 @@ impl From<Period> for PeriodDto {
end_date: p.end_date, end_date: p.end_date,
status: p.status, status: p.status,
savings_goal: p.savings_goal, 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, version: p.version,
weeks: weeks(start, end) weeks: weeks(start, end)
.into_iter() .into_iter()
@@ -178,9 +194,87 @@ pub async fn update(
start_date: req.start_date, start_date: req.start_date,
end_date: req.end_date, end_date: req.end_date,
savings_goal: req.savings_goal, savings_goal: req.savings_goal,
reflection_md: req.reflection_md,
version: req.version, version: req.version,
}, },
) )
.await?; .await?;
Ok(Json(p.into())) 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,
}))
}
+45 -9
View File
@@ -1,7 +1,8 @@
//! Derlenmiş arayüzün (frontend/dist) sunulması — tek sayfa uygulama (SPA). //! Derlenmiş arayüzün (frontend/dist) sunulması — tek sayfa uygulama (SPA).
//! //!
//! - `/assets/*` : adları içerik özetli (hash) → 1 yıl önbellek, `immutable` //! - `/assets/*` : adları içerik özetli (hash) → 1 yıl önbellek, `immutable`
//! - diğer dosyalar (favicon, ikonlar): 1 saat önbellek //! - PWA: `sw.js`, `manifest.webmanifest` → hiç önbelleğe alınmaz (yeni sürüm hemen fark edilsin)
//! - diğer dosyalar (favicon, ikonlar, `workbox-<hash>.js`): 1 saat önbellek
//! - olmayan yol (`/defter`, `/plan` ...) → `index.html` (yönlendirmeyi React Router yapar) //! - olmayan yol (`/defter`, `/plan` ...) → `index.html` (yönlendirmeyi React Router yapar)
//! - HTML hiç önbelleğe alınmaz → yeni sürüm yayınlanınca hemen yüklenir //! - HTML hiç önbelleğe alınmaz → yeni sürüm yayınlanınca hemen yüklenir
@@ -24,19 +25,54 @@ pub fn router(dir: &Path) -> Router {
} }
async fn cache_headers(req: Request, next: Next) -> Response { async fn cache_headers(req: Request, next: Next) -> Response {
let is_asset = req.uri().path().starts_with("/assets/"); let path = req.uri().path().to_string();
let mut res = next.run(req).await; let mut res = next.run(req).await;
let is_html = res let is_html = res
.headers() .headers()
.get(header::CONTENT_TYPE) .get(header::CONTENT_TYPE)
.is_some_and(|v| v.as_bytes().starts_with(b"text/html")); .is_some_and(|v| v.as_bytes().starts_with(b"text/html"));
let policy = match (is_html, is_asset) { res.headers_mut().insert(
(true, _) => "no-cache", header::CACHE_CONTROL,
(false, true) => "public, max-age=31536000, immutable", HeaderValue::from_static(cache_policy(&path, is_html)),
(false, false) => "public, max-age=3600", );
};
res.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static(policy));
res res
} }
/// Yol ve içerik türüne göre `Cache-Control`
fn cache_policy(path: &str, is_html: bool) -> &'static str {
const NO_CACHE: &str = "no-cache";
if is_html || matches!(path, "/sw.js" | "/manifest.webmanifest" | "/registerSW.js") {
NO_CACHE
} else if path.starts_with("/assets/") {
"public, max-age=31536000, immutable"
} else {
"public, max-age=3600"
}
}
#[cfg(test)]
mod tests {
use super::cache_policy;
#[test]
fn cache_rules() {
assert_eq!(cache_policy("/", true), "no-cache");
assert_eq!(
cache_policy("/defter", true),
"no-cache",
"SPA → index.html"
);
assert_eq!(cache_policy("/sw.js", false), "no-cache");
assert_eq!(cache_policy("/manifest.webmanifest", false), "no-cache");
assert_eq!(
cache_policy("/assets/index-AbC123.js", false),
"public, max-age=31536000, immutable"
);
assert_eq!(cache_policy("/icon-192.png", false), "public, max-age=3600");
assert_eq!(
cache_policy("/workbox-2ff6bd68.js", false),
"public, max-age=3600"
);
}
}
+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(),
}))
}
+149
View File
@@ -0,0 +1,149 @@
//! CSV dışa / içe aktarma (hanenin tüm üyeleri):
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/export/entries.csv?from=&to=` | harcamalar |
//! | GET | `/api/export/plan-items.csv` | plan kalemleri (tüm dönemler) |
//! | GET | `/api/export/asset-transactions.csv` | varlık işlemleri |
//! | POST | `/api/import/{tür}?commit=false\|true` | gövde: CSV metni → rapor; `commit=true` geçerli satırları yazar |
//!
//! `{tür}`: `entries` · `plan-items` · `asset-transactions`.
use axum::{
Json,
extract::{Path, Query, State},
http::header,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
services::{
calendar::{format_date, today_tr},
transfer,
},
state::AppState,
};
fn csv_response(name: &str, body: String) -> impl IntoResponse {
let file = format!("kakeibo-{name}-{}.csv", format_date(today_tr()));
(
[
(header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{file}\""),
),
(header::CACHE_CONTROL, "no-store".to_string()),
],
body,
)
}
#[derive(Deserialize)]
pub struct RangeQuery {
from: Option<String>,
to: Option<String>,
}
pub async fn export_entries(
State(s): State<AppState>,
auth: AuthUser,
Query(q): Query<RangeQuery>,
) -> AppResult<impl IntoResponse> {
let body = transfer::export_entries(
&s.db,
auth.household_id,
q.from.as_deref().filter(|x| !x.is_empty()),
q.to.as_deref().filter(|x| !x.is_empty()),
)
.await?;
Ok(csv_response("harcamalar", body))
}
pub async fn export_plan_items(
State(s): State<AppState>,
auth: AuthUser,
) -> AppResult<impl IntoResponse> {
let body = transfer::export_plan_items(&s.db, auth.household_id).await?;
Ok(csv_response("plan-kalemleri", body))
}
pub async fn export_asset_txs(
State(s): State<AppState>,
auth: AuthUser,
) -> AppResult<impl IntoResponse> {
let body = transfer::export_asset_txs(&s.db, auth.household_id).await?;
Ok(csv_response("varlik-islemleri", body))
}
#[derive(Deserialize)]
pub struct ImportQuery {
#[serde(default)]
commit: bool,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ImportReport")]
pub struct ReportDto {
/// Başlık hariç dolu satır
total: u32,
/// Eklenebilir (tekrarlar hariç); yazıldıysa yazılan
valid: u32,
/// Veritabanında zaten olduğu için atlanan
duplicates: u32,
/// Yazılan (denemede 0)
imported: u32,
errors: Vec<ImportError>,
/// İlk geçerli satırlar (gösterime hazır)
preview: Vec<Vec<String>>,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct ImportError {
/// Dosyadaki satır numarası (başlık 1)
line: u32,
message: String,
}
pub async fn import(
State(s): State<AppState>,
auth: AuthUser,
Path(kind): Path<String>,
Query(q): Query<ImportQuery>,
body: String,
) -> AppResult<Json<ReportDto>> {
let r = match kind.as_str() {
"entries" => {
transfer::import_entries(&s.db, auth.household_id, auth.user_id, &body, q.commit)
.await?
}
"plan-items" => {
transfer::import_plan_items(&s.db, auth.household_id, &body, q.commit).await?
}
"asset-transactions" => {
transfer::import_asset_txs(&s.db, auth.household_id, auth.user_id, &body, q.commit)
.await?
}
_ => return Err(AppError::NotFound),
};
Ok(Json(ReportDto {
total: r.total as u32,
valid: r.valid as u32,
duplicates: r.duplicates as u32,
imported: r.imported as u32,
errors: r
.errors
.into_iter()
.map(|(line, message)| ImportError {
line: line as u32,
message,
})
.collect(),
preview: r.preview,
}))
}
+180 -1
View File
@@ -1,4 +1,4 @@
//! Hesap işlemleri: kayıt. //! Hesap işlemleri: kayıt; profil, şifre, hane ayarları, üye çıkarma.
//! //!
//! İki yol var: //! İki yol var:
//! - **Davetsiz:** yeni hane + `owner` kullanıcı + örnek kategori/ödeme türleri //! - **Davetsiz:** yeni hane + `owner` kullanıcı + örnek kategori/ödeme türleri
@@ -120,6 +120,10 @@ async fn create_in_tx(
.insert_many(seed::payment_methods(household_id)) .insert_many(seed::payment_methods(household_id))
.session(&mut *session) .session(&mut *session)
.await?; .await?;
docs(col::ASSETS)
.insert_many(seed::assets(household_id))
.session(&mut *session)
.await?;
(household_id, Role::Owner) (household_id, Role::Owner)
} }
}; };
@@ -143,3 +147,178 @@ async fn create_in_tx(
role, role,
}) })
} }
// ---------- Profil, şifre, hane ayarları, üyeler (Faz 10) ----------
/// Oturumların geçerlilik başlangıcı (Unix saniye). Şifre değişince "şimdi" yapılır:
/// daha önce verilmiş tüm oturum jetonları (`iat` daha küçük) reddedilir.
pub const SESSIONS_VALID_FROM: &str = "sessions_valid_from";
fn unix_now() -> i64 {
time::OffsetDateTime::now_utc().unix_timestamp()
}
fn clean_text(v: &str, what: &str, max: usize) -> AppResult<String> {
let v = v.trim().to_string();
if v.is_empty() {
return Err(AppError::BadRequest(format!("{what} boş olamaz")));
}
if v.chars().count() > max {
return Err(AppError::BadRequest(format!(
"{what} en fazla {max} karakter olabilir"
)));
}
Ok(v)
}
pub async fn update_name(
db: &Database,
household_id: Uuid,
user_id: Uuid,
name: &str,
) -> AppResult<()> {
let name = clean_text(name, "Ad", 80)?;
let r = db
.collection::<Document>(col::USERS)
.update_one(
doc! { "_id": user_id, "household_id": household_id, "deleted_at": null },
doc! { "$set": { "name": name, "updated_at": crate::models::common::now() }, "$inc": { "version": 1 } },
)
.await?;
if r.matched_count == 0 {
return Err(AppError::Unauthorized);
}
Ok(())
}
/// Mevcut şifre doğruysa yenisini yazar ve bu andan önce verilmiş tüm oturumları geçersiz
/// kılar (route bu cihaza hemen yeni bir oturum verir).
pub async fn change_password(
db: &Database,
household_id: Uuid,
user_id: Uuid,
current: String,
new: String,
) -> AppResult<()> {
if new.chars().count() < 8 || new.chars().count() > 128 {
return Err(AppError::BadRequest(
"Yeni şifre en az 8 karakter olmalı".into(),
));
}
if new == current {
return Err(AppError::BadRequest(
"Yeni şifre mevcut şifreden farklı olmalı".into(),
));
}
let users = db.collection::<Document>(col::USERS);
let filter = doc! { "_id": user_id, "household_id": household_id, "deleted_at": null };
let user = users
.find_one(filter.clone())
.await?
.ok_or(AppError::Unauthorized)?;
let hash = user.get_str("password_hash").ok().map(str::to_string);
if !crate::auth::password::verify(current, hash).await {
return Err(AppError::BadRequest("Mevcut şifre hatalı".into()));
}
let new_hash = crate::auth::password::hash(new).await?;
users
.update_one(
filter,
doc! {
"$set": {
"password_hash": new_hash,
SESSIONS_VALID_FROM: unix_now(),
"updated_at": crate::models::common::now(),
},
"$inc": { "version": 1 },
},
)
.await?;
tracing::info!("Kullanıcı {user_id} şifresini değiştirdi; diğer oturumlar kapatıldı");
Ok(())
}
/// Hane ayarlarında değiştirilebilenler (`None` → dokunma)
#[derive(Default)]
pub struct HouseholdChanges {
pub name: Option<String>,
/// `Group::ALL` sırasıyla, toplamı 100
pub split_pct: Option<[u32; 4]>,
/// `default` | `previous` | `avg3`
pub suggestion: Option<String>,
}
pub async fn update_household(
db: &Database,
household_id: Uuid,
c: HouseholdChanges,
) -> AppResult<()> {
let mut set = doc! { "updated_at": crate::models::common::now() };
if let Some(name) = c.name {
set.insert("name", clean_text(&name, "Hane adı", 80)?);
}
if let Some(p) = c.split_pct {
if p.iter().any(|v| *v > 100) || p.iter().sum::<u32>() != 100 {
return Err(AppError::BadRequest("Yüzdelerin toplamı 100 olmalı".into()));
}
let mut pct = Document::new();
for (g, v) in crate::models::group::Group::ALL.iter().zip(p) {
pct.insert(g.as_str(), v as i32);
}
set.insert("settings.budget_split_pct", pct);
}
if let Some(m) = c.suggestion {
if !["default", "previous", "avg3"].contains(&m.as_str()) {
return Err(AppError::BadRequest("Geçersiz öneri yöntemi".into()));
}
set.insert("settings.budget_suggestion", m);
}
db.collection::<Document>(col::HOUSEHOLDS)
.update_one(
doc! { "_id": household_id, "deleted_at": null },
doc! { "$set": set, "$inc": { "version": 1 } },
)
.await?;
Ok(())
}
/// Sahip bir üyeyi çıkarır: kullanıcı silinmiş işaretlenir (oturumu anında geçersiz),
/// e-postası serbest kalır (aynı adresle yeniden davet edilebilir). Kayıtları hanede kalır.
pub async fn remove_member(
db: &Database,
household_id: Uuid,
owner_id: Uuid,
member_id: Uuid,
) -> AppResult<()> {
if member_id == owner_id {
return Err(AppError::BadRequest("Hane sahibi kendini çıkaramaz".into()));
}
let users = db.collection::<Document>(col::USERS);
let filter = doc! { "_id": member_id, "household_id": household_id, "deleted_at": null };
let user = users
.find_one(filter.clone())
.await?
.ok_or(AppError::NotFound)?;
if user.get_str("role") == Ok(Role::Owner.as_str()) {
return Err(AppError::BadRequest("Hane sahibi çıkarılamaz".into()));
}
let email = user.get_str("email").unwrap_or_default().to_string();
let ts = crate::models::common::now();
users
.update_one(
filter,
doc! {
"$set": {
"deleted_at": ts,
"updated_at": ts,
// Benzersiz e-posta indeksi: adres serbest kalsın, kayıtta iz kalsın
"email": format!("{email}#removed-{member_id}"),
"removed_email": email,
},
"$inc": { "version": 1 },
},
)
.await?;
tracing::info!("Hane {household_id}: üye {member_id} çıkarıldı");
Ok(())
}
+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),
))
}
File diff suppressed because it is too large Load Diff
+687
View File
@@ -0,0 +1,687 @@
//! Banka / kredi kartı ekstresi içe aktarma (kullanıcı isteği; Faz 9 Adım 4).
//!
//! Dosya tarayıcıda okunur (Excel/CSV) ve sütunları eşleştirilir; buraya yalnızca
//! **tarih, açıklama, tutar, yön** gelir. Her satır bir türe ayrılır:
//! - `expense` → harcama (kategori gerekir)
//! - `refund` → **iade**: aynı kategoride eksi tutarlı kayıt (harcamadan düşer)
//! - `income` → dönemin planına **gelir** kalemi
//! - `ignore` → aktarılmaz (kart borcu ödemesi, kendi hesaplar arası virman…)
//!
//! Dönem: satırın tarihini içeren yazılabilir dönem; yoksa takvim ayı kadar **arşiv** dönemi
//! açılır (var olan dönemlerle çakışmayacak şekilde kırpılır). Kapalı dönem ve gelecek tarih → hata.
//! Tekrar: `import_key` (tarih|yön|tutar|sadeleştirilmiş açıklama) — aynı ekstre ya da örtüşen
//! iki ekstre yüklense de çift kayıt olmaz. Öneri: hanenin öğrenilmiş kuralları (açıklamanın ilk
//! iki anlamlı kelimesi), yoksa hazır ipuçları ("kredi kartı ödemesi" → yoksay), yoksa yön.
use std::collections::HashMap;
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
};
use serde::Deserialize;
use time::Date;
use crate::{
db::col,
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
period::Period,
plan_item::PlanKind,
},
services::{
calendar::{format_date, parse_date, today_tr},
catalog::{self, Catalog},
entry::MAX_DESCRIPTION,
period,
plan::{self, NewItem},
},
};
pub const MAX_ROWS: usize = 5_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Source {
/// Kredi kartı: çıkış = harcama, giriş = iade ya da ödeme
Card,
/// Banka hesabı: çıkış = harcama, giriş = gelir
Account,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
In,
Out,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Expense,
Refund,
Income,
Ignore,
}
impl Kind {
pub fn as_str(self) -> &'static str {
match self {
Kind::Expense => "expense",
Kind::Refund => "refund",
Kind::Income => "income",
Kind::Ignore => "ignore",
}
}
}
#[derive(Debug, Clone)]
pub struct Row {
pub date: String,
pub description: String,
/// Kuruş, > 0
pub amount: i64,
pub direction: Direction,
/// Commit'te kullanıcının seçimi
pub kind: Option<Kind>,
pub category_id: Option<Uuid>,
}
// ---------- Metin sadeleştirme ----------
/// Türkçe harfleri ASCII'ye indir, küçült (bankalar çoğu zaman "ODEME", "SUT" yazar)
pub fn fold(s: &str) -> String {
s.chars()
// "İ".to_lowercase() = "i" + birleşik nokta (U+0307) → önce doğrudan "i"
.map(|c| if c == 'İ' { 'i' } else { c })
.flat_map(|c| c.to_lowercase())
.filter(|c| *c != '\u{307}')
.map(|c| match c {
'ı' | 'i' | 'î' => 'i',
'ş' => 's',
'ğ' => 'g',
'ü' | 'û' => 'u',
'ö' => 'o',
'ç' => 'c',
'â' => 'a',
c => c,
})
.collect()
}
/// Anlamlı kelimeler: harf/rakam dışı ayırıcı; yalnızca rakam ve tek harf atılır
fn words(desc: &str) -> Vec<String> {
fold(desc)
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() >= 2 && !w.chars().all(|c| c.is_ascii_digit()))
.map(str::to_string)
.collect()
}
/// Kural anahtarı: ilk iki anlamlı kelime (`MIGROS 1234 ISTANBUL TR` → `migros istanbul`)
pub fn rule_key(desc: &str) -> String {
words(desc)
.into_iter()
.take(2)
.collect::<Vec<_>>()
.join(" ")
}
/// İlk kelime eşleşmesinde kullanılmayacak genel sözcükler (satıcı adı değil)
const GENERIC: &[&str] = &[
"iade",
"odeme",
"pos",
"isyeri",
"eft",
"havale",
"fast",
"virman",
"kredi",
"kart",
"hesap",
"para",
"borc",
"harcama",
"alisveris",
"nakit",
"atm",
"taksit",
"ucret",
];
/// Öneri: tam kural (ilk 2 kelime); yoksa ilk kelimesi aynı kurallar hepsi aynı şeyi
/// söylüyorsa o (ör. "migros istanbul" öğrenildi → "MIGROS KADIKOY" de Market)
fn suggest<'a>(rules: &'a HashMap<String, Rule>, desc: &str) -> Option<&'a Rule> {
if let Some(r) = rules.get(&rule_key(desc)) {
return Some(r);
}
let first = words(desc).into_iter().next()?;
if first.len() < 3 || GENERIC.contains(&first.as_str()) {
return None;
}
let mut same = rules
.values()
.filter(|r| r.pattern.split(' ').next() == Some(first.as_str()));
let r = same.next()?;
same.all(|x| x.kind == r.kind && x.category_id == r.category_id)
.then_some(r)
}
/// Tekrar anahtarı (tüm açıklama, sadeleştirilmiş)
pub fn import_key(date: &str, dir: Direction, amount: i64, desc: &str) -> String {
let d = match dir {
Direction::In => "in",
Direction::Out => "out",
};
format!("{date}|{d}|{amount}|{}", words(desc).join(" "))
}
/// Hazır öneri (kural yoksa): ödeme/virman → yoksay; kartta giriş → iade; hesapta giriş → gelir
pub fn default_kind(source: Source, dir: Direction, desc: &str) -> Kind {
let f = words(desc).join(" ");
let transfer = [
"kredi karti",
"kart odeme",
"kk odeme",
"virman",
"hesaplar arasi",
"kendi hesab",
"hesabima",
"borc odeme",
"odemeniz icin",
"tahsilat",
]
.iter()
.any(|h| f.contains(h));
match (source, dir) {
_ if transfer => Kind::Ignore,
(_, Direction::Out) => Kind::Expense,
(Source::Card, Direction::In) => {
if f.contains("odeme") {
Kind::Ignore
} else {
Kind::Refund
}
}
(Source::Account, Direction::In) => Kind::Income,
}
}
// ---------- Dönem ----------
fn first_of_month(d: Date) -> Date {
d.replace_day(1).expect("1. gün")
}
fn next_month(d: Date) -> Date {
let (y, m) = if d.month() == time::Month::December {
(d.year() + 1, time::Month::January)
} else {
(d.year(), d.month().next())
};
Date::from_calendar_date(y, m, 1).expect("geçerli ay")
}
/// Satırın dönemi
#[derive(Debug, Clone, PartialEq)]
pub enum Target {
/// Var olan yazılabilir dönem (açık, düzeltmede ya da arşiv)
Existing {
id: Uuid,
name: String,
},
/// Açılacak arşiv dönemi [start, end)
Archive {
start: String,
end: String,
},
Blocked(String),
}
/// Tarih → dönem: içeren dönem; yoksa takvim ayı, var olan dönemlerin dışında kalan kısmı
pub fn target(periods: &[Period], date: &str, today: &str) -> Target {
if date > today {
return Target::Blocked("Gelecek tarihli satır".into());
}
if let Some(p) = periods
.iter()
.find(|p| p.start_date.as_str() <= date && date < p.end_date.as_str())
{
return if p.status.is_writable() {
Target::Existing {
id: p.id,
name: p.name.clone(),
}
} else {
Target::Blocked(format!(
"Dönem kapalı ({}); düzeltme için yeniden açın",
p.name
))
};
}
let d = parse_date(date).expect("doğrulanmış tarih");
let (ms, me) = (format_date(first_of_month(d)), format_date(next_month(d)));
// Ayın içinde, tarihten önce biten en geç dönem / tarihten sonra başlayan en erken dönem
let start = periods
.iter()
.map(|p| p.end_date.as_str())
.filter(|e| *e <= date && *e > ms.as_str())
.max()
.map_or(ms.clone(), str::to_string);
let end = periods
.iter()
.map(|p| p.start_date.as_str())
.filter(|s| *s > date && *s < me.as_str())
.min()
.map_or(me.clone(), str::to_string);
Target::Archive { start, end }
}
// ---------- Kurallar ----------
#[derive(Debug, Clone, Deserialize)]
struct Rule {
pattern: String,
kind: Kind,
#[serde(default)]
category_id: Option<Uuid>,
}
async fn rules(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, Rule>> {
let mut c = db
.collection::<Rule>(col::IMPORT_RULES)
.find(scope(household_id))
.await?;
let mut out = HashMap::new();
while c.advance().await? {
let r = c.deserialize_current()?;
out.insert(r.pattern.clone(), r);
}
Ok(out)
}
/// Var olan içe aktarma anahtarları (harcama + plan kalemi) → adet
async fn existing_keys(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, usize>> {
let mut out = HashMap::new();
for coll in [col::ENTRIES, col::PLAN_ITEMS] {
let mut f = scope(household_id);
f.insert("import_key", doc! { "$type": "string" });
let mut c = db
.collection::<Document>(coll)
.find(f)
.projection(doc! { "import_key": 1 })
.await?;
while c.advance().await? {
if let Ok(k) = c.current().get_str("import_key") {
*out.entry(k.to_string()).or_insert(0) += 1;
}
}
}
Ok(out)
}
fn take(m: &mut HashMap<String, usize>, k: &str) -> bool {
match m.get_mut(k) {
Some(n) if *n > 0 => {
*n -= 1;
true
}
_ => false,
}
}
fn validate(r: &Row) -> Result<(), String> {
parse_date(&r.date).ok_or_else(|| format!("Tarih geçersiz: {}", r.date))?;
if r.amount <= 0 {
return Err("Tutar sıfırdan büyük olmalı".into());
}
period::validate_amount(r.amount).map_err(|e| e.to_string())?;
Ok(())
}
// ---------- Önizleme ----------
pub struct PreviewRow {
pub kind: Kind,
pub category_id: Option<Uuid>,
/// Öneri hanenin öğrenilmiş kuralından mı?
pub from_rule: bool,
pub duplicate: bool,
pub target: Target,
}
pub struct Preview {
pub rows: Vec<PreviewRow>,
/// Açılacak arşiv dönemleri [start, end)
pub archives: Vec<(String, String)>,
}
fn check_rows(rows: &[Row]) -> AppResult<()> {
if rows.is_empty() {
return Err(AppError::BadRequest("Aktarılacak satır yok".into()));
}
if rows.len() > MAX_ROWS {
return Err(AppError::BadRequest(format!(
"Bir seferde en fazla {MAX_ROWS} satır aktarılabilir"
)));
}
Ok(())
}
pub async fn preview(
db: &Database,
household_id: Uuid,
source: Source,
rows: &[Row],
) -> AppResult<Preview> {
check_rows(rows)?;
let periods = period::list(db, household_id).await?;
let rules = rules(db, household_id).await?;
let mut keys = existing_keys(db, household_id).await?;
let today = format_date(today_tr());
let mut archives: Vec<(String, String)> = Vec::new();
let out = rows
.iter()
.map(|r| {
let target = match validate(r) {
Err(e) => Target::Blocked(e),
Ok(()) => target(&periods, &r.date, &today),
};
if let Target::Archive { start, end } = &target
&& !archives.iter().any(|(s, _)| s == start)
{
archives.push((start.clone(), end.clone()));
}
let duplicate = matches!(target, Target::Existing { .. } | Target::Archive { .. })
&& take(
&mut keys,
&import_key(&r.date, r.direction, r.amount, &r.description),
);
let rule = suggest(&rules, &r.description);
PreviewRow {
kind: rule.map_or_else(
|| default_kind(source, r.direction, &r.description),
|x| x.kind,
),
category_id: rule.and_then(|x| x.category_id),
from_rule: rule.is_some(),
duplicate,
target,
}
})
.collect();
archives.sort();
Ok(Preview {
rows: out,
archives,
})
}
// ---------- Aktarma ----------
#[derive(Debug, Default)]
pub struct Report {
pub entries: usize,
pub refunds: usize,
pub incomes: usize,
pub ignored: usize,
pub duplicates: usize,
/// Açılan arşiv dönemlerinin adları
pub archives: Vec<String>,
/// (satır sırası 0'dan, neden)
pub errors: Vec<(usize, String)>,
}
pub async fn commit(
db: &Database,
household_id: Uuid,
user_id: Uuid,
payment_method_id: Option<Uuid>,
rows: &[Row],
) -> AppResult<Report> {
check_rows(rows)?;
if let Some(pm) = payment_method_id {
catalog::find(db, household_id, Catalog::PaymentMethods, pm)
.await?
.ok_or_else(|| AppError::BadRequest("Ödeme türü bulunamadı".into()))?;
}
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
let mut periods = period::list(db, household_id).await?;
let mut keys = existing_keys(db, household_id).await?;
let today = format_date(today_tr());
let mut report = Report::default();
let mut entries: Vec<Document> = Vec::new();
let mut incomes: Vec<(usize, Uuid, NewItem, String)> = Vec::new();
let mut learned: HashMap<String, (Kind, Option<Uuid>)> = HashMap::new();
for (i, r) in rows.iter().enumerate() {
let kind = r.kind.unwrap_or(Kind::Ignore);
let pattern = rule_key(&r.description);
if kind == Kind::Ignore {
report.ignored += 1;
if !pattern.is_empty() {
learned.insert(pattern, (kind, None));
}
continue;
}
if let Err(e) = validate(r) {
report.errors.push((i, e));
continue;
}
let key = import_key(&r.date, r.direction, r.amount, &r.description);
let pid = match target(&periods, &r.date, &today) {
Target::Blocked(e) => {
report.errors.push((i, e));
continue;
}
Target::Existing { id, .. } => id,
Target::Archive { start, end } => {
let p = period::create_archive(
db,
household_id,
parse_date(&start).expect("tarih"),
parse_date(&end).expect("tarih"),
)
.await?;
report.archives.push(p.name.clone());
let id = p.id;
periods.push(p);
id
}
};
if take(&mut keys, &key) {
report.duplicates += 1;
continue;
}
let desc: String = r.description.trim().chars().take(MAX_DESCRIPTION).collect();
match kind {
Kind::Expense | Kind::Refund => {
let Some(cat) = r.category_id.and_then(|c| cats.iter().find(|x| x.id == c)) else {
report.errors.push((i, "Kategori seçin".into()));
continue;
};
let Some(group) = cat.group else {
report.errors.push((i, "Kategorinin grubu yok".into()));
continue;
};
let amount = if kind == Kind::Refund {
-r.amount
} else {
r.amount
};
let mut d = new_meta(household_id);
d.insert("period_id", pid);
d.insert("date", &r.date);
d.insert("amount", amount);
d.insert("group", group.as_str());
d.insert("category_id", cat.id);
d.insert(
"payment_method_id",
payment_method_id.map_or(Bson::Null, Bson::from),
);
d.insert(
"description",
if desc.is_empty() {
Bson::Null
} else {
Bson::String(desc)
},
);
d.insert("created_by", user_id);
d.insert("import_key", &key);
entries.push(d);
if kind == Kind::Refund {
report.refunds += 1;
} else {
report.entries += 1;
}
if !pattern.is_empty() {
learned.insert(pattern, (kind, Some(cat.id)));
}
}
Kind::Income => {
// Plan kalemi adı en fazla 60 karakter
let name: String = if desc.is_empty() {
"Gelir".into()
} else {
desc.chars().take(60).collect()
};
incomes.push((
i,
pid,
NewItem {
kind: PlanKind::Income,
name,
amount: r.amount,
date: Some(r.date.clone()),
},
key,
));
if !pattern.is_empty() {
learned.insert(pattern, (kind, None));
}
}
Kind::Ignore => unreachable!(),
}
}
if !entries.is_empty() {
db.collection::<Document>(col::ENTRIES)
.insert_many(entries)
.await?;
}
for (i, pid, item, key) in incomes {
match plan::create(db, household_id, pid, item).await {
Ok(p) => {
db.collection::<Document>(col::PLAN_ITEMS)
.update_one(doc! { "_id": p.id }, doc! { "$set": { "import_key": key } })
.await?;
report.incomes += 1;
}
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => report.errors.push((i, m)),
Err(e) => return Err(e),
}
}
// Kurallar: bu aktarımdaki seçimler sonraki ekstreler için öneri olur
let ts = now();
for (pattern, (kind, category_id)) in learned {
let mut f = scope(household_id);
f.insert("pattern", &pattern);
db.collection::<Document>(col::IMPORT_RULES)
.update_one(
f,
doc! {
"$set": {
"kind": kind.as_str(),
"category_id": category_id.map_or(Bson::Null, Bson::from),
"updated_at": ts,
},
"$inc": { "uses": 1, "version": 1 },
"$setOnInsert": { "_id": crate::models::common::new_id(), "created_at": ts },
},
)
.upsert(true)
.await?;
}
report.errors.sort_by_key(|(i, _)| *i);
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn folding_and_keys() {
assert_eq!(fold("ÖDEME Şükrü İĞNE"), "odeme sukru igne");
assert_eq!(rule_key("MIGROS 1234 ISTANBUL TR"), "migros istanbul");
assert_eq!(rule_key("A101 MAGAZACILIK*456"), "a101 magazacilik");
assert_eq!(rule_key("12/11 *"), "");
assert_eq!(
import_key("2026-11-13", Direction::Out, 4590, " Migros ŞUBE 12 "),
"2026-11-13|out|4590|migros sube"
);
}
#[test]
fn suggestion_falls_back_to_first_word() {
let market = Uuid::new();
let ulasim = Uuid::new();
let rule = |p: &str, c: Uuid| {
(
p.to_string(),
Rule {
pattern: p.into(),
kind: Kind::Expense,
category_id: Some(c),
},
)
};
let mut rules: HashMap<String, Rule> =
[rule("migros istanbul", market), rule("iade migros", market)].into();
assert_eq!(
suggest(&rules, "MIGROS 99 ISTANBUL").unwrap().category_id,
Some(market),
"tam"
);
assert_eq!(
suggest(&rules, "MİGROS KADIKÖY").unwrap().category_id,
Some(market),
"ilk kelime"
);
assert!(suggest(&rules, "IADE SHELL").is_none(), "genel ilk kelime");
assert!(suggest(&rules, "A101 SUBE").is_none());
// İlk kelimesi aynı ama farklı kategoriler → öneri yok
rules.extend([rule("migros jet", ulasim)]);
assert!(suggest(&rules, "MIGROS ANKARA").is_none());
}
#[test]
fn default_kinds() {
use Direction::*;
use Source::*;
assert_eq!(default_kind(Card, Out, "MIGROS ISTANBUL"), Kind::Expense);
assert_eq!(default_kind(Card, In, "IADE MIGROS"), Kind::Refund);
assert_eq!(
default_kind(Card, In, "ÖDEMENİZ İÇİN TEŞEKKÜRLER"),
Kind::Ignore
);
assert_eq!(default_kind(Account, In, "MAAŞ ÖDEMESİ ACME"), Kind::Income);
assert_eq!(
default_kind(Account, Out, "KREDİ KARTI ÖDEMESİ"),
Kind::Ignore
);
assert_eq!(
default_kind(Account, Out, "Hesaplar arası virman"),
Kind::Ignore
);
assert_eq!(
default_kind(Account, Out, "ELEKTRIK FATURASI"),
Kind::Expense
);
}
}
+82 -3
View File
@@ -5,6 +5,8 @@
//! Kullanıcı bir haftayı elle değiştirirse yalnızca o kayıt saklanır (`scope: "week"`); //! 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. //! 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**. //! - Özet: gelir − sabit gider − birikim − devreden borç + devreden artı = **harcanabilir**.
//! - Gerçekleşen harcama (`entries`) saklanmaz; her istekte toplanır:
//! özette toplam + kalan, bütçe görünümünde grup ve hafta bazında.
use std::collections::HashMap; use std::collections::HashMap;
@@ -40,6 +42,10 @@ pub struct Summary {
pub carryover_surplus: i64, pub carryover_surplus: i64,
pub spendable: i64, pub spendable: i64,
pub budget_total: i64, pub budget_total: i64,
/// Dönemde gerçekleşen harcama (silinenler hariç)
pub spent_total: i64,
/// Aynısı gruplara göre (`Group::ALL` sırasıyla)
pub spent_by_group: GroupAmounts,
} }
pub struct WeekBudget { pub struct WeekBudget {
@@ -49,11 +55,15 @@ pub struct WeekBudget {
pub days: u32, pub days: u32,
pub amount: i64, pub amount: i64,
pub is_manual: bool, pub is_manual: bool,
/// Bu haftada bu grupta gerçekleşen harcama
pub spent: i64,
} }
pub struct GroupBudget { pub struct GroupBudget {
pub group: Group, pub group: Group,
pub monthly: i64, pub monthly: i64,
/// Dönem boyunca bu grupta gerçekleşen harcama
pub spent: i64,
pub weeks: Vec<WeekBudget>, pub weeks: Vec<WeekBudget>,
/// Elle girilen haftaların toplamı aylık tutarı aşıyor mu? /// Elle girilen haftaların toplamı aylık tutarı aşıyor mu?
pub weekly_over: bool, pub weekly_over: bool,
@@ -131,6 +141,16 @@ pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppR
.sum(); .sum();
let spendable = let spendable =
income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus; 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 { Ok(Summary {
income_total, income_total,
@@ -140,6 +160,8 @@ pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppR
carryover_surplus, carryover_surplus,
spendable, spendable,
budget_total, 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 docs = load(db, household_id, period_id).await?;
let (start, end) = dates(&p)?; let (start, end) = dates(&p)?;
let period_weeks = weeks(start, end); 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); let mut out = Vec::with_capacity(4);
for group in Group::ALL { for group in Group::ALL {
@@ -183,6 +206,13 @@ pub async fn view(
.iter() .iter()
.map(|w| { .map(|w| {
let key = format_date(w.start); 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) { let (amount, is_manual) = match manual.get(&key) {
Some(&a) => (a, true), Some(&a) => (a, true),
None => (auto.next().unwrap_or(0), false), None => (auto.next().unwrap_or(0), false),
@@ -190,10 +220,11 @@ pub async fn view(
WeekBudget { WeekBudget {
index: w.index, index: w.index,
start_date: key, start_date: key,
last_date: format_date(w.last), last_date: last,
days: w.days, days: w.days,
amount, amount,
is_manual, is_manual,
spent: week_spent,
} }
}) })
.collect(); .collect();
@@ -201,6 +232,11 @@ pub async fn view(
out.push(GroupBudget { out.push(GroupBudget {
group, group,
monthly, monthly,
spent: spent
.iter()
.filter(|(g, _, _)| *g == group)
.map(|(_, _, a)| a)
.sum(),
weeks, weeks,
weekly_over: manual_total > monthly, weekly_over: manual_total > monthly,
}); });
@@ -451,6 +487,45 @@ async fn sum_by(
Ok(out) 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). /// Hane ayarındaki varsayılan yüzdeler (yoksa 50/30/10/10).
async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult<Vec<u64>> { async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult<Vec<u64>> {
let mut f = scope(household_id); let mut f = scope(household_id);
@@ -478,7 +553,8 @@ async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult<Vec<u
.collect()) .collect())
} }
/// Bu dönemden önceki en yeni `n` kapalı dönemin kimlikleri. /// Bu dönemden önceki en yeni `n` kapalı **ya da arşiv** dönemin kimlikleri
/// (arşiv: ekstreden aktarılan geçmiş aylar → öneriler gerçek harcamaya dayanır).
async fn previous_closed( async fn previous_closed(
db: &Database, db: &Database,
household_id: Uuid, household_id: Uuid,
@@ -487,7 +563,10 @@ async fn previous_closed(
) -> AppResult<Vec<Uuid>> { ) -> AppResult<Vec<Uuid>> {
let mut f = scope(household_id); let mut f = scope(household_id);
f.insert("start_date", doc! { "$lt": &p.start_date }); f.insert("start_date", doc! { "$lt": &p.start_date });
f.insert("status", PeriodStatus::Closed.as_str()); f.insert(
"status",
doc! { "$in": [PeriodStatus::Closed.as_str(), PeriodStatus::Archived.as_str()] },
);
let mut cursor = db let mut cursor = db
.collection::<Period>(col::PERIODS) .collection::<Period>(col::PERIODS)
.find(f) .find(f)
+83 -29
View File
@@ -26,6 +26,13 @@ pub fn parse_date(s: &str) -> Option<Date> {
} }
/// `Date` → `"2026-12-09"` /// `Date` → `"2026-12-09"`
/// Türkiye'de bugünün tarihi (UTC+3, yaz saati yok). Sunucu UTC'de çalışsa da gün doğru olsun.
pub fn today_tr() -> Date {
time::OffsetDateTime::now_utc()
.to_offset(time::UtcOffset::from_hms(3, 0, 0).expect("geçerli ofset"))
.date()
}
pub fn format_date(d: Date) -> String { pub fn format_date(d: Date) -> String {
format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day()) format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day())
} }
@@ -45,20 +52,35 @@ pub struct Week {
pub days: u32, pub days: u32,
} }
/// Dönemi Pazartesi başlangıçlı haftalara böler. /// Dönemi **ayın günlerine göre** haftalara böler (kullanıcı kararı, 2026-11-13):
/// Baştaki ve sondaki kısmi haftalar ayrı hafta sayılır (KAKEIBO_PROMPT §6). /// 1–8 → 1. hafta, 9–15 → 2., 16–22 → 3., 23–son gün → 4. hafta.
///
/// Gün numarası dönem başından sayılır (dönem 15'inde başlıyorsa 15–22 = 1. hafta).
/// Genel kural: hafta sayısı `max(1, gün / 7)`; ilk hafta 8 gün, aradakiler 7 gün,
/// son hafta kalan günler (6–12 gün). 28–31 günlük dönem → daima 4 hafta.
/// Bütçe haftalara gün sayısıyla orantılı dağıtılır (`days`).
pub fn weeks(start: Date, end_exclusive: Date) -> Vec<Week> { pub fn weeks(start: Date, end_exclusive: Date) -> Vec<Week> {
let mut out = Vec::new(); let total = days_between(start, end_exclusive);
if total <= 0 {
return Vec::new();
}
let count = (total / 7).max(1);
let mut out = Vec::with_capacity(count as usize);
let mut cursor = start; let mut cursor = start;
while cursor < end_exclusive { for i in 0..count {
// Bir sonraki Pazartesi (ya da dönem sonu, hangisi önce gelirse) let len = if i == count - 1 {
let to_monday = 7 - i64::from(cursor.weekday().number_days_from_monday()); days_between(cursor, end_exclusive)
let next = (cursor + Duration::days(to_monday)).min(end_exclusive); } else if i == 0 {
8
} else {
7
};
let next = cursor + Duration::days(len);
out.push(Week { out.push(Week {
index: out.len() as u32 + 1, index: i as u32 + 1,
start: cursor, start: cursor,
last: next - Duration::days(1), last: next - Duration::days(1),
days: days_between(cursor, next) as u32, days: len as u32,
}); });
cursor = next; cursor = next;
} }
@@ -116,37 +138,69 @@ mod tests {
assert!(parse_date("2028-02-29").is_some(), "artık yıl"); assert!(parse_date("2028-02-29").is_some(), "artık yıl");
} }
fn summary(w: &[Week]) -> Vec<(u32, String, String, u32)> {
w.iter()
.map(|w| (w.index, format_date(w.start), format_date(w.last), w.days))
.collect()
}
#[test] #[test]
fn december_2026_weeks() { fn month_weeks_follow_day_of_month() {
// 1 Aralık 2026 Salı → ilk hafta 6 gün (Sal–Paz), son hafta 4 gün (Pzt–Per) // Kasım 2026 (1'i Pazar): 1–8, 9–15, 16–22, 23–30 → 4 hafta
let w = weeks(d("2026-12-01"), d("2027-01-01")); let w = weeks(d("2026-11-01"), d("2026-12-01"));
let summary: Vec<(String, String, u32)> = w
.iter()
.map(|w| (format_date(w.start), format_date(w.last), w.days))
.collect();
assert_eq!( assert_eq!(
summary, summary(&w),
vec![ vec![
("2026-12-01".into(), "2026-12-06".into(), 6), (1, "2026-11-01".into(), "2026-11-08".into(), 8),
("2026-12-07".into(), "2026-12-13".into(), 7), (2, "2026-11-09".into(), "2026-11-15".into(), 7),
("2026-12-14".into(), "2026-12-20".into(), 7), (3, "2026-11-16".into(), "2026-11-22".into(), 7),
("2026-12-21".into(), "2026-12-27".into(), 7), (4, "2026-11-23".into(), "2026-11-30".into(), 8),
("2026-12-28".into(), "2026-12-31".into(), 4),
] ]
); );
assert_eq!(w.iter().map(|w| w.days).sum::<u32>(), 31); // 31 gün → son hafta 23–31 (9 gün); Şubat → 23–28 (6 gün); artık yıl → 7 gün
assert_eq!(w.last().unwrap().index, 5); for (start, end, last_days) in [
("2026-12-01", "2027-01-01", 9),
("2027-02-01", "2027-03-01", 6),
("2028-02-01", "2028-03-01", 7),
] {
let w = weeks(d(start), d(end));
assert_eq!(w.len(), 4, "{start}");
assert_eq!(w[3].start.day(), 23, "{start}");
assert_eq!(w[3].days, last_days, "{start}");
assert_eq!(
w.iter().map(|w| i64::from(w.days)).sum::<i64>(),
days_between(d(start), d(end))
);
}
} }
#[test] #[test]
fn weeks_edge_cases() { fn weeks_edge_cases() {
// Pazartesi başlayan, tam 2 hafta // Ay ortasında başlayan dönem: günler dönem başından sayılır
let w = weeks(d("2026-11-30"), d("2026-12-14")); let w = weeks(d("2026-12-15"), d("2027-01-15"));
assert_eq!(w.len(), 2); assert_eq!(
assert!(w.iter().all(|w| w.days == 7)); summary(&w)
// Tek gün (Pazar) .iter()
.map(|x| (x.1.as_str(), x.3))
.collect::<Vec<_>>(),
vec![
("2026-12-15", 8),
("2026-12-23", 7),
("2026-12-30", 7),
("2027-01-06", 9)
]
);
// Kısa dönemler: 13 gün → tek hafta, 14 gün → 8 + 6
assert_eq!(summary(&weeks(d("2026-12-01"), d("2026-12-14"))).len(), 1);
let w = weeks(d("2026-12-01"), d("2026-12-15"));
assert_eq!((w[0].days, w[1].days), (8, 6));
// Tek gün
let w = weeks(d("2026-12-06"), d("2026-12-07")); let w = weeks(d("2026-12-06"), d("2026-12-07"));
assert_eq!((w.len(), w[0].days), (1, 1)); assert_eq!((w.len(), w[0].days), (1, 1));
// Uzun dönem (62 gün) → 8 hafta, son hafta ≤ 12 gün
let w = weeks(d("2026-11-01"), d("2027-01-02"));
assert_eq!(w.len(), 8);
assert!(w.iter().all(|w| (6..=12).contains(&w.days)));
// Boş aralık // Boş aralık
assert!(weeks(d("2026-12-07"), d("2026-12-07")).is_empty()); assert!(weeks(d("2026-12-07"), d("2026-12-07")).is_empty());
} }
+12
View File
@@ -73,6 +73,18 @@ fn coll(db: &Database, c: Catalog) -> mongodb::Collection<Item> {
db.collection(c.collection()) 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. /// Hanenin kayıtları. Kategoriler grup sırasına, sonra `sort_order`'a göre sıralanır.
pub async fn list( pub async fn list(
db: &Database, db: &Database,
+291
View File
@@ -0,0 +1,291 @@
//! 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::Archived {
return Err(AppError::Conflict(
"Arşiv dönemi kapatılmaz; yalnızca kayıt tutar".into(),
));
}
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 });
// Arşiv dönemleri devir üretmediği için engel değildir
later.insert(
"status",
doc! { "$nin": [PeriodStatus::Open.as_str(), PeriodStatus::Archived.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 });
// Devir arşiv dönemine bağlanmaz (arşiv devir zincirinde yok)
f.insert("status", doc! { "$ne": PeriodStatus::Archived.as_str() });
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());
}
}
+280
View File
@@ -0,0 +1,280 @@
//! CSV okuma/yazma yardımcıları (saf; veritabanı yok). Türkçe Excel uyumlu:
//! yazarken UTF-8 BOM, `;` ayırıcı, CRLF, tutar `1250,50`, tarih `GG.AA.YYYY`.
//! Okurken esnek: BOM, `;` / `,` / sekme (başlık satırından anlaşılır), tırnaklı alanlar,
//! tutar `1.250,50` · `1250,50` · `1250.50`, tarih `13.11.2026` · `13/11/2026` · `2026-11-13`.
use crate::services::calendar::{format_date, parse_date};
pub const BOM: &str = "\u{FEFF}";
// ---------- Yazma ----------
fn quote(s: &str) -> String {
if s.contains([';', '"', '\n', '\r']) {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
/// Başlık + satırlar → CSV metni (BOM'lu)
pub fn write(headers: &[&str], rows: &[Vec<String>]) -> String {
let mut out = String::from(BOM);
let line = |cells: Vec<String>| cells.iter().map(|c| quote(c)).collect::<Vec<_>>().join(";");
out.push_str(&line(headers.iter().map(|h| h.to_string()).collect()));
out.push_str("\r\n");
for r in rows {
out.push_str(&line(r.clone()));
out.push_str("\r\n");
}
out
}
/// Kuruş → `1250,50`
pub fn money(kurus: i64) -> String {
let sign = if kurus < 0 { "-" } else { "" };
let a = kurus.unsigned_abs();
format!("{sign}{},{:02}", a / 100, a % 100)
}
/// × 1e6 → `5,5` (gereksiz sıfırsız)
pub fn e6(v: i64) -> String {
let sign = if v < 0 { "-" } else { "" };
let a = v.unsigned_abs();
let frac = format!("{:06}", a % 1_000_000);
let frac = frac.trim_end_matches('0');
if frac.is_empty() {
format!("{sign}{}", a / 1_000_000)
} else {
format!("{sign}{},{frac}", a / 1_000_000)
}
}
/// `"2026-11-13"` → `13.11.2026`
pub fn date(iso: &str) -> String {
match iso.split('-').collect::<Vec<_>>().as_slice() {
[y, m, d] => format!("{d}.{m}.{y}"),
_ => iso.to_string(),
}
}
// ---------- Okuma ----------
/// CSV metni → satırlar (başlık dahil). Boş satırlar atlanır; her satırın dosyadaki numarası.
pub fn parse(text: &str) -> Vec<(usize, Vec<String>)> {
let text = text.strip_prefix(BOM).unwrap_or(text);
let first = text.lines().next().unwrap_or("");
let delim = [';', '\t', ',']
.into_iter()
.max_by_key(|d| first.matches(*d).count())
.unwrap_or(';');
let mut rows = Vec::new();
let mut row = Vec::new();
let mut cell = String::new();
let mut in_quotes = false;
let mut line = 1;
let mut row_line = 1;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if in_quotes && chars.peek() == Some(&'"') => {
cell.push('"');
chars.next();
}
'"' if in_quotes => in_quotes = false,
'"' if cell.is_empty() => in_quotes = true,
c if c == delim && !in_quotes => row.push(std::mem::take(&mut cell)),
'\r' if !in_quotes => {}
'\n' if !in_quotes => {
row.push(std::mem::take(&mut cell));
if row.iter().any(|c| !c.trim().is_empty()) {
rows.push((row_line, std::mem::take(&mut row)));
}
row.clear();
line += 1;
row_line = line;
}
'\n' => {
cell.push(c);
line += 1;
}
c => cell.push(c),
}
}
row.push(cell);
if row.iter().any(|c| !c.trim().is_empty()) {
rows.push((row_line, row));
}
rows
}
/// Başlık adı karşılaştırması: küçük harf (Türkçe İ/I), boşluksuz
pub fn norm(s: &str) -> String {
s.trim()
.chars()
.map(|c| match c {
'İ' => 'i',
'I' => 'ı',
c => c.to_lowercase().next().unwrap_or(c),
})
.filter(|c| !c.is_whitespace())
.collect()
}
/// Başlık satırında adı verilen sütunların sırası (ilk eşleşen ad)
pub fn column(headers: &[String], names: &[&str]) -> Option<usize> {
let hs: Vec<String> = headers.iter().map(|h| norm(h)).collect();
names
.iter()
.find_map(|n| hs.iter().position(|h| *h == norm(n)))
}
/// Ondalık metin → (tam, kesir) basamakları; Türkçe ve İngilizce yazımı tanır
fn split_decimal(s: &str, max_frac: usize) -> Option<(bool, String, String)> {
let s: String = s
.trim()
.chars()
.filter(|c| !c.is_whitespace() && *c != '₺' && *c != '\u{a0}')
.collect();
let (neg, s) = match s.strip_prefix('-') {
Some(r) => (true, r.to_string()),
None => (false, s),
};
if s.is_empty() {
return None;
}
let (int, frac) = if let Some((i, f)) = s.rsplit_once(',') {
// Virgül ondalık; noktalar binlik
(i.replace('.', ""), f.to_string())
} else if s.matches('.').count() == 1 {
let (i, f) = s.split_once('.').unwrap();
// Tek nokta + tam 3 rakam → binlik (1.000), değilse ondalık (12.50)
if f.len() == 3 && i != "0" && !i.is_empty() {
(format!("{i}{f}"), String::new())
} else {
(i.to_string(), f.to_string())
}
} else {
(s.replace('.', ""), String::new())
};
let int = if int.is_empty() { "0".to_string() } else { int };
if !int.bytes().all(|b| b.is_ascii_digit())
|| !frac.bytes().all(|b| b.is_ascii_digit())
|| frac.len() > max_frac
{
return None;
}
Some((neg, int, frac))
}
/// `1.250,50` → 125050 kuruş (en fazla 2 ondalık)
pub fn parse_money(s: &str) -> Option<i64> {
let (neg, int, frac) = split_decimal(s, 2)?;
let v =
int.parse::<i64>().ok()?.checked_mul(100)? + format!("{frac:0<2}").parse::<i64>().ok()?;
Some(if neg { -v } else { v })
}
/// `5,5` → 5_500_000 (en fazla 6 ondalık; eksi yok)
pub fn parse_e6(s: &str) -> Option<i64> {
let (neg, int, frac) = split_decimal(s, 6)?;
if neg {
return None;
}
int.parse::<i64>()
.ok()?
.checked_mul(1_000_000)?
.checked_add(format!("{frac:0<6}").parse::<i64>().ok()?)
}
/// `13.11.2026` · `13/11/2026` · `2026-11-13` → `"2026-11-13"`
pub fn parse_date_any(s: &str) -> Option<String> {
let s = s.trim();
let iso = if s.contains('-') && s.len() == 10 && s.as_bytes()[4] == b'-' {
s.to_string()
} else {
let p: Vec<&str> = s.split(['.', '/']).collect();
match p.as_slice() {
[d, m, y] if y.len() == 4 => format!("{y}-{m:0>2}-{d:0>2}"),
_ => return None,
}
};
parse_date(&iso).map(format_date)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_excel_friendly() {
let csv = write(
&["Ad", "Not"],
&[
vec!["Market".into(), "a;b".into()],
vec!["\"x\"".into(), String::new()],
],
);
assert_eq!(
csv,
"\u{FEFF}Ad;Not\r\nMarket;\"a;b\"\r\n\"\"\"x\"\"\";\r\n"
);
assert_eq!(money(125_050), "1250,50");
assert_eq!(money(-5), "-0,05");
assert_eq!(e6(5_500_000), "5,5");
assert_eq!(e6(10_000_000), "10");
assert_eq!(e6(125_000), "0,125");
assert_eq!(date("2026-11-13"), "13.11.2026");
}
#[test]
fn parses_quotes_delimiters_and_line_numbers() {
let rows = parse(
"\u{FEFF}Tarih;Tutar;Açıklama\r\n13.11.2026;1.250,50;\"Kafe; tatlı\"\r\n\r\n14.11.2026;10;\"iki\nsatır\"\n",
);
assert_eq!(rows.len(), 3);
assert_eq!(
rows[1],
(
2,
vec!["13.11.2026".into(), "1.250,50".into(), "Kafe; tatlı".into()]
)
);
assert_eq!(rows[2].0, 4, "boş satır sayılır ama atlanır");
assert_eq!(rows[2].1[2], "iki\nsatır");
// Virgül ayraçlı dosya
let rows = parse("date,amount\n2026-11-13,\"1,5\"\n");
assert_eq!(rows[1].1, vec!["2026-11-13", "1,5"]);
}
#[test]
fn columns_by_name() {
let h: Vec<String> = ["TARİH", "Tutar ", "Ödeme Türü"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(column(&h, &["Tarih"]), Some(0));
assert_eq!(column(&h, &["ödeme türü"]), Some(2));
assert_eq!(column(&h, &["Kategori"]), None);
}
#[test]
fn money_and_dates() {
assert_eq!(parse_money("1.250,50"), Some(125_050));
assert_eq!(parse_money("1250,5"), Some(125_050));
assert_eq!(parse_money("1250.50"), Some(125_050));
assert_eq!(parse_money("1.000"), Some(100_000));
assert_eq!(parse_money("-45,90 ₺"), Some(-4_590));
assert_eq!(parse_money("1,234"), None, "3 ondalık para olmaz");
assert_eq!(parse_money("abc"), None);
assert_eq!(parse_e6("5,5"), Some(5_500_000));
assert_eq!(parse_e6("0.125"), Some(125_000));
assert_eq!(parse_e6("-1"), None);
assert_eq!(parse_date_any("13.11.2026").as_deref(), Some("2026-11-13"));
assert_eq!(parse_date_any("3/1/2026").as_deref(), Some("2026-01-03"));
assert_eq!(parse_date_any("2026-11-13").as_deref(), Some("2026-11-13"));
assert_eq!(parse_date_any("30.02.2026"), None);
assert_eq!(parse_date_any("13.11.26"), None);
}
}
File diff suppressed because it is too large Load Diff
+300
View File
@@ -0,0 +1,300 @@
//! Günlük defter: harcama kayıtları (KAKEIBO_PROMPT §8.4).
//!
//! Kurallar:
//! - Tutar kuruş, > 0. Tarih kaydın döneminin içinde olmalı. İstisna: ekstreden gelen **iade**
//! kayıtları eksi tutarlıdır (kategorinin harcamasından düşer); düzenlemede eksi kalır.
//! - 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 {
// İade kaydı (ekstreden, eksi tutar) iade olarak kalır: tutar eksi girilir
let amount = if current.amount < 0 {
if amount >= 0 {
return Err(AppError::BadRequest("İade tutarı eksi olmalı".into()));
}
-clean_amount(-amount)?
} else {
clean_amount(amount)?
};
set.insert("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
}
}
+15
View File
@@ -1,12 +1,27 @@
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar. //! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
pub mod account; pub mod account;
pub mod asset;
pub mod backup;
pub mod bank_import;
pub mod budget; pub mod budget;
pub mod calendar; pub mod calendar;
pub mod catalog; pub mod catalog;
pub mod closing;
pub mod csv_io;
pub mod debt;
pub mod entry;
pub mod holding;
pub mod invite; pub mod invite;
pub mod money; pub mod money;
pub mod monthly_report;
pub mod note; pub mod note;
pub mod period; pub mod period;
pub mod plan; pub mod plan;
pub mod rate_sources;
pub mod rates;
pub mod reports;
pub mod review;
pub mod seed; pub mod seed;
pub mod stats;
pub mod transfer;
File diff suppressed because it is too large Load Diff
+99 -13
View File
@@ -41,9 +41,14 @@ pub struct PeriodChanges {
pub start_date: Option<String>, pub start_date: Option<String>,
pub end_date: Option<String>, pub end_date: Option<String>,
pub savings_goal: Option<i64>, pub savings_goal: Option<i64>,
/// `Some("")` → metni kaldır
pub reflection_md: Option<String>,
pub version: Option<i32>, 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> { fn coll(db: &Database) -> mongodb::Collection<Period> {
db.collection(col::PERIODS) 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. /// Plan kalemleri, notlar ve (Faz 5) harcamalar bunu kullanır.
pub async fn get_open(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> { pub async fn get_open(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
let p = get(db, household_id, id).await?; let p = get(db, household_id, id).await?;
if p.status == PeriodStatus::Closed { if !p.status.is_writable() {
return Err(closed()); return Err(closed());
} }
Ok(p) Ok(p)
@@ -105,6 +110,8 @@ pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResul
// Bir önceki dönem (devir ve "önceki aydan kopyala" için) // Bir önceki dönem (devir ve "önceki aydan kopyala" için)
let mut prev_f = scope(household_id); let mut prev_f = scope(household_id);
prev_f.insert("start_date", doc! { "$lt": format_date(start) }); prev_f.insert("start_date", doc! { "$lt": format_date(start) });
// Arşiv dönemleri devir zincirinde yok
prev_f.insert("status", doc! { "$ne": PeriodStatus::Archived.as_str() });
let previous = coll(db) let previous = coll(db)
.find_one(prev_f) .find_one(prev_f)
.sort(doc! { "start_date": -1 }) .sort(doc! { "start_date": -1 })
@@ -117,24 +124,51 @@ pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResul
d.insert("end_date", format_date(end)); d.insert("end_date", format_date(end));
d.insert("status", PeriodStatus::Open.as_str()); d.insert("status", PeriodStatus::Open.as_str());
d.insert("savings_goal", savings_goal); d.insert("savings_goal", savings_goal);
d.insert("reflection_md", ""); d.insert("reflection_md", Bson::Null);
d.insert("to_savings", Bson::Null);
d.insert( d.insert(
"previous_period_id", "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_at", Bson::Null);
d.insert("closed_by", Bson::Null); d.insert("closed_by", Bson::Null);
d.insert("reopened_at", Bson::Null); d.insert("reopened_at", Bson::Null);
match db.collection::<Document>(col::PERIODS).insert_one(d).await { // Tek transaction: dönem + önceki dönemden bekleyen devrin bu döneme bağlanması
Ok(_) => {} let mut session = db.client().start_session().await?;
// Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller session.start_transaction().await?;
Err(e) if is_duplicate_key(&e) => { let result = async {
return Err(AppError::Conflict( db.collection::<Document>(col::PERIODS)
"Zaten açık bir dönem var. Sayfayı yenileyin.".into(), .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) coll(db)
@@ -150,7 +184,7 @@ pub async fn update(
ch: PeriodChanges, ch: PeriodChanges,
) -> AppResult<Period> { ) -> AppResult<Period> {
let current = get(db, household_id, id).await?; let current = get(db, household_id, id).await?;
if current.status == PeriodStatus::Closed { if !current.status.is_writable() {
return Err(closed()); return Err(closed());
} }
if ch.version.is_some_and(|v| v != current.version) { if ch.version.is_some_and(|v| v != current.version) {
@@ -177,6 +211,9 @@ pub async fn update(
if let Some(goal) = ch.savings_goal { if let Some(goal) = ch.savings_goal {
set.insert("savings_goal", validate_amount(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); let mut f = scope(household_id);
f.insert("_id", id); f.insert("_id", id);
@@ -190,6 +227,19 @@ pub async fn update(
// ---------- doğrulamalar ---------- // ---------- 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)> { fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> {
let bad = |m: &str| AppError::BadRequest(m.into()); let bad = |m: &str| AppError::BadRequest(m.into());
let start = parse_date(start).ok_or_else(|| bad("Başlangıç tarihi geçersiz"))?; let start = parse_date(start).ok_or_else(|| bad("Başlangıç tarihi geçersiz"))?;
@@ -225,6 +275,42 @@ fn clean_name(name: Option<&str>, start: Date, end: Date) -> AppResult<String> {
/// Başka bir dönemle çakışma: `mevcut.başlangıç < yeni.bitiş && mevcut.bitiş > yeni.başlangıç` /// Başka bir dönemle çakışma: `mevcut.başlangıç < yeni.bitiş && mevcut.bitiş > yeni.başlangıç`
/// (`YYYY-MM-DD` metinleri sözlük sırasıyla karşılaştırılabilir). /// (`YYYY-MM-DD` metinleri sözlük sırasıyla karşılaştırılabilir).
/// Geçmiş (arşiv) dönem açar: ekstre içe aktarımında dönemi olmayan aylar için.
/// Açık dönem kuralına takılmaz (arşiv açık sayılmaz); çakışma yine yasak. Plan, bütçe, devir yok.
pub async fn create_archive(
db: &Database,
household_id: Uuid,
start: Date,
end: Date,
) -> AppResult<Period> {
if end <= start {
return Err(AppError::BadRequest(
"Arşiv dönemi tarihleri geçersiz".into(),
));
}
ensure_no_overlap(db, household_id, start, end, None).await?;
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
d.insert("name", clean_name(None, start, end)?);
d.insert("start_date", format_date(start));
d.insert("end_date", format_date(end));
d.insert("status", PeriodStatus::Archived.as_str());
d.insert("savings_goal", 0_i64);
d.insert("reflection_md", Bson::Null);
d.insert("to_savings", Bson::Null);
d.insert("previous_period_id", Bson::Null);
d.insert("closed_at", Bson::Null);
d.insert("closed_by", Bson::Null);
d.insert("reopened_at", Bson::Null);
db.collection::<Document>(col::PERIODS)
.insert_one(d)
.await?;
coll(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("arşiv dönemi okunamadı".into()))
}
async fn ensure_no_overlap( async fn ensure_no_overlap(
db: &Database, db: &Database,
household_id: Uuid, household_id: Uuid,
@@ -276,7 +362,7 @@ async fn ensure_entries_inside(
Ok(()) Ok(())
} }
fn stale() -> AppError { pub fn stale() -> AppError {
AppError::Conflict("Dönem başka biri tarafından değiştirildi, sayfayı yenileyin".into()) 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}; use mongodb::bson::{Document, Uuid};
@@ -29,6 +30,35 @@ const PAYMENT_METHODS: &[(&str, &str)] = &[
("Havale/EFT", "🔁"), ("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> { pub fn categories(household_id: Uuid) -> Vec<Document> {
CATEGORIES CATEGORIES
.iter() .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,
})
}
+805
View File
@@ -0,0 +1,805 @@
//! Uygulamanın kendi CSV biçimiyle dışa / içe aktarma (KAKEIBO_PROMPT §8.9):
//! harcamalar, plan kalemleri, varlık işlemleri.
//!
//! İçe aktarma iki aşamalı: `commit = false` → yalnızca rapor (deneme); `true` → geçerli satırlar
//! yazılır. Hatalı satırlar satır numarası ve nedeniyle raporlanır, diğerleri etkilenmez.
//! **Aynı dosya iki kez yüklense de çift kayıt olmaz:** veritabanında aynısı olan satır atlanır
//! (dosyada aynı satırdan iki tane, veritabanında bir tane varsa biri eklenir).
//! Sütunlar başlık adıyla bulunur (sıra serbest); kategori / ödeme türü / varlık adla eşleşir.
use std::collections::HashMap;
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
};
use crate::{
db::col,
error::{AppError, AppResult},
models::{
asset::{Asset, AssetKind, AssetTx, TxType},
common::{new_meta, scope},
entry::Entry,
group::Group,
period::Period,
plan_item::{PlanItem, PlanKind},
},
services::{
asset,
catalog::{self, Catalog},
csv_io::{self, column, norm},
entry::MAX_DESCRIPTION,
holding::{self, Move},
period,
plan::{self, NewItem},
},
};
/// Bir dosyada en fazla satır
pub const MAX_ROWS: usize = 20_000;
/// Önizlemede gösterilen geçerli satır sayısı
const PREVIEW: usize = 10;
#[derive(Debug, Default)]
pub struct Report {
/// Başlık hariç dolu satır sayısı
pub total: usize,
/// Eklenebilir satırlar (tekrarlar hariç)
pub valid: usize,
/// Veritabanında zaten olduğu için atlanan
pub duplicates: usize,
/// Yazılan (deneme: 0)
pub imported: usize,
/// (satır numarası, neden)
pub errors: Vec<(usize, String)>,
/// İlk geçerli satırlar, gösterime hazır hücreler
pub preview: Vec<Vec<String>>,
}
/// (satır numarası, hücreler)
type Rows = Vec<(usize, Vec<String>)>;
fn rows_of(text: &str) -> AppResult<(Vec<String>, Rows)> {
let mut rows = csv_io::parse(text);
if rows.is_empty() {
return Err(AppError::BadRequest("Dosya boş".into()));
}
if rows.len() - 1 > MAX_ROWS {
return Err(AppError::BadRequest(format!(
"Dosya en fazla {MAX_ROWS} satır olabilir; parçalara bölün"
)));
}
let header = rows.remove(0).1;
Ok((header, rows))
}
/// Zorunlu sütunlar: (anahtar adlar) → sıra; eksikse anlaşılır hata
fn require(header: &[String], cols: &[(&str, &[&str])]) -> AppResult<Vec<usize>> {
let mut out = Vec::new();
let mut missing = Vec::new();
for (label, names) in cols {
match column(header, names) {
Some(i) => out.push(i),
None => missing.push(*label),
}
}
if !missing.is_empty() {
return Err(AppError::BadRequest(format!(
"Gerekli sütun eksik: {}",
missing.join(", ")
)));
}
Ok(out)
}
fn cell(row: &[String], i: Option<usize>) -> &str {
i.and_then(|i| row.get(i)).map_or("", |s| s.trim())
}
fn period_for<'a>(periods: &'a [Period], date: &str) -> Result<&'a Period, String> {
let p = periods
.iter()
.find(|p| p.start_date.as_str() <= date && date < p.end_date.as_str())
.ok_or_else(|| "Bu tarih için dönem yok".to_string())?;
if !p.status.is_writable() {
return Err(format!("Dönem kapalı ({})", p.name));
}
Ok(p)
}
fn group_from(s: &str) -> Option<Group> {
let n = norm(s);
Group::ALL
.into_iter()
.find(|g| norm(g.label()) == n || g.as_str() == n)
}
/// Ada göre katalog kaydı (grup verilmişse o grupta). Aynı ad birden çok grupta → hata.
fn find_by_name<'a>(
items: &'a [catalog::Item],
name: &str,
group: Option<Group>,
what: &str,
) -> Result<&'a catalog::Item, String> {
let n = norm(name);
let matches: Vec<&catalog::Item> = items
.iter()
.filter(|c| norm(&c.name) == n && (group.is_none() || c.group == group))
.collect();
match matches.as_slice() {
[one] => Ok(one),
[] => Err(format!("{what} bulunamadı: {name}")),
_ => Err(format!(
"\"{name}\" birden fazla grupta var; Grup sütununu doldurun"
)),
}
}
/// Tekrar sayacı: anahtar → veritabanında kaç tane var (eşleştikçe azalır)
struct Existing(HashMap<String, usize>);
impl Existing {
fn new(keys: impl Iterator<Item = String>) -> Self {
let mut m = HashMap::new();
for k in keys {
*m.entry(k).or_insert(0) += 1;
}
Self(m)
}
/// Varsa bir tanesini "tüket" ve `true` dön
fn take(&mut self, key: &str) -> bool {
match self.0.get_mut(key) {
Some(n) if *n > 0 => {
*n -= 1;
true
}
_ => false,
}
}
}
// ================= Harcamalar =================
const E_HEADERS: [&str; 6] = [
"Tarih",
"Tutar",
"Grup",
"Kategori",
"Ödeme türü",
"Açıklama",
];
fn entry_key(date: &str, amount: i64, category: Uuid, desc: Option<&str>) -> String {
format!("{date}|{amount}|{category}|{}", desc.unwrap_or("").trim())
}
pub async fn export_entries(
db: &Database,
household_id: Uuid,
from: Option<&str>,
to: Option<&str>,
) -> AppResult<String> {
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
let pms = catalog::list(db, Catalog::PaymentMethods, household_id, true).await?;
let mut f = scope(household_id);
let mut range = Document::new();
if let Some(d) = from {
range.insert("$gte", d);
}
if let Some(d) = to {
range.insert("$lte", d);
}
if !range.is_empty() {
f.insert("date", range);
}
let mut c = db
.collection::<Entry>(col::ENTRIES)
.find(f)
.sort(doc! { "date": 1, "_id": 1 })
.await?;
let mut rows = Vec::new();
while c.advance().await? {
let e = c.deserialize_current()?;
let cat = cats.iter().find(|x| x.id == e.category_id);
let pm = e
.payment_method_id
.and_then(|id| pms.iter().find(|x| x.id == id));
rows.push(vec![
csv_io::date(&e.date),
csv_io::money(e.amount),
e.group.label().to_string(),
cat.map_or(String::new(), |x| x.name.clone()),
pm.map_or(String::new(), |x| x.name.clone()),
e.description.unwrap_or_default(),
]);
}
Ok(csv_io::write(&E_HEADERS, &rows))
}
pub async fn import_entries(
db: &Database,
household_id: Uuid,
user_id: Uuid,
text: &str,
commit: bool,
) -> AppResult<Report> {
let (header, rows) = rows_of(text)?;
let req = require(
&header,
&[
("Tarih", &["Tarih", "date"]),
("Tutar", &["Tutar", "amount"]),
("Kategori", &["Kategori", "category"]),
],
)?;
let (c_date, c_amount, c_cat) = (Some(req[0]), Some(req[1]), Some(req[2]));
let c_group = column(&header, &["Grup", "group"]);
let c_pm = column(&header, &["Ödeme türü", "Ödeme", "payment_method"]);
let c_desc = column(&header, &["Açıklama", "description"]);
let periods = period::list(db, household_id).await?;
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
let pms = catalog::list(db, Catalog::PaymentMethods, household_id, true).await?;
let mut existing = {
let mut c = db
.collection::<Entry>(col::ENTRIES)
.find(scope(household_id))
.await?;
let mut keys = Vec::new();
while c.advance().await? {
let e = c.deserialize_current()?;
keys.push(entry_key(
&e.date,
e.amount,
e.category_id,
e.description.as_deref(),
));
}
Existing::new(keys.into_iter())
};
let mut report = Report {
total: rows.len(),
..Default::default()
};
let mut docs = Vec::new();
for (line, row) in &rows {
let parsed = (|| -> Result<Document, String> {
let date = csv_io::parse_date_any(cell(row, c_date))
.ok_or_else(|| format!("Tarih geçersiz: {}", cell(row, c_date)))?;
let p = period_for(&periods, &date)?;
let amount = csv_io::parse_money(cell(row, c_amount))
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_amount)))?;
if amount <= 0 {
return Err("Tutar sıfırdan büyük olmalı".into());
}
period::validate_amount(amount).map_err(|e| e.to_string())?;
let group = match cell(row, c_group) {
"" => None,
g => Some(group_from(g).ok_or_else(|| format!("Grup tanınmadı: {g}"))?),
};
let cat = find_by_name(&cats, cell(row, c_cat), group, "Kategori")?;
let cat_group = cat.group.ok_or("Kategorinin grubu yok")?;
let pm = match cell(row, c_pm) {
"" => None,
n => Some(find_by_name(&pms, n, None, "Ödeme türü")?.id),
};
let desc = match cell(row, c_desc) {
"" => None,
d if d.chars().count() > MAX_DESCRIPTION => {
return Err(format!(
"Açıklama en fazla {MAX_DESCRIPTION} karakter olabilir"
));
}
d => Some(d.to_string()),
};
let mut d = new_meta(household_id);
d.insert("period_id", p.id);
d.insert("date", &date);
d.insert("amount", amount);
d.insert("group", cat_group.as_str());
d.insert("category_id", cat.id);
d.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
d.insert("description", desc.clone().map_or(Bson::Null, Bson::String));
d.insert("created_by", user_id);
d.insert("__key", entry_key(&date, amount, cat.id, desc.as_deref()));
d.insert(
"__preview",
vec![
csv_io::date(&date),
csv_io::money(amount),
cat.name.clone(),
desc.unwrap_or_default(),
],
);
Ok(d)
})();
match parsed {
Err(e) => report.errors.push((*line, e)),
Ok(mut d) => {
let key = d.get_str("__key").unwrap_or_default().to_string();
if existing.take(&key) {
report.duplicates += 1;
continue;
}
if report.preview.len() < PREVIEW
&& let Ok(p) = d.get_array("__preview")
{
report.preview.push(
p.iter()
.map(|x| x.as_str().unwrap_or_default().to_string())
.collect(),
);
}
d.remove("__key");
d.remove("__preview");
docs.push(d);
}
}
}
report.valid = docs.len();
if commit && !docs.is_empty() {
db.collection::<Document>(col::ENTRIES)
.insert_many(docs)
.await?;
report.imported = report.valid;
}
Ok(report)
}
// ================= Plan kalemleri =================
const P_HEADERS: [&str; 6] = ["Dönem başlangıcı", "Dönem", "Tür", "Ad", "Tutar", "Tarih"];
fn kind_label(k: PlanKind) -> &'static str {
match k {
PlanKind::Income => "Gelir",
PlanKind::Expense => "Sabit gider",
}
}
fn kind_from(s: &str) -> Option<PlanKind> {
match norm(s).as_str() {
"gelir" | "income" => Some(PlanKind::Income),
"gider" | "sabitgider" | "expense" => Some(PlanKind::Expense),
_ => None,
}
}
fn plan_key(period: Uuid, kind: PlanKind, name: &str, amount: i64) -> String {
format!("{period}|{}|{}|{amount}", kind.as_str(), norm(name))
}
async fn all_plan_items(db: &Database, household_id: Uuid) -> AppResult<Vec<PlanItem>> {
let mut c = db
.collection::<PlanItem>(col::PLAN_ITEMS)
.find(scope(household_id))
.sort(doc! { "period_id": 1, "kind": 1, "sort_order": 1 })
.await?;
let mut out = Vec::new();
while c.advance().await? {
out.push(c.deserialize_current()?);
}
Ok(out)
}
pub async fn export_plan_items(db: &Database, household_id: Uuid) -> AppResult<String> {
let mut periods = period::list(db, household_id).await?;
periods.reverse(); // eskiden yeniye
let items = all_plan_items(db, household_id).await?;
let mut rows = Vec::new();
for p in &periods {
// Uygulamadaki sıra: önce gelirler, sonra sabit giderler
let mut list: Vec<&PlanItem> = items.iter().filter(|i| i.period_id == p.id).collect();
list.sort_by_key(|i| (i.kind != PlanKind::Income, i.sort_order));
for i in list {
rows.push(vec![
csv_io::date(&p.start_date),
p.name.clone(),
kind_label(i.kind).to_string(),
i.name.clone(),
csv_io::money(i.amount),
i.date.as_deref().map(csv_io::date).unwrap_or_default(),
]);
}
}
Ok(csv_io::write(&P_HEADERS, &rows))
}
pub async fn import_plan_items(
db: &Database,
household_id: Uuid,
text: &str,
commit: bool,
) -> AppResult<Report> {
let (header, rows) = rows_of(text)?;
let req = require(
&header,
&[
("Dönem başlangıcı", &["Dönem başlangıcı", "period_start"]),
("Tür", &["Tür", "kind"]),
("Ad", &["Ad", "name"]),
("Tutar", &["Tutar", "amount"]),
],
)?;
let (c_period, c_kind, c_name, c_amount) =
(Some(req[0]), Some(req[1]), Some(req[2]), Some(req[3]));
let c_date = column(&header, &["Tarih", "date"]);
let periods = period::list(db, household_id).await?;
let mut existing = Existing::new(
all_plan_items(db, household_id)
.await?
.iter()
.map(|i| plan_key(i.period_id, i.kind, &i.name, i.amount)),
);
let mut report = Report {
total: rows.len(),
..Default::default()
};
let mut todo: Vec<(usize, Uuid, NewItem)> = Vec::new();
for (line, row) in &rows {
let parsed = (|| -> Result<(Uuid, NewItem, Vec<String>), String> {
let start = csv_io::parse_date_any(cell(row, c_period))
.ok_or_else(|| format!("Dönem başlangıcı geçersiz: {}", cell(row, c_period)))?;
let p = period_for(&periods, &start)?;
let kind = kind_from(cell(row, c_kind)).ok_or_else(|| {
format!(
"Tür \"Gelir\" ya da \"Sabit gider\" olmalı: {}",
cell(row, c_kind)
)
})?;
let name = cell(row, c_name);
if name.is_empty() {
return Err("Ad boş".into());
}
let amount = csv_io::parse_money(cell(row, c_amount))
.filter(|a| *a > 0)
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_amount)))?;
let date = match cell(row, c_date) {
"" => None,
d => Some(csv_io::parse_date_any(d).ok_or_else(|| format!("Tarih geçersiz: {d}"))?),
};
let preview = vec![
p.name.clone(),
kind_label(kind).into(),
name.into(),
csv_io::money(amount),
];
Ok((
p.id,
NewItem {
kind,
name: name.to_string(),
amount,
date,
},
preview,
))
})();
match parsed {
Err(e) => report.errors.push((*line, e)),
Ok((pid, item, preview)) => {
if existing.take(&plan_key(pid, item.kind, &item.name, item.amount)) {
report.duplicates += 1;
continue;
}
if report.preview.len() < PREVIEW {
report.preview.push(preview);
}
todo.push((*line, pid, item));
}
}
}
report.valid = todo.len();
if commit {
// Ad uzunluğu, tarihin dönem içinde olması vb. son denetim `plan::create`'te
for (line, pid, item) in todo {
match plan::create(db, household_id, pid, item).await {
Ok(_) => report.imported += 1,
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => {
report.errors.push((line, m));
}
Err(e) => return Err(e),
}
}
report.valid = report.imported;
report.errors.sort_by_key(|(l, _)| *l);
}
Ok(report)
}
// ================= Varlık işlemleri =================
const A_HEADERS: [&str; 8] = [
"Tarih",
"Varlık kodu",
"Varlık",
"İşlem",
"Miktar",
"Birim fiyat",
"Tutar",
"Not",
];
fn tx_key(asset: Uuid, date: &str, t: TxType, q: i64, total: i64) -> String {
format!("{asset}|{date}|{}|{q}|{total}", t.as_str())
}
async fn household_assets(db: &Database, household_id: Uuid) -> AppResult<Vec<Asset>> {
let mut c = db
.collection::<Asset>(col::ASSETS)
.find(scope(household_id))
.sort(doc! { "sort_order": 1 })
.await?;
let mut out = Vec::new();
while c.advance().await? {
out.push(c.deserialize_current()?);
}
Ok(out)
}
async fn household_txs(db: &Database, household_id: Uuid) -> AppResult<Vec<AssetTx>> {
let mut c = db
.collection::<AssetTx>(col::ASSET_TRANSACTIONS)
.find(scope(household_id))
.sort(doc! { "date": 1, "_id": 1 })
.await?;
let mut out = Vec::new();
while c.advance().await? {
out.push(c.deserialize_current()?);
}
Ok(out)
}
pub async fn export_asset_txs(db: &Database, household_id: Uuid) -> AppResult<String> {
let assets = household_assets(db, household_id).await?;
let rows = household_txs(db, household_id)
.await?
.into_iter()
.map(|t| {
let a = assets.iter().find(|a| a.id == t.asset_id);
vec![
csv_io::date(&t.date),
a.map_or(String::new(), |a| a.code.clone()),
a.map_or(String::new(), |a| a.name.clone()),
match t.tx_type {
TxType::Buy => "Alım",
TxType::Sell => "Satım",
}
.to_string(),
csv_io::e6(t.quantity_e6),
csv_io::e6(t.unit_price_e6),
csv_io::money(t.total),
t.note.unwrap_or_default(),
]
})
.collect::<Vec<_>>();
Ok(csv_io::write(&A_HEADERS, &rows))
}
struct NewTxRow {
line: usize,
asset: Uuid,
tx: asset::NewTx,
quantity_e6: i64,
}
pub async fn import_asset_txs(
db: &Database,
household_id: Uuid,
user_id: Uuid,
text: &str,
commit: bool,
) -> AppResult<Report> {
let (header, rows) = rows_of(text)?;
let req = require(
&header,
&[
("Tarih", &["Tarih", "date"]),
("İşlem", &["İşlem", "type"]),
("Tutar", &["Tutar", "total"]),
],
)?;
let (c_date, c_type, c_total) = (Some(req[0]), Some(req[1]), Some(req[2]));
let c_code = column(&header, &["Varlık kodu", "Kod", "code"]);
let c_name = column(&header, &["Varlık", "asset"]);
if c_code.is_none() && c_name.is_none() {
return Err(AppError::BadRequest(
"Gerekli sütun eksik: Varlık kodu (ya da Varlık)".into(),
));
}
let c_qty = column(&header, &["Miktar", "quantity"]);
let c_price = column(&header, &["Birim fiyat", "unit_price"]);
let c_note = column(&header, &["Not", "note"]);
let assets = household_assets(db, household_id).await?;
let existing_txs = household_txs(db, household_id).await?;
let mut existing = Existing::new(
existing_txs
.iter()
.map(|t| tx_key(t.asset_id, &t.date, t.tx_type, t.quantity_e6, t.total)),
);
let mut report = Report {
total: rows.len(),
..Default::default()
};
let mut todo: Vec<NewTxRow> = Vec::new();
for (line, row) in &rows {
let parsed = (|| -> Result<(NewTxRow, Vec<String>), String> {
let a = {
let code = norm(cell(row, c_code));
let name = norm(cell(row, c_name));
assets
.iter()
.find(|a| {
(!code.is_empty() && norm(&a.code) == code)
|| (code.is_empty() && norm(&a.name) == name)
})
.ok_or_else(|| {
format!(
"Varlık bulunamadı: {}",
if code.is_empty() {
cell(row, c_name)
} else {
cell(row, c_code)
}
)
})?
};
if !a.is_active {
return Err(format!("Varlık pasif: {}", a.name));
}
let date = csv_io::parse_date_any(cell(row, c_date))
.ok_or_else(|| format!("Tarih geçersiz: {}", cell(row, c_date)))?;
let tx_type = match norm(cell(row, c_type)).as_str() {
"alım" | "alis" | "alış" | "buy" => TxType::Buy,
"satım" | "satış" | "satis" | "sell" => TxType::Sell,
t => return Err(format!("İşlem \"Alım\" ya da \"Satım\" olmalı: {t}")),
};
let total = csv_io::parse_money(cell(row, c_total))
.filter(|t| *t > 0)
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_total)))?;
let quantity_e6 = if a.kind == AssetKind::Cash {
total * 10_000
} else {
csv_io::parse_e6(cell(row, c_qty))
.filter(|q| *q > 0)
.ok_or_else(|| format!("Miktar geçersiz: {}", cell(row, c_qty)))?
};
let unit_price_e6 = match cell(row, c_price) {
"" => None,
p => Some(
csv_io::parse_e6(p)
.filter(|p| *p > 0)
.ok_or_else(|| format!("Birim fiyat geçersiz: {p}"))?,
),
};
let note = match cell(row, c_note) {
"" => None,
n => Some(n.to_string()),
};
let preview = vec![
csv_io::date(&date),
a.name.clone(),
if tx_type == TxType::Buy {
"Alım"
} else {
"Satım"
}
.into(),
if a.kind == AssetKind::Cash {
csv_io::money(total)
} else {
format!("{} {}", csv_io::e6(quantity_e6), a.unit_label)
},
];
Ok((
NewTxRow {
line: *line,
asset: a.id,
quantity_e6,
tx: asset::NewTx {
tx_type,
date,
quantity_e6,
unit_price_e6,
total,
note,
},
},
preview,
))
})();
match parsed {
Err(e) => report.errors.push((*line, e)),
Ok((r, preview)) => {
if existing.take(&tx_key(
r.asset,
&r.tx.date,
r.tx.tx_type,
r.quantity_e6,
r.tx.total,
)) {
report.duplicates += 1;
continue;
}
if report.preview.len() < PREVIEW {
report.preview.push(preview);
}
todo.push(r);
}
}
}
// Tarih sırasıyla (aynı gün dosya sırası) eldekinden fazla satış denetimi:
// mevcut işlemler + kabul edilen yeniler; aşan satır reddedilir, diğerleri sürer.
todo.sort_by(|a, b| (a.tx.date.as_str(), a.line).cmp(&(b.tx.date.as_str(), b.line)));
let mut accepted: Vec<NewTxRow> = Vec::new();
for r in todo {
let mut list: Vec<(String, u8, usize, Move)> = existing_txs
.iter()
.filter(|t| t.asset_id == r.asset)
.enumerate()
.map(|(i, t)| {
(
t.date.clone(),
0,
i,
Move {
tx_type: t.tx_type,
quantity_e6: t.quantity_e6,
total: t.total,
},
)
})
.chain(
accepted
.iter()
.chain(std::iter::once(&r))
.filter(|x| x.asset == r.asset)
.map(|x| {
(
x.tx.date.clone(),
1,
x.line,
Move {
tx_type: x.tx.tx_type,
quantity_e6: x.quantity_e6,
total: x.tx.total,
},
)
}),
)
.collect();
list.sort_by(|a, b| (a.0.as_str(), a.1, a.2).cmp(&(b.0.as_str(), b.1, b.2)));
let moves: Vec<Move> = list.into_iter().map(|x| x.3).collect();
if holding::holding(&moves).is_err() {
report.errors.push((
r.line,
"Satış miktarı o tarihte eldeki miktarı aşıyor".into(),
));
} else {
accepted.push(r);
}
}
report.errors.sort_by_key(|(l, _)| *l);
report.valid = accepted.len();
if commit {
report.valid = 0;
for r in accepted {
match asset::create_tx(db, household_id, user_id, r.asset, r.tx).await {
Ok(_) => report.imported += 1,
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => {
report.errors.push((r.line, m))
}
Err(e) => return Err(e),
}
}
report.valid = report.imported;
report.errors.sort_by_key(|(l, _)| *l);
}
Ok(report)
}
+15 -2
View File
@@ -5,7 +5,7 @@ use std::{sync::Arc, time::Duration};
use mongodb::Database; use mongodb::Database;
use crate::{ use crate::{
auth::{jwt::Jwt, limiter::LoginLimiter}, auth::{captcha::Captcha, guard::AttemptGuard, jwt::Jwt, limiter::LoginLimiter},
config::Config, config::Config,
}; };
@@ -15,7 +15,11 @@ pub struct AppState {
pub db: Database, pub db: Database,
pub config: Arc<Config>, pub config: Arc<Config>,
pub jwt: Arc<Jwt>, pub jwt: Arc<Jwt>,
/// Şifre değiştirmede mevcut şifre denemeleri (kullanıcı başına)
pub login_limiter: Arc<LoginLimiter>, pub login_limiter: Arc<LoginLimiter>,
/// Giriş / kayıt: IP başına üst üste 3 hata → 15 dk kilit
pub guard: Arc<AttemptGuard>,
pub captcha: Arc<Captcha>,
} }
impl AppState { impl AppState {
@@ -23,9 +27,18 @@ impl AppState {
Self { Self {
db, db,
jwt: Arc::new(Jwt::new(&config.jwt_secret)), jwt: Arc::new(Jwt::new(&config.jwt_secret)),
// Aynı e-postaya 15 dakikada en fazla 5 hatalı giriş denemesi
login_limiter: Arc::new(LoginLimiter::new(5, Duration::from_secs(15 * 60))), login_limiter: Arc::new(LoginLimiter::new(5, Duration::from_secs(15 * 60))),
guard: Arc::new(AttemptGuard::default()),
captcha: Arc::new(Captcha::from_secret(config.turnstile_secret_key.as_deref())),
config: Arc::new(config), config: Arc::new(config),
} }
} }
} }
impl AppState {
/// Testler: bot doğrulamasını ağsız sabit sonuçla değiştir
pub fn with_captcha(mut self, captcha: Captcha) -> Self {
self.captcha = Arc::new(captcha);
self
}
}
+267
View File
@@ -0,0 +1,267 @@
//! Hesap ve hane ayarları: profil adı, şifre değiştirme (diğer oturumlar kapanır),
//! hane adı + bütçe varsayılanları, üye çıkarma, yetkiler.
mod common;
use std::time::Duration;
use axum::http::{Method, StatusCode};
use common::{Session, TestApp};
use serde_json::{Value, json};
async fn login(app: &TestApp, email: &str, password: &str) -> (StatusCode, Option<String>) {
let (st, _, cookie) = app
.send(
Method::POST,
"/auth/login",
None,
Some(json!({ "email": email, "password": password, "remember_me": true })),
)
.await;
(st, cookie)
}
async fn me(app: &TestApp, s: &Session) -> (StatusCode, Value) {
app.get("/auth/me", s).await
}
fn with_cookie(s: &Session, cookie: String) -> Session {
Session {
cookie,
household_id: s.household_id.clone(),
}
}
#[tokio::test]
async fn profile_name_and_password_change_closes_other_sessions() {
let app = TestApp::new().await;
let a = app.register("Ayse", None).await;
let (_, m) = me(&app, &a).await;
let email = m["user"]["email"].as_str().unwrap().to_string();
// Ad
let (st, m) = app
.patch("/auth/me", &a, json!({ "name": " Ayşe Ç. " }))
.await;
assert_eq!(
(st, m["user"]["name"].as_str()),
(StatusCode::OK, Some("Ayşe Ç."))
);
let (st, _) = app.patch("/auth/me", &a, json!({ "name": " " })).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
// İkinci cihaz
let (st, other) = login(&app, &email, "test-sifre-123").await;
assert_eq!(st, StatusCode::OK);
let other = with_cookie(&a, other.unwrap());
tokio::time::sleep(Duration::from_millis(1100)).await; // iat saniye hassasiyetinde
// Yanlış mevcut şifre / kısa / aynı şifre
let pw = |cur: &str, new: &str| json!({ "current_password": cur, "new_password": new });
let (st, b) = app
.post(
"/auth/me/password",
&a,
pw("yanlis-sifre", "yeni-sifre-456"),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert_eq!(b["error"]["message"], "Mevcut şifre hatalı");
let (st, _) = app
.post("/auth/me/password", &a, pw("test-sifre-123", "kisa"))
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
let (st, b) = app
.post(
"/auth/me/password",
&a,
pw("test-sifre-123", "test-sifre-123"),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST, "{b}");
// Doğru → bu cihaz yeni çerezle devam, diğer cihaz düşer
let (st, _, cookie) = app
.send(
Method::POST,
"/auth/me/password",
Some(&a.cookie),
Some(pw("test-sifre-123", "yeni-sifre-456")),
)
.await;
assert_eq!(st, StatusCode::NO_CONTENT);
let fresh = with_cookie(&a, cookie.expect("yeni oturum çerezi"));
assert_eq!(me(&app, &fresh).await.0, StatusCode::OK);
assert_eq!(
me(&app, &other).await.0,
StatusCode::UNAUTHORIZED,
"diğer cihaz"
);
assert_eq!(me(&app, &a).await.0, StatusCode::UNAUTHORIZED, "eski çerez");
// Giriş: eski şifre geçmez, yeni geçer
assert_eq!(
login(&app, &email, "test-sifre-123").await.0,
StatusCode::UNAUTHORIZED
);
assert_eq!(
login(&app, &email, "yeni-sifre-456").await.0,
StatusCode::OK
);
app.cleanup().await;
}
#[tokio::test]
async fn wrong_current_password_is_rate_limited() {
let app = TestApp::new().await;
let a = app.register("Ayse", None).await;
let body = json!({ "current_password": "yanlis", "new_password": "yeni-sifre-456" });
for _ in 0..5 {
let (st, _) = app.post("/auth/me/password", &a, body.clone()).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
let (st, _) = app
.post(
"/auth/me/password",
&a,
json!({ "current_password": "test-sifre-123", "new_password": "yeni-sifre-456" }),
)
.await;
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS, "doğru şifre de bekler");
app.cleanup().await;
}
#[tokio::test]
async fn household_settings_and_budget_default_split() {
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 (_, h) = app.get("/household", &owner).await;
assert_eq!(
h["settings"],
json!({ "budget_split_pct": { "needs": 50, "wants": 30, "culture": 10, "unexpected": 10 }, "budget_suggestion": "default" })
);
let update = json!({
"name": "Çakır Ailesi",
"budget_split_pct": { "needs": 60, "wants": 20, "culture": 10, "unexpected": 10 },
"budget_suggestion": "avg3",
});
let (st, _) = app.patch("/household", &member, update.clone()).await;
assert_eq!(st, StatusCode::FORBIDDEN, "üye değiştiremez");
let (st, h) = app.patch("/household", &owner, update).await;
assert_eq!(st, StatusCode::OK, "{h}");
assert_eq!(h["name"], "Çakır Ailesi");
assert_eq!(h["settings"]["budget_split_pct"]["needs"], 60);
assert_eq!(h["settings"]["budget_suggestion"], "avg3");
let (_, m) = me(&app, &member).await;
assert_eq!(
m["household"]["name"], "Çakır Ailesi",
"üye de yeni adı görür"
);
// Toplam 100 değil / geçersiz yöntem
for bad in [
json!({ "budget_split_pct": { "needs": 60, "wants": 30, "culture": 10, "unexpected": 10 } }),
json!({ "budget_suggestion": "hepsi" }),
json!({ "name": "" }),
] {
let (st, b) = app.patch("/household", &owner, bad).await;
assert_eq!(st, StatusCode::BAD_REQUEST, "{b}");
}
// Varsayılan öneri yeni yüzdeleri kullanır: harcanabilir 100.000 → 60/20/10/10
let (_, p) = app
.post(
"/periods",
&owner,
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
)
.await;
let pid = p["id"].as_str().unwrap();
app.post(
&format!("/periods/{pid}/plan-items"),
&owner,
json!({ "kind": "income", "name": "Maaş", "amount": 100_000 }),
)
.await;
let (_, sug) = app
.get(
&format!("/periods/{pid}/budget/suggestion?method=default"),
&owner,
)
.await;
assert_eq!(
sug["amounts"],
json!({ "needs": 60_000, "wants": 20_000, "culture": 10_000, "unexpected": 10_000 })
);
app.cleanup().await;
}
#[tokio::test]
async fn owner_removes_member_who_can_be_invited_again() {
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 (_, mm) = me(&app, &member).await;
let member_id = mm["user"]["id"].as_str().unwrap().to_string();
let member_email = mm["user"]["email"].as_str().unwrap().to_string();
let (_, om) = me(&app, &owner).await;
let owner_id = om["user"]["id"].as_str().unwrap().to_string();
// Üye kimseyi çıkaramaz; sahip kendini çıkaramaz; başka hane bulunamaz
let (st, _) = app
.delete(&format!("/household/members/{owner_id}"), &member)
.await;
assert_eq!(st, StatusCode::FORBIDDEN);
let (st, _) = app
.delete(&format!("/household/members/{owner_id}"), &owner)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
let stranger = app.register("Yabanci", None).await;
let (st, _) = app
.delete(&format!("/household/members/{member_id}"), &stranger)
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
// Çıkar → oturumu anında düşer, listede yok, giriş yapamaz
let (st, _) = app
.delete(&format!("/household/members/{member_id}"), &owner)
.await;
assert_eq!(st, StatusCode::NO_CONTENT);
assert_eq!(me(&app, &member).await.0, StatusCode::UNAUTHORIZED);
let (_, h) = app.get("/household", &owner).await;
assert_eq!(h["members"].as_array().unwrap().len(), 1);
assert_eq!(
login(&app, &member_email, "test-sifre-123").await.0,
StatusCode::UNAUTHORIZED
);
// Aynı e-posta yeni davetle yeniden katılabilir
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
let (st, b, _) = app
.send(
Method::POST,
"/auth/register",
None,
Some(json!({
"name": "Uye",
"email": member_email,
"password": "test-sifre-123",
"invite_code": inv["code"],
})),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
app.cleanup().await;
}
+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;
}
+394
View File
@@ -0,0 +1,394 @@
//! JSON tam hane yedeği: dışa aktarma, önizleme, aynı / başka haneye geri yükleme,
//! geri dönüş noktaları ("geri al"), bozuk dosya, yetki.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use mongodb::bson::{Bson, Document, doc};
use serde_json::{Value, json};
async fn id_by(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_or_else(|| panic!("{name} yok"))["id"]
.as_str()
.unwrap()
.to_string()
}
fn id(v: &Value) -> String {
v["id"].as_str().unwrap().to_string()
}
/// Ekim (kapalı, birikim TL'ye aktarılmış, devirli) + Kasım (açık, harcama, not, bütçe)
async fn build(app: &TestApp, s: &Session) -> (String, String) {
let market = id_by(app, s, "/categories", "Market").await;
let (_, oct) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
)
.await;
let oct = id(&oct);
app.post(
&format!("/periods/{oct}/plan-items"),
s,
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
)
.await;
for (date, amount) in [("2026-10-05", 200_000), ("2026-10-06", 1)] {
let (st, e) = app
.post(
&format!("/periods/{oct}/entries"),
s,
json!({ "date": date, "amount": amount, "category_id": market, "description": "Ekim" }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{e}");
if amount == 1 {
app.delete(&format!("/entries/{}", id(&e)), s).await; // silinmiş kayıt da taşınır
}
}
let (st, b) = app
.post(
&format!("/periods/{oct}/close"),
s,
json!({ "to_savings": 500_000 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
let (st, b) = app
.post(&format!("/periods/{oct}/to-savings"), s, json!({}))
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
let (_, nov) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
)
.await;
let nov = id(&nov);
app.put(
&format!("/periods/{nov}/budget"),
s,
json!({ "needs": 100_000, "wants": 50_000, "culture": 0, "unexpected": 0 }),
)
.await;
app.post(
&format!("/periods/{nov}/entries"),
s,
json!({ "date": "2026-11-03", "amount": 45_090, "category_id": market, "description": "Migros" }),
)
.await;
app.post(
&format!("/periods/{nov}/notes"),
s,
json!({ "context": "planning", "title": "Hedef" }),
)
.await;
(oct, nov)
}
async fn download(app: &TestApp, s: &Session) -> String {
let (st, ct, body) = app.get_text("/backup/download", s).await;
assert_eq!(st, StatusCode::OK, "{body}");
assert!(ct.starts_with("application/json"), "{ct}");
body
}
fn data(file: &str) -> Value {
serde_json::from_str::<Value>(file).unwrap()["data"].clone()
}
/// Bir dönemin özet + harcama listesi (karşılaştırma için)
async fn view(app: &TestApp, s: &Session, pid: &str) -> (Value, Value) {
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), s).await;
let (_, list) = app.get(&format!("/periods/{pid}/entries"), s).await;
(sum, list)
}
async fn points(app: &TestApp, s: &Session) -> Vec<Value> {
let (_, st) = app.get("/backup/status", s).await;
st["restore_points"].as_array().unwrap().clone()
}
#[tokio::test]
async fn same_household_restore_and_undo_chain() {
let app = TestApp::new().await;
let s = app.register("Sahip", None).await;
let (oct, nov) = build(&app, &s).await;
let before = view(&app, &s, &nov).await;
let (_, st) = app.get("/backup/status", &s).await;
assert!(st["last_backup_at"].is_null(), "henüz yedek yok");
let file = download(&app, &s).await;
let v: Value = serde_json::from_str(&file).unwrap();
assert_eq!(v["format"], "kakeibo-backup");
assert_eq!(v["counts"]["entries"], 2, "silinmiş sayılmaz");
assert_eq!(
v["data"]["entries"].as_array().unwrap().len(),
3,
"ama dosyada var"
);
assert!(v["data"].get("users").is_none() && !file.contains("password"));
let (_, st) = app.get("/backup/status", &s).await;
assert!(st["last_backup_at"].is_string());
// Yedekten sonra değişiklikler
let market = id_by(&app, &s, "/categories", "Market").await;
app.post(
&format!("/periods/{nov}/entries"),
&s,
json!({ "date": "2026-11-10", "amount": 999, "category_id": market }),
)
.await;
app.patch(
&format!("/categories/{market}"),
&s,
json!({ "name": "Market (yeni)" }),
)
.await;
// Önizleme: hiçbir şey yazmaz
let (st, p) = app.post_text("/backup/preview", &s, &file).await;
assert_eq!(st, StatusCode::OK, "{p}");
assert_eq!(p["same_household"], true);
assert_eq!(
(p["first_date"].as_str(), p["last_date"].as_str()),
(Some("2026-10-01"), Some("2026-11-30"))
);
let entries = p["counts"]
.as_array()
.unwrap()
.iter()
.find(|c| c["kind"] == "entries")
.unwrap();
assert_eq!(
(entries["backup"].as_i64(), entries["current"].as_i64()),
(Some(2), Some(3))
);
assert_eq!(p["changed_since"], 2, "{p}");
assert!(p["warnings"][0].as_str().unwrap().contains("2 kayıt"));
assert!(points(&app, &s).await.is_empty());
// Geri yükle → yedekteki durum birebir
let (st, r) = app.post_text("/backup/restore", &s, &file).await;
assert_eq!(st, StatusCode::OK, "{r}");
assert_eq!(view(&app, &s, &nov).await, before);
assert_eq!(
data(&download(&app, &s).await),
data(&file),
"kayıtlar birebir"
);
let (_, oct_p) = app.get(&format!("/periods/{oct}"), &s).await;
assert_eq!(oct_p["status"], "closed");
// Tür korunur: version i32, tutar i64
let e: Document = app
.db
.collection::<Document>("entries")
.find_one(doc! { "amount": 45_090_i64 })
.await
.unwrap()
.unwrap();
assert!(matches!(e.get("version"), Some(Bson::Int32(_))));
assert!(matches!(e.get("amount"), Some(Bson::Int64(_))));
let list = points(&app, &s).await;
assert_eq!(list.len(), 1);
assert_eq!(list[0]["reason"], "before_restore");
assert_eq!(list[0]["entries"], 3, "geri yüklemeden önceki durum");
assert_eq!(list[0]["id"], r["restore_point_id"]);
// Geri al → değişiklikler geri gelir; bu da bir nokta bırakır
let (st, u) = app
.post(
&format!(
"/backup/restore-points/{}/restore",
r["restore_point_id"].as_str().unwrap()
),
&s,
json!({}),
)
.await;
assert_eq!(st, StatusCode::OK, "{u}");
let (_, list) = app.get(&format!("/periods/{nov}/entries"), &s).await;
assert_eq!(list.as_array().unwrap().len(), 2);
assert!(
app.get("/categories", &s)
.await
.1
.as_array()
.unwrap()
.iter()
.any(|c| c["name"] == "Market (yeni)")
);
let list = points(&app, &s).await;
assert_eq!(
list.iter().map(|p| p["reason"].clone()).collect::<Vec<_>>(),
vec![json!("before_undo"), json!("before_restore")]
);
// "Geri al"ı geri al → yine yedekteki durum
let (st, _) = app
.post(
&format!(
"/backup/restore-points/{}/restore",
u["restore_point_id"].as_str().unwrap()
),
&s,
json!({}),
)
.await;
assert_eq!(st, StatusCode::OK);
assert_eq!(view(&app, &s, &nov).await, before);
// En fazla 3 nokta saklanır
app.post_text("/backup/restore", &s, &file).await;
assert_eq!(points(&app, &s).await.len(), 3);
app.cleanup().await;
}
#[tokio::test]
async fn copy_to_another_household_gets_new_ids() {
let app = TestApp::new().await;
let a = app.register("Kaynak", None).await;
let (oct, nov) = build(&app, &a).await;
let file = download(&app, &a).await;
let a_before = view(&app, &a, &nov).await;
let b = app.register("Hedef", None).await;
let (st, p) = app.post_text("/backup/preview", &b, &file).await;
assert_eq!(st, StatusCode::OK, "{p}");
assert_eq!(p["same_household"], false);
assert_eq!(p["source_name"], "Kaynak Hanesi");
assert!(
p["warnings"][0]
.as_str()
.unwrap()
.contains("başka bir haneden")
);
for round in 0..2 {
// İki kez yüklense de kimlik çakışmaz
let (st, r) = app.post_text("/backup/restore", &b, &file).await;
assert_eq!(st, StatusCode::OK, "tur {round}: {r}");
}
let (_, periods) = app.get("/periods", &b).await;
let periods = periods.as_array().unwrap();
assert_eq!(periods.len(), 2);
assert!(
periods
.iter()
.all(|p| p["id"] != oct.as_str() && p["id"] != nov.as_str())
);
let b_nov = periods.iter().find(|p| p["status"] == "open").unwrap();
let b_nov_id = id(b_nov);
// Aynı rakamlar, yeni kimlikler; referanslar bu hanenin kayıtlarına
let (sum, list) = view(&app, &b, &b_nov_id).await;
assert_eq!(sum, a_before.0);
let entry = &list[0];
assert_eq!(entry["amount"], 45_090);
assert_ne!(entry["id"], a_before.1[0]["id"]);
let (_, cats) = app.get("/categories", &b).await;
assert!(
cats.as_array()
.unwrap()
.iter()
.any(|c| c["id"] == entry["category_id"])
);
let (_, me) = app.get("/auth/me", &b).await;
assert_eq!(
entry["created_by"], me["user"]["id"],
"kaynak kişi → geri yükleyen"
);
let (_, pf) = app.get("/portfolio", &b).await;
assert!(
pf.to_string().contains("500000"),
"birikim aktarımı da taşındı: {pf}"
);
// Kaynak hane değişmedi; hedef kaynağın kayıtlarını göremez
assert_eq!(view(&app, &a, &nov).await, a_before);
let (st, _) = app.get(&format!("/periods/{nov}"), &b).await;
assert_eq!(st, StatusCode::NOT_FOUND);
app.cleanup().await;
}
#[tokio::test]
async fn bad_files_and_permissions() {
let app = TestApp::new().await;
let owner = app.register("Sahip", None).await;
let (_, nov) = build(&app, &owner).await;
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
let member = app
.register("Uye", Some(inv["code"].as_str().unwrap()))
.await;
// Üye yedek alabilir, geri yükleyemez
let file = download(&app, &member).await;
for path in ["/backup/preview", "/backup/restore"] {
let (st, _) = app.post_text(path, &member, &file).await;
assert_eq!(st, StatusCode::FORBIDDEN, "{path}");
}
// Bozuk / değiştirilmiş / yabancı dosya → anlaşılır hata, hiçbir şey yazılmaz
let before = view(&app, &owner, &nov).await;
let tampered = file.replacen("45090", "45091", 1);
let cases = [
(tampered.as_str(), "bozuk"),
(&file[..file.len() / 2], "Kakeibo yedeği değil"),
("Tarih;Tutar\n01.11.2026;5", "Kakeibo yedeği değil"),
];
for (body, msg) in cases {
let (st, b) = app.post_text("/backup/restore", &owner, body).await;
assert_eq!(st, StatusCode::BAD_REQUEST, "{b}");
assert!(b["error"]["message"].as_str().unwrap().contains(msg), "{b}");
}
let newer = file.replacen("\"schema_version\": ", "\"schema_version\": 99", 1);
let (st, b) = app.post_text("/backup/preview", &owner, &newer).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert!(
b["error"]["message"]
.as_str()
.unwrap()
.contains("daha yeni"),
"{b}"
);
assert_eq!(view(&app, &owner, &nov).await, before);
assert!(points(&app, &owner).await.is_empty());
// Geri dönüş noktası: üye göremez; başka hane kullanamaz
let (_, r) = app.post_text("/backup/restore", &owner, &file).await;
let pid = r["restore_point_id"].as_str().unwrap();
assert!(points(&app, &member).await.is_empty());
let (st, _) = app
.post(
&format!("/backup/restore-points/{pid}/restore"),
&member,
json!({}),
)
.await;
assert_eq!(st, StatusCode::FORBIDDEN);
let other = app.register("Diger", None).await;
let (st, _) = app
.post(
&format!("/backup/restore-points/{pid}/restore"),
&other,
json!({}),
)
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
app.cleanup().await;
}
+376
View File
@@ -0,0 +1,376 @@
//! Ekstre içe aktarma: öneri (tür/kategori/kural), arşiv dönemi, iade, gelir, yoksay,
//! tekrar tespiti, kapalı/gelecek dönem engeli, arşiv kuralları.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use serde_json::{Value, json};
async fn id_by(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()
}
fn row(date: &str, desc: &str, amount: i64, dir: &str) -> Value {
json!({ "date": date, "description": desc, "amount": amount, "direction": dir })
}
async fn preview(app: &TestApp, s: &Session, source: &str, rows: &[Value]) -> Value {
let (st, b) = app
.post(
"/bank-import/preview",
s,
json!({ "source": source, "rows": rows }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
b
}
async fn commit(app: &TestApp, s: &Session, pm: Option<&str>, rows: Vec<Value>) -> Value {
let (st, b) = app
.post(
"/bank-import/commit",
s,
json!({ "payment_method_id": pm, "rows": rows }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
b
}
fn with(mut r: Value, kind: &str, cat: Option<&str>) -> Value {
r["kind"] = json!(kind);
if let Some(c) = cat {
r["category_id"] = json!(c);
}
r
}
#[tokio::test]
async fn card_statement_with_archive_refund_and_rules() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let market = id_by(&app, &s, "/categories", "Market").await;
let kart = id_by(&app, &s, "/payment-methods", "Kredi Kartı").await;
// Ekim kapalı, Kasım açık
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();
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();
let rows = vec![
row("2026-09-10", "MİGROS 1234 İSTANBUL TR", 45_000, "out"),
row("2026-09-12", "IADE MIGROS ISTANBUL", 10_000, "in"),
row("2026-09-30", "ÖDEMENİZ İÇİN TEŞEKKÜRLER", 500_000, "in"),
row("2026-11-05", "A101 MAGAZACILIK", 20_000, "out"),
row("2099-12-20", "GELECEK", 1_000, "out"),
row("2026-10-05", "EKIM", 1_000, "out"),
];
let p = preview(&app, &s, "card", &rows).await;
let kinds: Vec<&str> = p["rows"]
.as_array()
.unwrap()
.iter()
.map(|r| r["kind"].as_str().unwrap())
.collect();
assert_eq!(
kinds,
[
"expense", "refund", "ignore", "expense", "expense", "expense"
]
);
assert_eq!(p["rows"][0]["period"], "archive");
assert_eq!(p["rows"][0]["period_name"], "Eylül 2026");
assert_eq!(p["rows"][3]["period"], "existing");
assert_eq!(p["rows"][3]["period_name"], "Kasım 2026");
assert_eq!(p["rows"][4]["message"], "Gelecek tarihli satır");
assert_eq!(
p["rows"][5]["message"],
"Dönem kapalı (Ekim 2026); düzeltme için yeniden açın"
);
assert_eq!(
p["archives"],
json!([{ "name": "Eylül 2026", "start_date": "2026-09-01", "last_date": "2026-09-30" }])
);
assert_eq!(p["rows"][0]["from_rule"], false);
// Aktar: kullanıcı seçimleri
let chosen = vec![
with(rows[0].clone(), "expense", Some(&market)),
with(rows[1].clone(), "refund", Some(&market)),
with(rows[2].clone(), "ignore", None),
with(rows[3].clone(), "expense", Some(&market)),
with(rows[4].clone(), "expense", Some(&market)),
with(rows[5].clone(), "expense", Some(&market)),
];
let r = commit(&app, &s, Some(&kart), chosen.clone()).await;
assert_eq!(
(
r["entries"].as_u64(),
r["refunds"].as_u64(),
r["ignored"].as_u64()
),
(Some(2), Some(1), Some(1))
);
assert_eq!(r["archives"], json!(["Eylül 2026"]));
assert_eq!(
r["errors"]
.as_array()
.unwrap()
.iter()
.map(|e| e["index"].as_u64().unwrap())
.collect::<Vec<_>>(),
[4, 5]
);
// Arşiv dönemi: Eylül, net harcama 450 − 100 = 350
let (_, periods) = app.get("/periods", &s).await;
let sep = periods
.as_array()
.unwrap()
.iter()
.find(|p| p["name"] == "Eylül 2026")
.unwrap()
.clone();
assert_eq!(sep["status"], "archived");
let sep_id = sep["id"].as_str().unwrap();
let (_, sum) = app.get(&format!("/periods/{sep_id}/summary"), &s).await;
assert_eq!(sum["spent_total"], 35_000, "iade harcamadan düşer");
let (_, entries) = app.get(&format!("/periods/{sep_id}/entries"), &s).await;
let refund = entries
.as_array()
.unwrap()
.iter()
.find(|e| e["amount"] == -10_000)
.unwrap()
.clone();
assert_eq!(refund["payment_method_id"], kart.as_str());
assert_eq!(refund["description"], "IADE MIGROS ISTANBUL");
let (_, nov_entries) = app.get(&format!("/periods/{nov}/entries"), &s).await;
assert_eq!(nov_entries.as_array().unwrap().len(), 1);
// Aynı ekstre tekrar: öğrenilen kural + tekrar işareti; aktarma çift kayıt yapmaz
let p = preview(&app, &s, "card", &rows).await;
assert_eq!(p["rows"][0]["from_rule"], true);
assert_eq!(p["rows"][0]["category_id"], market.as_str());
assert_eq!(p["rows"][1]["kind"], "refund");
assert_eq!(p["rows"][2]["kind"], "ignore", "yoksay kuralı da öğrenildi");
assert_eq!(
p["rows"]
.as_array()
.unwrap()
.iter()
.map(|r| r["duplicate"].as_bool().unwrap())
.collect::<Vec<_>>(),
[true, true, false, true, false, false]
);
assert_eq!(p["archives"], json!([]), "Eylül artık var");
let r = commit(&app, &s, Some(&kart), chosen).await;
assert_eq!(
(r["entries"].as_u64(), r["duplicates"].as_u64()),
(Some(0), Some(3))
);
// İade düzenlemede eksi kalır
let rid = refund["id"].as_str().unwrap();
let (st, _) = app
.patch(&format!("/entries/{rid}"), &s, json!({ "amount": 5_000 }))
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
let (st, e) = app
.patch(&format!("/entries/{rid}"), &s, json!({ "amount": -5_000 }))
.await;
assert_eq!(st, StatusCode::OK, "{e}");
// Arşiv kapatılmaz; öneriler arşivi kullanır (Ekim 0 + Eylül 400 net) / 2 dönem
let (st, b) = app
.post(&format!("/periods/{sep_id}/close"), &s, json!({}))
.await;
assert_eq!(st, StatusCode::CONFLICT, "{b}");
let (_, sug) = app
.get(&format!("/periods/{nov}/budget/suggestion?method=avg3"), &s)
.await;
assert_eq!(sug["available"], true, "{sug}");
assert_eq!(sug["amounts"]["needs"], 20_000, "(450 − 50) / 2");
// Başka hane kuralları görmez
let other = app.register("Diger", None).await;
let p = preview(&app, &other, "card", &rows[..1]).await;
assert_eq!(p["rows"][0]["from_rule"], false);
assert_eq!(p["rows"][0]["duplicate"], false);
app.cleanup().await;
}
#[tokio::test]
async fn account_statement_income_and_transfers() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let fatura = id_by(&app, &s, "/categories", "Faturalar").await;
let rows = vec![
row("2026-08-01", "MAAŞ ÖDEMESİ ACME A.Ş.", 4_500_000, "in"),
row("2026-08-05", "KREDİ KARTI ÖDEMESİ", 1_200_000, "out"),
row("2026-08-07", "ELEKTRIK FATURASI", 85_000, "out"),
row("2026-08-31", "HESAPLAR ARASI VİRMAN", 100_000, "out"),
];
let p = preview(&app, &s, "account", &rows).await;
let kinds: Vec<&str> = p["rows"]
.as_array()
.unwrap()
.iter()
.map(|r| r["kind"].as_str().unwrap())
.collect();
assert_eq!(kinds, ["income", "ignore", "expense", "ignore"]);
let r = commit(
&app,
&s,
None,
vec![
with(rows[0].clone(), "income", None),
with(rows[1].clone(), "ignore", None),
with(rows[2].clone(), "expense", Some(&fatura)),
with(rows[3].clone(), "ignore", None),
],
)
.await;
assert_eq!(
(
r["incomes"].as_u64(),
r["entries"].as_u64(),
r["ignored"].as_u64()
),
(Some(1), Some(1), Some(2))
);
let (_, periods) = app.get("/periods", &s).await;
let aug = periods
.as_array()
.unwrap()
.iter()
.find(|p| p["name"] == "Ağustos 2026")
.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let (_, sum) = app.get(&format!("/periods/{aug}/summary"), &s).await;
assert_eq!(sum["income_total"], 4_500_000);
assert_eq!(sum["spent_total"], 85_000);
let (_, items) = app.get(&format!("/periods/{aug}/plan-items"), &s).await;
assert_eq!(items[0]["name"], "MAAŞ ÖDEMESİ ACME A.Ş.");
assert_eq!(items[0]["date"], "2026-08-01");
// Gelir de tekrar etmez
let p = preview(&app, &s, "account", &rows[..1]).await;
assert_eq!(p["rows"][0]["duplicate"], true);
// Kategori seçilmemiş harcama → satır hatası; boş istek → 400
let r = commit(
&app,
&s,
None,
vec![with(row("2026-08-09", "SU", 1_000, "out"), "expense", None)],
)
.await;
assert_eq!(r["errors"][0]["message"], "Kategori seçin");
let (st, _) = app
.post(
"/bank-import/preview",
&s,
json!({ "source": "card", "rows": [] }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
app.cleanup().await;
}
#[tokio::test]
async fn archives_are_skipped_in_the_carryover_chain() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let market = id_by(&app, &s, "/categories", "Market").await;
// Ekim: gelir 10.000, 5.000 birikime → 5.000 artı devreder (bağlanacak dönem yok)
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();
app.post(
&format!("/periods/{oct}/plan-items"),
&s,
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
)
.await;
app.post(
&format!("/periods/{oct}/close"),
&s,
json!({ "to_savings": 500_000 }),
)
.await;
// Kasım ekstresi → Kasım arşiv dönemi
let r = commit(
&app,
&s,
None,
vec![with(
row("2026-11-03", "MIGROS", 10_000, "out"),
"expense",
Some(&market),
)],
)
.await;
assert_eq!(r["archives"], json!(["Kasım 2026"]));
// Aralık açılır: önceki (devir) dönemi arşiv değil Ekim → artı Aralık'a bağlanır
let (st, dec) = app
.post(
"/periods",
&s,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01" }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{dec}");
let dec = dec["id"].as_str().unwrap().to_string();
let (_, sum) = app.get(&format!("/periods/{dec}/summary"), &s).await;
assert_eq!(sum["carryover_surplus"], 500_000);
// Ekim yeniden açılıp kapatılırsa yeni devir yine Aralık'a (arşiv Kasım değil)
app.post(&format!("/periods/{oct}/reopen"), &s, json!({}))
.await;
let (st, b) = app
.post(
&format!("/periods/{oct}/close"),
&s,
json!({ "to_savings": 400_000 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
assert_eq!(b["carryover"]["to_period_id"], dec.as_str());
let (_, sum) = app.get(&format!("/periods/{dec}/summary"), &s).await;
assert_eq!(sum["carryover_surplus"], 600_000);
app.cleanup().await;
}
+16 -20
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 // 4.800.000 − 1.545.000 − 500.000 − 100.000 + 20.000
"spendable": 2_675_000, "spendable": 2_675_000,
"budget_total": 0, "budget_total": 0,
"unallocated": 2_675_000 "unallocated": 2_675_000,
"spent_total": 0,
"remaining": 2_675_000
}) })
); );
@@ -137,7 +139,7 @@ async fn monthly_budget_is_split_into_weeks_by_days() {
// Başta hepsi 0 // Başta hepsi 0
let (_, b) = app.get(&url, &s).await; let (_, b) = app.get(&url, &s).await;
assert_eq!(b.as_array().unwrap().len(), 4); assert_eq!(b.as_array().unwrap().len(), 4);
assert_eq!(week_amounts(group(&b, "needs")), vec![0; 5]); assert_eq!(week_amounts(group(&b, "needs")), vec![0; 4]);
let (st, b) = app let (st, b) = app
.put( .put(
@@ -149,12 +151,9 @@ async fn monthly_budget_is_split_into_weeks_by_days() {
assert_eq!(st, StatusCode::OK, "{b}"); assert_eq!(st, StatusCode::OK, "{b}");
let needs = group(&b, "needs"); let needs = group(&b, "needs");
assert_eq!(needs["monthly"], 100_000); assert_eq!(needs["monthly"], 100_000);
// Aralık: 6,7,7,7,4 gün → toplam tam 100.000 // Aralık: 1–8, 9–15, 16–22, 23–31 → 8,7,7,9 gün; toplam tam 100.000
assert_eq!( assert_eq!(week_amounts(needs), vec![25_806, 22_581, 22_581, 29_032]);
week_amounts(needs), assert_eq!(week_amounts(group(&b, "culture")), vec![8, 7, 7, 9]);
vec![19_355, 22_581, 22_581, 22_580, 12_903]
);
assert_eq!(week_amounts(group(&b, "culture")), vec![6, 7, 7, 7, 4]);
for g in b.as_array().unwrap() { for g in b.as_array().unwrap() {
assert_eq!( assert_eq!(
week_amounts(g).iter().sum::<i64>(), week_amounts(g).iter().sum::<i64>(),
@@ -205,20 +204,17 @@ async fn manual_week_override_redistributes_the_rest() {
.await; .await;
let weeks_url = format!("/periods/{pid}/budget/weeks"); let weeks_url = format!("/periods/{pid}/budget/weeks");
// 2. hafta (7–13 Ara) elle 50.000 → kalan 50.000 diğer haftalara (6+7+7+4 = 24 gün) // 2. hafta (9–15 Ara) elle 50.000 → kalan 50.000 diğer haftalara (8+7+9 = 24 gün)
let (st, b) = app let (st, b) = app
.put( .put(
&weeks_url, &weeks_url,
&s, &s,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 50_000 }), json!({ "group": "needs", "week_start": "2026-12-09", "amount": 50_000 }),
) )
.await; .await;
assert_eq!(st, StatusCode::OK, "{b}"); assert_eq!(st, StatusCode::OK, "{b}");
let needs = group(&b, "needs"); let needs = group(&b, "needs");
assert_eq!( assert_eq!(week_amounts(needs), vec![16_667, 50_000, 14_583, 18_750]);
week_amounts(needs),
vec![12_500, 50_000, 14_584, 14_583, 8_333]
);
assert_eq!(week_amounts(needs).iter().sum::<i64>(), 100_000); assert_eq!(week_amounts(needs).iter().sum::<i64>(), 100_000);
let manual: Vec<bool> = needs["weeks"] let manual: Vec<bool> = needs["weeks"]
.as_array() .as_array()
@@ -226,7 +222,7 @@ async fn manual_week_override_redistributes_the_rest() {
.iter() .iter()
.map(|w| w["is_manual"].as_bool().unwrap()) .map(|w| w["is_manual"].as_bool().unwrap())
.collect(); .collect();
assert_eq!(manual, vec![false, true, false, false, false]); assert_eq!(manual, vec![false, true, false, false]);
assert_eq!(needs["weekly_over"], false); assert_eq!(needs["weekly_over"], false);
// Aylık tutar değişince elle girilen hafta korunur // Aylık tutar değişince elle girilen hafta korunur
@@ -245,25 +241,25 @@ async fn manual_week_override_redistributes_the_rest() {
.put( .put(
&weeks_url, &weeks_url,
&s, &s,
json!({ "group": "needs", "week_start": "2026-12-14", "amount": 30_000 }), json!({ "group": "needs", "week_start": "2026-12-16", "amount": 30_000 }),
) )
.await; .await;
let needs = group(&b, "needs"); let needs = group(&b, "needs");
assert_eq!(needs["weekly_over"], true); assert_eq!(needs["weekly_over"], true);
assert_eq!(week_amounts(needs), vec![0, 50_000, 30_000, 0, 0]); assert_eq!(week_amounts(needs), vec![0, 50_000, 30_000, 0]);
// null → otomatiğe dön // null → otomatiğe dön
app.put( app.put(
&weeks_url, &weeks_url,
&s, &s,
json!({ "group": "needs", "week_start": "2026-12-14", "amount": null }), json!({ "group": "needs", "week_start": "2026-12-16", "amount": null }),
) )
.await; .await;
let (_, b) = app let (_, b) = app
.put( .put(
&weeks_url, &weeks_url,
&s, &s,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": null }), json!({ "group": "needs", "week_start": "2026-12-09", "amount": null }),
) )
.await; .await;
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000); assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
@@ -370,7 +366,7 @@ async fn budget_closed_period_and_isolation() {
app.put( app.put(
&format!("/periods/{pid}/budget/weeks"), &format!("/periods/{pid}/budget/weeks"),
&b, &b,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 1 }), json!({ "group": "needs", "week_start": "2026-12-09", "amount": 1 }),
) )
.await .await
.0, .0,
+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;
}
+90
View File
@@ -57,11 +57,64 @@ impl TestApp {
port: 0, port: 0,
registration, registration,
static_dir: None, static_dir: None,
rates_refresh_minutes: 0,
turnstile_site_key: None,
turnstile_secret_key: None,
trust_proxy: true,
}; };
let router = routes::router(AppState::new(db.clone(), config)); let router = routes::router(AppState::new(db.clone(), config));
Self { router, db } Self { router, db }
} }
/// Bot doğrulaması açık (ağsız sabit sonuç) bir uygulama: giriş/kayıt güvenlik testleri
pub async fn with_captcha(pass: bool) -> Self {
let app = Self::new().await;
dotenvy::dotenv().ok();
let config = Config {
mongodb_uri: std::env::var("MONGODB_URI").unwrap(),
jwt_secret: "test-jwt-secret-en-az-32-karakter-olmali!!".into(),
app_url: "http://localhost".into(),
port: 0,
registration: RegistrationMode::Open,
static_dir: None,
rates_refresh_minutes: 0,
turnstile_site_key: Some("test-site-key".into()),
turnstile_secret_key: Some("test-secret".into()),
trust_proxy: true,
};
let state = AppState::new(app.db.clone(), config)
.with_captcha(kakeibo::auth::captcha::Captcha::Fixed(pass));
Self {
router: routes::router(state),
db: app.db,
}
}
pub fn router_clone(&self) -> Router {
self.router.clone()
}
/// Ham istek (başlık eklemek için): (durum, gövde, çerez)
pub async fn send_with(
&self,
req: axum::http::Request<Body>,
) -> (StatusCode, Value, Option<String>) {
let res = self.router.clone().oneshot(req).await.unwrap();
let status = res.status();
let set_cookie = res
.headers()
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(';').next())
.map(String::from);
let bytes = res.into_body().collect().await.unwrap().to_bytes();
(
status,
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
set_cookie,
)
}
/// Test sonunda geçici veritabanını siler. /// Test sonunda geçici veritabanını siler.
pub async fn cleanup(self) { pub async fn cleanup(self) {
self.db.drop().await.ok(); self.db.drop().await.ok();
@@ -100,6 +153,43 @@ impl TestApp {
(status, json, set_cookie) (status, json, set_cookie)
} }
/// Düz metin gövde gönderir (CSV içe aktarma)
pub async fn post_text(&self, path: &str, s: &Session, text: &str) -> (StatusCode, Value) {
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api{path}"))
.header(header::COOKIE, &s.cookie)
.header(header::CONTENT_TYPE, "text/csv; charset=utf-8")
.body(Body::from(text.to_string()))
.unwrap();
let res = self.router.clone().oneshot(req).await.unwrap();
let status = res.status();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
(
status,
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
)
}
/// Metin yanıt (CSV dışa aktarma): (durum, içerik türü, gövde)
pub async fn get_text(&self, path: &str, s: &Session) -> (StatusCode, String, String) {
let req = Request::builder()
.uri(format!("/api{path}"))
.header(header::COOKIE, &s.cookie)
.body(Body::empty())
.unwrap();
let res = self.router.clone().oneshot(req).await.unwrap();
let status = res.status();
let ct = res
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
(status, ct, String::from_utf8(bytes.to_vec()).unwrap())
}
pub async fn get(&self, path: &str, s: &Session) -> (StatusCode, Value) { pub async fn get(&self, path: &str, s: &Session) -> (StatusCode, Value) {
let (st, v, _) = self.send(Method::GET, path, Some(&s.cookie), None).await; let (st, v, _) = self.send(Method::GET, path, Some(&s.cookie), None).await;
(st, v) (st, v)
+313
View File
@@ -0,0 +1,313 @@
//! Borç takibi (docs/BORC_TAKIBI.md): kaynak belgedeki JSON'un içe aktarılması (kabul rakamları),
//! dönemin ay başı / devreden borcu, elle düzeltme, ölçüm ve taksit işlemleri, güvenlik, yalıtım.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use serde_json::{Value, json};
/// Kaynak belgedeki başlangıç verisi (+ `asOf`: paidCount'ların ait olduğu ay)
fn source_json() -> Value {
json!({
"asOf": "2026-11",
"debtSnapshots": [
{ "date": "2026-10-31", "kmh": 0, "positiveBalance": 2363.11, "cardBalance": 66715.90, "remainingInstallments": 14177.69, "total": 78530.48, "note": "Kasım başı" },
{ "date": "2026-11-13", "kmh": 39264.44, "positiveBalance": 0, "cardBalance": 29948.54, "remainingInstallments": 9865.87, "total": 79078.85, "note": "ara ölçüm" }
],
"installmentPlans": [
{ "merchant": "Koçtaş/Chippin", "totalAmount": 19023.29, "monthlyAmount": 2717.61, "totalCount": 7, "paidCount": 5 },
{ "merchant": "Hepsiburada", "totalAmount": 2999.15, "monthlyAmount": 333.24, "totalCount": 9, "paidCount": 5 },
{ "merchant": "Koçtaş/Chippin", "totalAmount": 836.14, "monthlyAmount": 119.45, "totalCount": 7, "paidCount": 5 },
{ "merchant": "Koçtaş/Chippin", "totalAmount": 890.19, "monthlyAmount": 127.17, "totalCount": 7, "paidCount": 4 },
{ "merchant": "Koçtaş Fix", "totalAmount": 1194.70, "monthlyAmount": 170.67, "totalCount": 7, "paidCount": 3 },
{ "merchant": "Koçtaş/Chippin", "totalAmount": 519.94, "monthlyAmount": 74.28, "totalCount": 7, "paidCount": 3 },
{ "merchant": "Koçtaş/Chippin", "totalAmount": 1274.13, "monthlyAmount": 182.02, "totalCount": 7, "paidCount": 2 },
{ "merchant": "Trendyol", "totalAmount": 1762.15, "monthlyAmount": 587.38, "totalCount": 3, "paidCount": 2 }
],
"roadmap": [
{ "month": "2026-11", "closingDebtTarget": 66995 },
{ "month": "2026-12", "closingDebtTarget": 49890 },
{ "month": "2027-01", "closingDebtTarget": 33286 },
{ "month": "2027-02", "closingDebtTarget": 15681 },
{ "month": "2027-03", "closingDebtTarget": 0 }
]
})
}
fn statuses(r: &Value) -> Vec<String> {
r["rows"]
.as_array()
.unwrap()
.iter()
.map(|x| x["status"].as_str().unwrap().to_string())
.collect()
}
async fn import(app: &TestApp, s: &Session, commit: bool) -> Value {
let (st, r) = app
.post(&format!("/debt/import?commit={commit}"), s, source_json())
.await;
assert_eq!(st, StatusCode::OK, "{r}");
r
}
#[tokio::test]
async fn import_source_document_and_acceptance_numbers() {
let app = TestApp::new().await;
let s = app.register("Sahip", None).await;
// Ön izleme: hiçbir şey yazılmaz, toplamlar belgedekiyle aynı
let r = import(&app, &s, false).await;
assert_eq!(r["errors"], json!([]));
assert_eq!(statuses(&r), vec!["new"; 15]);
let rows = r["rows"].as_array().unwrap();
assert_eq!(
(rows[0]["amount"].as_i64(), rows[1]["amount"].as_i64()),
(Some(7_853_048), Some(7_907_885))
);
assert_eq!(rows[2]["label"], "Koçtaş/Chippin · 7 taksit · ilk 2026-07");
let (_, d) = app.get("/debt", &s).await;
assert_eq!(d["snapshots"], json!([]), "ön izleme yazmaz");
// Aktar
let r = import(&app, &s, true).await;
assert_eq!(r["committed"], true);
let (_, d) = app.get("/debt", &s).await;
assert_eq!(d["latest"]["total"], 7_907_885);
assert_eq!(d["latest"]["change"], 7_907_885 - 7_853_048);
assert_eq!(d["change_vs_last_month"], 54_837);
assert_eq!(d["start"]["total"], 7_853_048);
assert_eq!(d["estimated_end"], "2027-03");
assert_eq!(d["installments"].as_array().unwrap().len(), 8);
assert_eq!(d["targets"].as_array().unwrap().len(), 5);
assert_eq!(d["schedule"].as_array().unwrap().len(), 6);
assert_eq!(d["snapshots"][0]["date"], "2026-11-13", "en yeni önce");
// Tekrar aktarınca çift kayıt yok
let r = import(&app, &s, true).await;
assert!(statuses(&r).iter().all(|x| x == "exists"), "{r}");
let (_, d) = app.get("/debt", &s).await;
assert_eq!(d["snapshots"].as_array().unwrap().len(), 2);
// Kasım planı: devreden borç 64.352,79; taksit yükü 4.311,82
let (_, p) = app
.post(
"/periods",
&s,
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01", "savings_goal": 700_000 }),
)
.await;
let pid = p["id"].as_str().unwrap();
let (st, pd) = app.get(&format!("/periods/{pid}/debt"), &s).await;
assert_eq!(st, StatusCode::OK, "{pd}");
assert_eq!(pd["opening_total"], 7_853_048);
assert_eq!(pd["opening_source"], "snapshot");
assert_eq!(pd["carried"], 6_435_279);
assert_eq!(pd["installment_load"], 431_182);
assert_eq!(pd["payment"], 700_000);
assert_eq!(pd["closing_target"], 7_853_048 - 700_000 - 431_182);
assert_eq!(pd["roadmap_target"], 6_699_500);
assert_eq!(
(pd["latest_date"].as_str(), pd["latest_total"].as_i64()),
(Some("2026-11-13"), Some(7_907_885))
);
// Elle ay başı → manual; null → yine ölçümden
let (st, _) = app
.put(
&format!("/periods/{pid}/opening-debt"),
&s,
json!({ "amount": 8_000_000 }),
)
.await;
assert_eq!(st, StatusCode::NO_CONTENT);
let (_, pd) = app.get(&format!("/periods/{pid}/debt"), &s).await;
assert_eq!(
(pd["opening_source"].as_str(), pd["carried"].as_i64()),
(Some("manual"), Some(8_000_000 - 1_417_769))
);
app.put(
&format!("/periods/{pid}/opening-debt"),
&s,
json!({ "amount": null }),
)
.await;
let (_, pd) = app.get(&format!("/periods/{pid}/debt"), &s).await;
assert_eq!(pd["opening_source"], "snapshot");
// JSON yedeği borç verisini de taşır
let (_, _, file) = app.get_text("/backup/download", &s).await;
let v: Value = serde_json::from_str(&file).unwrap();
assert_eq!(
(
v["counts"]["debt_snapshots"].as_i64(),
v["counts"]["installment_plans"].as_i64(),
v["counts"]["debt_targets"].as_i64()
),
(Some(2), Some(8), Some(5))
);
app.cleanup().await;
}
#[tokio::test]
async fn import_rejects_inconsistent_file() {
let app = TestApp::new().await;
let s = app.register("Sahip", None).await;
let mut bad = source_json();
bad["debtSnapshots"][0]["total"] = json!(78530.49);
bad["installmentPlans"][0]["monthlyAmount"] = json!(99999);
bad["installmentPlans"][1]
.as_object_mut()
.unwrap()
.remove("paidCount");
bad["roadmap"][0]["closingDebtTarget"] = json!("çok");
let (_, r) = app.post("/debt/import?commit=true", &s, bad).await;
let rows = r["rows"].as_array().unwrap();
assert_eq!(rows[0]["status"], "error", "toplam tutmuyor");
assert!(rows[0]["message"].as_str().unwrap().contains("tutmuyor"));
assert_eq!(rows[2]["status"], "error", "aylık > toplam");
assert_eq!(r["errors"].as_array().unwrap().len(), 2, "{r}");
let (_, d) = app.get("/debt", &s).await;
assert_eq!(
d["snapshots"].as_array().unwrap().len(),
1,
"yalnız geçerli ölçüm yazıldı"
);
let (_, r) = app.post("/debt/import", &s, json!({ "baska": 1 })).await;
assert_eq!(
r["errors"][0],
"Dosyada ölçüm, taksit ya da hedef bulunamadı"
);
app.cleanup().await;
}
#[tokio::test]
async fn snapshots_installments_targets_and_safety() {
let app = TestApp::new().await;
let s = app.register("Sahip", None).await;
let (st, sn) = app
.post(
"/debt/snapshots",
&s,
json!({ "date": "2026-11-30", "kmh": 1_000_000, "positive_balance": 0, "card_balance": 2_500_000, "remaining_installments": 986_587, "note": "maaş sonrası" }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{sn}");
assert_eq!(sn["total"], 1_000_000 + 2_500_000 + 986_587);
let id = sn["id"].as_str().unwrap();
let (st, u) = app
.patch(
&format!("/debt/snapshots/{id}"),
&s,
json!({ "kmh": 0, "version": 1 }),
)
.await;
assert_eq!((st, u["total"].as_i64()), (StatusCode::OK, Some(3_486_587)));
let (st, _) = app
.patch(
&format!("/debt/snapshots/{id}"),
&s,
json!({ "kmh": 5, "version": 1 }),
)
.await;
assert_eq!(st, StatusCode::CONFLICT, "eski sürüm");
// Kart / IBAN numarası yazılamaz
for note in ["kart 4543 1234 5678 9010", "TR120006400000112345678901"] {
let (st, b) = app
.post(
"/debt/snapshots",
&s,
json!({ "date": "2026-12-01", "note": note }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert!(
b["error"]["message"].as_str().unwrap().contains("IBAN"),
"{b}"
);
}
let (st, _) = app
.post("/debt/snapshots", &s, json!({ "date": "2026-02-30" }))
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
// Taksit
let (st, p) = app
.post(
"/debt/installments",
&s,
json!({ "merchant": "Mağaza", "total_amount": 120_000, "monthly_amount": 20_000, "total_count": 6, "first_month": "2026-10" }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
assert_eq!(p["last_month"], "2027-03");
for bad in [
json!({ "merchant": "X", "total_amount": 100, "monthly_amount": 200, "total_count": 2, "first_month": "2026-10" }),
json!({ "merchant": "X", "total_amount": 100, "monthly_amount": 50, "total_count": 0, "first_month": "2026-10" }),
json!({ "merchant": "X", "total_amount": 100, "monthly_amount": 50, "total_count": 2, "first_month": "2026-13" }),
] {
let (st, _) = app.post("/debt/installments", &s, bad).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
// Hedefler: liste olduğu gibi
let (st, _) = app
.put("/debt/targets", &s, json!({ "targets": [{ "month": "2026-12", "closing_target": 100 }, { "month": "2027-01", "closing_target": 0 }] }))
.await;
assert_eq!(st, StatusCode::NO_CONTENT);
app.put(
"/debt/targets",
&s,
json!({ "targets": [{ "month": "2027-01", "closing_target": 0 }] }),
)
.await;
let (_, d) = app.get("/debt", &s).await;
assert_eq!(
d["targets"],
json!([{ "month": "2027-01", "closing_target": 0 }])
);
let (st, _) = app
.put("/debt/targets", &s, json!({ "targets": [{ "month": "2027-01", "closing_target": 0 }, { "month": "2027-01", "closing_target": 5 }] }))
.await;
assert_eq!(st, StatusCode::BAD_REQUEST, "aynı ay iki kez");
// Başka hane göremez / silemez; silinen listeden çıkar
let other = app.register("Diger", None).await;
let (_, od) = app.get("/debt", &other).await;
assert_eq!(od["snapshots"], json!([]));
let (st, _) = app.delete(&format!("/debt/snapshots/{id}"), &other).await;
assert_eq!(st, StatusCode::NOT_FOUND);
let (st, _) = app.delete(&format!("/debt/snapshots/{id}"), &s).await;
assert_eq!(st, StatusCode::NO_CONTENT);
let (_, d) = app.get("/debt", &s).await;
assert_eq!(d["snapshots"], json!([]));
assert!(d["latest"].is_null());
// Kapalı dönemde ay başı borç değişmez
let (_, p) = app
.post(
"/periods",
&s,
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
)
.await;
let pid = p["id"].as_str().unwrap();
app.post(&format!("/periods/{pid}/close"), &s, json!({}))
.await;
let (st, _) = app
.put(
&format!("/periods/{pid}/opening-debt"),
&s,
json!({ "amount": 1 }),
)
.await;
assert_eq!(st, StatusCode::CONFLICT);
let (_, pd) = app.get(&format!("/periods/{pid}/debt"), &s).await;
assert_eq!(pd["opening_source"], "none");
app.cleanup().await;
}
+571
View File
@@ -0,0 +1,571 @@
//! 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–8 · 9–15 · 16–22 · 23–31
for (date, amount, cat) in [
("2026-12-01", 1_000, &needs),
("2026-12-08", 2_000, &needs), // 1. haftanın son günü
("2026-12-09", 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-10", "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-16", "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]));
assert_eq!(spent_by_week(&b, "wants"), (700, vec![0, 400, 0, 300]));
assert_eq!(spent_by_week(&b, "culture"), (0, vec![0; 4]));
// 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]));
// 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;
}
+156
View File
@@ -0,0 +1,156 @@
//! Aylık değerlendirme raporu: kapalı dönem (önceki dönemle karşılaştırma, notlar, devir),
//! ara rapor, hane yalıtımı.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use serde_json::json;
async fn id_by(app: &TestApp, s: &Session, name: &str) -> String {
let (_, list) = app.get("/categories", s).await;
list.as_array()
.unwrap()
.iter()
.find(|c| c["name"] == name)
.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
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}");
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;
id
}
async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64, cat: &str) {
let (st, b) = app
.post(
&format!("/periods/{pid}/entries"),
s,
json!({ "date": date, "amount": amount, "category_id": cat }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
}
async fn report(app: &TestApp, s: &Session, pid: &str) -> (StatusCode, String, String) {
app.get_text(&format!("/periods/{pid}/report.md"), s).await
}
#[tokio::test]
async fn closed_period_report_with_comparison_notes_and_carry() {
let app = TestApp::new().await;
let s = app.register("Sahip", None).await;
let market = id_by(&app, &s, "Market").await;
let yemek = id_by(&app, &s, "Dışarıda yemek").await;
// Ekim: 10.000 gelir, 6.000 harcama (Market 3.000, yemek 3.000) → kapalı
let oct = open(&app, &s, "2026-10-01", "2026-11-01", 200_000).await;
spend(&app, &s, &oct, "2026-10-05", 300_000, &market).await;
spend(&app, &s, &oct, "2026-10-06", 300_000, &yemek).await;
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
.await;
// Kasım: hedef 3.000; bütçe Zorunlu 4.000 / İstek 1.000; Market 5.000 (+%67), yemek 1.000
let nov = open(&app, &s, "2026-11-01", "2026-12-01", 300_000).await;
app.put(
&format!("/periods/{nov}/budget"),
&s,
json!({ "needs": 400_000, "wants": 100_000, "culture": 0, "unexpected": 0 }),
)
.await;
spend(&app, &s, &nov, "2026-11-17", 500_000, &market).await;
spend(&app, &s, &nov, "2026-11-03", 100_000, &yemek).await;
app.post(
&format!("/periods/{nov}/notes"),
&s,
json!({ "context": "planning", "title": "Bu ayın niyeti", "body_md": "Dışarıda **az** ye." }),
)
.await;
let (_, p) = app.get(&format!("/periods/{nov}"), &s).await;
app.patch(
&format!("/periods/{nov}"),
&s,
json!({ "reflection_md": "Market listesiyle çık.", "version": p["version"] }),
)
.await;
// Açıkken: ara rapor
let (st, ct, md) = report(&app, &s, &nov).await;
assert_eq!(st, StatusCode::OK, "{md}");
assert!(ct.starts_with("text/markdown"), "{ct}");
assert!(md.starts_with("# Kasım 2026 — Aylık Değerlendirme"), "{md}");
// Testin çalıştığı güne göre Kasım 2026 sürüyor ya da bitmiş olabilir
assert_eq!(md.contains("· Sürüyor ("), md.contains("> **Ara rapor:**"));
let (st, b) = app
.post(
&format!("/periods/{nov}/close"),
&s,
json!({ "to_savings": 0 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
let (st, _, md) = report(&app, &s, &nov).await;
assert_eq!(st, StatusCode::OK);
println!("{md}");
// Başlık ve karşılaştırma
assert!(md.contains("· Kapandı "));
// Önceki dönemle aynı birikim (4.000) → karşılaştırma cümlesi eklenmez
assert!(md.contains(
"> Birikim hedefin 3.000,00 ₺ idi; 4.000,00 ₺ biriktirdin (%133). Hedefe ulaştın.\n"
));
assert!(md.contains("- İstek bütçenin içinde kaldı.\n"));
assert!(md.contains("- Harcama yapmadığın gün: 28 / 30; günlük ortalama 200,00 ₺."));
assert!(md.contains("haftalık bütçe kullanımı %429."));
assert!(md.contains("| Kakeibo'nun 4 sorusu | Bu dönem | Ekim 2026 |"));
assert!(md.contains("| Ne kadar harcadım? | 6.000,00 ₺ | 6.000,00 ₺ |"));
// Gruplar, kategoriler (ortalamaya göre), haftalar
assert!(md.contains("| 🏠 Zorunlu | 4.000,00 ₺ | 5.000,00 ₺ | -1.000,00 ₺ | ❌ %125 |"));
assert!(
md.contains("| 🛒 Market | 5.000,00 ₺ | 1 | %83 | +%67 (+2.000,00 ₺) |"),
"{md}"
);
assert!(md.contains("| 3. hafta | 16.11 – 22.11 |"));
// Yorumlar
assert!(md.contains("- Zorunlu grubu bütçeyi 1.000,00 ₺ aştı (%125)."));
assert!(md.contains("- Market, ortalamasının %67 üstünde (+2.000,00 ₺); 1 işlem."));
assert!(md.contains("- Dışarıda yemek harcaman ortalamanın %67 altında (-2.000,00 ₺)."));
assert!(md.contains("- Zorunlu bütçede kalsaydı birikimin 5.000,00 ₺ olurdu (+1.000,00 ₺)."));
// Notlar
assert!(md.contains("#### Bu ayın niyeti\n\nDışarıda **az** ye."));
assert!(md.contains("#### Nasıl daha iyi yapabilirim?\n\nMarket listesiyle çık."));
// Öneri: Zorunlu (5.000 + 3.000) / 2 = 4.000
assert!(md.contains("| 🏠 Zorunlu | 4.000,00 ₺ | son 2 dönemin ortalaması |"));
assert!(md.contains("1. Market için haftalık 750,00 ₺ sınır koy"));
// Devir: 4.000 birikim, 0'ı aktarıldı → artı devreder
assert!(md.contains("**Devir:** gelecek döneme 4.000,00 ₺ artı devrediyor"));
assert!(md.contains("## Ek: Tüm kategoriler"));
assert!(!md.contains("{{") && !md.contains("{%"));
// Başka hane göremez; geçersiz kimlik → 404
let other = app.register("Diger", None).await;
let (st, _, _) = report(&app, &other, &nov).await;
assert_eq!(st, StatusCode::NOT_FOUND);
let (st, _, _) = report(&app, &s, "yok").await;
assert_eq!(st, StatusCode::NOT_FOUND);
app.cleanup().await;
}
+1 -5
View File
@@ -40,11 +40,7 @@ async fn open_period_returns_weeks_and_default_name() {
.iter() .iter()
.map(|w| w["days"].as_i64().unwrap()) .map(|w| w["days"].as_i64().unwrap())
.collect(); .collect();
assert_eq!( assert_eq!(days, vec![8, 7, 7, 9], "1–8, 9–15, 16–22, 23–31");
days,
vec![6, 7, 7, 7, 4],
"Pazartesi başlangıçlı, kısmi haftalar ayrı"
);
let (_, cur) = app.get("/periods/current", &s).await; let (_, cur) = app.get("/periods/current", &s).await;
assert_eq!(cur["id"], p["id"]); assert_eq!(cur["id"], p["id"]);
+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-09", 50_000, &market).await;
spend(&app, &s, &pid, "2026-12-09", 300_000, &rent).await;
spend(&app, &s, &pid, "2026-12-16", 40_000, &wants).await;
spend(&app, &s, &pid, "2026-12-31", 10_000, &culture).await;
let gone = spend(&app, &s, &pid, "2026-12-16", 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, 10_000]
);
assert_eq!(
column(&r["weeks"], "planned").iter().sum::<i64>(),
1_800_000
);
assert_eq!(r["weeks"][3]["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(), 4);
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;
}
+263
View File
@@ -0,0 +1,263 @@
//! Giriş / kayıt güvenliği: IP başına üst üste 3 hatada 15 dk kilit (giriş + kayıt birlikte),
//! sahte X-Forwarded-For ile aşılamaz, bot doğrulaması (Turnstile, testte ağsız sabit sonuç).
mod common;
use axum::{
body::Body,
http::{Method, Request, StatusCode, header},
};
use common::TestApp;
use kakeibo::config::RegistrationMode;
use serde_json::{Value, json};
/// Belirli bir IP'den (vekilin eklediği en sağdaki X-Forwarded-For) JSON POST
async fn post_from(
app: &TestApp,
xff: &str,
path: &str,
body: Value,
) -> (StatusCode, Value, Option<String>) {
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api{path}"))
.header(header::CONTENT_TYPE, "application/json")
.header("x-forwarded-for", xff)
.body(Body::from(body.to_string()))
.unwrap();
app.send_with(req).await
}
async fn guard_from(app: &TestApp, xff: &str) -> Value {
let req = Request::builder()
.uri("/api/auth/guard")
.header("x-forwarded-for", xff)
.body(Body::empty())
.unwrap();
app.send_with(req).await.1
}
fn login(email: &str, password: &str) -> Value {
json!({ "email": email, "password": password, "captcha_token": "jeton" })
}
#[tokio::test]
async fn three_wrong_logins_lock_login_and_register_for_that_ip() {
let app = TestApp::new().await;
let (st, _, _) = post_from(
&app,
"10.0.0.1",
"/auth/register",
json!({ "name": "Sahip", "email": "sahip@ornek.com", "password": "dogru-sifre-1" }),
)
.await;
assert_eq!(st, StatusCode::CREATED);
let ip = "203.0.113.5";
for left in [2, 1] {
let (st, b, _) =
post_from(&app, ip, "/auth/login", login("sahip@ornek.com", "yanlis")).await;
assert_eq!(st, StatusCode::UNAUTHORIZED);
assert_eq!(
b["error"]["message"],
format!("E-posta veya şifre hatalı. Kalan deneme hakkı: {left}")
);
}
// 3. hata → kilit
let req = Request::builder()
.method(Method::POST)
.uri("/api/auth/login")
.header(header::CONTENT_TYPE, "application/json")
.header("x-forwarded-for", ip)
.body(Body::from(login("sahip@ornek.com", "yanlis").to_string()))
.unwrap();
let res = tower::ServiceExt::oneshot(app_router(&app), req)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(res.headers()[header::RETRY_AFTER], "900");
// Kilitliyken doğru şifre de, kayıt da reddedilir
let (st, b, _) = post_from(
&app,
ip,
"/auth/login",
login("sahip@ornek.com", "dogru-sifre-1"),
)
.await;
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(b["error"]["code"], "locked");
assert!(
b["error"]["message"]
.as_str()
.unwrap()
.contains("15 dakika")
);
let (st, _, _) = post_from(
&app,
ip,
"/auth/register",
json!({ "name": "Yeni", "email": "yeni@ornek.com", "password": "sifre-12345" }),
)
.await;
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS, "kayıt da kilitli");
let g = guard_from(&app, ip).await;
assert!(g["locked_seconds"].as_u64().unwrap() > 890, "{g}");
// Sahte sol girdi kilidi aşamaz (en sağdaki = vekilin eklediği)
let (st, _, _) = post_from(
&app,
&format!("1.1.1.1, {ip}"),
"/auth/login",
login("sahip@ornek.com", "dogru-sifre-1"),
)
.await;
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS);
// Hane sahibi başka bağlantıdan girebilir (e-postaya göre kilit yok)
let (st, _, _) = post_from(
&app,
"198.51.100.9",
"/auth/login",
login("sahip@ornek.com", "dogru-sifre-1"),
)
.await;
assert_eq!(st, StatusCode::OK);
assert_eq!(guard_from(&app, "198.51.100.9").await["locked_seconds"], 0);
app.cleanup().await;
}
fn app_router(app: &TestApp) -> axum::Router {
app.router_clone()
}
#[tokio::test]
async fn success_resets_the_counter() {
let app = TestApp::new().await;
post_from(
&app,
"10.0.0.1",
"/auth/register",
json!({ "name": "A", "email": "a@ornek.com", "password": "dogru-sifre-1" }),
)
.await;
let ip = "203.0.113.6";
for _ in 0..2 {
post_from(&app, ip, "/auth/login", login("a@ornek.com", "yanlis")).await;
}
let (st, _, _) = post_from(
&app,
ip,
"/auth/login",
login("a@ornek.com", "dogru-sifre-1"),
)
.await;
assert_eq!(st, StatusCode::OK);
for _ in 0..2 {
let (st, _, _) = post_from(&app, ip, "/auth/login", login("a@ornek.com", "yanlis")).await;
assert_eq!(
st,
StatusCode::UNAUTHORIZED,
"sayaç sıfırlandı, henüz kilit yok"
);
}
app.cleanup().await;
}
#[tokio::test]
async fn invite_code_guessing_is_locked() {
let app = TestApp::with_registration(RegistrationMode::InviteOnly).await;
// İlk hane serbest
let (st, _, _) = post_from(
&app,
"10.0.0.1",
"/auth/register",
json!({ "name": "Sahip", "email": "sahip@ornek.com", "password": "sifre-12345" }),
)
.await;
assert_eq!(st, StatusCode::CREATED);
let ip = "203.0.113.7";
let guess = |code: &str, n: u32| json!({ "name": "Tahmin", "email": format!("t{n}@ornek.com"), "password": "sifre-12345", "invite_code": code });
for (n, code) in ["AAAA-AAAA", "BBBB-BBBB"].iter().enumerate() {
let (st, _, _) = post_from(&app, ip, "/auth/register", guess(code, n as u32)).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
// Davetsiz yeni hane denemesi (kapalı kayıt) da sayılır → 3. hata kilit
let (st, b, _) = post_from(
&app,
ip,
"/auth/register",
json!({ "name": "X", "email": "x@ornek.com", "password": "sifre-12345" }),
)
.await;
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS, "{b}");
// Form doğrulama hataları sayılmaz (başka IP'de 5 kez kısa şifre)
for _ in 0..5 {
let (st, _, _) = post_from(
&app,
"203.0.113.8",
"/auth/register",
json!({ "name": "Y", "email": "y@ornek.com", "password": "kisa" }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
assert_eq!(guard_from(&app, "203.0.113.8").await["locked_seconds"], 0);
app.cleanup().await;
}
#[tokio::test]
async fn bot_check_is_required_and_failures_count() {
// Doğrulama her zaman başarısız
let app = TestApp::with_captcha(false).await;
let g = guard_from(&app, "203.0.113.9").await;
assert_eq!(g["captcha_site_key"], "test-site-key");
for _ in 0..2 {
let (st, b, _) = post_from(
&app,
"203.0.113.9",
"/auth/login",
login("kim@ornek.com", "x"),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert_eq!(b["error"]["code"], "captcha");
}
let (st, _, _) = post_from(
&app,
"203.0.113.9",
"/auth/login",
login("kim@ornek.com", "x"),
)
.await;
assert_eq!(
st,
StatusCode::TOO_MANY_REQUESTS,
"bot denemeleri de sayılır"
);
app.cleanup().await;
// Doğrulama geçer, ama jeton gönderilmezse red
let app = TestApp::with_captcha(true).await;
let body = json!({ "name": "A", "email": "a@ornek.com", "password": "dogru-sifre-1" });
let (st, b, _) = post_from(&app, "10.0.0.2", "/auth/register", body.clone()).await;
assert_eq!(
(st, b["error"]["code"].as_str()),
(StatusCode::BAD_REQUEST, Some("captcha"))
);
let mut with_token = body;
with_token["captcha_token"] = json!("jeton");
let (st, _, _) = post_from(&app, "10.0.0.2", "/auth/register", with_token).await;
assert_eq!(st, StatusCode::CREATED);
let (st, _, _) = post_from(
&app,
"10.0.0.2",
"/auth/login",
login("a@ornek.com", "dogru-sifre-1"),
)
.await;
assert_eq!(st, StatusCode::OK);
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;
}
+277
View File
@@ -0,0 +1,277 @@
//! CSV dışa / içe aktarma: biçim, deneme → aktarma, satır hataları, tekrar atlama,
//! varlıkta tarih sırasıyla eldekinden fazla satış denetimi.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use serde_json::{Value, json};
async fn id_by(app: &TestApp, s: &Session, path: &str, key: &str, value: &str) -> String {
let (_, list) = app.get(path, s).await;
list.as_array()
.unwrap()
.iter()
.find(|c| c[key] == value)
.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn open(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": start, "end_date": end }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
p["id"].as_str().unwrap().to_string()
}
async fn import(app: &TestApp, s: &Session, kind: &str, csv: &str, commit: bool) -> Value {
let (st, r) = app
.post_text(&format!("/import/{kind}?commit={commit}"), s, csv)
.await;
assert_eq!(st, StatusCode::OK, "{r}");
r
}
fn errors(r: &Value) -> Vec<(u64, String)> {
r["errors"]
.as_array()
.unwrap()
.iter()
.map(|e| {
(
e["line"].as_u64().unwrap(),
e["message"].as_str().unwrap().to_string(),
)
})
.collect()
}
#[tokio::test]
async fn entries_export_and_import() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let market = id_by(&app, &s, "/categories", "name", "Market").await;
let nakit = id_by(&app, &s, "/payment-methods", "name", "Nakit").await;
// Ekim kapalı, Kasım açık
let oct = open(&app, &s, "2026-10-01", "2026-11-01").await;
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
.await;
let nov = open(&app, &s, "2026-11-01", "2026-12-01").await;
app.post(&format!("/periods/{nov}/entries"), &s, json!({ "date": "2026-11-05", "amount": 125_050, "category_id": market, "payment_method_id": nakit, "description": "Kafe; tatlı" })).await;
let (st, ct, csv) = app.get_text("/export/entries.csv", &s).await;
assert_eq!(st, StatusCode::OK);
assert!(ct.starts_with("text/csv"), "{ct}");
assert_eq!(
csv,
"\u{FEFF}Tarih;Tutar;Grup;Kategori;Ödeme türü;Açıklama\r\n05.11.2026;1250,50;Zorunlu;Market;Nakit;\"Kafe; tatlı\"\r\n"
);
// Aynı dosya geri yüklenirse çift kayıt olmaz
let r = import(&app, &s, "entries", &csv, true).await;
assert_eq!(
(
r["total"].as_u64(),
r["valid"].as_u64(),
r["duplicates"].as_u64(),
r["imported"].as_u64()
),
(Some(1), Some(0), Some(1), Some(0))
);
// Sütun sırası serbest, ayrı yazımlar; hatalı satırlar raporlanır
let file = "Açıklama;Kategori;Tutar;Tarih;Grup;Ödeme türü\r\n\
Ekmek;market;45,90;06.11.2026;;\r\n\
x;Market;10;31.02.2026;;\r\n\
x;Market;10;15.09.2026;;\r\n\
x;Market;10;15.10.2026;;\r\n\
x;Uzay;10;06.11.2026;;\r\n\
x;Market;0;06.11.2026;;\r\n\
x;Market;10;06.11.2026;;Kripto\r\n\
x;Market;10;06.11.2026;İstek;\r\n\
Kitaplar;Kitap;1.200;2026-11-07;Kültürel;nakit\r\n\
Kafe; tatlı;Market;1.250,50;05.11.2026;Zorunlu;Nakit\r\n";
// Son satırda tırnaksız ';' → sütunlar kayar: açıkça hata olmalı
let dry = import(&app, &s, "entries", file, false).await;
assert_eq!(dry["total"], 10);
assert_eq!(dry["valid"], 2);
assert_eq!(dry["imported"], 0, "deneme yazmaz");
assert_eq!(
errors(&dry),
vec![
(3, "Tarih geçersiz: 31.02.2026".into()),
(4, "Bu tarih için dönem yok".into()),
(5, "Dönem kapalı (Ekim 2026)".into()),
(6, "Kategori bulunamadı: Uzay".into()),
(7, "Tutar sıfırdan büyük olmalı".into()),
(8, "Ödeme türü bulunamadı: Kripto".into()),
(9, "Kategori bulunamadı: Market".into()),
(11, "Tarih geçersiz: 1.250,50".into()),
]
);
assert_eq!(
dry["preview"][0],
json!(["06.11.2026", "45,90", "Market", "Ekmek"])
);
let (_, before) = app.get(&format!("/periods/{nov}/entries"), &s).await;
assert_eq!(before.as_array().unwrap().len(), 1);
let done = import(&app, &s, "entries", file, true).await;
assert_eq!(done["imported"], 2);
let (_, list) = app.get(&format!("/periods/{nov}/entries"), &s).await;
let list = list.as_array().unwrap();
assert_eq!(list.len(), 3);
let book = list
.iter()
.find(|e| e["description"] == "Kitaplar")
.unwrap();
assert_eq!(book["amount"], 120_000, "1.200 → bin iki yüz");
assert_eq!(book["group"], "culture");
assert_eq!(
book["payment_method_id"],
nakit.as_str(),
"ad büyük/küçük harf duyarsız"
);
// Dosyada 2 aynı satır, veritabanında 1 → biri eklenir
let twice = "Tarih;Tutar;Kategori;Açıklama\r\n06.11.2026;45,90;Market;Ekmek\r\n06.11.2026;45,90;Market;Ekmek\r\n";
let r = import(&app, &s, "entries", twice, true).await;
assert_eq!(
(r["duplicates"].as_u64(), r["imported"].as_u64()),
(Some(1), Some(1))
);
// Biçim hataları
let (st, b) = app
.post_text(
"/import/entries?commit=false",
&s,
"Tarih;Tutar\r\n01.11.2026;5\r\n",
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert_eq!(b["error"]["message"], "Gerekli sütun eksik: Kategori");
let (st, _) = app.post_text("/import/entries?commit=false", &s, "").await;
assert_eq!(st, StatusCode::BAD_REQUEST);
let (st, _) = app.post_text("/import/bilinmeyen", &s, "a\r\n").await;
assert_eq!(st, StatusCode::NOT_FOUND);
// Başka hane yalnızca kendi verisini dışa aktarır
let other = app.register("Diger", None).await;
let (_, _, csv) = app.get_text("/export/entries.csv", &other).await;
assert_eq!(csv.lines().count(), 1, "yalnızca başlık");
app.cleanup().await;
}
#[tokio::test]
async fn plan_items_roundtrip() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let nov = open(&app, &s, "2026-11-01", "2026-12-01").await;
app.post(
&format!("/periods/{nov}/plan-items"),
&s,
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000 }),
)
.await;
app.post(
&format!("/periods/{nov}/plan-items"),
&s,
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-11-05" }),
)
.await;
let (_, _, csv) = app.get_text("/export/plan-items.csv", &s).await;
assert_eq!(
csv,
"\u{FEFF}Dönem başlangıcı;Dönem;Tür;Ad;Tutar;Tarih\r\n01.11.2026;Kasım 2026;Gelir;Maaş;45000,00;\r\n01.11.2026;Kasım 2026;Sabit gider;Kira;15000,00;05.11.2026\r\n"
);
// Başka hane: aynı dönemi açıp içe aktarır
let other = app.register("Diger", None).await;
let onov = open(&app, &other, "2026-11-01", "2026-12-01").await;
let with_errors = format!(
"{csv}01.11.2026;Kasım 2026;Prim;Bonus;100,00;\r\n01.01.2026;;Gelir;Eski;1,00;\r\n"
);
let r = import(&app, &other, "plan-items", &with_errors, true).await;
assert_eq!(r["imported"], 2);
assert_eq!(
errors(&r),
vec![
(4, "Tür \"Gelir\" ya da \"Sabit gider\" olmalı: Prim".into()),
(5, "Bu tarih için dönem yok".into()),
]
);
let (_, items) = app
.get(&format!("/periods/{onov}/plan-items"), &other)
.await;
assert_eq!(items.as_array().unwrap().len(), 2);
// Tekrar → atlanır
let r = import(&app, &other, "plan-items", &csv, true).await;
assert_eq!(
(r["duplicates"].as_u64(), r["imported"].as_u64()),
(Some(2), Some(0))
);
app.cleanup().await;
}
#[tokio::test]
async fn asset_transactions_in_date_order() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
// Satış dosyada alımdan önce ama tarihi sonra → geçerli; fazla satış → hata
let file = "Tarih;Varlık kodu;İşlem;Miktar;Birim fiyat;Tutar;Not\r\n\
10.11.2026;XAU_GRAM;Satım;4;;26.000;\r\n\
01.11.2026;XAU_GRAM;Alım;10;6.300;63.000;Kuyumcu\r\n\
12.11.2026;XAU_GRAM;Satış;7;;45.000;\r\n\
02.11.2026;TRY;Alım;;;5.000;\r\n\
02.11.2026;BTC;Alım;1;;1;\r\n\
03.11.2026;USD;Alım;abc;;100;\r\n";
let dry = import(&app, &s, "asset-transactions", file, false).await;
assert_eq!(dry["valid"], 3);
assert_eq!(
errors(&dry),
vec![
(4, "Satış miktarı o tarihte eldeki miktarı aşıyor".into()),
(6, "Varlık bulunamadı: BTC".into()),
(7, "Miktar geçersiz: abc".into()),
]
);
let r = import(&app, &s, "asset-transactions", file, true).await;
assert_eq!(r["imported"], 3);
let (_, assets) = app.get("/assets", &s).await;
let gram = assets
.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "XAU_GRAM")
.unwrap();
assert_eq!(gram["quantity_e6"], 6_000_000);
assert_eq!(gram["realized_pl"], 80_000);
let tl = assets
.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "TRY")
.unwrap();
assert_eq!(tl["cost"], 500_000);
// Dışa aktar → geri yükle: hepsi tekrar
let (_, _, csv) = app.get_text("/export/asset-transactions.csv", &s).await;
assert!(
csv.contains("01.11.2026;XAU_GRAM;Gram Altın;Alım;10;6300;63000,00;Kuyumcu"),
"{csv}"
);
let r = import(&app, &s, "asset-transactions", &csv, false).await;
assert_eq!(
(r["duplicates"].as_u64(), r["valid"].as_u64()),
(Some(3), Some(0))
);
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
+62
View File
@@ -0,0 +1,62 @@
# Borç takibi (Kakeibo uyarlaması)
Kaynak: `butcem/docs/BORC_TAKIBI.md` (kullanıcının özellik tanımı). Kişisel rakamlar (seed) bu repoya
**girmez**; canlıya "İçe aktar (JSON)" ile yüklenir (dosya repoda tutulmaz).
## Amaç
Gerçek banka borcu (KMH + kredi kartı + ekstreye gelmemiş taksitler) Panel'in en üstünde görünsün ve
her ay küçülmesi izlensin. Hedef: toplam borcu yol haritasındaki son aya kadar sıfırlamak.
## Kararlar (kullanıcı, 2026-11-14)
- **Ayrı "Borç" modülü.** Kakeibo'daki "devreden borç" (bütçe aşımının devri, `carryovers`) ile
karıştırılmaz; banka borcu **harcanabilir tutarı düşürmez** (aylara yayılarak birikimle ödenir).
Borç sıfırlanana kadar dönemin birikim hedefi (C) "Borç ödemesi" olarak etiketlenir.
- Başlangıç verisi canlıya **içe aktarma dosyasıyla** (önizleme → onay).
## Formüller (tutarlar kuruş, `i64`)
```
toplam borç = KMH eksi bakiyesi + kart borcu (provizyon dahil) + kalan taksitler − maaş hesabı artı bakiyesi
devreden borç = toplam borç − kalan taksitler (aylık planda gösterilen)
taksit yükü(ay) = Σ aylık tutar (ilk taksit ayı ≤ ay ≤ son taksit ayı)
kalan taksit(ay) = Σ aylık tutar × (ay'dan SONRAKİ taksit sayısı) (ölçümün ayına göre önerilir)
ay sonu hedefi = ay başı toplam borç − birikim (C) − o ayın taksit yükü
aylık başarı = ay başı toplam − ay sonu toplam ≥ C + o ayın taksit yükü
```
- Toplam **saklanmaz**, her okumada hesaplanır.
- Taksit planı "ödenen taksit sayısı" yerine **ilk taksit ayı** (`first_month`) ile saklanır; ödenen /
kalan sayılar her ay kendiliğinden hesaplanır. İçe aktarmada `asOf` ayı + `paidCount` →
`first_month = asOf − paidCount + 1`.
- Ay başı borç: dönemin başlangıcından önceki (≤ başlangıç günü) son ölçüm; dönemde elle düzeltilebilir.
## Veri modeli (MongoDB, ortak alanlar: `_id` UUID v7, `household_id`, zaman damgaları, `deleted_at`, `version`)
- `debt_snapshots`: `date`, `kmh`, `positive_balance`, `card_balance`, `remaining_installments`,
`note`, `source` (`manual` | `import`)
- `installment_plans`: `merchant`, `total_amount`, `monthly_amount`, `total_count`, `first_month` (`YYYY-MM`)
- `debt_targets`: `month` (`YYYY-MM`), `closing_target` (yol haritası, toplam borç cinsinden)
- `periods.opening_debt` (isteğe bağlı elle ay başı toplam borç)
**Güvenlik:** IBAN, kart ve müşteri numarası saklanmaz; açıklama/işyeri alanlarında 12+ haneli sayı
dizisi reddedilir.
## Ekranlar
1. Panel en üstü: güncel toplam borç, geçen aya göre değişim, başlangıç → 0 ilerleme, tahmini bitiş,
KMH · Kart · Kalan taksit dökümü.
2. `/borc`: gerçek (ölçümler) + hedef (yol haritası, kesikli) çizgi grafik, ölçüm tablosu, "Yeni ölçüm"
(toplam canlı hesaplanır, kalan taksit önerilir), hedefleri düzenleme, içe aktarma.
3. Aylık plan: ay başı borç (otomatik, düzenlenebilir), devreden borç, "Borç ödemesi" (C), taksit yükü,
hedef ay sonu borç (salt okunur); taksit yükü Sabit giderlere "Taksitler" olarak önerilir.
4. `/taksitler`: açık planlar, önümüzdeki 6 ayın taksit yükü (çubuk); yeni taksitte uyarı
"Borç sıfırlanana kadar yeni taksit planı yok."
## Kabul ölçütleri (kaynak belgeyle aynı)
- Ölçüm toplamları kaynak belgedeki iki ölçümle birebir (78.530,48 ve 79.078,85).
- Kasım planında devreden borç 64.352,79.
- Taksit yükü: Kasım ve Aralık 4.311,82 · Ocak 3.724,44 · Şubat 887,38 · Mart 760,21 · Nisan 182,02.
- Gerçek ve hedef aynı grafikte; hiçbir yerde IBAN / kart numarası yok.
## Adımlar
- B1 backend (modeller, hesaplar, uç noktalar, içe aktarma, testler)
- B2 arayüz: Panel kartı, `/borc`, `/taksitler`
- B3 aylık plan entegrasyonu + içe aktarma dosyasının hazırlanması (kullanıcıya, repo dışı: `samples/kakeibo-borc-baslangic.json`, git ve Docker dışında)
**Durum (2026-11-14): B1–B3 tamamlandı.**
+68 -11
View File
@@ -54,9 +54,12 @@ Renkler ve ikonlar frontend tasarım token'larında.
{ _id, household_id, email /* küçük harf, benzersiz */, password_hash /* argon2 */, { _id, household_id, email /* küçük harf, benzersiz */, password_hash /* argon2 */,
name, role: "owner" | "member", name, role: "owner" | "member",
preferences: { theme: "light" | "dark" | "system" }, preferences: { theme: "light" | "dark" | "system" },
last_login_at, created_at, updated_at, deleted_at, version } last_login_at,
sessions_valid_from, // Unix sn; şifre değişince "şimdi" → önceki oturum jetonları (iat <) geçersiz
removed_email, // üye çıkarılınca: e-posta "<adres>#removed-<id>" yapılır (adres serbest), asıl adres burada
created_at, updated_at, deleted_at, version }
``` ```
- Bir kullanıcı **tek bir haneye** üyedir. - Bir kullanıcı **tek bir haneye** üyedir. Üye çıkarma = `deleted_at` (oturum anında düşer, kayıtları hanede kalır).
### 3.2 `households` ### 3.2 `households`
```js ```js
@@ -66,6 +69,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
budget_split_pct: { needs: 50, wants: 30, culture: 10, unexpected: 10 }, budget_split_pct: { needs: 50, wants: 30, culture: 10, unexpected: 10 },
budget_suggestion: "previous" | "avg3" | "default" // varsayılan öneri yöntemi budget_suggestion: "previous" | "avg3" | "default" // varsayılan öneri yöntemi
}, },
last_backup_at, // son JSON dışa aktarma ("son yedek X gün önce")
created_at, updated_at, deleted_at, version } created_at, updated_at, deleted_at, version }
``` ```
- `household_id` = `_id` (ortak `scope()` filtresi tüm koleksiyonlarda aynı çalışsın diye). - `household_id` = `_id` (ortak `scope()` filtresi tüm koleksiyonlarda aynı çalışsın diye).
@@ -81,17 +85,19 @@ Renkler ve ikonlar frontend tasarım token'larında.
```js ```js
{ _id, household_id, name /* "Aralık 2026" */, { _id, household_id, name /* "Aralık 2026" */,
start_date: "2026-12-01", end_date: "2027-01-01", // bitiş HARİÇ start_date: "2026-12-01", end_date: "2027-01-01", // bitiş HARİÇ
status: "open" | "closed", status: "open" | "closed" | "correcting" | "archived",
savings_goal: 500000, // kuruş — birikim hedefi savings_goal: 500000, // kuruş — birikim hedefi
reflection_md: "...", // "Nasıl daha iyi yapabilirim?" 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, previous_period_id,
created_at, updated_at, deleted_at, version } 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. - **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). - 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ı). - **Haftalar saklanmaz.** `start_date`/`end_date`'ten hesaplanır (dönemin 1–8, 9–15, 16–22, 23–son günleri; `calendar::weeks`). Elle girilmiş haftalık bütçe `week_start` ile eşleşir; eşleşmeyen eski kayıt yok sayılır.
- **Arşiv (`archived`, karar 2026-11-13):** ekstre içe aktarımında dönemi olmayan aylar için takvim ayı kadar açılır (var olan dönemlerle çakışmayacak şekilde kırpılır). Yazılabilir (kayıt düzeltme), ama kapatılmaz, devir üretmez; devir zincirinde (yeni dönemin önceki dönemi, kapatmada sonraki dönem, yeniden açma engeli) **atlanır**. Bütçe önerileri ("önceki dönem", "son 3 ay") kapalı **ve** arşiv dönemleri kullanır.
### 3.5 `categories` — Alt kategoriler ### 3.5 `categories` — Alt kategoriler
```js ```js
@@ -136,6 +142,15 @@ Renkler ve ikonlar frontend tasarım token'larında.
description: "Haftalık market", description: "Haftalık market",
created_by /* user_id */, created_at, ... } created_by /* user_id */, created_at, ... }
``` ```
- **İade** (ekstreden, karar 2026-11-13): aynı kategoride **eksi** `amount`; toplamlar net olur. Elle girişte tutar > 0; iade düzenlemede eksi kalır.
- `import_key` (isteğe bağlı): ekstreden gelen kayıtta `tarih|yön|tutar|sadeleştirilmiş açıklama` → aynı satır ikinci kez aktarılmaz (plan kalemlerinde de, gelir için).
### 3.9a `import_rules` — Ekstre öğrenilmiş kuralları
```js
{ _id, household_id, pattern /* açıklamanın ilk 2 anlamlı kelimesi, ASCII küçük: "migros istanbul" */,
kind: "expense" | "refund" | "income" | "ignore", category_id, uses, created_at, updated_at, version }
```
- Aktarmada her seçim kural olarak yazılır (upsert); sonraki ekstrede aynı `pattern` öneri olur. Tam eşleşme yoksa **ilk kelimesi aynı** kurallar hepsi aynı tür+kategoriyi söylüyorsa o önerilir ("migros istanbul" → "MIGROS KADIKOY"); genel ilk kelimeler (iade, ödeme, pos, eft…) hariç.
### 3.10 `carryovers` — Devirler ### 3.10 `carryovers` — Devirler
```js ```js
@@ -143,7 +158,8 @@ Renkler ve ikonlar frontend tasarım token'larında.
kind: "debt" | "surplus", amount: 120000, kind: "debt" | "surplus", amount: 120000,
created_at, ... } 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` ### 3.11 `notes`
```js ```js
@@ -158,8 +174,9 @@ Renkler ve ikonlar frontend tasarım token'larında.
kind: "cash" | "gold" | "fx", kind: "cash" | "gold" | "fx",
code: "TRY" | "XAU_GRAM" | "XAU_CEYREK" | "XAU_YARIM" | "XAU_TAM" | "XAU_ONS" | "USD" | "EUR" | ..., code: "TRY" | "XAU_GRAM" | "XAU_CEYREK" | "XAU_YARIM" | "XAU_TAM" | "XAU_ONS" | "USD" | "EUR" | ...,
name: "Gram Altın", unit_label: "gr", 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 ### 3.13 `asset_transactions` — Alım / satım
```js ```js
@@ -172,7 +189,10 @@ Renkler ve ikonlar frontend tasarım token'larında.
note, source_period_id /* dönem kapanışından geldiyse */, note, source_period_id /* dönem kapanışından geldiyse */,
created_at, ... } 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 ### 3.14 `exchange_rates` — Kurlar
```js ```js
@@ -182,7 +202,44 @@ Renkler ve ikonlar frontend tasarım token'larında.
rate_date: "2026-12-15", fetched_at, rate_date: "2026-12-15", fetched_at,
created_at, ... } created_at, ... }
``` ```
- Güncel kur sırası: hanenin elle girdiği en yeni kur → en yeni otomatik kur. - Güncel kur: kod başına **en yeni `rate_date`**; aynı gün hem otomatik hem manuel varsa manuel. (Eski bir manuel kur, daha yeni otomatik kurun önüne geçmez.) Kur = 1 birimin TL karşılığı × 1e6; döviz **alış** kuru (değerleme).
- Otomatik kurlar gün başına tek kayıt (kod × kaynak × gün; aynı gün yeniden çekilince güncellenir) → günlük geçmiş birikir. TCMB `rate_date` = bülten tarihi; altın = Türkiye'de bugün.
- Altın: xaus.com gram (saf) USD × TCMB USD alış; çeyrek/yarım/tam = has gram (1,603 / 3,206 / 6,412) × gram, ons = 31,1034768 gr. İşçilik payı yok → gerekirse manuel.
### 3.14a `restore_points` — JSON geri yüklemeden önceki durum ("geri al")
```js
{ _id, household_id, created_at, created_by,
reason: "before_restore" | "before_undo",
counts: { periods: 3, entries: 412, ... }, // o anki silinmemiş kayıt sayıları
size, // sıkıştırılmış bayt
data: BinData } // gzip(JSON yedek dosyası)
```
- Her geri yükleme / geri alma öncesinde aynı transaction içinde yazılır. Son **3** nokta, en fazla **30 gün** (`backup::prune`). Hane verisi değil → yedeğe girmez.
### 3.14b JSON yedek dosyası (`services/backup.rs`)
```js
{ "format": "kakeibo-backup", "format_version": 1, "schema_version": 3, "app_version": "0.1.0",
"exported_at": "2026-11-13T17:22:00Z",
"household": { "id", "name", "settings" },
"counts": { "entries": 412, ... }, // silinmemiş
"checksum": "sha256:…", // household + data, anahtarlar sıralı
"data": { "periods": [ {…}, … ], "entries": [ … ], … } } // her kayıt bir satır
```
- İçerik: dönem, plan kalemi, bütçe, harcama, devir, not, kategori, ödeme türü, varlık, varlık işlemi, elle girilen kur, ekstre kuralı — **silinmiş işaretliler dahil** (referanslar kopmasın, "geri al" birebir olsun). Kullanıcı, şifre, oturum, davet, otomatik kur **yok**.
- Değerler: UUID `{"$uuid": "…"}`, zaman `{"$date": "…"}`, tam sayılar `i64` (`version`, `sort_order` ve ayarlar `i32`).
- Geri yükleme = **tam değiştirme** (birleştirme yok), tek transaction. Aynı hane → kimlikler korunur; başka hane → tüm `_id`'ler yeniden üretilir, `*_id` referansları çevrilir, `household_id` hedef hane, `*_by` hanede yoksa geri yükleyen kişi. Hane adı yalnız aynı hanede geri gelir; ayarlar her zaman.
- Sürüm: `format_version`/`schema_version` daha yeniyse reddedilir; eskiyse hane düzeyi yükseltmeler uygulanır (v<2 → varsayılan varlıklar).
### 3.14c Borç takibi (docs/BORC_TAKIBI.md)
```js
debt_snapshots { _id, household_id, date: "YYYY-MM-DD", kmh, positive_balance, card_balance,
remaining_installments, note, source: "manual" | "import", … } // toplam saklanmaz
installment_plans { _id, household_id, merchant, total_amount, monthly_amount, total_count,
first_month: "YYYY-MM", … } // ödenen/kalan her ay hesaplanır
debt_targets { _id, household_id, month: "YYYY-MM", closing_target, … } // yol haritası
periods.opening_debt // isteğe bağlı elle ay başı toplam borç
```
- Kakeibo "devreden borç"u (`carryovers`, bütçe aşımı) ile ilgisizdir; harcanabilir tutarı etkilemez.
### 3.15 `schema_migrations` ### 3.15 `schema_migrations`
```js ```js
@@ -223,7 +280,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
## 6. Dönem Kapanışı (tek transaction) ## 6. Dönem Kapanışı (tek transaction)
1. `periods.status = "closed"`, `closed_at`, `closed_by` 1. `periods.status = "closed"`, `closed_at`, `closed_by`
2. `carryovers` kaydı (borç veya artı) → sonraki dönem 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 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. Transaction'ın bir adımı hata verirse önceki adımlar da geri alınır, hiçbir şey değişmez.
+109 -2
View File
@@ -1,6 +1,6 @@
# 🚀 Canlıya Alma (Atlas Homeserver + Dokploy) # 🚀 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 ## 1. Mimari
@@ -24,6 +24,10 @@
| `APP_URL` | ✅ | `https://kakeibo.<alan-adı>` | `https://` ile başlarsa cookie `Secure` olur | | `APP_URL` | ✅ | `https://kakeibo.<alan-adı>` | `https://` ile başlarsa cookie `Secure` olur |
| `REGISTRATION` | – | `invite_only` | `invite_only`: ilk hane serbest, sonra yalnızca davet · `open`: herkes | | `REGISTRATION` | – | `invite_only` | `invite_only`: ilk hane serbest, sonra yalnızca davet · `open`: herkes |
| `RUST_LOG` | – | `kakeibo=info,tower_http=info` | Log seviyesi | | `RUST_LOG` | – | `kakeibo=info,tower_http=info` | Log seviyesi |
| `RATES_REFRESH_MINUTES` | – | `60` | Otomatik kur yenileme aralığı (dk); `0` → kapalı. Konteynerin internete çıkışı gerekir: `www.tcmb.gov.tr` (döviz), `xaus.com` (altın). Erişilemezse son kur kalır, elle kur girilebilir |
| `TURNSTILE_SITE_KEY` | önerilir | `0x4AAAA…` | Cloudflare Turnstile **site anahtarı** (herkese açık). Giriş/kayıtta bot doğrulaması; ikisi de boşsa kapalı (§8) |
| `TURNSTILE_SECRET_KEY` | önerilir | `0x4AAAA…` | Turnstile **gizli anahtarı** — yalnızca sunucuda, kimseyle paylaşmayın. Konteynerin `challenges.cloudflare.com`'a çıkışı gerekir |
| `TRUST_PROXY` | – | `true` | Dokploy/Traefik arkasında gerçek istemci IP'si `X-Forwarded-For`'dan (giriş kilidi IP'ye göre). Vekil **yoksa** `false` |
| `PORT` | – | `8080` | İmajda ayarlı, değiştirmeyin | | `PORT` | – | `8080` | İmajda ayarlı, değiştirmeyin |
| `STATIC_DIR` | – | `/app/static` | İmajda ayarlı, değiştirmeyin | | `STATIC_DIR` | – | `/app/static` | İmajda ayarlı, değiştirmeyin |
@@ -95,4 +99,107 @@ Notlar:
## 6. Yedekleme ve geri yükleme ## 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.
## 7. Telefona kurulum (PWA) ve güncellemeler
Uygulama **HTTPS** üzerinden açıldığında telefona "uygulama gibi" kurulabilir (service worker yalnız HTTPS'te ve `localhost`'ta çalışır).
- **Android (Chrome):** siteyi aç → menü ⋮ → **Ana ekrana ekle / Uygulamayı yükle**.
- **iPhone (Safari):** siteyi aç → Paylaş ⬆ → **Ana Ekrana Ekle**.
- Ana ekran ikonuna uzun basınca kısayollar: **Harcama ekle**, **Panel**.
Nasıl çalışır:
- Telefonda yalnızca **uygulama kabuğu** (JS, CSS, ikonlar, fontlar) saklanır → ikinci açılış çok hızlı.
- **Veri (`/api`) asla saklanmaz**: ekranda her zaman sunucudaki güncel veri vardır; bağlantı yoksa "Bağlantı yok" uyarısı çıkar, kayıt girilemez (çevrimdışı kayıt Faz 11'de, Tauri ile).
- Yeni sürüm yayınlanınca açık uygulamada **"Yeni sürüm hazır → Yenile"** bildirimi çıkar (saatte bir de kendisi yoklar). Yenile'ye basmadan eski sürüm çalışmaya devam eder; girilen kayıtlar etkilenmez.
- Sunucu: `sw.js` ve `manifest.webmanifest` hiç önbelleğe alınmaz (`routes/spa.rs`), böylece güncelleme hemen fark edilir.
Sorun olursa (eski sürüm takılı kaldı vb.): tarayıcı ayarlarından site verilerini temizlemek service worker'ı da kaldırır.
## 8. Giriş güvenliği: bot doğrulaması + deneme kilidi
**Deneme kilidi (her zaman açık):** aynı bağlantıdan (IP) üst üste **3 hatalı** deneme → o bağlantıya giriş **ve** kayıt **15 dakika** kapanır. Sayılanlar: yanlış şifre, geçersiz/kullanılmış davet kodu, kapalı kayda deneme, zaten kayıtlı e-posta, başarısız bot doğrulaması. Form hataları (kısa şifre vb.) sayılmaz; başarılı giriş sayacı sıfırlar. Kilit e-postaya değil IP'ye göredir: adresinizi bilen biri sizi dışarıda bırakamaz. Sayaçlar bellektedir (yeni sürüm yayınlanınca sıfırlanır).
**Bot doğrulaması (Cloudflare Turnstile) — kurulum:**
1. Ücretsiz bir Cloudflare hesabı açın (alan adınızın Cloudflare'de olması gerekmez).
2. Panel → **Turnstile** → **Add widget**: ad `Kakeibo`, **Hostname**: canlı alan adınız (ör. `kakeibo.<alan-adı>`; yerel deneme için `localhost` da eklenebilir), **Widget Mode: Managed**.
3. Verilen **Site Key** ve **Secret Key**'i Dokploy → uygulama → **Environment**'a `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` olarak girin, yeniden dağıtın.
4. Doğrulama: loglarda `Bot doğrulaması (Cloudflare Turnstile) açık`; giriş sayfasında "Başarılı ✓" kutusu.
Notlar: Gizli anahtarı arayüze, git'e ya da sohbete yazmayın. Cloudflare'e ulaşılamazsa giriş güvenlik için reddedilir (hata hakkından düşmez). Yerelde denemek için Cloudflare'in herkese açık test anahtarları: site `1x00000000000000000000AA`, gizli `1x0000000000000000000000000000000AA` (her zaman geçer; kutuda "yalnızca test" uyarısı görünür).
+73 -3
View File
@@ -1,9 +1,24 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ 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. **Sıradaki:** Kullanıcının canlıda (kakeibo.ta1sph.website) testi. Borç başlangıç verisi: `samples/kakeibo-borc-baslangic.json` (git/Docker dışı) → canlıda /borc → İçe aktar. Kullanıcının bulduğu hatalar/istekler önce. Faz 11 (Tauri) yalnız kullanıcı isteyince.
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
## 📌 Alınan Kararlar ## 📌 Alınan Kararlar
- **Borç takibi (kullanıcı, 2026-11-14):** `butcem/docs/BORC_TAKIBI.md` Kakeibo'ya uyarlanıyor (`docs/BORC_TAKIBI.md`). Ayrı Borç modülü (harcanabilir tutarı düşürmez; C = "Borç ödemesi"); başlangıç verisi canlıya içe aktarma dosyasıyla (repo dışı). Kullanıcı asıl olarak canlıyı kullanıyor.
- **Giriş güvenliği (kullanıcı, 2026-11-14):** reCAPTCHA istendi → seçeneklerden **Cloudflare Turnstile** seçildi (KVKK dostu); kilit **IP'ye göre** (e-postaya göre değil: sahibi kilitleme saldırısı olmasın). Üst üste 3 hata → giriş + kayıt 15 dk.
- **Tauri / mobil + masaüstü uygulamalar (kullanıcı, 2026-11-13):** Faz 11 (Windows, Linux, Android) **kullanıcının geniş kapsamlı testinden sonra, kullanıcı prompt verdiğinde** yapılır; kendiliğinden başlanmaz. Şimdi Faz 10: web uygulamasının eksikleri + arayüz cilası (PWA web kurulumu Faz 10'da kalır).
- **Aylık rapor tasarımı (kullanıcı onayı, 2026-11-13):** 7 bölüm — Bir bakışta (4 soru + önceki dönem) · Ne yaptım (gruplar ✅≤%90/⚠️≤%100/❌, ilk 5 kategori + ortalamaya göre, haftalar, ödeme türü, birikim) · İyi gitti · Zorladı · Ne yapılabilirdi ("olsaydı" hesapları) · Kendi notların (plan notları + yansıtma, yorumsuz) · Gelecek dönem (önerilen bütçe = son ≤4 dönem ortalaması 50 ₺ yukarı, ≤3 somut adım, devir, boş hedef kutuları) + Ek: tüm kategoriler. Eşik: kategori %20 **ve** 250 ₺. Karşılaştırma: önceki ≤3 kapalı/arşiv dönem. "Sen" dili. Açık dönem → ara rapor (hedef yargısı yok, bitmemiş hafta yok).
- **Hafta kuralı değişti (kullanıcı, 2026-11-13):** Pazartesi başlangıçlı haftalar yerine dönemin 1–8, 9–15, 16–22, 23–son günleri (ay = 4 hafta). Kasım 2026'da 6 hafta (01.11 tek gün) çıkması üzerine. Genel: `max(1, gün/7)` hafta, ilk 8 gün, ara 7, son kalan (6–12). Defter her açılışta bugünün haftası; hafta artık adrese yazılmaz (sekme geri yüklemede eski hafta açılıyordu).
- **Ekstre kararları (kullanıcı, 2026-11-13):** biçim Excel (.xlsx/.xls) + CSV (kullanıcı tam bilmiyor; ilk gerçek ekstreyle denenecek), hesaba gelenler → **gelir** (plan kalemi), arşiv dönemi **takvim ayı**, iadeler **aynı kategoriden düşülür** (eksi tutarlı kayıt).
- **Dikkat (2026-11-13):** Claude'un hızlı ardışık `backend/src` değişiklikleri (bilerek bozup geri alma, toplu düzenleme) kullanıcının `npm run dev` içindeki `cargo watch`'ını backend kapalı halde bırakabiliyor (giriş: "Beklenmeyen hata", 5173 → 502). Böyle denemelerden sonra `curl localhost:8080/api/health`; kapalıysa `touch backend/src/main.rs` (cargo watch yeniden başlatır). Arayüz artık 502/503/504'te "Sunucu şu an yanıt vermiyor" der.
- **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`). - **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`. - 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). - 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 +62,10 @@
## ❓ Açık Konular ## ❓ 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. - 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. - **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+). - Ü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). - Altın kuru için veri kaynağı seçimi (Faz 8).
- Aylık değerlendirme çıktı formatı kullanıcıdan gelecek (Faz 9). - 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. - Canlı MongoDB kurulum şekli (konteyner mi, doğrudan mı) Faz 3'te birlikte kontrol edilecek.
@@ -91,3 +106,58 @@
- **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 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 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 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.
- **Adım 3:** CSV dışa/içe aktarma. Backend `services/csv_io.rs` (saf: yazma BOM/`;`/CRLF/`1250,50`/GG.AA.YYYY; okuma: BOM, `;`/`,`/sekme otomatik, tırnak, satır numarası, `norm` başlık eşleme (Türkçe İ/ı), `parse_money` (1.250,50 · 1250.50 · 1.000=bin), `parse_e6`, `parse_date_any`), `services/transfer.rs` (harcama/plan/varlık: dışa aktar; içe aktar `commit=false` deneme → rapor {total, valid, duplicates, imported, errors[(satır, neden)], preview}; tekrar sayacı `Existing` (dosyada 2, DB'de 1 → 1 eklenir); harcamada dönem tarihten, kapalı/yok → satır hatası, kategori adla (+grup), aynı ad 2 grupta → hata; plan `plan::create` ile; varlıkta tarih+dosya sırasına göre eldekinden fazla satış simülasyonu, yazma `asset::create_tx`). `Group::label()`. Rotalar `routes/transfer.rs` (`/export/*.csv`, `POST /import/{tür}` 5 MB). `tests/transfer.rs` (3) + csv_io birim (4); mutasyon 3/3. Frontend: Ayarlar → Veri (`features/settings/data-section.tsx`: indirme bağlantıları, içe aktarma: tür seç → dosya → deneme raporu → onay), `api.postCsv`, `lib/decode.ts` (UTF-8 fatal → Windows-1254), `lib/transfer.ts`. Backend 164, frontend 125 test; e2e 16 kontrol.
- **Adım 4a:** Ekstre backend. `PeriodStatus::Archived` (yazılabilir; kapatılmaz 409; devir zincirinde atlanır: `period::create` önceki, `closing::next_period`, reopen engeli; `budget::previous_closed` kapalı+arşiv), `period::create_archive`. `services/bank_import.rs`: `fold` (Türkçe → ASCII; **"İ".to_lowercase() = i + U+0307** düzeltildi), `rule_key` (ilk 2 anlamlı kelime), `import_key`, `default_kind` (ödeme/virman → yoksay; kartta giriş → iade, hesapta → gelir), `target` (içeren yazılabilir dönem / takvim ayı arşivi (kırpılmış) / gelecek-kapalı engel), `preview`, `commit` (arşivleri açar, harcama/iade insert_many + `import_key`, gelir `plan::create` + `import_key`, kurallar upsert `import_rules`). Entry update: iade eksi kalır. Rotalar `routes/bank_import.rs` (`/bank-import/preview|commit`, 5 MB). `tests/bank_import.rs` (3, devir zinciri dahil); mutasyon 5/5. Frontend: `archived` durum metni, Değerlendirme'de arşiv bilgisi, kapatma sihirbazı arşivi reddeder. Backend 177 test.
- **Adım 4b:** Ekstre arayüzü `/ayarlar/ekstre` (`features/statement/`: `page.tsx` Dosya → Eşleştir (başlık satırı otomatik `detectHeaderRow`, sütun tahmini `guessMapping`, kaynak kart/hesap → tutar modu, Borç/Alacak, ödeme türü, profil adı → localStorage `kakeibo.statement.profiles`, ad ile eşleşir; önizlemede tarih yorumlanmış) ; `review.tsx` (tür+kategori, değişiklik aynı `ruleKey`'li elle değiştirilmemiş satırlara yayılır + bildirim, filtreler, sayfalama 50, telefonda kart/masaüstünde sütunlu liste, atlanacaklar soluk, arşiv bildirimi, kategori eksikse aktarma kapalı, sonuçta hata satırları tarih+açıklama ile)). `lib/statement.ts` (Excel seri tarih, `parseAmount` TR/EN/parantez/sondaki eksi, `normalize`, `fold`/`ruleKey` = backend, profiller), `lib/read-sheet.ts` (SheetJS lazy 160 KB gz, `blankrows: true` → satır no Excel ile aynı; CSV kendi ayrıştırıcı + `decodeText`), `lib/bank-import.ts`. Backend: kural önerisine **ilk kelime yedeği** (`suggest`, genel kelimeler hariç). SheetJS yerel paket (`frontend/vendor`, Dockerfile kopyalar; Docker web aşaması doğrulandı). Defter: iade satırı "İade" etiketi, düzenlemede artı gösterilip eksi kaydedilir. Frontend 134, backend 178 test; e2e 25 kontrol.
- **Hafta düzeltmesi (2026-11-13):** `calendar::weeks` yeni kural (testler: Kasım/Aralık/Şubat/artık yıl, ay ortası dönem, 13/14/62 gün). Bütçe/defter/değerlendirme entegrasyon testleri yeni sınırlara göre güncellendi (backend 178). Frontend: `LedgerView` haftayı `useState`'te tutar, `parseWeekParam` kaldırıldı, dönem kısalırsa son haftaya düşer; `budget-preview`/`ledger` testleri yeni Aralık (8,7,7,9) ile (133). E2E 9 kontrol (8091, DB silindi). Dev verisinde elle haftalık bütçe yoktu → taşıma gerekmedi; canlıda varsa eşleşmeyen eski hafta yok sayılır (aylık tutar korunur).
- **Faz 9 – Adım 5a (2026-11-13):** `services/backup.rs` + `routes/backup.rs` (`/backup/download|status|preview|restore`, `/backup/restore-points/{id}/restore`; preview/restore/undo **sahip**, 20 MB). Biçim ham BSON belgeleri üzerinden (modeller eksik alanlı olduğu için tür tabanlı değil): `$uuid`/`$date`, `INT32_KEYS` (version, sort_order) + ayarlar i32 (`small_ints`), SHA-256 kanonik (sıralı anahtar) → yeniden biçimlendirilmiş dosya geçerli, değiştirilmiş reddedilir. **Silinmiş kayıtlar dahil** (tasarımdan sapma: referans bütünlüğü + birebir geri al). Migration v3 `restore_points` (transaction içinde ilk yazım için) + indeks. `flate2`, `sha2` doğrudan eklendi (zaten bağımlılık ağacında). Testler: 4 birim + 3 entegrasyon (aynı hane + geri al zinciri + 3 nokta sınırı, başka haneye iki kez kopya, bozuk/yabancı/yeni sürüm dosya, üye 403, başka hane 404). 3 bilinçli bozma yakalandı. Backend 190 test.
- **Faz 9 – Adım 5b (2026-11-13):** `features/settings/backup-section.tsx` (Veri bölümünün en üstü): "Yedeği indir" (`<a download>`, tıklayınca 1,5 sn sonra durum yenilenir) + hatırlatma (`daysSince`, hiç yok ya da ≥ 30 gün → uyarı rengi); sahip: dosya → önizleme (kaynak, aynı/başka hane, dönem aralığı, sayılar yedek/şimdi — farklı olan uyarı rengi, uyarılar) → onay kutusu (`#backup-confirm`; Radix `name`'i düğmeye taşımaz) → "Geri yükle" (destructive) → sonuç kutusu + "Geri al"; geri dönüş noktaları listesi (`window.confirm`). Üye: yalnız indirme + "yalnız sahip" notu. `lib/backup.ts` (geri yükleme/geri alma → `qc.invalidateQueries()` tümü), `api.postJsonText`, `queryKeys.backup`, `t.backup`. Frontend 134 test; e2e 27 kontrol (8091, DB silindi).
- **Faz 9 – Adım 6a (2026-11-13):** `services/monthly_report.rs` + `routes/monthly_report.rs` (`GET /periods/{id}/report.md`, `text/markdown`), `templates/monthly_report.md` (minijinja 3.1, `serde` özelliği; trim/lstrip blocks; art arda boş satırlar sadeleştirilir). `minijinja` yeni bağımlılık (şablonda koşul/döngü). Dockerfile: `COPY templates/ /app/templates/` (`include_str!("../../../templates/…")`), `docker build --target api` geçti. Testler: 5 birim (biçim, eşikler, kapalı dönem yorumları, ara rapor + ilk dönem, tam şablon + hücre kaçışı) + 1 entegrasyon (Ekim kapalı → Kasım raporu: karşılaştırma, notlar, devir, öneri, 404). 5 bilinçli bozmadan 2'si önce kaçtı → eşik testleri eklendi, hepsi yakalandı. bson `DateTime::to_time_0_3` crate-içi → `from_unix_timestamp`. Backend 196 test.
- **Faz 9 – Adım 6b (2026-11-13) → Faz 9 ✅:** `features/review/report-page.tsx` (`paths.monthlyReport = /degerlendirme/rapor`, dönem seçici, Yazdır/PDF `printSection()`, "Markdown indir" `downloadFile` (csv.ts'e genel yardımcı), `reportFileName` = backend slug). Bağlantılar: Değerlendirme başlığı, kapatma sonucu, dönem geçmişinde ayrı simge bağlantı (iç içe `<a>` olmasın). Bulunan/düzeltilen: (1) şablondaki `<small>` dipnot react-markdown'da hiç görünmüyordu → düz `_…_`; (2) telefonda tablolar sayfayı 541 px'e taşırıyordu (mobil emülasyonda `innerWidth` da büyür → kontrol 390 ile) → `Markdown` tablo sarmalayıcısı `.table-wrap`; (3) PDF ilk sayfada sayfa başlığı/dönem seçici → `PageHeader printHidden`; (4) ara raporda başlamamış haftalar "✅ %0" → "—"; (5) hedef kutuları sayfa sonunda bölünüyordu. PDF: Chrome `printToPDF` → 4 sayfa A4, `pdftoppm` ile gözle kontrol. Frontend 135, backend 196 test; e2e 22 kontrol (DB silindi).
- **Faz 10 – Adım 1 (2026-11-13):** Ayarlar menüsü (`features/settings/page.tsx`: Kategoriler, Ödeme türleri, Veri ve yedek + tema) ve alt sayfalar (`subpage.tsx`, `catalog-pages.tsx`, `data-page.tsx`); ekstre sayfası "‹ Veri ve yedek"e döner. `catalog-editor.tsx`: satırda ↑ ↓ ✎ (44 px, `aria-label` adlı), düzenleme formu ad + emoji (hızlı seçim paleti, `aria-pressed`), Pasifleştir/Yeniden etkinleştir + toast "Geri al" (sürümsüz PATCH), aynı ad hatası backend'den. `lib/catalog.ts` mutasyonları `['categories'|'payment-methods']` + rapor/dönem önbelleğini yeniler. Telefonda grup başlığı bölünüyordu → açıklama alta. Frontend 136 test; e2e 25 kontrol (DB silindi).
- **Faz 10 – Adım 2 (2026-11-13):** Backend: `PATCH /auth/me` (ad), `POST /auth/me/password` (mevcut şifre + limiter `password:<uid>`, `sessions_valid_from` → diğer oturumlar 401, bu cihaza yeni çerez, "beni hatırla" eski çerezin `exp-iat`'ından), `PATCH /household` (sahip: ad, `budget_split_pct` toplam 100, `budget_suggestion`), `DELETE /household/members/{id}` (sahip; kendini/sahibi çıkaramaz; e-posta `#removed-<id>` → yeniden davet edilebilir). `GET /household` artık `settings` döner. `services/account.rs`. Testler `tests/account.rs` (4) + 2 bilinçli bozma yakalandı. Frontend: `/ayarlar/hane` (ad, üyeler + çıkar onaylı, davet oluştur → panoya kopyala / paylaş / iptal), `/ayarlar/hesap` (ad, e-posta salt okunur, şifre formu: zod tekrar + farklı, sunucu hatası alana), `/ayarlar/butce` (4 yüzde canlı toplam, 50/30/10/10 düğmesi, yöntem radyo; üye salt okunur). Öneri seçici ve sihirbaz hanenin yöntemiyle açılır (veri yoksa yüzdelere düşer). E2E 28 kontrol: **davet bağlantısıyla gerçek kayıt** dahil (`REGISTRATION=invite_only`). Not: başsız Chrome'da pano için `Emulation.setFocusEmulationEnabled`. Backend 205, frontend 140 test.
- **Faz 10 – Adım 3 (2026-11-13):** Tarama betiği (scratchpad `e2e/scan.mjs`): 21 sayfa × telefon açık/koyu + masaüstü (+ boş hane, giriş/kayıt); sayfa içi denetim (adsız denetim, <44 px hedef, canvas ile oklch→sRGB kontrast, tek h1, alt, lang, taşma) + Tab ile odak halkası. İlk sonuç 89 kontrast / 75 küçük hedef / 27 adsız / 3 h1 → benzersiz: muted yazı 4,35 (→ token 0.52), yeşil 4,37 (→ #166534), `sm` düğmeler 36 px (→ pointer-coarse 44), GFM görev kutusu adsız (→ "Tamamlandı/Yapılacak"), rapor 2 h1 (→ `shiftHeadings`). **Ekran görüntüsünden:** telefonda Defter formu taşıyordu (ızgara öğesi `min-width:auto` 425 px) — otomatik kontrol kaçırdı çünkü mobil öykünmede `innerWidth` da büyür → kontrol `screen.width`/390 ile; Raporlar grafik kartları da taşıyordu → `Section` + rapor bölümüne `min-w-0`. Eklenen: %80 bütçe uyarısı (`lib/budget-level.ts`), Raporlar boş durumu (dönem yok → Dönemi başlat / Ekstreden aktar; filtre boş → Filtreleri temizle), tutarlarda bölünmez boşluk. Sonra: kontrast/adsız/h1/telefon hedef 0. Frontend 142 test.
- **Faz 10 – Adım 4 (2026-11-13):** Önbellek başlıkları zaten doğruydu (Faz 5: `/assets/*` 1 yıl immutable, HTML no-cache, sıkıştırma). Ana JS 950 KB (gz 284) tek paket → sayfa bazlı `lazy` route: ana 402 KB (gz 128), sayfalar 2–23 KB; Markdown (49 KB gz) ve zod/form (40 KB gz) yalnız kullanan sayfada. İlk sürümde Defter yavaşlamıştı (lazy route → `/auth/me` sıralı) → `prefetchMe` ile paralel; `HydrateFallback: Splash`. Boşta alt menü sayfalarını önden indirme + `useNavigation` ilerleme çizgisi. Ölçüm (scratchpad `e2e/perf.mjs`: yavaş 4G 1,6 Mbps/150 ms + CPU 4×, soğuk; eski sürüm `git worktree` ile ayrı derlenip aynı koşulda, 2 tekrar ort.): Giriş 2,97→2,23 sn, Panel 2,42→2,03, Raporlar 2,36→2,11, Defter 2,33→2,37 (aynı); hazırda JS 295→171–221 KB. Regresyon: katalog (25) + rapor (21) e2e geçti. Not: e2e rapor başlığı artık `.md-h1`.
- **Faz 10 – Adım 5 (2026-11-13) → Faz 10 ✅:** `vite-plugin-pwa` 2.0 + `workbox-window` (dev): manifest (tr, standalone, ikonlar + maskable, kısayollar Harcama ekle/Panel), Workbox precache 98–101 dosya (xlsx ve vietnamca/yunanca/kiril font alt kümeleri hariç → runtime CacheFirst `/assets/`), `navigateFallback` index.html (`/api` hariç). `registerType: 'prompt'` → `PwaUpdater` toast + saatlik `reg.update()`. `OfflineBanner` (useSyncExternalStore). Backend `spa.rs` `cache_policy` (sw.js, manifest, registerSW → no-cache) + birim testi. **Bulunan hata:** çevrimdışı açılışta `/auth/me` ağ hatası → RequireAuth kullanıcıyı giriş sayfasına atıyordu; ayrıca TanStack onlineManager açılış durumunu bilmediği için birkaç sn boş açılış → `SessionUnknown` ("Oturum bilgisi alınamadı… Tekrar dene") + `onlineManager.setOnline(navigator.onLine)`; bağlantı gelince kendiliğinden toparlanır. E2E 14 kontrol (CDP `Page.getInstallabilityErrors` boş, önbellekte /api 0, çevrimdışı, güncelleme: dist kopyası + sw.js index revizyonu değiştirilip sunucu yeniden başlatıldı → bildirim → Yenile → yeni sürüm, oturum/sayfa korunur). npm audit: yeni `glob` (orta) yalnız derleme zamanı; diğer uyarılar mevcut `shadcn` CLI'dan. Docker web aşaması geçti. DEPLOY §7 kurulum.
- **Giriş güvenliği (2026-11-14):** Backend `auth/guard.rs` (AttemptGuard, ardışık sayaç, başarıda sıfır, süre bitince yeni tur, 1 sa dokunulmayan kayıt silinir), `auth/captcha.rs` (siteverify, 8 sn zaman aşımı, ağ hatası → `Unavailable` sayılmaz ama reddedilir, `internal-error` de öyle), `auth/client_ip.rs` (en sağ XFF, yoksa ConnectInfo; `main.rs` `into_make_service_with_connect_info`). Login/register `precheck` (kilit → captcha) + sayılan hatalar (yanlış şifre `InvalidCredentialsLeft(n)`, RegistrationClosed / BadRequest davet / Conflict e-posta). E-postaya göre giriş limiter'ı kaldırıldı (şifre değiştirmede kullanıcı başına kaldı). Config `TURNSTILE_SITE_KEY`/`SECRET_KEY` (ikisi birden), `TRUST_PROXY` (varsayılan true: Traefik arkasında herkes aynı IP olmasın). Testler `tests/security.rs` (4) + guard/XFF birim; 2 bilinçli bozma yakalandı; backend 214. Frontend `useAuthSecurity` (jeton, her hatada widget reset, kilit → `useAuthGuard` refetch, `dataUpdatedAt`+saniye geri sayım), Turnstile explicit render (tr, tema, flexible), betik yüklenemezse açıklama + düğme kapalı. E2E: Cloudflare test anahtarlarıyla gerçek widget + siteverify; kalan hak 2/1, kilit 14:57 sayaç, yenileme/kayıt/API kilitli; Cloudflare engelli → düğme kapalı, jetonsuz API 400 captcha. **Kullanıcıda:** Cloudflare'den anahtar alıp Dokploy'a girmek (DEPLOY §8).
- **Borç takibi B1 (2026-11-14):** Migration v4 (3 koleksiyon) + indeksler + `backup::KINDS`. Taksit planı `first_month` ile (içe aktarmada `asOf − paidCount + 1`); kaynak belgedeki tüm kabul rakamları birim + entegrasyon testinde birebir (78.530,48 / 79.078,85 / devreden 64.352,79 / yükler 4.311,82…182,02 / kalan 14.177,69 ve 9.865,87). Ay sonu hedefi toplam borç cinsinden (= ay başı − C − taksit; yol haritası da toplam). Kart/IBAN: 12+ haneli sayı reddedilir. Testler `tests/debt.rs` (3) + 6 birim; 2 bilinçli bozma yakalandı.
- **Borç takibi B2 (2026-11-14):** Panel en üstünde `DebtCard` (yalnız ölçüm varsa; değişim yeşil/kırmızı ok, başlangıç→0 ilerleme, tahmini bitiş, döküm), `/borc` (grafik: gerçek düz + hedef kesikli, ilk ölçümden başlar, eksen ay başları; ölçüm tablosu tam genişlik; form [toplam canlı, kalan taksit önerisi; form anahtarı taksit planlarına bağlı → içe aktarmadan sonra öneri dolar]; yol haritası düzenleyici; içe aktar ön izleme→onay), `/taksitler` (CSS çubuk 6 ay, planlar ödenen/kalan, yeni taksitte borç varken uyarı). Menüde "Borç". E2E 33 kontrol (belge JSON'u içe aktarılarak; kabul rakamları ekranda). Bulunan: öneri boş kalıyordu (form içe aktarmadan önce kurulmuştu); eksende aynı ay iki kez; tablo sıkışması.
- **Borç takibi B3 (2026-11-14) → B1–B3 ✅:** Plan'da "Bu ayın borcu" (`PeriodDebtSection`, yalnız borç verisi varsa): ay başı (ölçümden / elle, `useSetOpeningDebt`, "Son ölçüme dön"), devreden banka borcu, borç ödemesi (C), bu ayın taksiti, hedef ay sonu + yol haritası farkı (üstünde kırmızı / altında yeşil), dönem içi son ölçüme göre azalma vs C+taksit çubuğu, "Taksitler" sabit gideri tek tık (adı `findInstallmentItem`: taksit/taksitler, tr küçük harf; tutar farklıysa uyarı). `SummaryTiles debtPayment`. E2E 18 kontrol (hedef 67.218,66 = 78.530,48 − 7.000 − 4.311,82; harcanabilir 53.000 → 48.688,18). İçe aktarma dosyası `samples/` (gitignore) — 12+ haneli sayı yok.
- **Dev sunucusu düzeltmesi (2026-11-14):** Kullanıcı /borc'ta "Kayıt bulunamadı" gördü: 8080'de 00:40'tan kalma eski backend (borç kodu yok) çalışıyordu; yeniden başlatılan `npm run dev`'in cargo watch'ı portu alamıyordu (ikinci kez). Kök neden: `concurrently -k` cargo watch'ı kapatır, `cargo run`'ın çocuğu kakeibo bazen yetim kalır. Çözüm: `scripts/stop-stale-api.sh` (`dev:api` başında; 8080'i tutan süreç adı `kakeibo` ise kapatır, değilse uyarır). 8091'de denendi.
+56 -11
View File
@@ -8,14 +8,14 @@
| **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 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 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 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 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ✅ |
| **Faz 5** | Günlük defter | Harcama girişi | ⏳ | | **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 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 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ |
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ⏳ | | **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 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 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 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) — **kullanıcının geniş kapsamlı testinden sonra, kullanıcı istediğinde** | Masaüstü ve mobil uygulamalar | ⏸️ |
## Faz 0 Adımları ## Faz 0 Adımları
- ✅ 1. Ortam kontrolü ve kurulum yönergeleri - ✅ 1. Ortam kontrolü ve kurulum yönergeleri
@@ -49,6 +49,51 @@
- ✅ 1. Backend: dönem (aç, açık dönem, liste, detay + haftalar, düzenle), takvim hesapları, testler - ✅ 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 - ✅ 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) - ✅ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
- 🔄 4. Frontend: ilk dönem sihirbazı - ✅ 4. Frontend: ilk dönem sihirbazı
- ⏳ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı) - ✅ 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) - ✅ 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** — 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) + 4b arayüz (`/ayarlar/ekstre`: Excel/CSV, sütun eşleştirme + profil, gözden geçirme)
- ✅ 5. JSON tam hane yedeği — 5a backend (sürümlü dosya + SHA-256, önizleme, tam değiştirme tek transaction, geri dönüş noktaları + "geri al", başka haneye kimlik çevirerek taşıma) · 5b arayüz (Ayarlar → Veri: indir + "son yedek" hatırlatması, sahip için önizleme → onay → geri yükle → "Geri al", geri dönüş noktaları)
- ✅ 6. Aylık değerlendirme çıktısı — 6a backend (`templates/monthly_report.md` + minijinja, `GET /periods/{id}/report.md`, kural tabanlı yorum ve öneriler) · 6b arayüz (`/degerlendirme/rapor`: uygulama içi görünüm, Yazdır/PDF A4, Markdown indir; Değerlendirme, kapatma sonucu ve dönem geçmişinden bağlantı)
## Faz 10 Adımları (web uygulamasının eksikleri + cila)
- ✅ 1. Ayarlar yeniden düzeni (`/ayarlar` menü + tema) · **Kategori yönetimi** (grup grup ekle, ad/emoji düzenle, sırala, pasifleştir) · **Ödeme türü yönetimi** · Veri sayfası `/ayarlar/veri`
- ✅ 2. Hane ve hesap: hane adı, üyeler, **davet bağlantısı** (oluştur/kopyala/iptal), üye çıkarma; profil adı, **şifre değiştirme**; varsayılan bütçe yüzdeleri + öneri yöntemi
- ✅ 3. Arayüz cilası ve erişilebilirlik: tüm sayfaların telefon/masaüstü/koyu tema taraması, boş durumlar, skeleton, %80 bütçe uyarısı, odak/klavye, kontrast, onay diyalogları
- ✅ 4. Performans: sayfa bazlı kod bölme (lazy route), statik dosyalarda uzun önbellek, paket boyutu
- ✅ 5. PWA: manifest + service worker (uygulama kabuğu; `/api` asla önbelleğe alınmaz), kurulum, güncelleme bildirimi
## Borç Takibi Adımları (docs/BORC_TAKIBI.md, kullanıcı isteği 2026-11-14)
- ✅ B1. Backend: ölçümler, taksit planları (ilk ay + adet → aylık yük/kalan kendiliğinden), yol haritası, özet, dönem borcu (ay başı, devreden, hedef), elle ay başı, kaynak belge JSON'unu içe aktarma (ön izleme/onay), JSON yedeğine dahil
- ✅ B2. Arayüz: Panel borç kartı, `/borc` (gerçek + hedef grafik, tablo, yeni ölçüm, hedefler, içe aktar), `/taksitler`
- ✅ B3. Aylık plan entegrasyonu ("Borç ödemesi", ay başı/devreden/hedef, taksit önerisi) + içe aktarma dosyası (repo dışı)
+6311 -4
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -25,11 +25,15 @@
"react": "^19.3.1", "react": "^19.3.1",
"react-dom": "^19.3.1", "react-dom": "^19.3.1",
"react-hook-form": "^7.92.0", "react-hook-form": "^7.92.0",
"react-markdown": "^10.1.0",
"react-router": "^8.5.1", "react-router": "^8.5.1",
"recharts": "^3.10.1",
"remark-gfm": "^4.0.1",
"shadcn": "^4.23.0", "shadcn": "^4.23.0",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwindcss": "^4.3.5", "tailwindcss": "^4.3.5",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
"zod": "^4.6.7" "zod": "^4.6.7"
}, },
"devDependencies": { "devDependencies": {
@@ -41,6 +45,8 @@
"prettier": "^3.10.0", "prettier": "^3.10.0",
"typescript": "~7.0.2", "typescript": "~7.0.2",
"vite": "^8.3.6", "vite": "^8.3.6",
"vitest": "^5.1.0" "vite-plugin-pwa": "^2.0.0",
"vitest": "^5.1.0",
"workbox-window": "^7.4.1"
} }
} }
+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>
)
}
+48 -6
View File
@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { LogOut } from 'lucide-react' import { LogOut } from 'lucide-react'
import { NavLink, Outlet, ScrollRestoration, useLocation } from 'react-router' import { NavLink, Outlet, ScrollRestoration, useLocation, useNavigation } from 'react-router'
import { Logo } from '@/components/logo' import { Logo } from '@/components/logo'
import { ThemeToggle } from '@/components/theme-toggle' import { ThemeToggle } from '@/components/theme-toggle'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -14,19 +15,21 @@ import { bottomNav, moreNav, paths, sideNav } from './nav-items'
* - Masaüstü (≥ md): solda kenar çubuğu * - Masaüstü (≥ md): solda kenar çubuğu
*/ */
export function AppLayout() { export function AppLayout() {
usePrefetchMainPages()
return ( return (
<div className="min-h-dvh"> <div className="min-h-dvh">
<NavigationProgress />
<Sidebar /> <Sidebar />
{/* Mobil üst çubuk */} {/* 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 /> <Logo size={28} withText />
</header> </header>
{/* md:pl-64 → kenar çubuğu kadar boşluk; içerik bu alanda ortalanır. {/* 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. */} pb-28 → mobilde alt menünün altında içerik kalmasın. */}
<div className="md:pl-64"> <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"> <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 /> <Outlet />
</main> </main>
</div> </div>
@@ -37,9 +40,48 @@ export function AppLayout() {
) )
} }
/**
* Alt menüdeki ana sayfaların kodu, tarayıcı boşta kalınca önceden indirilir
* (ilk açılışı yavaşlatmaz; menüde gezinirken bekleme olmaz).
*/
function usePrefetchMainPages() {
useEffect(() => {
const load = () => {
void import('@/features/dashboard/page')
void import('@/features/ledger/page')
void import('@/features/planning/page')
void import('@/features/savings/page')
}
if ('requestIdleCallback' in window) {
const id = window.requestIdleCallback(load, { timeout: 5000 })
return () => window.cancelIdleCallback(id)
}
const id = setTimeout(load, 2000)
return () => clearTimeout(id)
}, [])
}
/** Sayfa kodu inerken (yavaş bağlantı) üstte ince ilerleme çizgisi; anlık geçişte görünmez */
function NavigationProgress() {
const loading = useNavigation().state === 'loading'
return (
<div
role="progressbar"
aria-label={t.common.loading}
aria-hidden={!loading}
className={cn(
'pointer-events-none fixed inset-x-0 top-0 z-50 h-0.5 origin-left bg-primary transition-[transform,opacity] print:hidden',
loading
? 'scale-x-75 opacity-100 delay-150 duration-[2s] ease-out'
: 'scale-x-100 opacity-0 duration-300',
)}
/>
)
}
function Sidebar() { function Sidebar() {
return ( 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"> <div className="flex h-16 items-center px-5">
<Logo size={32} withText /> <Logo size={32} withText />
</div> </div>
@@ -78,7 +120,7 @@ function BottomNav() {
return ( return (
<nav <nav
aria-label={t.nav.main} 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"> <ul className="grid grid-cols-5">
{bottomNav.map(({ to, label, icon: Icon }) => ( {bottomNav.map(({ to, label, icon: Icon }) => (
+34 -2
View File
@@ -2,6 +2,7 @@ import {
CalendarDays, CalendarDays,
ChartLine, ChartLine,
ClipboardCheck, ClipboardCheck,
Landmark,
LayoutDashboard, LayoutDashboard,
Menu, Menu,
NotebookPen, NotebookPen,
@@ -18,21 +19,43 @@ export const paths = {
dashboard: '/', dashboard: '/',
ledger: '/defter', ledger: '/defter',
planning: '/plan', planning: '/plan',
/** İlk dönem sihirbazı; `?adim=1..5` */
periodWizard: '/plan/donem-ac',
savings: '/birikim', savings: '/birikim',
/** Varlık ayrıntısı; adres için `assetPath(id)` */
asset: '/birikim/:id',
more: '/diger', more: '/diger',
review: '/degerlendirme', review: '/degerlendirme',
/** Dönem kapatma sihirbazı */
closePeriod: '/degerlendirme/kapat',
monthlyReport: '/degerlendirme/rapor',
/** Borç takibi */
debt: '/borc',
installments: '/taksitler',
reports: '/raporlar', reports: '/raporlar',
settings: '/ayarlar', settings: '/ayarlar',
settingsCategories: '/ayarlar/kategoriler',
settingsPaymentMethods: '/ayarlar/odeme-turleri',
settingsData: '/ayarlar/veri',
settingsHousehold: '/ayarlar/hane',
settingsAccount: '/ayarlar/hesap',
settingsBudget: '/ayarlar/butce',
/** Banka / kart ekstresi içe aktarma */
statement: '/ayarlar/ekstre',
login: '/giris', login: '/giris',
register: '/kayit', register: '/kayit',
} as const } 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 dashboard: NavItem = { to: paths.dashboard, label: t.nav.dashboard, icon: LayoutDashboard }
const ledger: NavItem = { to: paths.ledger, label: t.nav.ledger, icon: NotebookPen } const ledger: NavItem = { to: paths.ledger, label: t.nav.ledger, icon: NotebookPen }
const planning: NavItem = { to: paths.planning, label: t.nav.planning, icon: CalendarDays } const planning: NavItem = { to: paths.planning, label: t.nav.planning, icon: CalendarDays }
const savings: NavItem = { to: paths.savings, label: t.nav.savings, icon: PiggyBank } const savings: NavItem = { to: paths.savings, label: t.nav.savings, icon: PiggyBank }
const review: NavItem = { to: paths.review, label: t.nav.review, icon: ClipboardCheck } const review: NavItem = { to: paths.review, label: t.nav.review, icon: ClipboardCheck }
const reports: NavItem = { to: paths.reports, label: t.nav.reports, icon: ChartLine } const reports: NavItem = { to: paths.reports, label: t.nav.reports, icon: ChartLine }
const debt: NavItem = { to: paths.debt, label: t.nav.debt, icon: Landmark }
const settings: NavItem = { to: paths.settings, label: t.nav.settings, icon: Settings } const settings: NavItem = { to: paths.settings, label: t.nav.settings, icon: Settings }
/** Mobil alt menü: 5 öğe (KAKEIBO_PROMPT §9) */ /** Mobil alt menü: 5 öğe (KAKEIBO_PROMPT §9) */
@@ -45,7 +68,16 @@ export const bottomNav: NavItem[] = [
] ]
/** "Diğer" altında toplananlar (mobil) */ /** "Diğer" altında toplananlar (mobil) */
export const moreNav: NavItem[] = [review, reports, settings] export const moreNav: NavItem[] = [debt, review, reports, settings]
/** Masaüstü sol menü: hepsi doğrudan */ /** Masaüstü sol menü: hepsi doğrudan */
export const sideNav: NavItem[] = [dashboard, ledger, planning, review, savings, reports, settings] export const sideNav: NavItem[] = [
dashboard,
ledger,
planning,
review,
savings,
debt,
reports,
settings,
]
@@ -1,4 +1,6 @@
import { RotateCw, WifiOff } from 'lucide-react'
import { Navigate, Outlet, useLocation, useSearchParams } from 'react-router' import { Navigate, Outlet, useLocation, useSearchParams } from 'react-router'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/logo' import { Logo } from '@/components/logo'
import { useMe } from '@/lib/auth' import { useMe } from '@/lib/auth'
import { safeReturnPath } from '@/lib/return-path' import { safeReturnPath } from '@/lib/return-path'
@@ -14,12 +16,37 @@ export function Splash() {
) )
} }
/**
* Oturum sorulamadı (bağlantı yok / sunucu yanıt vermiyor): giriş sayfasına ATMA —
* kullanıcı çıkış yapılmış sanır. Bağlantı gelince sorgu kendiliğinden yenilenir.
*/
function SessionUnknown({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="grid min-h-dvh place-items-center p-6">
<div className="flex max-w-sm flex-col items-center gap-4 text-center" role="alert">
<Logo size={56} />
<WifiOff className="size-6 text-muted-foreground" aria-hidden />
<p className="text-muted-foreground">{message}</p>
<Button onClick={onRetry}>
<RotateCw aria-hidden />
{t.common.retry}
</Button>
</div>
</div>
)
}
/** Oturum gerektiren sayfalar. Oturum yoksa → /giris?donus=<şimdiki adres> */ /** Oturum gerektiren sayfalar. Oturum yoksa → /giris?donus=<şimdiki adres> */
export function RequireAuth() { export function RequireAuth() {
const me = useMe() const me = useMe()
const location = useLocation() const location = useLocation()
// Çevrimdışıyken sorgu duraklar (hata vermez) → sonsuz açılış ekranı yerine bağlantı mesajı
if (me.isPending && me.fetchStatus === 'paused')
return <SessionUnknown message={t.pwa.sessionOffline} onRetry={() => void me.refetch()} />
if (me.isPending) return <Splash /> if (me.isPending) return <Splash />
if (me.isError)
return <SessionUnknown message={me.error.message} onRetry={() => void me.refetch()} />
if (!me.data) { if (!me.data) {
const back = location.pathname + location.search const back = location.pathname + location.search
const to = back === '/' ? paths.login : `${paths.login}?donus=${encodeURIComponent(back)}` const to = back === '/' ? paths.login : `${paths.login}?donus=${encodeURIComponent(back)}`
@@ -36,7 +63,12 @@ export function RequireAuth() {
export function RequireGuest() { export function RequireGuest() {
const me = useMe() const me = useMe()
const [params] = useSearchParams() const [params] = useSearchParams()
// Çevrimdışıyken sorgu duraklar (hata vermez) → sonsuz açılış ekranı yerine bağlantı mesajı
if (me.isPending && me.fetchStatus === 'paused')
return <SessionUnknown message={t.pwa.sessionOffline} onRetry={() => void me.refetch()} />
if (me.isPending) return <Splash /> if (me.isPending) return <Splash />
if (me.isError)
return <SessionUnknown message={me.error.message} onRetry={() => void me.refetch()} />
if (me.data) return <Navigate to={safeReturnPath(params.get('donus'), paths.dashboard)} replace /> if (me.data) return <Navigate to={safeReturnPath(params.get('donus'), paths.dashboard)} replace />
return <Outlet /> return <Outlet />
} }
+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>
)
}

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