Compare commits

..

47 Commits

Author SHA1 Message Date
suphicakir f0d285a9ba feat(plan): devreden borç planın parçası, borçlu ayda %45 kuralı
- Devreden borç: elle girilen ya da dönem başından önceki son ay sonu ölçümü
- Borçlu ay: harcanabilir = gelir × oran (vars. %45), kalan borca, artan birikime
- Birikim hedefi ve Kakeibo devri borçlu ayda uygulanmaz; kapanış devir üretmez
- Plan düzenleme ve sihirbazda devreden borç alanı + oran; canlı önizleme
- Plan'daki eski borç bölümü ve taksit önerisi kaldırıldı (veri yerinde)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 06:27:12 +03:00
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
suphicakir 95231cd4d6 feat(bütçe): aylık/haftalık bütçe, bütçe önerisi ve dönem özeti (backend)
- /api/periods/{id}/summary: gelir − sabit gider − birikim − devreden borç + artı = harcanabilir
- /api/periods/{id}/budget: 4 grup aylık; haftalar gün sayısına göre, toplam kuruşu kuruşuna eşit
- Elle hafta ayarı (kalan diğer haftalara dağılır), otomatiğe dönüş, aşım uyarısı
- Öneri: varsayılan yüzdeler / önceki dönem / son 3 dönem ortalaması
- Testler: orantılı bölme (3), bütçe entegrasyon (6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:42:49 +03:00
suphicakir 7f3fde074c feat(plan): sabit gelir/gider kalemleri ve dönem notları (backend)
- /api/periods/{id}/plan-items: listele, ekle; /api/plan-items/{id}: düzenle, sil
- Önceki dönemden kopyala: göreli gün korunur, dönem sonuna kırpılır, aynı kalem tekrar eklenmez
- /api/periods/{id}/notes (planning | review), /api/notes/{id}: Markdown notlar
- Kapalı dönemde değişiklik yok; silme geri alınabilir şekilde işaretlenir
- Entegrasyon testleri (5): kurallar, kopyalama, salt okunur dönem, hane izolasyonu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:34:35 +03:00
363 changed files with 44734 additions and 375 deletions
+1
View File
@@ -9,3 +9,4 @@
backups/
.vscode/
*.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).
# Docker imajında /app/static olarak ayarlıdır.
# 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
backups/
# Örnek ekstreler (kişisel veri; depoya girmez)
samples/
+16
View File
@@ -0,0 +1,16 @@
# Değişiklik günlüğü
Biçim: [Keep a Changelog](https://keepachangelog.com/tr/1.1.0/). Tarihler GG.AA.YYYY.
## Yayınlanmamış
### Eklendi
- Aylık planda **Devreden borç (KMH + kart)**: son ay sonu ölçümünden otomatik gelir, planda ve yeni dönem sihirbazında elle düzeltilebilir ("elle düzeltildi", "Ölçüme dön"). Ölçüm yoksa elle yazılır.
- Borçlu ay düzeni: harcanabilir = gelirin %45'i (dönem başına %10–%90 ayarlanabilir); kalan önce borca gider, borcu aşan kısım birikime. Özet: Gelir − Sabit giderler − Bu ay borca giden = Harcanabilir; altında "Devreden borç · ay sonu hedef". Sabitler çok yüksekse kırmızı uyarı.
### Değişti
- Borç takibi aylık planın parçası oldu (docs/KAKEIBO_PANEL_ISTEKLERI.md). Borçlu ayda birikim hedefi ve Kakeibo'nun kendi devri (önceki ay aşımı/artısı) uygulanmaz; dönem kapanışında devir ve birikim aktarımı üretilmez. Devreden borç 0 olunca normal Kakeibo düzeni döner.
- Kakeibo'nun bütçe aşımı devri artık "Önceki ay aşımı" adıyla gösterilir (banka borcuyla karışmasın).
### Kaldırıldı
- Plan'daki "Bu ayın borcu" bölümü ve "Taksitler" sabit gideri önerisi (taksitler sabit giderlere elle girilir). Veriler yerinde.
+17 -4
View File
@@ -43,7 +43,7 @@ scripts/ seed, reset, backup
- Her sorgu `household_id` ile filtrelenir (ortak middleware).
- Hesaplanan değerler saklanmaz; aggregation ile hesaplanır.
- 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.
- 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 types # Rust DTO → frontend/src/types/*.ts (ts-rs; cargo test de üretir)
npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonları (rsvg-convert + magick)
npm run db:backup # kakeibo_dev → ./backups (canlı yedek: docs/DEPLOY.md §6)
npm run db:restore -- <dosya|latest> <hedef_db> [--force]
```
- `npm run dev` öncesinde DB otomatik başlar (`predev`).
- `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).
- Frontend lint: **oxlint** (Vite şablonu varsayılanı; ESLint yerine). `components/ui/**` shadcn dosyaları.
- Frontend: Tailwind v4 (`src/index.css` token'ları), shadcn/ui (style `radix-nova`, `cn` paketi), `@/` → `src/`. Renkler: `bg-needs`/`text-needs-fg`/`bg-needs-soft` (wants, culture, unexpected aynı), `text-income`/`text-expense`/`text-warning`. Tutarlarda `money` sınıfı. Butonlar ≥44 px (shadcn boyutları büyütüldü).
- Frontend: Tailwind v4 (`src/index.css` token'ları), shadcn/ui (style `radix-nova`, `cn` paketi), `@/` → `src/`. Renkler: `bg-needs`/`text-needs-fg`/`bg-needs-soft` (wants, culture, unexpected aynı), `text-income`/`text-expense`/`text-warning`. SVG/JS'te (Recharts vb.) ham değişken kullan: `var(--needs)`, `var(--primary)` — `@theme inline` yüzünden `--color-*` derlenmiş CSS'te olmayabilir. Tutarlarda `money` sınıfı. Butonlar ≥44 px (shadcn boyutları büyütüldü).
- API: `lib/api.ts` (`api.get/post/...`, hata → `ApiError` Türkçe `message`), `lib/query.ts` (QueryClient + `queryKeys`), `lib/auth.ts` (`useMe` → undefined/null/MeResponse, `useLogin`/`useRegister`/`useLogout`).
- Tipler: `frontend/src/types/` **elle düzenlenmez**; backend'de DTO'ya `#[derive(TS)] #[ts(export)]` ekle → `npm run types`. İstek yapılarında `optional_fields`.
- Sayfalar: `src/router.tsx`; adresler `components/layout/nav-items.ts` (`paths`); sayfa bileşenleri `features/<alan>/page.tsx`; iskelet `components/layout/app-layout.tsx`. Sayfa başlığı `PageHeader` (sekme başlığını da ayarlar). Metinler `lib/strings.ts` (`t`).
- 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.
- Geliştirmede tasarım önizlemesi: `/dev/tasarim`.
- Formlar: react-hook-form + zod (`mode: 'onTouched'`), alanlar `components/form-field.tsx` (`FormField`, `PasswordInput`, `FormError`); sunucu hatası `setError` ile ilgili alana. Bildirim: `toast` (sonner, `components/toaster.tsx`).
- Dönem verisi: `lib/periods.ts` (Query kancaları), tarih `lib/period-dates.ts` (arayüz son günü dahil gösterir, backend `end_date` hariç), tutar alanı `MoneyInput` + `moneyText`/`toKurus` (`lib/money-schema.ts`). İlk dönem sihirbazı `/plan/donem-ac?adim=`. Plan düzenleme: taslak → `diffPlan` → `useSavePlan`; haftalık önizleme `lib/budget-preview.ts` (backend hesabıyla aynı tutulmalı).
- Defter: `/defter` 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`.
- 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.
- 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(..)`.
- 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ç.
- 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 (docs/KAKEIBO_PANEL_ISTEKLERI.md A): devreden borç = `periods.opening_debt` (elle) yoksa dönem başından önceki son ölçüm (`debt::carried_for`); > 0 → borçlu ay: `budget::debt_plan` (harcanabilir = gelir × `spendable_rate_bp` [vars. 4500], borca giden = min(kalan, borç), artan → birikim; birikim hedefi ve Kakeibo devirleri yok sayılır, kapanış devir/birikim üretmez). Frontend aynası `lib/debt-plan.ts` (`debtPlan`, `previewSummary`), alan `features/planning/carried-debt-field.tsx` (plan düzenleme + sihirbaz 4. adım), `SummaryTiles` `summary.debt_mode`. Kakeibo devri "Önceki ay aşımı".
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
## Ortam
+4
View File
@@ -6,6 +6,8 @@ FROM node:26-alpine AS web
WORKDIR /app/frontend
# Önce yalnızca paket dosyaları → bağımlılık katmanı önbellekte kalır
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
COPY frontend/ ./
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 \
&& cargo build --release --locked \
&& 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
# 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
+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`.
- 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ı):
1. Özet ve planlanan/gerçekleşen karşılaştırması gösterilir.
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"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -257,7 +263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e"
dependencies = [
"ahash",
"base64",
"base64 0.22.1",
"bitvec",
"getrandom 0.2.17",
"getrandom 0.3.4",
@@ -301,6 +307,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
@@ -928,9 +940,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -1130,6 +1144,23 @@ dependencies = [
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -1138,13 +1169,22 @@ version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"base64 0.23.1",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"httparse",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
@@ -1361,7 +1401,7 @@ version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
dependencies = [
"base64",
"base64 0.22.1",
"ed25519-dalek",
"getrandom 0.2.17",
"hmac 0.12.1",
@@ -1385,16 +1425,21 @@ dependencies = [
"axum",
"axum-extra",
"dotenvy",
"flate2",
"http-body-util",
"jsonwebtoken",
"minijinja",
"mongodb",
"quick-xml",
"reqwest",
"serde",
"serde_json",
"sha2 0.10.9",
"thiserror",
"time",
"tokio",
"tower",
"tower-http",
"tower-http 0.7.1",
"tracing",
"tracing-subscriber",
"ts-rs",
@@ -1444,6 +1489,12 @@ version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "lru-slab"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "macro_magic"
version = "0.5.1"
@@ -1539,6 +1590,15 @@ dependencies = [
"unicase",
]
[[package]]
name = "minijinja"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "509eeb2603a84eccabb4d75f6ec8ba23b46bbf441aaeb863d6b83a5887fd51e4"
dependencies = [
"serde",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -1601,7 +1661,7 @@ version = "3.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6466d4601ff396952e1895d88083d1ab03930b812277180eb8258bbe1a10bb0f"
dependencies = [
"base64",
"base64 0.22.1",
"bitflags",
"bson",
"derive-where",
@@ -1928,6 +1988,72 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "quinn"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.3",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -2031,6 +2157,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2069,6 +2204,44 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "resolv-conf"
version = "0.7.6"
@@ -2119,6 +2292,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -2159,6 +2338,7 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "102f35f7de7b06d8c61f1cff6592f5253e0dea431c74867aad5dfd70f44663fb"
dependencies = [
"web-time",
"zeroize",
]
@@ -2504,6 +2684,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@@ -2717,6 +2900,24 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
"url",
]
[[package]]
name = "tower-http"
version = "0.7.1"
@@ -2818,6 +3019,12 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ts-rs"
version = "12.0.1"
@@ -2994,6 +3201,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -3021,6 +3237,17 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.130"
@@ -3053,6 +3280,26 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.12"
+5
View File
@@ -8,10 +8,15 @@ argon2 = "0.6.0"
axum = "0.8.9"
axum-extra = { version = "0.12.6", features = ["cookie"] }
dotenvy = "0.15.7"
flate2 = "1"
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
minijinja = { version = "3.1.0", features = ["serde"] }
mongodb = "3.10.0"
quick-xml = { version = "0.42.0", features = ["serialize"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
sha2 = "0.10"
thiserror = "2.0.21"
time = "0.3.55"
tokio = { version = "1.54.1", features = ["full"] }
+299
View File
@@ -170,3 +170,302 @@ Content-Type: application/json
"savings_goal": 750000,
"version": 1
}
# ─────────────────────────── Plan kalemleri (sabit gelir / gider) ───────────────────────────
# kind: "income" | "expense" · amount kuruş (> 0) · date isteğe bağlı, dönem içinde
### Dönemin kalemleri (önce gelirler)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items
### Kalem ekle
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items
Content-Type: application/json
{
"kind": "expense",
"name": "Kira",
"amount": 1500000,
"date": "2026-12-05"
}
### Önceki dönemden kopyala (aynı tür + ad varsa atlanır; tarihler göreli günle taşınır)
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items/copy-previous
### Kalemi düzenle ("date": "" → tarihi kaldır)
PATCH {{baseUrl}}/plan-items/BURAYA-KALEM-ID
Content-Type: application/json
{
"amount": 1600000,
"version": 1
}
### Kalemi sil
DELETE {{baseUrl}}/plan-items/BURAYA-KALEM-ID
# ─────────────────────────── Notlar ───────────────────────────
# context: "planning" (Plan sayfası) | "review" (Ay sonu değerlendirmesi) · body_md: Markdown
### Notlar (?context=planning ile filtre)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/notes?context=planning
### Not ekle
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/notes
Content-Type: application/json
{
"context": "planning",
"title": "Tatil birikimi",
"body_md": "- Ocak'ta **5.000 ₺** ayır\n- Dışarıda yemeği azalt"
}
### Notu düzenle
PATCH {{baseUrl}}/notes/BURAYA-NOT-ID
Content-Type: application/json
{
"body_md": "Güncel metin",
"version": 1
}
### Notu sil
DELETE {{baseUrl}}/notes/BURAYA-NOT-ID
# ─────────────────────────── Bütçe ve özet ───────────────────────────
# Gruplar: needs · wants · culture · unexpected · tutarlar kuruş
### Dönem özeti: gelir − sabit gider − birikim − devreden borç + devreden artı = harcanabilir
### + spent_total (gerçekleşen harcama), remaining (harcanabilir − harcanan)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/summary
### Bütçe: 4 grup, aylık + haftalık döküm (haftalar gün sayısına göre); her grup ve haftada `spent`
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/budget
### Aylık bütçeleri kaydet (4 grup birlikte)
PUT {{baseUrl}}/periods/BURAYA-DONEM-ID/budget
Content-Type: application/json
{
"needs": 1500000,
"wants": 600000,
"culture": 200000,
"unexpected": 200000
}
### Bir haftayı elle ayarla (week_start = haftanın ilk günü); "amount": null → otomatiğe döner
PUT {{baseUrl}}/periods/BURAYA-DONEM-ID/budget/weeks
Content-Type: application/json
{
"group": "needs",
"week_start": "2026-12-07",
"amount": 400000
}
### Bütçe önerisi — method: default (varsayılan yüzdeler) | previous (önceki dönem) | avg3 (son 3 dönem)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/budget/suggestion?method=default
# ─────────────────────── Günlük defter (harcamalar) ───────────────────────
# Kategori / ödeme türü kimlikleri: GET /categories, GET /payment-methods · tutarlar kuruş
### Dönemin harcamaları (en yeni başta)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/entries
### Bir haftanın harcamaları (from/to dahil)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/entries?from=2026-12-07&to=2026-12-13
### Harcama ekle — amount kuruş (345,50 ₺ = 34550); grup kategoriden gelir; ödeme türü ve açıklama isteğe bağlı
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/entries
Content-Type: application/json
{
"date": "2026-12-09",
"amount": 34550,
"category_id": "BURAYA-KATEGORI-ID",
"payment_method_id": "BURAYA-ODEME-TURU-ID",
"description": "Haftalık market"
}
### Harcamayı düzenle — "payment_method_id": "" / "description": "" → kaldır
PATCH {{baseUrl}}/entries/BURAYA-KAYIT-ID
Content-Type: application/json
{
"amount": 36000,
"version": 1
}
### Harcamayı sil
DELETE {{baseUrl}}/entries/BURAYA-KAYIT-ID
### Silmeyi geri al ("↩️ Geri al")
POST {{baseUrl}}/entries/BURAYA-KAYIT-ID/restore
# ─────────────────────────── Ay sonu değerlendirmesi ───────────────────────────
### Değerlendirme: 4 soru (available, savings_goal, spent_total, realized_savings) + grup/kategori/hafta
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/review
### "Nasıl daha iyi yapabilirim?" (Markdown; boş metin → kaldır; yalnızca açık dönem)
PATCH {{baseUrl}}/periods/BURAYA-DONEM-ID
Content-Type: application/json
{
"reflection_md": "- Dışarıda yemeği haftada 1'e indir\n- Market listesiyle alışveriş"
}
### Dönemi kapat: to_savings verilmezse gerçekleşen birikimin tamamı birikime; kalanı artı, açık varsa borç devreder
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/close
Content-Type: application/json
{
"to_savings": 500000
}
### Kapalı dönemi düzeltmeye aç (yalnızca hane sahibi) → status: correcting; düzeltince yeniden "close"
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/reopen
# ─────────────────────────── Panel ───────────────────────────
### Günlük harcama: dönemin her günü, 4 grup (harcama yoksa 0)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/daily
### Son dönemler (eskiden yeniye): harcanabilir, harcanan, birikime aktarılan + toplam birikim
GET {{baseUrl}}/stats/periods?limit=12
# ─────────────────────────── Birikimler ───────────────────────────
# Miktar/fiyat × 1e6 (5 gr → 5000000), tutar kuruş. TL varlığında yalnızca total yeter.
### Varlıklar + eldeki miktar, maliyet, ortalama maliyet, gerçekleşen kâr/zarar
GET {{baseUrl}}/assets
### Döviz ekle
POST {{baseUrl}}/assets
Content-Type: application/json
{ "code": "GBP", "name": "İngiliz Sterlini", "unit_label": "£" }
### Alım (birim fiyat verilmezse tutar ÷ miktar)
POST {{baseUrl}}/assets/BURAYA-VARLIK-ID/transactions
Content-Type: application/json
{ "type": "buy", "date": "2026-11-13", "quantity_e6": 5000000, "total": 3130000, "note": "Kuyumcu" }
### İşlem geçmişi (en yeni başta)
GET {{baseUrl}}/assets/BURAYA-VARLIK-ID/transactions
### İşlemi düzenle / sil
PATCH {{baseUrl}}/asset-transactions/BURAYA-ISLEM-ID
Content-Type: application/json
{ "total": 3150000, "version": 1 }
### Portföy: varlıklar + güncel kur, değer, kâr/zarar + toplamlar + son kur çekimi
GET {{baseUrl}}/portfolio
### Elle kur (TL × 1e6; tarih boşsa bugün). Aynı gün otomatik kur varsa elle girilen geçerli.
POST {{baseUrl}}/rates
Content-Type: application/json
{ "code": "XAU_CEYREK", "rate_e6": 10250000000 }
### Elle kuru sil → otomatik kura dön
DELETE {{baseUrl}}/rates/BURAYA-KUR-ID
### Kapanıp birikime henüz eklenmemiş dönemler
GET {{baseUrl}}/savings/pending
### Dönemin kapanıştaki birikimini TL varlığına ekle (dönem başına bir kez)
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/to-savings
# ─────────────────────────── Raporlar ───────────────────────────
### Harcama kırılımı: grup, tüm kategoriler, ödeme türü, ay (filtreler isteğe bağlı)
GET {{baseUrl}}/reports/breakdown?from=2026-01-01&to=2026-12-31
### Bir dönemde, yalnızca Zorunlu, ödeme türü girilmemiş olanlar
GET {{baseUrl}}/reports/breakdown?period_id=BURAYA-DONEM-ID&group=needs&payment_method_id=none
# ─────────────────────────── 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 limiter;
pub mod password;
+12 -2
View File
@@ -3,7 +3,8 @@
//! 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,
//! 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.
//! **Hane izolasyonu:** route'lar hane kimliğini her zaman `AuthUser.household_id`'den alır,
@@ -67,6 +68,9 @@ impl AuthUser {
#[derive(Deserialize)]
struct RoleOnly {
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 {
@@ -85,9 +89,15 @@ impl FromRequestParts<AppState> for AuthUser {
.db
.collection::<RoleOnly>(col::USERS)
.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?
.ok_or(AppError::Unauthorized)?;
if current
.sessions_valid_from
.is_some_and(|from| (claims.iat as i64) < from)
{
return Err(AppError::Unauthorized);
}
Ok(Self {
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
/// (canlıda tek servis). Geliştirmede boş: arayüzü Vite sunar.
pub static_dir: Option<PathBuf>,
/// Otomatik kur yenileme aralığı (dakika, `RATES_REFRESH_MINUTES`, varsayılan 60); 0 → kapalı
pub rates_refresh_minutes: u64,
/// 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).
@@ -69,6 +76,38 @@ impl Config {
Err(_) => 8080,
};
let rates_refresh_minutes = match env::var("RATES_REFRESH_MINUTES") {
Ok(m) if !m.trim().is_empty() => m.trim().parse().map_err(|_| {
ConfigError::Invalid("RATES_REFRESH_MINUTES", "dakika (sayı) olmalı")
})?,
_ => 60,
};
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 {
mongodb_uri,
jwt_secret,
@@ -76,6 +115,10 @@ impl Config {
port,
registration,
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![
idx(doc! { "household_id": 1, "period_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,
vec![idx(doc! { "household_id": 1, "to_period_id": 1 })],
),
(
col::ASSETS,
vec![idx(doc! { "household_id": 1, "sort_order": 1 })],
),
(
col::ASSET_TRANSACTIONS,
vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })],
),
(
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,
vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })],
+82 -2
View File
@@ -24,11 +24,33 @@ struct Migration {
}
/// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar.
const MIGRATIONS: &[Migration] = &[Migration {
const MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial",
run: |db| Box::pin(m001_initial(db)),
}];
},
Migration {
version: 2,
name: "default_assets",
run: |db| Box::pin(m002_default_assets(db)),
},
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.
pub async fn run(db: &Database) -> Result<()> {
@@ -66,6 +88,64 @@ async fn m001_initial(db: &Database) -> Result<()> {
Ok(())
}
/// v2: Faz 8'den önce açılmış hanelere varsayılan varlıklar (TL, altınlar, USD, EUR).
/// Varlığı olan haneye dokunmaz → tekrar çalışsa da çift kayıt olmaz.
async fn m002_default_assets(db: &Database) -> Result<()> {
#[derive(serde::Deserialize)]
struct Household {
#[serde(rename = "_id")]
id: mongodb::bson::Uuid,
}
let assets = db.collection::<Document>(col::ASSETS);
let mut cursor = db
.collection::<Household>(col::HOUSEHOLDS)
.find(doc! { "deleted_at": null })
.projection(doc! { "_id": 1 })
.await?;
while cursor.advance().await? {
let hid = cursor.deserialize_current()?.id;
if assets
.find_one(doc! { "household_id": hid })
.await?
.is_none()
{
assets
.insert_many(crate::services::seed::assets(hid))
.await?;
tracing::info!("Varsayılan varlıklar eklendi: hane {hid}");
}
}
Ok(())
}
/// 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)]
mod tests {
use super::*;
+14
View File
@@ -70,7 +70,17 @@ pub mod col {
pub const ASSETS: &str = "assets";
pub const ASSET_TRANSACTIONS: &str = "asset_transactions";
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";
/// 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).
pub const HOUSEHOLD_SCOPED: &[&str] = &[
@@ -87,5 +97,9 @@ pub mod col {
NOTES,
ASSETS,
ASSET_TRANSACTIONS,
IMPORT_RULES,
DEBT_SNAPSHOTS,
INSTALLMENT_PLANS,
DEBT_TARGETS,
];
}
+24 -2
View File
@@ -35,6 +35,15 @@ pub enum AppError {
Unauthorized,
#[error("E-posta veya şifre hatalı")]
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")]
Forbidden,
#[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::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
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::RegistrationClosed => (StatusCode::FORBIDDEN, "registration_closed"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
@@ -70,6 +83,10 @@ impl AppError {
impl IntoResponse for AppError {
fn into_response(self) -> Response {
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() {
// Teknik detay sadece logda; kullanıcıya genel mesaj.
@@ -85,7 +102,12 @@ impl IntoResponse for AppError {
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
}
}
+25 -2
View File
@@ -1,6 +1,6 @@
use std::{net::SocketAddr, process::ExitCode};
use kakeibo::{config::Config, db, routes, state::AppState};
use kakeibo::{config::Config, db, routes, services, state::AppState};
use tracing_subscriber::EnvFilter;
#[tokio::main]
@@ -41,6 +41,25 @@ async fn main() -> ExitCode {
return ExitCode::FAILURE;
}
// Kurlar arka planda (açılışta hemen, sonra aralıkla); hata uygulamayı durdurmaz
services::rates::spawn_refresher(db.clone(), config.rates_refresh_minutes);
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 state = AppState::new(db, config);
let app = routes::app(state);
@@ -57,7 +76,11 @@ async fn main() -> ExitCode {
};
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)
if let Err(e) = axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await
{
+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ı
pub fn order(self) -> usize {
Self::ALL.iter().position(|g| *g == self).unwrap_or(0)
+5
View File
@@ -1,9 +1,14 @@
//! Veritabanı modelleri. Her koleksiyonun yapısı, onu kullanan adımda eklenir.
//! Şema: `docs/DATA_MODEL.md`
pub mod asset;
pub mod common;
pub mod entry;
pub mod group;
pub mod household;
pub mod invite;
pub mod note;
pub mod period;
pub mod plan_item;
pub mod rate;
pub mod user;
+38
View File
@@ -0,0 +1,38 @@
//! `notes` koleksiyonu (DATA_MODEL.md §3.11). Notlar döneme özeldir.
use mongodb::bson::{DateTime, Uuid};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
/// Notun bağlı olduğu sayfa
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, rename = "NoteContext")]
pub enum NoteContext {
/// Aylık planlama
Planning,
/// Ay sonu değerlendirmesi
Review,
}
impl NoteContext {
pub fn as_str(self) -> &'static str {
match self {
NoteContext::Planning => "planning",
NoteContext::Review => "review",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Note {
#[serde(rename = "_id")]
pub id: Uuid,
pub period_id: Uuid,
pub context: NoteContext,
pub title: String,
pub body_md: String,
pub created_by: Uuid,
pub updated_at: DateTime,
pub version: i32,
}
+31 -2
View File
@@ -1,6 +1,6 @@
//! `periods` koleksiyonu (DATA_MODEL.md §3.4).
use mongodb::bson::Uuid;
use mongodb::bson::{DateTime, Uuid};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -10,8 +10,16 @@ use ts_rs::TS;
pub enum PeriodStatus {
/// Girişler yapılabilir. Hanede en fazla bir tane.
Open,
/// Salt okunur (Faz 6: kapanış sihirbazı, sahip yeniden açabilir).
/// Salt okunur. Sahip yeniden açarsa `Correcting` olur.
Closed,
/// Düzeltmede: kapanmış dönem sahip tarafından düzeltme için açıldı.
/// Yazılabilir, ama "açık dönem" sayılmaz (yeni dönem açıkken de olabilir).
/// "Yeniden kapat" ile devir yeniden hesaplanır.
Correcting,
/// 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 {
@@ -19,8 +27,15 @@ impl PeriodStatus {
match self {
PeriodStatus::Open => "open",
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)]
@@ -35,5 +50,19 @@ pub struct Period {
pub status: PeriodStatus,
/// Kuruş
pub savings_goal: i64,
/// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası)
#[serde(default)]
pub reflection_md: Option<String>,
/// Kapanışta birikime aktarılan tutar (kuruş). Faz 8'de varlık hareketine dönüşecek.
#[serde(default)]
pub to_savings: Option<i64>,
#[serde(default)]
pub closed_at: Option<DateTime>,
/// Elle girilen ay başı toplam banka borcu (kuruş); yoksa son ölçümden (borç takibi)
#[serde(default)]
pub opening_debt: Option<i64>,
/// Borçlu ayda harcanabilir oranı (baz puan, 4500 = %45); boşsa varsayılan
#[serde(default)]
pub spendable_rate_bp: Option<i32>,
pub version: i32,
}
+38
View File
@@ -0,0 +1,38 @@
//! `plan_items` koleksiyonu — sabit gelir / gider kalemleri (DATA_MODEL.md §3.7).
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, rename = "PlanItemKind")]
pub enum PlanKind {
Income,
Expense,
}
impl PlanKind {
pub fn as_str(self) -> &'static str {
match self {
PlanKind::Income => "income",
PlanKind::Expense => "expense",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct PlanItem {
#[serde(rename = "_id")]
pub id: Uuid,
pub period_id: Uuid,
pub kind: PlanKind,
pub name: String,
/// Kuruş
pub amount: i64,
/// `"YYYY-MM-DD"` (isteğe bağlı)
#[serde(default)]
pub date: Option<String>,
pub sort_order: i32,
pub version: i32,
}
+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ı:
//! `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_extra::extract::cookie::CookieJar;
@@ -10,8 +16,12 @@ use validator::Validate;
use crate::{
auth::{
AuthUser, password,
session::{removal_cookie, session_cookie, ttl},
AuthUser,
captcha::CaptchaError,
client_ip::ClientIp,
guard::Outcome,
password,
session::{COOKIE_NAME, removal_cookie, session_cookie, ttl},
},
db::col,
error::{AppError, AppResult},
@@ -41,6 +51,8 @@ pub struct RegisterRequest {
/// Verilirse yeni hane kurulmaz, davet eden haneye `member` olarak katılınır.
#[validate(length(max = 20, message = "Davet kodu geçersiz"))]
invite_code: Option<String>,
/// Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
captcha_token: Option<String>,
}
#[derive(Deserialize, Validate, TS)]
@@ -52,6 +64,10 @@ pub struct LoginRequest {
password: String,
#[serde(default)]
remember_me: bool,
/// Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
#[serde(default)]
#[ts(optional)]
captcha_token: Option<String>,
}
#[derive(Serialize, TS)]
@@ -77,13 +93,83 @@ struct HouseholdDto {
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 ----------
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(
State(state): State<AppState>,
ClientIp(ip): ClientIp,
jar: CookieJar,
ValidJson(req): ValidJson<RegisterRequest>,
) -> AppResult<(StatusCode, CookieJar, Json<MeResponse>)> {
precheck(&state, &ip, req.captcha_token.as_deref()).await?;
let name = req.name.trim().to_string();
if name.is_empty() {
return Err(AppError::BadRequest("Ad boş olamaz".into()));
@@ -101,7 +187,7 @@ pub async fn register(
.filter(|c| !c.is_empty());
let password_hash = password::hash(req.password).await?;
let created = account::register(
let created = match account::register(
&state.db,
state.config.registration,
NewAccount {
@@ -112,7 +198,19 @@ pub async fn register(
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(
created.user_id,
@@ -128,16 +226,13 @@ pub async fn register(
pub async fn login(
State(state): State<AppState>,
ClientIp(ip): ClientIp,
jar: CookieJar,
ValidJson(req): ValidJson<LoginRequest>,
) -> AppResult<(CookieJar, Json<MeResponse>)> {
precheck(&state, &ip, req.captcha_token.as_deref()).await?;
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
.db
.collection::<User>(col::USERS)
@@ -146,12 +241,12 @@ pub async fn login(
let hash = user.as_ref().map(|u| u.password_hash.clone());
if !password::verify(req.password, hash).await {
state.login_limiter.record_failure(&email);
return Err(AppError::InvalidCredentials);
let left = count_failure(&state, &ip, "giriş")?;
return Err(AppError::InvalidCredentialsLeft(left));
}
// verify() true döndüyse kullanıcı kesin vardır
let user = user.ok_or(AppError::InvalidCredentials)?;
state.login_limiter.reset(&email);
state.guard.success(&ip);
state
.db
@@ -200,6 +295,59 @@ pub async fn me(State(state): State<AppState>, auth: AuthUser) -> AppResult<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 ----------
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(),
}))
}
+299
View File
@@ -0,0 +1,299 @@
//! Bütçe ve özet uç noktaları:
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods/{id}/summary` | gelir/gider/birikim/devir → harcanabilir, harcanan, kalan |
//! | GET | `/api/periods/{id}/budget` | 4 grup: aylık + haftalık döküm, her birinde harcanan |
//! | PUT | `/api/periods/{id}/budget` | 4 grubun aylık tutarları |
//! | PUT | `/api/periods/{id}/budget/weeks` | bir haftayı elle ayarla / otomatiğe döndür |
//! | GET | `/api/periods/{id}/budget/suggestion?method=…` | `default` · `previous` · `avg3` |
use axum::{
Json,
extract::{Path, Query, State},
};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
extract::ValidJson,
models::group::Group,
services::budget::{self, GroupBudget, SuggestMethod},
state::AppState,
};
/// Gruplara göre tutarlar (kuruş)
#[derive(Serialize, Deserialize, Validate, TS, Clone, Copy)]
#[ts(export)]
pub struct GroupAmounts {
#[ts(type = "number")]
needs: i64,
#[ts(type = "number")]
wants: i64,
#[ts(type = "number")]
culture: i64,
#[ts(type = "number")]
unexpected: i64,
}
impl From<budget::GroupAmounts> for GroupAmounts {
fn from(a: budget::GroupAmounts) -> Self {
Self {
needs: a[0],
wants: a[1],
culture: a[2],
unexpected: a[3],
}
}
}
impl From<GroupAmounts> for budget::GroupAmounts {
fn from(a: GroupAmounts) -> Self {
[a.needs, a.wants, a.culture, a.unexpected]
}
}
#[derive(Serialize, TS)]
#[ts(export, rename = "PeriodSummary")]
pub struct SummaryDto {
#[ts(type = "number")]
income_total: i64,
#[ts(type = "number")]
fixed_expense_total: i64,
#[ts(type = "number")]
savings_goal: i64,
/// Önceki dönemden devreden borç
#[ts(type = "number")]
carryover_debt: i64,
/// Önceki dönemden devreden artı bakiye
#[ts(type = "number")]
carryover_surplus: i64,
/// Normal ay: gelir − sabit gider − birikim − borç + artı · Borçlu ay: gelir × oran
#[ts(type = "number")]
spendable: i64,
/// Devreden (banka) borcu; yoksa 0. > 0 → borçlu ay (birikim hedefi plana girmez)
#[ts(type = "number")]
carried_debt: i64,
/// `auto` (ay sonu ölçümü) | `manual` (planda elle) | `null` (hiç girilmedi)
#[ts(type = "\"auto\" | \"manual\" | null")]
carried_debt_source: Option<String>,
/// Son ay sonu ölçümünün tarihi (elle girilmiş olsa da); ölçüm yoksa `null`
carried_debt_date: Option<String>,
/// Son ay sonu ölçümünden gelen tutar (elle düzeltme kaldırılınca kullanılacak); yoksa `null`
#[ts(type = "number | null")]
carried_debt_auto: Option<i64>,
debt_mode: bool,
/// Etkin harcanabilir oranı (baz puan)
spendable_rate_bp: i32,
/// Bu ay borca giden
#[ts(type = "number")]
debt_payment: i64,
/// Borca giden devreden borcu aşarsa birikime kalan
#[ts(type = "number")]
debt_to_savings: i64,
/// Sabit giderler çok yüksek: bu ay borç azalmıyor
debt_fixed_too_high: bool,
/// Ay sonu hedef borç (borçlu ayda)
#[ts(type = "number | null")]
closing_debt_target: Option<i64>,
/// 4 grubun aylık bütçe toplamı
#[ts(type = "number")]
budget_total: i64,
/// harcanabilir − dağıtılan (negatifse fazla dağıtılmış)
#[ts(type = "number")]
unallocated: i64,
/// Dönemde gerçekleşen harcama
#[ts(type = "number")]
spent_total: i64,
/// Kalan harcanabilir: harcanabilir − gerçekleşen (negatifse aşılmış)
#[ts(type = "number")]
remaining: i64,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "GroupBudget")]
pub struct GroupBudgetDto {
group: Group,
#[ts(type = "number")]
monthly: i64,
/// Dönem boyunca gerçekleşen harcama
#[ts(type = "number")]
spent: i64,
/// Elle girilen haftaların toplamı aylık tutarı aşıyor
weekly_over: bool,
weeks: Vec<WeekBudgetDto>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "WeekBudget")]
struct WeekBudgetDto {
index: u32,
start_date: String,
last_date: String,
days: u32,
#[ts(type = "number")]
amount: i64,
/// Elle girildi (otomatik değil)
is_manual: bool,
/// Bu haftada gerçekleşen harcama
#[ts(type = "number")]
spent: i64,
}
impl From<GroupBudget> for GroupBudgetDto {
fn from(g: GroupBudget) -> Self {
Self {
group: g.group,
monthly: g.monthly,
spent: g.spent,
weekly_over: g.weekly_over,
weeks: g
.weeks
.into_iter()
.map(|w| WeekBudgetDto {
index: w.index,
start_date: w.start_date,
last_date: w.last_date,
days: w.days,
amount: w.amount,
is_manual: w.is_manual,
spent: w.spent,
})
.collect(),
}
}
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "BudgetWeekRequest")]
pub struct WeekRequest {
group: Group,
/// Haftanın ilk günü (`PeriodWeek.start_date`)
week_start: String,
/// Kuruş; `null` → otomatiğe döndür
#[ts(type = "number | null")]
amount: Option<i64>,
}
#[derive(Deserialize, Clone, Copy, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, rename = "SuggestMethod")]
pub enum Method {
Default,
Previous,
Avg3,
}
#[derive(Deserialize)]
pub struct SuggestQuery {
method: Option<Method>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "BudgetSuggestion")]
pub struct SuggestionDto {
/// `false` → bu yöntem için veri yok (`message` nedenini söyler)
available: bool,
message: Option<String>,
amounts: GroupAmounts,
}
fn parse_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
pub async fn summary(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<SummaryDto>> {
let x = budget::summary(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(Json(SummaryDto {
income_total: x.income_total,
fixed_expense_total: x.fixed_expense_total,
savings_goal: x.savings_goal,
carryover_debt: x.carryover_debt,
carryover_surplus: x.carryover_surplus,
spendable: x.spendable,
carried_debt: x.debt.map_or(0, |d| d.carried),
carried_debt_source: x.carried_source.map(str::to_string),
carried_debt_date: x.carried_auto.as_ref().map(|a| a.1.clone()),
carried_debt_auto: x.carried_auto.as_ref().map(|a| a.0),
debt_mode: x.debt.is_some(),
spendable_rate_bp: x
.debt
.map_or(budget::DEFAULT_SPENDABLE_RATE_BP, |d| d.rate_bp),
debt_payment: x.debt.map_or(0, |d| d.payment),
debt_to_savings: x.debt.map_or(0, |d| d.to_savings),
debt_fixed_too_high: x.debt.is_some_and(|d| d.fixed_too_high),
closing_debt_target: x.debt.map(|d| d.closing_target),
budget_total: x.budget_total,
unallocated: x.spendable - x.budget_total,
spent_total: x.spent_total,
remaining: x.spendable - x.spent_total,
}))
}
pub async fn get(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
let groups = budget::view(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect()))
}
pub async fn set_monthly(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<GroupAmounts>,
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
let pid = parse_id(&id)?;
budget::set_monthly(&s.db, auth.household_id, pid, req.into()).await?;
let groups = budget::view(&s.db, auth.household_id, pid).await?;
Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect()))
}
pub async fn set_week(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<WeekRequest>,
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
let pid = parse_id(&id)?;
budget::set_week(
&s.db,
auth.household_id,
pid,
req.group,
&req.week_start,
req.amount,
)
.await?;
let groups = budget::view(&s.db, auth.household_id, pid).await?;
Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect()))
}
pub async fn suggestion(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
Query(q): Query<SuggestQuery>,
) -> AppResult<Json<SuggestionDto>> {
let method = match q.method.unwrap_or(Method::Default) {
Method::Default => SuggestMethod::Default,
Method::Previous => SuggestMethod::Previous,
Method::Avg3 => SuggestMethod::Avg3,
};
let x = budget::suggest(&s.db, auth.household_id, parse_id(&id)?, method).await?;
Ok(Json(SuggestionDto {
available: x.available,
message: x.message,
amounts: x.amounts.into(),
}))
}
+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)
//! - `POST /api/household/invites` → yeni davet kodu (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::{
Json,
@@ -12,13 +14,15 @@ use axum::{
use mongodb::bson::{DateTime, Uuid, doc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
auth::AuthUser,
db::col,
error::{AppError, AppResult},
extract::ValidJson,
models::{common::scope, household::Household, invite::Invite, user::Role},
services::invite,
services::{account, invite},
state::AppState,
};
@@ -28,6 +32,41 @@ pub struct HouseholdResponse {
id: String,
name: String,
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)]
@@ -78,9 +117,16 @@ pub async fn get(
let household = state
.db
.collection::<Household>(col::HOUSEHOLDS)
.find_one(hh_filter)
.find_one(hh_filter.clone())
.await?
.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
.db
@@ -105,9 +151,66 @@ pub async fn get(
id: hid.to_string(),
name: household.name,
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(
State(state): State<AppState>,
auth: AuthUser,
+116 -2
View File
@@ -1,14 +1,29 @@
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
mod asset;
mod auth;
mod backup;
mod bank_import;
mod budget;
mod catalog;
mod debt;
mod entry;
mod health;
mod household;
mod monthly_report;
mod note;
mod period;
mod plan;
mod rates;
mod reports;
mod review;
mod spa;
mod stats;
mod transfer;
use axum::{
Router,
extract::DefaultBodyLimit,
routing::{delete, get, patch, post, put},
};
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
@@ -33,10 +48,13 @@ pub fn router(state: AppState) -> Router {
.route("/health", get(health::health))
.route("/auth/register", post(auth::register))
.route("/auth/registration", get(auth::registration_info))
.route("/auth/guard", get(auth::guard_info))
.route("/auth/login", post(auth::login))
.route("/auth/logout", post(auth::logout))
.route("/auth/me", get(auth::me))
.route("/household", get(household::get))
.route("/auth/me", get(auth::me).patch(auth::update_me))
.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(
"/household/invites",
get(household::list_invites).post(household::create_invite),
@@ -63,8 +81,104 @@ pub fn router(state: AppState) -> Router {
.route("/periods", get(period::list).post(period::create))
.route("/periods/current", get(period::current))
.route("/periods/{id}", get(period::get).patch(period::update))
.route("/periods/{id}/close", post(period::close))
.route("/periods/{id}/reopen", post(period::reopen))
.route(
"/periods/{id}/plan-items",
get(plan::list).post(plan::create),
)
.route(
"/periods/{id}/plan-items/copy-previous",
post(plan::copy_previous),
)
.route("/plan-items/{id}", patch(plan::update).delete(plan::delete))
.route("/periods/{id}/notes", get(note::list).post(note::create))
.route("/periods/{id}/summary", get(budget::summary))
.route("/periods/{id}/review", get(review::get))
.route("/periods/{id}/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(
"/periods/{id}/budget",
get(budget::get).put(budget::set_monthly),
)
.route("/periods/{id}/budget/weeks", put(budget::set_week))
.route("/periods/{id}/budget/suggestion", get(budget::suggestion))
.route("/notes/{id}", patch(note::update).delete(note::delete))
.route(
"/periods/{id}/entries",
get(entry::list).post(entry::create),
)
.route("/entries/{id}", patch(entry::update).delete(entry::delete))
.route("/entries/{id}/restore", post(entry::restore))
.route("/assets", get(asset::list).post(asset::create))
.route("/portfolio", get(rates::portfolio))
.route("/savings/pending", get(asset::pending))
.route("/periods/{id}/to-savings", post(asset::add_period_savings))
.route("/rates", post(rates::create))
.route("/rates/{id}", delete(rates::delete))
.route("/assets/{id}", patch(asset::update))
.route(
"/assets/{id}/transactions",
get(asset::list_txs).post(asset::create_tx),
)
.route(
"/asset-transactions/{id}",
patch(asset::update_tx).delete(asset::delete_tx),
)
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
.fallback(|| async { AppError::NotFound });
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,
))
}
+137
View File
@@ -0,0 +1,137 @@
//! Dönem notları:
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods/{id}/notes?context=planning` | notlar (sayfa filtresi isteğe bağlı) |
//! | POST | `/api/periods/{id}/notes` | not ekle |
//! | PATCH | `/api/notes/{id}` | düzenle |
//! | DELETE | `/api/notes/{id}` | sil |
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
extract::ValidJson,
models::note::{Note, NoteContext},
services::note,
state::AppState,
};
#[derive(Deserialize)]
pub struct ListQuery {
context: Option<NoteContext>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "NoteCreateRequest")]
pub struct CreateRequest {
context: NoteContext,
title: String,
/// Markdown
#[serde(default)]
body_md: String,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "NoteUpdateRequest", optional_fields)]
pub struct UpdateRequest {
title: Option<String>,
body_md: Option<String>,
version: Option<i32>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "Note")]
pub struct NoteDto {
id: String,
context: NoteContext,
title: String,
body_md: String,
created_by: String,
/// ISO 8601
updated_at: String,
version: i32,
}
impl From<Note> for NoteDto {
fn from(n: Note) -> Self {
Self {
id: n.id.to_string(),
context: n.context,
title: n.title,
body_md: n.body_md,
created_by: n.created_by.to_string(),
updated_at: n.updated_at.try_to_rfc3339_string().unwrap_or_default(),
version: n.version,
}
}
}
fn parse_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
pub async fn list(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Vec<NoteDto>>> {
let notes = note::list(&s.db, auth.household_id, parse_id(&period_id)?, q.context).await?;
Ok(Json(notes.into_iter().map(NoteDto::from).collect()))
}
pub async fn create(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<NoteDto>)> {
let n = note::create(
&s.db,
auth.household_id,
auth.user_id,
parse_id(&period_id)?,
req.context,
&req.title,
&req.body_md,
)
.await?;
Ok((StatusCode::CREATED, Json(n.into())))
}
pub async fn update(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<UpdateRequest>,
) -> AppResult<Json<NoteDto>> {
let n = note::update(
&s.db,
auth.household_id,
parse_id(&id)?,
req.title.as_deref(),
req.body_md.as_deref(),
req.version,
)
.await?;
Ok(Json(n.into()))
}
pub async fn delete(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
note::delete(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(StatusCode::NO_CONTENT)
}
+101 -1
View File
@@ -6,7 +6,10 @@
//! | GET | `/api/periods/current` | açık dönem ya da `null` |
//! | POST | `/api/periods` | dönem aç |
//! | GET | `/api/periods/{id}` | dönem + haftalar |
//! | PATCH | `/api/periods/{id}` | ad / tarihler / birikim hedefi |
//! | PATCH | `/api/periods/{id}` | ad / tarihler / birikim hedefi / "Nasıl daha iyi…" |
//! | POST | `/api/periods/{id}/close` | dönemi kapat: devir + birikime aktarılan (transaction) |
//! | POST | `/api/periods/{id}/reopen` | **sahip:** kapalı dönemi düzeltmeye aç (`correcting`) |
//! | POST | `/api/periods/{id}/to-savings` | kapanıştaki birikimi TL varlığına ekle (bkz. `routes/asset.rs`) |
use axum::{
Json,
@@ -25,6 +28,7 @@ use crate::{
models::period::{Period, PeriodStatus},
services::{
calendar::{days_between, format_date, parse_date, weeks},
closing,
period::{self, NewPeriod, PeriodChanges},
},
state::AppState,
@@ -54,6 +58,10 @@ pub struct UpdateRequest {
end_date: Option<String>,
#[ts(type = "number", optional)]
savings_goal: Option<i64>,
/// Borçlu ayda harcanabilir oranı (baz puan; 4500 = %45)
spendable_rate_bp: Option<i32>,
/// "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır
reflection_md: Option<String>,
version: Option<i32>,
}
@@ -73,6 +81,15 @@ pub struct PeriodDto {
/// Kuruş
#[ts(type = "number")]
savings_goal: i64,
/// Borçlu ayda harcanabilir oranı (baz puan); `null` → varsayılan %45
spendable_rate_bp: Option<i32>,
/// "Nasıl daha iyi yapabilirim?" (Markdown)
reflection_md: Option<String>,
/// Kapanışta birikime aktarılan (kuruş); kapanmamışsa `null`
#[ts(type = "number | null")]
to_savings: Option<i64>,
/// Kapanış zamanı (ISO 8601)
closed_at: Option<String>,
version: i32,
weeks: Vec<WeekDto>,
}
@@ -102,6 +119,10 @@ impl From<Period> for PeriodDto {
end_date: p.end_date,
status: p.status,
savings_goal: p.savings_goal,
spendable_rate_bp: p.spendable_rate_bp,
reflection_md: p.reflection_md,
to_savings: p.to_savings,
closed_at: p.closed_at.and_then(|d| d.try_to_rfc3339_string().ok()),
version: p.version,
weeks: weeks(start, end)
.into_iter()
@@ -178,9 +199,88 @@ pub async fn update(
start_date: req.start_date,
end_date: req.end_date,
savings_goal: req.savings_goal,
spendable_rate_bp: req.spendable_rate_bp,
reflection_md: req.reflection_md,
version: req.version,
},
)
.await?;
Ok(Json(p.into()))
}
// ---------- Kapatma / yeniden açma ----------
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "PeriodCloseRequest", optional_fields)]
pub struct CloseRequest {
/// Birikime aktarılacak (kuruş); verilmezse gerçekleşen birikimin tamamı. Kalanı artı olarak devreder.
#[ts(type = "number", optional)]
to_savings: Option<i64>,
version: Option<i32>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "PeriodCloseResult")]
pub struct CloseResultDto {
period: PeriodDto,
/// Değerlendirmenin 4. sorusu (negatifse açık)
#[ts(type = "number")]
realized_savings: i64,
#[ts(type = "number")]
to_savings: i64,
/// Sonraki döneme devreden; yoksa `null`
carryover: Option<CarryoverDto>,
/// Birikimlerde bu döneme bağlı kayıt var (yeniden kapatmada yeni tutara güncellendi).
/// `false` ve `to_savings > 0` → arayüz "Birikime ekle" önerir.
savings_linked: bool,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "Carryover")]
struct CarryoverDto {
/// `debt` (borç) · `surplus` (artı)
#[ts(type = "\"debt\" | \"surplus\"")]
kind: &'static str,
#[ts(type = "number")]
amount: i64,
/// Sonraki dönem henüz açılmadıysa `null` (açılınca bağlanır)
to_period_id: Option<String>,
}
pub async fn close(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<CloseRequest>,
) -> AppResult<Json<CloseResultDto>> {
let c = closing::close(
&s.db,
auth.household_id,
auth.user_id,
parse_id(&id)?,
req.to_savings,
req.version,
)
.await?;
Ok(Json(CloseResultDto {
period: c.period.into(),
realized_savings: c.realized_savings,
to_savings: c.to_savings,
savings_linked: c.savings_linked,
carryover: c.carryover.map(|x| CarryoverDto {
kind: x.kind.as_str(),
amount: x.amount,
to_period_id: x.to_period_id.map(|u| u.to_string()),
}),
}))
}
pub async fn reopen(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<PeriodDto>> {
auth.require_owner()?;
let p = closing::reopen(&s.db, auth.household_id, auth.user_id, parse_id(&id)?).await?;
Ok(Json(p.into()))
}
+159
View File
@@ -0,0 +1,159 @@
//! Sabit gelir / gider kalemleri:
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods/{id}/plan-items` | dönemin kalemleri |
//! | POST | `/api/periods/{id}/plan-items` | kalem ekle |
//! | POST | `/api/periods/{id}/plan-items/copy-previous` | önceki dönemden kopyala |
//! | PATCH | `/api/plan-items/{id}` | düzenle |
//! | DELETE | `/api/plan-items/{id}` | sil |
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
extract::ValidJson,
models::plan_item::{PlanItem, PlanKind},
services::plan::{self, ItemChanges, NewItem},
state::AppState,
};
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "PlanItemCreateRequest", optional_fields)]
pub struct CreateRequest {
kind: PlanKind,
name: String,
/// Kuruş, > 0
#[ts(type = "number")]
amount: i64,
/// `"YYYY-MM-DD"`, dönem içinde (isteğe bağlı)
date: Option<String>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "PlanItemUpdateRequest", optional_fields)]
pub struct UpdateRequest {
name: Option<String>,
#[ts(type = "number", optional)]
amount: Option<i64>,
/// Boş metin → tarihi kaldır
date: Option<String>,
version: Option<i32>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "PlanItem")]
pub struct PlanItemDto {
id: String,
kind: PlanKind,
name: String,
/// Kuruş
#[ts(type = "number")]
amount: i64,
date: Option<String>,
sort_order: i32,
version: i32,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct CopyPreviousResult {
/// Eklenen kalem sayısı (zaten var olanlar atlanır)
added: usize,
}
impl From<PlanItem> for PlanItemDto {
fn from(i: PlanItem) -> Self {
Self {
id: i.id.to_string(),
kind: i.kind,
name: i.name,
amount: i.amount,
date: i.date,
sort_order: i.sort_order,
version: i.version,
}
}
}
fn parse_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
pub async fn list(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
) -> AppResult<Json<Vec<PlanItemDto>>> {
let items = plan::list(&s.db, auth.household_id, parse_id(&period_id)?).await?;
Ok(Json(items.into_iter().map(PlanItemDto::from).collect()))
}
pub async fn create(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<PlanItemDto>)> {
let item = plan::create(
&s.db,
auth.household_id,
parse_id(&period_id)?,
NewItem {
kind: req.kind,
name: req.name,
amount: req.amount,
date: req.date,
},
)
.await?;
Ok((StatusCode::CREATED, Json(item.into())))
}
pub async fn copy_previous(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
) -> AppResult<Json<CopyPreviousResult>> {
let added = plan::copy_previous(&s.db, auth.household_id, parse_id(&period_id)?).await?;
Ok(Json(CopyPreviousResult { added }))
}
pub async fn update(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<UpdateRequest>,
) -> AppResult<Json<PlanItemDto>> {
let item = plan::update(
&s.db,
auth.household_id,
parse_id(&id)?,
ItemChanges {
name: req.name,
amount: req.amount,
date: req.date,
version: req.version,
},
)
.await?;
Ok(Json(item.into()))
}
pub async fn delete(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
plan::delete(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(StatusCode::NO_CONTENT)
}
+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).
//!
//! - `/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)
//! - 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 {
let is_asset = req.uri().path().starts_with("/assets/");
let path = req.uri().path().to_string();
let mut res = next.run(req).await;
let is_html = res
.headers()
.get(header::CONTENT_TYPE)
.is_some_and(|v| v.as_bytes().starts_with(b"text/html"));
let policy = match (is_html, is_asset) {
(true, _) => "no-cache",
(false, true) => "public, max-age=31536000, immutable",
(false, false) => "public, max-age=3600",
};
res.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static(policy));
res.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(cache_policy(&path, is_html)),
);
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:
//! - **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))
.session(&mut *session)
.await?;
docs(col::ASSETS)
.insert_many(seed::assets(household_id))
.session(&mut *session)
.await?;
(household_id, Role::Owner)
}
};
@@ -143,3 +147,178 @@ async fn create_in_tx(
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
);
}
}
+659
View File
@@ -0,0 +1,659 @@
//! Bütçe ve dönem özeti (KAKEIBO_PROMPT §8.3, DATA_MODEL.md §3.8, §5).
//!
//! - Her grup için **aylık** tutar (`budgets`, `scope: "month"`).
//! - Haftalık tutar saklanmaz; aylık tutar hafta gün sayısına göre bölünür.
//! Kullanıcı bir haftayı elle değiştirirse yalnızca o kayıt saklanır (`scope: "week"`);
//! kalan tutar diğer haftalara gün oranında dağıtılır → haftaların toplamı = aylık.
//! - Özet: gelir − sabit gider − birikim − devreden borç + devreden artı = **harcanabilir**.
//! - Gerçekleşen harcama (`entries`) saklanmaz; her istekte toplanır:
//! özette toplam + kalan, bütçe görünümünde grup ve hafta bazında.
use std::collections::HashMap;
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
};
use serde::Deserialize;
use crate::{
db::col,
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
group::Group,
period::{Period, PeriodStatus},
},
services::{
calendar::{format_date, parse_date, weeks},
money::split_proportional,
period,
},
};
/// Gruplara göre tutarlar (kuruş), `Group::ALL` sırasıyla.
pub type GroupAmounts = [i64; 4];
/// Borçlu ayda varsayılan harcanabilir oranı: %45 (docs/KAKEIBO_PANEL_ISTEKLERI.md A5)
pub const DEFAULT_SPENDABLE_RATE_BP: i32 = 4500;
/// Borçlu ayın planı (devreden borç > 0)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DebtPlan {
/// Devreden borç (toplam)
pub carried: i64,
pub rate_bp: i32,
/// Harcanabilir = gelir × oran (sabitler çok yüksekse gelir − sabit)
pub spendable: i64,
/// Bu ay borca giden = gelir − sabit − harcanabilir (en fazla devreden borç kadar)
pub payment: i64,
/// Borca giden devreden borçtan büyükse artan kısım birikime
pub to_savings: i64,
/// Gelir − sabit < gelir × oran → bu ay borç azalmıyor
pub fixed_too_high: bool,
/// Ay sonu hedef borç = devreden − borca giden
pub closing_target: i64,
}
/// A5: Harcanabilir = gelir × oran; borca giden = gelir − sabit − harcanabilir.
pub fn debt_plan(income: i64, fixed: i64, carried: i64, rate_bp: i32) -> DebtPlan {
// Kuruşa yarım yukarı yuvarlama (gelir ≥ 0)
let by_rate = (i128::from(income.max(0)) * i128::from(rate_bp) + 5_000) / 10_000;
let by_rate = by_rate as i64;
let after_fixed = income - fixed;
let (spendable, raw, fixed_too_high) = if after_fixed < by_rate {
(after_fixed, 0, true)
} else {
(by_rate, after_fixed - by_rate, false)
};
let payment = raw.min(carried);
DebtPlan {
carried,
rate_bp,
spendable,
payment,
to_savings: raw - payment,
fixed_too_high,
closing_target: carried - payment,
}
}
pub struct Summary {
/// Borçlu ay (devreden borç > 0): harcanabilir %45 kuralıyla; birikim hedefi ve
/// Kakeibo devirleri hesaba girmez. `None` → normal Kakeibo düzeni.
pub debt: Option<DebtPlan>,
/// Devreden borcun kaynağı: `auto` (ay sonu ölçümü) | `manual` (planda elle); yoksa `None`
pub carried_source: Option<&'static str>,
/// Son ay sonu ölçümü (toplam ≥ 0, tarih) — elle girilmiş olsa da
pub carried_auto: Option<(i64, String)>,
pub income_total: i64,
pub fixed_expense_total: i64,
pub savings_goal: i64,
pub carryover_debt: i64,
pub carryover_surplus: i64,
pub spendable: i64,
pub budget_total: i64,
/// Dönemde gerçekleşen harcama (silinenler hariç)
pub spent_total: i64,
/// Aynısı gruplara göre (`Group::ALL` sırasıyla)
pub spent_by_group: GroupAmounts,
}
pub struct WeekBudget {
pub index: u32,
pub start_date: String,
pub last_date: String,
pub days: u32,
pub amount: i64,
pub is_manual: bool,
/// Bu haftada bu grupta gerçekleşen harcama
pub spent: i64,
}
pub struct GroupBudget {
pub group: Group,
pub monthly: i64,
/// Dönem boyunca bu grupta gerçekleşen harcama
pub spent: i64,
pub weeks: Vec<WeekBudget>,
/// Elle girilen haftaların toplamı aylık tutarı aşıyor mu?
pub weekly_over: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SuggestMethod {
/// Hanenin varsayılan yüzdeleri × harcanabilir tutar
Default,
/// Önceki dönemde gerçekleşen harcamalar
Previous,
/// Son 3 dönemin gerçekleşen harcama ortalaması
Avg3,
}
pub struct Suggestion {
pub available: bool,
pub message: Option<String>,
pub amounts: GroupAmounts,
}
#[derive(Deserialize)]
struct BudgetDoc {
group: Group,
scope: String,
#[serde(default)]
week_start: Option<String>,
amount: i64,
}
// ---------- Özet ----------
pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Summary> {
let p = period::get(db, household_id, period_id).await?;
let mut income_total = 0;
let mut fixed_expense_total = 0;
for (kind, total) in sum_by(
db,
col::PLAN_ITEMS,
household_id,
doc! { "period_id": period_id },
"$kind",
)
.await?
{
match kind.as_str() {
"income" => income_total = total,
"expense" => fixed_expense_total = total,
_ => {}
}
}
let mut carryover_debt = 0;
let mut carryover_surplus = 0;
for (kind, total) in sum_by(
db,
col::CARRYOVERS,
household_id,
doc! { "to_period_id": period_id },
"$kind",
)
.await?
{
match kind.as_str() {
"debt" => carryover_debt = total,
"surplus" => carryover_surplus = total,
_ => {}
}
}
let budget_total = monthly_amounts(db, household_id, period_id)
.await?
.iter()
.sum();
let carried = crate::services::debt::carried_for(db, household_id, &p).await?;
let debt = carried.as_ref().filter(|c| c.amount > 0).map(|c| {
debt_plan(
income_total,
fixed_expense_total,
c.amount,
p.spendable_rate_bp.unwrap_or(DEFAULT_SPENDABLE_RATE_BP),
)
});
let spendable = match &debt {
Some(d) => d.spendable,
None => {
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 {
debt,
carried_source: carried.as_ref().map(|c| c.source),
carried_auto: carried.and_then(|c| c.auto),
income_total,
fixed_expense_total,
savings_goal: p.savings_goal,
carryover_debt,
carryover_surplus,
spendable,
budget_total,
spent_total,
spent_by_group,
})
}
// ---------- Bütçe görünümü ----------
pub async fn view(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<Vec<GroupBudget>> {
let p = period::get(db, household_id, period_id).await?;
let docs = load(db, household_id, period_id).await?;
let (start, end) = dates(&p)?;
let period_weeks = weeks(start, end);
let spent = spent_by_group_and_date(db, household_id, period_id).await?;
let mut out = Vec::with_capacity(4);
for group in Group::ALL {
let monthly = docs
.iter()
.find(|d| d.group == group && d.scope == "month")
.map_or(0, |d| d.amount);
// Elle girilmiş haftalar (dönem tarihleri değiştiyse eşleşmeyen eski kayıtlar yok sayılır)
let manual: HashMap<String, i64> = docs
.iter()
.filter(|d| d.group == group && d.scope == "week")
.filter_map(|d| d.week_start.clone().map(|w| (w, d.amount)))
.collect();
let manual_total: i64 = period_weeks
.iter()
.filter_map(|w| manual.get(&format_date(w.start)))
.sum();
let auto_days: Vec<u64> = period_weeks
.iter()
.filter(|w| !manual.contains_key(&format_date(w.start)))
.map(|w| u64::from(w.days))
.collect();
let mut auto = split_proportional(monthly - manual_total, &auto_days).into_iter();
let weeks = period_weeks
.iter()
.map(|w| {
let key = format_date(w.start);
let last = format_date(w.last);
// Tarihler "YYYY-MM-DD" → metin karşılaştırması tarih sırasıyla aynı
let week_spent = spent
.iter()
.filter(|(g, d, _)| *g == group && *d >= key && *d <= last)
.map(|(_, _, a)| a)
.sum();
let (amount, is_manual) = match manual.get(&key) {
Some(&a) => (a, true),
None => (auto.next().unwrap_or(0), false),
};
WeekBudget {
index: w.index,
start_date: key,
last_date: last,
days: w.days,
amount,
is_manual,
spent: week_spent,
}
})
.collect();
out.push(GroupBudget {
group,
monthly,
spent: spent
.iter()
.filter(|(g, _, _)| *g == group)
.map(|(_, _, a)| a)
.sum(),
weeks,
weekly_over: manual_total > monthly,
});
}
Ok(out)
}
// ---------- Yazma ----------
/// 4 grubun aylık tutarını birlikte kaydeder (elle girilmiş haftalar korunur).
pub async fn set_monthly(
db: &Database,
household_id: Uuid,
period_id: Uuid,
amounts: GroupAmounts,
) -> AppResult<()> {
period::get_open(db, household_id, period_id).await?;
for a in amounts {
period::validate_amount(a)?;
}
for (group, amount) in Group::ALL.into_iter().zip(amounts) {
upsert(db, household_id, period_id, group, "month", None, amount).await?;
}
Ok(())
}
/// Bir haftayı elle ayarlar; `amount = None` → otomatiğe döner.
pub async fn set_week(
db: &Database,
household_id: Uuid,
period_id: Uuid,
group: Group,
week_start: &str,
amount: Option<i64>,
) -> AppResult<()> {
let p = period::get_open(db, household_id, period_id).await?;
let (start, end) = dates(&p)?;
let valid =
parse_date(week_start).is_some_and(|d| weeks(start, end).iter().any(|w| w.start == d));
if !valid {
return Err(AppError::BadRequest("Hafta bu döneme ait değil".into()));
}
match amount {
Some(a) => {
period::validate_amount(a)?;
upsert(
db,
household_id,
period_id,
group,
"week",
Some(week_start),
a,
)
.await
}
None => {
let mut f = scope(household_id);
f.insert("period_id", period_id);
f.insert("group", group.as_str());
f.insert("scope", "week");
f.insert("week_start", week_start);
let ts = now();
db.collection::<Document>(col::BUDGETS)
.update_many(
f,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
Ok(())
}
}
}
// ---------- Öneri ----------
pub async fn suggest(
db: &Database,
household_id: Uuid,
period_id: Uuid,
method: SuggestMethod,
) -> AppResult<Suggestion> {
let p = period::get(db, household_id, period_id).await?;
match method {
SuggestMethod::Default => {
let s = summary(db, household_id, period_id).await?;
if s.spendable <= 0 {
return Ok(unavailable(
"Harcanabilir tutar sıfır ya da negatif. Önce gelir, gider ve birikim hedefini girin.",
));
}
let pct = split_percentages(db, household_id).await?;
let parts = split_proportional(s.spendable, &pct);
Ok(Suggestion {
available: true,
message: None,
amounts: [parts[0], parts[1], parts[2], parts[3]],
})
}
SuggestMethod::Previous | SuggestMethod::Avg3 => {
let n = if method == SuggestMethod::Previous {
1
} else {
3
};
let previous = previous_closed(db, household_id, &p, n).await?;
if previous.is_empty() {
return Ok(unavailable("Henüz kapanmış bir önceki dönem yok."));
}
let ids: Vec<Bson> = previous.iter().map(|id| Bson::from(*id)).collect();
let sums = sum_by(
db,
col::ENTRIES,
household_id,
doc! { "period_id": { "$in": ids } },
"$group",
)
.await?;
let total: i64 = sums.values().sum();
if total == 0 {
return Ok(unavailable("Önceki dönemlerde henüz harcama kaydı yok."));
}
let count = previous.len() as i64;
let avg = |g: Group| {
let s = sums.get(g.as_str()).copied().unwrap_or(0);
(s + count / 2) / count // yuvarlayarak bölme
};
Ok(Suggestion {
available: true,
message: None,
amounts: Group::ALL.map(avg),
})
}
}
}
// ---------- yardımcılar ----------
fn dates(p: &Period) -> AppResult<(time::Date, time::Date)> {
let bad = || AppError::Internal("dönem tarihi okunamadı".into());
Ok((
parse_date(&p.start_date).ok_or_else(bad)?,
parse_date(&p.end_date).ok_or_else(bad)?,
))
}
fn unavailable(msg: &str) -> Suggestion {
Suggestion {
available: false,
message: Some(msg.into()),
amounts: [0; 4],
}
}
async fn load(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Vec<BudgetDoc>> {
let mut f = scope(household_id);
f.insert("period_id", period_id);
let mut cursor = db.collection::<BudgetDoc>(col::BUDGETS).find(f).await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?);
}
Ok(out)
}
async fn monthly_amounts(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<GroupAmounts> {
let docs = load(db, household_id, period_id).await?;
Ok(Group::ALL.map(|g| {
docs.iter()
.find(|d| d.group == g && d.scope == "month")
.map_or(0, |d| d.amount)
}))
}
async fn upsert(
db: &Database,
household_id: Uuid,
period_id: Uuid,
group: Group,
level: &str,
week_start: Option<&str>,
amount: i64,
) -> AppResult<()> {
let coll = db.collection::<Document>(col::BUDGETS);
let mut f = scope(household_id);
f.insert("period_id", period_id);
f.insert("group", group.as_str());
f.insert("scope", level);
f.insert(
"week_start",
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
);
let res = coll
.update_one(
f,
doc! { "$set": { "amount": amount, "updated_at": now() }, "$inc": { "version": 1 } },
)
.await?;
if res.matched_count == 0 {
let mut d = new_meta(household_id);
d.insert("period_id", period_id);
d.insert("group", group.as_str());
d.insert("scope", level);
d.insert(
"week_start",
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
);
d.insert("amount", amount);
coll.insert_one(d).await?;
}
Ok(())
}
/// Bir koleksiyonda `amount` alanını bir alana göre toplar: `{ "income": 450000, ... }`
async fn sum_by(
db: &Database,
collection: &str,
household_id: Uuid,
filter: Document,
key: &str,
) -> AppResult<HashMap<String, i64>> {
let mut m = scope(household_id);
m.extend(filter);
let pipeline = vec![
doc! { "$match": m },
doc! { "$group": { "_id": key, "total": { "$sum": "$amount" } } },
];
let mut cursor = db
.collection::<Document>(collection)
.aggregate(pipeline)
.await?;
let mut out = HashMap::new();
while cursor.advance().await? {
let d = cursor.deserialize_current()?;
let k = d.get_str("_id").unwrap_or_default().to_string();
let v = match d.get("total") {
Some(Bson::Int64(v)) => *v,
Some(Bson::Int32(v)) => i64::from(*v),
_ => 0,
};
out.insert(k, v);
}
Ok(out)
}
/// Dönemin harcamaları (grup, gün) bazında toplanmış: en fazla 4 × 62 satır.
pub(crate) async fn spent_by_group_and_date(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<Vec<(Group, String, i64)>> {
#[derive(Deserialize)]
struct Key {
group: Group,
date: String,
}
#[derive(Deserialize)]
struct Row {
#[serde(rename = "_id")]
key: Key,
total: i64,
}
let mut m = scope(household_id);
m.insert("period_id", period_id);
let pipeline = vec![
doc! { "$match": m },
doc! { "$group": {
"_id": { "group": "$group", "date": "$date" },
"total": { "$sum": "$amount" },
} },
];
let mut cursor = db
.collection::<Document>(col::ENTRIES)
.aggregate(pipeline)
.with_type::<Row>()
.await?;
let mut out = Vec::new();
while cursor.advance().await? {
let r = cursor.deserialize_current()?;
out.push((r.key.group, r.key.date, r.total));
}
Ok(out)
}
/// Hane ayarındaki varsayılan yüzdeler (yoksa 50/30/10/10).
async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult<Vec<u64>> {
let mut f = scope(household_id);
f.insert("_id", household_id);
let hh = db
.collection::<Document>(col::HOUSEHOLDS)
.find_one(f)
.await?;
let pct = hh
.as_ref()
.and_then(|h| h.get_document("settings").ok())
.and_then(|s| s.get_document("budget_split_pct").ok());
let default = [50u64, 30, 10, 10];
Ok(Group::ALL
.iter()
.zip(default)
.map(|(g, d)| {
pct.and_then(|p| match p.get(g.as_str()) {
Some(Bson::Int32(v)) => Some(*v as u64),
Some(Bson::Int64(v)) => Some(*v as u64),
_ => None,
})
.unwrap_or(d)
})
.collect())
}
/// Bu dönemden önceki en yeni `n` kapalı **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(
db: &Database,
household_id: Uuid,
p: &Period,
n: i64,
) -> AppResult<Vec<Uuid>> {
let mut f = scope(household_id);
f.insert("start_date", doc! { "$lt": &p.start_date });
f.insert(
"status",
doc! { "$in": [PeriodStatus::Closed.as_str(), PeriodStatus::Archived.as_str()] },
);
let mut cursor = db
.collection::<Period>(col::PERIODS)
.find(f)
.sort(doc! { "start_date": -1 })
.limit(n)
.await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?.id);
}
Ok(out)
}
#[cfg(test)]
mod debt_plan_tests {
use super::*;
#[test]
fn debt_month_matches_the_request_document() {
let d = debt_plan(7_950_964, 2_300_690, 5_710_000, 4500);
assert_eq!(d.spendable, 3_577_934);
}
}
+83 -29
View File
@@ -26,6 +26,13 @@ pub fn parse_date(s: &str) -> Option<Date> {
}
/// `Date` → `"2026-12-09"`
/// Türkiye'de bugünün tarihi (UTC+3, yaz saati yok). Sunucu UTC'de çalışsa da gün doğru olsun.
pub fn today_tr() -> Date {
time::OffsetDateTime::now_utc()
.to_offset(time::UtcOffset::from_hms(3, 0, 0).expect("geçerli ofset"))
.date()
}
pub fn format_date(d: Date) -> String {
format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day())
}
@@ -45,20 +52,35 @@ pub struct Week {
pub days: u32,
}
/// Dönemi Pazartesi başlangıçlı haftalara böler.
/// Baştaki ve sondaki kısmi haftalar ayrı hafta sayılır (KAKEIBO_PROMPT §6).
/// Dönemi **ayın günlerine göre** haftalara böler (kullanıcı kararı, 2026-11-13):
/// 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> {
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;
while cursor < end_exclusive {
// Bir sonraki Pazartesi (ya da dönem sonu, hangisi önce gelirse)
let to_monday = 7 - i64::from(cursor.weekday().number_days_from_monday());
let next = (cursor + Duration::days(to_monday)).min(end_exclusive);
for i in 0..count {
let len = if i == count - 1 {
days_between(cursor, end_exclusive)
} else if i == 0 {
8
} else {
7
};
let next = cursor + Duration::days(len);
out.push(Week {
index: out.len() as u32 + 1,
index: i as u32 + 1,
start: cursor,
last: next - Duration::days(1),
days: days_between(cursor, next) as u32,
days: len as u32,
});
cursor = next;
}
@@ -116,37 +138,69 @@ mod tests {
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]
fn december_2026_weeks() {
// 1 Aralık 2026 Salı → ilk hafta 6 gün (Sal–Paz), son hafta 4 gün (Pzt–Per)
let w = weeks(d("2026-12-01"), d("2027-01-01"));
let summary: Vec<(String, String, u32)> = w
.iter()
.map(|w| (format_date(w.start), format_date(w.last), w.days))
.collect();
fn month_weeks_follow_day_of_month() {
// Kasım 2026 (1'i Pazar): 1–8, 9–15, 16–22, 23–30 → 4 hafta
let w = weeks(d("2026-11-01"), d("2026-12-01"));
assert_eq!(
summary,
summary(&w),
vec![
("2026-12-01".into(), "2026-12-06".into(), 6),
("2026-12-07".into(), "2026-12-13".into(), 7),
("2026-12-14".into(), "2026-12-20".into(), 7),
("2026-12-21".into(), "2026-12-27".into(), 7),
("2026-12-28".into(), "2026-12-31".into(), 4),
(1, "2026-11-01".into(), "2026-11-08".into(), 8),
(2, "2026-11-09".into(), "2026-11-15".into(), 7),
(3, "2026-11-16".into(), "2026-11-22".into(), 7),
(4, "2026-11-23".into(), "2026-11-30".into(), 8),
]
);
assert_eq!(w.iter().map(|w| w.days).sum::<u32>(), 31);
assert_eq!(w.last().unwrap().index, 5);
// 31 gün → son hafta 23–31 (9 gün); Şubat → 23–28 (6 gün); artık yıl → 7 gün
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]
fn weeks_edge_cases() {
// Pazartesi başlayan, tam 2 hafta
let w = weeks(d("2026-11-30"), d("2026-12-14"));
assert_eq!(w.len(), 2);
assert!(w.iter().all(|w| w.days == 7));
// Tek gün (Pazar)
// Ay ortasında başlayan dönem: günler dönem başından sayılır
let w = weeks(d("2026-12-15"), d("2027-01-15"));
assert_eq!(
summary(&w)
.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"));
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
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())
}
/// Hanenin tek kaydı (pasifler dahil). Başka hanenin ya da olmayan kayıt → `None`.
pub async fn find(
db: &Database,
household_id: Uuid,
c: Catalog,
id: Uuid,
) -> AppResult<Option<Item>> {
let mut f = scope(household_id);
f.insert("_id", id);
Ok(coll(db, c).find_one(f).await?)
}
/// Hanenin kayıtları. Kategoriler grup sırasına, sonra `sort_order`'a göre sıralanır.
pub async fn list(
db: &Database,
+296
View File
@@ -0,0 +1,296 @@
//! 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 r = review::review(db, household_id, period_id).await?;
let realized = r.realized_savings;
// Borçlu ay: Kakeibo'nun kendi devri üretilmez (açık zaten banka borcunda; çifte sayım
// olmasın) ve birikim aktarılmaz — borcun ay sonu hali bir sonraki dönemin devreden borcu olur.
let (to_savings, carry) = if r.summary.debt.is_some() {
(0, None)
} else {
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
}
}
+19
View File
@@ -1,8 +1,27 @@
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
pub mod account;
pub mod asset;
pub mod backup;
pub mod bank_import;
pub mod budget;
pub mod calendar;
pub mod catalog;
pub mod closing;
pub mod csv_io;
pub mod debt;
pub mod entry;
pub mod holding;
pub mod invite;
pub mod money;
pub mod monthly_report;
pub mod note;
pub mod period;
pub mod plan;
pub mod rate_sources;
pub mod rates;
pub mod reports;
pub mod review;
pub mod seed;
pub mod stats;
pub mod transfer;
+68
View File
@@ -0,0 +1,68 @@
//! Para hesapları (kuruş, `i64`). Float yok.
/// `total` kuruşu ağırlıklara orantılı böler; **toplam her zaman `total`'a eşittir**.
///
/// "En büyük kalan" yöntemi: herkes tam payını alır, artan kuruşlar
/// küsuratı en büyük olana (eşitlikte öndekine) birer birer verilir.
/// Ağırlıkların hepsi 0 ise hepsine 0 döner (ve `total` dağıtılamaz → çağıran kontrol eder).
pub fn split_proportional(total: i64, weights: &[u64]) -> Vec<i64> {
let sum: u128 = weights.iter().map(|&w| u128::from(w)).sum();
if sum == 0 || total <= 0 {
return vec![0; weights.len()];
}
let total_u = total as u128;
let mut parts: Vec<i64> = Vec::with_capacity(weights.len());
let mut remainders: Vec<(u128, usize)> = Vec::with_capacity(weights.len());
for (i, &w) in weights.iter().enumerate() {
let exact = total_u * u128::from(w);
parts.push((exact / sum) as i64);
remainders.push((exact % sum, i));
}
let mut left = total - parts.iter().sum::<i64>();
// Büyük kalan önce; eşitlikte küçük indeks önce
remainders.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
for (_, i) in remainders {
if left == 0 {
break;
}
parts[i] += 1;
left -= 1;
}
parts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sums_exactly_and_is_proportional() {
// 1.000,00 ₺ Aralık haftalarına (6,7,7,7,4 gün)
let p = split_proportional(100_000, &[6, 7, 7, 7, 4]);
assert_eq!(p.iter().sum::<i64>(), 100_000);
assert_eq!(p, vec![19_355, 22_581, 22_581, 22_580, 12_903]);
}
#[test]
fn default_percentages() {
// Tam paylar 617.283,5 / 370.370,1 / 123.456,7 / 123.456,7 → artan 2 kuruş
// küsuratı en büyük olan ikisine (0,7'ler) gider
assert_eq!(
split_proportional(1_234_567, &[50, 30, 10, 10]),
vec![617_283, 370_370, 123_457, 123_457]
);
assert_eq!(split_proportional(1, &[50, 30, 10, 10]), vec![1, 0, 0, 0]);
}
#[test]
fn edge_cases() {
assert_eq!(split_proportional(0, &[1, 2]), vec![0, 0]);
assert_eq!(split_proportional(-500, &[1, 2]), vec![0, 0]);
assert_eq!(split_proportional(100, &[0, 0]), vec![0, 0]);
assert_eq!(split_proportional(100, &[]), Vec::<i64>::new());
assert_eq!(split_proportional(7, &[0, 3, 0]), vec![0, 7, 0]);
// Çok büyük tutarlarda taşma yok
let big = 10_000_000_000_000;
assert_eq!(split_proportional(big, &[31, 31]).iter().sum::<i64>(), big);
}
}
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
//! Dönem notları (KAKEIBO_PROMPT §8.6): başlık + Markdown metin, `planning` ya da `review`.
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
options::ReturnDocument,
};
use crate::{
db::col,
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
note::{Note, NoteContext},
},
services::period,
};
pub const MAX_BODY: usize = 20_000;
fn coll(db: &Database) -> mongodb::Collection<Note> {
db.collection(col::NOTES)
}
/// Dönemin notları (isteğe bağlı sayfa filtresiyle), en yeni değişen başta.
pub async fn list(
db: &Database,
household_id: Uuid,
period_id: Uuid,
context: Option<NoteContext>,
) -> AppResult<Vec<Note>> {
period::get(db, household_id, period_id).await?;
let mut f = scope(household_id);
f.insert("period_id", period_id);
if let Some(c) = context {
f.insert("context", c.as_str());
}
let mut cursor = coll(db).find(f).sort(doc! { "updated_at": -1 }).await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?);
}
Ok(out)
}
pub async fn create(
db: &Database,
household_id: Uuid,
user_id: Uuid,
period_id: Uuid,
context: NoteContext,
title: &str,
body_md: &str,
) -> AppResult<Note> {
period::get_open(db, household_id, period_id).await?;
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
d.insert("period_id", period_id);
d.insert("context", context.as_str());
d.insert("title", clean_title(title)?);
d.insert("body_md", clean_body(body_md)?);
d.insert("created_by", user_id);
db.collection::<Document>(col::NOTES).insert_one(d).await?;
coll(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("eklenen not okunamadı".into()))
}
pub async fn update(
db: &Database,
household_id: Uuid,
id: Uuid,
title: Option<&str>,
body_md: Option<&str>,
version: Option<i32>,
) -> AppResult<Note> {
let current = get(db, household_id, id).await?;
period::get_open(db, household_id, current.period_id).await?;
if version.is_some_and(|v| v != current.version) {
return Err(stale());
}
let mut set = doc! { "updated_at": now() };
if let Some(t) = title {
set.insert("title", clean_title(t)?);
}
if let Some(b) = body_md {
set.insert("body_md", clean_body(b)?);
}
let mut f = scope(household_id);
f.insert("_id", id);
f.insert("version", current.version);
coll(db)
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
.return_document(ReturnDocument::After)
.await?
.ok_or_else(stale)
}
pub async fn delete(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
let current = get(db, household_id, id).await?;
period::get_open(db, household_id, current.period_id).await?;
let ts = now();
let mut f = scope(household_id);
f.insert("_id", id);
db.collection::<Document>(col::NOTES)
.update_one(
f,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
Ok(())
}
async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Note> {
let mut f = scope(household_id);
f.insert("_id", id);
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
}
fn clean_title(t: &str) -> AppResult<String> {
let t = t.trim();
match t.chars().count() {
0 => Err(AppError::BadRequest("Not başlığı boş olamaz".into())),
1..=120 => Ok(t.to_string()),
_ => Err(AppError::BadRequest(
"Not başlığı en fazla 120 karakter olabilir".into(),
)),
}
}
fn clean_body(b: &str) -> AppResult<String> {
if b.chars().count() > MAX_BODY {
return Err(AppError::BadRequest(format!(
"Not metni en fazla {MAX_BODY} karakter olabilir"
)));
}
Ok(b.to_string())
}
fn stale() -> AppError {
AppError::Conflict("Not başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
+122 -12
View File
@@ -41,9 +41,16 @@ pub struct PeriodChanges {
pub start_date: Option<String>,
pub end_date: Option<String>,
pub savings_goal: Option<i64>,
/// Borçlu ayda harcanabilir oranı (baz puan, 1000–9000)
pub spendable_rate_bp: Option<i32>,
/// `Some("")` → metni kaldır
pub reflection_md: Option<String>,
pub version: Option<i32>,
}
/// "Nasıl daha iyi yapabilirim?" üst sınırı (karakter) — notlarla aynı
pub const MAX_REFLECTION: usize = 20_000;
fn coll(db: &Database) -> mongodb::Collection<Period> {
db.collection(col::PERIODS)
}
@@ -73,6 +80,22 @@ pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Perio
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
}
/// Yazma işlemleri için: dönem bu haneye ait ve **açık** olmalı.
/// Plan kalemleri, notlar ve (Faz 5) harcamalar bunu kullanır.
pub async fn get_open(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
let p = get(db, household_id, id).await?;
if !p.status.is_writable() {
return Err(closed());
}
Ok(p)
}
pub fn closed() -> AppError {
AppError::Conflict(
"Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(),
)
}
pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResult<Period> {
let (start, end) = validate_range(&p.start_date, &p.end_date)?;
let savings_goal = validate_amount(p.savings_goal.unwrap_or(0))?;
@@ -89,6 +112,8 @@ pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResul
// Bir önceki dönem (devir ve "önceki aydan kopyala" için)
let mut prev_f = scope(household_id);
prev_f.insert("start_date", doc! { "$lt": format_date(start) });
// Arşiv dönemleri devir zincirinde yok
prev_f.insert("status", doc! { "$ne": PeriodStatus::Archived.as_str() });
let previous = coll(db)
.find_one(prev_f)
.sort(doc! { "start_date": -1 })
@@ -101,24 +126,51 @@ pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResul
d.insert("end_date", format_date(end));
d.insert("status", PeriodStatus::Open.as_str());
d.insert("savings_goal", savings_goal);
d.insert("reflection_md", "");
d.insert("reflection_md", Bson::Null);
d.insert("to_savings", Bson::Null);
d.insert(
"previous_period_id",
previous.map_or(Bson::Null, |p| p.id.into()),
previous.as_ref().map_or(Bson::Null, |p| p.id.into()),
);
d.insert("closed_at", Bson::Null);
d.insert("closed_by", Bson::Null);
d.insert("reopened_at", Bson::Null);
match db.collection::<Document>(col::PERIODS).insert_one(d).await {
Ok(_) => {}
// Tek transaction: dönem + önceki dönemden bekleyen devrin bu döneme bağlanması
let mut session = db.client().start_session().await?;
session.start_transaction().await?;
let result = async {
db.collection::<Document>(col::PERIODS)
.insert_one(d)
.session(&mut session)
.await?;
if let Some(prev) = &previous {
let mut f = scope(household_id);
f.insert("from_period_id", prev.id);
f.insert("to_period_id", Bson::Null);
db.collection::<Document>(col::CARRYOVERS)
.update_many(
f,
doc! { "$set": { "to_period_id": id.clone(), "updated_at": now() }, "$inc": { "version": 1 } },
)
.session(&mut session)
.await?;
}
Ok::<_, mongodb::error::Error>(())
}
.await;
match result {
Ok(()) => session.commit_transaction().await?,
Err(e) => {
let _ = session.abort_transaction().await;
// Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller
Err(e) if is_duplicate_key(&e) => {
if is_duplicate_key(&e) {
return Err(AppError::Conflict(
"Zaten açık bir dönem var. Sayfayı yenileyin.".into(),
));
}
Err(e) => return Err(e.into()),
return Err(e.into());
}
}
coll(db)
@@ -134,10 +186,8 @@ pub async fn update(
ch: PeriodChanges,
) -> AppResult<Period> {
let current = get(db, household_id, id).await?;
if current.status == PeriodStatus::Closed {
return Err(AppError::Conflict(
"Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(),
));
if !current.status.is_writable() {
return Err(closed());
}
if ch.version.is_some_and(|v| v != current.version) {
return Err(stale());
@@ -160,9 +210,20 @@ pub async fn update(
if let Some(name) = ch.name {
set.insert("name", clean_name(Some(&name), start, end)?);
}
if let Some(bp) = ch.spendable_rate_bp {
if !(1000..=9000).contains(&bp) {
return Err(AppError::BadRequest(
"Harcanabilir oranı %10 ile %90 arasında olmalı".into(),
));
}
set.insert("spendable_rate_bp", bp);
}
if let Some(goal) = ch.savings_goal {
set.insert("savings_goal", validate_amount(goal)?);
}
if let Some(text) = ch.reflection_md {
set.insert("reflection_md", clean_reflection(&text)?);
}
let mut f = scope(household_id);
f.insert("_id", id);
@@ -176,6 +237,19 @@ pub async fn update(
// ---------- doğrulamalar ----------
fn clean_reflection(text: &str) -> AppResult<Bson> {
let t = text.trim();
if t.is_empty() {
return Ok(Bson::Null);
}
if t.chars().count() > MAX_REFLECTION {
return Err(AppError::BadRequest(
"Metin en fazla 20.000 karakter olabilir".into(),
));
}
Ok(Bson::String(t.to_string()))
}
fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> {
let bad = |m: &str| AppError::BadRequest(m.into());
let start = parse_date(start).ok_or_else(|| bad("Başlangıç tarihi geçersiz"))?;
@@ -192,7 +266,7 @@ fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> {
Ok((start, end))
}
fn validate_amount(v: i64) -> AppResult<i64> {
pub fn validate_amount(v: i64) -> AppResult<i64> {
if !(0..=MAX_AMOUNT).contains(&v) {
return Err(AppError::BadRequest("Tutar geçersiz".into()));
}
@@ -211,6 +285,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ıç`
/// (`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(
db: &Database,
household_id: Uuid,
@@ -262,7 +372,7 @@ async fn ensure_entries_inside(
Ok(())
}
fn stale() -> AppError {
pub fn stale() -> AppError {
AppError::Conflict("Dönem başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
+283
View File
@@ -0,0 +1,283 @@
//! Sabit gelir / gider kalemleri (KAKEIBO_PROMPT §8.3).
//!
//! - Tutar kuruş, > 0. Tarih isteğe bağlı; verilirse dönem içinde olmalı.
//! - Kapalı dönemde yazma yok.
//! - "Önceki dönemden kopyala": kalemler göreli günüyle taşınır (5 Ara → 5 Oca);
//! aynı tür + ad zaten varsa atlanır (iki kez basınca kopya oluşmaz).
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
options::ReturnDocument,
};
use time::Duration;
use crate::{
db::col,
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
period::Period,
plan_item::{PlanItem, PlanKind},
},
services::{
calendar::{format_date, parse_date},
period,
},
};
pub struct NewItem {
pub kind: PlanKind,
pub name: String,
pub amount: i64,
pub date: Option<String>,
}
#[derive(Default)]
pub struct ItemChanges {
pub name: Option<String>,
pub amount: Option<i64>,
/// `Some("")` → tarihi kaldır
pub date: Option<String>,
pub version: Option<i32>,
}
fn coll(db: &Database) -> mongodb::Collection<PlanItem> {
db.collection(col::PLAN_ITEMS)
}
/// Dönemin kalemleri: önce gelirler, sonra giderler; her biri sıralı.
pub async fn list(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Vec<PlanItem>> {
period::get(db, household_id, period_id).await?; // dönem bu haneye ait mi?
let mut f = scope(household_id);
f.insert("period_id", period_id);
let mut cursor = coll(db)
.find(f)
.sort(doc! { "kind": -1, "sort_order": 1 }) // "income" > "expense" → gelir önce
.await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?);
}
Ok(out)
}
pub async fn create(
db: &Database,
household_id: Uuid,
period_id: Uuid,
item: NewItem,
) -> AppResult<PlanItem> {
let p = period::get_open(db, household_id, period_id).await?;
let name = clean_name(&item.name)?;
let amount = clean_amount(item.amount)?;
let date = clean_date(item.date.as_deref(), &p)?;
let sort_order = next_sort_order(db, household_id, period_id, item.kind).await?;
insert(
db,
household_id,
period_id,
item.kind,
name,
amount,
date,
sort_order,
None,
)
.await
}
pub async fn update(
db: &Database,
household_id: Uuid,
id: Uuid,
ch: ItemChanges,
) -> AppResult<PlanItem> {
let current = get(db, household_id, id).await?;
let p = period::get_open(db, household_id, current.period_id).await?;
if ch.version.is_some_and(|v| v != current.version) {
return Err(stale());
}
let mut set = doc! { "updated_at": now() };
if let Some(name) = ch.name {
set.insert("name", clean_name(&name)?);
}
if let Some(amount) = ch.amount {
set.insert("amount", clean_amount(amount)?);
}
if let Some(date) = ch.date {
set.insert(
"date",
clean_date(Some(&date), &p)?.map_or(Bson::Null, Bson::String),
);
}
let mut f = scope(household_id);
f.insert("_id", id);
f.insert("version", current.version);
coll(db)
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
.return_document(ReturnDocument::After)
.await?
.ok_or_else(stale)
}
/// Soft delete (geçmiş/eşitleme için kayıt kalır).
pub async fn delete(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
let current = get(db, household_id, id).await?;
period::get_open(db, household_id, current.period_id).await?;
let ts = now();
let mut f = scope(household_id);
f.insert("_id", id);
db.collection::<Document>(col::PLAN_ITEMS)
.update_one(
f,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
Ok(())
}
/// Önceki dönemin kalemlerini bu döneme kopyalar. Eklenen kalem sayısını döner.
pub async fn copy_previous(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<usize> {
let target = period::get_open(db, household_id, period_id).await?;
// Bir önceki dönem: başlangıcı bu dönemden önce olan en yeni dönem
let mut pf = scope(household_id);
pf.insert("start_date", doc! { "$lt": &target.start_date });
let source = db
.collection::<Period>(col::PERIODS)
.find_one(pf)
.sort(doc! { "start_date": -1 })
.await?
.ok_or_else(|| AppError::BadRequest("Kopyalanacak önceki dönem yok".into()))?;
let existing = list(db, household_id, period_id).await?;
let src_items = list(db, household_id, source.id).await?;
let src_start = parse_date(&source.start_date).ok_or_else(bad_period)?;
let dst_start = parse_date(&target.start_date).ok_or_else(bad_period)?;
let dst_last = parse_date(&target.end_date).ok_or_else(bad_period)? - Duration::days(1);
let mut added = 0;
for item in src_items {
let duplicate = existing
.iter()
.any(|e| e.kind == item.kind && e.name.to_lowercase() == item.name.to_lowercase());
if duplicate {
continue;
}
// Göreli gün korunur, dönem dışına taşarsa son güne çekilir
let date = item.date.as_deref().and_then(parse_date).map(|d| {
let shifted = dst_start + (d - src_start);
format_date(shifted.clamp(dst_start, dst_last))
});
let sort_order = next_sort_order(db, household_id, period_id, item.kind).await?;
insert(
db,
household_id,
period_id,
item.kind,
item.name,
item.amount,
date,
sort_order,
Some(item.id),
)
.await?;
added += 1;
}
Ok(added)
}
// ---------- yardımcılar ----------
async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<PlanItem> {
let mut f = scope(household_id);
f.insert("_id", id);
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
}
#[allow(clippy::too_many_arguments)]
async fn insert(
db: &Database,
household_id: Uuid,
period_id: Uuid,
kind: PlanKind,
name: String,
amount: i64,
date: Option<String>,
sort_order: i32,
copied_from: Option<Uuid>,
) -> AppResult<PlanItem> {
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
d.insert("period_id", period_id);
d.insert("kind", kind.as_str());
d.insert("name", name);
d.insert("amount", amount);
d.insert("date", date.map_or(Bson::Null, Bson::String));
d.insert("sort_order", sort_order);
d.insert("copied_from_id", copied_from.map_or(Bson::Null, Bson::from));
db.collection::<Document>(col::PLAN_ITEMS)
.insert_one(d)
.await?;
coll(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("eklenen kalem okunamadı".into()))
}
async fn next_sort_order(
db: &Database,
household_id: Uuid,
period_id: Uuid,
kind: PlanKind,
) -> AppResult<i32> {
let mut f = scope(household_id);
f.insert("period_id", period_id);
f.insert("kind", kind.as_str());
let last = coll(db).find_one(f).sort(doc! { "sort_order": -1 }).await?;
Ok(last.map_or(10, |i| i.sort_order + 10))
}
fn clean_name(name: &str) -> AppResult<String> {
let n = name.trim();
match n.chars().count() {
0 => Err(AppError::BadRequest("Kalem adı boş olamaz".into())),
1..=60 => Ok(n.to_string()),
_ => Err(AppError::BadRequest(
"Kalem adı en fazla 60 karakter olabilir".into(),
)),
}
}
fn clean_amount(v: i64) -> AppResult<i64> {
if v <= 0 {
return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into()));
}
period::validate_amount(v)
}
/// Boş → `None`; doluysa geçerli ve dönem içinde olmalı.
fn clean_date(date: Option<&str>, p: &Period) -> AppResult<Option<String>> {
match date.map(str::trim) {
None | Some("") => Ok(None),
Some(s) => {
parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
if s < p.start_date.as_str() || s >= p.end_date.as_str() {
return Err(AppError::BadRequest("Tarih dönem içinde olmalı".into()));
}
Ok(Some(s.to_string()))
}
}
}
fn stale() -> AppError {
AppError::Conflict("Kalem başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
fn bad_period() -> AppError {
AppError::Internal("dönem tarihi okunamadı".into())
}
+214
View File
@@ -0,0 +1,214 @@
//! Kur kaynaklarının ayrıştırıcıları (saf; ağ yok → örnek metinle test edilir).
//!
//! - **TCMB** `today.xml`: dövizlerin **alış** kuru (`ForexBuying`) ÷ `Unit` (JPY 100 birim…).
//! Değerleme alış kuruyla (elindekini bugün bozdursan alacağın; kullanıcı kararı).
//! - **xaus.com** `/api/v1/spot`: 1 gr saf altının USD fiyatı (`per_gram_usd`) × TCMB USD alış.
//! Çeyrek/yarım/tam = içerdiği saf altın (has) gramı × gram fiyatı (kuyumcu işçilik payı hariç);
//! ons = 31,1034768 gr. Bu yaklaşık değer; gerekirse manuel kur girilir.
use serde::Deserialize;
use crate::services::calendar::{format_date, parse_date};
/// Altın kodları ve içerdiği saf altın (gr × 1e6)
pub const GOLD: &[(&str, i64)] = &[
("XAU_GRAM", 1_000_000),
("XAU_CEYREK", 1_603_000), // 1,75 gr × 0,916 (22 ayar)
("XAU_YARIM", 3_206_000), // 3,50 gr × 0,916
("XAU_TAM", 6_412_000), // 7,00 gr × 0,916
("XAU_ONS", 31_103_477), // 31,1034768 gr
];
#[derive(Debug, PartialEq, Eq)]
pub struct Bulletin {
/// `"YYYY-MM-DD"`
pub date: String,
/// (kod, TL × 1e6 / birim)
pub rates: Vec<(String, i64)>,
}
#[derive(Deserialize)]
struct TcmbXml {
#[serde(rename = "@Tarih")]
tarih: String,
#[serde(rename = "Currency", default)]
currencies: Vec<TcmbCurrency>,
}
#[derive(Deserialize)]
struct TcmbCurrency {
#[serde(rename = "@CurrencyCode")]
code: String,
#[serde(rename = "Unit")]
unit: Option<String>,
#[serde(rename = "ForexBuying")]
forex_buying: Option<String>,
}
/// `"50.1691"` → `50_169_100` (ondalık noktalı metin → × 1e6, kayan nokta kullanmadan)
pub fn decimal_e6(s: &str) -> Option<i64> {
let s = s.trim();
let (int, frac) = s.split_once('.').unwrap_or((s, ""));
if int.is_empty()
|| !int.bytes().all(|b| b.is_ascii_digit())
|| !frac.bytes().all(|b| b.is_ascii_digit())
{
return None;
}
let mut frac6: String = frac.chars().take(6).collect();
while frac6.len() < 6 {
frac6.push('0');
}
let v = int.parse::<i64>().ok()?.checked_mul(1_000_000)? + frac6.parse::<i64>().ok()?;
// 7. basamak yuvarlama
let round = frac.as_bytes().get(6).is_some_and(|b| *b >= b'5');
Some(v + i64::from(round))
}
pub fn parse_tcmb(xml: &str) -> Result<Bulletin, String> {
let doc: TcmbXml =
quick_xml::de::from_str(xml).map_err(|e| format!("TCMB XML okunamadı: {e}"))?;
// "12.11.2026" → "2026-11-12"
let parts: Vec<&str> = doc.tarih.split('.').collect();
let date = match parts.as_slice() {
[d, m, y] => format!("{y}-{m}-{d}"),
_ => return Err(format!("TCMB tarihi okunamadı: {}", doc.tarih)),
};
let date = parse_date(&date)
.map(format_date)
.ok_or_else(|| format!("TCMB tarihi geçersiz: {}", doc.tarih))?;
let rates = doc
.currencies
.into_iter()
.filter_map(|c| {
let unit: i64 = c
.unit
.as_deref()
.unwrap_or("1")
.trim()
.parse()
.ok()
.filter(|u| *u > 0)?;
let buying = decimal_e6(c.forex_buying.as_deref()?)?;
(buying > 0).then(|| (c.code.trim().to_uppercase(), buying / unit))
})
.collect();
Ok(Bulletin { date, rates })
}
#[derive(Deserialize)]
struct XausSpot {
per_gram_usd: f64,
}
/// 1 gr saf altın, USD × 1e6
pub fn parse_gold(json: &str) -> Result<i64, String> {
let s: XausSpot =
serde_json::from_str(json).map_err(|e| format!("altın fiyatı okunamadı: {e}"))?;
// JSON sayıyı ondalıklı verir; kurda 6 basamak yeterli
let v = (s.per_gram_usd * 1_000_000.0).round();
if !(v.is_finite() && v > 0.0 && v < 1e15) {
return Err(format!("altın fiyatı geçersiz: {}", s.per_gram_usd));
}
Ok(v as i64)
}
/// Gram USD fiyatı ve USD/TL alış → altın kodlarının TL kurları
pub fn gold_rates(per_gram_usd_e6: i64, usd_try_e6: i64) -> Vec<(&'static str, i64)> {
let gram_try_e6 = i128::from(per_gram_usd_e6) * i128::from(usd_try_e6) / 1_000_000;
GOLD.iter()
.map(|(code, has_e6)| {
(
*code,
(gram_try_e6 * i128::from(*has_e6) / 1_000_000) as i64,
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
pub const TCMB_SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="isokur.xsl"?>
<Tarih_Date Tarih="12.11.2026" Date="11/12/2026" Bulten_No="2026/213" >
<Currency CrossOrder="0" Kod="USD" CurrencyCode="USD">
<Unit>1</Unit>
<Isim>ABD DOLARI</Isim>
<CurrencyName>US DOLLAR</CurrencyName>
<ForexBuying>50.1691</ForexBuying>
<ForexSelling>50.2595</ForexSelling>
<BanknoteBuying>50.1340</BanknoteBuying>
<BanknoteSelling>50.3349</BanknoteSelling>
<CrossRateUSD/>
<CrossRateOther/>
</Currency>
<Currency CrossOrder="9" Kod="JPY" CurrencyCode="JPY">
<Unit>100</Unit>
<Isim>JAPON YENİ</Isim>
<CurrencyName>JAPENESE YEN</CurrencyName>
<ForexBuying>32.4512</ForexBuying>
<ForexSelling>32.6661</ForexSelling>
</Currency>
<Currency CrossOrder="19" Kod="XDR" CurrencyCode="XDR">
<Unit>1</Unit>
<Isim>ÖZEL ÇEKME HAKKI (SDR)</Isim>
<CurrencyName>SPECIAL DRAWING RIGHT (SDR)</CurrencyName>
<ForexBuying>68.5521</ForexBuying>
<ForexSelling/>
</Currency>
<Currency CrossOrder="20" Kod="IRR" CurrencyCode="IRR">
<Unit>1</Unit>
<ForexBuying/>
</Currency>
</Tarih_Date>"#;
#[test]
fn decimal_without_floats() {
assert_eq!(decimal_e6("50.1691"), Some(50_169_100));
assert_eq!(decimal_e6("7"), Some(7_000_000));
assert_eq!(
decimal_e6("0.1234567"),
Some(123_457),
"7. basamak yuvarlanır"
);
assert_eq!(decimal_e6(""), None);
assert_eq!(decimal_e6("1,5"), None);
assert_eq!(decimal_e6("-2.0"), None);
}
#[test]
fn tcmb_buying_rates_per_unit() {
let b = parse_tcmb(TCMB_SAMPLE).unwrap();
assert_eq!(b.date, "2026-11-12");
assert_eq!(
b.rates,
vec![
("USD".into(), 50_169_100),
("JPY".into(), 324_512), // 100 yen = 32,4512 TL
("XDR".into(), 68_552_100),
],
"alış kuru boş olan (IRR) atlanır"
);
assert!(parse_tcmb("<html>bakımda</html>").is_err());
}
#[test]
fn gold_from_usd() {
let g = parse_gold(
r#"{"xau":{"price":3874.1},"per_gram_usd":124.5552,"spot_usd_oz":3874.100098}"#,
)
.unwrap();
assert_eq!(g, 124_555_200);
assert!(parse_gold(r#"{"per_gram_usd":0}"#).is_err());
assert!(parse_gold("{}").is_err());
let r = gold_rates(g, 50_169_100);
// 124,5552 $ × 50,1691 TL = 6.248,8222843 TL/gr
assert_eq!(r[0], ("XAU_GRAM", 6_248_822_284));
assert_eq!(r[1].0, "XAU_CEYREK");
assert_eq!(r[1].1, 6_248_822_284 * 1_603 / 1_000);
assert_eq!(r[4].1, 194_360_100_187, "ons ≈ 194.360,10 TL");
}
}
+358
View File
@@ -0,0 +1,358 @@
//! Kurlar ve portföy değerlemesi (KAKEIBO_PROMPT §8.7, DATA_MODEL.md §3.14).
//!
//! - **Otomatik:** `refresh` TCMB'den dövizleri, xaus.com'dan altını çeker; `exchange_rates`'e
//! `household_id: null` ile gün başına tek kayıt yazar (aynı gün güncellenir). İki kaynak
//! birbirinden bağımsız: biri erişilemezse diğeri yine yazılır; altın için USD kuru o an
//! çekilemediyse veritabanındaki son USD kullanılır. Hata → log; son kur geçerli kalır.
//! - **Manuel:** hane kendi kurunu girer (gün başına tek; aynı gün tekrar girilirse güncellenir).
//! - **Güncel kur:** kod başına en yeni `rate_date`; aynı gün hem otomatik hem manuel varsa manuel.
//! (Eski bir manuel kur, daha yeni otomatik kurun önüne geçmez.)
//! - **Değer:** miktar × kur. TL varlığı kursuz (değeri = tutarı).
use std::{collections::HashMap, time::Duration};
use mongodb::{
Database,
bson::{DateTime, Document, Uuid, doc},
};
use crate::{
db::col,
error::{AppError, AppResult},
models::{
asset::{Asset, AssetKind},
common::{new_id, now, scope},
rate::Rate,
},
services::{
asset,
calendar::{format_date, parse_date, today_tr},
holding::Holding,
rate_sources,
},
};
/// Kaynak adresleri (testlerde sahte sunucu verilir)
#[derive(Debug, Clone)]
pub struct Sources {
pub tcmb_url: String,
pub gold_url: String,
}
impl Default for Sources {
fn default() -> Self {
Self {
tcmb_url: "https://www.tcmb.gov.tr/kurlar/today.xml".into(),
gold_url: "https://xaus.com/api/v1/spot".into(),
}
}
}
/// HTTP istemcisi: kısa zaman aşımı, genel kimlik (kişisel bilgi yok)
pub fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.user_agent("kakeibo/0.1 (ev finans defteri)")
.build()
.expect("HTTP istemcisi kurulamadı")
}
#[derive(Debug, Default)]
pub struct Report {
/// Yazılan döviz kuru sayısı ya da hata
pub fx: Option<Result<usize, String>>,
pub gold: Option<Result<usize, String>>,
}
async fn fetch_text(client: &reqwest::Client, url: &str) -> Result<String, String> {
let r = client
.get(url)
.send()
.await
.map_err(|e| format!("{url}: {e}"))?;
if !r.status().is_success() {
return Err(format!("{url}: HTTP {}", r.status()));
}
r.text().await.map_err(|e| format!("{url}: {e}"))
}
/// İki kaynağı da çeker ve yazar. Veritabanı hatası dışında hata döndürmez (rapora yazar).
pub async fn refresh(db: &Database, client: &reqwest::Client, src: &Sources) -> AppResult<Report> {
let mut report = Report::default();
let fetched_at = now();
// Döviz (TCMB)
let mut usd = None;
match fetch_text(client, &src.tcmb_url)
.await
.and_then(|x| rate_sources::parse_tcmb(&x))
{
Ok(b) => {
for (code, rate) in &b.rates {
upsert_auto(db, code, *rate, "tcmb", &b.date, fetched_at).await?;
}
usd = b.rates.iter().find(|(c, _)| c == "USD").map(|(_, r)| *r);
report.fx = Some(Ok(b.rates.len()));
}
Err(e) => {
tracing::warn!("TCMB kurları alınamadı: {e}");
report.fx = Some(Err(e));
}
}
// Altın (xaus.com × USD)
let usd = match usd {
Some(u) => Some(u),
None => latest_auto(db, "USD").await?.map(|r| r.rate_e6),
};
let gold = match usd {
None => Err("USD kuru yok; altın TL'ye çevrilemedi".to_string()),
Some(usd) => fetch_text(client, &src.gold_url)
.await
.and_then(|x| rate_sources::parse_gold(&x))
.map(|g| rate_sources::gold_rates(g, usd)),
};
match gold {
Ok(list) => {
let date = format_date(today_tr());
for (code, rate) in &list {
upsert_auto(db, code, *rate, "xaus", &date, fetched_at).await?;
}
report.gold = Some(Ok(list.len()));
}
Err(e) => {
tracing::warn!("Altın fiyatı alınamadı: {e}");
report.gold = Some(Err(e));
}
}
Ok(report)
}
/// Uygulama açılınca ve her `minutes` dakikada bir kurları yeniler (0 → kapalı).
pub fn spawn_refresher(db: Database, minutes: u64) {
if minutes == 0 {
tracing::info!("Otomatik kur yenileme kapalı (RATES_REFRESH_MINUTES=0)");
return;
}
tokio::spawn(async move {
let client = http_client();
let src = Sources::default();
let mut tick = tokio::time::interval(Duration::from_secs(minutes * 60));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tick.tick().await;
match refresh(&db, &client, &src).await {
Ok(r) => tracing::info!("Kurlar yenilendi: döviz {:?}, altın {:?}", r.fx, r.gold),
Err(e) => tracing::error!("Kurlar yazılamadı: {e}"),
}
}
});
}
async fn upsert_auto(
db: &Database,
code: &str,
rate_e6: i64,
source: &str,
rate_date: &str,
fetched_at: DateTime,
) -> AppResult<()> {
let ts = now();
db.collection::<Document>(col::EXCHANGE_RATES)
.update_one(
doc! { "household_id": null, "code": code, "source": source, "rate_date": rate_date, "deleted_at": null },
doc! {
"$set": { "rate_e6": rate_e6, "fetched_at": fetched_at, "updated_at": ts },
"$setOnInsert": { "_id": new_id(), "created_at": ts, "version": 1_i32 },
},
)
.upsert(true)
.await?;
Ok(())
}
async fn latest_auto(db: &Database, code: &str) -> AppResult<Option<Rate>> {
Ok(db
.collection::<Rate>(col::EXCHANGE_RATES)
.find_one(doc! { "household_id": null, "code": code, "deleted_at": null })
.sort(doc! { "rate_date": -1, "fetched_at": -1 })
.await?)
}
// ---------- Güncel kur ----------
/// Kod → güncel kur (hanenin manuel kurları + otomatik kurlar)
pub async fn current(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, Rate>> {
// Kod × (otomatik | bu hane) başına en yeni kayıt → en fazla 2 aday
let pipeline = vec![
doc! { "$match": {
"deleted_at": null,
"$or": [ { "household_id": null }, { "household_id": household_id } ],
} },
doc! { "$sort": { "rate_date": -1, "fetched_at": -1 } },
doc! { "$group": {
"_id": { "code": "$code", "hh": "$household_id" },
"doc": { "$first": "$$ROOT" },
} },
doc! { "$replaceRoot": { "newRoot": "$doc" } },
];
let mut cursor = db
.collection::<Document>(col::EXCHANGE_RATES)
.aggregate(pipeline)
.with_type::<Rate>()
.await?;
let mut out: HashMap<String, Rate> = HashMap::new();
while cursor.advance().await? {
let r = cursor.deserialize_current()?;
let better = match out.get(&r.code) {
None => true,
Some(cur) => {
(r.rate_date.as_str(), r.is_manual()) > (cur.rate_date.as_str(), cur.is_manual())
}
};
if better {
out.insert(r.code.clone(), r);
}
}
Ok(out)
}
/// En son otomatik çekim zamanı (kaynak sağlıklı mı?)
pub async fn last_fetch(db: &Database) -> AppResult<Option<DateTime>> {
Ok(db
.collection::<Rate>(col::EXCHANGE_RATES)
.find_one(doc! { "household_id": null, "deleted_at": null })
.sort(doc! { "fetched_at": -1 })
.await?
.map(|r| r.fetched_at))
}
// ---------- Manuel kur ----------
/// Hanenin varlıklarından birinin kodu için elle kur (aynı gün tekrar → güncellenir)
pub async fn set_manual(
db: &Database,
household_id: Uuid,
code: &str,
rate_e6: i64,
rate_date: Option<&str>,
) -> AppResult<Rate> {
let code = code.trim().to_uppercase();
let mut f = scope(household_id);
f.insert("code", &code);
let a = db
.collection::<Asset>(col::ASSETS)
.find_one(f)
.await?
.ok_or_else(|| AppError::BadRequest("Bu varlık bulunamadı".into()))?;
if a.kind == AssetKind::Cash {
return Err(AppError::BadRequest("TL için kur girilmez".into()));
}
if rate_e6 <= 0 || rate_e6 > 1_000_000_000_000_000 {
return Err(AppError::BadRequest("Kur sıfırdan büyük olmalı".into()));
}
let date = match rate_date.map(str::trim).filter(|d| !d.is_empty()) {
Some(d) => {
format_date(parse_date(d).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?)
}
None => format_date(today_tr()),
};
let ts = now();
let filter = doc! { "household_id": household_id, "code": &code, "rate_date": &date, "deleted_at": null };
db.collection::<Document>(col::EXCHANGE_RATES)
.update_one(
filter.clone(),
doc! {
"$set": { "rate_e6": rate_e6, "source": "manual", "fetched_at": ts, "updated_at": ts },
"$inc": { "version": 1 },
"$setOnInsert": { "_id": new_id(), "created_at": ts },
},
)
.upsert(true)
.await?;
db.collection::<Rate>(col::EXCHANGE_RATES)
.find_one(filter)
.await?
.ok_or_else(|| AppError::Internal("manuel kur okunamadı".into()))
}
/// Hanenin manuel kuru silinir (soft) → otomatik kura dönülür
pub async fn delete_manual(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
let ts = now();
let r = db
.collection::<Document>(col::EXCHANGE_RATES)
.update_one(
doc! { "_id": id, "household_id": household_id, "deleted_at": null },
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
if r.matched_count == 0 {
return Err(AppError::NotFound);
}
Ok(())
}
// ---------- Portföy ----------
pub struct Valued {
pub asset: Asset,
pub holding: Holding,
pub tx_count: usize,
/// TL varlığında `None` (kur gerekmez)
pub rate: Option<Rate>,
/// Güncel değer (kuruş); kur yoksa `None`
pub value: Option<i64>,
}
pub struct Portfolio {
pub items: Vec<Valued>,
pub last_fetch: Option<DateTime>,
}
/// miktar (× 1e6) × kur (TL × 1e6) → kuruş
pub fn value_of(quantity_e6: i64, rate_e6: i64) -> i64 {
let n = i128::from(quantity_e6) * i128::from(rate_e6);
((n + 5_000_000_000) / 10_000_000_000) as i64
}
pub async fn portfolio(db: &Database, household_id: Uuid) -> AppResult<Portfolio> {
let mut rates = current(db, household_id).await?;
let items = asset::list(db, household_id)
.await?
.into_iter()
.map(|(a, h, n)| {
let (rate, value) = if a.kind == AssetKind::Cash {
(None, Some(h.quantity_e6 / 10_000)) // TL: miktar = tutar
} else {
let r = rates.remove(&a.code);
let v = r.as_ref().map(|r| value_of(h.quantity_e6, r.rate_e6));
(r, v)
};
Valued {
asset: a,
holding: h,
tx_count: n,
rate,
value,
}
})
.collect();
Ok(Portfolio {
items,
last_fetch: last_fetch(db).await?,
})
}
#[cfg(test)]
mod tests {
use super::value_of;
#[test]
fn value_rounds_to_nearest_kurus() {
// 1 birim × 123,456789 TL = 12.345,6789 kuruş → 12.346
assert_eq!(value_of(1_000_000, 123_456_789), 12_346);
// 0,5 gr × 6.248,822284 TL = 3.124,411142 TL → 312.441 kuruş
assert_eq!(value_of(500_000, 6_248_822_284), 312_441);
assert_eq!(value_of(0, 6_248_822_284), 0);
}
}
+164
View File
@@ -0,0 +1,164 @@
//! Raporlar (KAKEIBO_PROMPT §8.8). Harcama kırılımı tek aggregation (`$facet`) ile:
//! grup, **tüm** kategoriler, ödeme türü, ay. Saklanmaz; her istekte hesaplanır.
//!
//! Filtre: dönem **ya da** tarih aralığı (ikisi de dahil), grup, kategori, ödeme türü
//! (`Some(None)` → ödeme türü girilmemiş kayıtlar). Silinen kayıtlar sayılmaz.
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
};
use serde::Deserialize;
use crate::{
db::col,
error::{AppError, AppResult},
models::{common::scope, group::Group},
services::{calendar::parse_date, period},
};
#[derive(Default)]
pub struct Filter {
pub period_id: Option<Uuid>,
pub from: Option<String>,
pub to: Option<String>,
pub group: Option<Group>,
pub category_id: Option<Uuid>,
/// `Some(None)` → ödeme türü boş olanlar
pub payment_method: Option<Option<Uuid>>,
}
#[derive(Debug, Deserialize)]
pub struct Row<K> {
#[serde(rename = "_id")]
pub key: K,
pub total: i64,
pub count: i64,
}
#[derive(Debug, Deserialize)]
pub struct CategoryKey {
pub category_id: Uuid,
pub group: Group,
}
#[derive(Debug, Deserialize)]
struct Facets {
totals: Vec<Row<Bson>>,
groups: Vec<Row<Group>>,
categories: Vec<Row<CategoryKey>>,
methods: Vec<Row<Option<Uuid>>>,
months: Vec<Row<String>>,
}
pub struct Breakdown {
pub total: i64,
pub count: i64,
/// `Group::ALL` sırasıyla, harcama olmayan grup 0
pub groups: Vec<Row<Group>>,
/// Çoktan aza (eşitlikte kategori kimliği)
pub categories: Vec<Row<CategoryKey>>,
/// Çoktan aza; `None` = ödeme türü girilmemiş
pub methods: Vec<Row<Option<Uuid>>>,
/// `"YYYY-MM"`, eskiden yeniye
pub months: Vec<Row<String>>,
}
pub async fn breakdown(db: &Database, household_id: Uuid, f: Filter) -> AppResult<Breakdown> {
let mut m = scope(household_id);
if let Some(pid) = f.period_id {
period::get(db, household_id, pid).await?; // bu haneye ait mi?
m.insert("period_id", pid);
}
let mut range = Document::new();
for (op, v) in [("$gte", &f.from), ("$lte", &f.to)] {
if let Some(d) = v {
let d = d.trim();
parse_date(d).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
range.insert(op, d);
}
}
if let (Some(a), Some(b)) = (&f.from, &f.to)
&& a.trim() > b.trim()
{
return Err(AppError::BadRequest(
"Başlangıç tarihi bitişten sonra olamaz".into(),
));
}
if !range.is_empty() {
m.insert("date", range);
}
if let Some(g) = f.group {
m.insert("group", g.as_str());
}
if let Some(c) = f.category_id {
m.insert("category_id", c);
}
if let Some(pm) = f.payment_method {
m.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
}
let sum = || doc! { "total": { "$sum": "$amount" }, "count": { "$sum": Bson::Int64(1) } };
let group_by = |key: Bson| {
let mut g = doc! { "_id": key };
g.extend(sum());
doc! { "$group": g }
};
let pipeline = vec![
doc! { "$match": m },
doc! { "$facet": {
"totals": [ group_by(Bson::Null) ],
"groups": [ group_by(Bson::String("$group".into())) ],
"categories": [
group_by(Bson::Document(doc! { "category_id": "$category_id", "group": "$group" })),
{ "$sort": { "total": -1, "_id.category_id": 1 } },
],
"methods": [
// Alanı hiç olmayan eski kayıtlar da "girilmemiş" sayılsın
group_by(Bson::Document(doc! { "$ifNull": ["$payment_method_id", null] })),
{ "$sort": { "total": -1, "_id": 1 } },
],
"months": [
group_by(Bson::Document(doc! { "$substrBytes": ["$date", 0, 7] })),
{ "$sort": { "_id": 1 } },
],
} },
];
let mut cursor = db
.collection::<Document>(col::ENTRIES)
.aggregate(pipeline)
.with_type::<Facets>()
.await?;
let f = if cursor.advance().await? {
cursor.deserialize_current()?
} else {
return Err(AppError::Internal("rapor sorgusu boş döndü".into()));
};
let (total, count) = f.totals.first().map_or((0, 0), |r| (r.total, r.count));
let groups = Group::ALL
.into_iter()
.map(|g| {
f.groups.iter().find(|r| r.key == g).map_or(
Row {
key: g,
total: 0,
count: 0,
},
|r| Row {
key: g,
total: r.total,
count: r.count,
},
)
})
.collect();
Ok(Breakdown {
total,
count,
groups,
categories: f.categories,
methods: f.methods,
months: f.months,
})
}
+105
View File
@@ -0,0 +1,105 @@
//! Ay sonu değerlendirmesi (KAKEIBO_PROMPT §8.5, DATA_MODEL.md §5).
//!
//! Kakeibo'nun dört sorusu — hepsi sorgu anında hesaplanır, saklanmaz:
//! 1. Ne kadar param vardı? `available` = gelir − sabit gider − devreden borç + devreden artı
//! 2. Ne kadar biriktirmek istedim? `savings_goal`
//! 3. Ne kadar harcadım? `spent_total`
//! 4. Ne kadar biriktirdim? `realized_savings` = available − spent_total
//!
//! Ek olarak: grup bazlı planlanan/gerçekleşen, kategori bazlı harcama, haftalık dağılım.
//! Kapalı dönemde de okunabilir.
use mongodb::{
Database,
bson::{Document, Uuid, doc},
};
use serde::Deserialize;
use crate::{
db::col,
error::AppResult,
models::{common::scope, group::Group},
services::budget::{self, GroupBudget, Summary},
};
pub struct Review {
pub summary: Summary,
/// 1. soru
pub available: i64,
/// 4. soru (negatifse açık var → kapanışta borç olarak devreder)
pub realized_savings: i64,
/// Grup: aylık bütçe ve gerçekleşen (`Group::ALL` sırasıyla)
pub groups: Vec<GroupBudget>,
/// Harcama olan kategoriler, çoktan aza
pub categories: Vec<CategorySpent>,
}
pub struct CategorySpent {
pub category_id: Uuid,
pub group: Group,
pub spent: i64,
pub count: i64,
}
pub async fn review(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Review> {
// `summary` dönemin bu haneye ait olduğunu da denetler (yoksa 404)
let summary = budget::summary(db, household_id, period_id).await?;
let groups = budget::view(db, household_id, period_id).await?;
let categories = spent_by_category(db, household_id, period_id).await?;
let available = summary.income_total - summary.fixed_expense_total - summary.carryover_debt
+ summary.carryover_surplus;
Ok(Review {
realized_savings: available - summary.spent_total,
available,
summary,
groups,
categories,
})
}
async fn spent_by_category(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<Vec<CategorySpent>> {
#[derive(Deserialize)]
struct Key {
category_id: Uuid,
group: Group,
}
#[derive(Deserialize)]
struct Row {
#[serde(rename = "_id")]
key: Key,
total: i64,
count: i64,
}
let mut m = scope(household_id);
m.insert("period_id", period_id);
let pipeline = vec![
doc! { "$match": m },
doc! { "$group": {
"_id": { "category_id": "$category_id", "group": "$group" },
"total": { "$sum": "$amount" },
"count": { "$sum": 1_i64 },
} },
// Eşit tutarda sıra her seferinde aynı olsun
doc! { "$sort": { "total": -1, "_id.category_id": 1 } },
];
let mut cursor = db
.collection::<Document>(col::ENTRIES)
.aggregate(pipeline)
.with_type::<Row>()
.await?;
let mut out = Vec::new();
while cursor.advance().await? {
let r = cursor.deserialize_current()?;
out.push(CategorySpent {
category_id: r.key.category_id,
group: r.key.group,
spent: r.total,
count: r.count,
});
}
Ok(out)
}
+31 -1
View File
@@ -1,4 +1,5 @@
//! Yeni hane için örnek alt kategoriler ve ödeme türleri (DATA_MODEL.md §3.5, §3.6).
//! Yeni hane için örnek alt kategoriler, ödeme türleri ve varsayılan varlıklar
//! (DATA_MODEL.md §3.5, §3.6, §3.12).
use mongodb::bson::{Document, Uuid};
@@ -29,6 +30,35 @@ const PAYMENT_METHODS: &[(&str, &str)] = &[
("Havale/EFT", "🔁"),
];
/// (tür, kod, ad, birim) — kurlar Faz 8 Adım 2'de bu kodlarla eşleşir
const ASSETS: &[(&str, &str, &str, &str)] = &[
("cash", "TRY", "Türk Lirası", "₺"),
("gold", "XAU_GRAM", "Gram Altın", "gr"),
("gold", "XAU_CEYREK", "Çeyrek Altın", "adet"),
("gold", "XAU_YARIM", "Yarım Altın", "adet"),
("gold", "XAU_TAM", "Tam Altın", "adet"),
("gold", "XAU_ONS", "Ons Altın", "ons"),
("fx", "USD", "ABD Doları", "$"),
("fx", "EUR", "Euro", "€"),
];
pub fn assets(household_id: Uuid) -> Vec<Document> {
ASSETS
.iter()
.enumerate()
.map(|(i, (kind, code, name, unit))| {
let mut d = new_meta(household_id);
d.insert("kind", *kind);
d.insert("code", *code);
d.insert("name", *name);
d.insert("unit_label", *unit);
d.insert("sort_order", (i as i32 + 1) * 10);
d.insert("is_active", true);
d
})
.collect()
}
pub fn categories(household_id: Uuid) -> Vec<Document> {
CATEGORIES
.iter()
+93
View File
@@ -0,0 +1,93 @@
//! Panel (Dashboard) verileri (KAKEIBO_PROMPT §8.2). Hiçbiri saklanmaz; her istekte toplanır.
//!
//! - `daily`: dönemin her günü için 4 grubun harcaması (harcama olmayan gün 0).
//! Günlük/haftalık ve kümülatif seriler istemcide bundan türetilir.
//! - `periods`: son N dönemin harcanabilir/harcanan tutarı, grup kırılımıyla (aylık grafik) ve
//! **toplam birikim** = kapanışlarda birikime aktarılanların toplamı
//! (varlık değerleri Faz 8'de bunun yerini alacak). Yeniden açılan dönemin son
//! kapanıştaki tutarı, yeniden kapatılana kadar sayılır.
use mongodb::{Database, bson::Uuid};
use crate::{
error::AppResult,
models::period::Period,
services::{
budget::{self, GroupAmounts},
calendar::{format_date, parse_date},
period,
},
};
/// Aylık grafikte en fazla dönem sayısı
pub const MAX_PERIODS: usize = 24;
pub struct DaySpent {
/// `"YYYY-MM-DD"`
pub date: String,
/// `Group::ALL` sırasıyla
pub spent: GroupAmounts,
}
pub async fn daily(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Vec<DaySpent>> {
let p = period::get(db, household_id, period_id).await?;
// Veritabanındaki tarihler her zaman doğrulanmış biçimde yazılır
let start = parse_date(&p.start_date).expect("geçerli başlangıç tarihi");
let end = parse_date(&p.end_date).expect("geçerli bitiş tarihi");
let mut days = Vec::new();
let mut d = start;
while d < end {
days.push(DaySpent {
date: format_date(d),
spent: [0; 4],
});
d += time::Duration::days(1);
}
for (group, date, amount) in
budget::spent_by_group_and_date(db, household_id, period_id).await?
{
// Dönem tarihleri sonradan daraltıldıysa dışarıda kalan kayıt grafiğe girmez
if let Some(day) = days.iter_mut().find(|x| x.date == date) {
day.spent[group.order()] += amount;
}
}
Ok(days)
}
pub struct PeriodStat {
pub period: Period,
/// Gelir, sabit gider, devirler, harcanabilir, harcanan (gruplara göre dahil)
pub summary: budget::Summary,
}
impl PeriodStat {
/// Değerlendirmenin 4. sorusu: gelir − sabit − borç + artı − harcanan (`review` ile aynı)
pub fn realized_savings(&self) -> i64 {
let s = &self.summary;
s.income_total - s.fixed_expense_total - s.carryover_debt + s.carryover_surplus
- s.spent_total
}
}
pub struct Stats {
/// Kapanışlarda birikime aktarılanların toplamı (tüm dönemler)
pub total_saved: i64,
/// Son `limit` dönem, **eskiden yeniye**
pub periods: Vec<PeriodStat>,
}
pub async fn periods(db: &Database, household_id: Uuid, limit: usize) -> AppResult<Stats> {
let list = period::list(db, household_id).await?; // en yeni başta
let total_saved = list.iter().filter_map(|p| p.to_savings).sum();
let mut out = Vec::new();
for p in list.into_iter().take(limit.clamp(1, MAX_PERIODS)) {
let summary = budget::summary(db, household_id, p.id).await?;
out.push(PeriodStat { period: p, summary });
}
out.reverse();
Ok(Stats {
total_saved,
periods: out,
})
}
+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 crate::{
auth::{jwt::Jwt, limiter::LoginLimiter},
auth::{captcha::Captcha, guard::AttemptGuard, jwt::Jwt, limiter::LoginLimiter},
config::Config,
};
@@ -15,7 +15,11 @@ pub struct AppState {
pub db: Database,
pub config: Arc<Config>,
pub jwt: Arc<Jwt>,
/// Şifre değiştirmede mevcut şifre denemeleri (kullanıcı başına)
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 {
@@ -23,9 +27,18 @@ impl AppState {
Self {
db,
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))),
guard: Arc::new(AttemptGuard::default()),
captcha: Arc::new(Captcha::from_secret(config.turnstile_secret_key.as_deref())),
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;
}
+410
View File
@@ -0,0 +1,410 @@
//! Bütçe ve dönem özeti.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use mongodb::bson::{Document, Uuid, doc};
use serde_json::{Value, json};
async fn open_december(app: &TestApp, s: &Session, savings_goal: i64) -> String {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": savings_goal }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
p["id"].as_str().unwrap().to_string()
}
async fn add_item(app: &TestApp, s: &Session, pid: &str, kind: &str, amount: i64) {
let (st, b) = app
.post(
&format!("/periods/{pid}/plan-items"),
s,
json!({ "kind": kind, "name": format!("{kind}-{amount}"), "amount": amount }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
}
fn group<'a>(budget: &'a Value, code: &str) -> &'a Value {
budget
.as_array()
.unwrap()
.iter()
.find(|g| g["group"] == code)
.unwrap()
}
fn week_amounts(g: &Value) -> Vec<i64> {
g["weeks"]
.as_array()
.unwrap()
.iter()
.map(|w| w["amount"].as_i64().unwrap())
.collect()
}
#[tokio::test]
async fn summary_computes_spendable_with_carryovers() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 500_000).await;
// Boş dönem: gelir yok → harcanabilir = -birikim
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(sum["spendable"], -500_000);
add_item(&app, &s, &pid, "income", 4_500_000).await;
add_item(&app, &s, &pid, "income", 300_000).await;
add_item(&app, &s, &pid, "expense", 1_500_000).await;
add_item(&app, &s, &pid, "expense", 45_000).await;
// Önceki dönemden devir (Faz 6'da kapanış sihirbazı yazacak)
let hid = Uuid::parse_str(&s.household_id).unwrap();
let to = Uuid::parse_str(&pid).unwrap();
app.db
.collection::<Document>("carryovers")
.insert_many([
doc! { "household_id": hid, "to_period_id": to, "kind": "debt", "amount": 100_000_i64, "deleted_at": null },
doc! { "household_id": hid, "to_period_id": to, "kind": "surplus", "amount": 20_000_i64, "deleted_at": null },
])
.await
.unwrap();
let (st, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(
sum,
json!({
"income_total": 4_800_000,
"fixed_expense_total": 1_545_000,
"savings_goal": 500_000,
"carryover_debt": 100_000,
"carryover_surplus": 20_000,
// 4.800.000 − 1.545.000 − 500.000 − 100.000 + 20.000
"spendable": 2_675_000,
// Banka borcu yok → normal Kakeibo (docs/KAKEIBO_PANEL_ISTEKLERI.md A)
"carried_debt": 0,
"carried_debt_source": null,
"carried_debt_date": null,
"carried_debt_auto": null,
"debt_mode": false,
"spendable_rate_bp": 4500,
"debt_payment": 0,
"debt_to_savings": 0,
"debt_fixed_too_high": false,
"closing_debt_target": null,
"budget_total": 0,
"unallocated": 2_675_000,
"spent_total": 0,
"remaining": 2_675_000
})
);
app.cleanup().await;
}
#[tokio::test]
async fn default_suggestion_splits_spendable() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 0).await;
let url = format!("/periods/{pid}/budget/suggestion?method=default");
// Gelir yokken öneri yok
let (_, sug) = app.get(&url, &s).await;
assert_eq!(sug["available"], false);
assert!(sug["message"].as_str().unwrap().contains("Harcanabilir"));
add_item(&app, &s, &pid, "income", 1_234_567).await;
let (_, sug) = app.get(&url, &s).await;
assert_eq!(sug["available"], true);
assert_eq!(
sug["amounts"],
json!({ "needs": 617_283, "wants": 370_370, "culture": 123_457, "unexpected": 123_457 })
);
// Önceki dönem yokken previous/avg3 → veri yok
for m in ["previous", "avg3"] {
let (_, sug) = app
.get(&format!("/periods/{pid}/budget/suggestion?method={m}"), &s)
.await;
assert_eq!(sug["available"], false, "{m}");
}
app.cleanup().await;
}
#[tokio::test]
async fn monthly_budget_is_split_into_weeks_by_days() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 0).await;
add_item(&app, &s, &pid, "income", 300_000).await;
let url = format!("/periods/{pid}/budget");
// Başta hepsi 0
let (_, b) = app.get(&url, &s).await;
assert_eq!(b.as_array().unwrap().len(), 4);
assert_eq!(week_amounts(group(&b, "needs")), vec![0; 4]);
let (st, b) = app
.put(
&url,
&s,
json!({ "needs": 100_000, "wants": 50_000, "culture": 31, "unexpected": 0 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
let needs = group(&b, "needs");
assert_eq!(needs["monthly"], 100_000);
// Aralık: 1–8, 9–15, 16–22, 23–31 → 8,7,7,9 gün; toplam tam 100.000
assert_eq!(week_amounts(needs), vec![25_806, 22_581, 22_581, 29_032]);
assert_eq!(week_amounts(group(&b, "culture")), vec![8, 7, 7, 9]);
for g in b.as_array().unwrap() {
assert_eq!(
week_amounts(g).iter().sum::<i64>(),
g["monthly"].as_i64().unwrap()
);
}
// Özet: 150.031 dağıtıldı, harcanabilir 300.000 → kalan 149.969
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(
(sum["budget_total"].as_i64(), sum["unallocated"].as_i64()),
(Some(150_031), Some(149_969))
);
// Fazla dağıtım → kalan negatif (arayüz uyarı gösterir)
app.put(
&url,
&s,
json!({ "needs": 400_000, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(sum["unallocated"], -100_000);
// Negatif tutar → 400
let (st, _) = app
.put(
&url,
&s,
json!({ "needs": -1, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
app.cleanup().await;
}
#[tokio::test]
async fn manual_week_override_redistributes_the_rest() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 0).await;
app.put(
&format!("/periods/{pid}/budget"),
&s,
json!({ "needs": 100_000, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
let weeks_url = format!("/periods/{pid}/budget/weeks");
// 2. hafta (9–15 Ara) elle 50.000 → kalan 50.000 diğer haftalara (8+7+9 = 24 gün)
let (st, b) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-09", "amount": 50_000 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
let needs = group(&b, "needs");
assert_eq!(week_amounts(needs), vec![16_667, 50_000, 14_583, 18_750]);
assert_eq!(week_amounts(needs).iter().sum::<i64>(), 100_000);
let manual: Vec<bool> = needs["weeks"]
.as_array()
.unwrap()
.iter()
.map(|w| w["is_manual"].as_bool().unwrap())
.collect();
assert_eq!(manual, vec![false, true, false, false]);
assert_eq!(needs["weekly_over"], false);
// Aylık tutar değişince elle girilen hafta korunur
let (_, b) = app
.put(
&format!("/periods/{pid}/budget"),
&s,
json!({ "needs": 74_000, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
assert_eq!(week_amounts(group(&b, "needs"))[1], 50_000);
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
// Elle girilenler aylığı aşarsa uyarı; diğer haftalar 0
let (_, b) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-16", "amount": 30_000 }),
)
.await;
let needs = group(&b, "needs");
assert_eq!(needs["weekly_over"], true);
assert_eq!(week_amounts(needs), vec![0, 50_000, 30_000, 0]);
// null → otomatiğe dön
app.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-16", "amount": null }),
)
.await;
let (_, b) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-09", "amount": null }),
)
.await;
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
assert!(
group(&b, "needs")["weeks"]
.as_array()
.unwrap()
.iter()
.all(|w| w["is_manual"] == false)
);
// Haftanın ilk günü olmayan tarih → 400
let (st, _) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-08", "amount": 1 }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
app.cleanup().await;
}
#[tokio::test]
async fn previous_and_avg3_suggestions_use_actual_spending() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let hid = Uuid::parse_str(&s.household_id).unwrap();
// Ekim ve Kasım kapalı, harcamaları var (Faz 5'te defterden gelecek)
let mut closed = Vec::new();
for (start, end) in [("2026-10-01", "2026-11-01"), ("2026-11-01", "2026-12-01")] {
let (_, p) = app
.post(
"/periods",
&s,
json!({ "start_date": start, "end_date": end }),
)
.await;
let id = Uuid::parse_str(p["id"].as_str().unwrap()).unwrap();
app.db
.collection::<Document>("periods")
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
.await
.unwrap();
closed.push(id);
}
let entry = |pid: Uuid, group: &str, amount: i64| {
doc! { "household_id": hid, "period_id": pid, "group": group, "amount": amount, "date": "2026-10-10", "deleted_at": null }
};
app.db
.collection::<Document>("entries")
.insert_many([
entry(closed[0], "needs", 100_000),
entry(closed[0], "wants", 41_000),
entry(closed[1], "needs", 200_000),
entry(closed[1], "needs", 50_000),
entry(closed[1], "culture", 10_000),
])
.await
.unwrap();
let pid = open_december(&app, &s, 0).await;
let (_, prev) = app
.get(
&format!("/periods/{pid}/budget/suggestion?method=previous"),
&s,
)
.await;
assert_eq!(prev["available"], true, "{prev}");
assert_eq!(
prev["amounts"],
json!({ "needs": 250_000, "wants": 0, "culture": 10_000, "unexpected": 0 })
);
// avg3: 2 kapalı dönem var → ikiye bölünür (yuvarlayarak)
let (_, avg) = app
.get(&format!("/periods/{pid}/budget/suggestion?method=avg3"), &s)
.await;
assert_eq!(
avg["amounts"],
json!({ "needs": 175_000, "wants": 20_500, "culture": 5_000, "unexpected": 0 })
);
app.cleanup().await;
}
#[tokio::test]
async fn budget_closed_period_and_isolation() {
let app = TestApp::new().await;
let a = app.register("A", None).await;
let b = app.register("B", None).await;
let pid = open_december(&app, &a, 0).await;
let body = json!({ "needs": 1, "wants": 0, "culture": 0, "unexpected": 0 });
// B erişemez
let checks = [
app.get(&format!("/periods/{pid}/summary"), &b).await.0,
app.get(&format!("/periods/{pid}/budget"), &b).await.0,
app.put(&format!("/periods/{pid}/budget"), &b, body.clone())
.await
.0,
app.put(
&format!("/periods/{pid}/budget/weeks"),
&b,
json!({ "group": "needs", "week_start": "2026-12-09", "amount": 1 }),
)
.await
.0,
app.get(&format!("/periods/{pid}/budget/suggestion"), &b)
.await
.0,
];
assert!(
checks.iter().all(|s| *s == StatusCode::NOT_FOUND),
"{checks:?}"
);
// Kapalı dönem: okuma serbest, yazma 409
let id = Uuid::parse_str(&pid).unwrap();
app.db
.collection::<Document>("periods")
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
.await
.unwrap();
assert_eq!(
app.get(&format!("/periods/{pid}/budget"), &a).await.0,
StatusCode::OK
);
assert_eq!(
app.put(&format!("/periods/{pid}/budget"), &a, body).await.0,
StatusCode::CONFLICT
);
app.cleanup().await;
}
+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,
registration,
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));
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.
pub async fn cleanup(self) {
self.db.drop().await.ok();
@@ -100,6 +153,43 @@ impl TestApp {
(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) {
let (st, v, _) = self.send(Method::GET, path, Some(&s.cookie), None).await;
(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;
}
+234
View File
@@ -0,0 +1,234 @@
//! Devreden borç planın parçası (docs/KAKEIBO_PANEL_ISTEKLERI.md A): %45 kuralı, kaynak
//! (ölçüm / elle), borç bitince normal düzen, borçlu ayda Kakeibo devri yok.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use serde_json::{Value, json};
async fn december(app: &TestApp, s: &Session) -> String {
let (_, p) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": 500_000 }),
)
.await;
let id = p["id"].as_str().unwrap().to_string();
// A5 kontrol: gelir 79.509,64 · sabit 23.006,90
for (kind, name, amount) in [
("income", "Maaş", 7_950_964),
("expense", "Kira", 2_000_000),
("expense", "Taksitler", 300_690),
] {
app.post(
&format!("/periods/{id}/plan-items"),
s,
json!({ "kind": kind, "name": name, "amount": amount }),
)
.await;
}
id
}
async fn summary(app: &TestApp, s: &Session, pid: &str) -> Value {
let (st, b) = app.get(&format!("/periods/{pid}/summary"), s).await;
assert_eq!(st, StatusCode::OK, "{b}");
b
}
#[tokio::test]
async fn debt_month_uses_45_percent_rule() {
let app = TestApp::new().await;
let s = app.register("Sahip", None).await;
let pid = december(&app, &s).await;
// Borç yokken normal Kakeibo: gelir − sabit − birikim
let x = summary(&app, &s, &pid).await;
assert_eq!(x["debt_mode"], false);
assert_eq!(x["carried_debt_source"], Value::Null);
assert_eq!(x["spendable"], 7_950_964 - 2_300_690 - 500_000);
// Kasım sonu ölçümü (toplam 57.100) → otomatik devreden borç
app.post(
"/debt/snapshots",
&s,
json!({ "date": "2026-11-30", "kmh": 3_000_000, "card_balance": 2_710_000, "note": "maaş sonrası" }),
)
.await;
let x = summary(&app, &s, &pid).await;
assert_eq!(x["debt_mode"], true);
assert_eq!(
(
x["carried_debt"].as_i64(),
x["carried_debt_source"].as_str()
),
(Some(5_710_000), Some("auto"))
);
assert_eq!(x["carried_debt_date"], "2026-11-30");
assert_eq!(x["spendable_rate_bp"], 4500);
assert_eq!(
x["spendable"], 3_577_934,
"35.779,34 — birikim hedefi girmez"
);
assert_eq!(x["debt_payment"], 2_072_340, "20.723,40");
assert_eq!(x["closing_debt_target"], 3_637_660, "36.376,60");
assert_eq!(x["debt_fixed_too_high"], false);
assert_eq!(
x["unallocated"], 3_577_934,
"bütçe dağıtımı yeni harcanabilire göre"
);
// Elle düzeltme → manual; 10.000 → borca 10.000, birikime 10.723,40
let (st, _) = app
.put(
&format!("/periods/{pid}/opening-debt"),
&s,
json!({ "amount": 1_000_000 }),
)
.await;
assert_eq!(st, StatusCode::NO_CONTENT);
let x = summary(&app, &s, &pid).await;
assert_eq!(x["carried_debt_source"], "manual");
assert_eq!(
x["carried_debt_auto"], 5_710_000,
"ölçüm değeri elle düzeltmede de görünür"
);
assert_eq!(x["carried_debt_date"], "2026-11-30");
assert_eq!(
(x["debt_payment"].as_i64(), x["debt_to_savings"].as_i64()),
(Some(1_000_000), Some(1_072_340))
);
// Elle 0 → borç bitti, normal düzen (A6)
app.put(
&format!("/periods/{pid}/opening-debt"),
&s,
json!({ "amount": 0 }),
)
.await;
let x = summary(&app, &s, &pid).await;
assert_eq!(x["debt_mode"], false);
assert_eq!(x["spendable"], 7_950_964 - 2_300_690 - 500_000);
// Oran ayarlanabilir (%10–%90)
app.put(
&format!("/periods/{pid}/opening-debt"),
&s,
json!({ "amount": null }),
)
.await;
let (_, p) = app.get(&format!("/periods/{pid}"), &s).await;
let (st, p2) = app
.patch(
&format!("/periods/{pid}"),
&s,
json!({ "spendable_rate_bp": 5000, "version": p["version"] }),
)
.await;
assert_eq!(st, StatusCode::OK, "{p2}");
assert_eq!(p2["spendable_rate_bp"], 5000);
assert_eq!(summary(&app, &s, &pid).await["spendable"], 3_975_482);
let (st, _) = app
.patch(
&format!("/periods/{pid}"),
&s,
json!({ "spendable_rate_bp": 9500 }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
app.cleanup().await;
}
#[tokio::test]
async fn net_positive_measurement_means_no_debt_and_fixed_too_high_warns() {
let app = TestApp::new().await;
let s = app.register("Sahip", None).await;
let pid = december(&app, &s).await;
// Artı bakiye borçtan büyük → borç 0 → normal düzen
app.post(
"/debt/snapshots",
&s,
json!({ "date": "2026-11-30", "card_balance": 100_000, "positive_balance": 300_000 }),
)
.await;
let x = summary(&app, &s, &pid).await;
assert_eq!(
(x["debt_mode"].as_bool(), x["carried_debt"].as_i64()),
(Some(false), Some(0))
);
// Sabitler çok yüksek: harcanabilir = gelir − sabit, borca 0, uyarı
app.put(
&format!("/periods/{pid}/opening-debt"),
&s,
json!({ "amount": 5_710_000 }),
)
.await;
app.post(
&format!("/periods/{pid}/plan-items"),
&s,
json!({ "kind": "expense", "name": "Büyük ödeme", "amount": 3_000_000 }),
)
.await;
let x = summary(&app, &s, &pid).await;
assert_eq!(x["debt_fixed_too_high"], true);
assert_eq!(x["debt_payment"], 0);
assert_eq!(x["spendable"], 7_950_964 - 5_300_690);
assert_eq!(x["closing_debt_target"], 5_710_000);
app.cleanup().await;
}
#[tokio::test]
async fn closing_a_debt_month_creates_no_kakeibo_carryover() {
let app = TestApp::new().await;
let s = app.register("Sahip", None).await;
let (_, p) = app
.post(
"/periods",
&s,
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01", "savings_goal": 0 }),
)
.await;
let nov = p["id"].as_str().unwrap().to_string();
app.post(
&format!("/periods/{nov}/plan-items"),
&s,
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
)
.await;
app.put(
&format!("/periods/{nov}/opening-debt"),
&s,
json!({ "amount": 5_000_000 }),
)
.await;
let (st, c) = app
.post(
&format!("/periods/{nov}/close"),
&s,
json!({ "to_savings": 200_000 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{c}");
assert_eq!(c["to_savings"], 0, "borçlu ayda birikim aktarılmaz");
assert!(c["carryover"].is_null(), "Kakeibo devri yok: {c}");
let (_, p) = app
.post(
"/periods",
&s,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01" }),
)
.await;
let x = summary(&app, &s, p["id"].as_str().unwrap()).await;
assert_eq!(
(
x["carryover_debt"].as_i64(),
x["carryover_surplus"].as_i64()
),
(Some(0), Some(0))
);
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()
.map(|w| w["days"].as_i64().unwrap())
.collect();
assert_eq!(
days,
vec![6, 7, 7, 7, 4],
"Pazartesi başlangıçlı, kısmi haftalar ayrı"
);
assert_eq!(days, vec![8, 7, 7, 9], "1–8, 9–15, 16–22, 23–31");
let (_, cur) = app.get("/periods/current", &s).await;
assert_eq!(cur["id"], p["id"]);
+375
View File
@@ -0,0 +1,375 @@
//! Plan kalemleri ve notlar.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use mongodb::bson::{Document, doc};
use serde_json::{Value, json};
async fn open_period(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": start, "end_date": end }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
p["id"].as_str().unwrap().to_string()
}
async fn close_period(app: &TestApp, id: &str) {
let uuid = mongodb::bson::Uuid::parse_str(id).unwrap();
app.db
.collection::<Document>("periods")
.update_one(
doc! { "_id": uuid },
doc! { "$set": { "status": "closed" } },
)
.await
.unwrap();
}
fn names(list: &Value) -> Vec<String> {
list.as_array()
.unwrap()
.iter()
.map(|i| {
format!(
"{}:{}",
i["kind"].as_str().unwrap(),
i["name"].as_str().unwrap()
)
})
.collect()
}
#[tokio::test]
async fn plan_items_crud_and_validation() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
let items = format!("/periods/{pid}/plan-items");
// Gider önce eklense de liste gelirle başlar
let (st, rent) = app
.post(
&items,
&s,
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{rent}");
app.post(
&items,
&s,
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000 }),
)
.await;
app.post(
&items,
&s,
json!({ "kind": "expense", "name": "İnternet", "amount": 45_000 }),
)
.await;
let (_, list) = app.get(&items, &s).await;
assert_eq!(
names(&list),
vec!["income:Maaş", "expense:Kira", "expense:İnternet"]
);
assert_eq!(list[0]["date"], Value::Null);
// Doğrulamalar
for bad in [
json!({ "kind": "expense", "name": "X", "amount": 0 }),
json!({ "kind": "expense", "name": " ", "amount": 100 }),
json!({ "kind": "expense", "name": "X", "amount": 100, "date": "2027-01-01" }), // dönem dışı (bitiş hariç)
json!({ "kind": "expense", "name": "X", "amount": 100, "date": "05.12.2026" }),
json!({ "kind": "gift", "name": "X", "amount": 100 }),
] {
let (st, _) = app.post(&items, &s, bad.clone()).await;
assert_eq!(st, StatusCode::BAD_REQUEST, "{bad}");
}
// Düzenle: tutar + tarihi kaldır; eski sürüm → 409
let rent_path = format!("/plan-items/{}", rent["id"].as_str().unwrap());
let (st, upd) = app
.patch(
&rent_path,
&s,
json!({ "amount": 1_600_000, "date": "", "version": 1 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{upd}");
assert_eq!(
(upd["amount"].as_i64(), &upd["date"]),
(Some(1_600_000), &Value::Null)
);
let (st, _) = app
.patch(&rent_path, &s, json!({ "name": "Y", "version": 1 }))
.await;
assert_eq!(st, StatusCode::CONFLICT);
// Sil → listeden çıkar
assert_eq!(app.delete(&rent_path, &s).await.0, StatusCode::NO_CONTENT);
let (_, list) = app.get(&items, &s).await;
assert_eq!(names(&list), vec!["income:Maaş", "expense:İnternet"]);
app.cleanup().await;
}
#[tokio::test]
async fn copy_previous_shifts_dates_and_skips_duplicates() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
let dec_items = format!("/periods/{dec}/plan-items");
app.post(
&dec_items,
&s,
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000, "date": "2026-12-15" }),
)
.await;
app.post(
&dec_items,
&s,
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }),
)
.await;
app.post(
&dec_items,
&s,
json!({ "kind": "expense", "name": "Aidat", "amount": 80_000 }),
)
.await;
app.post(
&dec_items,
&s,
json!({ "kind": "expense", "name": "Fatura", "amount": 90_000, "date": "2026-12-31" }),
)
.await;
close_period(&app, &dec).await;
let jan = open_period(&app, &s, "2027-01-01", "2027-02-01").await;
let jan_items = format!("/periods/{jan}/plan-items");
// Kullanıcı kirayı elle zaten eklemiş (farklı tutarla) → kopyada atlanmalı
app.post(
&jan_items,
&s,
json!({ "kind": "expense", "name": "kira", "amount": 1_700_000 }),
)
.await;
let (st, res) = app
.post(&format!("{jan_items}/copy-previous"), &s, json!({}))
.await;
assert_eq!(st, StatusCode::OK, "{res}");
assert_eq!(res["added"], 3);
let (_, list) = app.get(&jan_items, &s).await;
let by_name = |n: &str| {
list.as_array()
.unwrap()
.iter()
.find(|i| i["name"] == n)
.unwrap()
.clone()
};
assert_eq!(by_name("Maaş")["date"], "2027-01-15", "göreli gün korunur");
assert_eq!(by_name("Aidat")["date"], Value::Null);
assert_eq!(by_name("kira")["amount"], 1_700_000, "elle eklenen korunur");
assert_eq!(by_name("Fatura")["date"], "2027-01-31");
// Ocak → Şubat (28 gün): 31 Ocak'taki fatura dönem dışına taşar → son güne (28 Şubat) çekilir
close_period(&app, &jan).await;
let feb = open_period(&app, &s, "2027-02-01", "2027-03-01").await;
let feb_items = format!("/periods/{feb}/plan-items");
app.post(&format!("{feb_items}/copy-previous"), &s, json!({}))
.await;
let (_, feb_list) = app.get(&feb_items, &s).await;
let fatura = feb_list
.as_array()
.unwrap()
.iter()
.find(|i| i["name"] == "Fatura")
.unwrap();
assert_eq!(fatura["date"], "2027-02-28");
// İkinci kez → hiçbir şey eklenmez (Ocak kapalı olduğu için Şubat'ta deniyoruz)
let (_, res) = app
.post(&format!("{feb_items}/copy-previous"), &s, json!({}))
.await;
assert_eq!(res["added"], 0);
// İlk dönemde önceki yok → 400
let (st, _) = app
.post(&format!("{dec_items}/copy-previous"), &s, json!({}))
.await;
assert!(
st == StatusCode::BAD_REQUEST || st == StatusCode::CONFLICT,
"{st}"
);
app.cleanup().await;
}
#[tokio::test]
async fn closed_period_is_read_only() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
let (_, item) = app
.post(
&format!("/periods/{pid}/plan-items"),
&s,
json!({ "kind": "income", "name": "Maaş", "amount": 100 }),
)
.await;
let (_, note) = app
.post(
&format!("/periods/{pid}/notes"),
&s,
json!({ "context": "planning", "title": "Hedef" }),
)
.await;
close_period(&app, &pid).await;
let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap());
let note_path = format!("/notes/{}", note["id"].as_str().unwrap());
let checks = [
app.post(
&format!("/periods/{pid}/plan-items"),
&s,
json!({ "kind": "income", "name": "X", "amount": 1 }),
)
.await
.0,
app.patch(&item_path, &s, json!({ "amount": 5 })).await.0,
app.delete(&item_path, &s).await.0,
app.post(
&format!("/periods/{pid}/notes"),
&s,
json!({ "context": "review", "title": "X" }),
)
.await
.0,
app.patch(&note_path, &s, json!({ "title": "Y" })).await.0,
app.delete(&note_path, &s).await.0,
];
assert!(
checks.iter().all(|st| *st == StatusCode::CONFLICT),
"{checks:?}"
);
// Okuma serbest
assert_eq!(
app.get(&format!("/periods/{pid}/plan-items"), &s).await.0,
StatusCode::OK
);
assert_eq!(
app.get(&format!("/periods/{pid}/notes"), &s).await.0,
StatusCode::OK
);
app.cleanup().await;
}
#[tokio::test]
async fn notes_crud_and_context_filter() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
let notes = format!("/periods/{pid}/notes");
let (st, n) = app
.post(&notes, &s, json!({ "context": "planning", "title": " Tatil birikimi ", "body_md": "- Ocak'ta **5.000 ₺**" }))
.await;
assert_eq!(st, StatusCode::CREATED, "{n}");
assert_eq!(n["title"], "Tatil birikimi");
app.post(&notes, &s, json!({ "context": "review", "title": "Ders" }))
.await;
let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await;
assert_eq!(planning.as_array().unwrap().len(), 1);
let (_, all) = app.get(&notes, &s).await;
assert_eq!(all.as_array().unwrap().len(), 2);
let (st, _) = app
.post(&notes, &s, json!({ "context": "planning", "title": "" }))
.await;
assert_eq!(st, StatusCode::BAD_REQUEST, "boş başlık");
let path = format!("/notes/{}", n["id"].as_str().unwrap());
let (st, upd) = app
.patch(&path, &s, json!({ "body_md": "Güncel", "version": 1 }))
.await;
assert_eq!(
(st, upd["body_md"].as_str()),
(StatusCode::OK, Some("Güncel"))
);
assert_eq!(app.delete(&path, &s).await.0, StatusCode::NO_CONTENT);
let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await;
assert_eq!(planning, json!([]));
app.cleanup().await;
}
#[tokio::test]
async fn plan_items_and_notes_are_isolated() {
let app = TestApp::new().await;
let a = app.register("A", None).await;
let b = app.register("B", None).await;
let pid = open_period(&app, &a, "2026-12-01", "2027-01-01").await;
let (_, item) = app
.post(
&format!("/periods/{pid}/plan-items"),
&a,
json!({ "kind": "income", "name": "Maaş", "amount": 100 }),
)
.await;
let (_, note) = app
.post(
&format!("/periods/{pid}/notes"),
&a,
json!({ "context": "planning", "title": "Gizli" }),
)
.await;
let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap());
let note_path = format!("/notes/{}", note["id"].as_str().unwrap());
let checks = [
app.get(&format!("/periods/{pid}/plan-items"), &b).await.0,
app.post(
&format!("/periods/{pid}/plan-items"),
&b,
json!({ "kind": "income", "name": "X", "amount": 1 }),
)
.await
.0,
app.post(
&format!("/periods/{pid}/plan-items/copy-previous"),
&b,
json!({}),
)
.await
.0,
app.patch(&item_path, &b, json!({ "amount": 1 })).await.0,
app.delete(&item_path, &b).await.0,
app.get(&format!("/periods/{pid}/notes"), &b).await.0,
app.patch(&note_path, &b, json!({ "title": "X" })).await.0,
app.delete(&note_path, &b).await.0,
];
assert!(
checks.iter().all(|st| *st == StatusCode::NOT_FOUND),
"{checks:?}"
);
// A'nın verisi değişmedi
let (_, list) = app.get(&format!("/periods/{pid}/plan-items"), &a).await;
assert_eq!(list[0]["amount"], 100);
assert_eq!(list[0]["version"], 1);
app.cleanup().await;
}
+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
+64
View File
@@ -0,0 +1,64 @@
> **Bu yaklaşım `docs/KAKEIBO_PANEL_ISTEKLERI.md` ile değiştirildi (14 Kasım 2026):** borç ayrı sayfada değil, aylık planın ve panelin içinde "Devreden borç" olarak izlenir. Koleksiyonlar ve veri yerinde kalır.
# 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 */,
name, role: "owner" | "member",
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`
```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_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 }
```
- `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
{ _id, household_id, name /* "Aralık 2026" */,
start_date: "2026-12-01", end_date: "2027-01-01", // bitiş HARİÇ
status: "open" | "closed",
status: "open" | "closed" | "correcting" | "archived",
savings_goal: 500000, // kuruş — birikim hedefi
reflection_md: "...", // "Nasıl daha iyi yapabilirim?"
closed_at, closed_by, reopened_at,
to_savings: 450000, // kapanışta birikime aktarılan (Faz 8'de varlık hareketi)
closed_at, closed_by, reopened_at, reopened_by,
previous_period_id,
created_at, updated_at, deleted_at, version }
```
- **Bitiş tarihi hariç:** `01.12.2026 – 01.01.2027` → 1–31 Aralık. Dönemler arka arkaya boşluksuz ve çakışmasız eklenir.
- Hanede en fazla **bir** `open` dönem (servis kontrolü + kısmi benzersiz indeks).
- Yeniden açılan dönemin durumu tekrar `open` olur. O sırada başka açık dönem varsa önce o kapatılmalı mı, yoksa geçici olarak iki açık dönem mi olsun? → **Açık konu, Faz 6'da netleşecek.**
- **Haftalar saklanmaz.** `start_date`/`end_date`'ten hesaplanır (Pazartesi başlangıçlı; ilk/son kısmi haftalar ayrı).
- **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 (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
```js
@@ -136,6 +142,15 @@ Renkler ve ikonlar frontend tasarım token'larında.
description: "Haftalık market",
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
```js
@@ -143,7 +158,8 @@ Renkler ve ikonlar frontend tasarım token'larında.
kind: "debt" | "surplus", amount: 120000,
created_at, ... }
```
- Dönem kapanışında oluşur. Dönem yeniden açılırsa soft delete edilir ve kapanışta yeniden hesaplanır.
- Dönem kapanışında oluşur. Sonraki dönem henüz yoksa `to_period_id: null`; o dönem açılınca (aynı transaction'da) bağlanır.
- Yeniden kapatmada eskisi soft delete edilir, yenisi yazılır (düzeltme süresince eskisi geçerli kalır).
### 3.11 `notes`
```js
@@ -158,8 +174,9 @@ Renkler ve ikonlar frontend tasarım token'larında.
kind: "cash" | "gold" | "fx",
code: "TRY" | "XAU_GRAM" | "XAU_CEYREK" | "XAU_YARIM" | "XAU_TAM" | "XAU_ONS" | "USD" | "EUR" | ...,
name: "Gram Altın", unit_label: "gr",
is_active, created_at, ... }
sort_order, is_active, created_at, ... }
```
- Varsayılanlar hane açılınca gelir (migration v2 eski hanelere ekler): TRY, XAU_GRAM, XAU_CEYREK, XAU_YARIM, XAU_TAM, XAU_ONS, USD, EUR. Kullanıcı yalnızca **döviz** (`fx`, 3 harfli kod) ekler; kod hanede tekil. Pasif varlık listede kalır, yeni işlem almaz.
### 3.13 `asset_transactions` — Alım / satım
```js
@@ -172,7 +189,10 @@ Renkler ve ikonlar frontend tasarım token'larında.
note, source_period_id /* dönem kapanışından geldiyse */,
created_at, ... }
```
- Ortalama maliyet ve kâr/zarar bu kayıtlardan hesaplanır, saklanmaz.
- Ortalama maliyet ve kâr/zarar bu kayıtlardan hesaplanır, saklanmaz: **ağırlıklı ortalama maliyet** (satışta çıkan maliyet = maliyet × satılan / eldeki; gerçekleşen kâr/zarar = satış tutarı − çıkan maliyet). Maliyet `total`'dan hesaplanır (birim fiyat bilgi amaçlı; verilmezse `total ÷ quantity`).
- Tarih sırasıyla (aynı gün: ekleniş sırası) hiçbir anda eldeki miktar eksiye düşemez; ekleme, düzenleme ve silmede yeniden denetlenir.
- TL varlığında `quantity_e6 = total × 10.000`, `unit_price_e6 = 1.000.000`.
- **Kapanıştan birikim:** kullanıcı onaylayınca (`POST /periods/{id}/to-savings`) dönemin `to_savings` tutarı TL varlığına `buy` olarak eklenir: tarih = dönemin son günü, `source_period_id` = dönem; dönem başına tek. Eklenmemiş kapalı dönemler `GET /savings/pending`. **Yeniden kapatmada** bağlı kayıt yeni tutar ve tarihe güncellenir (tutar 0 → silinir), kapatma transaction'ı içinde; TL'den sonraki çıkışlar yeni tutarı aşacaksa kapatma reddedilir (kullanıcı kararı "a", 2026-11-13). Bağlı kayıt yoksa otomatik eklenmez (onay gerekir).
### 3.14 `exchange_rates` — Kurlar
```js
@@ -182,7 +202,44 @@ Renkler ve ikonlar frontend tasarım token'larında.
rate_date: "2026-12-15", fetched_at,
created_at, ... }
```
- Güncel kur sırası: hanenin elle girdiği en yeni kur → en yeni otomatik kur.
- Güncel kur: kod başına **en yeni `rate_date`**; aynı gün hem otomatik hem manuel varsa manuel. (Eski bir manuel kur, daha yeni otomatik kurun önüne geçmez.) Kur = 1 birimin TL karşılığı × 1e6; döviz **alış** kuru (değerleme).
- Otomatik kurlar gün başına tek kayıt (kod × kaynak × gün; aynı gün yeniden çekilince güncellenir) → günlük geçmiş birikir. TCMB `rate_date` = bülten tarihi; altın = Türkiye'de bugün.
- Altın: xaus.com gram (saf) USD × TCMB USD alış; çeyrek/yarım/tam = has gram (1,603 / 3,206 / 6,412) × gram, ons = 31,1034768 gr. İşçilik payı yok → gerekirse manuel.
### 3.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`
```js
@@ -223,7 +280,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
## 6. Dönem Kapanışı (tek transaction)
1. `periods.status = "closed"`, `closed_at`, `closed_by`
2. `carryovers` kaydı (borç veya artı) → sonraki dönem
3. (Onaylıysa) gerçekleşen birikim → `asset_transactions` (`TRY`, `buy`, `source_period_id`)
3. Gerçekleşen birikimin `to_savings` kadarı birikime: şimdilik `periods.to_savings`; Faz 8'de → `asset_transactions` (`TRY`, `buy`, `source_period_id`)
4. (İstenirse) yeni dönem: `plan_items` ve aylık `budgets` kopyalanarak açılır
Transaction'ın bir adımı hata verirse önceki adımlar da geri alınır, hiçbir şey değişmez.

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