Compare commits

..

48 Commits

Author SHA1 Message Date
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
suphicakir b883530f76 feat(dönem): dönem açma, açık dönem, düzenleme ve haftalar (backend)
- /api/periods: liste, açık dönem, aç, detay, düzenle
- Kurallar: tek açık dönem, çakışma yok, bitiş hariç, en fazla 62 gün, kapalı dönem salt okunur
- Tarih daraltılırken dışarıda harcama kalırsa engellenir
- Haftalar Pazartesi başlangıçlı, kısmi haftalar ayrı (takvim yardımcıları)
- Otomatik dönem adı: "Aralık 2026" / "15 Ara 2026 – 14 Oca 2027"
- Testler: takvim (4), dönem kuralları (2), entegrasyon (5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:56:13 +03:00
suphicakir a2b4119f3e docs(dağıtım): Dokploy kurulumu ve ilk canlı yayın; Faz 3 tamamlandı
- DEPLOY.md: Dokploy uygulama ayarları, sık hatalar, ilk yayın kontrolü
- Canlı: https://kakeibo.ta1sph.website (health ok, Let's Encrypt)
- Yedekleme ertelendi (açık konu: Faz 5 bitmeden)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:48:23 +03:00
suphicakir f86cff4755 docs(dağıtım): canlı MongoDB kurulumu ve açık konular
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:16:00 +03:00
suphicakir ad90551c6d feat(dağıtım): Docker imajı ve Axum'un arayüzü sunması
- Çok aşamalı Dockerfile: Node (arayüz) → Rust (release) → debian-slim, root olmayan kullanıcı
- Axum, STATIC_DIR tanımlıysa derlenmiş arayüzü sunar (SPA yönlendirmesi)
- Önbellek başlıkları (HTML no-cache, /assets 1 yıl), gzip/brotli sıkıştırma, istek logları
- Bilinmeyen /api/... → JSON 404; SIGTERM'de düzgün kapanış
- HEALTHCHECK (/api/health), .dockerignore, docs/DEPLOY.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:26:34 +03:00
suphicakir e188922c0f feat(kayıt): yeni hane kaydı yalnızca davetle (REGISTRATION=invite_only)
- REGISTRATION ortam değişkeni: invite_only (varsayılan) | open
- invite_only: ilk hane serbest, sonrasında yalnızca davet koduyla katılım
- Backend'de zorunlu: 403 registration_closed; GET /api/auth/registration
- Kayıt ekranı: kayıt kapalıyken yalnızca davet kodu ve açıklama
- Entegrasyon testleri: kayıt politikası (2)
- Prettier ayarı eklendi (proje stiliyle uyumlu), kaynaklar biçimlendirildi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:04:48 +03:00
suphicakir 9cbeaaaf76 docs(api): api.http'de hazır hesaplar ve kayıt örneği netleştirildi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:55:42 +03:00
suphicakir b269411823 feat(giriş): giriş ve kayıt ekranları
- Giriş: satır içi doğrulama, şifre göster/gizle, beni hatırla, hoş geldin bildirimi
- Kayıt: yeni hane kur / davetle katıl, /kayit?davet=KOD bağlantısı
- Sunucu hataları ilgili alanda gösterilir (kayıtlı e-posta, geçersiz davet)
- react-hook-form + zod, sonner bildirimleri, shadcn input/label/checkbox/card
- Ortak form bileşenleri (FormField, PasswordInput, FormError)
- 8 form kuralı testi (toplam 47); api.http demo hesaplara güncellendi
- Faz 2 tamamlandı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:52:13 +03:00
suphicakir c6d4c30461 feat(arayüz): sayfa yönlendirme, korumalı sayfalar ve uygulama iskeleti
- react-router: Türkçe adresler (/defter, /plan, /birikim, ...), 404 sayfası
- Korumalı sayfalar: oturum yoksa /giris?donus=..., girişten sonra geri dönüş
- Açık yönlendirme koruması (safeReturnPath, 10 test)
- İskelet: mobilde 5 öğeli alt menü, masaüstünde sol kenar çubuğu
- Diğer sayfası (değerlendirme, raporlar, ayarlar, tema, çıkış)
- Arayüz metinleri lib/strings.ts'de toplanmaya başlandı
- Tasarım önizlemesi /dev/tasarim (yalnızca geliştirmede)
- Düzeltme: çıkışta önbellek temizliği ekranı güncellemiyordu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:46:17 +03:00
suphicakir 906f05f62f feat(arayüz): API istemcisi, TanStack Query, ts-rs tip üretimi ve oturum durumu
- Backend: ts-rs ile DTO'lardan TypeScript tipleri (frontend/src/types, npm run types)
- Hata ve health yanıtları tipli yapılara çevrildi
- lib/api.ts: tek API istemcisi, Türkçe ApiError, ağ hatası mesajı
- lib/query.ts: QueryClient (4xx tekrar denenmez, 401 → oturum kapanır)
- lib/auth.ts: useMe, useLogin, useRegister, useLogout
- Vitest: 7 API istemcisi testi (toplam 29)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:39:56 +03:00
suphicakir f9be08114d feat(arayüz): logo ve uygulama ikonları
- Logo: indigo zemin, spiral defter ve ¥ (frontend/brand/*.svg)
- scripts/icons.sh (npm run icons): favicon (svg/ico), PWA 192/512, maskable, Apple touch icon, Tauri 1024
- Logo bileşeni (components/logo.tsx)
- index.html: ikon bağlantıları, theme-color, açıklama

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:30:39 +03:00
suphicakir 440523b176 feat(arayüz): Tailwind + shadcn/ui tasarım sistemi, tema ve biçimlendirme
- Tailwind v4 + shadcn/ui (Radix), Inter yazı tipi (yerel)
- Tasarım token'ları: 4 ana grup (renk körlüğüne uygun) ve durum renkleri, WCAG AA
- Aydınlık / Karanlık / Sistem teması (ilk açılışta parlama yok)
- lib/format.ts: para (1.250,00 ₺), miktar, yüzde, tarih (GG.AA.YYYY), tutar ayrıştırma
- Vitest: 22 biçimlendirme testi; npm test artık frontend'i de çalıştırır
- Butonlar mobil dokunma alanına (≥44 px) göre büyütüldü

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:06:27 +03:00
suphicakir 6eb5440d4d feat(katalog): kategori ve ödeme türü uç noktaları, hane izolasyonu entegrasyon testleri
- /api/categories ve /api/payment-methods: listele, ekle, düzenle, sırala, pasife al
- Sürüm kontrolü ile eşzamanlı düzenleme çakışması (409)
- Backend lib + bin yapısına ayrıldı (entegrasyon testleri için)
- tests/: hane izolasyonu (3) ve katalog (4) entegrasyon testleri
- db:reset artık artık test veritabanlarını da siler
- api.http tamamlandı; Faz 1 tamamlandı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:54:16 +03:00
suphicakir 4cc4fa5882 feat(hane): davet kodu ile üye katılımı ve hane izolasyonu
- AuthUser her istekte kullanıcıyı DB'den doğrular (silinen kullanıcı → 401)
- require_owner: yalnızca hane sahibine açık işlemler
- Davet kodu: oluştur, listele, iptal et (7 gün, tek kullanım)
- Davet koduyla kayıt → mevcut haneye member olarak katılma (transaction)
- GET /api/household: hane bilgisi ve üyeler
- api.http güncellendi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:43:16 +03:00
suphicakir d6136959b1 feat(auth): kayıt, giriş, çıkış ve oturum (argon2 + JWT httpOnly cookie)
- Kayıt: kullanıcı + hane + örnek kategori/ödeme türü tek transaction'da
- Giriş: beni hatırla (30 gün / oturum), e-posta başına deneme sınırı
- AuthUser extractor: cookie'deki JWT'yi doğrular
- ValidJson: JSON + doğrulama, Türkçe hata mesajları
- api.http: auth istekleri eklendi
- Birim testleri: şifre, JWT, deneme sınırı, seed (11 test)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:37:52 +03:00
suphicakir 4103b90e4b docs: push akışı güncellendi (push kullanıcı tarafından yapılır)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:30:24 +03:00
suphicakir 5550c7c43d feat(backend): migration mekanizması, indeksler ve ortak model yardımcıları
- db/migrations.rs: schema_migrations ile sürümlü migration (v1: koleksiyonlar)
- db/indexes.rs: DATA_MODEL indeksleri + senkronizasyon indeksi, açılışta otomatik
- models/common.rs: UUID v7, ortak alanlar, hane kapsamlı filtre
- Birim testleri (4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:25:55 +03:00
suphicakir 8ec24ecec2 updated package.json
updated package.json
2026-11-11 23:16:36 +03:00
suphicakir 125bb7f476 feat(backend): ayarlar, MongoDB bağlantısı, Türkçe hata yapısı ve DB'li health check
- config.rs: ortam değişkenleri ve doğrulama
- db.rs: bağlantı + ping (5 sn zaman aşımı)
- error.rs: AppError → Türkçe JSON hata yanıtı
- /api/health: veritabanına erişilemezse 503

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:09:46 +03:00
suphicakir 306c550b28 docs(veri-modeli): veri modeli v1 onaylandı, Faz 0 tamamlandı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:05:26 +03:00
349 changed files with 41363 additions and 176 deletions
+11
View File
@@ -0,0 +1,11 @@
# Docker build bağlamına girmeyecekler (hız + gizli bilgi sızmasın)
.git
.env
.env.*
!.env.example
**/node_modules
**/target
**/dist
backups/
.vscode/
*.log
+13
View File
@@ -9,8 +9,21 @@ JWT_SECRET=degistir-beni-en-az-32-karakterlik-gizli-anahtar
# Uygulamanın erişildiği adres
APP_URL=http://localhost:5173
# Yeni hane kaydı: invite_only (varsayılan, önerilen) | open
# invite_only → sistemde hiç hane yokken ilk kayıt yeni hane kurar (sunucu sahibi),
# sonra yalnızca davet koduyla katılınır.
# open → herkes /kayit ekranından yeni hane kurabilir.
REGISTRATION=invite_only
# Backend portu
PORT=8080
# Log seviyesi
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
+24 -4
View File
@@ -10,7 +10,7 @@ Tam gereksinimler: `KAKEIBO_PROMPT.md` (yalnızca ilgili bölümü oku).
- Değişikliklerde dosyayı baştan yazma; yalnızca değişen kısmı düzenle.
- Yanıt formatı: ne yapıldı → nasıl test edilir → sıradaki adım.
- Her adım sonunda `docs/MEMORY.md`'ye 3–5 satır not ekle.
- Her faz sonunda commit + `git push origin main` (Claude yapar).
- Her adım sonunda Claude commit atar ve "push zamanı" diye haber verir; push'u kullanıcı kendi uygulamasıyla yapar (Claude `git push` çalıştırmaz).
- Bir hata 2 denemede çözülmezse dur, seçenek sun.
- Kullanıcı Rust'a yeni: kavramları ihtiyaç anında, kısa örnekle açıkla.
- Her yeni bağımlılık için kısa gerekçe ver.
@@ -53,14 +53,34 @@ npm run dev # frontend (5173) + backend (8080) birlikte, hot reload
npm run dev:host # telefondan test için ağa aç
npm run db:up # yerel MongoDB (Docker)
npm run db:down
npm run db:seed / db:reset
npm test # backend testleri
npm run db:seed
npm run db:reset # kakeibo_dev + artık kakeibo_test_* veritabanlarını siler
npm test # backend (cargo test, MongoDB açık olmalı) + frontend (vitest)
npm run lint # clippy + oxlint
npm run types # Rust DTO → frontend/src/types/*.ts (ts-rs; cargo test de üretir)
npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonları (rsvg-convert + magick)
npm run db:backup # kakeibo_dev → ./backups (canlı yedek: docs/DEPLOY.md §6)
npm run db:restore -- <dosya|latest> <hedef_db> [--force]
```
- `npm run dev` öncesinde DB otomatik başlar (`predev`).
- Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler).
- Frontend lint: **oxlint** (Vite şablonu varsayılanı; ESLint yerine).
- 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`. SVG/JS'te (Recharts vb.) ham değişken kullan: `var(--needs)`, `var(--primary)` — `@theme inline` yüzünden `--color-*` derlenmiş CSS'te olmayabilir. Tutarlarda `money` sınıfı. Butonlar ≥44 px (shadcn boyutları büyütüldü).
- API: `lib/api.ts` (`api.get/post/...`, hata → `ApiError` Türkçe `message`), `lib/query.ts` (QueryClient + `queryKeys`), `lib/auth.ts` (`useMe` → undefined/null/MeResponse, `useLogin`/`useRegister`/`useLogout`).
- Tipler: `frontend/src/types/` **elle düzenlenmez**; backend'de DTO'ya `#[derive(TS)] #[ts(export)]` ekle → `npm run types`. İstek yapılarında `optional_fields`.
- Sayfalar: `src/router.tsx`; adresler `components/layout/nav-items.ts` (`paths`); sayfa bileşenleri `features/<alan>/page.tsx`; iskelet `components/layout/app-layout.tsx`. Sayfa başlığı `PageHeader` (sekme başlığını da ayarlar). Metinler `lib/strings.ts` (`t`).
- Yönlendirme: `RequireAuth` (oturum yok → `/giris?donus=`), `RequireGuest` (oturum var → `donus` ya da panel; formlar ayrıca navigate etmez). `donus` daima `safeReturnPath` ile.
- Geliştirmede tasarım önizlemesi: `/dev/tasarim`.
- Formlar: react-hook-form + zod (`mode: 'onTouched'`), alanlar `components/form-field.tsx` (`FormField`, `PasswordInput`, `FormError`); sunucu hatası `setError` ile ilgili alana. Bildirim: `toast` (sonner, `components/toaster.tsx`).
- Dönem verisi: `lib/periods.ts` (Query kancaları), tarih `lib/period-dates.ts` (arayüz son günü dahil gösterir, backend `end_date` hariç), tutar alanı `MoneyInput` + `moneyText`/`toKurus` (`lib/money-schema.ts`). İlk dönem sihirbazı `/plan/donem-ac?adim=`. Plan düzenleme: taslak → `diffPlan` → `useSavePlan`; haftalık önizleme `lib/budget-preview.ts` (backend hesabıyla aynı tutulmalı).
- Defter: `/defter?hafta=N`, `features/ledger/` (hafta seçici, hızlı giriş), saf hesaplar `lib/ledger.ts`, kancalar `lib/entries.ts` + `lib/catalog.ts`. Kayıt değişince `queryKeys.period(id)` yenilenir (özet/bütçe `spent` dahil).
- Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`.
- API örnekleri: `backend/api.http`
- Canlı: `Dockerfile` (tek imaj), `docs/DEPLOY.md` (ortam değişkenleri, Dokploy, MongoDB, yedek). `routes::app()` = API + `STATIC_DIR` varsa SPA (`routes/spa.rs`) + sıkıştırma + TraceLayer. Testler `routes::router()` kullanır.
- 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(..)`.
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
## Ortam
- Geliştirme makinesi: CachyOS (Arch), fish kabuğu, VS Code
+42
View File
@@ -0,0 +1,42 @@
# Kakeibo — canlı ortam imajı (tek servis: Axum hem /api'yi hem arayüzü sunar)
# Yerelde deneme: docker build -t kakeibo . && docker run --rm -p 8090:8080 --env-file .env kakeibo
# ─── 1) Arayüz (React + Vite) ─────────────────────────────────────────────
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 ./
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
# ─── 2) Backend (Rust, release) ───────────────────────────────────────────
FROM rust:1.99-slim-trixie AS api
WORKDIR /app/backend
# Bağımlılıkları ayrı katmanda derle (kod değişince yeniden derlenmez)
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*
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
# ─── 3) Çalışma imajı (küçük, root olmayan kullanıcı) ─────────────────────
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --no-create-home kakeibo
COPY --from=api /app/backend/target/release/kakeibo /usr/local/bin/kakeibo
COPY --from=web /app/frontend/dist /app/static
ENV STATIC_DIR=/app/static \
PORT=8080 \
RUST_LOG=kakeibo=info,tower_http=info
EXPOSE 8080
USER kakeibo
# Dokploy/Docker sağlık kontrolü (uygulama + veritabanı)
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://localhost:${PORT}/api/health || exit 1
CMD ["kakeibo"]
+3
View File
@@ -0,0 +1,3 @@
# ts-rs: Rust DTO'larından üretilen TypeScript tipleri buraya yazılır (npm run types)
[env]
TS_RS_EXPORT_DIR = { value = "../frontend/src/types", relative = true }
+2880 -5
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -4,9 +4,26 @@ version = "0.1.0"
edition = "2024"
[dependencies]
argon2 = "0.6.0"
axum = "0.8.9"
axum-extra = { version = "0.12.6", features = ["cookie"] }
dotenvy = "0.15.7"
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
mongodb = "3.10.0"
quick-xml = { version = "0.42.0", features = ["serialize"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
thiserror = "2.0.21"
time = "0.3.55"
tokio = { version = "1.54.1", features = ["full"] }
tower-http = { version = "0.7.1", features = ["fs", "compression-gzip", "compression-br", "trace"], default-features = false }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
ts-rs = "12.0.1"
uuid = { version = "1.29.2", features = ["v4", "v7"] }
validator = { version = "0.21.0", features = ["derive"] }
[dev-dependencies]
http-body-util = "0.1.5"
tower = { version = "0.5.3", features = ["util"] }
+393 -1
View File
@@ -1,7 +1,399 @@
### Kakeibo API — örnek istekler (VS Code REST Client eklentisi)
### Satırın üstündeki "Send Request" bağlantısına tıklayın.
### ┌──────────────────────────────────────────────────────────────────────┐
### │ GİRİŞ İÇİN HAZIR HESAPLAR (yalnızca yerel geliştirme veritabanı): │
### │ demo@ornek.com / demo12345 → hane sahibi (Demo Hanesi) │
### │ es@ornek.com / demo12345 → üye (aynı hane) │
### └──────────────────────────────────────────────────────────────────────┘
@baseUrl = http://localhost:8080/api
### Sağlık kontrolü
### Sağlık kontrolü (DB erişilemezse 503 + "db": "error")
GET {{baseUrl}}/health
# ─────────────────────────── Kimlik doğrulama ───────────────────────────
# REST Client cookie'leri otomatik saklar: kayıt/giriş sonrası diğer istekler oturumlu gider.
### KAYIT ÖRNEĞİ — bu bir hesap DEĞİL, hesap OLUŞTURMA isteğidir.
### Hesap ancak bu isteği gönderince oluşur (kullanıcı + hane + örnek kategoriler/ödeme türleri).
### Aynı e-postayla ikinci kez gönderirseniz 409 "zaten kayıtlı" alırsınız.
### REGISTRATION=invite_only (varsayılan): sistemde zaten hane varsa davetsiz kayıt 403 "registration_closed" döner
### → aşağıdaki "Davet koduyla kayıt" örneğini kullanın (kodu hane sahibi oluşturur).
POST {{baseUrl}}/auth/register
Content-Type: application/json
{
"name": "Suphi",
"email": "yeni@ornek.com",
"password": "gizli-sifre-123",
"household_name": "Çakır Hanesi"
}
### Davet koduyla kayıt (mevcut haneye "member" olarak katılır; household_name yok sayılır)
# Kod büyük/küçük harf ve tireden bağımsızdır: "abcd-efgh" = "ABCDEFGH"
POST {{baseUrl}}/auth/register
Content-Type: application/json
{
"name": "Ayşe",
"email": "ayse@ornek.com",
"password": "gizli-sifre-123",
"invite_code": "BURAYA-KOD"
}
### Giriş ("remember_me": true → 30 gün, false → tarayıcı kapanınca biter)
POST {{baseUrl}}/auth/login
Content-Type: application/json
{
"email": "demo@ornek.com",
"password": "gizli-sifre-123",
"remember_me": true
}
### Kayıt politikası: yeni hane kurulabilir mi? (oturum gerektirmez)
GET {{baseUrl}}/auth/registration
### Ben kimim? (oturum yoksa 401)
GET {{baseUrl}}/auth/me
### Çıkış
POST {{baseUrl}}/auth/logout
# ─────────────────────────── Hane ve davetler ───────────────────────────
### Hane bilgisi ve üyeler
GET {{baseUrl}}/household
### Yeni davet kodu (yalnızca hane sahibi; 7 gün geçerli, tek kullanımlık)
POST {{baseUrl}}/household/invites
### Aktif davetler (yalnızca hane sahibi)
GET {{baseUrl}}/household/invites
### Daveti iptal et (yalnızca hane sahibi) — id'yi yukarıdaki listeden alın
DELETE {{baseUrl}}/household/invites/BURAYA-ID
# ─────────────────────────── Kategoriler ───────────────────────────
# Gruplar: needs (Zorunlu) · wants (İstek) · culture (Kültürel) · unexpected (Beklenmeyen)
# Silme yok; "is_active": false ile pasife alınır.
### Kategoriler (aktif olanlar; pasifler için ?include_inactive=true)
GET {{baseUrl}}/categories
### Kategori ekle (grubun sonuna eklenir)
POST {{baseUrl}}/categories
Content-Type: application/json
{
"group": "needs",
"name": "Aidat",
"emoji": "🏢"
}
### Kategori düzenle / pasife al — "version": listede gördüğünüz sürüm (başkası değiştirdiyse 409)
PATCH {{baseUrl}}/categories/BURAYA-ID
Content-Type: application/json
{
"name": "Site Aidatı",
"is_active": true,
"version": 1
}
### Grup içi sıralama — gruptaki TÜM kimlikler (pasifler dahil), istenen sırada
PUT {{baseUrl}}/categories/order
Content-Type: application/json
{
"group": "culture",
"ids": ["ID-1", "ID-2", "ID-3"]
}
# ─────────────────────────── Ödeme türleri ───────────────────────────
### Ödeme türleri
GET {{baseUrl}}/payment-methods
### Ödeme türü ekle
POST {{baseUrl}}/payment-methods
Content-Type: application/json
{
"name": "Yemek Kartı",
"emoji": "🍽️"
}
### Ödeme türü düzenle / pasife al
PATCH {{baseUrl}}/payment-methods/BURAYA-ID
Content-Type: application/json
{
"is_active": false
}
### Ödeme türü sıralama — TÜM kimlikler
PUT {{baseUrl}}/payment-methods/order
Content-Type: application/json
{
"ids": ["ID-1", "ID-2", "ID-3", "ID-4"]
}
# ─────────────────────────── Dönemler ───────────────────────────
# Bitiş tarihi HARİÇ: Aralık 2026 = "2026-12-01" → "2027-01-01". Hanede tek açık dönem.
### Açık dönem (yoksa null)
GET {{baseUrl}}/periods/current
### Tüm dönemler (en yeni başta)
GET {{baseUrl}}/periods
### Dönem aç — ad boşsa otomatik ("Aralık 2026"); savings_goal kuruş (5.000,00 ₺ = 500000)
POST {{baseUrl}}/periods
Content-Type: application/json
{
"start_date": "2026-12-01",
"end_date": "2027-01-01",
"savings_goal": 500000
}
### Dönem detayı (haftalar dahil)
GET {{baseUrl}}/periods/BURAYA-ID
### Dönemi düzenle — "version": son gördüğünüz sürüm (başkası değiştirdiyse 409)
PATCH {{baseUrl}}/periods/BURAYA-ID
Content-Type: application/json
{
"savings_goal": 750000,
"version": 1
}
# ─────────────────────────── Plan kalemleri (sabit gelir / gider) ───────────────────────────
# kind: "income" | "expense" · amount kuruş (> 0) · date isteğe bağlı, dönem içinde
### Dönemin kalemleri (önce gelirler)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items
### Kalem ekle
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items
Content-Type: application/json
{
"kind": "expense",
"name": "Kira",
"amount": 1500000,
"date": "2026-12-05"
}
### Önceki dönemden kopyala (aynı tür + ad varsa atlanır; tarihler göreli günle taşınır)
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items/copy-previous
### Kalemi düzenle ("date": "" → tarihi kaldır)
PATCH {{baseUrl}}/plan-items/BURAYA-KALEM-ID
Content-Type: application/json
{
"amount": 1600000,
"version": 1
}
### Kalemi sil
DELETE {{baseUrl}}/plan-items/BURAYA-KALEM-ID
# ─────────────────────────── Notlar ───────────────────────────
# context: "planning" (Plan sayfası) | "review" (Ay sonu değerlendirmesi) · body_md: Markdown
### Notlar (?context=planning ile filtre)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/notes?context=planning
### Not ekle
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/notes
Content-Type: application/json
{
"context": "planning",
"title": "Tatil birikimi",
"body_md": "- Ocak'ta **5.000 ₺** ayır\n- Dışarıda yemeği azalt"
}
### Notu düzenle
PATCH {{baseUrl}}/notes/BURAYA-NOT-ID
Content-Type: application/json
{
"body_md": "Güncel metin",
"version": 1
}
### Notu sil
DELETE {{baseUrl}}/notes/BURAYA-NOT-ID
# ─────────────────────────── Bütçe ve özet ───────────────────────────
# Gruplar: needs · wants · culture · unexpected · tutarlar kuruş
### Dönem özeti: gelir − sabit gider − birikim − devreden borç + devreden artı = harcanabilir
### + spent_total (gerçekleşen harcama), remaining (harcanabilir − harcanan)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/summary
### Bütçe: 4 grup, aylık + haftalık döküm (haftalar gün sayısına göre); her grup ve haftada `spent`
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/budget
### Aylık bütçeleri kaydet (4 grup birlikte)
PUT {{baseUrl}}/periods/BURAYA-DONEM-ID/budget
Content-Type: application/json
{
"needs": 1500000,
"wants": 600000,
"culture": 200000,
"unexpected": 200000
}
### Bir haftayı elle ayarla (week_start = haftanın ilk günü); "amount": null → otomatiğe döner
PUT {{baseUrl}}/periods/BURAYA-DONEM-ID/budget/weeks
Content-Type: application/json
{
"group": "needs",
"week_start": "2026-12-07",
"amount": 400000
}
### Bütçe önerisi — method: default (varsayılan yüzdeler) | previous (önceki dönem) | avg3 (son 3 dönem)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/budget/suggestion?method=default
# ─────────────────────── Günlük defter (harcamalar) ───────────────────────
# Kategori / ödeme türü kimlikleri: GET /categories, GET /payment-methods · tutarlar kuruş
### Dönemin harcamaları (en yeni başta)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/entries
### Bir haftanın harcamaları (from/to dahil)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/entries?from=2026-12-07&to=2026-12-13
### Harcama ekle — amount kuruş (345,50 ₺ = 34550); grup kategoriden gelir; ödeme türü ve açıklama isteğe bağlı
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/entries
Content-Type: application/json
{
"date": "2026-12-09",
"amount": 34550,
"category_id": "BURAYA-KATEGORI-ID",
"payment_method_id": "BURAYA-ODEME-TURU-ID",
"description": "Haftalık market"
}
### Harcamayı düzenle — "payment_method_id": "" / "description": "" → kaldır
PATCH {{baseUrl}}/entries/BURAYA-KAYIT-ID
Content-Type: application/json
{
"amount": 36000,
"version": 1
}
### Harcamayı sil
DELETE {{baseUrl}}/entries/BURAYA-KAYIT-ID
### Silmeyi geri al ("↩️ Geri al")
POST {{baseUrl}}/entries/BURAYA-KAYIT-ID/restore
# ─────────────────────────── Ay sonu değerlendirmesi ───────────────────────────
### Değerlendirme: 4 soru (available, savings_goal, spent_total, realized_savings) + grup/kategori/hafta
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/review
### "Nasıl daha iyi yapabilirim?" (Markdown; boş metin → kaldır; yalnızca açık dönem)
PATCH {{baseUrl}}/periods/BURAYA-DONEM-ID
Content-Type: application/json
{
"reflection_md": "- Dışarıda yemeği haftada 1'e indir\n- Market listesiyle alışveriş"
}
### Dönemi kapat: to_savings verilmezse gerçekleşen birikimin tamamı birikime; kalanı artı, açık varsa borç devreder
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/close
Content-Type: application/json
{
"to_savings": 500000
}
### Kapalı dönemi düzeltmeye aç (yalnızca hane sahibi) → status: correcting; düzeltince yeniden "close"
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/reopen
# ─────────────────────────── Panel ───────────────────────────
### Günlük harcama: dönemin her günü, 4 grup (harcama yoksa 0)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/daily
### Son dönemler (eskiden yeniye): harcanabilir, harcanan, birikime aktarılan + toplam birikim
GET {{baseUrl}}/stats/periods?limit=12
# ─────────────────────────── Birikimler ───────────────────────────
# Miktar/fiyat × 1e6 (5 gr → 5000000), tutar kuruş. TL varlığında yalnızca total yeter.
### Varlıklar + eldeki miktar, maliyet, ortalama maliyet, gerçekleşen kâr/zarar
GET {{baseUrl}}/assets
### Döviz ekle
POST {{baseUrl}}/assets
Content-Type: application/json
{ "code": "GBP", "name": "İngiliz Sterlini", "unit_label": "£" }
### Alım (birim fiyat verilmezse tutar ÷ miktar)
POST {{baseUrl}}/assets/BURAYA-VARLIK-ID/transactions
Content-Type: application/json
{ "type": "buy", "date": "2026-11-13", "quantity_e6": 5000000, "total": 3130000, "note": "Kuyumcu" }
### İşlem geçmişi (en yeni başta)
GET {{baseUrl}}/assets/BURAYA-VARLIK-ID/transactions
### İşlemi düzenle / sil
PATCH {{baseUrl}}/asset-transactions/BURAYA-ISLEM-ID
Content-Type: application/json
{ "total": 3150000, "version": 1 }
### Portföy: varlıklar + güncel kur, değer, kâr/zarar + toplamlar + son kur çekimi
GET {{baseUrl}}/portfolio
### Elle kur (TL × 1e6; tarih boşsa bugün). Aynı gün otomatik kur varsa elle girilen geçerli.
POST {{baseUrl}}/rates
Content-Type: application/json
{ "code": "XAU_CEYREK", "rate_e6": 10250000000 }
### Elle kuru sil → otomatik kura dön
DELETE {{baseUrl}}/rates/BURAYA-KUR-ID
### Kapanıp birikime henüz eklenmemiş dönemler
GET {{baseUrl}}/savings/pending
### Dönemin kapanıştaki birikimini TL varlığına ekle (dönem başına bir kez)
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/to-savings
# ─────────────────────────── Raporlar ───────────────────────────
### Harcama kırılımı: grup, tüm kategoriler, ödeme türü, ay (filtreler isteğe bağlı)
GET {{baseUrl}}/reports/breakdown?from=2026-01-01&to=2026-12-31
### Bir dönemde, yalnızca Zorunlu, ödeme türü girilmemiş olanlar
GET {{baseUrl}}/reports/breakdown?period_id=BURAYA-DONEM-ID&group=needs&payment_method_id=none
# ─────────────────────────── 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
View File
+104
View File
@@ -0,0 +1,104 @@
//! Oturum token'ı (JWT, HS256).
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use crate::{
error::{AppError, AppResult},
models::user::Role,
};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
/// Kullanıcı kimliği
pub sub: String,
/// Hane kimliği
pub hid: String,
pub role: Role,
pub iat: u64,
pub exp: u64,
}
pub struct Jwt {
enc: EncodingKey,
dec: DecodingKey,
}
impl Jwt {
pub fn new(secret: &str) -> Self {
Self {
enc: EncodingKey::from_secret(secret.as_bytes()),
dec: DecodingKey::from_secret(secret.as_bytes()),
}
}
pub fn issue(
&self,
user_id: Uuid,
household_id: Uuid,
role: Role,
ttl: Duration,
) -> AppResult<String> {
let now = unix_now();
let claims = Claims {
sub: user_id.to_string(),
hid: household_id.to_string(),
role,
iat: now,
exp: now + ttl.as_secs(),
};
encode(&Header::default(), &claims, &self.enc)
.map_err(|e| AppError::Internal(format!("JWT üretilemedi: {e}")))
}
/// İmza ve süre geçerliyse claim'leri döner.
pub fn verify(&self, token: &str) -> Option<Claims> {
decode::<Claims>(token, &self.dec, &Validation::default())
.ok()
.map(|t| t.claims)
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::common::new_id;
const SECRET: &str = "test-secret-en-az-32-karakter-uzunlugunda!!";
#[test]
fn roundtrip() {
let jwt = Jwt::new(SECRET);
let (uid, hid) = (new_id(), new_id());
let token = jwt
.issue(uid, hid, Role::Owner, Duration::from_secs(60))
.unwrap();
let c = jwt.verify(&token).unwrap();
assert_eq!(c.sub, uid.to_string());
assert_eq!(c.hid, hid.to_string());
assert_eq!(c.role, Role::Owner);
}
#[test]
fn rejects_wrong_secret_and_garbage() {
let token = Jwt::new(SECRET)
.issue(new_id(), new_id(), Role::Member, Duration::from_secs(60))
.unwrap();
assert!(
Jwt::new("baska-bir-gizli-anahtar-32-karakterden-uzun")
.verify(&token)
.is_none()
);
assert!(Jwt::new(SECRET).verify("rastgele.metin.token").is_none());
}
}
+78
View File
@@ -0,0 +1,78 @@
//! Basit, bellek içi giriş deneme sınırlayıcı.
//!
//! Anahtar: e-posta adresi. Pencere içinde `max_failures` hatalı denemeden sonra
//! pencere dolana kadar giriş engellenir. Başarılı girişte sayaç sıfırlanır.
//! (Tek sunucu için yeterli; sunucu yeniden başlarsa sayaçlar sıfırlanır.)
use std::{
collections::HashMap,
sync::Mutex,
time::{Duration, Instant},
};
pub struct LoginLimiter {
attempts: Mutex<HashMap<String, (u32, Instant)>>,
max_failures: u32,
window: Duration,
}
impl LoginLimiter {
pub fn new(max_failures: u32, window: Duration) -> Self {
Self {
attempts: Mutex::new(HashMap::new()),
max_failures,
window,
}
}
/// Bu anahtar için giriş denemesine izin var mı?
pub fn is_allowed(&self, key: &str) -> bool {
let map = self.attempts.lock().unwrap();
match map.get(key) {
Some((count, since)) => *count < self.max_failures || since.elapsed() >= self.window,
None => true,
}
}
pub fn record_failure(&self, key: &str) {
let mut map = self.attempts.lock().unwrap();
// Süresi dolmuş kayıtları temizle (bellek büyümesin)
map.retain(|_, (_, since)| since.elapsed() < self.window);
let entry = map.entry(key.to_string()).or_insert((0, Instant::now()));
entry.0 += 1;
}
pub fn reset(&self, key: &str) {
self.attempts.lock().unwrap().remove(key);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_after_max_failures_and_resets() {
let l = LoginLimiter::new(3, Duration::from_secs(60));
for _ in 0..3 {
assert!(l.is_allowed("a@b.c"));
l.record_failure("a@b.c");
}
assert!(!l.is_allowed("a@b.c"), "3 hatadan sonra engellenmeli");
assert!(
l.is_allowed("baska@b.c"),
"diğer kullanıcılar etkilenmemeli"
);
l.reset("a@b.c");
assert!(l.is_allowed("a@b.c"));
}
#[test]
fn unblocks_after_window() {
let l = LoginLimiter::new(1, Duration::from_millis(20));
l.record_failure("x");
assert!(!l.is_allowed("x"));
std::thread::sleep(Duration::from_millis(30));
assert!(l.is_allowed("x"));
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Kimlik doğrulama: şifre hash'leme, JWT, oturum cookie'si, giriş deneme sınırı.
pub mod jwt;
pub mod limiter;
pub mod password;
pub mod session;
pub use session::AuthUser;
+76
View File
@@ -0,0 +1,76 @@
//! Argon2id ile şifre hash'leme.
//!
//! Hash'leme bilerek yavaştır (kaba kuvvet saldırısını zorlaştırmak için), bu yüzden
//! async sunucuyu bloklamasın diye `spawn_blocking` ile ayrı bir iş parçacığında çalışır.
use std::sync::LazyLock;
use argon2::{
Argon2,
password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
};
use crate::error::{AppError, AppResult};
fn hash_sync(password: &str) -> AppResult<String> {
Argon2::default()
.hash_password(password.as_bytes())
.map(|h| h.to_string())
.map_err(|e| AppError::Internal(format!("şifre hash'lenemedi: {e}")))
}
fn verify_sync(password: &str, hash: &str) -> bool {
PasswordHash::new(hash)
.map(|h| {
Argon2::default()
.verify_password(password.as_bytes(), &h)
.is_ok()
})
.unwrap_or(false)
}
/// Kullanıcı bulunamadığında da aynı sürede yanıt vermek için kullanılan sahte hash.
/// (Aksi halde yanıt süresinden "bu e-posta kayıtlı mı" anlaşılabilir.)
static DUMMY_HASH: LazyLock<String> =
LazyLock::new(|| hash_sync("kakeibo-dummy-password").expect("dummy hash"));
pub async fn hash(password: String) -> AppResult<String> {
tokio::task::spawn_blocking(move || hash_sync(&password))
.await
.map_err(|e| AppError::Internal(format!("hash görevi: {e}")))?
}
/// `hash` `None` ise sahte hash ile doğrulama yapılır ve her zaman `false` döner.
pub async fn verify(password: String, hash: Option<String>) -> bool {
tokio::task::spawn_blocking(move || match hash {
Some(h) => verify_sync(&password, &h),
None => {
let _ = verify_sync(&password, &DUMMY_HASH);
false
}
})
.await
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_and_verify() {
let h = hash_sync("gizli-sifre-123").unwrap();
assert!(h.starts_with("$argon2id$"));
assert!(verify_sync("gizli-sifre-123", &h));
assert!(!verify_sync("yanlis-sifre", &h));
}
#[test]
fn same_password_different_hashes() {
// Her hash'te rastgele "tuz" (salt) kullanılır
assert_ne!(
hash_sync("abc12345").unwrap(),
hash_sync("abc12345").unwrap()
);
}
}
+98
View File
@@ -0,0 +1,98 @@
//! Oturum cookie'si ve `AuthUser` extractor'ı.
//!
//! 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).
//!
//! 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,
//! asla istekten (URL/gövde) almaz.
use std::time::Duration;
use axum::{extract::FromRequestParts, http::request::Parts};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use mongodb::bson::{Uuid, doc};
use serde::Deserialize;
use crate::{db::col, error::AppError, models::user::Role, state::AppState};
pub const COOKIE_NAME: &str = "kakeibo_session";
/// "Beni hatırla" seçiliyse oturum süresi
pub const REMEMBER_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
/// "Beni hatırla" seçili değilse (tarayıcı kapanınca da cookie silinir)
pub const SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60);
pub fn ttl(remember: bool) -> Duration {
if remember { REMEMBER_TTL } else { SESSION_TTL }
}
pub fn session_cookie(token: String, remember: bool, secure: bool) -> Cookie<'static> {
let mut c = Cookie::build((COOKIE_NAME, token))
.http_only(true) // JavaScript okuyamaz → XSS ile çalınamaz
.secure(secure) // yalnızca HTTPS'te gönderilir (canlıda)
.same_site(SameSite::Lax) // başka sitelerden gelen POST'larda gönderilmez
.path("/");
if remember {
c = c.max_age(time::Duration::seconds(REMEMBER_TTL.as_secs() as i64));
}
c.build()
}
pub fn removal_cookie() -> Cookie<'static> {
Cookie::build((COOKIE_NAME, "")).path("/").build()
}
/// Oturum açmış kullanıcı (JWT'den).
#[derive(Debug, Clone, Copy)]
pub struct AuthUser {
pub user_id: Uuid,
pub household_id: Uuid,
/// Veritabanındaki güncel rol (JWT'deki değil)
pub role: Role,
}
impl AuthUser {
/// Yalnızca hane sahibinin yapabileceği işlemler için.
pub fn require_owner(&self) -> Result<(), AppError> {
match self.role {
Role::Owner => Ok(()),
Role::Member => Err(AppError::Forbidden),
}
}
}
#[derive(Deserialize)]
struct RoleOnly {
role: Role,
}
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, AppError> {
let jar = CookieJar::from_headers(&parts.headers);
let token = jar.get(COOKIE_NAME).ok_or(AppError::Unauthorized)?.value();
let claims = state.jwt.verify(token).ok_or(AppError::Unauthorized)?;
let parse = |s: &str| Uuid::parse_str(s).map_err(|_| AppError::Unauthorized);
let user_id = parse(&claims.sub)?;
let household_id = parse(&claims.hid)?;
let current = state
.db
.collection::<RoleOnly>(col::USERS)
.find_one(doc! { "_id": user_id, "household_id": household_id, "deleted_at": null })
.projection(doc! { "role": 1 })
.await?
.ok_or(AppError::Unauthorized)?;
Ok(Self {
user_id,
household_id,
role: current.role,
})
}
}
+103
View File
@@ -0,0 +1,103 @@
//! Ortam değişkenlerinden uygulama ayarlarını okur.
use std::{env, path::PathBuf};
#[derive(Debug, Clone)]
pub struct Config {
pub mongodb_uri: String,
pub jwt_secret: String,
pub app_url: String,
pub port: u16,
pub registration: RegistrationMode,
/// Derlenmiş frontend klasörü (`STATIC_DIR`). Tanımlıysa Axum arayüzü de sunar
/// (canlıda tek servis). Geliştirmede boş: arayüzü Vite sunar.
pub static_dir: Option<PathBuf>,
/// Otomatik kur yenileme aralığı (dakika, `RATES_REFRESH_MINUTES`, varsayılan 60); 0 → kapalı
pub rates_refresh_minutes: u64,
}
/// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegistrationMode {
/// Varsayılan. Sistemde hiç hane yokken ilk kayıt yeni hane kurar;
/// sonrasında yalnızca davet koduyla katılınır.
InviteOnly,
/// Herkes yeni hane kurabilir.
Open,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("{0} ortam değişkeni tanımlı değil (.env dosyasını kontrol edin)")]
Missing(&'static str),
#[error("{0} geçersiz: {1}")]
Invalid(&'static str, &'static str),
}
impl Config {
pub fn from_env() -> Result<Self, ConfigError> {
let mongodb_uri = required("MONGODB_URI")?;
let jwt_secret = required("JWT_SECRET")?;
if jwt_secret.len() < 32 {
return Err(ConfigError::Invalid(
"JWT_SECRET",
"en az 32 karakter olmalı (openssl rand -hex 32)",
));
}
let app_url = env::var("APP_URL").unwrap_or_else(|_| "http://localhost:5173".into());
let registration = match env::var("REGISTRATION").as_deref() {
Err(_) | Ok("") | Ok("invite_only") => RegistrationMode::InviteOnly,
Ok("open") => RegistrationMode::Open,
Ok(_) => {
return Err(ConfigError::Invalid(
"REGISTRATION",
"\"invite_only\" veya \"open\" olmalı",
));
}
};
let static_dir = env::var("STATIC_DIR")
.ok()
.filter(|v| !v.trim().is_empty())
.map(PathBuf::from);
let port = match env::var("PORT") {
Ok(p) => p
.parse()
.map_err(|_| ConfigError::Invalid("PORT", "sayı olmalı"))?,
Err(_) => 8080,
};
let rates_refresh_minutes = match env::var("RATES_REFRESH_MINUTES") {
Ok(m) if !m.trim().is_empty() => m.trim().parse().map_err(|_| {
ConfigError::Invalid("RATES_REFRESH_MINUTES", "dakika (sayı) olmalı")
})?,
_ => 60,
};
Ok(Self {
mongodb_uri,
jwt_secret,
app_url,
port,
registration,
static_dir,
rates_refresh_minutes,
})
}
/// Cookie'lerde `Secure` bayrağı yalnızca HTTPS'te açılır.
pub fn is_https(&self) -> bool {
self.app_url.starts_with("https://")
}
}
fn required(key: &'static str) -> Result<String, ConfigError> {
env::var(key)
.ok()
.filter(|v| !v.trim().is_empty())
.ok_or(ConfigError::Missing(key))
}
+126
View File
@@ -0,0 +1,126 @@
//! İndeksler (DATA_MODEL.md §4). Açılışta her seferinde çağrılır;
//! MongoDB zaten var olan aynı indeksi yeniden oluşturmaz.
use mongodb::{
Database, IndexModel,
bson::{Document, doc},
error::Result,
options::IndexOptions,
};
use super::col;
pub async fn ensure(db: &Database) -> Result<()> {
for (collection, indexes) in definitions() {
db.collection::<Document>(collection)
.create_indexes(indexes)
.await?;
}
tracing::info!("İndeksler hazır");
Ok(())
}
fn idx(keys: Document) -> IndexModel {
IndexModel::builder().keys(keys).build()
}
fn idx_opts(keys: Document, options: IndexOptions) -> IndexModel {
IndexModel::builder().keys(keys).options(options).build()
}
fn unique() -> IndexOptions {
IndexOptions::builder().unique(true).build()
}
fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
let hp = || idx(doc! { "household_id": 1, "period_id": 1 });
let mut defs = vec![
(col::USERS, vec![idx_opts(doc! { "email": 1 }, unique())]),
(col::INVITES, vec![idx_opts(doc! { "code": 1 }, unique())]),
(
col::PERIODS,
vec![
idx(doc! { "household_id": 1, "start_date": 1 }),
// Hanede en fazla bir açık dönem
idx_opts(
doc! { "household_id": 1 },
IndexOptions::builder()
.name("one_open_period_per_household".to_string())
.unique(true)
.partial_filter_expression(doc! { "status": "open" })
.build(),
),
],
),
(
col::CATEGORIES,
vec![idx(doc! { "household_id": 1, "sort_order": 1 })],
),
(
col::PAYMENT_METHODS,
vec![idx(doc! { "household_id": 1, "sort_order": 1 })],
),
(col::PLAN_ITEMS, vec![hp()]),
(col::BUDGETS, vec![hp()]),
(col::NOTES, vec![hp()]),
(
col::ENTRIES,
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::EXCHANGE_RATES,
vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })],
),
];
// Senkronizasyon (Faz 11): "şu tarihten sonra değişenler" sorgusu.
for &name in col::HOUSEHOLD_SCOPED {
let sync = idx(doc! { "household_id": 1, "updated_at": 1 });
match defs.iter_mut().find(|(c, _)| *c == name) {
Some((_, list)) => list.push(sync),
None => defs.push((name, vec![sync])),
}
}
defs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_household_collection_has_sync_index() {
let defs = definitions();
for &name in col::HOUSEHOLD_SCOPED {
let (_, list) = defs.iter().find(|(c, _)| *c == name).unwrap();
assert!(
list.iter()
.any(|i| i.keys == doc! { "household_id": 1, "updated_at": 1 }),
"{name} koleksiyonunda senkronizasyon indeksi yok"
);
}
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Basit şema migration mekanizması (DATA_MODEL.md §3.15).
//!
//! Her migration bir kez çalışır; `schema_migrations` koleksiyonuna
//! `{ _id: sürüm, name, applied_at }` olarak kaydedilir.
//! Yeni migration eklemek: `MIGRATIONS` listesine bir sonraki sürüm numarasıyla ekle.
use std::{future::Future, pin::Pin};
use mongodb::{
Database,
bson::{DateTime, Document, doc},
error::Result,
};
use super::col;
type MigrationFn =
for<'a> fn(&'a Database) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
struct Migration {
version: i32,
name: &'static str,
run: MigrationFn,
}
/// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar.
const MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial",
run: |db| Box::pin(m001_initial(db)),
},
Migration {
version: 2,
name: "default_assets",
run: |db| Box::pin(m002_default_assets(db)),
},
];
/// Uygulanmamış migration'ları sırayla çalıştırır.
pub async fn run(db: &Database) -> Result<()> {
let coll = db.collection::<Document>(col::SCHEMA_MIGRATIONS);
for m in MIGRATIONS {
if coll.find_one(doc! { "_id": m.version }).await?.is_some() {
continue;
}
tracing::info!("Migration çalışıyor: v{} ({})", m.version, m.name);
(m.run)(db).await?;
coll.insert_one(doc! {
"_id": m.version,
"name": m.name,
"applied_at": DateTime::now(),
})
.await?;
}
Ok(())
}
/// v1: Tüm koleksiyonları açıkça oluşturur.
/// (Transaction içinde ilk kez yazılan koleksiyonlar sorun çıkarmasın diye önceden var olsunlar.)
async fn m001_initial(db: &Database) -> Result<()> {
let existing = db.list_collection_names().await?;
let all = col::HOUSEHOLD_SCOPED
.iter()
.chain(&[col::EXCHANGE_RATES, col::SCHEMA_MIGRATIONS]);
for name in all {
if !existing.iter().any(|e| e == name) {
db.create_collection(*name).await?;
}
}
Ok(())
}
/// v2: Faz 8'den önce açılmış hanelere varsayılan varlıklar (TL, altınlar, USD, EUR).
/// Varlığı olan haneye dokunmaz → tekrar çalışsa da çift kayıt olmaz.
async fn m002_default_assets(db: &Database) -> Result<()> {
#[derive(serde::Deserialize)]
struct Household {
#[serde(rename = "_id")]
id: mongodb::bson::Uuid,
}
let assets = db.collection::<Document>(col::ASSETS);
let mut cursor = db
.collection::<Household>(col::HOUSEHOLDS)
.find(doc! { "deleted_at": null })
.projection(doc! { "_id": 1 })
.await?;
while cursor.advance().await? {
let hid = cursor.deserialize_current()?.id;
if assets
.find_one(doc! { "household_id": hid })
.await?
.is_none()
{
assets
.insert_many(crate::services::seed::assets(hid))
.await?;
tracing::info!("Varsayılan varlıklar eklendi: hane {hid}");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions_start_at_1_and_are_sequential() {
for (i, m) in MIGRATIONS.iter().enumerate() {
assert_eq!(
m.version,
i as i32 + 1,
"migration sürümleri ardışık olmalı"
);
}
}
}
+94
View File
@@ -0,0 +1,94 @@
//! MongoDB bağlantısı, koleksiyon adları, migration ve indeksler.
pub mod indexes;
pub mod migrations;
use std::time::Duration;
use mongodb::{Client, Database, bson::doc, options::ClientOptions};
/// URI'de veritabanı adı yoksa kullanılacak ad.
const DEFAULT_DB_NAME: &str = "kakeibo";
/// Bağlantıyı kurar ve bir `ping` ile sunucunun gerçekten erişilebilir olduğunu doğrular.
pub async fn connect(uri: &str) -> mongodb::error::Result<Database> {
let mut options = ClientOptions::parse(uri).await?;
options.app_name = Some("kakeibo".into());
// Sunucu yoksa 30 sn yerine 5 sn içinde hata ver.
options.server_selection_timeout = Some(Duration::from_secs(5));
let db_name = options
.default_database
.clone()
.unwrap_or_else(|| DEFAULT_DB_NAME.into());
let client = Client::with_options(options)?;
let db = client.database(&db_name);
ping(&db).await?;
Ok(db)
}
pub async fn ping(db: &Database) -> mongodb::error::Result<()> {
db.run_command(doc! { "ping": 1 }).await.map(|_| ())
}
/// Benzersiz indeks ihlali mi? (ör. aynı e-postayla ikinci kayıt — hata kodu 11000)
pub fn is_duplicate_key(e: &mongodb::error::Error) -> bool {
use mongodb::error::{ErrorKind, WriteFailure};
match e.kind.as_ref() {
ErrorKind::Write(WriteFailure::WriteError(we)) => we.code == 11000,
ErrorKind::Command(ce) => ce.code == 11000,
ErrorKind::InsertMany(ime) => ime
.write_errors
.as_ref()
.is_some_and(|errs| errs.iter().any(|we| we.code == 11000)),
_ => false,
}
}
/// Açılışta çalışır: bekleyen migration'lar, ardından indeksler.
/// İkisi de tekrar çalıştırılabilir (idempotent); zaten yapılmış olanı atlar.
pub async fn prepare(db: &Database) -> mongodb::error::Result<()> {
migrations::run(db).await?;
indexes::ensure(db).await?;
Ok(())
}
/// Koleksiyon adları (DATA_MODEL.md §3). Yazım hatasını derleyici yakalasın diye sabit.
pub mod col {
pub const USERS: &str = "users";
pub const HOUSEHOLDS: &str = "households";
pub const INVITES: &str = "invites";
pub const PERIODS: &str = "periods";
pub const CATEGORIES: &str = "categories";
pub const PAYMENT_METHODS: &str = "payment_methods";
pub const PLAN_ITEMS: &str = "plan_items";
pub const BUDGETS: &str = "budgets";
pub const ENTRIES: &str = "entries";
pub const CARRYOVERS: &str = "carryovers";
pub const NOTES: &str = "notes";
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";
/// Hane verisi taşıyan koleksiyonlar (`household_id` alanı var).
pub const HOUSEHOLD_SCOPED: &[&str] = &[
USERS,
HOUSEHOLDS,
INVITES,
PERIODS,
CATEGORIES,
PAYMENT_METHODS,
PLAN_ITEMS,
BUDGETS,
ENTRIES,
CARRYOVERS,
NOTES,
ASSETS,
ASSET_TRANSACTIONS,
IMPORT_RULES,
];
}
+106
View File
@@ -0,0 +1,106 @@
//! Uygulama genelindeki hata türü.
//! Kullanıcıya Türkçe ve sade mesaj döner; teknik detay yalnızca loga yazılır.
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use ts_rs::TS;
/// Tüm hata yanıtlarının gövdesi: `{ "error": { "code": "...", "message": "..." } }`
#[derive(Serialize, TS)]
#[ts(export)]
pub struct ApiErrorBody {
pub error: ApiErrorDetail,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct ApiErrorDetail {
/// Makine için: `unauthorized`, `bad_request`, `conflict`, `not_found`, ...
pub code: String,
/// Kullanıcıya gösterilecek Türkçe mesaj
pub message: String,
}
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("Kayıt bulunamadı")]
NotFound,
#[error("{0}")]
BadRequest(String),
#[error("Oturum açmanız gerekiyor")]
Unauthorized,
#[error("E-posta veya şifre hatalı")]
InvalidCredentials,
#[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.")]
RegistrationClosed,
#[error("{0}")]
Conflict(String),
#[error("Çok fazla deneme yapıldı, lütfen biraz sonra tekrar deneyin")]
TooManyRequests,
#[error("veritabanı hatası: {0}")]
Database(#[from] mongodb::error::Error),
#[error("iç hata: {0}")]
Internal(String),
}
impl AppError {
fn status_and_code(&self) -> (StatusCode, &'static str) {
match self {
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::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
Self::RegistrationClosed => (StatusCode::FORBIDDEN, "registration_closed"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
Self::Database(_) | Self::Internal(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
}
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = self.status_and_code();
let message = if status.is_server_error() {
// Teknik detay sadece logda; kullanıcıya genel mesaj.
tracing::error!(error = %self, "sunucu hatası");
"Beklenmeyen bir hata oluştu, lütfen tekrar deneyin".to_string()
} else {
self.to_string()
};
let body = ApiErrorBody {
error: ApiErrorDetail {
code: code.into(),
message,
},
};
(status, Json(body)).into_response()
}
}
/// Doğrulama hatası → ilk alanın Türkçe mesajıyla 400.
impl From<validator::ValidationErrors> for AppError {
fn from(errors: validator::ValidationErrors) -> Self {
let message = errors
.field_errors()
.values()
.flat_map(|list| list.iter())
.find_map(|e| e.message.as_ref().map(|m| m.to_string()))
.unwrap_or_else(|| "Girilen bilgiler geçersiz".into());
AppError::BadRequest(message)
}
}
/// Route fonksiyonlarında kısa dönüş tipi: `AppResult<Json<T>>`
pub type AppResult<T> = Result<T, AppError>;
+31
View File
@@ -0,0 +1,31 @@
//! Özel extractor'lar.
use axum::{
Json,
extract::{FromRequest, Request},
};
use serde::de::DeserializeOwned;
use validator::Validate;
use crate::error::AppError;
/// `Json<T>` gibi çalışır, ek olarak `validator` kurallarını da kontrol eder.
/// Hatalı JSON veya geçersiz alanlarda Türkçe 400 hatası döner.
pub struct ValidJson<T>(pub T);
impl<T, S> FromRequest<S> for ValidJson<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request(req: Request, state: &S) -> Result<Self, AppError> {
let Json(value) = Json::<T>::from_request(req, state).await.map_err(|e| {
tracing::debug!(error = %e, "JSON okunamadı");
AppError::BadRequest("Gönderilen veri okunamadı veya eksik alan var".into())
})?;
value.validate()?;
Ok(Self(value))
}
}
+13
View File
@@ -0,0 +1,13 @@
//! Kakeibo backend kütüphanesi.
//!
//! `main.rs` (çalıştırılabilir sunucu) ve `tests/` (entegrasyon testleri) bu modülleri kullanır.
pub mod auth;
pub mod config;
pub mod db;
pub mod error;
pub mod extract;
pub mod models;
pub mod routes;
pub mod services;
pub mod state;
+74 -16
View File
@@ -1,11 +1,10 @@
use std::net::SocketAddr;
use std::{net::SocketAddr, process::ExitCode};
use axum::{Json, Router, routing::get};
use serde_json::{Value, json};
use kakeibo::{config::Config, db, routes, services, state::AppState};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() {
async fn main() -> ExitCode {
// .env dosyasını yükle (proje kökünde; üst klasörler de aranır). Yoksa sorun değil.
dotenvy::dotenv().ok();
@@ -15,22 +14,81 @@ async fn main() {
)
.init();
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(8080);
let config = match Config::from_env() {
Ok(c) => c,
Err(e) => {
tracing::error!("Ayar hatası: {e}");
return ExitCode::FAILURE;
}
};
let app = Router::new().route("/api/health", get(health));
let db = match db::connect(&config.mongodb_uri).await {
Ok(db) => {
tracing::info!("MongoDB bağlantısı kuruldu (veritabanı: {})", db.name());
db
}
Err(e) => {
tracing::error!(
"MongoDB'ye bağlanılamadı. MONGODB_URI doğru mu, veritabanı çalışıyor mu? \
(yerelde: npm run db:up)\nDetay: {e}"
);
return ExitCode::FAILURE;
}
};
if let Err(e) = db::prepare(&db).await {
tracing::error!("Veritabanı hazırlanamadı (migration/indeks): {e}");
return ExitCode::FAILURE;
}
// Kurlar arka planda (açılışta hemen, sonra aralıkla); hata uygulamayı durdurmaz
services::rates::spawn_refresher(db.clone(), config.rates_refresh_minutes);
let port = config.port;
let state = AppState::new(db, config);
let app = routes::app(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.expect("Port dinlenemedi");
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
tracing::error!(
"{port} portu dinlenemedi (başka bir uygulama kullanıyor olabilir): {e}"
);
return ExitCode::FAILURE;
}
};
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
axum::serve(listener, app).await.expect("Sunucu hatası");
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
{
tracing::error!("Sunucu hatası: {e}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
/// Sağlık kontrolü. Faz 1'de veritabanı durumu da eklenecek.
async fn health() -> Json<Value> {
Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") }))
/// Docker/Dokploy durdururken (SIGTERM) veya Ctrl+C'de: yeni istek almayı bırak,
/// süren istekleri bitir, sonra kapan (yarım kalan yazma olmasın).
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
s.recv().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("Kapatma sinyali alındı, sunucu kapanıyor…");
}
View File
+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,
}
+60
View File
@@ -0,0 +1,60 @@
//! Tüm koleksiyonlarda ortak alanlar için yardımcılar (DATA_MODEL.md §1.1).
//!
//! Her kayıt şu alanları taşır:
//! `_id` (UUID v7), `household_id`, `created_at`, `updated_at`, `deleted_at`, `version`
use mongodb::bson::{DateTime, Document, Uuid, doc};
/// Yeni UUID v7 üretir. İlk 48 bit zaman damgası olduğu için
/// sonradan üretilen kimlik her zaman daha büyüktür (zamana göre sıralanabilir).
pub fn new_id() -> Uuid {
Uuid::from_bytes(uuid::Uuid::now_v7().into_bytes())
}
/// Şu anki zaman (UTC, BSON DateTime).
pub fn now() -> DateTime {
DateTime::now()
}
/// Yeni kayıt için ortak alanlar. Modele özgü alanlarla birleştirilir:
/// `let mut d = new_meta(hid); d.insert("name", "Market");`
pub fn new_meta(household_id: Uuid) -> Document {
let ts = now();
doc! {
"_id": new_id(),
"household_id": household_id,
"created_at": ts,
"updated_at": ts,
"deleted_at": null,
"version": 1_i32,
}
}
/// Hane kapsamlı, silinmemiş kayıtlar için temel filtre.
/// Tüm okuma sorguları bununla başlar → başka hanenin verisi asla dönmez.
pub fn scope(household_id: Uuid) -> Document {
doc! { "household_id": household_id, "deleted_at": null }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_id_is_v7_and_sortable() {
let a = uuid::Uuid::from_bytes(new_id().bytes());
std::thread::sleep(std::time::Duration::from_millis(2));
let b = uuid::Uuid::from_bytes(new_id().bytes());
assert_eq!(a.get_version_num(), 7);
assert!(b > a, "sonra üretilen UUID daha büyük olmalı");
}
#[test]
fn scope_always_filters_household_and_deleted() {
let hid = new_id();
let f = scope(hid);
assert_eq!(f.get("household_id"), Some(&hid.into()));
assert!(f.contains_key("deleted_at"));
}
}
+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,
}
+51
View File
@@ -0,0 +1,51 @@
//! 4 sabit ana grup (DATA_MODEL.md §2). Değiştirilemez, silinemez; veritabanında değil kodda.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum Group {
/// 🏠 Zorunlu
Needs,
/// 🛍️ İstek
Wants,
/// 📚 Kültürel
Culture,
/// ⚡ Beklenmeyen
Unexpected,
}
impl Group {
pub const ALL: [Group; 4] = [
Group::Needs,
Group::Wants,
Group::Culture,
Group::Unexpected,
];
pub fn as_str(self) -> &'static str {
match self {
Group::Needs => "needs",
Group::Wants => "wants",
Group::Culture => "culture",
Group::Unexpected => "unexpected",
}
}
/// 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)
}
}
+19
View File
@@ -0,0 +1,19 @@
//! `households` koleksiyonu (DATA_MODEL.md §3.2).
use mongodb::bson::{Document, doc};
use serde::Deserialize;
/// Veritabanından okunan hane (yalnızca ihtiyaç duyulan alanlar).
#[derive(Debug, Deserialize)]
pub struct Household {
pub name: String,
}
/// Yeni hane için varsayılan ayarlar.
pub fn default_settings() -> Document {
doc! {
"currency": "TRY",
"budget_split_pct": { "needs": 50, "wants": 30, "culture": 10, "unexpected": 10 },
"budget_suggestion": "default",
}
}
+13
View File
@@ -0,0 +1,13 @@
//! `invites` koleksiyonu (DATA_MODEL.md §3.3).
use mongodb::bson::{DateTime, Uuid};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Invite {
#[serde(rename = "_id")]
pub id: Uuid,
pub household_id: Uuid,
pub code: String,
pub expires_at: DateTime,
}
+14
View File
@@ -0,0 +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,
}
+62
View File
@@ -0,0 +1,62 @@
//! `periods` koleksiyonu (DATA_MODEL.md §3.4).
use mongodb::bson::{DateTime, Uuid};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum PeriodStatus {
/// Girişler yapılabilir. Hanede en fazla bir tane.
Open,
/// Salt okunur. Sahip yeniden açarsa `Correcting` olur.
Closed,
/// Düzeltmede: kapanmış dönem sahip tarafından düzeltme için açıldı.
/// Yazılabilir, ama "açık dönem" sayılmaz (yeni dönem açıkken de olabilir).
/// "Yeniden kapat" ile devir yeniden hesaplanır.
Correcting,
/// 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 {
pub fn as_str(self) -> &'static str {
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)]
pub struct Period {
#[serde(rename = "_id")]
pub id: Uuid,
pub name: String,
/// `"YYYY-MM-DD"`, dahil
pub start_date: String,
/// `"YYYY-MM-DD"`, **hariç**
pub end_date: String,
pub status: PeriodStatus,
/// Kuruş
pub savings_goal: i64,
/// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası)
#[serde(default)]
pub reflection_md: Option<String>,
/// Kapanışta birikime aktarılan tutar (kuruş). Faz 8'de varlık hareketine dönüşecek.
#[serde(default)]
pub to_savings: Option<i64>,
#[serde(default)]
pub closed_at: Option<DateTime>,
pub version: i32,
}
+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()
}
}
+34
View File
@@ -0,0 +1,34 @@
//! `users` koleksiyonu (DATA_MODEL.md §3.1).
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 Role {
Owner,
Member,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Role::Owner => "owner",
Role::Member => "member",
}
}
}
/// Veritabanından okunan kullanıcı (yalnızca ihtiyaç duyulan alanlar).
#[derive(Debug, Deserialize)]
pub struct User {
#[serde(rename = "_id")]
pub id: Uuid,
pub household_id: Uuid,
pub email: String,
pub password_hash: String,
pub name: String,
pub role: Role,
}
View File
+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())))
}
+241
View File
@@ -0,0 +1,241 @@
//! Kimlik doğrulama uç noktaları:
//! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`
use axum::{Json, extract::State, http::StatusCode};
use axum_extra::extract::cookie::CookieJar;
use mongodb::bson::{Uuid, doc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
auth::{
AuthUser, password,
session::{removal_cookie, session_cookie, ttl},
},
db::col,
error::{AppError, AppResult},
extract::ValidJson,
models::{
common::{now, scope},
household::Household,
user::{Role, User},
},
services::account::{self, NewAccount},
state::AppState,
};
// ---------- İstek / yanıt yapıları ----------
#[derive(Deserialize, Validate, TS)]
#[ts(export, optional_fields)]
pub struct RegisterRequest {
#[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))]
name: String,
#[validate(email(message = "Geçerli bir e-posta adresi girin"))]
email: String,
#[validate(length(min = 8, max = 128, message = "Şifre en az 8 karakter olmalı"))]
password: String,
#[validate(length(max = 80, message = "Hane adı en fazla 80 karakter olabilir"))]
household_name: Option<String>,
/// 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>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export)]
pub struct LoginRequest {
#[validate(length(min = 1, message = "E-posta gerekli"))]
email: String,
#[validate(length(min = 1, message = "Şifre gerekli"))]
password: String,
#[serde(default)]
remember_me: bool,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct MeResponse {
user: UserDto,
household: HouseholdDto,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "User")]
struct UserDto {
id: String,
name: String,
email: String,
role: Role,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "HouseholdSummary")]
struct HouseholdDto {
id: String,
name: String,
}
// ---------- Uç noktalar ----------
pub async fn register(
State(state): State<AppState>,
jar: CookieJar,
ValidJson(req): ValidJson<RegisterRequest>,
) -> AppResult<(StatusCode, CookieJar, Json<MeResponse>)> {
let name = req.name.trim().to_string();
if name.is_empty() {
return Err(AppError::BadRequest("Ad boş olamaz".into()));
}
let email = normalize_email(&req.email);
let household_name = req
.household_name
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty())
.unwrap_or_else(|| format!("{name} Hanesi"));
let invite_code = req
.invite_code
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty());
let password_hash = password::hash(req.password).await?;
let created = account::register(
&state.db,
state.config.registration,
NewAccount {
name,
email,
password_hash,
household_name,
invite_code,
},
)
.await?;
let token = state.jwt.issue(
created.user_id,
created.household_id,
created.role,
ttl(true),
)?;
let jar = jar.add(session_cookie(token, true, state.config.is_https()));
let me = load_me(&state, created.user_id, created.household_id).await?;
Ok((StatusCode::CREATED, jar, Json(me)))
}
pub async fn login(
State(state): State<AppState>,
jar: CookieJar,
ValidJson(req): ValidJson<LoginRequest>,
) -> AppResult<(CookieJar, Json<MeResponse>)> {
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)
.find_one(doc! { "email": &email, "deleted_at": null })
.await?;
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);
}
// verify() true döndüyse kullanıcı kesin vardır
let user = user.ok_or(AppError::InvalidCredentials)?;
state.login_limiter.reset(&email);
state
.db
.collection::<User>(col::USERS)
.update_one(
doc! { "_id": user.id },
doc! { "$set": { "last_login_at": now() } },
)
.await?;
let token = state
.jwt
.issue(user.id, user.household_id, user.role, ttl(req.remember_me))?;
let jar = jar.add(session_cookie(
token,
req.remember_me,
state.config.is_https(),
));
let me = load_me(&state, user.id, user.household_id).await?;
Ok((jar, Json(me)))
}
/// Kayıt ekranı için: yeni hane kurulabilir mi? (oturum gerektirmez)
#[derive(Serialize, TS)]
#[ts(export)]
pub struct RegistrationInfo {
/// `false` ise yalnızca davet koduyla kayıt yapılabilir
new_household_allowed: bool,
}
pub async fn registration_info(State(state): State<AppState>) -> AppResult<Json<RegistrationInfo>> {
let allowed = account::new_household_allowed(&state.db, state.config.registration).await?;
Ok(Json(RegistrationInfo {
new_household_allowed: allowed,
}))
}
pub async fn logout(jar: CookieJar) -> (StatusCode, CookieJar) {
(StatusCode::NO_CONTENT, jar.remove(removal_cookie()))
}
pub async fn me(State(state): State<AppState>, auth: AuthUser) -> AppResult<Json<MeResponse>> {
load_me(&state, auth.user_id, auth.household_id)
.await
.map(Json)
}
// ---------- Yardımcılar ----------
fn normalize_email(email: &str) -> String {
email.trim().to_lowercase()
}
/// Kullanıcı ve hanesini okur. Kullanıcı silinmişse oturum geçersiz sayılır.
async fn load_me(state: &AppState, user_id: Uuid, household_id: Uuid) -> AppResult<MeResponse> {
let mut user_filter = scope(household_id);
user_filter.insert("_id", user_id);
let user = state
.db
.collection::<User>(col::USERS)
.find_one(user_filter)
.await?
.ok_or(AppError::Unauthorized)?;
let mut hh_filter = scope(household_id);
hh_filter.insert("_id", household_id);
let household = state
.db
.collection::<Household>(col::HOUSEHOLDS)
.find_one(hh_filter)
.await?
.ok_or(AppError::Unauthorized)?;
Ok(MeResponse {
user: UserDto {
id: user.id.to_string(),
name: user.name,
email: user.email,
role: user.role,
},
household: HouseholdDto {
id: household_id.to_string(),
name: household.name,
},
})
}
+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(),
}))
}
+262
View File
@@ -0,0 +1,262 @@
//! Bütçe ve özet uç noktaları:
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods/{id}/summary` | gelir/gider/birikim/devir → harcanabilir, harcanan, kalan |
//! | GET | `/api/periods/{id}/budget` | 4 grup: aylık + haftalık döküm, her birinde harcanan |
//! | PUT | `/api/periods/{id}/budget` | 4 grubun aylık tutarları |
//! | PUT | `/api/periods/{id}/budget/weeks` | bir haftayı elle ayarla / otomatiğe döndür |
//! | GET | `/api/periods/{id}/budget/suggestion?method=…` | `default` · `previous` · `avg3` |
use axum::{
Json,
extract::{Path, Query, State},
};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
extract::ValidJson,
models::group::Group,
services::budget::{self, GroupBudget, SuggestMethod},
state::AppState,
};
/// Gruplara göre tutarlar (kuruş)
#[derive(Serialize, Deserialize, Validate, TS, Clone, Copy)]
#[ts(export)]
pub struct GroupAmounts {
#[ts(type = "number")]
needs: i64,
#[ts(type = "number")]
wants: i64,
#[ts(type = "number")]
culture: i64,
#[ts(type = "number")]
unexpected: i64,
}
impl From<budget::GroupAmounts> for GroupAmounts {
fn from(a: budget::GroupAmounts) -> Self {
Self {
needs: a[0],
wants: a[1],
culture: a[2],
unexpected: a[3],
}
}
}
impl From<GroupAmounts> for budget::GroupAmounts {
fn from(a: GroupAmounts) -> Self {
[a.needs, a.wants, a.culture, a.unexpected]
}
}
#[derive(Serialize, TS)]
#[ts(export, rename = "PeriodSummary")]
pub struct SummaryDto {
#[ts(type = "number")]
income_total: i64,
#[ts(type = "number")]
fixed_expense_total: i64,
#[ts(type = "number")]
savings_goal: i64,
/// Önceki dönemden devreden borç
#[ts(type = "number")]
carryover_debt: i64,
/// Önceki dönemden devreden artı bakiye
#[ts(type = "number")]
carryover_surplus: i64,
/// gelir − sabit gider − birikim − borç + artı
#[ts(type = "number")]
spendable: i64,
/// 4 grubun aylık bütçe toplamı
#[ts(type = "number")]
budget_total: i64,
/// harcanabilir − dağıtılan (negatifse fazla dağıtılmış)
#[ts(type = "number")]
unallocated: i64,
/// Dönemde gerçekleşen harcama
#[ts(type = "number")]
spent_total: i64,
/// Kalan harcanabilir: harcanabilir − gerçekleşen (negatifse aşılmış)
#[ts(type = "number")]
remaining: i64,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "GroupBudget")]
pub struct GroupBudgetDto {
group: Group,
#[ts(type = "number")]
monthly: i64,
/// Dönem boyunca gerçekleşen harcama
#[ts(type = "number")]
spent: i64,
/// Elle girilen haftaların toplamı aylık tutarı aşıyor
weekly_over: bool,
weeks: Vec<WeekBudgetDto>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "WeekBudget")]
struct WeekBudgetDto {
index: u32,
start_date: String,
last_date: String,
days: u32,
#[ts(type = "number")]
amount: i64,
/// Elle girildi (otomatik değil)
is_manual: bool,
/// Bu haftada gerçekleşen harcama
#[ts(type = "number")]
spent: i64,
}
impl From<GroupBudget> for GroupBudgetDto {
fn from(g: GroupBudget) -> Self {
Self {
group: g.group,
monthly: g.monthly,
spent: g.spent,
weekly_over: g.weekly_over,
weeks: g
.weeks
.into_iter()
.map(|w| WeekBudgetDto {
index: w.index,
start_date: w.start_date,
last_date: w.last_date,
days: w.days,
amount: w.amount,
is_manual: w.is_manual,
spent: w.spent,
})
.collect(),
}
}
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "BudgetWeekRequest")]
pub struct WeekRequest {
group: Group,
/// Haftanın ilk günü (`PeriodWeek.start_date`)
week_start: String,
/// Kuruş; `null` → otomatiğe döndür
#[ts(type = "number | null")]
amount: Option<i64>,
}
#[derive(Deserialize, Clone, Copy, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, rename = "SuggestMethod")]
pub enum Method {
Default,
Previous,
Avg3,
}
#[derive(Deserialize)]
pub struct SuggestQuery {
method: Option<Method>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "BudgetSuggestion")]
pub struct SuggestionDto {
/// `false` → bu yöntem için veri yok (`message` nedenini söyler)
available: bool,
message: Option<String>,
amounts: GroupAmounts,
}
fn parse_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
pub async fn summary(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<SummaryDto>> {
let x = budget::summary(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(Json(SummaryDto {
income_total: x.income_total,
fixed_expense_total: x.fixed_expense_total,
savings_goal: x.savings_goal,
carryover_debt: x.carryover_debt,
carryover_surplus: x.carryover_surplus,
spendable: x.spendable,
budget_total: x.budget_total,
unallocated: x.spendable - x.budget_total,
spent_total: x.spent_total,
remaining: x.spendable - x.spent_total,
}))
}
pub async fn get(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
let groups = budget::view(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect()))
}
pub async fn set_monthly(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<GroupAmounts>,
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
let pid = parse_id(&id)?;
budget::set_monthly(&s.db, auth.household_id, pid, req.into()).await?;
let groups = budget::view(&s.db, auth.household_id, pid).await?;
Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect()))
}
pub async fn set_week(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<WeekRequest>,
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
let pid = parse_id(&id)?;
budget::set_week(
&s.db,
auth.household_id,
pid,
req.group,
&req.week_start,
req.amount,
)
.await?;
let groups = budget::view(&s.db, auth.household_id, pid).await?;
Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect()))
}
pub async fn suggestion(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
Query(q): Query<SuggestQuery>,
) -> AppResult<Json<SuggestionDto>> {
let method = match q.method.unwrap_or(Method::Default) {
Method::Default => SuggestMethod::Default,
Method::Previous => SuggestMethod::Previous,
Method::Avg3 => SuggestMethod::Avg3,
};
let x = budget::suggest(&s.db, auth.household_id, parse_id(&id)?, method).await?;
Ok(Json(SuggestionDto {
available: x.available,
message: x.message,
amounts: x.amounts.into(),
}))
}
+236
View File
@@ -0,0 +1,236 @@
//! Kategori ve ödeme türü uç noktaları (hanenin tüm üyeleri kullanabilir):
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/categories?include_inactive=true` | listele |
//! | POST | `/api/categories` | ekle |
//! | PATCH | `/api/categories/{id}` | ad / emoji / aktiflik değiştir |
//! | PUT | `/api/categories/order` | sırala |
//!
//! `/api/payment-methods` aynı yapıdadır (yalnızca `group` alanı yoktur).
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::group::Group,
services::catalog::{self, Catalog, Changes, Item},
state::AppState,
};
// ---------- İstek / yanıt ----------
#[derive(Deserialize, Default)]
pub struct ListQuery {
#[serde(default)]
include_inactive: bool,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "CatalogCreateRequest", optional_fields)]
pub struct CreateRequest {
group: Option<Group>,
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
name: String,
#[validate(length(max = 16, message = "Emoji çok uzun"))]
emoji: Option<String>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "CatalogUpdateRequest", optional_fields)]
pub struct UpdateRequest {
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
name: Option<String>,
#[validate(length(max = 16, message = "Emoji çok uzun"))]
emoji: Option<String>,
is_active: Option<bool>,
version: Option<i32>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "CatalogOrderRequest", optional_fields)]
pub struct OrderRequest {
group: Option<Group>,
#[validate(length(max = 200, message = "Liste çok uzun"))]
ids: Vec<String>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "CatalogItem")]
pub struct ItemDto {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
group: Option<Group>,
name: String,
emoji: Option<String>,
sort_order: i32,
is_active: bool,
version: i32,
}
impl From<Item> for ItemDto {
fn from(i: Item) -> Self {
Self {
id: i.id.to_string(),
group: i.group,
name: i.name,
emoji: i.emoji,
sort_order: i.sort_order,
is_active: i.is_active,
version: i.version,
}
}
}
fn dtos(items: Vec<Item>) -> Json<Vec<ItemDto>> {
Json(items.into_iter().map(ItemDto::from).collect())
}
/// URL'deki kimlik bozuksa "bulunamadı" (başka hanenin kaydıyla aynı yanıt).
fn parse_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
// ---------- Ortak işleyiciler ----------
async fn list(
c: Catalog,
state: AppState,
auth: AuthUser,
q: ListQuery,
) -> AppResult<Json<Vec<ItemDto>>> {
let items = catalog::list(&state.db, c, auth.household_id, q.include_inactive).await?;
Ok(dtos(items))
}
async fn create(
c: Catalog,
state: AppState,
auth: AuthUser,
req: CreateRequest,
) -> AppResult<(StatusCode, Json<ItemDto>)> {
let item = catalog::create(
&state.db,
c,
auth.household_id,
req.group,
req.name,
req.emoji,
)
.await?;
Ok((StatusCode::CREATED, Json(item.into())))
}
async fn update(
c: Catalog,
state: AppState,
auth: AuthUser,
id: String,
req: UpdateRequest,
) -> AppResult<Json<ItemDto>> {
let changes = Changes {
name: req.name,
emoji: req.emoji,
is_active: req.is_active,
version: req.version,
};
let item = catalog::update(&state.db, c, auth.household_id, parse_id(&id)?, changes).await?;
Ok(Json(item.into()))
}
async fn reorder(
c: Catalog,
state: AppState,
auth: AuthUser,
req: OrderRequest,
) -> AppResult<Json<Vec<ItemDto>>> {
let ids = req
.ids
.iter()
.map(Uuid::parse_str)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| AppError::BadRequest("Sıralama listesinde geçersiz kimlik var".into()))?;
let items = catalog::reorder(&state.db, c, auth.household_id, req.group, ids).await?;
Ok(dtos(items))
}
// ---------- Kategoriler ----------
pub async fn list_categories(
State(s): State<AppState>,
auth: AuthUser,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Vec<ItemDto>>> {
list(Catalog::Categories, s, auth, q).await
}
pub async fn create_category(
State(s): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<ItemDto>)> {
create(Catalog::Categories, s, auth, req).await
}
pub async fn update_category(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<UpdateRequest>,
) -> AppResult<Json<ItemDto>> {
update(Catalog::Categories, s, auth, id, req).await
}
pub async fn reorder_categories(
State(s): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<OrderRequest>,
) -> AppResult<Json<Vec<ItemDto>>> {
reorder(Catalog::Categories, s, auth, req).await
}
// ---------- Ödeme türleri ----------
pub async fn list_payment_methods(
State(s): State<AppState>,
auth: AuthUser,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Vec<ItemDto>>> {
list(Catalog::PaymentMethods, s, auth, q).await
}
pub async fn create_payment_method(
State(s): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<ItemDto>)> {
create(Catalog::PaymentMethods, s, auth, req).await
}
pub async fn update_payment_method(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<UpdateRequest>,
) -> AppResult<Json<ItemDto>> {
update(Catalog::PaymentMethods, s, auth, id, req).await
}
pub async fn reorder_payment_methods(
State(s): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<OrderRequest>,
) -> AppResult<Json<Vec<ItemDto>>> {
reorder(Catalog::PaymentMethods, s, auth, req).await
}
+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()))
}
+43
View File
@@ -0,0 +1,43 @@
use axum::{Json, extract::State, http::StatusCode};
use serde::Serialize;
use ts_rs::TS;
use crate::{db, state::AppState};
#[derive(Serialize, TS)]
#[ts(export)]
pub struct HealthResponse {
/// `ok` veya `degraded`
status: &'static str,
/// `ok` veya `error`
db: &'static str,
version: &'static str,
}
/// Sağlık kontrolü (Dokploy health check bunu kullanır).
/// DB erişilemezse 503 döner.
pub async fn health(State(state): State<AppState>) -> (StatusCode, Json<HealthResponse>) {
let version = env!("CARGO_PKG_VERSION");
match db::ping(&state.db).await {
Ok(()) => (
StatusCode::OK,
Json(HealthResponse {
status: "ok",
db: "ok",
version,
}),
),
Err(e) => {
tracing::warn!(error = %e, "health: veritabanına erişilemiyor");
(
StatusCode::SERVICE_UNAVAILABLE,
Json(HealthResponse {
status: "degraded",
db: "error",
version,
}),
)
}
}
}
+142
View File
@@ -0,0 +1,142 @@
//! Hane ve davet uç noktaları:
//! - `GET /api/household` → hane bilgisi + üyeler (tüm üyeler)
//! - `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)
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use mongodb::bson::{DateTime, Uuid, doc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{
auth::AuthUser,
db::col,
error::{AppError, AppResult},
models::{common::scope, household::Household, invite::Invite, user::Role},
services::invite,
state::AppState,
};
#[derive(Serialize, TS)]
#[ts(export, rename = "Household")]
pub struct HouseholdResponse {
id: String,
name: String,
members: Vec<MemberDto>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "Member")]
struct MemberDto {
id: String,
name: String,
email: String,
role: Role,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "Invite")]
pub struct InviteDto {
id: String,
code: String,
expires_at: String,
}
impl From<Invite> for InviteDto {
fn from(i: Invite) -> Self {
Self {
id: i.id.to_string(),
code: i.code,
expires_at: iso(i.expires_at),
}
}
}
/// Üye listesi için okunan alanlar
#[derive(Deserialize)]
struct MemberRow {
#[serde(rename = "_id")]
id: Uuid,
name: String,
email: String,
role: Role,
}
pub async fn get(
State(state): State<AppState>,
auth: AuthUser,
) -> AppResult<Json<HouseholdResponse>> {
let hid = auth.household_id;
let mut hh_filter = scope(hid);
hh_filter.insert("_id", hid);
let household = state
.db
.collection::<Household>(col::HOUSEHOLDS)
.find_one(hh_filter)
.await?
.ok_or(AppError::NotFound)?;
let mut cursor = state
.db
.collection::<MemberRow>(col::USERS)
.find(scope(hid))
.projection(doc! { "name": 1, "email": 1, "role": 1 })
.sort(doc! { "created_at": 1 })
.await?;
let mut members = Vec::new();
while cursor.advance().await? {
let m = cursor.deserialize_current()?;
members.push(MemberDto {
id: m.id.to_string(),
name: m.name,
email: m.email,
role: m.role,
});
}
Ok(Json(HouseholdResponse {
id: hid.to_string(),
name: household.name,
members,
}))
}
pub async fn list_invites(
State(state): State<AppState>,
auth: AuthUser,
) -> AppResult<Json<Vec<InviteDto>>> {
auth.require_owner()?;
let list = invite::list_active(&state.db, auth.household_id).await?;
Ok(Json(list.into_iter().map(InviteDto::from).collect()))
}
pub async fn create_invite(
State(state): State<AppState>,
auth: AuthUser,
) -> AppResult<(StatusCode, Json<InviteDto>)> {
auth.require_owner()?;
let inv = invite::create(&state.db, auth.household_id, auth.user_id).await?;
Ok((StatusCode::CREATED, Json(inv.into())))
}
pub async fn revoke_invite(
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)?;
invite::revoke(&state.db, auth.household_id, id).await?;
Ok(StatusCode::NO_CONTENT)
}
fn iso(dt: DateTime) -> String {
dt.try_to_rfc3339_string().unwrap_or_default()
}
+146
View File
@@ -0,0 +1,146 @@
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
mod asset;
mod auth;
mod bank_import;
mod budget;
mod catalog;
mod entry;
mod health;
mod household;
mod note;
mod period;
mod plan;
mod rates;
mod reports;
mod review;
mod spa;
mod stats;
mod transfer;
use axum::{
Router,
extract::DefaultBodyLimit,
routing::{delete, get, patch, post, put},
};
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
use crate::{error::AppError, state::AppState};
/// Sunucunun tamamı: API + (STATIC_DIR tanımlıysa) arayüz dosyaları,
/// sıkıştırma ve istek logları. `main.rs` bunu kullanır.
pub fn app(state: AppState) -> Router {
let static_dir = state.config.static_dir.clone();
let mut app = router(state);
if let Some(dir) = static_dir {
app = app.fallback_service(spa::router(&dir));
}
app.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
}
/// Tüm API route'ları. Testler de bu fonksiyonu kullanır.
pub fn router(state: AppState) -> Router {
let api = Router::new()
.route("/health", get(health::health))
.route("/auth/register", post(auth::register))
.route("/auth/registration", get(auth::registration_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(
"/household/invites",
get(household::list_invites).post(household::create_invite),
)
.route("/household/invites/{id}", delete(household::revoke_invite))
.route(
"/categories",
get(catalog::list_categories).post(catalog::create_category),
)
.route("/categories/order", put(catalog::reorder_categories))
.route("/categories/{id}", patch(catalog::update_category))
.route(
"/payment-methods",
get(catalog::list_payment_methods).post(catalog::create_payment_method),
)
.route(
"/payment-methods/order",
put(catalog::reorder_payment_methods),
)
.route(
"/payment-methods/{id}",
patch(catalog::update_payment_method),
)
.route("/periods", get(period::list).post(period::create))
.route("/periods/current", get(period::current))
.route("/periods/{id}", get(period::get).patch(period::update))
.route("/periods/{id}/close", post(period::close))
.route("/periods/{id}/reopen", post(period::reopen))
.route(
"/periods/{id}/plan-items",
get(plan::list).post(plan::create),
)
.route(
"/periods/{id}/plan-items/copy-previous",
post(plan::copy_previous),
)
.route("/plan-items/{id}", patch(plan::update).delete(plan::delete))
.route("/periods/{id}/notes", get(note::list).post(note::create))
.route("/periods/{id}/summary", get(budget::summary))
.route("/periods/{id}/review", get(review::get))
.route("/periods/{id}/daily", get(stats::daily))
.route("/stats/periods", get(stats::periods))
.route("/reports/breakdown", get(reports::breakdown))
.route("/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)
}
+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)
}
+280
View File
@@ -0,0 +1,280 @@
//! Dönem uç noktaları (hanenin tüm üyeleri):
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods` | tüm dönemler (en yeni başta) |
//! | GET | `/api/periods/current` | açık dönem ya da `null` |
//! | POST | `/api/periods` | dönem aç |
//! | GET | `/api/periods/{id}` | dönem + haftalar |
//! | PATCH | `/api/periods/{id}` | ad / tarihler / birikim hedefi / "Nasıl daha iyi…" |
//! | POST | `/api/periods/{id}/close` | dönemi kapat: devir + birikime aktarılan (transaction) |
//! | POST | `/api/periods/{id}/reopen` | **sahip:** kapalı dönemi düzeltmeye aç (`correcting`) |
//! | POST | `/api/periods/{id}/to-savings` | kapanıştaki birikimi TL varlığına ekle (bkz. `routes/asset.rs`) |
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
extract::ValidJson,
models::period::{Period, PeriodStatus},
services::{
calendar::{days_between, format_date, parse_date, weeks},
closing,
period::{self, NewPeriod, PeriodChanges},
},
state::AppState,
};
// ---------- İstek / yanıt ----------
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "PeriodCreateRequest", optional_fields)]
pub struct CreateRequest {
/// `"YYYY-MM-DD"`, dahil
start_date: String,
/// `"YYYY-MM-DD"`, hariç (ör. Aralık için `2027-01-01`)
end_date: String,
/// Boşsa otomatik ("Aralık 2026")
name: Option<String>,
/// Kuruş
#[ts(type = "number", optional)]
savings_goal: Option<i64>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "PeriodUpdateRequest", optional_fields)]
pub struct UpdateRequest {
name: Option<String>,
start_date: Option<String>,
end_date: Option<String>,
#[ts(type = "number", optional)]
savings_goal: Option<i64>,
/// "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır
reflection_md: Option<String>,
version: Option<i32>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "Period")]
pub struct PeriodDto {
id: String,
name: String,
/// Dahil
start_date: String,
/// Hariç (veritabanındaki değer)
end_date: String,
/// Son gün, dahil (gösterim için)
last_date: String,
days: u32,
status: PeriodStatus,
/// Kuruş
#[ts(type = "number")]
savings_goal: i64,
/// "Nasıl daha iyi yapabilirim?" (Markdown)
reflection_md: Option<String>,
/// Kapanışta birikime aktarılan (kuruş); kapanmamışsa `null`
#[ts(type = "number | null")]
to_savings: Option<i64>,
/// Kapanış zamanı (ISO 8601)
closed_at: Option<String>,
version: i32,
weeks: Vec<WeekDto>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "PeriodWeek")]
struct WeekDto {
/// 1'den başlar
index: u32,
start_date: String,
/// Dahil
last_date: String,
days: u32,
}
impl From<Period> for PeriodDto {
fn from(p: Period) -> Self {
// Veritabanındaki tarihler her zaman doğrulanmış biçimde yazılır
let start = parse_date(&p.start_date).expect("geçerli başlangıç tarihi");
let end = parse_date(&p.end_date).expect("geçerli bitiş tarihi");
Self {
id: p.id.to_string(),
name: p.name,
last_date: format_date(end - time::Duration::days(1)),
days: days_between(start, end) as u32,
start_date: p.start_date,
end_date: p.end_date,
status: p.status,
savings_goal: p.savings_goal,
reflection_md: p.reflection_md,
to_savings: p.to_savings,
closed_at: p.closed_at.and_then(|d| d.try_to_rfc3339_string().ok()),
version: p.version,
weeks: weeks(start, end)
.into_iter()
.map(|w| WeekDto {
index: w.index,
start_date: format_date(w.start),
last_date: format_date(w.last),
days: w.days,
})
.collect(),
}
}
}
fn parse_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
// ---------- Uç noktalar ----------
pub async fn list(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<Vec<PeriodDto>>> {
let items = period::list(&s.db, auth.household_id).await?;
Ok(Json(items.into_iter().map(PeriodDto::from).collect()))
}
pub async fn current(
State(s): State<AppState>,
auth: AuthUser,
) -> AppResult<Json<Option<PeriodDto>>> {
let p = period::current(&s.db, auth.household_id).await?;
Ok(Json(p.map(PeriodDto::from)))
}
pub async fn create(
State(s): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<PeriodDto>)> {
let p = period::create(
&s.db,
auth.household_id,
NewPeriod {
start_date: req.start_date,
end_date: req.end_date,
name: req.name,
savings_goal: req.savings_goal,
},
)
.await?;
Ok((StatusCode::CREATED, Json(p.into())))
}
pub async fn get(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<PeriodDto>> {
let p = period::get(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(Json(p.into()))
}
pub async fn update(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<UpdateRequest>,
) -> AppResult<Json<PeriodDto>> {
let p = period::update(
&s.db,
auth.household_id,
parse_id(&id)?,
PeriodChanges {
name: req.name,
start_date: req.start_date,
end_date: req.end_date,
savings_goal: req.savings_goal,
reflection_md: req.reflection_md,
version: req.version,
},
)
.await?;
Ok(Json(p.into()))
}
// ---------- Kapatma / yeniden açma ----------
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "PeriodCloseRequest", optional_fields)]
pub struct CloseRequest {
/// Birikime aktarılacak (kuruş); verilmezse gerçekleşen birikimin tamamı. Kalanı artı olarak devreder.
#[ts(type = "number", optional)]
to_savings: Option<i64>,
version: Option<i32>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "PeriodCloseResult")]
pub struct CloseResultDto {
period: PeriodDto,
/// Değerlendirmenin 4. sorusu (negatifse açık)
#[ts(type = "number")]
realized_savings: i64,
#[ts(type = "number")]
to_savings: i64,
/// Sonraki döneme devreden; yoksa `null`
carryover: Option<CarryoverDto>,
/// Birikimlerde bu döneme bağlı kayıt var (yeniden kapatmada yeni tutara güncellendi).
/// `false` ve `to_savings > 0` → arayüz "Birikime ekle" önerir.
savings_linked: bool,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "Carryover")]
struct CarryoverDto {
/// `debt` (borç) · `surplus` (artı)
#[ts(type = "\"debt\" | \"surplus\"")]
kind: &'static str,
#[ts(type = "number")]
amount: i64,
/// Sonraki dönem henüz açılmadıysa `null` (açılınca bağlanır)
to_period_id: Option<String>,
}
pub async fn close(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<CloseRequest>,
) -> AppResult<Json<CloseResultDto>> {
let c = closing::close(
&s.db,
auth.household_id,
auth.user_id,
parse_id(&id)?,
req.to_savings,
req.version,
)
.await?;
Ok(Json(CloseResultDto {
period: c.period.into(),
realized_savings: c.realized_savings,
to_savings: c.to_savings,
savings_linked: c.savings_linked,
carryover: c.carryover.map(|x| CarryoverDto {
kind: x.kind.as_str(),
amount: x.amount,
to_period_id: x.to_period_id.map(|u| u.to_string()),
}),
}))
}
pub async fn reopen(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<PeriodDto>> {
auth.require_owner()?;
let p = closing::reopen(&s.db, auth.household_id, auth.user_id, parse_id(&id)?).await?;
Ok(Json(p.into()))
}
+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,
}))
}
+42
View File
@@ -0,0 +1,42 @@
//! 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
//! - 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
use std::path::Path;
use axum::{
Router,
extract::Request,
http::{HeaderValue, header},
middleware::{self, Next},
response::Response,
};
use tower_http::services::{ServeDir, ServeFile};
pub fn router(dir: &Path) -> Router {
let files = ServeDir::new(dir).fallback(ServeFile::new(dir.join("index.html")));
Router::new()
.fallback_service(files)
.layer(middleware::from_fn(cache_headers))
}
async fn cache_headers(req: Request, next: Next) -> Response {
let is_asset = req.uri().path().starts_with("/assets/");
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
}
+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,
}))
}
View File
+149
View File
@@ -0,0 +1,149 @@
//! Hesap işlemleri: kayıt.
//!
//! İki yol var:
//! - **Davetsiz:** yeni hane + `owner` kullanıcı + örnek kategori/ödeme türleri
//! - **Davet koduyla:** kod kullanılır, mevcut haneye `member` olarak katılınır
use mongodb::{
ClientSession, Database,
bson::{Bson, Document, Uuid, doc},
};
use crate::{
config::RegistrationMode,
db::{col, is_duplicate_key},
error::{AppError, AppResult},
models::{
common::{new_id, new_meta},
household::default_settings,
user::Role,
},
services::{invite, seed},
};
pub struct NewAccount {
pub name: String,
pub email: String,
pub password_hash: String,
pub household_name: String,
pub invite_code: Option<String>,
}
/// Şu an davetsiz (yeni hane kurarak) kayıt yapılabilir mi?
pub async fn new_household_allowed(db: &Database, mode: RegistrationMode) -> AppResult<bool> {
Ok(match mode {
RegistrationMode::Open => true,
// Hiç hane yoksa ilk kurulum serbest (sunucu sahibi kendi hesabını açar)
RegistrationMode::InviteOnly => {
db.collection::<Document>(col::HOUSEHOLDS)
.count_documents(doc! {})
.limit(1)
.await?
== 0
}
})
}
pub struct CreatedAccount {
pub user_id: Uuid,
pub household_id: Uuid,
pub role: Role,
}
/// Kaydı **tek transaction** içinde yapar: herhangi bir adım başarısız olursa
/// hiçbir kayıt oluşmaz ve (varsa) davet kodu kullanılmamış sayılır.
pub async fn register(
db: &Database,
mode: RegistrationMode,
acc: NewAccount,
) -> AppResult<CreatedAccount> {
// Davetsiz kayıt = yeni hane → politika kontrolü (arayüzden bağımsız, API'de zorunlu)
if acc.invite_code.is_none() && !new_household_allowed(db, mode).await? {
return Err(AppError::RegistrationClosed);
}
let user_id = new_id();
let mut session = db.client().start_session().await?;
session.start_transaction().await?;
let result = create_in_tx(db, &mut session, user_id, acc).await;
match result {
Ok(created) => {
session.commit_transaction().await?;
Ok(created)
}
Err(e) => {
let _ = session.abort_transaction().await;
Err(match e {
AppError::Database(ref err) if is_duplicate_key(err) => {
AppError::Conflict("Bu e-posta adresi zaten kayıtlı".into())
}
other => other,
})
}
}
}
async fn create_in_tx(
db: &Database,
session: &mut ClientSession,
user_id: Uuid,
acc: NewAccount,
) -> AppResult<CreatedAccount> {
let docs = |name| db.collection::<Document>(name);
let (household_id, role) = match acc.invite_code.as_deref() {
Some(code) => (
invite::claim(db, session, code, user_id).await?,
Role::Member,
),
None => {
let household_id = new_id();
// Hanede household_id = _id (DATA_MODEL §3.2)
let mut household = new_meta(household_id);
household.insert("_id", household_id);
household.insert("name", acc.household_name);
household.insert("owner_id", user_id);
household.insert("settings", default_settings());
docs(col::HOUSEHOLDS)
.insert_one(household)
.session(&mut *session)
.await?;
docs(col::CATEGORIES)
.insert_many(seed::categories(household_id))
.session(&mut *session)
.await?;
docs(col::PAYMENT_METHODS)
.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)
}
};
let mut user = new_meta(household_id);
user.insert("_id", user_id);
user.insert("email", acc.email);
user.insert("password_hash", acc.password_hash);
user.insert("name", acc.name);
user.insert("role", role.as_str());
user.insert("preferences", doc! { "theme": "system" });
user.insert("last_login_at", Bson::Null);
docs(col::USERS)
.insert_one(user)
.session(&mut *session)
.await?;
Ok(CreatedAccount {
user_id,
household_id,
role,
})
}
+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),
))
}
+613
View File
@@ -0,0 +1,613 @@
//! 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(" ")
}
/// 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 = rules.get(&rule_key(&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 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
);
}
}
+581
View File
@@ -0,0 +1,581 @@
//! Bütçe ve dönem özeti (KAKEIBO_PROMPT §8.3, DATA_MODEL.md §3.8, §5).
//!
//! - Her grup için **aylık** tutar (`budgets`, `scope: "month"`).
//! - Haftalık tutar saklanmaz; aylık tutar hafta gün sayısına göre bölünür.
//! Kullanıcı bir haftayı elle değiştirirse yalnızca o kayıt saklanır (`scope: "week"`);
//! kalan tutar diğer haftalara gün oranında dağıtılır → haftaların toplamı = aylık.
//! - Özet: gelir − sabit gider − birikim − devreden borç + devreden artı = **harcanabilir**.
//! - Gerçekleşen harcama (`entries`) saklanmaz; her istekte toplanır:
//! özette toplam + kalan, bütçe görünümünde grup ve hafta bazında.
use std::collections::HashMap;
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
};
use serde::Deserialize;
use crate::{
db::col,
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
group::Group,
period::{Period, PeriodStatus},
},
services::{
calendar::{format_date, parse_date, weeks},
money::split_proportional,
period,
},
};
/// Gruplara göre tutarlar (kuruş), `Group::ALL` sırasıyla.
pub type GroupAmounts = [i64; 4];
pub struct Summary {
pub income_total: i64,
pub fixed_expense_total: i64,
pub savings_goal: i64,
pub carryover_debt: i64,
pub carryover_surplus: i64,
pub spendable: i64,
pub budget_total: i64,
/// Dönemde gerçekleşen harcama (silinenler hariç)
pub spent_total: i64,
/// Aynısı gruplara göre (`Group::ALL` sırasıyla)
pub spent_by_group: GroupAmounts,
}
pub struct WeekBudget {
pub index: u32,
pub start_date: String,
pub last_date: String,
pub days: u32,
pub amount: i64,
pub is_manual: bool,
/// Bu haftada bu grupta gerçekleşen harcama
pub spent: i64,
}
pub struct GroupBudget {
pub group: Group,
pub monthly: i64,
/// Dönem boyunca bu grupta gerçekleşen harcama
pub spent: i64,
pub weeks: Vec<WeekBudget>,
/// Elle girilen haftaların toplamı aylık tutarı aşıyor mu?
pub weekly_over: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SuggestMethod {
/// Hanenin varsayılan yüzdeleri × harcanabilir tutar
Default,
/// Önceki dönemde gerçekleşen harcamalar
Previous,
/// Son 3 dönemin gerçekleşen harcama ortalaması
Avg3,
}
pub struct Suggestion {
pub available: bool,
pub message: Option<String>,
pub amounts: GroupAmounts,
}
#[derive(Deserialize)]
struct BudgetDoc {
group: Group,
scope: String,
#[serde(default)]
week_start: Option<String>,
amount: i64,
}
// ---------- Özet ----------
pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Summary> {
let p = period::get(db, household_id, period_id).await?;
let mut income_total = 0;
let mut fixed_expense_total = 0;
for (kind, total) in sum_by(
db,
col::PLAN_ITEMS,
household_id,
doc! { "period_id": period_id },
"$kind",
)
.await?
{
match kind.as_str() {
"income" => income_total = total,
"expense" => fixed_expense_total = total,
_ => {}
}
}
let mut carryover_debt = 0;
let mut carryover_surplus = 0;
for (kind, total) in sum_by(
db,
col::CARRYOVERS,
household_id,
doc! { "to_period_id": period_id },
"$kind",
)
.await?
{
match kind.as_str() {
"debt" => carryover_debt = total,
"surplus" => carryover_surplus = total,
_ => {}
}
}
let budget_total = monthly_amounts(db, household_id, period_id)
.await?
.iter()
.sum();
let spendable =
income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus;
let by_group = sum_by(
db,
col::ENTRIES,
household_id,
doc! { "period_id": period_id },
"$group",
)
.await?;
let spent_by_group = Group::ALL.map(|g| by_group.get(g.as_str()).copied().unwrap_or(0));
let spent_total = spent_by_group.iter().sum();
Ok(Summary {
income_total,
fixed_expense_total,
savings_goal: p.savings_goal,
carryover_debt,
carryover_surplus,
spendable,
budget_total,
spent_total,
spent_by_group,
})
}
// ---------- Bütçe görünümü ----------
pub async fn view(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<Vec<GroupBudget>> {
let p = period::get(db, household_id, period_id).await?;
let docs = load(db, household_id, period_id).await?;
let (start, end) = dates(&p)?;
let period_weeks = weeks(start, end);
let spent = spent_by_group_and_date(db, household_id, period_id).await?;
let mut out = Vec::with_capacity(4);
for group in Group::ALL {
let monthly = docs
.iter()
.find(|d| d.group == group && d.scope == "month")
.map_or(0, |d| d.amount);
// Elle girilmiş haftalar (dönem tarihleri değiştiyse eşleşmeyen eski kayıtlar yok sayılır)
let manual: HashMap<String, i64> = docs
.iter()
.filter(|d| d.group == group && d.scope == "week")
.filter_map(|d| d.week_start.clone().map(|w| (w, d.amount)))
.collect();
let manual_total: i64 = period_weeks
.iter()
.filter_map(|w| manual.get(&format_date(w.start)))
.sum();
let auto_days: Vec<u64> = period_weeks
.iter()
.filter(|w| !manual.contains_key(&format_date(w.start)))
.map(|w| u64::from(w.days))
.collect();
let mut auto = split_proportional(monthly - manual_total, &auto_days).into_iter();
let weeks = period_weeks
.iter()
.map(|w| {
let key = format_date(w.start);
let last = format_date(w.last);
// Tarihler "YYYY-MM-DD" → metin karşılaştırması tarih sırasıyla aynı
let week_spent = spent
.iter()
.filter(|(g, d, _)| *g == group && *d >= key && *d <= last)
.map(|(_, _, a)| a)
.sum();
let (amount, is_manual) = match manual.get(&key) {
Some(&a) => (a, true),
None => (auto.next().unwrap_or(0), false),
};
WeekBudget {
index: w.index,
start_date: key,
last_date: last,
days: w.days,
amount,
is_manual,
spent: week_spent,
}
})
.collect();
out.push(GroupBudget {
group,
monthly,
spent: spent
.iter()
.filter(|(g, _, _)| *g == group)
.map(|(_, _, a)| a)
.sum(),
weeks,
weekly_over: manual_total > monthly,
});
}
Ok(out)
}
// ---------- Yazma ----------
/// 4 grubun aylık tutarını birlikte kaydeder (elle girilmiş haftalar korunur).
pub async fn set_monthly(
db: &Database,
household_id: Uuid,
period_id: Uuid,
amounts: GroupAmounts,
) -> AppResult<()> {
period::get_open(db, household_id, period_id).await?;
for a in amounts {
period::validate_amount(a)?;
}
for (group, amount) in Group::ALL.into_iter().zip(amounts) {
upsert(db, household_id, period_id, group, "month", None, amount).await?;
}
Ok(())
}
/// Bir haftayı elle ayarlar; `amount = None` → otomatiğe döner.
pub async fn set_week(
db: &Database,
household_id: Uuid,
period_id: Uuid,
group: Group,
week_start: &str,
amount: Option<i64>,
) -> AppResult<()> {
let p = period::get_open(db, household_id, period_id).await?;
let (start, end) = dates(&p)?;
let valid =
parse_date(week_start).is_some_and(|d| weeks(start, end).iter().any(|w| w.start == d));
if !valid {
return Err(AppError::BadRequest("Hafta bu döneme ait değil".into()));
}
match amount {
Some(a) => {
period::validate_amount(a)?;
upsert(
db,
household_id,
period_id,
group,
"week",
Some(week_start),
a,
)
.await
}
None => {
let mut f = scope(household_id);
f.insert("period_id", period_id);
f.insert("group", group.as_str());
f.insert("scope", "week");
f.insert("week_start", week_start);
let ts = now();
db.collection::<Document>(col::BUDGETS)
.update_many(
f,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
Ok(())
}
}
}
// ---------- Öneri ----------
pub async fn suggest(
db: &Database,
household_id: Uuid,
period_id: Uuid,
method: SuggestMethod,
) -> AppResult<Suggestion> {
let p = period::get(db, household_id, period_id).await?;
match method {
SuggestMethod::Default => {
let s = summary(db, household_id, period_id).await?;
if s.spendable <= 0 {
return Ok(unavailable(
"Harcanabilir tutar sıfır ya da negatif. Önce gelir, gider ve birikim hedefini girin.",
));
}
let pct = split_percentages(db, household_id).await?;
let parts = split_proportional(s.spendable, &pct);
Ok(Suggestion {
available: true,
message: None,
amounts: [parts[0], parts[1], parts[2], parts[3]],
})
}
SuggestMethod::Previous | SuggestMethod::Avg3 => {
let n = if method == SuggestMethod::Previous {
1
} else {
3
};
let previous = previous_closed(db, household_id, &p, n).await?;
if previous.is_empty() {
return Ok(unavailable("Henüz kapanmış bir önceki dönem yok."));
}
let ids: Vec<Bson> = previous.iter().map(|id| Bson::from(*id)).collect();
let sums = sum_by(
db,
col::ENTRIES,
household_id,
doc! { "period_id": { "$in": ids } },
"$group",
)
.await?;
let total: i64 = sums.values().sum();
if total == 0 {
return Ok(unavailable("Önceki dönemlerde henüz harcama kaydı yok."));
}
let count = previous.len() as i64;
let avg = |g: Group| {
let s = sums.get(g.as_str()).copied().unwrap_or(0);
(s + count / 2) / count // yuvarlayarak bölme
};
Ok(Suggestion {
available: true,
message: None,
amounts: Group::ALL.map(avg),
})
}
}
}
// ---------- yardımcılar ----------
fn dates(p: &Period) -> AppResult<(time::Date, time::Date)> {
let bad = || AppError::Internal("dönem tarihi okunamadı".into());
Ok((
parse_date(&p.start_date).ok_or_else(bad)?,
parse_date(&p.end_date).ok_or_else(bad)?,
))
}
fn unavailable(msg: &str) -> Suggestion {
Suggestion {
available: false,
message: Some(msg.into()),
amounts: [0; 4],
}
}
async fn load(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Vec<BudgetDoc>> {
let mut f = scope(household_id);
f.insert("period_id", period_id);
let mut cursor = db.collection::<BudgetDoc>(col::BUDGETS).find(f).await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?);
}
Ok(out)
}
async fn monthly_amounts(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<GroupAmounts> {
let docs = load(db, household_id, period_id).await?;
Ok(Group::ALL.map(|g| {
docs.iter()
.find(|d| d.group == g && d.scope == "month")
.map_or(0, |d| d.amount)
}))
}
async fn upsert(
db: &Database,
household_id: Uuid,
period_id: Uuid,
group: Group,
level: &str,
week_start: Option<&str>,
amount: i64,
) -> AppResult<()> {
let coll = db.collection::<Document>(col::BUDGETS);
let mut f = scope(household_id);
f.insert("period_id", period_id);
f.insert("group", group.as_str());
f.insert("scope", level);
f.insert(
"week_start",
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
);
let res = coll
.update_one(
f,
doc! { "$set": { "amount": amount, "updated_at": now() }, "$inc": { "version": 1 } },
)
.await?;
if res.matched_count == 0 {
let mut d = new_meta(household_id);
d.insert("period_id", period_id);
d.insert("group", group.as_str());
d.insert("scope", level);
d.insert(
"week_start",
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
);
d.insert("amount", amount);
coll.insert_one(d).await?;
}
Ok(())
}
/// Bir koleksiyonda `amount` alanını bir alana göre toplar: `{ "income": 450000, ... }`
async fn sum_by(
db: &Database,
collection: &str,
household_id: Uuid,
filter: Document,
key: &str,
) -> AppResult<HashMap<String, i64>> {
let mut m = scope(household_id);
m.extend(filter);
let pipeline = vec![
doc! { "$match": m },
doc! { "$group": { "_id": key, "total": { "$sum": "$amount" } } },
];
let mut cursor = db
.collection::<Document>(collection)
.aggregate(pipeline)
.await?;
let mut out = HashMap::new();
while cursor.advance().await? {
let d = cursor.deserialize_current()?;
let k = d.get_str("_id").unwrap_or_default().to_string();
let v = match d.get("total") {
Some(Bson::Int64(v)) => *v,
Some(Bson::Int32(v)) => i64::from(*v),
_ => 0,
};
out.insert(k, v);
}
Ok(out)
}
/// Dönemin harcamaları (grup, gün) bazında toplanmış: en fazla 4 × 62 satır.
pub(crate) async fn spent_by_group_and_date(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<Vec<(Group, String, i64)>> {
#[derive(Deserialize)]
struct Key {
group: Group,
date: String,
}
#[derive(Deserialize)]
struct Row {
#[serde(rename = "_id")]
key: Key,
total: i64,
}
let mut m = scope(household_id);
m.insert("period_id", period_id);
let pipeline = vec![
doc! { "$match": m },
doc! { "$group": {
"_id": { "group": "$group", "date": "$date" },
"total": { "$sum": "$amount" },
} },
];
let mut cursor = db
.collection::<Document>(col::ENTRIES)
.aggregate(pipeline)
.with_type::<Row>()
.await?;
let mut out = Vec::new();
while cursor.advance().await? {
let r = cursor.deserialize_current()?;
out.push((r.key.group, r.key.date, r.total));
}
Ok(out)
}
/// Hane ayarındaki varsayılan yüzdeler (yoksa 50/30/10/10).
async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult<Vec<u64>> {
let mut f = scope(household_id);
f.insert("_id", household_id);
let hh = db
.collection::<Document>(col::HOUSEHOLDS)
.find_one(f)
.await?;
let pct = hh
.as_ref()
.and_then(|h| h.get_document("settings").ok())
.and_then(|s| s.get_document("budget_split_pct").ok());
let default = [50u64, 30, 10, 10];
Ok(Group::ALL
.iter()
.zip(default)
.map(|(g, d)| {
pct.and_then(|p| match p.get(g.as_str()) {
Some(Bson::Int32(v)) => Some(*v as u64),
Some(Bson::Int64(v)) => Some(*v as u64),
_ => None,
})
.unwrap_or(d)
})
.collect())
}
/// Bu dönemden önceki en yeni `n` kapalı **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)
}
+181
View File
@@ -0,0 +1,181 @@
//! Takvim hesapları: tarih okuma/yazma, dönem haftaları, Türkçe ay adları.
//!
//! Tarihler veritabanında `"YYYY-MM-DD"` metni olarak tutulur (DATA_MODEL.md §1.3).
//! Dönem bitişi **hariçtir**: `2026-12-01 – 2027-01-01` = 1–31 Aralık.
use time::{Date, Duration, Month};
const MONTHS: [&str; 12] = [
"Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz", "Ağustos", "Eylül", "Ekim",
"Kasım", "Aralık",
];
const MONTHS_SHORT: [&str; 12] = [
"Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara",
];
/// `"2026-12-09"` → `Date`. Biçim ya da tarih geçersizse `None` (ör. `2026-02-30`).
pub fn parse_date(s: &str) -> Option<Date> {
let b = s.as_bytes();
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return None;
}
let year: i32 = s[0..4].parse().ok()?;
let month: u8 = s[5..7].parse().ok()?;
let day: u8 = s[8..10].parse().ok()?;
Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
}
/// `Date` → `"2026-12-09"`
/// Türkiye'de bugünün tarihi (UTC+3, yaz saati yok). Sunucu UTC'de çalışsa da gün doğru olsun.
pub fn today_tr() -> Date {
time::OffsetDateTime::now_utc()
.to_offset(time::UtcOffset::from_hms(3, 0, 0).expect("geçerli ofset"))
.date()
}
pub fn format_date(d: Date) -> String {
format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day())
}
/// Bitişi hariç aralığın gün sayısı.
pub fn days_between(start: Date, end_exclusive: Date) -> i64 {
(end_exclusive - start).whole_days()
}
/// Dönem içindeki bir hafta. Son gün dahildir (`last`), gösterim kolaylığı için.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Week {
/// 1'den başlar
pub index: u32,
pub start: Date,
pub last: Date,
pub days: u32,
}
/// Dönemi Pazartesi başlangıçlı haftalara böler.
/// Baştaki ve sondaki kısmi haftalar ayrı hafta sayılır (KAKEIBO_PROMPT §6).
pub fn weeks(start: Date, end_exclusive: Date) -> Vec<Week> {
let mut out = Vec::new();
let mut cursor = start;
while cursor < end_exclusive {
// Bir sonraki Pazartesi (ya da dönem sonu, hangisi önce gelirse)
let to_monday = 7 - i64::from(cursor.weekday().number_days_from_monday());
let next = (cursor + Duration::days(to_monday)).min(end_exclusive);
out.push(Week {
index: out.len() as u32 + 1,
start: cursor,
last: next - Duration::days(1),
days: days_between(cursor, next) as u32,
});
cursor = next;
}
out
}
/// Kullanıcı ad vermezse dönem adı:
/// - takvim ayının tamamıysa: `"Aralık 2026"`
/// - değilse: `"15 Ara 2026 – 14 Oca 2027"`
pub fn default_period_name(start: Date, end_exclusive: Date) -> String {
let last = end_exclusive - Duration::days(1);
let full_month = start.day() == 1
&& last.month() == start.month()
&& last.year() == start.year()
&& (last + Duration::days(1)).day() == 1;
if full_month {
return format!("{} {}", MONTHS[month_idx(start)], start.year());
}
format!(
"{} {} {} – {} {} {}",
start.day(),
MONTHS_SHORT[month_idx(start)],
start.year(),
last.day(),
MONTHS_SHORT[month_idx(last)],
last.year()
)
}
fn month_idx(d: Date) -> usize {
u8::from(d.month()) as usize - 1
}
#[cfg(test)]
mod tests {
use super::*;
fn d(s: &str) -> Date {
parse_date(s).unwrap()
}
#[test]
fn parse_and_format() {
assert_eq!(format_date(d("2026-12-09")), "2026-12-09");
for bad in [
"2026-02-30",
"2026-13-01",
"26-12-09",
"2026/12/09",
"",
"2026-12-9",
] {
assert!(parse_date(bad).is_none(), "{bad} geçersiz olmalı");
}
assert!(parse_date("2028-02-29").is_some(), "artık yıl");
}
#[test]
fn december_2026_weeks() {
// 1 Aralık 2026 Salı → ilk hafta 6 gün (Sal–Paz), son hafta 4 gün (Pzt–Per)
let w = weeks(d("2026-12-01"), d("2027-01-01"));
let summary: Vec<(String, String, u32)> = w
.iter()
.map(|w| (format_date(w.start), format_date(w.last), w.days))
.collect();
assert_eq!(
summary,
vec![
("2026-12-01".into(), "2026-12-06".into(), 6),
("2026-12-07".into(), "2026-12-13".into(), 7),
("2026-12-14".into(), "2026-12-20".into(), 7),
("2026-12-21".into(), "2026-12-27".into(), 7),
("2026-12-28".into(), "2026-12-31".into(), 4),
]
);
assert_eq!(w.iter().map(|w| w.days).sum::<u32>(), 31);
assert_eq!(w.last().unwrap().index, 5);
}
#[test]
fn weeks_edge_cases() {
// Pazartesi başlayan, tam 2 hafta
let w = weeks(d("2026-11-30"), d("2026-12-14"));
assert_eq!(w.len(), 2);
assert!(w.iter().all(|w| w.days == 7));
// Tek gün (Pazar)
let w = weeks(d("2026-12-06"), d("2026-12-07"));
assert_eq!((w.len(), w[0].days), (1, 1));
// Boş aralık
assert!(weeks(d("2026-12-07"), d("2026-12-07")).is_empty());
}
#[test]
fn period_names() {
assert_eq!(
default_period_name(d("2026-12-01"), d("2027-01-01")),
"Aralık 2026"
);
assert_eq!(
default_period_name(d("2027-02-01"), d("2027-03-01")),
"Şubat 2027"
);
assert_eq!(
default_period_name(d("2026-12-15"), d("2027-01-15")),
"15 Ara 2026 – 14 Oca 2027"
);
// Ayın 1'inde başlayıp ay ortasında biten
assert_eq!(
default_period_name(d("2026-12-01"), d("2026-12-16")),
"1 Ara 2026 – 15 Ara 2026"
);
}
}
+330
View File
@@ -0,0 +1,330 @@
//! Kategoriler ve ödeme türleri için ortak iş mantığı (DATA_MODEL.md §3.5, §3.6).
//!
//! İkisi de aynı şekilde çalışır: listele, ekle, düzenle, sırala, pasife al.
//! Tek fark: kategoriler bir ana gruba (`group`) bağlıdır, ödeme türleri değil.
//! **Silme yoktur** — geçmiş harcama kayıtları bozulmasın diye yalnızca pasife alınır.
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
options::ReturnDocument,
};
use serde::Deserialize;
use crate::{
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
group::Group,
},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Catalog {
Categories,
PaymentMethods,
}
impl Catalog {
fn collection(self) -> &'static str {
match self {
Catalog::Categories => crate::db::col::CATEGORIES,
Catalog::PaymentMethods => crate::db::col::PAYMENT_METHODS,
}
}
fn has_group(self) -> bool {
self == Catalog::Categories
}
fn noun(self) -> &'static str {
match self {
Catalog::Categories => "kategori",
Catalog::PaymentMethods => "ödeme türü",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Item {
#[serde(rename = "_id")]
pub id: Uuid,
#[serde(default)]
pub group: Option<Group>,
pub name: String,
#[serde(default)]
pub emoji: Option<String>,
pub sort_order: i32,
pub is_active: bool,
pub version: i32,
}
#[derive(Debug, Default)]
pub struct Changes {
pub name: Option<String>,
/// `Some("")` → emoji kaldırılır
pub emoji: Option<String>,
pub is_active: Option<bool>,
/// İstemcinin gördüğü sürüm; farklıysa başkası değiştirmiş demektir → 409
pub version: Option<i32>,
}
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,
c: Catalog,
household_id: Uuid,
include_inactive: bool,
) -> AppResult<Vec<Item>> {
let mut filter = scope(household_id);
if !include_inactive {
filter.insert("is_active", true);
}
let mut items = collect(coll(db, c).find(filter).await?).await?;
items.sort_by_key(|i| (i.group.map(Group::order), i.sort_order));
Ok(items)
}
pub async fn create(
db: &Database,
c: Catalog,
household_id: Uuid,
group: Option<Group>,
name: String,
emoji: Option<String>,
) -> AppResult<Item> {
let group = check_group(c, group)?;
let name = clean_name(&name)?;
ensure_unique_name(db, c, household_id, group, &name, None).await?;
// Grubun (veya listenin) en sonuna ekle
let mut last_filter = scope(household_id);
if let Some(g) = group {
last_filter.insert("group", g.as_str());
}
let last = coll(db, c)
.find_one(last_filter)
.sort(doc! { "sort_order": -1 })
.await?;
let sort_order = last.map_or(10, |i| i.sort_order + 10);
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
if let Some(g) = group {
d.insert("group", g.as_str());
}
d.insert("name", name);
d.insert("emoji", clean_emoji(emoji));
d.insert("sort_order", sort_order);
d.insert("is_active", true);
db.collection::<Document>(c.collection())
.insert_one(d)
.await?;
coll(db, c)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("eklenen kayıt okunamadı".into()))
}
pub async fn update(
db: &Database,
c: Catalog,
household_id: Uuid,
id: Uuid,
changes: Changes,
) -> AppResult<Item> {
let mut filter = scope(household_id);
filter.insert("_id", id);
let current = coll(db, c)
.find_one(filter.clone())
.await?
.ok_or(AppError::NotFound)?;
if changes.version.is_some_and(|v| v != current.version) {
return Err(stale());
}
let mut set = doc! { "updated_at": now() };
if let Some(name) = changes.name {
let name = clean_name(&name)?;
if name != current.name {
ensure_unique_name(db, c, household_id, current.group, &name, Some(id)).await?;
}
set.insert("name", name);
}
if let Some(emoji) = changes.emoji {
set.insert("emoji", clean_emoji(Some(emoji)));
}
if let Some(active) = changes.is_active {
set.insert("is_active", active);
}
// Sürüm filtresi: okuma ile yazma arasında başkası değiştirdiyse güncelleme olmaz
filter.insert("version", current.version);
coll(db, c)
.find_one_and_update(filter, doc! { "$set": set, "$inc": { "version": 1 } })
.return_document(ReturnDocument::After)
.await?
.ok_or_else(stale)
}
/// Sıralamayı verilen kimlik listesine göre yeniden yazar.
/// Liste, (kategorilerde ilgili gruptaki) **tüm** kayıtları içermelidir — pasifler dahil.
pub async fn reorder(
db: &Database,
c: Catalog,
household_id: Uuid,
group: Option<Group>,
ids: Vec<Uuid>,
) -> AppResult<Vec<Item>> {
let group = check_group(c, group)?;
let mut filter = scope(household_id);
if let Some(g) = group {
filter.insert("group", g.as_str());
}
let existing = collect(coll(db, c).find(filter).await?).await?;
let mut want: Vec<Uuid> = ids.clone();
let mut have: Vec<Uuid> = existing.iter().map(|i| i.id).collect();
want.sort_by_key(|u| u.bytes());
have.sort_by_key(|u| u.bytes());
if want != have {
return Err(AppError::BadRequest(
"Sıralama listesi eksik veya hatalı, sayfayı yenileyip tekrar deneyin".into(),
));
}
let mut session = db.client().start_session().await?;
session.start_transaction().await?;
let ts = now();
for (i, id) in ids.iter().enumerate() {
let r = db
.collection::<Document>(c.collection())
.update_one(
doc! { "_id": id, "household_id": household_id },
doc! {
"$set": { "sort_order": (i as i32 + 1) * 10, "updated_at": ts },
"$inc": { "version": 1 },
},
)
.session(&mut session)
.await;
if let Err(e) = r {
let _ = session.abort_transaction().await;
return Err(e.into());
}
}
session.commit_transaction().await?;
let all = list(db, c, household_id, true).await?;
Ok(all.into_iter().filter(|i| i.group == group).collect())
}
// ---------- yardımcılar ----------
fn check_group(c: Catalog, group: Option<Group>) -> AppResult<Option<Group>> {
match (c.has_group(), group) {
(true, None) => Err(AppError::BadRequest("Ana grup seçilmeli".into())),
(true, g) => Ok(g),
(false, _) => Ok(None), // ödeme türlerinde grup yok sayılır
}
}
fn clean_name(name: &str) -> AppResult<String> {
let name = name.trim();
match name.chars().count() {
0 => Err(AppError::BadRequest("Ad boş olamaz".into())),
1..=40 => Ok(name.to_string()),
_ => Err(AppError::BadRequest(
"Ad en fazla 40 karakter olabilir".into(),
)),
}
}
fn clean_emoji(emoji: Option<String>) -> Bson {
match emoji.map(|e| e.trim().to_string()) {
Some(e) if !e.is_empty() => Bson::String(e),
_ => Bson::Null,
}
}
/// Aynı grupta (pasifler dahil) aynı adda kayıt olmasın. Büyük/küçük harf duyarsız.
async fn ensure_unique_name(
db: &Database,
c: Catalog,
household_id: Uuid,
group: Option<Group>,
name: &str,
exclude: Option<Uuid>,
) -> AppResult<()> {
let mut filter = scope(household_id);
if let Some(g) = group {
filter.insert("group", g.as_str());
}
let wanted = name.to_lowercase();
let items = collect(coll(db, c).find(filter).await?).await?;
let clash = items
.iter()
.any(|i| Some(i.id) != exclude && i.name.to_lowercase() == wanted);
if clash {
return Err(AppError::Conflict(format!(
"Bu adla bir {} zaten var (pasif olabilir)",
c.noun()
)));
}
Ok(())
}
fn stale() -> AppError {
AppError::Conflict("Bu kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
async fn collect(mut cursor: mongodb::Cursor<Item>) -> AppResult<Vec<Item>> {
let mut v = Vec::new();
while cursor.advance().await? {
v.push(cursor.deserialize_current()?);
}
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_rules() {
assert_eq!(clean_name(" Market ").unwrap(), "Market");
assert!(clean_name(" ").is_err());
assert!(
clean_name(&"ş".repeat(40)).is_ok(),
"Türkçe karakterler tek sayılmalı"
);
assert!(clean_name(&"a".repeat(41)).is_err());
}
#[test]
fn group_required_only_for_categories() {
assert!(check_group(Catalog::Categories, None).is_err());
assert_eq!(
check_group(Catalog::PaymentMethods, Some(Group::Needs)).unwrap(),
None
);
}
}
+291
View File
@@ -0,0 +1,291 @@
//! Dönem kapatma ve yeniden açma (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.10, §6).
//!
//! Kapatma (tek transaction):
//! 1. Dönem `closed` olur (`closed_at`, `closed_by`, `to_savings`).
//! 2. Bu dönemden önceki devirler (yeniden kapatmada) soft delete edilir.
//! 3. Gerçekleşen birikim (değerlendirmenin 4. sorusu) dağıtılır:
//! - < 0 → açık **borç** olarak devreder (`debt`).
//! - ≥ 0 → `to_savings` kadarı birikime (varsayılan: tamamı), kalanı **artı** olarak devreder (`surplus`).
//! 4. Devir sonraki döneme bağlanır; sonraki dönem henüz yoksa `to_period_id: null` kalır
//! ve o dönem açılınca bağlanır (`period::create`).
//!
//! Yeniden açma (yalnızca sahip): dönem `correcting` olur, yeni açık dönem varsa o da açık kalır.
//! Mevcut devir, "yeniden kapat" denene kadar yerinde kalır (sonraki dönemin rakamları oynamaz).
//! Ardından kapanmış dönem olan bir dönem açılamaz: sonraki devirler zincirleme bozulurdu.
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
};
use crate::{
db::col,
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
period::{Period, PeriodStatus},
},
services::{asset, period, review},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CarryKind {
Debt,
Surplus,
}
impl CarryKind {
pub fn as_str(self) -> &'static str {
match self {
CarryKind::Debt => "debt",
CarryKind::Surplus => "surplus",
}
}
}
pub struct Carry {
pub kind: CarryKind,
pub amount: i64,
pub to_period_id: Option<Uuid>,
}
pub struct Closing {
pub period: Period,
pub realized_savings: i64,
pub to_savings: i64,
pub carryover: Option<Carry>,
/// Birikime eklenmiş kayıt var (yeniden kapatmada güncellendi)
pub savings_linked: bool,
}
/// Gerçekleşen birikimi birikim / devir olarak böler. `to_savings` verilmezse tamamı birikime.
pub fn settle(
realized: i64,
to_savings: Option<i64>,
) -> AppResult<(i64, Option<(CarryKind, i64)>)> {
if realized < 0 {
if to_savings.unwrap_or(0) != 0 {
return Err(AppError::BadRequest(
"Bu dönemde birikim yok; açık, borç olarak sonraki döneme devreder".into(),
));
}
return Ok((0, Some((CarryKind::Debt, -realized))));
}
let t = to_savings.unwrap_or(realized);
if !(0..=realized).contains(&t) {
return Err(AppError::BadRequest(
"Birikime aktarılacak tutar 0 ile gerçekleşen birikim arasında olmalı".into(),
));
}
let surplus = realized - t;
Ok((t, (surplus > 0).then_some((CarryKind::Surplus, surplus))))
}
pub async fn close(
db: &Database,
household_id: Uuid,
user_id: Uuid,
period_id: Uuid,
to_savings: Option<i64>,
version: Option<i32>,
) -> AppResult<Closing> {
let p = period::get(db, household_id, period_id).await?;
if p.status == PeriodStatus::Archived {
return Err(AppError::Conflict(
"Arşiv dönemi kapatılmaz; yalnızca kayıt tutar".into(),
));
}
if p.status == PeriodStatus::Closed {
return Err(AppError::Conflict("Bu dönem zaten kapalı".into()));
}
if version.is_some_and(|v| v != p.version) {
return Err(period::stale());
}
let realized = review::review(db, household_id, period_id)
.await?
.realized_savings;
let (to_savings, carry) = settle(realized, to_savings)?;
let next = next_period(db, household_id, &p).await?;
// Yeniden kapatma: birikime eklenmiş kayıt varsa yeni tutara uyarlanır (önce denetlenir)
let savings_sync =
asset::plan_savings_sync(db, household_id, period_id, to_savings, &p.end_date).await?;
let ts = now();
let mut session = db.client().start_session().await?;
session.start_transaction().await?;
let result = async {
let mut f = scope(household_id);
f.insert("_id", period_id);
f.insert("version", p.version);
let r = db
.collection::<Document>(col::PERIODS)
.update_one(
f,
doc! {
"$set": {
"status": PeriodStatus::Closed.as_str(),
"closed_at": ts,
"closed_by": user_id,
"to_savings": to_savings,
"updated_at": ts,
},
"$inc": { "version": 1 },
},
)
.session(&mut session)
.await?;
if r.matched_count == 0 {
return Err(period::stale());
}
let mut old = scope(household_id);
old.insert("from_period_id", period_id);
db.collection::<Document>(col::CARRYOVERS)
.update_many(
old,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.session(&mut session)
.await?;
if let Some(sync) = &savings_sync {
asset::apply_savings_sync(db, &mut session, household_id, sync).await?;
}
if let Some((kind, amount)) = carry {
let mut d = new_meta(household_id);
d.insert("from_period_id", period_id);
d.insert(
"to_period_id",
next.as_ref().map_or(Bson::Null, |n| n.id.into()),
);
d.insert("kind", kind.as_str());
d.insert("amount", amount);
db.collection::<Document>(col::CARRYOVERS)
.insert_one(d)
.session(&mut session)
.await?;
}
Ok(())
}
.await;
match result {
Ok(()) => session.commit_transaction().await?,
Err(e) => {
let _ = session.abort_transaction().await;
return Err(e);
}
}
Ok(Closing {
savings_linked: matches!(savings_sync, Some(asset::SavingsSync::Update { .. })),
period: period::get(db, household_id, period_id).await?,
realized_savings: realized,
to_savings,
carryover: carry.map(|(kind, amount)| Carry {
kind,
amount,
to_period_id: next.map(|n| n.id),
}),
})
}
/// Kapalı dönemi düzeltme için açar (yalnızca sahip — rota denetler).
pub async fn reopen(
db: &Database,
household_id: Uuid,
user_id: Uuid,
period_id: Uuid,
) -> AppResult<Period> {
let p = period::get(db, household_id, period_id).await?;
if p.status != PeriodStatus::Closed {
return Err(AppError::Conflict(
"Yalnızca kapalı bir dönem yeniden açılabilir".into(),
));
}
let mut later = scope(household_id);
later.insert("start_date", doc! { "$gte": &p.end_date });
// Arşiv dönemleri devir üretmediği için engel değildir
later.insert(
"status",
doc! { "$nin": [PeriodStatus::Open.as_str(), PeriodStatus::Archived.as_str()] },
);
if db
.collection::<Document>(col::PERIODS)
.find_one(later)
.await?
.is_some()
{
return Err(AppError::Conflict(
"Bu dönemden sonra kapanmış bir dönem var. Yalnızca en son kapanan dönem yeniden açılabilir."
.into(),
));
}
let ts = now();
let mut f = scope(household_id);
f.insert("_id", period_id);
f.insert("version", p.version);
let r = db
.collection::<Document>(col::PERIODS)
.update_one(
f,
doc! {
"$set": {
"status": PeriodStatus::Correcting.as_str(),
"reopened_at": ts,
"reopened_by": user_id,
"updated_at": ts,
},
"$inc": { "version": 1 },
},
)
.await?;
if r.matched_count == 0 {
return Err(period::stale());
}
period::get(db, household_id, period_id).await
}
/// Bu dönemden hemen sonra gelen dönem (varsa)
async fn next_period(db: &Database, household_id: Uuid, p: &Period) -> AppResult<Option<Period>> {
let mut f = scope(household_id);
f.insert("start_date", doc! { "$gte": &p.end_date });
// Devir arşiv dönemine bağlanmaz (arşiv devir zincirinde yok)
f.insert("status", doc! { "$ne": PeriodStatus::Archived.as_str() });
Ok(db
.collection::<Period>(col::PERIODS)
.find_one(f)
.sort(doc! { "start_date": 1 })
.await?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settle_rules() {
// Artı: varsayılan tamamı birikime
assert_eq!(settle(700, None).unwrap(), (700, None));
assert_eq!(
settle(700, Some(500)).unwrap(),
(500, Some((CarryKind::Surplus, 200)))
);
assert_eq!(
settle(700, Some(0)).unwrap(),
(0, Some((CarryKind::Surplus, 700)))
);
assert!(settle(700, Some(701)).is_err());
assert!(settle(700, Some(-1)).is_err());
// Sıfır: devir yok
assert_eq!(settle(0, None).unwrap(), (0, None));
// Açık: borç; birikim istenemez
assert_eq!(settle(-50, None).unwrap(), (0, Some((CarryKind::Debt, 50))));
assert_eq!(
settle(-50, Some(0)).unwrap(),
(0, Some((CarryKind::Debt, 50)))
);
assert!(settle(-50, Some(10)).is_err());
}
}
+280
View File
@@ -0,0 +1,280 @@
//! CSV okuma/yazma yardımcıları (saf; veritabanı yok). Türkçe Excel uyumlu:
//! yazarken UTF-8 BOM, `;` ayırıcı, CRLF, tutar `1250,50`, tarih `GG.AA.YYYY`.
//! Okurken esnek: BOM, `;` / `,` / sekme (başlık satırından anlaşılır), tırnaklı alanlar,
//! tutar `1.250,50` · `1250,50` · `1250.50`, tarih `13.11.2026` · `13/11/2026` · `2026-11-13`.
use crate::services::calendar::{format_date, parse_date};
pub const BOM: &str = "\u{FEFF}";
// ---------- Yazma ----------
fn quote(s: &str) -> String {
if s.contains([';', '"', '\n', '\r']) {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
/// Başlık + satırlar → CSV metni (BOM'lu)
pub fn write(headers: &[&str], rows: &[Vec<String>]) -> String {
let mut out = String::from(BOM);
let line = |cells: Vec<String>| cells.iter().map(|c| quote(c)).collect::<Vec<_>>().join(";");
out.push_str(&line(headers.iter().map(|h| h.to_string()).collect()));
out.push_str("\r\n");
for r in rows {
out.push_str(&line(r.clone()));
out.push_str("\r\n");
}
out
}
/// Kuruş → `1250,50`
pub fn money(kurus: i64) -> String {
let sign = if kurus < 0 { "-" } else { "" };
let a = kurus.unsigned_abs();
format!("{sign}{},{:02}", a / 100, a % 100)
}
/// × 1e6 → `5,5` (gereksiz sıfırsız)
pub fn e6(v: i64) -> String {
let sign = if v < 0 { "-" } else { "" };
let a = v.unsigned_abs();
let frac = format!("{:06}", a % 1_000_000);
let frac = frac.trim_end_matches('0');
if frac.is_empty() {
format!("{sign}{}", a / 1_000_000)
} else {
format!("{sign}{},{frac}", a / 1_000_000)
}
}
/// `"2026-11-13"` → `13.11.2026`
pub fn date(iso: &str) -> String {
match iso.split('-').collect::<Vec<_>>().as_slice() {
[y, m, d] => format!("{d}.{m}.{y}"),
_ => iso.to_string(),
}
}
// ---------- Okuma ----------
/// CSV metni → satırlar (başlık dahil). Boş satırlar atlanır; her satırın dosyadaki numarası.
pub fn parse(text: &str) -> Vec<(usize, Vec<String>)> {
let text = text.strip_prefix(BOM).unwrap_or(text);
let first = text.lines().next().unwrap_or("");
let delim = [';', '\t', ',']
.into_iter()
.max_by_key(|d| first.matches(*d).count())
.unwrap_or(';');
let mut rows = Vec::new();
let mut row = Vec::new();
let mut cell = String::new();
let mut in_quotes = false;
let mut line = 1;
let mut row_line = 1;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if in_quotes && chars.peek() == Some(&'"') => {
cell.push('"');
chars.next();
}
'"' if in_quotes => in_quotes = false,
'"' if cell.is_empty() => in_quotes = true,
c if c == delim && !in_quotes => row.push(std::mem::take(&mut cell)),
'\r' if !in_quotes => {}
'\n' if !in_quotes => {
row.push(std::mem::take(&mut cell));
if row.iter().any(|c| !c.trim().is_empty()) {
rows.push((row_line, std::mem::take(&mut row)));
}
row.clear();
line += 1;
row_line = line;
}
'\n' => {
cell.push(c);
line += 1;
}
c => cell.push(c),
}
}
row.push(cell);
if row.iter().any(|c| !c.trim().is_empty()) {
rows.push((row_line, row));
}
rows
}
/// Başlık adı karşılaştırması: küçük harf (Türkçe İ/I), boşluksuz
pub fn norm(s: &str) -> String {
s.trim()
.chars()
.map(|c| match c {
'İ' => 'i',
'I' => 'ı',
c => c.to_lowercase().next().unwrap_or(c),
})
.filter(|c| !c.is_whitespace())
.collect()
}
/// Başlık satırında adı verilen sütunların sırası (ilk eşleşen ad)
pub fn column(headers: &[String], names: &[&str]) -> Option<usize> {
let hs: Vec<String> = headers.iter().map(|h| norm(h)).collect();
names
.iter()
.find_map(|n| hs.iter().position(|h| *h == norm(n)))
}
/// Ondalık metin → (tam, kesir) basamakları; Türkçe ve İngilizce yazımı tanır
fn split_decimal(s: &str, max_frac: usize) -> Option<(bool, String, String)> {
let s: String = s
.trim()
.chars()
.filter(|c| !c.is_whitespace() && *c != '₺' && *c != '\u{a0}')
.collect();
let (neg, s) = match s.strip_prefix('-') {
Some(r) => (true, r.to_string()),
None => (false, s),
};
if s.is_empty() {
return None;
}
let (int, frac) = if let Some((i, f)) = s.rsplit_once(',') {
// Virgül ondalık; noktalar binlik
(i.replace('.', ""), f.to_string())
} else if s.matches('.').count() == 1 {
let (i, f) = s.split_once('.').unwrap();
// Tek nokta + tam 3 rakam → binlik (1.000), değilse ondalık (12.50)
if f.len() == 3 && i != "0" && !i.is_empty() {
(format!("{i}{f}"), String::new())
} else {
(i.to_string(), f.to_string())
}
} else {
(s.replace('.', ""), String::new())
};
let int = if int.is_empty() { "0".to_string() } else { int };
if !int.bytes().all(|b| b.is_ascii_digit())
|| !frac.bytes().all(|b| b.is_ascii_digit())
|| frac.len() > max_frac
{
return None;
}
Some((neg, int, frac))
}
/// `1.250,50` → 125050 kuruş (en fazla 2 ondalık)
pub fn parse_money(s: &str) -> Option<i64> {
let (neg, int, frac) = split_decimal(s, 2)?;
let v =
int.parse::<i64>().ok()?.checked_mul(100)? + format!("{frac:0<2}").parse::<i64>().ok()?;
Some(if neg { -v } else { v })
}
/// `5,5` → 5_500_000 (en fazla 6 ondalık; eksi yok)
pub fn parse_e6(s: &str) -> Option<i64> {
let (neg, int, frac) = split_decimal(s, 6)?;
if neg {
return None;
}
int.parse::<i64>()
.ok()?
.checked_mul(1_000_000)?
.checked_add(format!("{frac:0<6}").parse::<i64>().ok()?)
}
/// `13.11.2026` · `13/11/2026` · `2026-11-13` → `"2026-11-13"`
pub fn parse_date_any(s: &str) -> Option<String> {
let s = s.trim();
let iso = if s.contains('-') && s.len() == 10 && s.as_bytes()[4] == b'-' {
s.to_string()
} else {
let p: Vec<&str> = s.split(['.', '/']).collect();
match p.as_slice() {
[d, m, y] if y.len() == 4 => format!("{y}-{m:0>2}-{d:0>2}"),
_ => return None,
}
};
parse_date(&iso).map(format_date)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_excel_friendly() {
let csv = write(
&["Ad", "Not"],
&[
vec!["Market".into(), "a;b".into()],
vec!["\"x\"".into(), String::new()],
],
);
assert_eq!(
csv,
"\u{FEFF}Ad;Not\r\nMarket;\"a;b\"\r\n\"\"\"x\"\"\";\r\n"
);
assert_eq!(money(125_050), "1250,50");
assert_eq!(money(-5), "-0,05");
assert_eq!(e6(5_500_000), "5,5");
assert_eq!(e6(10_000_000), "10");
assert_eq!(e6(125_000), "0,125");
assert_eq!(date("2026-11-13"), "13.11.2026");
}
#[test]
fn parses_quotes_delimiters_and_line_numbers() {
let rows = parse(
"\u{FEFF}Tarih;Tutar;Açıklama\r\n13.11.2026;1.250,50;\"Kafe; tatlı\"\r\n\r\n14.11.2026;10;\"iki\nsatır\"\n",
);
assert_eq!(rows.len(), 3);
assert_eq!(
rows[1],
(
2,
vec!["13.11.2026".into(), "1.250,50".into(), "Kafe; tatlı".into()]
)
);
assert_eq!(rows[2].0, 4, "boş satır sayılır ama atlanır");
assert_eq!(rows[2].1[2], "iki\nsatır");
// Virgül ayraçlı dosya
let rows = parse("date,amount\n2026-11-13,\"1,5\"\n");
assert_eq!(rows[1].1, vec!["2026-11-13", "1,5"]);
}
#[test]
fn columns_by_name() {
let h: Vec<String> = ["TARİH", "Tutar ", "Ödeme Türü"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(column(&h, &["Tarih"]), Some(0));
assert_eq!(column(&h, &["ödeme türü"]), Some(2));
assert_eq!(column(&h, &["Kategori"]), None);
}
#[test]
fn money_and_dates() {
assert_eq!(parse_money("1.250,50"), Some(125_050));
assert_eq!(parse_money("1250,5"), Some(125_050));
assert_eq!(parse_money("1250.50"), Some(125_050));
assert_eq!(parse_money("1.000"), Some(100_000));
assert_eq!(parse_money("-45,90 ₺"), Some(-4_590));
assert_eq!(parse_money("1,234"), None, "3 ondalık para olmaz");
assert_eq!(parse_money("abc"), None);
assert_eq!(parse_e6("5,5"), Some(5_500_000));
assert_eq!(parse_e6("0.125"), Some(125_000));
assert_eq!(parse_e6("-1"), None);
assert_eq!(parse_date_any("13.11.2026").as_deref(), Some("2026-11-13"));
assert_eq!(parse_date_any("3/1/2026").as_deref(), Some("2026-01-03"));
assert_eq!(parse_date_any("2026-11-13").as_deref(), Some("2026-11-13"));
assert_eq!(parse_date_any("30.02.2026"), None);
assert_eq!(parse_date_any("13.11.26"), None);
}
}
+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
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Hane davet kodları.
use std::time::Duration;
use mongodb::{
ClientSession, Database,
bson::{DateTime, Document, Uuid, doc},
options::ReturnDocument,
};
use crate::{
db::{col, is_duplicate_key},
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
invite::Invite,
},
};
/// Davet kodunun geçerlilik süresi
pub const INVITE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
/// Karıştırılabilecek karakterler yok: 0/O, 1/I/L (31 karakter).
const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
const CODE_LEN: usize = 8;
/// 8 karakterlik rastgele kod üretir (kaynak: UUID v4'ün rastgele baytları).
/// 248 = 31 × 8 ve üzeri baytlar atlanır, böylece her karakter eşit olasılıkla seçilir.
fn generate_code() -> String {
let limit = (256 / ALPHABET.len() * ALPHABET.len()) as u8;
let mut code = String::with_capacity(CODE_LEN);
while code.len() < CODE_LEN {
for b in uuid::Uuid::new_v4().into_bytes() {
if b < limit && code.len() < CODE_LEN {
code.push(ALPHABET[b as usize % ALPHABET.len()] as char);
}
}
}
code
}
/// Kullanıcının yazdığı kodu karşılaştırılabilir hale getirir: boşluk/tire temizlenir, büyük harf.
pub fn normalize_code(code: &str) -> String {
code.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_uppercase())
.collect()
}
/// Yeni davet kodu oluşturur (çok düşük ihtimalle kod çakışırsa yeniden dener).
pub async fn create(db: &Database, household_id: Uuid, created_by: Uuid) -> AppResult<Invite> {
let expires_at =
DateTime::from_millis(now().timestamp_millis() + INVITE_TTL.as_millis() as i64);
for _ in 0..3 {
let mut d = new_meta(household_id);
let id = d.get("_id").cloned();
let code = generate_code();
d.insert("code", &code);
d.insert("created_by", created_by);
d.insert("expires_at", expires_at);
d.insert("used_by", mongodb::bson::Bson::Null);
d.insert("used_at", mongodb::bson::Bson::Null);
match db.collection::<Document>(col::INVITES).insert_one(d).await {
Ok(_) => {
let filter = doc! { "_id": id };
return db
.collection::<Invite>(col::INVITES)
.find_one(filter)
.await?
.ok_or_else(|| AppError::Internal("davet kaydı okunamadı".into()));
}
Err(e) if is_duplicate_key(&e) => continue,
Err(e) => return Err(e.into()),
}
}
Err(AppError::Internal(
"benzersiz davet kodu üretilemedi".into(),
))
}
/// Hanenin kullanılmamış ve süresi dolmamış davetleri.
pub async fn list_active(db: &Database, household_id: Uuid) -> AppResult<Vec<Invite>> {
let mut filter = scope(household_id);
filter.insert("used_by", mongodb::bson::Bson::Null);
filter.insert("expires_at", doc! { "$gt": now() });
let mut cursor = db
.collection::<Invite>(col::INVITES)
.find(filter)
.sort(doc! { "created_at": -1 })
.await?;
let mut list = Vec::new();
while cursor.advance().await? {
list.push(cursor.deserialize_current()?);
}
Ok(list)
}
/// Daveti iptal eder (soft delete). Başka hanenin davetine dokunamaz.
pub async fn revoke(db: &Database, household_id: Uuid, invite_id: Uuid) -> AppResult<()> {
let mut filter = scope(household_id);
filter.insert("_id", invite_id);
let ts = now();
let res = db
.collection::<Document>(col::INVITES)
.update_one(
filter,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
if res.matched_count == 0 {
return Err(AppError::NotFound);
}
Ok(())
}
/// Kodu tek hamlede "kullanıldı" olarak işaretler ve hanenin kimliğini döner.
/// Bul + güncelle tek atomik işlem olduğundan aynı kod iki kişi tarafından kullanılamaz.
/// Kayıt transaction'ının parçasıdır: kullanıcı oluşturulamazsa kod yeniden kullanılabilir olur.
pub async fn claim(
db: &Database,
session: &mut ClientSession,
code: &str,
user_id: Uuid,
) -> AppResult<Uuid> {
let ts = now();
let invite = db
.collection::<Invite>(col::INVITES)
.find_one_and_update(
doc! {
"code": normalize_code(code),
"used_by": null,
"deleted_at": null,
"expires_at": { "$gt": ts },
},
doc! {
"$set": { "used_by": user_id, "used_at": ts, "updated_at": ts },
"$inc": { "version": 1 },
},
)
.return_document(ReturnDocument::After)
.session(session)
.await?
.ok_or_else(|| AppError::BadRequest("Davet kodu geçersiz veya süresi dolmuş".into()))?;
Ok(invite.household_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn code_format() {
for _ in 0..200 {
let c = generate_code();
assert_eq!(c.len(), CODE_LEN);
assert!(
c.chars()
.all(|ch| "ABCDEFGHJKMNPQRSTUVWXYZ23456789".contains(ch)),
"{c}"
);
}
}
#[test]
fn normalize_accepts_user_typing() {
assert_eq!(normalize_code(" abcd-efgh "), "ABCDEFGH");
}
}
+24
View File
@@ -0,0 +1,24 @@
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
pub mod account;
pub mod asset;
pub mod bank_import;
pub mod budget;
pub mod calendar;
pub mod catalog;
pub mod closing;
pub mod csv_io;
pub mod entry;
pub mod holding;
pub mod invite;
pub mod money;
pub mod note;
pub mod period;
pub mod plan;
pub mod rate_sources;
pub mod rates;
pub mod reports;
pub mod review;
pub mod seed;
pub mod stats;
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);
}
}
+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())
}
+395
View File
@@ -0,0 +1,395 @@
//! Dönem iş mantığı (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.4).
//!
//! Kurallar:
//! - Hanede aynı anda en fazla **bir açık** dönem (servis kontrolü + kısmi benzersiz indeks).
//! - Dönemler çakışmaz. Bitiş tarihi hariçtir.
//! - Kapalı dönem salt okunur (yeniden açma: Faz 6, yalnızca sahip).
//! - Tarih aralığı daraltılırken dışarıda harcama kalıyorsa engellenir.
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
options::ReturnDocument,
};
use time::Date;
use crate::{
db::{col, is_duplicate_key},
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
period::{Period, PeriodStatus},
},
services::calendar::{days_between, default_period_name, format_date, parse_date},
};
/// En uzun dönem (gün). Aylık defter için ~2 ay pay bırakır.
pub const MAX_DAYS: i64 = 62;
/// Birikim hedefi üst sınırı (kuruş) — yazım hatalarını yakalamak için: 100 milyar ₺
pub const MAX_AMOUNT: i64 = 10_000_000_000_000;
pub struct NewPeriod {
pub start_date: String,
pub end_date: String,
pub name: Option<String>,
pub savings_goal: Option<i64>,
}
#[derive(Default)]
pub struct PeriodChanges {
pub name: Option<String>,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub savings_goal: Option<i64>,
/// `Some("")` → metni kaldır
pub reflection_md: Option<String>,
pub version: Option<i32>,
}
/// "Nasıl daha iyi yapabilirim?" üst sınırı (karakter) — notlarla aynı
pub const MAX_REFLECTION: usize = 20_000;
fn coll(db: &Database) -> mongodb::Collection<Period> {
db.collection(col::PERIODS)
}
pub async fn current(db: &Database, household_id: Uuid) -> AppResult<Option<Period>> {
let mut f = scope(household_id);
f.insert("status", PeriodStatus::Open.as_str());
Ok(coll(db).find_one(f).await?)
}
/// En yeni dönem başta.
pub async fn list(db: &Database, household_id: Uuid) -> AppResult<Vec<Period>> {
let mut cursor = coll(db)
.find(scope(household_id))
.sort(doc! { "start_date": -1 })
.await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?);
}
Ok(out)
}
pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
let mut f = scope(household_id);
f.insert("_id", id);
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
}
/// Yazma işlemleri için: dönem bu haneye ait ve **açık** olmalı.
/// Plan kalemleri, notlar ve (Faz 5) harcamalar bunu kullanır.
pub async fn get_open(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
let p = get(db, household_id, id).await?;
if !p.status.is_writable() {
return Err(closed());
}
Ok(p)
}
pub fn closed() -> AppError {
AppError::Conflict(
"Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(),
)
}
pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResult<Period> {
let (start, end) = validate_range(&p.start_date, &p.end_date)?;
let savings_goal = validate_amount(p.savings_goal.unwrap_or(0))?;
let name = clean_name(p.name.as_deref(), start, end)?;
if let Some(open) = current(db, household_id).await? {
return Err(AppError::Conflict(format!(
"Zaten açık bir dönem var ({}). Yeni dönem açmadan önce onu kapatın.",
open.name
)));
}
ensure_no_overlap(db, household_id, start, end, None).await?;
// Bir önceki dönem (devir ve "önceki aydan kopyala" için)
let mut prev_f = scope(household_id);
prev_f.insert("start_date", doc! { "$lt": format_date(start) });
// 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 })
.await?;
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
d.insert("name", name);
d.insert("start_date", format_date(start));
d.insert("end_date", format_date(end));
d.insert("status", PeriodStatus::Open.as_str());
d.insert("savings_goal", savings_goal);
d.insert("reflection_md", Bson::Null);
d.insert("to_savings", Bson::Null);
d.insert(
"previous_period_id",
previous.as_ref().map_or(Bson::Null, |p| p.id.into()),
);
d.insert("closed_at", Bson::Null);
d.insert("closed_by", Bson::Null);
d.insert("reopened_at", Bson::Null);
// Tek transaction: dönem + önceki dönemden bekleyen devrin bu döneme bağlanması
let mut session = db.client().start_session().await?;
session.start_transaction().await?;
let result = async {
db.collection::<Document>(col::PERIODS)
.insert_one(d)
.session(&mut session)
.await?;
if let Some(prev) = &previous {
let mut f = scope(household_id);
f.insert("from_period_id", prev.id);
f.insert("to_period_id", Bson::Null);
db.collection::<Document>(col::CARRYOVERS)
.update_many(
f,
doc! { "$set": { "to_period_id": id.clone(), "updated_at": now() }, "$inc": { "version": 1 } },
)
.session(&mut session)
.await?;
}
Ok::<_, mongodb::error::Error>(())
}
.await;
match result {
Ok(()) => session.commit_transaction().await?,
Err(e) => {
let _ = session.abort_transaction().await;
// Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller
if is_duplicate_key(&e) {
return Err(AppError::Conflict(
"Zaten açık bir dönem var. Sayfayı yenileyin.".into(),
));
}
return Err(e.into());
}
}
coll(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("açılan dönem okunamadı".into()))
}
pub async fn update(
db: &Database,
household_id: Uuid,
id: Uuid,
ch: PeriodChanges,
) -> AppResult<Period> {
let current = get(db, household_id, id).await?;
if !current.status.is_writable() {
return Err(closed());
}
if ch.version.is_some_and(|v| v != current.version) {
return Err(stale());
}
let mut set = doc! { "updated_at": now() };
let start_s = ch.start_date.as_deref().unwrap_or(&current.start_date);
let end_s = ch.end_date.as_deref().unwrap_or(&current.end_date);
let (start, end) = validate_range(start_s, end_s)?;
let dates_changed =
format_date(start) != current.start_date || format_date(end) != current.end_date;
if dates_changed {
ensure_no_overlap(db, household_id, start, end, Some(id)).await?;
ensure_entries_inside(db, household_id, id, start, end).await?;
set.insert("start_date", format_date(start));
set.insert("end_date", format_date(end));
}
if let Some(name) = ch.name {
set.insert("name", clean_name(Some(&name), start, end)?);
}
if let Some(goal) = ch.savings_goal {
set.insert("savings_goal", validate_amount(goal)?);
}
if let Some(text) = ch.reflection_md {
set.insert("reflection_md", clean_reflection(&text)?);
}
let mut f = scope(household_id);
f.insert("_id", id);
f.insert("version", current.version);
coll(db)
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
.return_document(ReturnDocument::After)
.await?
.ok_or_else(stale)
}
// ---------- doğrulamalar ----------
fn clean_reflection(text: &str) -> AppResult<Bson> {
let t = text.trim();
if t.is_empty() {
return Ok(Bson::Null);
}
if t.chars().count() > MAX_REFLECTION {
return Err(AppError::BadRequest(
"Metin en fazla 20.000 karakter olabilir".into(),
));
}
Ok(Bson::String(t.to_string()))
}
fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> {
let bad = |m: &str| AppError::BadRequest(m.into());
let start = parse_date(start).ok_or_else(|| bad("Başlangıç tarihi geçersiz"))?;
let end = parse_date(end).ok_or_else(|| bad("Bitiş tarihi geçersiz"))?;
let days = days_between(start, end);
if days < 1 {
return Err(bad("Bitiş tarihi başlangıçtan sonra olmalı"));
}
if days > MAX_DAYS {
return Err(AppError::BadRequest(format!(
"Dönem en fazla {MAX_DAYS} gün olabilir"
)));
}
Ok((start, end))
}
pub fn validate_amount(v: i64) -> AppResult<i64> {
if !(0..=MAX_AMOUNT).contains(&v) {
return Err(AppError::BadRequest("Tutar geçersiz".into()));
}
Ok(v)
}
fn clean_name(name: Option<&str>, start: Date, end: Date) -> AppResult<String> {
match name.map(str::trim) {
None | Some("") => Ok(default_period_name(start, end)),
Some(n) if n.chars().count() > 60 => Err(AppError::BadRequest(
"Dönem adı en fazla 60 karakter olabilir".into(),
)),
Some(n) => Ok(n.to_string()),
}
}
/// Başka bir dönemle çakışma: `mevcut.başlangıç < yeni.bitiş && mevcut.bitiş > yeni.başlangıç`
/// (`YYYY-MM-DD` metinleri sözlük sırasıyla karşılaştırılabilir).
/// 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,
start: Date,
end: Date,
exclude: Option<Uuid>,
) -> AppResult<()> {
let mut f = scope(household_id);
f.insert("start_date", doc! { "$lt": format_date(end) });
f.insert("end_date", doc! { "$gt": format_date(start) });
if let Some(id) = exclude {
f.insert("_id", doc! { "$ne": id });
}
if let Some(other) = coll(db).find_one(f).await? {
return Err(AppError::Conflict(format!(
"Bu tarihler \"{}\" dönemiyle çakışıyor",
other.name
)));
}
Ok(())
}
/// Dönem daraltılırken dışarıda harcama kalmasın.
async fn ensure_entries_inside(
db: &Database,
household_id: Uuid,
period_id: Uuid,
start: Date,
end: Date,
) -> AppResult<()> {
let mut f = scope(household_id);
f.insert("period_id", period_id);
f.insert(
"$or",
vec![
doc! { "date": { "$lt": format_date(start) } },
doc! { "date": { "$gte": format_date(end) } },
],
);
let outside = db
.collection::<Document>(col::ENTRIES)
.count_documents(f)
.await?;
if outside > 0 {
return Err(AppError::Conflict(format!(
"Yeni tarih aralığının dışında kalan {outside} harcama var. Önce onları taşıyın ya da silin."
)));
}
Ok(())
}
pub fn stale() -> AppError {
AppError::Conflict("Dönem başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn range_rules() {
assert!(validate_range("2026-12-01", "2027-01-01").is_ok());
assert!(
validate_range("2026-12-01", "2026-12-01").is_err(),
"boş aralık"
);
assert!(validate_range("2026-12-10", "2026-12-01").is_err(), "ters");
assert!(
validate_range("2026-12-01", "2027-02-02").is_err(),
"63 gün"
);
assert!(validate_range("2026-12-01", "2027-02-01").is_ok(), "62 gün");
assert!(validate_range("01.12.2026", "2027-01-01").is_err(), "biçim");
}
#[test]
fn amount_rules() {
assert!(validate_amount(0).is_ok());
assert!(validate_amount(-1).is_err());
assert!(validate_amount(MAX_AMOUNT + 1).is_err());
}
}
+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)
}
+105
View File
@@ -0,0 +1,105 @@
//! Yeni hane için örnek alt kategoriler, ödeme türleri ve varsayılan varlıklar
//! (DATA_MODEL.md §3.5, §3.6, §3.12).
use mongodb::bson::{Document, Uuid};
use crate::models::common::new_meta;
/// (grup, ad, emoji)
const CATEGORIES: &[(&str, &str, &str)] = &[
("needs", "Market", "🛒"),
("needs", "Kira", "🏠"),
("needs", "Faturalar", "💡"),
("needs", "Ulaşım", "🚌"),
("wants", "Dışarıda yemek", "🍽️"),
("wants", "Giyim", "👕"),
("wants", "Eğlence", "🎉"),
("culture", "Kitap", "📖"),
("culture", "Eğitim", "🎓"),
("culture", "Sinema/Konser", "🎬"),
("unexpected", "Sağlık", "💊"),
("unexpected", "Tamir", "🔧"),
("unexpected", "Hediye", "🎁"),
];
/// (ad, emoji)
const PAYMENT_METHODS: &[(&str, &str)] = &[
("Nakit", "💵"),
("Kredi Kartı", "💳"),
("Banka Kartı", "🏧"),
("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()
.enumerate()
.map(|(i, (group, name, emoji))| {
let mut d = new_meta(household_id);
d.insert("group", *group);
d.insert("name", *name);
d.insert("emoji", *emoji);
d.insert("sort_order", (i as i32 + 1) * 10);
d.insert("is_active", true);
d
})
.collect()
}
pub fn payment_methods(household_id: Uuid) -> Vec<Document> {
PAYMENT_METHODS
.iter()
.enumerate()
.map(|(i, (name, emoji))| {
let mut d = new_meta(household_id);
d.insert("name", *name);
d.insert("emoji", *emoji);
d.insert("sort_order", (i as i32 + 1) * 10);
d.insert("is_active", true);
d
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::common::new_id;
#[test]
fn every_group_has_categories() {
let docs = categories(new_id());
for g in ["needs", "wants", "culture", "unexpected"] {
assert!(docs.iter().any(|d| d.get_str("group").unwrap() == g));
}
}
}
+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)
}
+31
View File
@@ -0,0 +1,31 @@
//! Tüm route'ların paylaştığı uygulama durumu.
use std::{sync::Arc, time::Duration};
use mongodb::Database;
use crate::{
auth::{jwt::Jwt, limiter::LoginLimiter},
config::Config,
};
/// `Clone` ucuzdur: `Database` ve `Arc` içte referans sayımı kullanır, veri kopyalanmaz.
#[derive(Clone)]
pub struct AppState {
pub db: Database,
pub config: Arc<Config>,
pub jwt: Arc<Jwt>,
pub login_limiter: Arc<LoginLimiter>,
}
impl AppState {
pub fn new(db: Database, config: Config) -> Self {
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))),
config: Arc::new(config),
}
}
}
+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;
}
+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;
}
+405
View File
@@ -0,0 +1,405 @@
//! Bütçe ve dönem özeti.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use mongodb::bson::{Document, Uuid, doc};
use serde_json::{Value, json};
async fn open_december(app: &TestApp, s: &Session, savings_goal: i64) -> String {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": savings_goal }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
p["id"].as_str().unwrap().to_string()
}
async fn add_item(app: &TestApp, s: &Session, pid: &str, kind: &str, amount: i64) {
let (st, b) = app
.post(
&format!("/periods/{pid}/plan-items"),
s,
json!({ "kind": kind, "name": format!("{kind}-{amount}"), "amount": amount }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
}
fn group<'a>(budget: &'a Value, code: &str) -> &'a Value {
budget
.as_array()
.unwrap()
.iter()
.find(|g| g["group"] == code)
.unwrap()
}
fn week_amounts(g: &Value) -> Vec<i64> {
g["weeks"]
.as_array()
.unwrap()
.iter()
.map(|w| w["amount"].as_i64().unwrap())
.collect()
}
#[tokio::test]
async fn summary_computes_spendable_with_carryovers() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 500_000).await;
// Boş dönem: gelir yok → harcanabilir = -birikim
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(sum["spendable"], -500_000);
add_item(&app, &s, &pid, "income", 4_500_000).await;
add_item(&app, &s, &pid, "income", 300_000).await;
add_item(&app, &s, &pid, "expense", 1_500_000).await;
add_item(&app, &s, &pid, "expense", 45_000).await;
// Önceki dönemden devir (Faz 6'da kapanış sihirbazı yazacak)
let hid = Uuid::parse_str(&s.household_id).unwrap();
let to = Uuid::parse_str(&pid).unwrap();
app.db
.collection::<Document>("carryovers")
.insert_many([
doc! { "household_id": hid, "to_period_id": to, "kind": "debt", "amount": 100_000_i64, "deleted_at": null },
doc! { "household_id": hid, "to_period_id": to, "kind": "surplus", "amount": 20_000_i64, "deleted_at": null },
])
.await
.unwrap();
let (st, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(
sum,
json!({
"income_total": 4_800_000,
"fixed_expense_total": 1_545_000,
"savings_goal": 500_000,
"carryover_debt": 100_000,
"carryover_surplus": 20_000,
// 4.800.000 − 1.545.000 − 500.000 − 100.000 + 20.000
"spendable": 2_675_000,
"budget_total": 0,
"unallocated": 2_675_000,
"spent_total": 0,
"remaining": 2_675_000
})
);
app.cleanup().await;
}
#[tokio::test]
async fn default_suggestion_splits_spendable() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 0).await;
let url = format!("/periods/{pid}/budget/suggestion?method=default");
// Gelir yokken öneri yok
let (_, sug) = app.get(&url, &s).await;
assert_eq!(sug["available"], false);
assert!(sug["message"].as_str().unwrap().contains("Harcanabilir"));
add_item(&app, &s, &pid, "income", 1_234_567).await;
let (_, sug) = app.get(&url, &s).await;
assert_eq!(sug["available"], true);
assert_eq!(
sug["amounts"],
json!({ "needs": 617_283, "wants": 370_370, "culture": 123_457, "unexpected": 123_457 })
);
// Önceki dönem yokken previous/avg3 → veri yok
for m in ["previous", "avg3"] {
let (_, sug) = app
.get(&format!("/periods/{pid}/budget/suggestion?method={m}"), &s)
.await;
assert_eq!(sug["available"], false, "{m}");
}
app.cleanup().await;
}
#[tokio::test]
async fn monthly_budget_is_split_into_weeks_by_days() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 0).await;
add_item(&app, &s, &pid, "income", 300_000).await;
let url = format!("/periods/{pid}/budget");
// Başta hepsi 0
let (_, b) = app.get(&url, &s).await;
assert_eq!(b.as_array().unwrap().len(), 4);
assert_eq!(week_amounts(group(&b, "needs")), vec![0; 5]);
let (st, b) = app
.put(
&url,
&s,
json!({ "needs": 100_000, "wants": 50_000, "culture": 31, "unexpected": 0 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
let needs = group(&b, "needs");
assert_eq!(needs["monthly"], 100_000);
// Aralık: 6,7,7,7,4 gün → toplam tam 100.000
assert_eq!(
week_amounts(needs),
vec![19_355, 22_581, 22_581, 22_580, 12_903]
);
assert_eq!(week_amounts(group(&b, "culture")), vec![6, 7, 7, 7, 4]);
for g in b.as_array().unwrap() {
assert_eq!(
week_amounts(g).iter().sum::<i64>(),
g["monthly"].as_i64().unwrap()
);
}
// Özet: 150.031 dağıtıldı, harcanabilir 300.000 → kalan 149.969
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(
(sum["budget_total"].as_i64(), sum["unallocated"].as_i64()),
(Some(150_031), Some(149_969))
);
// Fazla dağıtım → kalan negatif (arayüz uyarı gösterir)
app.put(
&url,
&s,
json!({ "needs": 400_000, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(sum["unallocated"], -100_000);
// Negatif tutar → 400
let (st, _) = app
.put(
&url,
&s,
json!({ "needs": -1, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
app.cleanup().await;
}
#[tokio::test]
async fn manual_week_override_redistributes_the_rest() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 0).await;
app.put(
&format!("/periods/{pid}/budget"),
&s,
json!({ "needs": 100_000, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
let weeks_url = format!("/periods/{pid}/budget/weeks");
// 2. hafta (7–13 Ara) elle 50.000 → kalan 50.000 diğer haftalara (6+7+7+4 = 24 gün)
let (st, b) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 50_000 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
let needs = group(&b, "needs");
assert_eq!(
week_amounts(needs),
vec![12_500, 50_000, 14_584, 14_583, 8_333]
);
assert_eq!(week_amounts(needs).iter().sum::<i64>(), 100_000);
let manual: Vec<bool> = needs["weeks"]
.as_array()
.unwrap()
.iter()
.map(|w| w["is_manual"].as_bool().unwrap())
.collect();
assert_eq!(manual, vec![false, true, false, false, false]);
assert_eq!(needs["weekly_over"], false);
// Aylık tutar değişince elle girilen hafta korunur
let (_, b) = app
.put(
&format!("/periods/{pid}/budget"),
&s,
json!({ "needs": 74_000, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
assert_eq!(week_amounts(group(&b, "needs"))[1], 50_000);
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
// Elle girilenler aylığı aşarsa uyarı; diğer haftalar 0
let (_, b) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-14", "amount": 30_000 }),
)
.await;
let needs = group(&b, "needs");
assert_eq!(needs["weekly_over"], true);
assert_eq!(week_amounts(needs), vec![0, 50_000, 30_000, 0, 0]);
// null → otomatiğe dön
app.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-14", "amount": null }),
)
.await;
let (_, b) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": null }),
)
.await;
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
assert!(
group(&b, "needs")["weeks"]
.as_array()
.unwrap()
.iter()
.all(|w| w["is_manual"] == false)
);
// Haftanın ilk günü olmayan tarih → 400
let (st, _) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-08", "amount": 1 }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
app.cleanup().await;
}
#[tokio::test]
async fn previous_and_avg3_suggestions_use_actual_spending() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let hid = Uuid::parse_str(&s.household_id).unwrap();
// Ekim ve Kasım kapalı, harcamaları var (Faz 5'te defterden gelecek)
let mut closed = Vec::new();
for (start, end) in [("2026-10-01", "2026-11-01"), ("2026-11-01", "2026-12-01")] {
let (_, p) = app
.post(
"/periods",
&s,
json!({ "start_date": start, "end_date": end }),
)
.await;
let id = Uuid::parse_str(p["id"].as_str().unwrap()).unwrap();
app.db
.collection::<Document>("periods")
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
.await
.unwrap();
closed.push(id);
}
let entry = |pid: Uuid, group: &str, amount: i64| {
doc! { "household_id": hid, "period_id": pid, "group": group, "amount": amount, "date": "2026-10-10", "deleted_at": null }
};
app.db
.collection::<Document>("entries")
.insert_many([
entry(closed[0], "needs", 100_000),
entry(closed[0], "wants", 41_000),
entry(closed[1], "needs", 200_000),
entry(closed[1], "needs", 50_000),
entry(closed[1], "culture", 10_000),
])
.await
.unwrap();
let pid = open_december(&app, &s, 0).await;
let (_, prev) = app
.get(
&format!("/periods/{pid}/budget/suggestion?method=previous"),
&s,
)
.await;
assert_eq!(prev["available"], true, "{prev}");
assert_eq!(
prev["amounts"],
json!({ "needs": 250_000, "wants": 0, "culture": 10_000, "unexpected": 0 })
);
// avg3: 2 kapalı dönem var → ikiye bölünür (yuvarlayarak)
let (_, avg) = app
.get(&format!("/periods/{pid}/budget/suggestion?method=avg3"), &s)
.await;
assert_eq!(
avg["amounts"],
json!({ "needs": 175_000, "wants": 20_500, "culture": 5_000, "unexpected": 0 })
);
app.cleanup().await;
}
#[tokio::test]
async fn budget_closed_period_and_isolation() {
let app = TestApp::new().await;
let a = app.register("A", None).await;
let b = app.register("B", None).await;
let pid = open_december(&app, &a, 0).await;
let body = json!({ "needs": 1, "wants": 0, "culture": 0, "unexpected": 0 });
// B erişemez
let checks = [
app.get(&format!("/periods/{pid}/summary"), &b).await.0,
app.get(&format!("/periods/{pid}/budget"), &b).await.0,
app.put(&format!("/periods/{pid}/budget"), &b, body.clone())
.await
.0,
app.put(
&format!("/periods/{pid}/budget/weeks"),
&b,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 1 }),
)
.await
.0,
app.get(&format!("/periods/{pid}/budget/suggestion"), &b)
.await
.0,
];
assert!(
checks.iter().all(|s| *s == StatusCode::NOT_FOUND),
"{checks:?}"
);
// Kapalı dönem: okuma serbest, yazma 409
let id = Uuid::parse_str(&pid).unwrap();
app.db
.collection::<Document>("periods")
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
.await
.unwrap();
assert_eq!(
app.get(&format!("/periods/{pid}/budget"), &a).await.0,
StatusCode::OK
);
assert_eq!(
app.put(&format!("/periods/{pid}/budget"), &a, body).await.0,
StatusCode::CONFLICT
);
app.cleanup().await;
}
+216
View File
@@ -0,0 +1,216 @@
//! Kategori ve ödeme türü davranış testleri.
mod common;
use axum::http::StatusCode;
use common::TestApp;
use serde_json::{Value, json};
fn names(list: &Value) -> Vec<&str> {
list.as_array()
.unwrap()
.iter()
.map(|i| i["name"].as_str().unwrap())
.collect()
}
#[tokio::test]
async fn new_household_gets_default_categories_and_payment_methods() {
let app = TestApp::new().await;
let s = app.register("Yeni", None).await;
let (_, cats) = app.get("/categories", &s).await;
assert_eq!(cats.as_array().unwrap().len(), 13);
// Grup sırası: needs → wants → culture → unexpected
let groups: Vec<&str> = cats
.as_array()
.unwrap()
.iter()
.map(|c| c["group"].as_str().unwrap())
.collect();
assert_eq!(groups.first(), Some(&"needs"));
assert_eq!(groups.last(), Some(&"unexpected"));
let (_, pms) = app.get("/payment-methods", &s).await;
assert_eq!(
names(&pms),
vec!["Nakit", "Kredi Kartı", "Banka Kartı", "Havale/EFT"]
);
assert!(
pms[0].get("group").is_none(),
"ödeme türünde group alanı olmamalı"
);
app.cleanup().await;
}
#[tokio::test]
async fn create_validates_and_appends_to_end_of_group() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
// Grup zorunlu
let (st, _) = app
.post("/categories", &s, json!({ "name": "Grupsuz" }))
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
// Boş ad
let (st, _) = app
.post(
"/categories",
&s,
json!({ "group": "needs", "name": " " }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
// Geçersiz grup
let (st, _) = app
.post("/categories", &s, json!({ "group": "luks", "name": "X" }))
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
// Aynı ad (büyük/küçük harf duyarsız) → 409
let (st, body) = app
.post(
"/categories",
&s,
json!({ "group": "needs", "name": "MARKET" }),
)
.await;
assert_eq!(st, StatusCode::CONFLICT, "{body}");
// Aynı ad başka grupta serbest
let (st, _) = app
.post(
"/categories",
&s,
json!({ "group": "wants", "name": "Market" }),
)
.await;
assert_eq!(st, StatusCode::CREATED);
// Yeni kayıt grubun sonuna eklenir
let (st, cat) = app
.post(
"/categories",
&s,
json!({ "group": "needs", "name": " Aidat ", "emoji": "🏢" }),
)
.await;
assert_eq!(st, StatusCode::CREATED);
assert_eq!(cat["name"], "Aidat");
let (_, cats) = app.get("/categories", &s).await;
let needs: Vec<&str> = cats
.as_array()
.unwrap()
.iter()
.filter(|c| c["group"] == "needs")
.map(|c| c["name"].as_str().unwrap())
.collect();
assert_eq!(needs.last(), Some(&"Aidat"));
app.cleanup().await;
}
#[tokio::test]
async fn update_deactivate_and_version_conflict() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let (_, pm) = app
.post(
"/payment-methods",
&s,
json!({ "name": "Yemek Kartı", "emoji": "🍽️" }),
)
.await;
let path = format!("/payment-methods/{}", pm["id"].as_str().unwrap());
assert_eq!(pm["version"], 1);
// Ad + emoji kaldırma, doğru sürümle
let (st, upd) = app
.patch(
&path,
&s,
json!({ "name": "Multinet", "emoji": "", "version": 1 }),
)
.await;
assert_eq!(st, StatusCode::OK);
assert_eq!(upd["name"], "Multinet");
assert_eq!(upd["emoji"], Value::Null);
assert_eq!(upd["version"], 2);
// Eski sürümle güncelleme → 409 (başkası değiştirmiş)
let (st, _) = app
.patch(&path, &s, json!({ "name": "Sodexo", "version": 1 }))
.await;
assert_eq!(st, StatusCode::CONFLICT);
// Pasife al: varsayılan listede görünmez, include_inactive ile görünür
let (st, _) = app.patch(&path, &s, json!({ "is_active": false })).await;
assert_eq!(st, StatusCode::OK);
let (_, active) = app.get("/payment-methods", &s).await;
assert!(!names(&active).contains(&"Multinet"));
let (_, all) = app.get("/payment-methods?include_inactive=true", &s).await;
assert!(names(&all).contains(&"Multinet"));
// Pasif kayıtla aynı adda yeni kayıt açılamaz
let (st, _) = app
.post("/payment-methods", &s, json!({ "name": "multinet" }))
.await;
assert_eq!(st, StatusCode::CONFLICT);
// Olmayan / bozuk kimlik → 404
let (st, _) = app
.patch(
"/payment-methods/01a1cc59-0000-7000-8000-000000000000",
&s,
json!({ "name": "X" }),
)
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
let (st, _) = app
.patch("/payment-methods/bozuk", &s, json!({ "name": "X" }))
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
app.cleanup().await;
}
#[tokio::test]
async fn reorder_requires_complete_list() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let (_, cats) = app.get("/categories", &s).await;
let mut culture: Vec<String> = cats
.as_array()
.unwrap()
.iter()
.filter(|c| c["group"] == "culture")
.map(|c| c["id"].as_str().unwrap().to_string())
.collect();
assert_eq!(culture.len(), 3);
// Eksik liste → 400
let (st, _) = app
.put(
"/categories/order",
&s,
json!({ "group": "culture", "ids": &culture[..2] }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
// Ters çevir → yeni sıra
culture.reverse();
let (st, ordered) = app
.put(
"/categories/order",
&s,
json!({ "group": "culture", "ids": culture }),
)
.await;
assert_eq!(st, StatusCode::OK);
assert_eq!(names(&ordered), vec!["Sinema/Konser", "Eğitim", "Kitap"]);
assert_eq!(ordered[0]["sort_order"], 10);
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;
}
+198
View File
@@ -0,0 +1,198 @@
//! Entegrasyon testleri için yardımcılar.
//!
//! Her test kendi geçici veritabanını (`kakeibo_test_<uuid>`) kullanır ve sonunda siler.
//! Gereken: yerel MongoDB çalışıyor olmalı (`npm run db:up`), `.env` içinde `MONGODB_URI`.
#![allow(dead_code)] // her test dosyası tüm yardımcıları kullanmaz
use axum::{
Router,
body::Body,
http::{Method, Request, StatusCode, header},
};
use http_body_util::BodyExt;
use kakeibo::{
config::{Config, RegistrationMode},
db, routes,
state::AppState,
};
use mongodb::Database;
use serde_json::{Value, json};
use tower::ServiceExt;
pub struct TestApp {
router: Router,
pub db: Database,
}
/// Oturum açmış bir test kullanıcısı
#[derive(Clone)]
pub struct Session {
pub cookie: String,
pub household_id: String,
}
impl TestApp {
/// Kayıt açık (birden fazla hane gerektiren testler için).
pub async fn new() -> Self {
Self::with_registration(RegistrationMode::Open).await
}
pub async fn with_registration(registration: RegistrationMode) -> Self {
dotenvy::dotenv().ok();
let uri = std::env::var("MONGODB_URI")
.expect("MONGODB_URI tanımlı değil (.env dosyasını kontrol edin)");
let base = db::connect(&uri)
.await
.expect("MongoDB'ye bağlanılamadı — `npm run db:up` çalıştırın");
let name = format!("kakeibo_test_{}", uuid::Uuid::new_v4().simple());
let db = base.client().database(&name);
db::prepare(&db).await.expect("test DB hazırlanamadı");
let config = Config {
mongodb_uri: uri,
jwt_secret: "test-jwt-secret-en-az-32-karakter-olmali!!".into(),
app_url: "http://localhost".into(),
port: 0,
registration,
static_dir: None,
rates_refresh_minutes: 0,
};
let router = routes::router(AppState::new(db.clone(), config));
Self { router, db }
}
/// Test sonunda geçici veritabanını siler.
pub async fn cleanup(self) {
self.db.drop().await.ok();
}
/// İstek gönderir; (durum kodu, JSON gövde, set-cookie) döner.
pub async fn send(
&self,
method: Method,
path: &str,
cookie: Option<&str>,
body: Option<Value>,
) -> (StatusCode, Value, Option<String>) {
let mut req = Request::builder().method(method).uri(format!("/api{path}"));
if let Some(c) = cookie {
req = req.header(header::COOKIE, c);
}
let req = match body {
Some(b) => req
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(b.to_string())),
None => req.body(Body::empty()),
}
.unwrap();
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();
let json = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
(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)
}
pub async fn post(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) {
let (st, v, _) = self
.send(Method::POST, path, Some(&s.cookie), Some(body))
.await;
(st, v)
}
pub async fn patch(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) {
let (st, v, _) = self
.send(Method::PATCH, path, Some(&s.cookie), Some(body))
.await;
(st, v)
}
pub async fn put(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) {
let (st, v, _) = self
.send(Method::PUT, path, Some(&s.cookie), Some(body))
.await;
(st, v)
}
pub async fn delete(&self, path: &str, s: &Session) -> (StatusCode, Value) {
let (st, v, _) = self.send(Method::DELETE, path, Some(&s.cookie), None).await;
(st, v)
}
/// Yeni kullanıcı kaydeder (davet koduyla veya yeni hane olarak) ve oturumunu döner.
pub async fn register(&self, name: &str, invite_code: Option<&str>) -> Session {
let email = format!(
"{}-{}@test.com",
name.to_lowercase(),
uuid::Uuid::new_v4().simple()
);
let (status, body, cookie) = self
.send(
Method::POST,
"/auth/register",
None,
Some(json!({
"name": name,
"email": email,
"password": "test-sifre-123",
"invite_code": invite_code,
})),
)
.await;
assert_eq!(status, StatusCode::CREATED, "kayıt başarısız: {body}");
Session {
cookie: cookie.expect("kayıtta cookie dönmedi"),
household_id: body["household"]["id"].as_str().unwrap().to_string(),
}
}
}
+574
View File
@@ -0,0 +1,574 @@
//! Günlük defter: harcama kayıtları.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use mongodb::bson::{Document, Uuid, doc};
use serde_json::{Value, json};
async fn open_period(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": start, "end_date": end }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
p["id"].as_str().unwrap().to_string()
}
/// Hanenin ilk (örnek) kategorisi, verilen grupta
async fn category(app: &TestApp, s: &Session, group: &str) -> String {
let (_, cats) = app.get("/categories", s).await;
cats.as_array()
.unwrap()
.iter()
.find(|c| c["group"] == group)
.unwrap_or_else(|| panic!("{group} grubunda kategori yok"))["id"]
.as_str()
.unwrap()
.to_string()
}
async fn payment_method(app: &TestApp, s: &Session) -> String {
let (_, pms) = app.get("/payment-methods", s).await;
pms[0]["id"].as_str().unwrap().to_string()
}
async fn add(app: &TestApp, s: &Session, pid: &str, body: Value) -> (StatusCode, Value) {
app.post(&format!("/periods/{pid}/entries"), s, body).await
}
async fn close(app: &TestApp, pid: &str) {
app.db
.collection::<Document>("periods")
.update_one(
doc! { "_id": Uuid::parse_str(pid).unwrap() },
doc! { "$set": { "status": "closed" } },
)
.await
.unwrap();
}
fn ids(list: &Value) -> Vec<&str> {
list.as_array()
.unwrap()
.iter()
.map(|e| e["description"].as_str().unwrap_or(""))
.collect()
}
#[tokio::test]
async fn crud_group_from_category_and_ordering() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
let needs = category(&app, &s, "needs").await;
let wants = category(&app, &s, "wants").await;
let pm = payment_method(&app, &s).await;
let (st, e) = add(
&app,
&s,
&pid,
json!({ "date": "2026-12-09", "amount": 34_550, "category_id": needs,
"payment_method_id": pm, "description": " Haftalık market " }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{e}");
assert_eq!(e["group"], "needs", "grup kategoriden gelir");
assert_eq!(e["description"], "Haftalık market", "boşluklar kırpılır");
assert_eq!(e["payment_method_id"], pm.as_str());
assert_eq!(e["version"], 1);
let id = e["id"].as_str().unwrap().to_string();
// Ödeme türü ve açıklama isteğe bağlı
for (date, desc) in [
("2026-12-09", "aynı gün, sonra"),
("2026-12-02", "erken"),
("2026-12-20", "geç"),
] {
let (st, b) = add(
&app,
&s,
&pid,
json!({ "date": date, "amount": 1_000, "category_id": wants, "description": desc }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
assert!(b["payment_method_id"].is_null());
}
// Sıra: yeni tarih önce; aynı gün içinde son eklenen önce
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
assert_eq!(
ids(&list),
["geç", "aynı gün, sonra", "Haftalık market", "erken"]
);
// Tarih aralığı (hafta) filtresi: iki uç dahil
let (_, week) = app
.get(
&format!("/periods/{pid}/entries?from=2026-12-07&to=2026-12-13"),
&s,
)
.await;
assert_eq!(ids(&week), ["aynı gün, sonra", "Haftalık market"]);
let (st, _) = app
.get(&format!("/periods/{pid}/entries?from=2026-13-01"), &s)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
// Düzenle: kategori değişince grup da değişir; boş metin → kaldır
let (st, u) = app
.patch(
&format!("/entries/{id}"),
&s,
json!({ "amount": 40_000, "category_id": wants, "payment_method_id": "",
"description": "", "version": 1 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{u}");
assert_eq!(u["amount"], 40_000);
assert_eq!(u["group"], "wants");
assert!(u["payment_method_id"].is_null());
assert!(u["description"].is_null());
assert_eq!(u["version"], 2);
assert_eq!(u["date"], "2026-12-09", "gönderilmeyen alan değişmez");
// Eski sürümle düzenleme → 409
let (st, _) = app
.patch(
&format!("/entries/{id}"),
&s,
json!({ "amount": 1, "version": 1 }),
)
.await;
assert_eq!(st, StatusCode::CONFLICT);
// Sil → listede yok
let (st, _) = app.delete(&format!("/entries/{id}"), &s).await;
assert_eq!(st, StatusCode::NO_CONTENT);
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
assert_eq!(list.as_array().unwrap().len(), 3);
let (st, _) = app
.patch(&format!("/entries/{id}"), &s, json!({ "amount": 5 }))
.await;
assert_eq!(st, StatusCode::NOT_FOUND, "silinmiş kayıt düzenlenemez");
app.cleanup().await;
}
#[tokio::test]
async fn delete_and_restore_undo() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
let needs = category(&app, &s, "needs").await;
let (_, e) = add(
&app,
&s,
&pid,
json!({ "date": "2026-12-03", "amount": 2_500, "category_id": needs }),
)
.await;
let id = e["id"].as_str().unwrap();
// Silinmemiş kayıt geri alınamaz
let (st, _) = app
.post(&format!("/entries/{id}/restore"), &s, json!({}))
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
app.delete(&format!("/entries/{id}"), &s).await;
let (st, r) = app
.post(&format!("/entries/{id}/restore"), &s, json!({}))
.await;
assert_eq!(st, StatusCode::OK, "{r}");
assert_eq!(r["amount"], 2_500);
assert_eq!(r["version"], 3, "sil + geri al iki değişiklik");
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
assert_eq!(
list.as_array().unwrap().len(),
1,
"geri gelen kayıt listede"
);
// İkinci kez geri alma → 404
let (st, _) = app
.post(&format!("/entries/{id}/restore"), &s, json!({}))
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
app.cleanup().await;
}
#[tokio::test]
async fn validation_errors_are_turkish_400s() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
let needs = category(&app, &s, "needs").await;
let base = json!({ "date": "2026-12-05", "amount": 1_000, "category_id": needs });
let with = |k: &str, v: Value| {
let mut b = base.clone();
b[k] = v;
b
};
let cases = [
(with("amount", json!(0)), "Tutar sıfırdan büyük olmalı"),
(with("amount", json!(-5)), "Tutar sıfırdan büyük olmalı"),
(
with("date", json!("2027-01-01")),
"Tarih dönem içinde olmalı",
),
(with("date", json!("05.12.2026")), "Tarih geçersiz"),
(with("category_id", json!("")), "Kategori geçersiz"),
(
with("category_id", json!(Uuid::new().to_string())),
"Kategori bulunamadı",
),
(
with("payment_method_id", json!("xyz")),
"Ödeme türü geçersiz",
),
(
with("payment_method_id", json!(Uuid::new().to_string())),
"Ödeme türü bulunamadı",
),
(
with("description", json!("a".repeat(201))),
"Açıklama en fazla 200 karakter olabilir",
),
];
for (body, msg) in cases {
let (st, b) = add(&app, &s, &pid, body.clone()).await;
assert_eq!(st, StatusCode::BAD_REQUEST, "{body} → {b}");
assert_eq!(b["error"]["message"], msg, "{body}");
}
// 200 karakter (emoji = 1 karakter) kabul
let (st, _) = add(&app, &s, &pid, with("description", json!("🛒".repeat(200)))).await;
assert_eq!(st, StatusCode::CREATED);
// Pasif kategori: yeni kayıtta olmaz, onu zaten kullanan kayıt düzenlenebilir
let (_, e) = add(&app, &s, &pid, base.clone()).await;
let (st, _) = app
.patch(
&format!("/categories/{needs}"),
&s,
json!({ "is_active": false }),
)
.await;
assert_eq!(st, StatusCode::OK);
let (st, b) = add(&app, &s, &pid, base.clone()).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert_eq!(
b["error"]["message"],
"Bu kategori pasif; başka bir kategori seçin"
);
let (st, b) = app
.patch(
&format!("/entries/{}", e["id"].as_str().unwrap()),
&s,
json!({ "amount": 2_000, "category_id": needs }),
)
.await;
assert_eq!(
st,
StatusCode::OK,
"eski kaydın kendi kategorisi kalabilir: {b}"
);
app.cleanup().await;
}
#[tokio::test]
async fn closed_period_is_read_only() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
let needs = category(&app, &s, "needs").await;
let (_, e) = add(
&app,
&s,
&pid,
json!({ "date": "2026-12-05", "amount": 1_000, "category_id": needs }),
)
.await;
let (_, gone) = add(
&app,
&s,
&pid,
json!({ "date": "2026-12-06", "amount": 500, "category_id": needs }),
)
.await;
let id = e["id"].as_str().unwrap();
let gone_id = gone["id"].as_str().unwrap();
app.delete(&format!("/entries/{gone_id}"), &s).await;
close(&app, &pid).await;
let (st, _) = add(
&app,
&s,
&pid,
json!({ "date": "2026-12-07", "amount": 1, "category_id": needs }),
)
.await;
assert_eq!(st, StatusCode::CONFLICT);
let (st, _) = app
.patch(&format!("/entries/{id}"), &s, json!({ "amount": 5 }))
.await;
assert_eq!(st, StatusCode::CONFLICT);
let (st, _) = app.delete(&format!("/entries/{id}"), &s).await;
assert_eq!(st, StatusCode::CONFLICT);
let (st, _) = app
.post(&format!("/entries/{gone_id}/restore"), &s, json!({}))
.await;
assert_eq!(st, StatusCode::CONFLICT);
// Okuma serbest
let (st, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(list.as_array().unwrap().len(), 1);
app.cleanup().await;
}
#[tokio::test]
async fn entries_are_isolated_between_households() {
let app = TestApp::new().await;
let a = app.register("Ali", None).await;
let b = app.register("Banu", None).await;
let pid = open_period(&app, &a, "2026-12-01", "2027-01-01").await;
let b_pid = open_period(&app, &b, "2026-12-01", "2027-01-01").await;
let a_cat = category(&app, &a, "needs").await;
let a_pm = payment_method(&app, &a).await;
let (_, e) = add(
&app,
&a,
&pid,
json!({ "date": "2026-12-05", "amount": 1_000, "category_id": a_cat }),
)
.await;
let id = e["id"].as_str().unwrap();
// B, A'nın dönemini/kaydını göremez, değiştiremez
let (st, _) = app.get(&format!("/periods/{pid}/entries"), &b).await;
assert_eq!(st, StatusCode::NOT_FOUND);
let (st, _) = add(
&app,
&b,
&pid,
json!({ "date": "2026-12-05", "amount": 1, "category_id": a_cat }),
)
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
let (st, _) = app
.patch(&format!("/entries/{id}"), &b, json!({ "amount": 5 }))
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
let (st, _) = app.delete(&format!("/entries/{id}"), &b).await;
assert_eq!(st, StatusCode::NOT_FOUND);
app.delete(&format!("/entries/{id}"), &a).await;
let (st, _) = app
.post(&format!("/entries/{id}/restore"), &b, json!({}))
.await;
assert_eq!(
st,
StatusCode::NOT_FOUND,
"başkasının silinmiş kaydı geri alınamaz"
);
// B kendi dönemine A'nın kategorisini / ödeme türünü kullanamaz
let (st, body) = add(
&app,
&b,
&b_pid,
json!({ "date": "2026-12-05", "amount": 1, "category_id": a_cat }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["message"], "Kategori bulunamadı");
let b_cat = category(&app, &b, "needs").await;
let (st, body) = add(
&app,
&b,
&b_pid,
json!({ "date": "2026-12-05", "amount": 1, "category_id": b_cat, "payment_method_id": a_pm }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["message"], "Ödeme türü bulunamadı");
app.cleanup().await;
}
/// Bütçe önerisi (önceki dönem) artık API'den girilen gerçek kayıtları kullanır.
#[tokio::test]
async fn previous_suggestion_uses_real_entries() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let nov = open_period(&app, &s, "2026-11-01", "2026-12-01").await;
let needs = category(&app, &s, "needs").await;
let culture = category(&app, &s, "culture").await;
for (cat, amount) in [(&needs, 120_000), (&needs, 30_000), (&culture, 15_000)] {
let (st, b) = add(
&app,
&s,
&nov,
json!({ "date": "2026-11-10", "amount": amount, "category_id": cat }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
}
// Silinen kayıt toplama girmez
let (_, del) = add(
&app,
&s,
&nov,
json!({ "date": "2026-11-11", "amount": 999_999, "category_id": needs }),
)
.await;
app.delete(&format!("/entries/{}", del["id"].as_str().unwrap()), &s)
.await;
close(&app, &nov).await;
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
let (_, sug) = app
.get(
&format!("/periods/{dec}/budget/suggestion?method=previous"),
&s,
)
.await;
assert_eq!(sug["available"], true, "{sug}");
assert_eq!(
sug["amounts"],
json!({ "needs": 150_000, "wants": 0, "culture": 15_000, "unexpected": 0 })
);
app.cleanup().await;
}
fn spent_by_week(budget: &Value, group: &str) -> (i64, Vec<i64>) {
let g = budget
.as_array()
.unwrap()
.iter()
.find(|g| g["group"] == group)
.unwrap();
let weeks = g["weeks"]
.as_array()
.unwrap()
.iter()
.map(|w| w["spent"].as_i64().unwrap())
.collect();
(g["spent"].as_i64().unwrap(), weeks)
}
#[tokio::test]
async fn actual_spending_in_summary_and_budget() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
// Kasım'da bir harcama: Aralık toplamlarına girmemeli
let nov = open_period(&app, &s, "2026-11-01", "2026-12-01").await;
let needs = category(&app, &s, "needs").await;
let wants = category(&app, &s, "wants").await;
let culture = category(&app, &s, "culture").await;
let (st, _) = add(
&app,
&s,
&nov,
json!({ "date": "2026-11-30", "amount": 1_000_000, "category_id": needs }),
)
.await;
assert_eq!(st, StatusCode::CREATED);
close(&app, &nov).await;
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
app.post(
&format!("/periods/{pid}/plan-items"),
&s,
json!({ "kind": "income", "name": "Maaş", "amount": 300_000 }),
)
.await;
// Aralık 2026 haftaları: 1–6 · 7–13 · 14–20 · 21–27 · 28–31
for (date, amount, cat) in [
("2026-12-01", 1_000, &needs),
("2026-12-06", 2_000, &needs), // 1. haftanın son günü
("2026-12-07", 500, &needs), // 2. haftanın ilk günü
("2026-12-31", 300, &wants), // dönemin son günü
] {
let (st, b) = add(
&app,
&s,
&pid,
json!({ "date": date, "amount": amount, "category_id": cat }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
}
// Kategori değişince harcama diğer gruba geçer
let (_, moved) = add(
&app,
&s,
&pid,
json!({ "date": "2026-12-08", "amount": 400, "category_id": needs }),
)
.await;
app.patch(
&format!("/entries/{}", moved["id"].as_str().unwrap()),
&s,
json!({ "category_id": wants }),
)
.await;
// Silinen harcama sayılmaz
let (_, gone) = add(
&app,
&s,
&pid,
json!({ "date": "2026-12-15", "amount": 700, "category_id": culture }),
)
.await;
let gone_id = gone["id"].as_str().unwrap();
app.delete(&format!("/entries/{gone_id}"), &s).await;
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
// 1.000 + 2.000 + 500 + 300 + 400 = 4.200
assert_eq!(
(sum["spent_total"].as_i64(), sum["remaining"].as_i64()),
(Some(4_200), Some(295_800)),
"{sum}"
);
let (st, b) = app.get(&format!("/periods/{pid}/budget"), &s).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(
spent_by_week(&b, "needs"),
(3_500, vec![3_000, 500, 0, 0, 0])
);
assert_eq!(spent_by_week(&b, "wants"), (700, vec![0, 400, 0, 0, 300]));
assert_eq!(spent_by_week(&b, "culture"), (0, vec![0; 5]));
// Geri al → yeniden sayılır
app.post(&format!("/entries/{gone_id}/restore"), &s, json!({}))
.await;
let (_, b) = app.get(&format!("/periods/{pid}/budget"), &s).await;
assert_eq!(spent_by_week(&b, "culture"), (700, vec![0, 0, 700, 0, 0]));
// Harcanabilir aşılırsa kalan negatif
add(
&app,
&s,
&pid,
json!({ "date": "2026-12-20", "amount": 300_000, "category_id": needs }),
)
.await;
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(sum["remaining"], 300_000 - 304_900);
app.cleanup().await;
}
+168
View File
@@ -0,0 +1,168 @@
//! Hane izolasyonu testleri: bir hane, başka hanenin verisini göremez ve değiştiremez.
//! (KAKEIBO_PROMPT.md §11 — "başka hanenin verisine erişim imkânsız olmalı")
mod common;
use axum::http::{Method, StatusCode};
use common::TestApp;
use serde_json::{Value, json};
fn ids(list: &Value) -> Vec<String> {
list.as_array()
.unwrap()
.iter()
.map(|i| i["id"].as_str().unwrap().to_string())
.collect()
}
#[tokio::test]
async fn households_cannot_see_or_touch_each_other() {
let app = TestApp::new().await;
let a = app.register("Ayse", None).await;
let b = app.register("Burak", None).await;
assert_ne!(a.household_id, b.household_id);
// A'ya özel kategori ve ödeme türü
let (st, cat) = app
.post(
"/categories",
&a,
json!({ "group": "wants", "name": "A'nın gizli kategorisi" }),
)
.await;
assert_eq!(st, StatusCode::CREATED);
let cat_id = cat["id"].as_str().unwrap();
let (_, pm) = app
.post("/payment-methods", &a, json!({ "name": "A'nın kartı" }))
.await;
let pm_id = pm["id"].as_str().unwrap();
let (_, invite) = app.post("/household/invites", &a, json!({})).await;
let invite_id = invite["id"].as_str().unwrap();
// --- Okuma: B, A'nın kayıtlarını görmez ---
let (_, b_cats) = app.get("/categories?include_inactive=true", &b).await;
assert!(!ids(&b_cats).contains(&cat_id.to_string()));
let (_, b_pms) = app.get("/payment-methods?include_inactive=true", &b).await;
assert!(!ids(&b_pms).contains(&pm_id.to_string()));
let (_, b_hh) = app.get("/household", &b).await;
assert_eq!(b_hh["id"], b.household_id.as_str());
assert_eq!(b_hh["members"].as_array().unwrap().len(), 1);
let (_, b_invites) = app.get("/household/invites", &b).await;
assert_eq!(b_invites, json!([]));
// --- Yazma: B, A'nın kayıtlarını değiştiremez (404: varlığı bile sızmaz) ---
let (st, _) = app
.patch(
&format!("/categories/{cat_id}"),
&b,
json!({ "name": "ele geçirildi" }),
)
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
let (st, _) = app
.patch(
&format!("/payment-methods/{pm_id}"),
&b,
json!({ "is_active": false }),
)
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
let (st, _) = app
.delete(&format!("/household/invites/{invite_id}"), &b)
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
// B, kendi sıralama listesine A'nın kaydını sokamaz
let mut b_wants: Vec<String> = b_cats
.as_array()
.unwrap()
.iter()
.filter(|c| c["group"] == "wants")
.map(|c| c["id"].as_str().unwrap().to_string())
.collect();
b_wants.push(cat_id.to_string());
let (st, _) = app
.put(
"/categories/order",
&b,
json!({ "group": "wants", "ids": b_wants }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
// --- A'nın verisi hiç değişmedi ---
let (_, a_cats) = app.get("/categories", &a).await;
let mine = a_cats
.as_array()
.unwrap()
.iter()
.find(|c| c["id"] == cat_id)
.unwrap();
assert_eq!(mine["name"], "A'nın gizli kategorisi");
assert_eq!(mine["version"], 1);
let (_, a_invites) = app.get("/household/invites", &a).await;
assert_eq!(ids(&a_invites), vec![invite_id.to_string()]);
app.cleanup().await;
}
#[tokio::test]
async fn member_joins_via_invite_and_shares_household_data() {
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;
assert_eq!(member.household_id, owner.household_id);
// Üye, sahibin eklediği kategoriyi görür ve düzenleyebilir (ortak defter)
let (_, cat) = app
.post(
"/categories",
&owner,
json!({ "group": "needs", "name": "Ortak" }),
)
.await;
let (st, _) = app
.patch(
&format!("/categories/{}", cat["id"].as_str().unwrap()),
&member,
json!({ "emoji": "🤝" }),
)
.await;
assert_eq!(st, StatusCode::OK);
// Ama davet oluşturamaz
let (st, _) = app.post("/household/invites", &member, json!({})).await;
assert_eq!(st, StatusCode::FORBIDDEN);
app.cleanup().await;
}
#[tokio::test]
async fn protected_endpoints_require_session() {
let app = TestApp::new().await;
let endpoints = [
(Method::GET, "/auth/me"),
(Method::GET, "/household"),
(Method::GET, "/household/invites"),
(Method::POST, "/household/invites"),
(Method::GET, "/categories"),
(Method::POST, "/categories"),
(Method::PUT, "/categories/order"),
(Method::GET, "/payment-methods"),
(Method::POST, "/payment-methods"),
];
for (method, path) in endpoints {
// Oturumsuz ve sahte cookie ile
for cookie in [None, Some("kakeibo_session=sahte.token.degeri")] {
let (st, _, _) = app
.send(method.clone(), path, cookie, Some(json!({})))
.await;
assert_eq!(st, StatusCode::UNAUTHORIZED, "{method} {path} korumasız!");
}
}
app.cleanup().await;
}
+244
View File
@@ -0,0 +1,244 @@
//! Dönem kuralları (KAKEIBO_PROMPT §6).
mod common;
use axum::http::StatusCode;
use common::TestApp;
use mongodb::bson::{Document, doc};
use serde_json::{Value, json};
async fn open_december(app: &TestApp, s: &common::Session) -> Value {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": 500000 }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
p
}
#[tokio::test]
async fn open_period_returns_weeks_and_default_name() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
// Başta açık dönem yok → null
let (st, cur) = app.get("/periods/current", &s).await;
assert_eq!((st, cur), (StatusCode::OK, Value::Null));
let p = open_december(&app, &s).await;
assert_eq!(p["name"], "Aralık 2026");
assert_eq!(p["status"], "open");
assert_eq!(p["days"], 31);
assert_eq!(p["last_date"], "2026-12-31");
assert_eq!(p["savings_goal"], 500000);
let days: Vec<i64> = p["weeks"]
.as_array()
.unwrap()
.iter()
.map(|w| w["days"].as_i64().unwrap())
.collect();
assert_eq!(
days,
vec![6, 7, 7, 7, 4],
"Pazartesi başlangıçlı, kısmi haftalar ayrı"
);
let (_, cur) = app.get("/periods/current", &s).await;
assert_eq!(cur["id"], p["id"]);
app.cleanup().await;
}
#[tokio::test]
async fn only_one_open_period_and_no_overlap() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let dec = open_december(&app, &s).await;
// İkinci açık dönem → 409
let (st, body) = app
.post(
"/periods",
&s,
json!({ "start_date": "2027-01-01", "end_date": "2027-02-01" }),
)
.await;
assert_eq!(st, StatusCode::CONFLICT);
assert!(
body["error"]["message"]
.as_str()
.unwrap()
.contains("Aralık 2026")
);
// Aralık'ı kapat (Faz 6'da sihirbaz yapacak) → çakışan yeni dönem yine 409
app.db
.collection::<Document>("periods")
.update_one(
doc! { "name": "Aralık 2026" },
doc! { "$set": { "status": "closed" } },
)
.await
.unwrap();
let (st, body) = app
.post(
"/periods",
&s,
json!({ "start_date": "2026-12-20", "end_date": "2027-01-20" }),
)
.await;
assert_eq!(st, StatusCode::CONFLICT, "{body}");
assert!(
body["error"]["message"]
.as_str()
.unwrap()
.contains("çakışıyor")
);
// Bitişik dönem (bitiş hariç olduğu için 1 Ocak serbest) → 201
let (st, jan) = app
.post(
"/periods",
&s,
json!({ "start_date": "2027-01-01", "end_date": "2027-02-01" }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{jan}");
assert_eq!(jan["name"], "Ocak 2027");
// Liste: en yeni başta
let (_, list) = app.get("/periods", &s).await;
let names: Vec<&str> = list
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert_eq!(names, vec!["Ocak 2027", "Aralık 2026"]);
// Kapalı dönem salt okunur
let (st, _) = app
.patch(
&format!("/periods/{}", dec["id"].as_str().unwrap()),
&s,
json!({ "savings_goal": 1 }),
)
.await;
assert_eq!(st, StatusCode::CONFLICT);
app.cleanup().await;
}
#[tokio::test]
async fn validation_errors() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
for (start, end) in [
("2026-12-10", "2026-12-01"), // ters
("2026-12-01", "2026-12-01"), // boş
("2026-02-30", "2026-03-30"), // geçersiz gün
("01.12.2026", "2027-01-01"), // biçim
("2026-12-01", "2027-03-01"), // 90 gün
] {
let (st, _) = app
.post(
"/periods",
&s,
json!({ "start_date": start, "end_date": end }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST, "{start} → {end}");
}
let (st, _) = app
.post(
"/periods",
&s,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": -5 }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST, "negatif birikim");
app.cleanup().await;
}
#[tokio::test]
async fn update_dates_name_goal_and_version() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let p = open_december(&app, &s).await;
let path = format!("/periods/{}", p["id"].as_str().unwrap());
// Maaş 15'inde → dönemi kaydır; ad otomatik güncellenmez, istenirse verilir
let (st, upd) = app
.patch(
&path,
&s,
json!({ "start_date": "2026-12-15", "end_date": "2027-01-15", "name": "", "savings_goal": 750000, "version": 1 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{upd}");
assert_eq!(upd["name"], "15 Ara 2026 – 14 Oca 2027");
assert_eq!(upd["savings_goal"], 750000);
assert_eq!(upd["version"], 2);
// Eski sürümle → 409
let (st, _) = app
.patch(&path, &s, json!({ "name": "X", "version": 1 }))
.await;
assert_eq!(st, StatusCode::CONFLICT);
// Aralığın dışında kalacak bir harcama varsa daraltma engellenir
let hid = mongodb::bson::Uuid::parse_str(&s.household_id).unwrap();
let pid = mongodb::bson::Uuid::parse_str(p["id"].as_str().unwrap()).unwrap();
app.db
.collection::<Document>("entries")
.insert_one(doc! { "household_id": hid, "period_id": pid, "date": "2027-01-10", "deleted_at": null })
.await
.unwrap();
let (st, body) = app
.patch(&path, &s, json!({ "end_date": "2027-01-05" }))
.await;
assert_eq!(st, StatusCode::CONFLICT, "{body}");
assert!(
body["error"]["message"]
.as_str()
.unwrap()
.contains("1 harcama")
);
app.cleanup().await;
}
#[tokio::test]
async fn periods_are_isolated_between_households() {
let app = TestApp::new().await;
let a = app.register("A", None).await;
let b = app.register("B", None).await;
let p = open_december(&app, &a).await;
let path = format!("/periods/{}", p["id"].as_str().unwrap());
// B, A'nın dönemini göremez/değiştiremez; kendi açık dönemi yok
assert_eq!(app.get(&path, &b).await.0, StatusCode::NOT_FOUND);
assert_eq!(
app.patch(&path, &b, json!({ "name": "ele geçirildi" }))
.await
.0,
StatusCode::NOT_FOUND
);
assert_eq!(app.get("/periods/current", &b).await.1, Value::Null);
assert_eq!(app.get("/periods", &b).await.1, json!([]));
// A'nın açık dönemi B'nin dönem açmasını engellemez (açık dönem kuralı hane bazlı)
let (st, _) = app
.post(
"/periods",
&b,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01" }),
)
.await;
assert_eq!(st, StatusCode::CREATED);
app.cleanup().await;
}
+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;
}
+70
View File
@@ -0,0 +1,70 @@
//! Kayıt politikası: REGISTRATION=invite_only (varsayılan).
mod common;
use axum::http::{Method, StatusCode};
use common::TestApp;
use kakeibo::config::RegistrationMode;
use serde_json::json;
async fn new_household_allowed(app: &TestApp) -> bool {
let (st, body, _) = app
.send(Method::GET, "/auth/registration", None, None)
.await;
assert_eq!(st, StatusCode::OK);
body["new_household_allowed"].as_bool().unwrap()
}
#[tokio::test]
async fn invite_only_allows_first_household_then_only_invites() {
let app = TestApp::with_registration(RegistrationMode::InviteOnly).await;
// Boş sistem: ilk kurulum serbest
assert!(new_household_allowed(&app).await);
let owner = app.register("Sahip", None).await;
// Artık yeni hane kurulamaz (arayüz bilgisi + API zorunluluğu)
assert!(!new_household_allowed(&app).await);
let (st, body, _) = app
.send(
Method::POST,
"/auth/register",
None,
Some(
json!({ "name": "Yabancı", "email": "yabanci@test.com", "password": "sifre12345" }),
),
)
.await;
assert_eq!(st, StatusCode::FORBIDDEN);
assert_eq!(body["error"]["code"], "registration_closed");
// Davetle katılım serbest
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
let member = app
.register("Es", Some(inv["code"].as_str().unwrap()))
.await;
assert_eq!(member.household_id, owner.household_id);
// Geçersiz davet kodu yine 400 (politika hatası değil)
let (st, _, _) = app
.send(
Method::POST,
"/auth/register",
None,
Some(json!({ "name": "X", "email": "x@test.com", "password": "sifre12345", "invite_code": "ZZZZZZZZ" })),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
app.cleanup().await;
}
#[tokio::test]
async fn open_mode_allows_many_households() {
let app = TestApp::with_registration(RegistrationMode::Open).await;
let a = app.register("A", None).await;
let b = app.register("B", None).await;
assert_ne!(a.household_id, b.household_id);
assert!(new_household_allowed(&app).await);
app.cleanup().await;
}
+191
View File
@@ -0,0 +1,191 @@
//! Raporlar: harcama kırılımı (grup, tüm kategoriler, ödeme türü, ay) ve filtreler.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use serde_json::{Value, json};
async fn id_by_name(app: &TestApp, s: &Session, path: &str, name: &str) -> String {
let (_, list) = app.get(path, s).await;
list.as_array()
.unwrap()
.iter()
.find(|c| c["name"] == name)
.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn spend(
app: &TestApp,
s: &Session,
pid: &str,
date: &str,
amount: i64,
cat: &str,
pm: Option<&str>,
) -> Value {
let mut body = json!({ "date": date, "amount": amount, "category_id": cat });
if let Some(pm) = pm {
body["payment_method_id"] = json!(pm);
}
let (st, e) = app.post(&format!("/periods/{pid}/entries"), s, body).await;
assert_eq!(st, StatusCode::CREATED, "{e}");
e
}
async fn report(app: &TestApp, s: &Session, query: &str) -> Value {
let (st, b) = app.get(&format!("/reports/breakdown{query}"), s).await;
assert_eq!(st, StatusCode::OK, "{query} → {b}");
b
}
fn totals(list: &Value, key: &str) -> Vec<(String, i64)> {
list.as_array()
.unwrap()
.iter()
.map(|r| {
(
r[key].as_str().unwrap_or("-").to_string(),
r["total"].as_i64().unwrap(),
)
})
.collect()
}
#[tokio::test]
async fn breakdown_with_filters() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let market = id_by_name(&app, &s, "/categories", "Market").await;
let giyim = id_by_name(&app, &s, "/categories", "Giyim").await;
let kitap = id_by_name(&app, &s, "/categories", "Kitap").await;
let nakit = id_by_name(&app, &s, "/payment-methods", "Nakit").await;
let kart = id_by_name(&app, &s, "/payment-methods", "Kredi Kartı").await;
let (_, oct) = app
.post(
"/periods",
&s,
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
)
.await;
let oct = oct["id"].as_str().unwrap().to_string();
spend(&app, &s, &oct, "2026-10-05", 100_000, &market, Some(&nakit)).await;
spend(&app, &s, &oct, "2026-10-20", 50_000, &giyim, None).await;
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
.await;
let (_, nov) = app
.post(
"/periods",
&s,
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
)
.await;
let nov = nov["id"].as_str().unwrap().to_string();
spend(&app, &s, &nov, "2026-11-02", 30_000, &market, Some(&kart)).await;
spend(&app, &s, &nov, "2026-11-03", 20_000, &kitap, Some(&nakit)).await;
spend(&app, &s, &nov, "2026-11-04", 10_000, &market, Some(&nakit)).await;
let gone = spend(&app, &s, &nov, "2026-11-05", 99_000, &kitap, None).await;
app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
.await;
// Filtresiz: tüm zamanlar
let r = report(&app, &s, "").await;
assert_eq!(r["total"], 210_000);
assert_eq!(r["count"], 5, "silinen sayılmaz");
assert_eq!(
totals(&r["groups"], "group"),
[
("needs".into(), 140_000),
("wants".into(), 50_000),
("culture".into(), 20_000),
("unexpected".into(), 0)
]
);
assert_eq!(
totals(&r["categories"], "category_id"),
[
(market.clone(), 140_000),
(giyim.clone(), 50_000),
(kitap.clone(), 20_000)
]
);
assert_eq!(r["categories"][0]["count"], 3);
assert_eq!(r["categories"][0]["group"], "needs");
assert_eq!(
totals(&r["payment_methods"], "payment_method_id"),
[
(nakit.clone(), 130_000),
("-".into(), 50_000),
(kart.clone(), 30_000)
],
"ödeme türü girilmemiş → null"
);
assert_eq!(
totals(&r["months"], "month"),
[("2026-10".into(), 150_000), ("2026-11".into(), 60_000)]
);
// Dönem
assert_eq!(
report(&app, &s, &format!("?period_id={nov}")).await["total"],
60_000
);
// Tarih aralığı iki dönemi keser (ikisi de dahil)
let r = report(&app, &s, "?from=2026-10-15&to=2026-11-03").await;
assert_eq!(
(r["total"].as_i64(), r["count"].as_i64()),
(Some(100_000), Some(3))
);
// Grup, kategori, ödeme türü (+ girilmemiş)
assert_eq!(report(&app, &s, "?group=needs").await["total"], 140_000);
assert_eq!(
report(&app, &s, &format!("?category_id={kitap}")).await["total"],
20_000
);
assert_eq!(
report(&app, &s, &format!("?payment_method_id={nakit}")).await["total"],
130_000
);
assert_eq!(
report(&app, &s, "?payment_method_id=none").await["total"],
50_000
);
// Birleşik
let r = report(
&app,
&s,
&format!("?period_id={nov}&group=needs&payment_method_id={nakit}"),
)
.await;
assert_eq!(
(r["total"].as_i64(), r["count"].as_i64()),
(Some(10_000), Some(1))
);
// Hatalı filtreler
for q in [
"?from=2026-11-10&to=2026-11-01",
"?from=2026-02-30",
"?group=xyz",
"?category_id=abc",
] {
let (st, b) = app.get(&format!("/reports/breakdown{q}"), &s).await;
assert_eq!(st, StatusCode::BAD_REQUEST, "{q} → {b}");
}
// Başka hane: kendi verisi (boş); bu hanenin dönemi → 404
let other = app.register("Diger", None).await;
let r = report(&app, &other, "").await;
assert_eq!(r["total"], 0);
assert_eq!(r["groups"].as_array().unwrap().len(), 4);
assert_eq!(r["categories"], json!([]));
let (st, _) = app
.get(&format!("/reports/breakdown?period_id={nov}"), &other)
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
app.cleanup().await;
}
+241
View File
@@ -0,0 +1,241 @@
//! Ay sonu değerlendirmesi: dört soru, grup/kategori/hafta dökümü, "Nasıl daha iyi yapabilirim?".
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use mongodb::bson::{Document, Uuid, doc};
use serde_json::{Value, json};
async fn open_december(app: &TestApp, s: &Session, savings_goal: i64) -> String {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": savings_goal }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
p["id"].as_str().unwrap().to_string()
}
/// Gruptaki n'inci (0'dan) örnek kategori
async fn category(app: &TestApp, s: &Session, group: &str, n: usize) -> String {
let (_, cats) = app.get("/categories", s).await;
cats.as_array()
.unwrap()
.iter()
.filter(|c| c["group"] == group)
.nth(n)
.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64, cat: &str) -> Value {
let (st, e) = app
.post(
&format!("/periods/{pid}/entries"),
s,
json!({ "date": date, "amount": amount, "category_id": cat }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{e}");
e
}
async fn plan(app: &TestApp, s: &Session, pid: &str, kind: &str, amount: i64) {
let (st, b) = app
.post(
&format!("/periods/{pid}/plan-items"),
s,
json!({ "kind": kind, "name": format!("{kind}-{amount}"), "amount": amount }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
}
fn column(list: &Value, key: &str) -> Vec<i64> {
list.as_array()
.unwrap()
.iter()
.map(|x| x[key].as_i64().unwrap())
.collect()
}
#[tokio::test]
async fn four_questions_and_breakdowns() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 500_000).await;
plan(&app, &s, &pid, "income", 4_800_000).await;
plan(&app, &s, &pid, "expense", 1_545_000).await;
app.db
.collection::<Document>("carryovers")
.insert_one(doc! {
"household_id": Uuid::parse_str(&s.household_id).unwrap(),
"to_period_id": Uuid::parse_str(&pid).unwrap(),
"kind": "debt", "amount": 100_000_i64, "deleted_at": null,
})
.await
.unwrap();
app.put(
&format!("/periods/{pid}/budget"),
&s,
json!({ "needs": 1_000_000, "wants": 500_000, "culture": 200_000, "unexpected": 100_000 }),
)
.await;
let (market, rent) = (
category(&app, &s, "needs", 0).await,
category(&app, &s, "needs", 1).await,
);
let wants = category(&app, &s, "wants", 0).await;
let culture = category(&app, &s, "culture", 0).await;
// Aralık 2026 haftaları: 1–6 · 7–13 · 14–20 · 21–27 · 28–31
spend(&app, &s, &pid, "2026-12-01", 100_000, &market).await;
spend(&app, &s, &pid, "2026-12-08", 50_000, &market).await;
spend(&app, &s, &pid, "2026-12-08", 300_000, &rent).await;
spend(&app, &s, &pid, "2026-12-15", 40_000, &wants).await;
spend(&app, &s, &pid, "2026-12-31", 10_000, &culture).await;
let gone = spend(&app, &s, &pid, "2026-12-15", 99_999, &wants).await;
app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
.await;
let (st, r) = app.get(&format!("/periods/{pid}/review"), &s).await;
assert_eq!(st, StatusCode::OK, "{r}");
// 1: 4.800.000 − 1.545.000 − 100.000 = 3.155.000 · 3: 500.000 · 4: 3.155.000 − 500.000
for (k, v) in [
("available", 3_155_000),
("savings_goal", 500_000),
("spent_total", 500_000),
("realized_savings", 2_655_000),
("savings_gap", 2_155_000),
("carryover_debt", 100_000),
] {
assert_eq!(r[k], v, "{k}: {r}");
}
assert_eq!(
r["groups"],
json!([
{ "group": "needs", "planned": 1_000_000, "spent": 450_000 },
{ "group": "wants", "planned": 500_000, "spent": 40_000 },
{ "group": "culture", "planned": 200_000, "spent": 10_000 },
{ "group": "unexpected", "planned": 100_000, "spent": 0 },
])
);
// Kategoriler çoktan aza; silinen kayıt sayılmaz
let cats = r["categories"].as_array().unwrap();
assert_eq!(
column(&r["categories"], "spent"),
vec![300_000, 150_000, 40_000, 10_000]
);
assert_eq!(
(
cats[0]["category_id"].as_str(),
cats[1]["category_id"].as_str()
),
(Some(rent.as_str()), Some(market.as_str()))
);
assert_eq!(
(cats[1]["count"].as_i64(), cats[1]["group"].as_str()),
(Some(2), Some("needs"))
);
// Haftalar: 4 grubun toplamı; planlananların toplamı aylık bütçelerin toplamı
assert_eq!(
column(&r["weeks"], "spent"),
vec![100_000, 350_000, 40_000, 0, 10_000]
);
assert_eq!(
column(&r["weeks"], "planned").iter().sum::<i64>(),
1_800_000
);
assert_eq!(r["weeks"][4]["last_date"], "2026-12-31");
app.cleanup().await;
}
#[tokio::test]
async fn overspending_gives_negative_savings() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 100_000).await;
plan(&app, &s, &pid, "income", 200_000).await;
let needs = category(&app, &s, "needs", 0).await;
spend(&app, &s, &pid, "2026-12-10", 250_000, &needs).await;
let (_, r) = app.get(&format!("/periods/{pid}/review"), &s).await;
assert_eq!(
(
r["available"].as_i64(),
r["realized_savings"].as_i64(),
r["savings_gap"].as_i64()
),
(Some(200_000), Some(-50_000), Some(-150_000))
);
// Hiç bütçe yoksa: gruplar 0 planlı, haftalar yine dönem takviminden
assert_eq!(column(&r["groups"], "planned"), vec![0; 4]);
assert_eq!(r["weeks"].as_array().unwrap().len(), 5);
app.cleanup().await;
}
#[tokio::test]
async fn reflection_text_and_access_rules() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let other = app.register("Diger", None).await;
let pid = open_december(&app, &s, 0).await;
let url = format!("/periods/{pid}");
let (st, p) = app
.patch(
&url,
&s,
json!({ "reflection_md": " **Dışarıda yemeği** azalt. " }),
)
.await;
assert_eq!(st, StatusCode::OK, "{p}");
assert_eq!(p["reflection_md"], "**Dışarıda yemeği** azalt.");
let (_, p) = app.get(&url, &s).await;
assert_eq!(p["reflection_md"], "**Dışarıda yemeği** azalt.");
// Türkçe karakterler tek sayılır; sınırın üstü 400
let (st, _) = app
.patch(&url, &s, json!({ "reflection_md": "ş".repeat(20_000) }))
.await;
assert_eq!(st, StatusCode::OK);
let (st, b) = app
.patch(&url, &s, json!({ "reflection_md": "a".repeat(20_001) }))
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert_eq!(
b["error"]["message"],
"Metin en fazla 20.000 karakter olabilir"
);
// Boş metin → kaldırılır
let (_, p) = app.patch(&url, &s, json!({ "reflection_md": "" })).await;
assert_eq!(p["reflection_md"], Value::Null);
// Başka hane göremez
let (st, _) = app.get(&format!("{url}/review"), &other).await;
assert_eq!(st, StatusCode::NOT_FOUND);
// Kapalı dönem: değerlendirme okunur, metin değiştirilemez
app.db
.collection::<Document>("periods")
.update_one(
doc! { "_id": Uuid::parse_str(&pid).unwrap() },
doc! { "$set": { "status": "closed" } },
)
.await
.unwrap();
let (st, _) = app.get(&format!("{url}/review"), &s).await;
assert_eq!(st, StatusCode::OK);
let (st, _) = app.patch(&url, &s, json!({ "reflection_md": "geç" })).await;
assert_eq!(st, StatusCode::CONFLICT);
app.cleanup().await;
}
+223
View File
@@ -0,0 +1,223 @@
//! Dönem kapanışı → birikim: onayla TL varlığına ekleme, bekleyenler, yeniden kapatmada
//! bağlı kaydın güncellenmesi / silinmesi, birikimden çıkış varsa reddedilmesi.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use serde_json::{Value, json};
/// Ekim: gelir 10.000, harcama 3.000 → gerçekleşen 7.000; `to_savings` ile kapatılır
async fn october(app: &TestApp, s: &Session, to_savings: i64) -> String {
let (_, p) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
)
.await;
let id = p["id"].as_str().unwrap().to_string();
app.post(
&format!("/periods/{id}/plan-items"),
s,
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
)
.await;
let (_, cats) = app.get("/categories", s).await;
let cat = cats[0]["id"].as_str().unwrap();
app.post(
&format!("/periods/{id}/entries"),
s,
json!({ "date": "2026-10-10", "amount": 300_000, "category_id": cat }),
)
.await;
close(app, s, &id, to_savings).await;
id
}
async fn close(app: &TestApp, s: &Session, id: &str, to_savings: i64) -> Value {
let (st, b) = app
.post(
&format!("/periods/{id}/close"),
s,
json!({ "to_savings": to_savings }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
b
}
async fn reopen(app: &TestApp, s: &Session, id: &str) {
let (st, b) = app
.post(&format!("/periods/{id}/reopen"), s, json!({}))
.await;
assert_eq!(st, StatusCode::OK, "{b}");
}
async fn cash(app: &TestApp, s: &Session) -> Value {
let (_, list) = app.get("/assets", s).await;
list.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "TRY")
.unwrap()
.clone()
}
async fn cash_txs(app: &TestApp, s: &Session) -> Vec<Value> {
let id = cash(app, s).await["id"].as_str().unwrap().to_string();
let (_, list) = app.get(&format!("/assets/{id}/transactions"), s).await;
list.as_array().unwrap().clone()
}
#[tokio::test]
async fn add_closing_savings_once() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let oct = october(&app, &s, 500_000).await;
let (_, pending) = app.get("/savings/pending", &s).await;
assert_eq!(
pending,
json!([{ "period_id": oct, "name": "Ekim 2026", "last_date": "2026-10-31", "to_savings": 500_000 }])
);
let (st, t) = app
.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
.await;
assert_eq!(st, StatusCode::CREATED, "{t}");
assert_eq!(t["type"], "buy");
assert_eq!(t["total"], 500_000);
assert_eq!(t["date"], "2026-10-31");
assert_eq!(t["note"], "Ekim 2026 kapanışı");
assert_eq!(t["source_period_id"], oct.as_str());
assert_eq!(cash(&app, &s).await["cost"], 500_000);
let (st, b) = app
.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
.await;
assert_eq!(st, StatusCode::CONFLICT, "{b}");
let (_, pending) = app.get("/savings/pending", &s).await;
assert_eq!(pending, json!([]));
// Açık dönem eklenemez; başka hane göremez
let (_, nov) = app
.post(
"/periods",
&s,
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
)
.await;
let (st, _) = app
.post(
&format!("/periods/{}/to-savings", nov["id"].as_str().unwrap()),
&s,
json!({}),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
let other = app.register("Diger", None).await;
let (st, _) = app
.post(&format!("/periods/{oct}/to-savings"), &other, json!({}))
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
let (_, op) = app.get("/savings/pending", &other).await;
assert_eq!(op, json!([]));
// Silinen kayıt → dönem yeniden "bekleyen" olur
let (st, _) = app
.delete(
&format!("/asset-transactions/{}", t["id"].as_str().unwrap()),
&s,
)
.await;
assert_eq!(st, StatusCode::NO_CONTENT);
let (_, pending) = app.get("/savings/pending", &s).await;
assert_eq!(pending.as_array().unwrap().len(), 1);
app.cleanup().await;
}
#[tokio::test]
async fn reclosing_updates_or_removes_linked_savings() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let oct = october(&app, &s, 500_000).await;
app.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
.await;
// Yeni tutarla yeniden kapat → kayıt güncellenir
reopen(&app, &s, &oct).await;
let b = close(&app, &s, &oct, 400_000).await;
assert_eq!(b["savings_linked"], true);
let txs = cash_txs(&app, &s).await;
assert_eq!(txs.len(), 1);
assert_eq!(txs[0]["total"], 400_000);
assert_eq!(txs[0]["version"], 2);
assert_eq!(cash(&app, &s).await["cost"], 400_000);
// Birikim 0 → kayıt silinir; bekleyen de yok
reopen(&app, &s, &oct).await;
let b = close(&app, &s, &oct, 0).await;
assert_eq!(b["savings_linked"], false);
assert!(cash_txs(&app, &s).await.is_empty());
let (_, pending) = app.get("/savings/pending", &s).await;
assert_eq!(pending, json!([]));
// Kayıt yokken yeniden kapat → otomatik eklenmez, bekleyenlere düşer
reopen(&app, &s, &oct).await;
let b = close(&app, &s, &oct, 600_000).await;
assert_eq!(b["savings_linked"], false);
assert!(cash_txs(&app, &s).await.is_empty());
let (_, pending) = app.get("/savings/pending", &s).await;
assert_eq!(pending[0]["to_savings"], 600_000);
app.cleanup().await;
}
#[tokio::test]
async fn reclosing_is_refused_when_savings_were_spent() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let oct = october(&app, &s, 500_000).await;
app.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
.await;
// Birikimden 4.500 TL çıkış
let cash_id = cash(&app, &s).await["id"].as_str().unwrap().to_string();
let (st, _) = app
.post(
&format!("/assets/{cash_id}/transactions"),
&s,
json!({ "type": "sell", "date": "2026-11-05", "total": 450_000 }),
)
.await;
assert_eq!(st, StatusCode::CREATED);
reopen(&app, &s, &oct).await;
let (st, b) = app
.post(
&format!("/periods/{oct}/close"),
&s,
json!({ "to_savings": 300_000 }),
)
.await;
assert_eq!(st, StatusCode::CONFLICT, "{b}");
assert!(
b["error"]["message"]
.as_str()
.unwrap()
.contains("TL çıkışları"),
"{b}"
);
// Hiçbir şey değişmedi: dönem düzeltmede, kayıt 5.000 TL
let (_, p) = app.get(&format!("/periods/{oct}"), &s).await;
assert_eq!(p["status"], "correcting");
let buy = cash_txs(&app, &s)
.await
.into_iter()
.find(|t| t["type"] == "buy")
.unwrap();
assert_eq!(buy["total"], 500_000);
// Yeterli tutarla kapanır
let b = close(&app, &s, &oct, 450_000).await;
assert_eq!(b["savings_linked"], true);
app.cleanup().await;
}
+183
View File
@@ -0,0 +1,183 @@
//! Panel verileri: günlük harcama ve dönem istatistikleri (toplam birikim dahil).
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use serde_json::{Value, json};
async fn open(app: &TestApp, s: &Session, start: &str, end: &str, goal: i64) -> String {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": start, "end_date": end, "savings_goal": goal }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
p["id"].as_str().unwrap().to_string()
}
async fn category(app: &TestApp, s: &Session, group: &str) -> String {
let (_, cats) = app.get("/categories", s).await;
cats.as_array()
.unwrap()
.iter()
.find(|c| c["group"] == group)
.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64, cat: &str) -> Value {
let (st, e) = app
.post(
&format!("/periods/{pid}/entries"),
s,
json!({ "date": date, "amount": amount, "category_id": cat }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{e}");
e
}
async fn income(app: &TestApp, s: &Session, pid: &str, amount: i64) {
let (st, b) = app
.post(
&format!("/periods/{pid}/plan-items"),
s,
json!({ "kind": "income", "name": "Maaş", "amount": amount }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
}
/// Kasım: gelir 10.000, harcama 3.000, 5.000 birikime → 2.000 artı Aralık'a.
/// Aralık: gelir 20.000, hedef 1.000 → harcanabilir 21.000.
async fn two_periods(app: &TestApp, s: &Session) -> (String, String) {
let needs = category(app, s, "needs").await;
let nov = open(app, s, "2026-11-01", "2026-12-01", 0).await;
income(app, s, &nov, 1_000_000).await;
spend(app, s, &nov, "2026-11-30", 300_000, &needs).await;
let (st, b) = app
.post(
&format!("/periods/{nov}/close"),
s,
json!({ "to_savings": 500_000 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
let dec = open(app, s, "2026-12-01", "2027-01-01", 100_000).await;
income(app, s, &dec, 2_000_000).await;
(nov, dec)
}
#[tokio::test]
async fn daily_spending_by_group() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let (_, dec) = two_periods(&app, &s).await;
let needs = category(&app, &s, "needs").await;
let wants = category(&app, &s, "wants").await;
spend(&app, &s, &dec, "2026-12-01", 100_000, &needs).await;
spend(&app, &s, &dec, "2026-12-01", 50_000, &wants).await;
spend(&app, &s, &dec, "2026-12-07", 20_000, &needs).await;
spend(&app, &s, &dec, "2026-12-31", 7_000, &needs).await;
let gone = spend(&app, &s, &dec, "2026-12-02", 99_000, &needs).await;
let (st, _) = app
.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
.await;
assert_eq!(st, StatusCode::NO_CONTENT);
let (st, days) = app.get(&format!("/periods/{dec}/daily"), &s).await;
assert_eq!(st, StatusCode::OK, "{days}");
let days = days.as_array().unwrap();
assert_eq!(days.len(), 31, "her gün, harcama olmasa da");
assert_eq!(days[0]["date"], "2026-12-01");
assert_eq!(
days[0]["spent"],
json!({ "needs": 100_000, "wants": 50_000, "culture": 0, "unexpected": 0 })
);
assert_eq!(days[1]["spent"]["needs"], 0, "silinen kayıt sayılmaz");
assert_eq!(days[6]["spent"]["needs"], 20_000);
assert_eq!(days[30]["date"], "2026-12-31");
assert_eq!(days[30]["spent"]["needs"], 7_000, "son gün dahil");
// Kasım'ın 30.11 harcaması Aralık'a karışmaz
let total: i64 = days
.iter()
.map(|d| d["spent"]["needs"].as_i64().unwrap())
.sum();
assert_eq!(total, 127_000);
// Başka hane göremez
let other = app.register("Diger", None).await;
let (st, _) = app.get(&format!("/periods/{dec}/daily"), &other).await;
assert_eq!(st, StatusCode::NOT_FOUND);
app.cleanup().await;
}
#[tokio::test]
async fn period_stats_and_total_savings() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let (nov, dec) = two_periods(&app, &s).await;
let needs = category(&app, &s, "needs").await;
let culture = category(&app, &s, "culture").await;
spend(&app, &s, &dec, "2026-12-05", 40_000, &needs).await;
spend(&app, &s, &dec, "2026-12-06", 2_500, &culture).await;
let (st, b) = app.get("/stats/periods", &s).await;
assert_eq!(st, StatusCode::OK, "{b}");
assert_eq!(b["total_saved"], 500_000);
let list = b["periods"].as_array().unwrap();
assert_eq!(list.len(), 2);
// Eskiden yeniye
assert_eq!(list[0]["id"], nov.as_str());
assert_eq!(list[0]["status"], "closed");
assert_eq!(list[0]["spendable"], 1_000_000);
assert_eq!(list[0]["spent"], 300_000);
assert_eq!(
list[0]["spent_by_group"],
json!({ "needs": 300_000, "wants": 0, "culture": 0, "unexpected": 0 })
);
assert_eq!(list[0]["to_savings"], 500_000);
assert_eq!(list[0]["last_date"], "2026-11-30");
assert_eq!(list[1]["id"], dec.as_str());
assert_eq!(
list[1]["spendable"], 2_100_000,
"20.000 − 1.000 hedef + 2.000 artı"
);
assert_eq!(list[1]["spent"], 42_500);
assert_eq!(list[1]["spent_by_group"]["needs"], 40_000);
assert_eq!(list[1]["spent_by_group"]["culture"], 2_500);
assert_eq!(list[1]["income_total"], 2_000_000);
assert_eq!(list[1]["savings_goal"], 100_000);
assert_eq!(list[1]["carryover_surplus"], 200_000);
assert_eq!(
list[0]["realized_savings"], 700_000,
"Kasım: 10.000 − 3.000"
);
assert_eq!(
list[1]["realized_savings"], 2_157_500,
"20.000 + 2.000 artı − 425 harcanan (hedef düşülmez)"
);
assert_eq!(list[1]["to_savings"], Value::Null);
// limit: en yeni N dönem
let (_, b) = app.get("/stats/periods?limit=1", &s).await;
let list = b["periods"].as_array().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0]["id"], dec.as_str());
assert_eq!(
b["total_saved"], 500_000,
"toplam birikim limitten bağımsız"
);
// Başka hane: boş
let other = app.register("Diger", None).await;
let (_, b) = app.get("/stats/periods", &other).await;
assert_eq!(b["total_saved"], 0);
assert_eq!(b["periods"], json!([]));
app.cleanup().await;
}
+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

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