Compare commits

...

40 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:16:32 +03:00
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
326 changed files with 35429 additions and 233 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 # Uygulamanın erişildiği adres
APP_URL=http://localhost:5173 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 # Backend portu
PORT=8080 PORT=8080
# Log seviyesi # Log seviyesi
RUST_LOG=kakeibo=debug,tower_http=info 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
+23 -3
View File
@@ -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 dev:host # telefondan test için ağa aç
npm run db:up # yerel MongoDB (Docker) npm run db:up # yerel MongoDB (Docker)
npm run db:down npm run db:down
npm run db:seed / db:reset npm run db:seed
npm test # backend testleri 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 lint # clippy + oxlint
npm run types # Rust DTO → frontend/src/types/*.ts (ts-rs; cargo test de üretir)
npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonları (rsvg-convert + magick)
npm run db:backup # kakeibo_dev → ./backups (canlı yedek: docs/DEPLOY.md §6)
npm run db:restore -- <dosya|latest> <hedef_db> [--force]
``` ```
- `npm run dev` öncesinde DB otomatik başlar (`predev`). - `npm run dev` öncesinde DB otomatik başlar (`predev`).
- Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler). - Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler).
- Frontend lint: **oxlint** (Vite şablonu varsayılanı; ESLint yerine). - 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` - 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 ## Ortam
- Geliştirme makinesi: CachyOS (Arch), fish kabuğu, VS Code - 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 }
+1107 -20
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -4,13 +4,26 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
argon2 = "0.6.0"
axum = "0.8.9" axum = "0.8.9"
axum-extra = { version = "0.12.6", features = ["cookie"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
mongodb = "3.10.0" mongodb = "3.10.0"
quick-xml = { version = "0.42.0", features = ["serialize"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154" serde_json = "1.0.154"
thiserror = "2.0.21" thiserror = "2.0.21"
time = "0.3.55"
tokio = { version = "1.54.1", features = ["full"] } 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 = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
uuid = { version = "1.29.2", features = ["v7"] } 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"] }
+380
View File
@@ -1,7 +1,387 @@
### Kakeibo API — örnek istekler (VS Code REST Client eklentisi) ### Kakeibo API — örnek istekler (VS Code REST Client eklentisi)
### Satırın üstündeki "Send Request" bağlantısına tıklayın. ### 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 @baseUrl = http://localhost:8080/api
### Sağlık kontrolü (DB erişilemezse 503 + "db": "error") ### Sağlık kontrolü (DB erişilemezse 503 + "db": "error")
GET {{baseUrl}}/health 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
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,
})
}
}
+43 -3
View File
@@ -1,14 +1,29 @@
//! Ortam değişkenlerinden uygulama ayarlarını okur. //! Ortam değişkenlerinden uygulama ayarlarını okur.
use std::env; use std::{env, path::PathBuf};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
pub mongodb_uri: String, pub mongodb_uri: String,
#[allow(dead_code)] // Faz 1 – Adım 3'te (JWT) kullanılacak
pub jwt_secret: String, pub jwt_secret: String,
pub app_url: String, pub app_url: String,
pub port: u16, 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)] #[derive(Debug, thiserror::Error)]
@@ -33,6 +48,22 @@ impl Config {
let app_url = env::var("APP_URL").unwrap_or_else(|_| "http://localhost:5173".into()); 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") { let port = match env::var("PORT") {
Ok(p) => p Ok(p) => p
.parse() .parse()
@@ -40,16 +71,25 @@ impl Config {
Err(_) => 8080, Err(_) => 8080,
}; };
let rates_refresh_minutes = match env::var("RATES_REFRESH_MINUTES") {
Ok(m) if !m.trim().is_empty() => m.trim().parse().map_err(|_| {
ConfigError::Invalid("RATES_REFRESH_MINUTES", "dakika (sayı) olmalı")
})?,
_ => 60,
};
Ok(Self { Ok(Self {
mongodb_uri, mongodb_uri,
jwt_secret, jwt_secret,
app_url, app_url,
port, port,
registration,
static_dir,
rates_refresh_minutes,
}) })
} }
/// Cookie'lerde `Secure` bayrağı yalnızca HTTPS'te açılır. /// Cookie'lerde `Secure` bayrağı yalnızca HTTPS'te açılır.
#[allow(dead_code)] // Faz 1 – Adım 3'te (kimlik doğrulama) kullanılacak
pub fn is_https(&self) -> bool { pub fn is_https(&self) -> bool {
self.app_url.starts_with("https://") self.app_url.starts_with("https://")
} }
+4
View File
@@ -75,6 +75,10 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
col::CARRYOVERS, col::CARRYOVERS,
vec![idx(doc! { "household_id": 1, "to_period_id": 1 })], vec![idx(doc! { "household_id": 1, "to_period_id": 1 })],
), ),
(
col::ASSETS,
vec![idx(doc! { "household_id": 1, "sort_order": 1 })],
),
( (
col::ASSET_TRANSACTIONS, col::ASSET_TRANSACTIONS,
vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })], vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })],
+42 -5
View File
@@ -24,11 +24,18 @@ struct Migration {
} }
/// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar. /// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar.
const MIGRATIONS: &[Migration] = &[Migration { const MIGRATIONS: &[Migration] = &[
version: 1, Migration {
name: "initial", version: 1,
run: |db| Box::pin(m001_initial(db)), name: "initial",
}]; run: |db| Box::pin(m001_initial(db)),
},
Migration {
version: 2,
name: "default_assets",
run: |db| Box::pin(m002_default_assets(db)),
},
];
/// Uygulanmamış migration'ları sırayla çalıştırır. /// Uygulanmamış migration'ları sırayla çalıştırır.
pub async fn run(db: &Database) -> Result<()> { pub async fn run(db: &Database) -> Result<()> {
@@ -66,6 +73,36 @@ async fn m001_initial(db: &Database) -> Result<()> {
Ok(()) Ok(())
} }
/// v2: Faz 8'den önce açılmış hanelere varsayılan varlıklar (TL, altınlar, USD, EUR).
/// Varlığı olan haneye dokunmaz → tekrar çalışsa da çift kayıt olmaz.
async fn m002_default_assets(db: &Database) -> Result<()> {
#[derive(serde::Deserialize)]
struct Household {
#[serde(rename = "_id")]
id: mongodb::bson::Uuid,
}
let assets = db.collection::<Document>(col::ASSETS);
let mut cursor = db
.collection::<Household>(col::HOUSEHOLDS)
.find(doc! { "deleted_at": null })
.projection(doc! { "_id": 1 })
.await?;
while cursor.advance().await? {
let hid = cursor.deserialize_current()?.id;
if assets
.find_one(doc! { "household_id": hid })
.await?
.is_none()
{
assets
.insert_many(crate::services::seed::assets(hid))
.await?;
tracing::info!("Varsayılan varlıklar eklendi: hane {hid}");
}
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+14 -1
View File
@@ -32,6 +32,20 @@ pub async fn ping(db: &Database) -> mongodb::error::Result<()> {
db.run_command(doc! { "ping": 1 }).await.map(|_| ()) 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. /// Açılışta çalışır: bekleyen migration'lar, ardından indeksler.
/// İkisi de tekrar çalıştırılabilir (idempotent); zaten yapılmış olanı atlar. /// İkisi de tekrar çalıştırılabilir (idempotent); zaten yapılmış olanı atlar.
pub async fn prepare(db: &Database) -> mongodb::error::Result<()> { pub async fn prepare(db: &Database) -> mongodb::error::Result<()> {
@@ -41,7 +55,6 @@ pub async fn prepare(db: &Database) -> mongodb::error::Result<()> {
} }
/// Koleksiyon adları (DATA_MODEL.md §3). Yazım hatasını derleyici yakalasın diye sabit. /// Koleksiyon adları (DATA_MODEL.md §3). Yazım hatasını derleyici yakalasın diye sabit.
#[allow(dead_code)] // Bazıları sonraki fazlarda kullanılacak
pub mod col { pub mod col {
pub const USERS: &str = "users"; pub const USERS: &str = "users";
pub const HOUSEHOLDS: &str = "households"; pub const HOUSEHOLDS: &str = "households";
+44 -6
View File
@@ -6,7 +6,24 @@ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use serde_json::json; 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)] #[derive(Debug, thiserror::Error)]
pub enum AppError { pub enum AppError {
@@ -16,8 +33,12 @@ pub enum AppError {
BadRequest(String), BadRequest(String),
#[error("Oturum açmanız gerekiyor")] #[error("Oturum açmanız gerekiyor")]
Unauthorized, Unauthorized,
#[error("E-posta veya şifre hatalı")]
InvalidCredentials,
#[error("Bu işlem için yetkiniz yok")] #[error("Bu işlem için yetkiniz yok")]
Forbidden, Forbidden,
#[error("Yeni hane kaydı kapalı. Katılmak için hane sahibinden davet bağlantısı isteyin.")]
RegistrationClosed,
#[error("{0}")] #[error("{0}")]
Conflict(String), Conflict(String),
#[error("Çok fazla deneme yapıldı, lütfen biraz sonra tekrar deneyin")] #[error("Çok fazla deneme yapıldı, lütfen biraz sonra tekrar deneyin")]
@@ -34,7 +55,9 @@ impl AppError {
Self::NotFound => (StatusCode::NOT_FOUND, "not_found"), Self::NotFound => (StatusCode::NOT_FOUND, "not_found"),
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"), Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
Self::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"),
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"), Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
Self::RegistrationClosed => (StatusCode::FORBIDDEN, "registration_closed"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"), Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"), Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
Self::Database(_) | Self::Internal(_) => { Self::Database(_) | Self::Internal(_) => {
@@ -56,11 +79,26 @@ impl IntoResponse for AppError {
self.to_string() self.to_string()
}; };
( let body = ApiErrorBody {
status, error: ApiErrorDetail {
Json(json!({ "error": { "code": code, "message": message } })), code: code.into(),
) message,
.into_response() },
};
(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)
} }
} }
+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;
+34 -17
View File
@@ -1,17 +1,8 @@
mod config; use std::{net::SocketAddr, process::ExitCode};
mod db;
#[allow(dead_code)] // AppError, Faz 1 – Adım 3 itibarıyla route'larda kullanılacak
mod error;
mod models;
mod routes;
mod state;
use std::{net::SocketAddr, process::ExitCode, sync::Arc};
use kakeibo::{config::Config, db, routes, services, state::AppState};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use crate::{config::Config, state::AppState};
#[tokio::main] #[tokio::main]
async fn main() -> ExitCode { async fn main() -> ExitCode {
// .env dosyasını yükle (proje kökünde; üst klasörler de aranır). Yoksa sorun değil. // .env dosyasını yükle (proje kökünde; üst klasörler de aranır). Yoksa sorun değil.
@@ -50,12 +41,12 @@ async fn main() -> ExitCode {
return ExitCode::FAILURE; return ExitCode::FAILURE;
} }
// Kurlar arka planda (açılışta hemen, sonra aralıkla); hata uygulamayı durdurmaz
services::rates::spawn_refresher(db.clone(), config.rates_refresh_minutes);
let port = config.port; let port = config.port;
let state = AppState { let state = AppState::new(db, config);
db, let app = routes::app(state);
config: Arc::new(config),
};
let app = routes::router(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port)); let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = match tokio::net::TcpListener::bind(addr).await { let listener = match tokio::net::TcpListener::bind(addr).await {
@@ -69,9 +60,35 @@ async fn main() -> ExitCode {
}; };
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}"); tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
if let Err(e) = axum::serve(listener, app).await { if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
{
tracing::error!("Sunucu hatası: {e}"); tracing::error!("Sunucu hatası: {e}");
return ExitCode::FAILURE; return ExitCode::FAILURE;
} }
ExitCode::SUCCESS ExitCode::SUCCESS
} }
/// 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…");
}
+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,
}
-10
View File
@@ -3,8 +3,6 @@
//! Her kayıt şu alanları taşır: //! Her kayıt şu alanları taşır:
//! `_id` (UUID v7), `household_id`, `created_at`, `updated_at`, `deleted_at`, `version` //! `_id` (UUID v7), `household_id`, `created_at`, `updated_at`, `deleted_at`, `version`
#![allow(dead_code)] // Faz 1 – Adım 3 itibarıyla kullanılacak
use mongodb::bson::{DateTime, Document, Uuid, doc}; use mongodb::bson::{DateTime, Document, Uuid, doc};
/// Yeni UUID v7 üretir. İlk 48 bit zaman damgası olduğu için /// Yeni UUID v7 üretir. İlk 48 bit zaman damgası olduğu için
@@ -38,14 +36,6 @@ pub fn scope(household_id: Uuid) -> Document {
doc! { "household_id": household_id, "deleted_at": null } doc! { "household_id": household_id, "deleted_at": null }
} }
/// Güncellemelerde eklenecek ortak kısım: `updated_at` yenilenir, `version` +1.
pub fn touch() -> Document {
doc! {
"$set": { "updated_at": now() },
"$inc": { "version": 1_i32 },
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+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,
}
+41
View File
@@ -0,0 +1,41 @@
//! 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",
}
}
/// 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,
}
+10
View File
@@ -1,4 +1,14 @@
//! Veritabanı modelleri. Her koleksiyonun yapısı, onu kullanan adımda eklenir. //! Veritabanı modelleri. Her koleksiyonun yapısı, onu kullanan adımda eklenir.
//! Şema: `docs/DATA_MODEL.md` //! Şema: `docs/DATA_MODEL.md`
pub mod asset;
pub mod common; pub mod common;
pub mod entry;
pub mod group;
pub mod 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,
}
+57
View File
@@ -0,0 +1,57 @@
//! `periods` koleksiyonu (DATA_MODEL.md §3.4).
use mongodb::bson::{DateTime, Uuid};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum PeriodStatus {
/// Girişler yapılabilir. Hanede en fazla bir tane.
Open,
/// Salt okunur. Sahip yeniden açarsa `Correcting` olur.
Closed,
/// Düzeltmede: kapanmış dönem sahip tarafından düzeltme için açıldı.
/// Yazılabilir, ama "açık dönem" sayılmaz (yeni dönem açıkken de olabilir).
/// "Yeniden kapat" ile devir yeniden hesaplanır.
Correcting,
}
impl PeriodStatus {
pub fn as_str(self) -> &'static str {
match self {
PeriodStatus::Open => "open",
PeriodStatus::Closed => "closed",
PeriodStatus::Correcting => "correcting",
}
}
/// Kayıt eklenip değiştirilebilir mi?
pub fn is_writable(self) -> bool {
self != PeriodStatus::Closed
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Period {
#[serde(rename = "_id")]
pub id: Uuid,
pub name: String,
/// `"YYYY-MM-DD"`, dahil
pub start_date: String,
/// `"YYYY-MM-DD"`, **hariç**
pub end_date: String,
pub status: PeriodStatus,
/// Kuruş
pub savings_goal: i64,
/// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası)
#[serde(default)]
pub reflection_md: Option<String>,
/// Kapanışta birikime aktarılan tutar (kuruş). Faz 8'de varlık hareketine dönüşecek.
#[serde(default)]
pub to_savings: Option<i64>,
#[serde(default)]
pub closed_at: Option<DateTime>,
pub version: i32,
}
+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,
}
+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,
},
})
}
+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()))
}
+23 -4
View File
@@ -1,23 +1,42 @@
use axum::{Json, extract::State, http::StatusCode}; use axum::{Json, extract::State, http::StatusCode};
use serde_json::{Value, json}; use serde::Serialize;
use ts_rs::TS;
use crate::{db, state::AppState}; 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). /// Sağlık kontrolü (Dokploy health check bunu kullanır).
/// DB erişilemezse 503 döner. /// DB erişilemezse 503 döner.
pub async fn health(State(state): State<AppState>) -> (StatusCode, Json<Value>) { pub async fn health(State(state): State<AppState>) -> (StatusCode, Json<HealthResponse>) {
let version = env!("CARGO_PKG_VERSION"); let version = env!("CARGO_PKG_VERSION");
match db::ping(&state.db).await { match db::ping(&state.db).await {
Ok(()) => ( Ok(()) => (
StatusCode::OK, StatusCode::OK,
Json(json!({ "status": "ok", "db": "ok", "version": version })), Json(HealthResponse {
status: "ok",
db: "ok",
version,
}),
), ),
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "health: veritabanına erişilemiyor"); tracing::warn!(error = %e, "health: veritabanına erişilemiyor");
( (
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "status": "degraded", "db": "error", "version": version })), 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()
}
+114 -3
View File
@@ -1,13 +1,124 @@
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında. //! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
mod asset;
mod auth;
mod budget;
mod catalog;
mod entry;
mod health; mod health;
mod household;
mod note;
mod period;
mod plan;
mod rates;
mod reports;
mod review;
mod spa;
mod stats;
use axum::{Router, routing::get}; use axum::{
Router,
routing::{delete, get, patch, post, put},
};
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
use crate::state::AppState; 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 { pub fn router(state: AppState) -> Router {
let api = Router::new().route("/health", get(health::health)); 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(
"/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) 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(),
}))
}
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),
))
}
+577
View File
@@ -0,0 +1,577 @@
//! Bütçe ve dönem özeti (KAKEIBO_PROMPT §8.3, DATA_MODEL.md §3.8, §5).
//!
//! - Her grup için **aylık** tutar (`budgets`, `scope: "month"`).
//! - Haftalık tutar saklanmaz; aylık tutar hafta gün sayısına göre bölünür.
//! Kullanıcı bir haftayı elle değiştirirse yalnızca o kayıt saklanır (`scope: "week"`);
//! kalan tutar diğer haftalara gün oranında dağıtılır → haftaların toplamı = aylık.
//! - Özet: gelir − sabit gider − birikim − devreden borç + devreden artı = **harcanabilir**.
//! - Gerçekleşen harcama (`entries`) saklanmaz; her istekte toplanır:
//! özette toplam + kalan, bütçe görünümünde grup ve hafta bazında.
use std::collections::HashMap;
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
};
use serde::Deserialize;
use crate::{
db::col,
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
group::Group,
period::{Period, PeriodStatus},
},
services::{
calendar::{format_date, parse_date, weeks},
money::split_proportional,
period,
},
};
/// Gruplara göre tutarlar (kuruş), `Group::ALL` sırasıyla.
pub type GroupAmounts = [i64; 4];
pub struct Summary {
pub income_total: i64,
pub fixed_expense_total: i64,
pub savings_goal: i64,
pub carryover_debt: i64,
pub carryover_surplus: i64,
pub spendable: i64,
pub budget_total: i64,
/// Dönemde gerçekleşen harcama (silinenler hariç)
pub spent_total: i64,
/// Aynısı gruplara göre (`Group::ALL` sırasıyla)
pub spent_by_group: GroupAmounts,
}
pub struct WeekBudget {
pub index: u32,
pub start_date: String,
pub last_date: String,
pub days: u32,
pub amount: i64,
pub is_manual: bool,
/// Bu haftada bu grupta gerçekleşen harcama
pub spent: i64,
}
pub struct GroupBudget {
pub group: Group,
pub monthly: i64,
/// Dönem boyunca bu grupta gerçekleşen harcama
pub spent: i64,
pub weeks: Vec<WeekBudget>,
/// Elle girilen haftaların toplamı aylık tutarı aşıyor mu?
pub weekly_over: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SuggestMethod {
/// Hanenin varsayılan yüzdeleri × harcanabilir tutar
Default,
/// Önceki dönemde gerçekleşen harcamalar
Previous,
/// Son 3 dönemin gerçekleşen harcama ortalaması
Avg3,
}
pub struct Suggestion {
pub available: bool,
pub message: Option<String>,
pub amounts: GroupAmounts,
}
#[derive(Deserialize)]
struct BudgetDoc {
group: Group,
scope: String,
#[serde(default)]
week_start: Option<String>,
amount: i64,
}
// ---------- Özet ----------
pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Summary> {
let p = period::get(db, household_id, period_id).await?;
let mut income_total = 0;
let mut fixed_expense_total = 0;
for (kind, total) in sum_by(
db,
col::PLAN_ITEMS,
household_id,
doc! { "period_id": period_id },
"$kind",
)
.await?
{
match kind.as_str() {
"income" => income_total = total,
"expense" => fixed_expense_total = total,
_ => {}
}
}
let mut carryover_debt = 0;
let mut carryover_surplus = 0;
for (kind, total) in sum_by(
db,
col::CARRYOVERS,
household_id,
doc! { "to_period_id": period_id },
"$kind",
)
.await?
{
match kind.as_str() {
"debt" => carryover_debt = total,
"surplus" => carryover_surplus = total,
_ => {}
}
}
let budget_total = monthly_amounts(db, household_id, period_id)
.await?
.iter()
.sum();
let spendable =
income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus;
let by_group = sum_by(
db,
col::ENTRIES,
household_id,
doc! { "period_id": period_id },
"$group",
)
.await?;
let spent_by_group = Group::ALL.map(|g| by_group.get(g.as_str()).copied().unwrap_or(0));
let spent_total = spent_by_group.iter().sum();
Ok(Summary {
income_total,
fixed_expense_total,
savings_goal: p.savings_goal,
carryover_debt,
carryover_surplus,
spendable,
budget_total,
spent_total,
spent_by_group,
})
}
// ---------- Bütçe görünümü ----------
pub async fn view(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<Vec<GroupBudget>> {
let p = period::get(db, household_id, period_id).await?;
let docs = load(db, household_id, period_id).await?;
let (start, end) = dates(&p)?;
let period_weeks = weeks(start, end);
let spent = spent_by_group_and_date(db, household_id, period_id).await?;
let mut out = Vec::with_capacity(4);
for group in Group::ALL {
let monthly = docs
.iter()
.find(|d| d.group == group && d.scope == "month")
.map_or(0, |d| d.amount);
// Elle girilmiş haftalar (dönem tarihleri değiştiyse eşleşmeyen eski kayıtlar yok sayılır)
let manual: HashMap<String, i64> = docs
.iter()
.filter(|d| d.group == group && d.scope == "week")
.filter_map(|d| d.week_start.clone().map(|w| (w, d.amount)))
.collect();
let manual_total: i64 = period_weeks
.iter()
.filter_map(|w| manual.get(&format_date(w.start)))
.sum();
let auto_days: Vec<u64> = period_weeks
.iter()
.filter(|w| !manual.contains_key(&format_date(w.start)))
.map(|w| u64::from(w.days))
.collect();
let mut auto = split_proportional(monthly - manual_total, &auto_days).into_iter();
let weeks = period_weeks
.iter()
.map(|w| {
let key = format_date(w.start);
let last = format_date(w.last);
// Tarihler "YYYY-MM-DD" → metin karşılaştırması tarih sırasıyla aynı
let week_spent = spent
.iter()
.filter(|(g, d, _)| *g == group && *d >= key && *d <= last)
.map(|(_, _, a)| a)
.sum();
let (amount, is_manual) = match manual.get(&key) {
Some(&a) => (a, true),
None => (auto.next().unwrap_or(0), false),
};
WeekBudget {
index: w.index,
start_date: key,
last_date: last,
days: w.days,
amount,
is_manual,
spent: week_spent,
}
})
.collect();
out.push(GroupBudget {
group,
monthly,
spent: spent
.iter()
.filter(|(g, _, _)| *g == group)
.map(|(_, _, a)| a)
.sum(),
weeks,
weekly_over: manual_total > monthly,
});
}
Ok(out)
}
// ---------- Yazma ----------
/// 4 grubun aylık tutarını birlikte kaydeder (elle girilmiş haftalar korunur).
pub async fn set_monthly(
db: &Database,
household_id: Uuid,
period_id: Uuid,
amounts: GroupAmounts,
) -> AppResult<()> {
period::get_open(db, household_id, period_id).await?;
for a in amounts {
period::validate_amount(a)?;
}
for (group, amount) in Group::ALL.into_iter().zip(amounts) {
upsert(db, household_id, period_id, group, "month", None, amount).await?;
}
Ok(())
}
/// Bir haftayı elle ayarlar; `amount = None` → otomatiğe döner.
pub async fn set_week(
db: &Database,
household_id: Uuid,
period_id: Uuid,
group: Group,
week_start: &str,
amount: Option<i64>,
) -> AppResult<()> {
let p = period::get_open(db, household_id, period_id).await?;
let (start, end) = dates(&p)?;
let valid =
parse_date(week_start).is_some_and(|d| weeks(start, end).iter().any(|w| w.start == d));
if !valid {
return Err(AppError::BadRequest("Hafta bu döneme ait değil".into()));
}
match amount {
Some(a) => {
period::validate_amount(a)?;
upsert(
db,
household_id,
period_id,
group,
"week",
Some(week_start),
a,
)
.await
}
None => {
let mut f = scope(household_id);
f.insert("period_id", period_id);
f.insert("group", group.as_str());
f.insert("scope", "week");
f.insert("week_start", week_start);
let ts = now();
db.collection::<Document>(col::BUDGETS)
.update_many(
f,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
Ok(())
}
}
}
// ---------- Öneri ----------
pub async fn suggest(
db: &Database,
household_id: Uuid,
period_id: Uuid,
method: SuggestMethod,
) -> AppResult<Suggestion> {
let p = period::get(db, household_id, period_id).await?;
match method {
SuggestMethod::Default => {
let s = summary(db, household_id, period_id).await?;
if s.spendable <= 0 {
return Ok(unavailable(
"Harcanabilir tutar sıfır ya da negatif. Önce gelir, gider ve birikim hedefini girin.",
));
}
let pct = split_percentages(db, household_id).await?;
let parts = split_proportional(s.spendable, &pct);
Ok(Suggestion {
available: true,
message: None,
amounts: [parts[0], parts[1], parts[2], parts[3]],
})
}
SuggestMethod::Previous | SuggestMethod::Avg3 => {
let n = if method == SuggestMethod::Previous {
1
} else {
3
};
let previous = previous_closed(db, household_id, &p, n).await?;
if previous.is_empty() {
return Ok(unavailable("Henüz kapanmış bir önceki dönem yok."));
}
let ids: Vec<Bson> = previous.iter().map(|id| Bson::from(*id)).collect();
let sums = sum_by(
db,
col::ENTRIES,
household_id,
doc! { "period_id": { "$in": ids } },
"$group",
)
.await?;
let total: i64 = sums.values().sum();
if total == 0 {
return Ok(unavailable("Önceki dönemlerde henüz harcama kaydı yok."));
}
let count = previous.len() as i64;
let avg = |g: Group| {
let s = sums.get(g.as_str()).copied().unwrap_or(0);
(s + count / 2) / count // yuvarlayarak bölme
};
Ok(Suggestion {
available: true,
message: None,
amounts: Group::ALL.map(avg),
})
}
}
}
// ---------- yardımcılar ----------
fn dates(p: &Period) -> AppResult<(time::Date, time::Date)> {
let bad = || AppError::Internal("dönem tarihi okunamadı".into());
Ok((
parse_date(&p.start_date).ok_or_else(bad)?,
parse_date(&p.end_date).ok_or_else(bad)?,
))
}
fn unavailable(msg: &str) -> Suggestion {
Suggestion {
available: false,
message: Some(msg.into()),
amounts: [0; 4],
}
}
async fn load(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Vec<BudgetDoc>> {
let mut f = scope(household_id);
f.insert("period_id", period_id);
let mut cursor = db.collection::<BudgetDoc>(col::BUDGETS).find(f).await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?);
}
Ok(out)
}
async fn monthly_amounts(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<GroupAmounts> {
let docs = load(db, household_id, period_id).await?;
Ok(Group::ALL.map(|g| {
docs.iter()
.find(|d| d.group == g && d.scope == "month")
.map_or(0, |d| d.amount)
}))
}
async fn upsert(
db: &Database,
household_id: Uuid,
period_id: Uuid,
group: Group,
level: &str,
week_start: Option<&str>,
amount: i64,
) -> AppResult<()> {
let coll = db.collection::<Document>(col::BUDGETS);
let mut f = scope(household_id);
f.insert("period_id", period_id);
f.insert("group", group.as_str());
f.insert("scope", level);
f.insert(
"week_start",
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
);
let res = coll
.update_one(
f,
doc! { "$set": { "amount": amount, "updated_at": now() }, "$inc": { "version": 1 } },
)
.await?;
if res.matched_count == 0 {
let mut d = new_meta(household_id);
d.insert("period_id", period_id);
d.insert("group", group.as_str());
d.insert("scope", level);
d.insert(
"week_start",
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
);
d.insert("amount", amount);
coll.insert_one(d).await?;
}
Ok(())
}
/// Bir koleksiyonda `amount` alanını bir alana göre toplar: `{ "income": 450000, ... }`
async fn sum_by(
db: &Database,
collection: &str,
household_id: Uuid,
filter: Document,
key: &str,
) -> AppResult<HashMap<String, i64>> {
let mut m = scope(household_id);
m.extend(filter);
let pipeline = vec![
doc! { "$match": m },
doc! { "$group": { "_id": key, "total": { "$sum": "$amount" } } },
];
let mut cursor = db
.collection::<Document>(collection)
.aggregate(pipeline)
.await?;
let mut out = HashMap::new();
while cursor.advance().await? {
let d = cursor.deserialize_current()?;
let k = d.get_str("_id").unwrap_or_default().to_string();
let v = match d.get("total") {
Some(Bson::Int64(v)) => *v,
Some(Bson::Int32(v)) => i64::from(*v),
_ => 0,
};
out.insert(k, v);
}
Ok(out)
}
/// Dönemin harcamaları (grup, gün) bazında toplanmış: en fazla 4 × 62 satır.
pub(crate) async fn spent_by_group_and_date(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<Vec<(Group, String, i64)>> {
#[derive(Deserialize)]
struct Key {
group: Group,
date: String,
}
#[derive(Deserialize)]
struct Row {
#[serde(rename = "_id")]
key: Key,
total: i64,
}
let mut m = scope(household_id);
m.insert("period_id", period_id);
let pipeline = vec![
doc! { "$match": m },
doc! { "$group": {
"_id": { "group": "$group", "date": "$date" },
"total": { "$sum": "$amount" },
} },
];
let mut cursor = db
.collection::<Document>(col::ENTRIES)
.aggregate(pipeline)
.with_type::<Row>()
.await?;
let mut out = Vec::new();
while cursor.advance().await? {
let r = cursor.deserialize_current()?;
out.push((r.key.group, r.key.date, r.total));
}
Ok(out)
}
/// Hane ayarındaki varsayılan yüzdeler (yoksa 50/30/10/10).
async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult<Vec<u64>> {
let mut f = scope(household_id);
f.insert("_id", household_id);
let hh = db
.collection::<Document>(col::HOUSEHOLDS)
.find_one(f)
.await?;
let pct = hh
.as_ref()
.and_then(|h| h.get_document("settings").ok())
.and_then(|s| s.get_document("budget_split_pct").ok());
let default = [50u64, 30, 10, 10];
Ok(Group::ALL
.iter()
.zip(default)
.map(|(g, d)| {
pct.and_then(|p| match p.get(g.as_str()) {
Some(Bson::Int32(v)) => Some(*v as u64),
Some(Bson::Int64(v)) => Some(*v as u64),
_ => None,
})
.unwrap_or(d)
})
.collect())
}
/// Bu dönemden önceki en yeni `n` kapalı dönemin kimlikleri.
async fn previous_closed(
db: &Database,
household_id: Uuid,
p: &Period,
n: i64,
) -> AppResult<Vec<Uuid>> {
let mut f = scope(household_id);
f.insert("start_date", doc! { "$lt": &p.start_date });
f.insert("status", PeriodStatus::Closed.as_str());
let mut cursor = db
.collection::<Period>(col::PERIODS)
.find(f)
.sort(doc! { "start_date": -1 })
.limit(n)
.await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?.id);
}
Ok(out)
}
+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
);
}
}
+280
View File
@@ -0,0 +1,280 @@
//! Dönem kapatma ve yeniden açma (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.10, §6).
//!
//! Kapatma (tek transaction):
//! 1. Dönem `closed` olur (`closed_at`, `closed_by`, `to_savings`).
//! 2. Bu dönemden önceki devirler (yeniden kapatmada) soft delete edilir.
//! 3. Gerçekleşen birikim (değerlendirmenin 4. sorusu) dağıtılır:
//! - < 0 → açık **borç** olarak devreder (`debt`).
//! - ≥ 0 → `to_savings` kadarı birikime (varsayılan: tamamı), kalanı **artı** olarak devreder (`surplus`).
//! 4. Devir sonraki döneme bağlanır; sonraki dönem henüz yoksa `to_period_id: null` kalır
//! ve o dönem açılınca bağlanır (`period::create`).
//!
//! Yeniden açma (yalnızca sahip): dönem `correcting` olur, yeni açık dönem varsa o da açık kalır.
//! Mevcut devir, "yeniden kapat" denene kadar yerinde kalır (sonraki dönemin rakamları oynamaz).
//! Ardından kapanmış dönem olan bir dönem açılamaz: sonraki devirler zincirleme bozulurdu.
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
};
use crate::{
db::col,
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
period::{Period, PeriodStatus},
},
services::{asset, period, review},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CarryKind {
Debt,
Surplus,
}
impl CarryKind {
pub fn as_str(self) -> &'static str {
match self {
CarryKind::Debt => "debt",
CarryKind::Surplus => "surplus",
}
}
}
pub struct Carry {
pub kind: CarryKind,
pub amount: i64,
pub to_period_id: Option<Uuid>,
}
pub struct Closing {
pub period: Period,
pub realized_savings: i64,
pub to_savings: i64,
pub carryover: Option<Carry>,
/// Birikime eklenmiş kayıt var (yeniden kapatmada güncellendi)
pub savings_linked: bool,
}
/// Gerçekleşen birikimi birikim / devir olarak böler. `to_savings` verilmezse tamamı birikime.
pub fn settle(
realized: i64,
to_savings: Option<i64>,
) -> AppResult<(i64, Option<(CarryKind, i64)>)> {
if realized < 0 {
if to_savings.unwrap_or(0) != 0 {
return Err(AppError::BadRequest(
"Bu dönemde birikim yok; açık, borç olarak sonraki döneme devreder".into(),
));
}
return Ok((0, Some((CarryKind::Debt, -realized))));
}
let t = to_savings.unwrap_or(realized);
if !(0..=realized).contains(&t) {
return Err(AppError::BadRequest(
"Birikime aktarılacak tutar 0 ile gerçekleşen birikim arasında olmalı".into(),
));
}
let surplus = realized - t;
Ok((t, (surplus > 0).then_some((CarryKind::Surplus, surplus))))
}
pub async fn close(
db: &Database,
household_id: Uuid,
user_id: Uuid,
period_id: Uuid,
to_savings: Option<i64>,
version: Option<i32>,
) -> AppResult<Closing> {
let p = period::get(db, household_id, period_id).await?;
if p.status == PeriodStatus::Closed {
return Err(AppError::Conflict("Bu dönem zaten kapalı".into()));
}
if version.is_some_and(|v| v != p.version) {
return Err(period::stale());
}
let realized = review::review(db, household_id, period_id)
.await?
.realized_savings;
let (to_savings, carry) = settle(realized, to_savings)?;
let next = next_period(db, household_id, &p).await?;
// Yeniden kapatma: birikime eklenmiş kayıt varsa yeni tutara uyarlanır (önce denetlenir)
let savings_sync =
asset::plan_savings_sync(db, household_id, period_id, to_savings, &p.end_date).await?;
let ts = now();
let mut session = db.client().start_session().await?;
session.start_transaction().await?;
let result = async {
let mut f = scope(household_id);
f.insert("_id", period_id);
f.insert("version", p.version);
let r = db
.collection::<Document>(col::PERIODS)
.update_one(
f,
doc! {
"$set": {
"status": PeriodStatus::Closed.as_str(),
"closed_at": ts,
"closed_by": user_id,
"to_savings": to_savings,
"updated_at": ts,
},
"$inc": { "version": 1 },
},
)
.session(&mut session)
.await?;
if r.matched_count == 0 {
return Err(period::stale());
}
let mut old = scope(household_id);
old.insert("from_period_id", period_id);
db.collection::<Document>(col::CARRYOVERS)
.update_many(
old,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.session(&mut session)
.await?;
if let Some(sync) = &savings_sync {
asset::apply_savings_sync(db, &mut session, household_id, sync).await?;
}
if let Some((kind, amount)) = carry {
let mut d = new_meta(household_id);
d.insert("from_period_id", period_id);
d.insert(
"to_period_id",
next.as_ref().map_or(Bson::Null, |n| n.id.into()),
);
d.insert("kind", kind.as_str());
d.insert("amount", amount);
db.collection::<Document>(col::CARRYOVERS)
.insert_one(d)
.session(&mut session)
.await?;
}
Ok(())
}
.await;
match result {
Ok(()) => session.commit_transaction().await?,
Err(e) => {
let _ = session.abort_transaction().await;
return Err(e);
}
}
Ok(Closing {
savings_linked: matches!(savings_sync, Some(asset::SavingsSync::Update { .. })),
period: period::get(db, household_id, period_id).await?,
realized_savings: realized,
to_savings,
carryover: carry.map(|(kind, amount)| Carry {
kind,
amount,
to_period_id: next.map(|n| n.id),
}),
})
}
/// Kapalı dönemi düzeltme için açar (yalnızca sahip — rota denetler).
pub async fn reopen(
db: &Database,
household_id: Uuid,
user_id: Uuid,
period_id: Uuid,
) -> AppResult<Period> {
let p = period::get(db, household_id, period_id).await?;
if p.status != PeriodStatus::Closed {
return Err(AppError::Conflict(
"Yalnızca kapalı bir dönem yeniden açılabilir".into(),
));
}
let mut later = scope(household_id);
later.insert("start_date", doc! { "$gte": &p.end_date });
later.insert("status", doc! { "$ne": PeriodStatus::Open.as_str() });
if db
.collection::<Document>(col::PERIODS)
.find_one(later)
.await?
.is_some()
{
return Err(AppError::Conflict(
"Bu dönemden sonra kapanmış bir dönem var. Yalnızca en son kapanan dönem yeniden açılabilir."
.into(),
));
}
let ts = now();
let mut f = scope(household_id);
f.insert("_id", period_id);
f.insert("version", p.version);
let r = db
.collection::<Document>(col::PERIODS)
.update_one(
f,
doc! {
"$set": {
"status": PeriodStatus::Correcting.as_str(),
"reopened_at": ts,
"reopened_by": user_id,
"updated_at": ts,
},
"$inc": { "version": 1 },
},
)
.await?;
if r.matched_count == 0 {
return Err(period::stale());
}
period::get(db, household_id, period_id).await
}
/// Bu dönemden hemen sonra gelen dönem (varsa)
async fn next_period(db: &Database, household_id: Uuid, p: &Period) -> AppResult<Option<Period>> {
let mut f = scope(household_id);
f.insert("start_date", doc! { "$gte": &p.end_date });
Ok(db
.collection::<Period>(col::PERIODS)
.find_one(f)
.sort(doc! { "start_date": 1 })
.await?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settle_rules() {
// Artı: varsayılan tamamı birikime
assert_eq!(settle(700, None).unwrap(), (700, None));
assert_eq!(
settle(700, Some(500)).unwrap(),
(500, Some((CarryKind::Surplus, 200)))
);
assert_eq!(
settle(700, Some(0)).unwrap(),
(0, Some((CarryKind::Surplus, 700)))
);
assert!(settle(700, Some(701)).is_err());
assert!(settle(700, Some(-1)).is_err());
// Sıfır: devir yok
assert_eq!(settle(0, None).unwrap(), (0, None));
// Açık: borç; birikim istenemez
assert_eq!(settle(-50, None).unwrap(), (0, Some((CarryKind::Debt, 50))));
assert_eq!(
settle(-50, Some(0)).unwrap(),
(0, Some((CarryKind::Debt, 50)))
);
assert!(settle(-50, Some(10)).is_err());
}
}
+290
View File
@@ -0,0 +1,290 @@
//! Günlük defter: harcama kayıtları (KAKEIBO_PROMPT §8.4).
//!
//! Kurallar:
//! - Tutar kuruş, > 0. Tarih kaydın döneminin içinde olmalı.
//! - Grup **kategoriden** alınır (istemci grup göndermez) → kategori ile grup hiç çelişmez.
//! - Kategori ve ödeme türü bu haneye ait olmalı; yeni seçimde **aktif** olmalı
//! (pasife alınmış kategoride kalan eski kayıt düzenlenebilir).
//! - Yalnızca açık dönemde yazma. Silme soft delete; "Geri al" için `restore`.
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
options::ReturnDocument,
};
use crate::{
db::col,
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
entry::Entry,
group::Group,
period::Period,
},
services::{calendar::parse_date, catalog, period},
};
/// Açıklama üst sınırı (karakter)
pub const MAX_DESCRIPTION: usize = 200;
pub struct NewEntry {
pub date: String,
pub amount: i64,
pub category_id: Uuid,
pub payment_method_id: Option<Uuid>,
pub description: Option<String>,
}
#[derive(Default)]
pub struct EntryChanges {
pub date: Option<String>,
pub amount: Option<i64>,
pub category_id: Option<Uuid>,
/// `Some(None)` → ödeme türünü kaldır (istekte boş metin)
pub payment_method_id: Option<Option<Uuid>>,
/// `Some("")` → açıklamayı kaldır
pub description: Option<String>,
pub version: Option<i32>,
}
/// Liste filtresi: tarih aralığı (ikisi de dahil, isteğe bağlı)
#[derive(Default)]
pub struct EntryFilter {
pub from: Option<String>,
pub to: Option<String>,
}
fn coll(db: &Database) -> mongodb::Collection<Entry> {
db.collection(col::ENTRIES)
}
/// Dönemin kayıtları: en yeni tarih başta, aynı gün içinde en son eklenen başta.
pub async fn list(
db: &Database,
household_id: Uuid,
period_id: Uuid,
filter: EntryFilter,
) -> AppResult<Vec<Entry>> {
period::get(db, household_id, period_id).await?; // dönem bu haneye ait mi?
let mut f = scope(household_id);
f.insert("period_id", period_id);
let mut range = Document::new();
if let Some(from) = filter.from {
range.insert("$gte", valid_date(&from)?);
}
if let Some(to) = filter.to {
range.insert("$lte", valid_date(&to)?);
}
if !range.is_empty() {
f.insert("date", range);
}
// UUID v7 zamana göre sıralı → `_id` azalan = en son eklenen önce
let mut cursor = coll(db)
.find(f)
.sort(doc! { "date": -1, "_id": -1 })
.await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?);
}
Ok(out)
}
pub async fn create(
db: &Database,
household_id: Uuid,
user_id: Uuid,
period_id: Uuid,
e: NewEntry,
) -> AppResult<Entry> {
let p = period::get_open(db, household_id, period_id).await?;
let date = clean_date(&e.date, &p)?;
let amount = clean_amount(e.amount)?;
let group = category_group(db, household_id, e.category_id, None).await?;
if let Some(pm) = e.payment_method_id {
check_payment_method(db, household_id, pm, None).await?;
}
let description = clean_description(e.description.as_deref())?;
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
d.insert("period_id", period_id);
d.insert("date", date);
d.insert("amount", amount);
d.insert("group", group.as_str());
d.insert("category_id", e.category_id);
d.insert(
"payment_method_id",
e.payment_method_id.map_or(Bson::Null, Bson::from),
);
d.insert("description", description.map_or(Bson::Null, Bson::String));
d.insert("created_by", user_id);
db.collection::<Document>(col::ENTRIES)
.insert_one(d)
.await?;
coll(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("eklenen kayıt okunamadı".into()))
}
pub async fn update(
db: &Database,
household_id: Uuid,
id: Uuid,
ch: EntryChanges,
) -> AppResult<Entry> {
let current = get(db, household_id, id).await?;
let p = period::get_open(db, household_id, current.period_id).await?;
if ch.version.is_some_and(|v| v != current.version) {
return Err(stale());
}
let mut set = doc! { "updated_at": now() };
if let Some(date) = ch.date {
set.insert("date", clean_date(&date, &p)?);
}
if let Some(amount) = ch.amount {
set.insert("amount", clean_amount(amount)?);
}
if let Some(cid) = ch.category_id {
let group = category_group(db, household_id, cid, Some(current.category_id)).await?;
set.insert("category_id", cid);
set.insert("group", group.as_str());
}
if let Some(pm) = ch.payment_method_id {
if let Some(pm) = pm {
check_payment_method(db, household_id, pm, current.payment_method_id).await?;
}
set.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
}
if let Some(desc) = ch.description {
set.insert(
"description",
clean_description(Some(&desc))?.map_or(Bson::Null, Bson::String),
);
}
let mut f = scope(household_id);
f.insert("_id", id);
f.insert("version", current.version);
coll(db)
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
.return_document(ReturnDocument::After)
.await?
.ok_or_else(stale)
}
/// Soft delete. Silinen kayıt `restore` ile geri getirilebilir ("Geri al").
pub async fn delete(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
let current = get(db, household_id, id).await?;
period::get_open(db, household_id, current.period_id).await?;
let ts = now();
let mut f = scope(household_id);
f.insert("_id", id);
coll(db)
.update_one(
f,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
Ok(())
}
/// Silinmiş kaydı geri getirir. Silinmemiş ya da başka hanenin kaydı → 404.
pub async fn restore(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Entry> {
let f = doc! { "_id": id, "household_id": household_id, "deleted_at": { "$ne": null } };
let deleted = coll(db)
.find_one(f.clone())
.await?
.ok_or(AppError::NotFound)?;
period::get_open(db, household_id, deleted.period_id).await?;
coll(db)
.find_one_and_update(
f,
doc! { "$set": { "deleted_at": null, "updated_at": now() }, "$inc": { "version": 1 } },
)
.return_document(ReturnDocument::After)
.await?
.ok_or(AppError::NotFound)
}
// ---------- yardımcılar ----------
async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Entry> {
let mut f = scope(household_id);
f.insert("_id", id);
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
}
/// Kategori bu haneye ait mi, aktif mi (ya da kaydın zaten kullandığı kategori mi)? → grubu
async fn category_group(
db: &Database,
household_id: Uuid,
category_id: Uuid,
current: Option<Uuid>,
) -> AppResult<Group> {
let c = catalog::find(db, household_id, catalog::Catalog::Categories, category_id)
.await?
.ok_or_else(|| AppError::BadRequest("Kategori bulunamadı".into()))?;
if !c.is_active && current != Some(category_id) {
return Err(AppError::BadRequest(
"Bu kategori pasif; başka bir kategori seçin".into(),
));
}
c.group
.ok_or_else(|| AppError::Internal("kategorinin grubu yok".into()))
}
async fn check_payment_method(
db: &Database,
household_id: Uuid,
id: Uuid,
current: Option<Uuid>,
) -> AppResult<()> {
let m = catalog::find(db, household_id, catalog::Catalog::PaymentMethods, id)
.await?
.ok_or_else(|| AppError::BadRequest("Ödeme türü bulunamadı".into()))?;
if !m.is_active && current != Some(id) {
return Err(AppError::BadRequest(
"Bu ödeme türü pasif; başka birini seçin".into(),
));
}
Ok(())
}
fn valid_date(s: &str) -> AppResult<String> {
let s = s.trim();
parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
Ok(s.to_string())
}
fn clean_date(s: &str, p: &Period) -> AppResult<String> {
let s = valid_date(s)?;
if s < p.start_date || s >= p.end_date {
return Err(AppError::BadRequest("Tarih dönem içinde olmalı".into()));
}
Ok(s)
}
fn clean_amount(v: i64) -> AppResult<i64> {
if v <= 0 {
return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into()));
}
period::validate_amount(v)
}
fn clean_description(d: Option<&str>) -> AppResult<Option<String>> {
match d.map(str::trim) {
None | Some("") => Ok(None),
Some(s) if s.chars().count() > MAX_DESCRIPTION => Err(AppError::BadRequest(format!(
"Açıklama en fazla {MAX_DESCRIPTION} karakter olabilir"
))),
Some(s) => Ok(Some(s.to_string())),
}
}
fn stale() -> AppError {
AppError::Conflict("Kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
+164
View File
@@ -0,0 +1,164 @@
//! Bir varlığın eldeki durumu: işlemlerden **ağırlıklı ortalama maliyet** yöntemiyle hesaplanır
//! (saklanmaz). Saf fonksiyon; veritabanı yok.
//!
//! - Alım: miktar += q, maliyet += toplam tutar.
//! - Satış: çıkan maliyet = maliyet × q / miktar (ortalama maliyetten);
//! gerçekleşen kâr/zarar += satış tutarı − çıkan maliyet.
//! - Hiçbir anda miktar eksiye düşemez (eldekinden fazla satılamaz).
use crate::models::asset::TxType;
/// Hesap için işlem özeti; **tarih sırasıyla** verilmeli (aynı gün: ekleniş sırası).
#[derive(Debug, Clone, Copy)]
pub struct Move {
pub tx_type: TxType,
pub quantity_e6: i64,
/// Kuruş
pub total: i64,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Holding {
pub quantity_e6: i64,
/// Eldeki miktarın maliyeti (kuruş)
pub cost: i64,
/// Birim başına ortalama maliyet (TL × 1e6); elde yoksa `None`
pub avg_cost_e6: Option<i64>,
/// Satışlardan gerçekleşen kâr/zarar (kuruş)
pub realized_pl: i64,
}
/// Miktarın eksiye düştüğü işlemin sırası (0'dan)
#[derive(Debug, PartialEq, Eq)]
pub struct Oversold(pub usize);
/// a × b / c, en yakına yuvarlanmış (taşma olmasın diye i128)
fn mul_div(a: i64, b: i64, c: i64) -> i64 {
let (a, b, c) = (i128::from(a), i128::from(b), i128::from(c));
let n = a * b;
let q = (2 * n + c) / (2 * c); // pozitif sayılar için yarım yukarı
q as i64
}
pub fn holding(moves: &[Move]) -> Result<Holding, Oversold> {
let mut h = Holding::default();
for (i, m) in moves.iter().enumerate() {
match m.tx_type {
TxType::Buy => {
h.quantity_e6 += m.quantity_e6;
h.cost += m.total;
}
TxType::Sell => {
if m.quantity_e6 > h.quantity_e6 {
return Err(Oversold(i));
}
let out = if m.quantity_e6 == h.quantity_e6 {
h.cost // tamamı satıldı: yuvarlama artığı kalmasın
} else {
mul_div(h.cost, m.quantity_e6, h.quantity_e6)
};
h.realized_pl += m.total - out;
h.cost -= out;
h.quantity_e6 -= m.quantity_e6;
}
}
}
// kuruş → TL×1e6 için ×10.000, birim e6 → ×1e6 ⇒ maliyet × 1e10 / miktar_e6
h.avg_cost_e6 = (h.quantity_e6 > 0).then(|| mul_div(h.cost, 10_000_000_000, h.quantity_e6));
Ok(h)
}
/// Birim fiyat verilmezse toplam ÷ miktar (TL × 1e6)
pub fn unit_price_e6(total: i64, quantity_e6: i64) -> i64 {
mul_div(total, 10_000_000_000, quantity_e6)
}
#[cfg(test)]
mod tests {
use super::*;
const fn buy(q: i64, t: i64) -> Move {
Move {
tx_type: TxType::Buy,
quantity_e6: q,
total: t,
}
}
const fn sell(q: i64, t: i64) -> Move {
Move {
tx_type: TxType::Sell,
quantity_e6: q,
total: t,
}
}
const GR: i64 = 1_000_000;
#[test]
fn weighted_average_cost() {
// 10 gr × 3.000 TL + 10 gr × 4.000 TL → ort. 3.500 TL/gr
let h = holding(&[buy(10 * GR, 3_000_000), buy(10 * GR, 4_000_000)]).unwrap();
assert_eq!(h.quantity_e6, 20 * GR);
assert_eq!(h.cost, 7_000_000);
assert_eq!(h.avg_cost_e6, Some(3_500_000_000));
assert_eq!(h.realized_pl, 0);
}
#[test]
fn sell_uses_average_cost() {
// 5 gr 4.500'den satıldı: çıkan maliyet 5 × 3.500 = 17.500 → kâr 5.000
let h = holding(&[
buy(10 * GR, 3_000_000),
buy(10 * GR, 4_000_000),
sell(5 * GR, 2_250_000),
])
.unwrap();
assert_eq!(h.quantity_e6, 15 * GR);
assert_eq!(h.cost, 5_250_000);
assert_eq!(h.realized_pl, 500_000);
assert_eq!(
h.avg_cost_e6,
Some(3_500_000_000),
"satış ortalamayı değiştirmez"
);
}
#[test]
fn selling_everything_clears_cost() {
// 3 parça: 1/3 maliyetler yuvarlanır ama sonunda artık kalmaz
let h = holding(&[
buy(3 * GR, 1_000),
sell(GR, 300),
sell(GR, 300),
sell(GR, 300),
])
.unwrap();
assert_eq!(
h,
Holding {
quantity_e6: 0,
cost: 0,
avg_cost_e6: None,
realized_pl: -100
}
);
}
#[test]
fn cannot_sell_more_than_held() {
assert_eq!(
holding(&[buy(GR, 100), sell(2 * GR, 300)]),
Err(Oversold(1))
);
assert_eq!(
holding(&[sell(GR, 100), buy(GR, 100)]),
Err(Oversold(0)),
"sıra önemli"
);
}
#[test]
fn unit_price_from_total() {
assert_eq!(unit_price_e6(1_625_000, 5 * GR), 3_250_000_000); // 16.250 TL / 5 gr
assert_eq!(unit_price_e6(100, 3 * GR), 333_333); // 1 TL / 3 → 0,333333
}
}
+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");
}
}
+21
View File
@@ -0,0 +1,21 @@
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
pub mod account;
pub mod asset;
pub mod budget;
pub mod calendar;
pub mod catalog;
pub mod closing;
pub mod entry;
pub mod holding;
pub mod invite;
pub mod money;
pub mod note;
pub mod period;
pub mod plan;
pub mod rate_sources;
pub mod rates;
pub mod reports;
pub mod review;
pub mod seed;
pub mod stats;
+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())
}
+357
View File
@@ -0,0 +1,357 @@
//! Dönem iş mantığı (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.4).
//!
//! Kurallar:
//! - Hanede aynı anda en fazla **bir açık** dönem (servis kontrolü + kısmi benzersiz indeks).
//! - Dönemler çakışmaz. Bitiş tarihi hariçtir.
//! - Kapalı dönem salt okunur (yeniden açma: Faz 6, yalnızca sahip).
//! - Tarih aralığı daraltılırken dışarıda harcama kalıyorsa engellenir.
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
options::ReturnDocument,
};
use time::Date;
use crate::{
db::{col, is_duplicate_key},
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
period::{Period, PeriodStatus},
},
services::calendar::{days_between, default_period_name, format_date, parse_date},
};
/// En uzun dönem (gün). Aylık defter için ~2 ay pay bırakır.
pub const MAX_DAYS: i64 = 62;
/// Birikim hedefi üst sınırı (kuruş) — yazım hatalarını yakalamak için: 100 milyar ₺
pub const MAX_AMOUNT: i64 = 10_000_000_000_000;
pub struct NewPeriod {
pub start_date: String,
pub end_date: String,
pub name: Option<String>,
pub savings_goal: Option<i64>,
}
#[derive(Default)]
pub struct PeriodChanges {
pub name: Option<String>,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub savings_goal: Option<i64>,
/// `Some("")` → metni kaldır
pub reflection_md: Option<String>,
pub version: Option<i32>,
}
/// "Nasıl daha iyi yapabilirim?" üst sınırı (karakter) — notlarla aynı
pub const MAX_REFLECTION: usize = 20_000;
fn coll(db: &Database) -> mongodb::Collection<Period> {
db.collection(col::PERIODS)
}
pub async fn current(db: &Database, household_id: Uuid) -> AppResult<Option<Period>> {
let mut f = scope(household_id);
f.insert("status", PeriodStatus::Open.as_str());
Ok(coll(db).find_one(f).await?)
}
/// En yeni dönem başta.
pub async fn list(db: &Database, household_id: Uuid) -> AppResult<Vec<Period>> {
let mut cursor = coll(db)
.find(scope(household_id))
.sort(doc! { "start_date": -1 })
.await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?);
}
Ok(out)
}
pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
let mut f = scope(household_id);
f.insert("_id", id);
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
}
/// Yazma işlemleri için: dönem bu haneye ait ve **açık** olmalı.
/// Plan kalemleri, notlar ve (Faz 5) harcamalar bunu kullanır.
pub async fn get_open(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
let p = get(db, household_id, id).await?;
if !p.status.is_writable() {
return Err(closed());
}
Ok(p)
}
pub fn closed() -> AppError {
AppError::Conflict(
"Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(),
)
}
pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResult<Period> {
let (start, end) = validate_range(&p.start_date, &p.end_date)?;
let savings_goal = validate_amount(p.savings_goal.unwrap_or(0))?;
let name = clean_name(p.name.as_deref(), start, end)?;
if let Some(open) = current(db, household_id).await? {
return Err(AppError::Conflict(format!(
"Zaten açık bir dönem var ({}). Yeni dönem açmadan önce onu kapatın.",
open.name
)));
}
ensure_no_overlap(db, household_id, start, end, None).await?;
// Bir önceki dönem (devir ve "önceki aydan kopyala" için)
let mut prev_f = scope(household_id);
prev_f.insert("start_date", doc! { "$lt": format_date(start) });
let previous = coll(db)
.find_one(prev_f)
.sort(doc! { "start_date": -1 })
.await?;
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
d.insert("name", name);
d.insert("start_date", format_date(start));
d.insert("end_date", format_date(end));
d.insert("status", PeriodStatus::Open.as_str());
d.insert("savings_goal", savings_goal);
d.insert("reflection_md", Bson::Null);
d.insert("to_savings", Bson::Null);
d.insert(
"previous_period_id",
previous.as_ref().map_or(Bson::Null, |p| p.id.into()),
);
d.insert("closed_at", Bson::Null);
d.insert("closed_by", Bson::Null);
d.insert("reopened_at", Bson::Null);
// Tek transaction: dönem + önceki dönemden bekleyen devrin bu döneme bağlanması
let mut session = db.client().start_session().await?;
session.start_transaction().await?;
let result = async {
db.collection::<Document>(col::PERIODS)
.insert_one(d)
.session(&mut session)
.await?;
if let Some(prev) = &previous {
let mut f = scope(household_id);
f.insert("from_period_id", prev.id);
f.insert("to_period_id", Bson::Null);
db.collection::<Document>(col::CARRYOVERS)
.update_many(
f,
doc! { "$set": { "to_period_id": id.clone(), "updated_at": now() }, "$inc": { "version": 1 } },
)
.session(&mut session)
.await?;
}
Ok::<_, mongodb::error::Error>(())
}
.await;
match result {
Ok(()) => session.commit_transaction().await?,
Err(e) => {
let _ = session.abort_transaction().await;
// Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller
if is_duplicate_key(&e) {
return Err(AppError::Conflict(
"Zaten açık bir dönem var. Sayfayı yenileyin.".into(),
));
}
return Err(e.into());
}
}
coll(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("açılan dönem okunamadı".into()))
}
pub async fn update(
db: &Database,
household_id: Uuid,
id: Uuid,
ch: PeriodChanges,
) -> AppResult<Period> {
let current = get(db, household_id, id).await?;
if !current.status.is_writable() {
return Err(closed());
}
if ch.version.is_some_and(|v| v != current.version) {
return Err(stale());
}
let mut set = doc! { "updated_at": now() };
let start_s = ch.start_date.as_deref().unwrap_or(&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).
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,
})
}
+19 -3
View File
@@ -1,15 +1,31 @@
//! Tüm route'ların paylaştığı uygulama durumu. //! Tüm route'ların paylaştığı uygulama durumu.
use std::sync::Arc; use std::{sync::Arc, time::Duration};
use mongodb::Database; use mongodb::Database;
use crate::config::Config; use crate::{
auth::{jwt::Jwt, limiter::LoginLimiter},
config::Config,
};
/// `Clone` ucuzdur: `Database` ve `Arc` içte referans sayımı kullanır, veri kopyalanmaz. /// `Clone` ucuzdur: `Database` ve `Arc` içte referans sayımı kullanır, veri kopyalanmaz.
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub db: Database, pub db: Database,
#[allow(dead_code)] // Faz 1 – Adım 3'te kullanılacak
pub config: Arc<Config>, 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;
}
+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;
}
+161
View File
@@ -0,0 +1,161 @@
//! 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)
}
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;
}
@@ -0,0 +1,11 @@
# Bu bilgisayar: ~/.config/systemd/user/kakeibo-backup-pull.service
[Unit]
Description=Kakeibo yedeklerini sunucudan çek
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
# ~/.ssh/config'teki sunucu adı "atlas" değilse başındaki # işaretini kaldırıp düzeltin
#Environment=KAKEIBO_BACKUP_HOST=atlas
ExecStart=%h/Developer/Rust/kakeibo/scripts/backup-pull.sh
+12
View File
@@ -0,0 +1,12 @@
# Bu bilgisayar: ~/.config/systemd/user/kakeibo-backup-pull.timer
[Unit]
Description=Kakeibo yedeklerini her gün çek
[Timer]
# Sunucudaki yedekten sonra; bilgisayar kapalıysa açıldıktan 10 dk sonra
OnCalendar=*-*-* 09:00:00
OnStartupSec=10min
Persistent=true
[Install]
WantedBy=timers.target
+13
View File
@@ -0,0 +1,13 @@
# Atlas (sunucu): /etc/systemd/system/kakeibo-backup.service
[Unit]
Description=Kakeibo MongoDB günlük yedeği
Wants=docker.service
After=docker.service
[Service]
Type=oneshot
# Yedeği çeken SSH kullanıcısı root değilse: başındaki # işaretini kaldırıp grubunu yazın.
#Environment=BACKUP_GROUP=<ssh-kullanıcınız>
ExecStart=/usr/local/lib/kakeibo/backup.sh
Nice=10
IOSchedulingClass=idle
+12
View File
@@ -0,0 +1,12 @@
# Atlas (sunucu): /etc/systemd/system/kakeibo-backup.timer
[Unit]
Description=Kakeibo yedeği her gece 03:30
[Timer]
OnCalendar=*-*-* 03:30:00
# Sunucu o saatte kapalıysa açılınca hemen çalışır
Persistent=true
RandomizedDelaySec=5m
[Install]
WantedBy=timers.target
+16 -8
View File
@@ -81,16 +81,17 @@ Renkler ve ikonlar frontend tasarım token'larında.
```js ```js
{ _id, household_id, name /* "Aralık 2026" */, { _id, household_id, name /* "Aralık 2026" */,
start_date: "2026-12-01", end_date: "2027-01-01", // bitiş HARİÇ start_date: "2026-12-01", end_date: "2027-01-01", // bitiş HARİÇ
status: "open" | "closed", status: "open" | "closed" | "correcting",
savings_goal: 500000, // kuruş — birikim hedefi savings_goal: 500000, // kuruş — birikim hedefi
reflection_md: "...", // "Nasıl daha iyi yapabilirim?" reflection_md: "...", // "Nasıl daha iyi yapabilirim?"
closed_at, closed_by, reopened_at, to_savings: 450000, // kapanışta birikime aktarılan (Faz 8'de varlık hareketi)
closed_at, closed_by, reopened_at, reopened_by,
previous_period_id, previous_period_id,
created_at, updated_at, deleted_at, version } created_at, updated_at, deleted_at, version }
``` ```
- **Bitiş tarihi hariç:** `01.12.2026 – 01.01.2027` → 1–31 Aralık. Dönemler arka arkaya boşluksuz ve çakışmasız eklenir. - **Bitiş tarihi hariç:** `01.12.2026 – 01.01.2027` → 1–31 Aralık. Dönemler arka arkaya boşluksuz ve çakışmasız eklenir.
- Hanede en fazla **bir** `open` dönem (servis kontrolü + kısmi benzersiz indeks). - Hanede en fazla **bir** `open` dönem (servis kontrolü + kısmi benzersiz indeks).
- Yeniden açılan dönemin durumu tekrar `open` olur. O sırada başka açık dönem varsa önce o kapatılmalı mı, yoksa geçici olarak iki açık dönem mi olsun? → **Açık konu, Faz 6'da netleşecek.** - **Yeniden açma (karar 2026-11-12, "düzeltme modu"):** sahip kapalı dönemi açınca durum `correcting` olur; yazılabilir ama "açık dönem" sayılmaz → yeni dönem açık kalabilir (tek `open` kuralı korunur). Mevcut devir "yeniden kapat"a kadar yerinde kalır; yeniden kapatınca eskisi soft delete, yenisi hesaplanır. Ardından kapanmış dönem varsa açılamaz (zincirleme devirler bozulmasın).
- **Haftalar saklanmaz.** `start_date`/`end_date`'ten hesaplanır (Pazartesi başlangıçlı; ilk/son kısmi haftalar ayrı). - **Haftalar saklanmaz.** `start_date`/`end_date`'ten hesaplanır (Pazartesi başlangıçlı; ilk/son kısmi haftalar ayrı).
### 3.5 `categories` — Alt kategoriler ### 3.5 `categories` — Alt kategoriler
@@ -143,7 +144,8 @@ Renkler ve ikonlar frontend tasarım token'larında.
kind: "debt" | "surplus", amount: 120000, kind: "debt" | "surplus", amount: 120000,
created_at, ... } created_at, ... }
``` ```
- Dönem kapanışında oluşur. Dönem yeniden açılırsa soft delete edilir ve kapanışta yeniden hesaplanır. - Dönem kapanışında oluşur. Sonraki dönem henüz yoksa `to_period_id: null`; o dönem açılınca (aynı transaction'da) bağlanır.
- Yeniden kapatmada eskisi soft delete edilir, yenisi yazılır (düzeltme süresince eskisi geçerli kalır).
### 3.11 `notes` ### 3.11 `notes`
```js ```js
@@ -158,8 +160,9 @@ Renkler ve ikonlar frontend tasarım token'larında.
kind: "cash" | "gold" | "fx", kind: "cash" | "gold" | "fx",
code: "TRY" | "XAU_GRAM" | "XAU_CEYREK" | "XAU_YARIM" | "XAU_TAM" | "XAU_ONS" | "USD" | "EUR" | ..., code: "TRY" | "XAU_GRAM" | "XAU_CEYREK" | "XAU_YARIM" | "XAU_TAM" | "XAU_ONS" | "USD" | "EUR" | ...,
name: "Gram Altın", unit_label: "gr", name: "Gram Altın", unit_label: "gr",
is_active, created_at, ... } sort_order, is_active, created_at, ... }
``` ```
- Varsayılanlar hane açılınca gelir (migration v2 eski hanelere ekler): TRY, XAU_GRAM, XAU_CEYREK, XAU_YARIM, XAU_TAM, XAU_ONS, USD, EUR. Kullanıcı yalnızca **döviz** (`fx`, 3 harfli kod) ekler; kod hanede tekil. Pasif varlık listede kalır, yeni işlem almaz.
### 3.13 `asset_transactions` — Alım / satım ### 3.13 `asset_transactions` — Alım / satım
```js ```js
@@ -172,7 +175,10 @@ Renkler ve ikonlar frontend tasarım token'larında.
note, source_period_id /* dönem kapanışından geldiyse */, note, source_period_id /* dönem kapanışından geldiyse */,
created_at, ... } created_at, ... }
``` ```
- Ortalama maliyet ve kâr/zarar bu kayıtlardan hesaplanır, saklanmaz. - Ortalama maliyet ve kâr/zarar bu kayıtlardan hesaplanır, saklanmaz: **ağırlıklı ortalama maliyet** (satışta çıkan maliyet = maliyet × satılan / eldeki; gerçekleşen kâr/zarar = satış tutarı − çıkan maliyet). Maliyet `total`'dan hesaplanır (birim fiyat bilgi amaçlı; verilmezse `total ÷ quantity`).
- Tarih sırasıyla (aynı gün: ekleniş sırası) hiçbir anda eldeki miktar eksiye düşemez; ekleme, düzenleme ve silmede yeniden denetlenir.
- TL varlığında `quantity_e6 = total × 10.000`, `unit_price_e6 = 1.000.000`.
- **Kapanıştan birikim:** kullanıcı onaylayınca (`POST /periods/{id}/to-savings`) dönemin `to_savings` tutarı TL varlığına `buy` olarak eklenir: tarih = dönemin son günü, `source_period_id` = dönem; dönem başına tek. Eklenmemiş kapalı dönemler `GET /savings/pending`. **Yeniden kapatmada** bağlı kayıt yeni tutar ve tarihe güncellenir (tutar 0 → silinir), kapatma transaction'ı içinde; TL'den sonraki çıkışlar yeni tutarı aşacaksa kapatma reddedilir (kullanıcı kararı "a", 2026-11-13). Bağlı kayıt yoksa otomatik eklenmez (onay gerekir).
### 3.14 `exchange_rates` — Kurlar ### 3.14 `exchange_rates` — Kurlar
```js ```js
@@ -182,7 +188,9 @@ Renkler ve ikonlar frontend tasarım token'larında.
rate_date: "2026-12-15", fetched_at, rate_date: "2026-12-15", fetched_at,
created_at, ... } created_at, ... }
``` ```
- Güncel kur sırası: hanenin elle girdiği en yeni kur → en yeni otomatik kur. - Güncel kur: kod başına **en yeni `rate_date`**; aynı gün hem otomatik hem manuel varsa manuel. (Eski bir manuel kur, daha yeni otomatik kurun önüne geçmez.) Kur = 1 birimin TL karşılığı × 1e6; döviz **alış** kuru (değerleme).
- Otomatik kurlar gün başına tek kayıt (kod × kaynak × gün; aynı gün yeniden çekilince güncellenir) → günlük geçmiş birikir. TCMB `rate_date` = bülten tarihi; altın = Türkiye'de bugün.
- Altın: xaus.com gram (saf) USD × TCMB USD alış; çeyrek/yarım/tam = has gram (1,603 / 3,206 / 6,412) × gram, ons = 31,1034768 gr. İşçilik payı yok → gerekirse manuel.
### 3.15 `schema_migrations` ### 3.15 `schema_migrations`
```js ```js
@@ -223,7 +231,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
## 6. Dönem Kapanışı (tek transaction) ## 6. Dönem Kapanışı (tek transaction)
1. `periods.status = "closed"`, `closed_at`, `closed_by` 1. `periods.status = "closed"`, `closed_at`, `closed_by`
2. `carryovers` kaydı (borç veya artı) → sonraki dönem 2. `carryovers` kaydı (borç veya artı) → sonraki dönem
3. (Onaylıysa) gerçekleşen birikim → `asset_transactions` (`TRY`, `buy`, `source_period_id`) 3. Gerçekleşen birikimin `to_savings` kadarı birikime: şimdilik `periods.to_savings`; Faz 8'de → `asset_transactions` (`TRY`, `buy`, `source_period_id`)
4. (İstenirse) yeni dönem: `plan_items` ve aylık `budgets` kopyalanarak açılır 4. (İstenirse) yeni dönem: `plan_items` ve aylık `budgets` kopyalanarak açılır
Transaction'ın bir adımı hata verirse önceki adımlar da geri alınır, hiçbir şey değişmez. Transaction'ın bir adımı hata verirse önceki adımlar da geri alınır, hiçbir şey değişmez.
+173
View File
@@ -0,0 +1,173 @@
# 🚀 Canlıya Alma (Atlas Homeserver + Dokploy)
> Durum: ✅ Yayında — https://kakeibo.ta1sph.website (2026-11-12) · Yedekleme: betikler hazır, sunucuda kurulum bekliyor (§6)
## 1. Mimari
```
İnternet ──HTTPS──▶ Traefik (Dokploy) ──▶ kakeibo konteyneri :8080 ──▶ MongoDB (rs0)
├─ /api/* → Rust (Axum) API
└─ diğer → derlenmiş arayüz (React)
```
- **Tek imaj, tek servis, tek alan adı** → CORS yok, cookie aynı origin'de.
- İmaj: çok aşamalı `Dockerfile` (Node → arayüz derlemesi, Rust → release derleme, `debian:trixie-slim` çalışma imajı, root olmayan kullanıcı `kakeibo`, ~175 MB).
- Sağlık kontrolü: `GET /api/health` → `200 {"status":"ok","db":"ok"}`; veritabanı yoksa `503`.
- Kapanış: `SIGTERM` gelince süren istekler bitirilir, sonra kapanır.
## 2. Ortam değişkenleri
| Değişken | Zorunlu | Örnek / varsayılan | Açıklama |
|---|---|---|---|
| `MONGODB_URI` | ✅ | `mongodb://kakeibo_app:<şifre>@<host>:27017/kakeibo?authSource=kakeibo&replicaSet=rs0` | Ayrı, yalnızca `kakeibo` DB'sinde `readWrite` yetkili kullanıcı |
| `JWT_SECRET` | ✅ | `openssl rand -hex 32` çıktısı | En az 32 karakter. **Değişirse tüm oturumlar kapanır** |
| `APP_URL` | ✅ | `https://kakeibo.<alan-adı>` | `https://` ile başlarsa cookie `Secure` olur |
| `REGISTRATION` | – | `invite_only` | `invite_only`: ilk hane serbest, sonra yalnızca davet · `open`: herkes |
| `RUST_LOG` | – | `kakeibo=info,tower_http=info` | Log seviyesi |
| `RATES_REFRESH_MINUTES` | – | `60` | Otomatik kur yenileme aralığı (dk); `0` → kapalı. Konteynerin internete çıkışı gerekir: `www.tcmb.gov.tr` (döviz), `xaus.com` (altın). Erişilemezse son kur kalır, elle kur girilebilir |
| `PORT` | – | `8080` | İmajda ayarlı, değiştirmeyin |
| `STATIC_DIR` | – | `/app/static` | İmajda ayarlı, değiştirmeyin |
## 3. Yerelde imajı deneme
```bash
docker build -t kakeibo .
# Yerel MongoDB (27020) ile, geliştirme sunucusuyla çakışmasın diye 8090'da:
docker run --rm --network host --env-file .env -e PORT=8090 kakeibo
# → http://localhost:8090
```
Kontrol listesi (Faz 3 – Adım 1'de doğrulandı):
- [x] `/` ve `/defter` → `index.html` (`Cache-Control: no-cache`)
- [x] `/assets/*.js` → brotli/gzip, `max-age=31536000, immutable`
- [x] `/api/health` → `db: ok`; bilinmeyen `/api/...` → JSON 404
- [x] Docker `HEALTHCHECK` → healthy; süreç `kakeibo` kullanıcısıyla çalışır
- [x] `docker stop` → düzgün kapanış logu
## 4. Canlı MongoDB
- Dokploy'da **ayrı** MongoDB servisi (Bütçem'in veritabanı paylaşılmaz): proje `Kakeibo` → Database → MongoDB `kakeibo-mongo`.
- Servis (iç ağ) adı: **`kakeibo-kakeibomongo-fibzhc`**, imaj `mongo:7`, ağ `dokploy-network`, volume `kakeibo-kakeibomongo-fibzhc-data → /data/db`.
- Replica set: `rs0`, üye `kakeibo-kakeibomongo-fibzhc:27017` (Dokploy "Replica Sets" açık).
- Kullanıcılar: `kakeibo_root` (admin, `root`) · `kakeibo_app` (`kakeibo` DB, yalnızca `readWrite`). Şifreler 2026-11-12'de yenilendi (eskileri açığa çıkmıştı).
- Dış erişim: `docker service inspect … .Endpoint.Ports` → `null` (port yayınlanmıyor) ✅. External Port alanı boş kalmalı.
- Bağlantı:
```
mongodb://kakeibo_app:<APP_ŞİFRESİ>@kakeibo-kakeibomongo-fibzhc:27017/kakeibo?authSource=kakeibo&replicaSet=rs0
```
### ⚠️ Açık konu: MongoDB kimlik doğrulaması kapalı
Dokploy'un replica set betiği `mongod --replSet rs0 --bind_ip_all` ile başlatıyor (`--keyFile` yok) ve her deploy'da
Advanced → Command/Args'ı yok sayıp kendi betiğini yazıyor. Sonuç: kullanıcılar tanımlı ama şifre **zorunlu değil**;
`dokploy-network`'teki herhangi bir konteyner erişebilir. İnternetten erişim yok.
Çözüm adayları (ileride): Dokploy'un MongoDB'si yerine Compose servisi (kendi komutumuzla `--keyFile`) ya da Dokploy'da keyFile desteği.
Uygulama zaten kullanıcı/şifreyle bağlandığı için auth açıldığında uygulamada değişiklik gerekmez.
### Şifre girerken dikkat
- `mongosh` içinde `passwordPrompt()`'a **yapıştırmayın** (terminal görünmez karakter ekleyebiliyor → `U_STRINGPREP_PROHIBITED_ERROR`).
Bunun yerine: `const p = require('crypto').randomBytes(24).toString('hex'); db.changeUserPassword("kakeibo_app", p); print(p)`
- Şifreyi komuta düz yazmayın; sonra `rm -f /root/.mongodb/mongosh/mongosh_repl_history`.
- "Rebuild Database" veriyi siler ve Dokploy betiği yeni root şifresini servis ayarına düz metin yazar.
## 5. Dokploy kurulumu
Proje `Kakeibo` → Create Service → **Application** `kakeibo-app` (iç ad: `kakeibo-kakeiboapp-nd5kek`).
| Bölüm | Ayar |
|---|---|
| General → Provider | **Gitea** sekmesi (varsayılan GitHub sekmesi seçili kalırsa: `Github Provider not found`) → repo `suphicakir/kakeibo`, branch `main`, Build Path `/` → **Save** |
| General → Build Type | **Dockerfile**, dosya `Dockerfile`, context `.` |
| Environment | §2'deki değişkenler (`MONGODB_URI`, `JWT_SECRET`, `APP_URL=https://kakeibo.ta1sph.website`, `REGISTRATION=invite_only`, `RUST_LOG`) |
| Domains | Host `kakeibo.ta1sph.website`, Path `/`, **Container Port `8080`**, HTTPS açık, Let's Encrypt |
| Auto Deploy | Açık → Gitea'ya push = otomatik yayın |
Notlar:
- İlk derleme ~6–10 dk (Rust bağımlılıkları); sonrakiler katman önbelleğiyle daha hızlı.
- Derleme hatası **Deployments** → ilgili deploy logunda görünür; **Logs** sekmesi yalnızca çalışan konteyneri gösterir.
- Dokploy, Gitea'daki son commit'i derler → değişiklikler önce push edilmeli.
- Geri dönüş (rollback): Deployments listesinden önceki başarılı deploy seçilerek yeniden yayınlanır.
- DNS: `kakeibo.ta1sph.website` → ev dış IP'si; modemde 80/443 → Atlas yönlendirmesi Let's Encrypt için gerekli.
- Sade metin `404 page not found` + "Not secure" = Traefik'in kendi yanıtı (bu host için router yok): domain eklenip **Redeploy** yapılmamış ya da konteyner çalışmıyor demektir.
### İlk yayın kontrolü (2026-11-12) ✅
- `/api/health` → `{"status":"ok","db":"ok"}`, servis `1/1` healthy, migration v1 + indeksler canlı DB'de oluştu.
- TLS: Let's Encrypt (otomatik yenileme), `/kayit` doğrudan açılıyor (SPA).
- İlk hesap açıldı → `GET /api/auth/registration` = `new_household_allowed: false` (yalnızca davet).
## 6. Yedekleme ve geri yükleme
```
Atlas 03:30 backup.sh ──▶ /var/backups/kakeibo/kakeibo-YYYY-MM-DD_HHMM.archive.gz (son 14)
│ rsync (SSH)
Bu bilgisayar 09:00 / açılışta ──▶ ~/Yedekler/kakeibo/ (son 60, 2 günden eskiyse masaüstü uyarısı)
```
| Dosya | Nerede çalışır | İş |
|---|---|---|
| `scripts/backup.sh` | Atlas → `/usr/local/lib/kakeibo/` | `mongodump --archive --gzip` → gzip + `mongorestore --dryRun` denetimi → son 14'ü tutar |
| `scripts/restore.sh` | Atlas (ve yerelde `npm run db:restore`) | yedeği **başka adla** geri yükler, kayıt sayılarını gösterir/karşılaştırır |
| `scripts/backup-pull.sh` | bu bilgisayar | yeni yedekleri çeker, eskiyse uyarır |
| `deploy/systemd/*` | ikisi | zamanlayıcılar (`Persistent=true`: kapalıyken kaçan çalışma açılınca yapılır) |
Yerel deneme: `npm run db:backup` (kakeibo_dev → `./backups`, git'e girmez) ·
`COMPARE_DB=kakeibo_dev npm run db:restore -- latest kakeibo_restore_test`.
### 6.1 Atlas'ta kurulum (bir kez)
Bu bilgisayarda, proje klasöründe:
```bash
scp scripts/backup.sh scripts/restore.sh deploy/systemd/kakeibo-backup.service deploy/systemd/kakeibo-backup.timer atlas:/tmp/
ssh atlas
```
Atlas'ta:
```bash
sudo install -Dm755 /tmp/backup.sh /usr/local/lib/kakeibo/backup.sh
sudo install -Dm755 /tmp/restore.sh /usr/local/lib/kakeibo/restore.sh
sudo install -m644 /tmp/kakeibo-backup.service /tmp/kakeibo-backup.timer /etc/systemd/system/
```
- SSH'ye **root olmayan** bir kullanıcıyla bağlanıyorsanız: `sudo systemctl edit --full kakeibo-backup.service` →
`#Environment=BACKUP_GROUP=…` satırının başındaki `#`'i kaldırıp kendi kullanıcı grubunuzu yazın (yoksa yedekleri çekemezsiniz).
İlk yedek ve kontrol:
```bash
sudo systemctl daemon-reload
sudo systemctl start kakeibo-backup.service
journalctl -u kakeibo-backup -n 20 --no-pager # "tamam: …" görülmeli
```
### 6.2 Geri yükleme denemesi (kurulumda bir kez, sonra ayda bir önerilir)
Canlı veriye dokunmaz; ayrı `kakeibo_restore_test` veritabanına yükler ve sayıları karşılaştırır:
```bash
sudo COMPARE_DB=kakeibo /usr/local/lib/kakeibo/restore.sh latest kakeibo_restore_test
# "✅ kakeibo ile aynı" görülmeli. Sonra deneme veritabanını silin:
sudo docker exec $(sudo docker ps -q -f name=kakeibo-kakeibomongo-fibzhc) mongosh --quiet kakeibo_restore_test --eval 'db.dropDatabase()'
```
Sonra zamanlayıcıyı açın:
```bash
sudo systemctl enable --now kakeibo-backup.timer
systemctl list-timers kakeibo-backup.timer # bir sonraki çalışma saati
```
### 6.3 Bu bilgisayarda (sunucu dışı kopya)
Önce şifresiz SSH çalışmalı: `ssh -o BatchMode=yes atlas ls /var/backups/kakeibo` yedek dosyasını listelemeli.
```bash
scripts/backup-pull.sh # elle bir kez: ~/Yedekler/kakeibo/
mkdir -p ~/.config/systemd/user
cp deploy/systemd/kakeibo-backup-pull.service deploy/systemd/kakeibo-backup-pull.timer ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now kakeibo-backup-pull.timer
```
- SSH'de sunucunun adı `atlas` değilse: `systemctl --user edit --full kakeibo-backup-pull.service` → `KAKEIBO_BACKUP_HOST` satırı.
- Günlük: `journalctl --user -u kakeibo-backup-pull -n 20`.
### 6.4 Felaket sonrası geri yükleme (canlı veritabanının yerine)
1. Dokploy → `kakeibo-app` → **Stop** (yükleme sırasında yazma olmasın).
2. Gerekirse yedeği sunucuya kopyalayın: `scp ~/Yedekler/kakeibo/<dosya> atlas:/tmp/`.
3. Atlas'ta: `sudo /usr/local/lib/kakeibo/restore.sh /tmp/<dosya> kakeibo --force` (önce mevcut `kakeibo` silinir).
4. `kakeibo-app` → **Start**, `/api/health` ve giriş kontrolü.
- MongoDB servisi tamamen yeniden kurulduysa önce §4'teki kullanıcıları (`kakeibo_app`) yeniden oluşturun.
- Yedekte kullanıcı şifre özetleri (argon2) ve oturum bilgisi bulunur → yedek dosyalarını paylaşmayın.
### Notlar
- MongoDB'de kimlik doğrulaması zorunlu olmadığından (§4) `mongodump` konteynerin içinden şifresiz çalışır.
Auth açıldığında yedek için ayrı bir `backup` rolü kullanıcısı ve betiğe `--config` ile şifre dosyası eklenmeli.
- Yedek boyutu şu an birkaç KB–MB; 14 + 60 dosya önemsiz yer tutar.
+106 -3
View File
@@ -1,9 +1,18 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ Sıradaki Adım
**Faz 1 – Adım 3:** Kimlik doğrulama: `POST /api/auth/register` (kullanıcı + hane + örnek kategori/ödeme türü, transaction), `login`, `logout`, `GET /api/auth/me`; argon2, JWT httpOnly cookie, giriş rate limit. **Faz 9 – Adım 3:** Uygulamanın kendi CSV'si (harcamalar, plan kalemleri, varlık işlemleri): dışa aktarma (backend ya da frontend; `lib/csv.ts` biçimi: BOM, `;`, `1250,50`, GG.AA.YYYY) + içe aktarma (önizleme → satır satır hata → geçerliler aktarılır; kategori/ödeme türü **adla** eşleşir; dönem tarihten bulunur, yazılamaz dönem → satır hatası). Adım 4 (ekstre) bunun üstüne kurulacak: ortak satır doğrulama/aktarma servisi tasarla.
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
## 📌 Alınan Kararlar ## 📌 Alınan Kararlar
- **Faz 9 kararları (kullanıcı, 2026-11-13):** (1) Aylık rapor biçimini **Claude tasarlar**: müşteri gözüyle ne yaptım / ne yapılabilirdi / ne yapılmalıydı. (2) JSON yedek: Claude best practice analizi yapıp kullanıcıyı üzmeyen çözüm üretir (öneri: sunucuda otomatik geri dönüş noktası + "geri al", önizleme, sahip). (3) **Tüm kredi kartı ve nakit/banka hesabı ekstreleri** içe aktarılabilsin; amaç **yıl başından beri** veriyi toplayıp sonraki aylara referans; grup/kategori eşleştirmesi kullanıcıya bırakılabilir → ayrı adım (Faz 9 Adım 4), geçmiş dönemler gerekir.
- **Yeniden kapatmada birikim (kullanıcı "a", 2026-11-13):** bağlı TL alımı yeni tutara güncellenir (0 → silinir).
- **Faz 8 kararları (kullanıcı, 2026-11-13):** kur kaynakları TCMB (döviz) + xaus.com ons×TCMB USD (altın; çeyrek/yarım/tam has gram ile) + manuel; değerleme **alış kuru**; kapanış birikimi onayla TL varlığına alım (tekrar eklenmez), geçmiş kapanışlar "bekleyen birikimler".
- **Kategori yönetimi ekranı (kullanıcı, 2026-11-13): Faz 10'da** (seçenek b). Faz 8 önce.
- **Kur kaynağı araştırması (2026-11-13):** TCMB `today.xml` döviz var, **altın yok** (USD ForexBuying/Selling, 22 kod). xaus.com `/api/v1/spot` anahtarsız çalışıyor (`spot_usd_oz`, `per_gram_usd`), resmi değil → yedek: son kur + manuel. Truncgil doğrulanamadı.
- **Kategoriler (kullanıcı, 2026-11-12):** 3. kat alt kategori **yok** (grup → kategori). Şartı: raporlarda **tüm** kategorilerin dağılımı görünmeli (yalnızca ilk 5 değil). Amaç "market ne için?" gibi kırılım → kategoriyi bölerek (ör. Market · Gıda / Temizlik); bunun için Ayarlar'da kategori yönetimi ekranı gerekli.
- **Canlı sürüm eski (2026-11-12 kontrol):** push'lar Gitea'da ama Dokploy yeniden derlemiyor (Faz 4+ yok, `/api/stats/periods` 404). Auto Deploy için Gitea webhook'u gerekiyor → kullanıcıya adım adım anlatıldı.
- **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`).
- Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`. - Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`.
- Her adım sonunda Claude commit atar ve haber verir; push'u kullanıcı kendi uygulamasıyla yapar (Claude `git push` çalıştırmaz). - Her adım sonunda Claude commit atar ve haber verir; push'u kullanıcı kendi uygulamasıyla yapar (Claude `git push` çalıştırmaz).
- Node v26 (LTS) sistemde mevcut; ayrı kurulum gerekmedi. - Node v26 (LTS) sistemde mevcut; ayrı kurulum gerekmedi.
@@ -12,10 +21,44 @@
- Veri modeli v1 onaylandı (2026-11-11): ölçekli tam sayı (`_e6`), tarih `YYYY-MM-DD` metin, dönem bitişi hariç, kullanıcı = tek hane, ayarlar haneye gömülü, haftalık bütçe hesaplanır. - Veri modeli v1 onaylandı (2026-11-11): ölçekli tam sayı (`_e6`), tarih `YYYY-MM-DD` metin, dönem bitişi hariç, kullanıcı = tek hane, ayarlar haneye gömülü, haftalık bütçe hesaplanır.
- Model yaklaşımı: `#[serde(flatten)]` yerine ortak alanlar `models/common.rs` yardımcılarıyla (`new_meta`, `scope`, `touch`). Modeller ilgili adımda eklenir. - Model yaklaşımı: `#[serde(flatten)]` yerine ortak alanlar `models/common.rs` yardımcılarıyla (`new_meta`, `scope`, `touch`). Modeller ilgili adımda eklenir.
- `households.household_id = _id` (scope filtresi her yerde aynı). - `households.household_id = _id` (scope filtresi her yerde aynı).
- Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7. - Auth: JWT (HS256, jsonwebtoken `rust_crypto`) `kakeibo_session` cookie'de; HttpOnly, SameSite=Lax, Secure yalnızca APP_URL https ise. Beni hatırla → 30 gün Max-Age; değilse 12 saat JWT + oturum cookie'si. Kayıttan sonra 30 gün.
- Giriş sınırı: e-posta başına 15 dk'da 5 hatalı deneme (bellek içi, `auth/limiter.rs`). Olmayan kullanıcıda da sahte argon2 doğrulaması (zamanlama sızıntısı yok).
- Hata kodları: `{error:{code,message}}`; `invalid_credentials`, `unauthorized`, `bad_request`, `conflict`, `too_many_requests`.
- Hane izolasyonu kuralı: hane kimliği **her zaman** `AuthUser.household_id`'den alınır, asla URL/gövdeden. Başka hanenin kaydı → 404 (403 değil; varlığı sızdırılmaz).
- `AuthUser` her istekte DB'den kullanıcıyı doğrular (silinen kullanıcı → 401, rol DB'den). Owner-only işlemler: `auth.require_owner()?`.
- Davet: 8 karakter (31 harflik okunaklı alfabe), 7 gün, tek kullanım; `find_one_and_update` ile atomik claim, kayıt transaction'ı içinde.
- Kategoriler/ödeme türleri: tüm üyeler yönetir; silme yok (pasife alma); grup sonradan değişmez; ad grup içinde benzersiz (büyük/küçük harf duyarsız, pasifler dahil); PATCH'te `version` → iyimser kilit (409); sıralama tam liste ister.
- Tasarım: ana renk indigo (`oklch(0.42 0.13 268)`), grup renkleri Okabe-Ito (needs #0072b2, wants #cc79a7, culture #009e73, unexpected #d55e00), *-fg tonları AA (açık ≥5.0, koyu ≥9) hesaplanarak doğrulandı. Gelir #15803d, gider #c62828, uyarı #b45309.
- Tema seçimi localStorage (`kakeibo-theme`), yalnızca cihaz kolaylığı; ileride `users.preferences.theme` ile senkron olabilir.
- `parseMoney`: virgül varsa ondalık; virgül yoksa son noktadan sonra 1–2 rakam → ondalık ("1.25" = 1,25 ₺; "1.250" = 1250 ₺).
- Logo: Taslak A (indigo zemin, spiral krem defter, ¥) seçildi. Kaynak `frontend/brand/logo.svg` + `logo-fullbleed.svg` (maskable/Apple, içerik %82). Uygulama içi `components/logo.tsx` aynı çizim (elle senkron tutulmalı). Manifest Faz 10'da (vite-plugin-pwa).
- ts-rs 12: `backend/.cargo/config.toml` → `TS_RS_EXPORT_DIR=../frontend/src/types`. TS adları: User, HouseholdSummary, Household, Member, Invite, CatalogItem, Catalog{Create,Update,Order}Request, RegisterRequest, LoginRequest, MeResponse, HealthResponse, ApiErrorBody, Group, Role.
- QueryClient: 4xx tekrar denenmez; herhangi bir sorgu 401 alırsa `me` = null. Çıkışta `qc.clear()`.
- Adresler Türkçe: / (Panel), /defter, /plan, /birikim, /diger (mobil), /degerlendirme, /raporlar, /ayarlar, /giris, /kayit. Mobil alt menü: Panel, Defter, Plan, Birikim, Diğer.
- Çıkış: `qc.clear()` KULLANMA (açık ekranlar silinen sorguya bağlı kalıyor) → önce `me=null`, sonra `removeQueries` (me hariç).
- Canlıda SPA yönlendirmesi (Axum: bilinmeyen yol → index.html) Faz 3'te yapılmalı.
- Kayıt ekranı: "Yeni hane kur" / "Davetle katıl"; davet bağlantısı `/kayit?davet=KOD`. Sunucu 409 → e-posta alanı, "Davet…" 400 → kod alanı.
- Yönlendirme sonrası toast için `mutateAsync` + await kullanıldı (mutate() callback'leri bileşen kalkınca çalışmayabilir).
- **Kayıt politikası (kullanıcı kararı, 2026-11-12): Seçenek 1** → `REGISTRATION=invite_only` varsayılan; sistemde hiç hane yokken ilk kayıt yeni hane kurar (sunucu sahibi), sonra yalnızca davetle. `open` isteğe bağlı. Canlıda ilk hesabı kullanıcı `/kayit`'tan açar; demo hesaplar canlıya taşınmaz.
- Prettier ayarı eklendi (önceden yoktu → VS Code kaydedince çift tırnağa çevirebilirdi).
- Canlı imaj: node:26-alpine → rust:1.99-slim-trixie → debian:trixie-slim (ca-certificates + curl, uid 10001 `kakeibo`), 174 MB. Docker katman önbelleği için sahte lib/main ile bağımlılık derlemesi; kalıntılar `*kakeibo*` + `.fingerprint` silinir, gerçek kaynak `touch`lanır (yoksa sahte lib kullanılıyordu).
- Önbellek: HTML `no-cache`, `/assets/*` 1 yıl immutable, diğer statik 1 saat. Bilinmeyen `/api/*` → JSON 404.
- Canlı: domain `kakeibo.ta1sph.website` (DNS → 159.146.61.151, ev dış IP'si olmalı), Gitea Dokploy'a bağlı. Canlı MongoDB = ayrı Dokploy servisi `kakeibo-kakeibomongo-fibzhc` (rs0), kullanıcı `kakeibo_app` (readWrite/kakeibo).
- Dönem: bitiş hariç, en fazla 62 gün, hane başına tek açık, çakışma yok (metin karşılaştırması), kapalı salt okunur, daraltmada dışarıda harcama kalırsa 409. Ad boşsa otomatik (`calendar::default_period_name`). Tüm üyeler açabilir/düzenleyebilir.
- ts-rs: `i64` tutarlar `#[ts(type = "number")]`; isteklerde `Option<i64>` için `#[ts(type = "number", optional)]` (yoksa `?` kayboluyor).
- Plan kalemleri: tarih isteğe bağlı (dönem içinde), tutar > 0, kopyalama göreli gün + dönem sonuna kırpma, aynı tür+ad (büyük/küçük harf duyarsız) atlanır. Notlar: başlık 1–120, metin ≤ 20.000. Kapalı dönemde hepsi 409 (`period::get_open`). Silme = soft delete.
- İzolasyon iki katmanlı: kayıt okuma `scope()` + dönem `get_open(hid, period_id)` + yazma filtresi `scope()`. Tek katmanı bozmak açık oluşturmuyor (mutasyon denemesiyle görüldü).
- Bütçe: aylık = `budgets{scope:"month"}`; haftalık otomatik = (aylık − elle girilenler) gün oranında, `money::split_proportional` (en büyük kalan, toplam tam eşit). Elle hafta = `scope:"week", week_start`; `null` → soft delete. Aylık değişince elle haftalar korunur; elle toplam > aylık → `weekly_over`.
- Özet: spendable = gelir − sabit gider − birikim − borç + artı (`carryovers.to_period_id`). `unallocated` = spendable − bütçe toplamı (negatif = fazla dağıtım).
- Öneri: default = hane `settings.budget_split_pct` × spendable (≤0 → available:false); previous/avg3 = önceki kapalı dönem(ler)in `entries` toplamı / dönem sayısı (yuvarlayarak).
- Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7, Tailwind 4.3, shadcn 4.23, Vitest 5, react-router 8, TanStack Query 5.
## ❓ Açık Konular ## ❓ Açık Konular
- Kapalı dönem yeniden açılırken başka açık dönem varsa davranış (Faz 6). - Geliştirme makinesi: CachyOS nodejs 26.13.0-2 ↔ simdjson .so.36 uyumsuzluğu → simdjson 2:5.0.5-1'e düşürüldü (2026-11-12). Düzeltilmiş nodejs gelene kadar `pacman -Syu --ignore simdjson`. `node: libsimdjson.so.34` hatası görülürse sebep budur.
- **Canlı yedekleme kurulumu bekliyor** (betikler hazır, `docs/DEPLOY.md` §6) → gerçek veri girişinden önce.
- **Canlı MongoDB auth kapalı** (Dokploy replica set betiği keyFile kullanmıyor, özel Command'ı yok sayıyor). İç ağda, port kapalı. Ayrıntı: `docs/DEPLOY.md` §4.
- Üye çıkarma / rol değiştirme / sahipliği devretme henüz yok (Ayarlar ekranıyla birlikte, Faz 2+).
- ~~Kapalı dönem yeniden açma~~ → çözüldü: düzeltme modu (`correcting`). Arayüzde düzeltmedeki dönem için Defter/Plan dönem seçimi Faz 6 Adım 5'te.
- Altın kuru için veri kaynağı seçimi (Faz 8). - Altın kuru için veri kaynağı seçimi (Faz 8).
- Aylık değerlendirme çıktı formatı kullanıcıdan gelecek (Faz 9). - Aylık değerlendirme çıktı formatı kullanıcıdan gelecek (Faz 9).
- Canlı MongoDB kurulum şekli (konteyner mi, doğrudan mı) Faz 3'te birlikte kontrol edilecek. - Canlı MongoDB kurulum şekli (konteyner mi, doğrudan mı) Faz 3'te birlikte kontrol edilecek.
@@ -32,3 +75,63 @@
### 2026-11-11 — Faz 1 ### 2026-11-11 — Faz 1
- **Adım 1:** `config.rs` (env + doğrulama, JWT_SECRET ≥ 32), `db.rs` (5 sn timeout + ping), `error.rs` (`AppError` → `{error:{code,message}}` Türkçe; 5xx detayı yalnızca logda), `state.rs`, `routes/` (health: DB yoksa 503). Açılışta DB/ayar hatasında anlaşılır log + çıkış kodu 1. Elle test: 4 senaryo geçti. - **Adım 1:** `config.rs` (env + doğrulama, JWT_SECRET ≥ 32), `db.rs` (5 sn timeout + ping), `error.rs` (`AppError` → `{error:{code,message}}` Türkçe; 5xx detayı yalnızca logda), `state.rs`, `routes/` (health: DB yoksa 503). Açılışta DB/ayar hatasında anlaşılır log + çıkış kodu 1. Elle test: 4 senaryo geçti.
- **Adım 2:** `db/` modülü: `col` sabitleri, `migrations.rs` (`schema_migrations`, v1 = koleksiyonları oluştur), `indexes.rs` (DATA_MODEL §4 + tüm hane koleksiyonlarına `(household_id, updated_at)`). `models/common.rs`: UUID v7, `new_meta`, `scope`, `touch`. Açılışta `db::prepare`. 4 birim testi; idempotency ve tek açık dönem indeksi elle doğrulandı. `uuid` (v7) eklendi. - **Adım 2:** `db/` modülü: `col` sabitleri, `migrations.rs` (`schema_migrations`, v1 = koleksiyonları oluştur), `indexes.rs` (DATA_MODEL §4 + tüm hane koleksiyonlarına `(household_id, updated_at)`). `models/common.rs`: UUID v7, `new_meta`, `scope`, `touch`. Açılışta `db::prepare`. 4 birim testi; idempotency ve tek açık dönem indeksi elle doğrulandı. `uuid` (v7) eklendi.
- **Adım 3:** Kimlik doğrulama: `auth/` (password argon2id + spawn_blocking, jwt, limiter, session cookie + `AuthUser` extractor), `extract.rs` (`ValidJson` = JSON + validator, Türkçe 400), `services/account.rs` (kayıt tek transaction: hane + owner + 13 kategori + 4 ödeme türü), `services/seed.rs`, `routes/auth.rs` (register/login/logout/me). Yeni crate'ler: argon2, jsonwebtoken, axum-extra(cookie), validator, time. 11 birim testi; 13 senaryo curl ile, rollback DB'de doğrulandı.
- **Adım 4:** `AuthUser` DB doğrulaması + `require_owner`, `services/invite.rs` (create/list_active/revoke/claim), kayıt davet koduyla `member` olarak katılma (`services/account.rs` yeniden düzenlendi), `routes/household.rs` (GET /household, invites GET/POST/DELETE). 13 birim testi; 15 senaryo e2e (izolasyon 404, member 403, kod tekrar kullanımı, rollback) geçti.
- **Adım 5:** `models/group.rs`, `services/catalog.rs` (kategori + ödeme türü ortak: list/create/update/reorder), `routes/catalog.rs`. Backend lib+bin'e ayrıldı (`AppState::new`). Entegrasyon testleri: `tests/isolation.rs` (3), `tests/catalog.rs` (4) — toplam 22 test. İzolasyon testi, kasıtlı açık eklenerek doğrulandı (yakaladı). `db:reset` artık test DB'lerini de siler. Dev-deps: tower, http-body-util. **Faz 1 tamamlandı.**
### 2026-11-12 — Faz 2
- **Adım 1:** Tailwind v4 (@tailwindcss/vite), shadcn init (radix, preset nova; `cn` paketi = shadcn'in clsx+tailwind-merge yerine geçeni, incelendi), Inter (@fontsource-variable, çevrimdışı). `index.css`: grup + durum token'ları, odak halkası, `money` utility, reduced-motion. `lib/format.ts` (+22 vitest), `lib/theme.tsx`, `lib/groups.ts`, `components/theme-toggle.tsx`. Butonlar 44/48 px'e büyütüldü. Geçici önizleme ekranı (App.tsx), 375 px headless ekran görüntüsüyle iki temada kontrol edildi.
- **Adım 2:** 3 logo taslağı (A indigo defter, B açık defter + para, C hanko) 512/64/32/16 px önizlemeyle sunuldu; kullanıcı A'yı seçti. `scripts/icons.sh` (`npm run icons`): favicon.svg/.ico, icon-192/512, icon-maskable-512, apple-touch-icon (180), brand/icon-1024 (Tauri). Maskable güvenli alan görsel olarak doğrulandı. `index.html`: ikon linkleri, theme-color, description.
- **Adım 3:** ts-rs eklendi (17 tip), health ve hata gövdesi struct'a çevrildi (`ApiErrorBody`). Frontend: @tanstack/react-query, `lib/api.ts` (+7 vitest), `lib/query.ts`, `lib/auth.ts`, main.tsx'te QueryClientProvider. Geçici oturum paneli. Tarayıcı e2e (CDP betiği): yanlış şifre mesajı, giriş, yenilemede kalıcılık, httpOnly (document.cookie'de yok), çıkış — hepsi geçti.
- **Adım 4:** react-router 8 (createBrowserRouter), `RequireAuth`/`RequireGuest`/`Splash`, `AppLayout` (masaüstü kenar çubuğu + mobil üst çubuk + 5'li alt menü, safe-area), Diğer sayfası, 7 yer tutucu sayfa, 404, `lib/strings.ts`, `useDocumentTitle`, `lib/return-path.ts` (+10 test, açık yönlendirme koruması), ThemeToggle `compact`. Tasarım önizlemesi → `/dev/tasarim` (yalnızca DEV). CDP e2e (10 kontrol) iki hata buldu, düzeltildi: giriş sonrası `donus` yarışı, çıkışta `qc.clear()`. Masaüstü içerik boşluğu düzeltildi.
- **Adım 5:** react-hook-form, zod 4, @hookform/resolvers, sonner; shadcn input/label/checkbox/card (44 px / 20 px'e büyütüldü). `components/form-field.tsx`, `components/toaster.tsx`, `features/auth/` (auth-shell, login-page, register-page, schemas + 8 vitest). Metinler `t.auth`. CDP e2e 11 kontrol geçti (boş form, yanlış şifre, giriş + toast, davet linki, kayıtlı e-posta → alan hatası, geçersiz davet → alan hatası, yeni hane). Frontend 47 test. **Faz 2 tamamlandı.**
### 2026-11-12 — Faz 3 öncesi
- Kullanıcı `api.http`'deki kayıt örneğini (yeni@ornek.com) hesap sandı → dosya başlığına hazır hesaplar kutusu ve "bu bir KAYIT isteğidir" uyarısı eklendi.
- Kayıt politikası: `config.rs` `RegistrationMode`, `AppError::RegistrationClosed` (403), `account::new_household_allowed`, `GET /api/auth/registration` (`RegistrationInfo` TS tipi). `tests/registration.rs` (2). Kayıt ekranı kapalıyken yalnızca davet modu + açıklama. `.env`/`.env.example`: `REGISTRATION=invite_only`. Prettier (`.prettierrc.json`, `.prettierignore`, `format` / `format:check`), tüm src biçimlendirildi (yalnızca boşluk).
### 2026-11-12 — Faz 3
- **Adım 1:** tower-http (fs, compression gzip/br, trace). `routes::app()` + `routes/spa.rs` (ServeDir + index.html fallback + cache middleware), API fallback JSON 404, `STATIC_DIR` config, graceful shutdown (SIGTERM/Ctrl+C). `Dockerfile`, `.dockerignore`, `docs/DEPLOY.md` başlatıldı. İmaj yerelde çalıştırıldı (host network, 8090): 10 kontrol + tarayıcı ekran görüntüsü geçti. İlk derlemede Docker önbellek hilesi hatası (sahte lib.rs) bulundu, düzeltildi.
- **Adım 2:** Mevcut tek MongoDB (butcem-mongo) Bütçem'e ait → kullanıcı ayrı MongoDB seçti. Dokploy MongoDB (replica set) kuruldu, `kakeibo_app` oluşturuldu, port yayını yok (iki servis de `null`). Sorunlar: kullanıcı şifreyi komuta düz yazıp paylaştı; Dokploy betiği root şifresini servis Args'ına düz yazıyor → iki şifre de mongosh içinde üretilerek yenilendi. passwordPrompt yapıştırma hatası (U_STRINGPREP). Auth açma denemesi: Dokploy özel Command'ı uygulamadı → auth kapalı kabul edildi (açık konu).
- **Adım 3–4:** Dokploy Application `kakeibo-app` (iç ad `kakeibo-kakeiboapp-nd5kek`). Sorunlar: push yapılmamıştı (Dockerfile yok); Provider GitHub sekmesinde kalmıştı (`Github Provider not found`, kullanıcı çözdü); Traefik 404 (kullanıcı çözdü, büyük olasılıkla domain sonrası redeploy). **Yayında:** https://kakeibo.ta1sph.website — health ok, Let's Encrypt, ilk hesap açıldı. **Faz 3 tamamlandı (yedekleme hariç).**
### 2026-11-12 — Faz 4
- **Adım 1:** `services/calendar.rs` (parse/format, `weeks` Pazartesi başlangıçlı + kısmi haftalar, Türkçe ay adları; 4 test), `models/period.rs`, `services/period.rs` (2 test), `routes/period.rs` (list/current/create/get/patch; DTO'da `last_date`, `days`, `weeks`). `tests/periods.rs` (5) — çakışma kontrolü bilerek bozulunca test yakaladı. Backend toplam 58 test.
- **Adım 2:** `models/plan_item.rs`, `models/note.rs`, `services/plan.rs` (list/create/update/delete/copy_previous), `services/note.rs`, `routes/plan.rs`, `routes/note.rs`, `period::get_open` + `closed()`. `tests/plan_notes.rs` (5): CRUD + doğrulama, kopyalama (göreli gün, 28 Şubat'a kırpma, tekrar yok), kapalı dönem salt okunur, not filtresi, izolasyon. Backend 72 test.
- **Adım 3:** `services/money.rs` (`split_proportional` + 3 test; beklenen değer hatası testteydi, elle hesapla düzeltildi), `services/budget.rs` (summary, view, set_monthly, set_week, suggest), `routes/budget.rs` (5 uç nokta, 9 TS tipi). `tests/budget.rs` (6) — özet formülü bilerek bozulunca yakaladı. Backend 88 test.
- **Adım 4:** İlk dönem sihirbazı `/plan/donem-ac?adim=1..5` (`features/period-wizard/`): Dönem → Gelirler → Giderler → Birikim → Bütçe. Dönem 1. adımda açılır, her adım hemen kaydedilir; adım adreste (yenileme/geri tuşu çalışır). `lib/periods.ts` (Query kancaları; değişince `queryKeys.period(id)` öneki yenilenir), `lib/period-dates.ts` (son gün dahil ↔ backend bitiş hariç), `lib/money-schema.ts` (`moneyText`, `toKurus`), `components/money-input.tsx` (inputMode=decimal, blur'da `1.250,50`), `PageLoading`/`PageError`, `SummaryTiles`, `NoPeriodCard` (Panel + Plan). Frontend 60 test; CDP e2e 43 kontrol geçti, test verisi demo hanesinden silindi.
- Not: üretim JS paketi ~620 kB (gzip 195 kB) → Faz 10'da sayfa bazlı bölme (lazy route).
- **Adım 5:** Plan ekranı (`features/planning/`): `plan-read.tsx` (salt okunur: kalemler, 4 grup + haftalık döküm, "elle" etiketi), `plan-edit.tsx` (taslak formu, Kaydet/Vazgeç, `useBlocker` + beforeunload ile kaydedilmemiş değişiklik onayı). `lib/plan-draft.ts` (`toDraft`/`diffPlan`: yalnızca değişenler gönderilir), `lib/budget-preview.ts` (backend `split_proportional` + `view` ile birebir; aynı test örnekleri), `useSavePlan` (sıralı istekler; hata olursa yeniden yükler, transaction değil). Ortak: `DistributionBar`, `SuggestionPicker` (sihirbaz da kullanıyor). Frontend 69 test.
- E2E artık ayrı sunucuda: `REGISTRATION=open PORT=8091 STATIC_DIR=frontend/dist MONGODB_URI=…/kakeibo_test_e2e` (kullanıcının dev verisine dokunulmaz; bitince DB silinir). 31 kontrol geçti; ekran görüntüsüyle 3 düzen sorunu bulundu/düzeltildi (dar satırlar, mobil tutar/tarih, hafta etiketi).
- **Adım 6:** Notlar: `features/notes/` (`NotesSection` — Plan'da `context="planning"`, Değerlendirme'de `review` ile yeniden kullanılacak; `NoteEditor` Yaz/Önizle sekmeleri; zod şeması karakteri backend gibi sayar), `lib/notes.ts` (+ `useHousehold` yazar adı için), `components/markdown.tsx` (ham HTML yok, bağlantılar yeni sekme), `components/ui/textarea.tsx`, `index.css` `.markdown` stilleri. Yeni bağımlılık: react-markdown 10, remark-gfm 4. E2E 16 kontrol (XSS, 409 çakışma dahil). `DistributionBar` 0/0 durumunda "tamamı dağıtıldı" demiyor. **Faz 4 tamamlandı.**
- Not: JS paketi 820 kB (gzip 254 kB; markdown ~200 kB) → Faz 10'da route bazlı lazy + Markdown'u ayrı parçaya al. `npm audit` uyarıları shadcn CLI bağımlılıklarından (deepmerge, minimatch), uygulamaya girmiyor; `--force` shadcn'i 1.0'a düşürür, yapılmadı.
### 2026-11-12 — Faz 5
- **Adım 1:** Harcamalar backend: `models/entry.rs`, `services/entry.rs` (list + `from`/`to` filtresi, sıra `date` ↓ `_id` ↓; create/update/delete soft/`restore`), `routes/entry.rs` (5 uç nokta), `catalog::find`. Grup **kategoriden türetilir** (istemci göndermez). Pasif kategori/ödeme türü yeni seçimde reddedilir, kaydın mevcut değeri olarak kalabilir. Ödeme türü isteğe bağlı; düzenlemede `""` → kaldır (projedeki "boş metin = kaldır" kuralı; ts-rs `deserialize_with` desteklemediği için çift-Option yerine). `tests/entries.rs` (6) — `previous` önerisi artık gerçek kayıtla test edildi. Mutasyon: grup türetme bozulunca yakalandı; restore'da hane filtresi tek başına kaldırılınca `get_open` ikinci katman olarak korudu, ikisi birden kaldırılınca 2 test yakaladı. Backend 97 test.
- **Adım 2:** Gerçekleşen harcama (saklanmaz, her istekte aggregation): `GET /summary` → `spent_total`, `remaining` (= harcanabilir − harcanan, negatifse aşılmış). `GET /budget` → grupta `spent` (dönem), haftada `spent`. `spent_by_group_and_date` (grup×gün toplanır, Rust'ta haftalara dağıtılır; tarih metin karşılaştırması). Silinen kayıt sayılmaz, kategori değişince harcama yeni gruba geçer. Test `actual_spending_in_summary_and_budget` (hafta sınırları 6/7 Aralık, dönem son günü, önceki dönem hariç, geri al). Mutasyon: hafta sınırı `<=`→`<` ve silinenleri sayma → ikisi de yakalandı. Backend 98 test.
- **Adım 3:** Defter ekranı (`features/ledger/`): `WeekPicker` (◀ ▶, "Bu hafta", haftanın harcanan/bütçe toplamı), `EntryForm` (büyük `MoneyInput`, 4 renkli grup düğmesi, kategori/ödeme türü çipleri, tarih, açıklama; kayıttan sonra tutar+açıklama temizlenir, odak tutara döner), `schema.ts` (`entrySchema(start,last)`, `fieldForError`: sunucu mesajı → alan). Hafta adreste: `/defter?hafta=N` (bugünün haftasında parametre yok); başka haftaya tarihli kayıt → o haftaya geçer; hafta değişince tarih o haftaya çekilir. `lib/ledger.ts` (`weekIndexFor`, `defaultEntryDate`, `parseWeekParam`, son seçim localStorage `kakeibo.ledger.last`: grup + **her grubun son kategorisi** + ödeme türü; pasifleşen seçim düşer). `lib/catalog.ts` (`useCategories`, `usePaymentMethods`, 5 dk staleTime), `lib/entries.ts` (`useEntries`, `useCreateEntry`), `queryKeys.entries`. Frontend 79 test; e2e 31 kontrol (ayrı 8091 sunucu, DB silindi). Mobilde "Beklenmeyen" taşması düzeltildi.
- **Adım 4:** Defter listesi ve özet: `entry-list.tsx` (gün gün `groupByDay`, gün adı `formatWeekday` = Intl tr-TR UTC; satır: kategori emojisi grup renginde, grup · ödeme · açıklama; düzenle/sil 44 px), sil → sonner `action` "↩️ Geri al" (8 sn) → `useRestoreEntry`. Düzenleme aynı `EntryForm` (`entry` + `key`, Güncelle/Vazgeç; `version` gönderilir, 409 Türkçe root hatası; `''` → ödeme/açıklama kaldır). Katalog **pasifler dahil** okunur (eski kayıtların adı; seçenekte yalnız aktifler + kaydın mevcut değeri). `week-summary.tsx` grup bazlı bütçe/harcanan/kalan + çubuk, aşımda "Aşıldı" kırmızı. Masaüstü iki sütun (form solda yapışkan); dar sütunda taşmaya karşı **container query** (`@container`, `@lg:grid-cols-4`). Frontend 81 test; e2e 29 kontrol (+ Adım 3'ün 31'i yeniden geçti). **Not:** e2e betikleri aynı DB'yi paylaşırsa birbirini bozar → her biri temiz DB ile.
- **Adım 5:** Yedekleme betikleri: `scripts/backup.sh` (Swarm etiketiyle konteyneri bulur, `mongodump --archive --gzip` → `.partial` → `gzip -t` + `mongorestore --dryRun` → ad verilir; son `KEEP=14`, `BACKUP_GROUP` ile okuma izni), `scripts/restore.sh` (dosya adından kaynak DB, `--nsFrom/--nsTo` ile **başka adla**; mevcut hedefe yalnızca `--force` + `--drop`; `COMPARE_DB` ile sayım karşılaştırma), `scripts/backup-pull.sh` (rsync SSH BatchMode, `~/Yedekler/kakeibo`, son 60, 48 saatten eski → `notify-send`). `deploy/systemd/` (sunucu 03:30, bu bilgisayar 09:00 + açılış, `Persistent=true`). Kullanıcı kararı: sunucu dışı kopya **bu bilgisayara** (S3 değil). `npm run db:backup` / `db:restore` (yerel, `backups/` gitignore). Yerel test: yedek → geri yükleme sayıları kakeibo_dev ile aynı, koruma, bozuk dosya hatası, saklama ✅.
### 2026-11-12 — Faz 6
- **Adım 1:** Değerlendirme backend: `services/review.rs` (`budget::summary` + `budget::view` + kategori aggregation), `routes/review.rs` → `GET /periods/{id}/review` (`PeriodReview`: `available` = gelir − sabit − borç + artı, `savings_goal`, `spent_total`, `realized_savings` = available − harcanan, `savings_gap`; `groups` planned/spent, `categories` çoktan aza (+count), `weeks` 4 grubun toplamı). Kapalı dönemde okunur. `periods.reflection_md` (PATCH, ≤ 20.000 karakter, `""` → null, yalnızca açık dönem). `tests/review.rs` (3). Mutasyon: 4. soruya hedef düşmek / silinenleri saymak → yakalandı. Not: `TestApp::register` adı e-postaya çevirir → Türkçe karakterli ad kullanma.
- **Adım 2:** Kapatma backend: `services/closing.rs` — `settle(realized, to_savings)` (< 0 → borç, birikim istenemez; ≥ 0 → varsayılan tamamı birikime, kalan artı), `close` (tek transaction: dönem `closed` + `closed_at/by` + `to_savings`, bu dönemden eski devirler soft delete, yeni devir; `to_period_id` = sonraki dönem ya da null), `reopen` (sahip; `closed` → **`correcting`**; ardından kapalı/düzeltmede dönem varsa 409). `PeriodStatus::Correcting` + `is_writable()` → `get_open`/`update` buna bakar; `current()` yalnız `open`. `period::create` artık transaction: önceki dönemin bağsız devrini bağlar. `POST /periods/{id}/close` (`PeriodCloseRequest`/`PeriodCloseResult`/`Carryover`), `POST /periods/{id}/reopen`. Period DTO: `to_savings`, `closed_at`. `tests/closing.rs` (4) + `settle` birim testi. Mutasyon 3/3 yakalandı; hata zorlanınca transaction geri aldı (dönem açık, sürüm aynı). Backend 113 test. Karar: yeniden açma = düzeltme modu (kullanıcı "a").
- **Adım 3:** Değerlendirme sayfası (`features/review/`): `Questions` (4 kart; 1. kart hesap açıklaması, 3. bütçe yüzdesi, 4. hedefe göre durum `lib/review.ts` → `savingsStatus`: above/below/deficit), `breakdowns.tsx` (`GroupComparison`, `TopCategories` ilk 5 + "Tümünü göster", `WeeklyDistribution`; ortak `Bar`, `ratio`), `Reflection` (görüntüle/düzenle, `useUpdatePeriod` + `version`, 409 formda; şema `features/review/schema.ts`), `NotesSection collapsed` (yalnız başlık, `aria-expanded` ile açılır). Ortak `components/markdown-tabs.tsx` (Yaz/Önizle, `useId` kimlikler; `NoteEditor` da kullanıyor). `useReview` + `queryKeys.review` (dönem önekinde → kayıt değişince yenilenir). Dönem sürüyorsa bilgi satırı. Frontend 84 test; e2e 24 kontrol (Ekim kapatılıp Kasım'a artı devri dahil). Mobil haftalık satır sıkıştırıldı.
- **Adım 4:** Kapatma sihirbazı `/degerlendirme/kapat` (`features/review/close-page.tsx`): Özet (4 soru, erken kapatma uyarısı, "Nasıl daha iyi" boşsa uyarı + bağlantı) → Birikim (artıda `MoneyInput` varsayılan tamamı, Tamamı/Hiçbiri, canlı önizleme; açıkta/sıfırda yalnız bilgi) → Onay (`useClosePeriod`, `version`; 409 formda, `onSettled` tüm `['period']` + liste yenilenir → ikinci deneme güncel sürümle) → Sonuç (sayfa durumunda; kapanınca `current` null). `lib/review.ts` → `settlePreview` (backend `settle` ile aynı tutulmalı), şema `close-schema.ts` (0..gerçekleşen). `Stepper` artık `steps`/`label` alıyor. Dönem açma sihirbazı tarih önerisi: `suggestRanges(today, prevEnd)` / `rangeAfter` — son dönemin bitişinden başlar, çakışan ay önerilmez (erken kapatılan ay tekrar önerilmezdi hatası önlendi). Not: `moneyToText(0)` → `''` (plan taslağı için); 0 göstermek gerekiyorsa `formatNumber`. Frontend 89 test; e2e 33 kontrol (DB silindi, 8091 kapatıldı).
- **Adım 5:** Dönem seçimi: `lib/period-select.ts` (`useSelectedPeriod`: `?donem=` → listeden; yoksa açık dönem, o da yoksa düzeltmedeki; `pickPeriod`, `isWritable`, `canReopen` = backend kuralı, `periodPath`), `features/periods/` (`PeriodPicker` başlıkta `<select>`, açık döneme dönünce parametre kalkar; `SelectedPeriod` yükleniyor/hata/bulunamadı/yok kapısı). Defter, Plan, Değerlendirme, kapatma sihirbazı buna geçti (Panel ve dönem sihirbazı hâlâ `useCurrentPeriod`). Defter kapalı dönemde salt okunur (form yok, `EntryList readOnly`); hafta değişimi `donem`'i korur. Plan: düzenleme `isWritable`, kopyalama yalnız açık. `PeriodHeading` durum rozeti. Değerlendirme sonu `ClosingSection` (açık → Kapat · düzeltmede → Defter'de/Plan'da düzelt + Yeniden Kapat · kapalı → kapanış bilgisi + **sahip** için satır içi onaylı "Düzeltme için yeniden aç") + `PeriodHistory`. `useReopenPeriod`; `useUpdatePeriod` liste önbelleğini de günceller (seçili dönem listeden → art arda kayıtta 409 olmasın). **Ders:** başarıda bileşen kalkıyorsa `mutate(…, {onSuccess})` çalışmaz → `mutateAsync` kullan. Frontend 92 test; e2e 36 + kapatma 33 yeniden (DB silindi). Faz 6 ✅.
### 2026-11-12 — Faz 7
- Kararlar (kullanıcı onayı): toplam birikim Faz 8'e kadar = kapanışlarda birikime aktarılanların toplamı; sekmeler Günlük/Haftalık = bu dönem (kümülatif, harcanabilir günlere eşit dağıtılır), Aylık = son 12 dönem; açık dönem yoksa son dönem salt okunur.
- **Adım 1:** `services/stats.rs` + `routes/stats.rs`: `GET /periods/{id}/daily` (`DaySpent[]`: dönemin her günü, `spent` = `GroupAmounts`, harcama yoksa 0; `budget::spent_by_group_and_date` artık `pub(crate)`), `GET /stats/periods?limit=12` (`PeriodStats`: `total_saved` tüm dönemlerin `to_savings` toplamı — limitten bağımsız, yeniden açılan dönemin eski tutarı sayılır; `periods` eskiden yeniye, her biri `budget::summary` ile → dönem başına ~5 sorgu, en fazla 24). `tests/stats.rs` (2). Mutasyon 2/2 yakalandı. Backend 118 test.
- **Adım 2:** Panel (`features/dashboard/`): `page.tsx` (açık dönem; yoksa `NoPeriodCard` + son dönem salt okunur, `lib/dashboard.ts` → `dashboardPeriod`), `overview.tsx` (kartlar: gelir, sabit, toplam birikim `useStats().total_saved`, borç yalnızca varsa — 3 kartta ızgara 3 sütun; **Anlık durum** DOM'da önce/mobilde üstte, masaüstünde sağda: kalan harcanabilir + `dailyAllowance` = kalan ÷ kalan gün (bugün dahil, kuruş aşağı); gruplar `WeekSummary` artık `index: number | 'month'`, Haftalık/Aylık geçişi; boş durumlar: bütçe dağıtılmamış → Plan, harcama yok → Defter). "Harcama Ekle": masaüstünde başlıkta, mobilde yüzen (alt menünün üstü) → `/defter` `state.focus` → `EntryForm autoFocus`. `components/skeleton.tsx`. `queryKeys.stats = ['periods','stats']` (kapatma/plan ile yenilenir), kayıt mutasyonları da stats'ı yeniler. Frontend 95 test; e2e 22 kontrol (DB silindi). Canlı yayın 2026-11-12 güncellendi (kullanıcı Deploy yaptı; `/api/stats/periods` 401).
- **Adım 3:** Grafikler (`features/dashboard/charts.tsx`, Recharts 3, **lazy** ayrı parça ~110 KB gz): Günlük/Haftalık/Aylık (`Segmented` — `components/segmented.tsx`, Panel grup kartı da kullanıyor). Seriler `lib/charts.ts` (`dailySeries`: harcanabilir günlere eşit + kümülatif harcanan, gelecek gün `null`; `weeklySeries`: hafta sonu planı, içinde bulunulan hafta bugüne kadar; `monthlySeries`: `useStats` dönemleri; `lastPoint`) + test. Backend: `budget::Summary.spent_by_group` → `PeriodStat.spent_by_group` (aylık grup grafiği). Grup grafiğinde harcanabilir çizgisi varsayılan kapalı (ölçeği ezip grup çizgilerini sıfıra yapıştırıyordu), `aria-pressed` düğmeyle açılır. Lejant/ipucu sırası `itemSorter` ile sabit (Recharts 3 varsayılanı alfabetik). Grafik `aria-hidden`, altında metin özeti (figcaption). `formatCompact` (eksen, bölünmez boşluk). **Ders:** `--color-*` değişkenleri `@theme inline` yüzünden derlenmiş CSS'te yok → SVG'de ham `var(--needs)` (CLAUDE.md'ye eklendi); e2e'de çizgi rengi `getComputedStyle` ile denetlenir. Tam sayfa ekran görüntüsü (`captureBeyondViewport`) ResponsiveContainer'ı küçültebilir → grafik için görünen alan görüntüsü al. Frontend 101, backend 118 test; e2e 18 kontrol. `npm audit` uyarıları shadcn CLI'dan (geliştirme aracı; `--force` shadcn'i 1.0'a düşürür → uygulanmadı). **Faz 7 ✅.**
### 2026-11-13 — Faz 8
- **Adım 1:** Varlıklar backend: `models/asset.rs` (`AssetKind` cash/gold/fx, `TxType` buy/sell), `services/holding.rs` (saf: ağırlıklı ortalama maliyet, tam satışta artık maliyet sıfırlanır, `Oversold`, i128 `mul_div`; 5 birim test), `services/asset.rs` (liste + hesaplanmış durum, döviz ekleme 3 harf/tekil, ad/aktiflik, işlem ekle/düzenle/sil; her yazmada tarih sıralı yeniden denetim; TL: miktar = tutar), `routes/asset.rs` (7 uç nokta). Varsayılan 8 varlık `seed::assets` (kayıtta) + **migration v2** (eski haneler, idempotent → canlıdaki hane uygulama yeniden başlayınca alır). `tests/assets.rs` (4). Mutasyon 3/3 yakalandı. Backend 135 test.
- **Adım 2:** Kurlar: `reqwest` 0.12 (`rustls-tls` → MongoDB ile aynı `ring`; 0.13 `aws-lc-rs` getiriyordu), `quick-xml` (serialize). `services/rate_sources.rs` (saf: `parse_tcmb` ForexBuying ÷ Unit, boş alış atlanır, `decimal_e6` kayan noktasız; `parse_gold` xaus `per_gram_usd`; `gold_rates` has gram katsayıları; testler örnek metinle), `services/rates.rs` (`refresh`: iki kaynak bağımsız, TCMB düşerse DB'deki son USD; gün başına upsert; `spawn_refresher` main'de, `RATES_REFRESH_MINUTES` varsayılan 60 / 0 kapalı; `current`: aggregation kod×(oto|hane) en yeni → en yeni tarih, eşitlikte manuel; `set_manual`/`delete_manual`; `portfolio` + `value_of` yarım yukarı). `GET /portfolio`, `POST /rates`, `DELETE /rates/{id}`. `calendar::today_tr` (UTC+3). Testler sahte axum sunucusuyla (internet yok): `tests/rates.rs` (2) + birim (4). Mutasyon: öncelik, USD yedeği yakalandı; yuvarlama kaçtı → `value_of` birim testi eklendi, artık yakalıyor. Gerçek kaynak denemesi (test DB): TCMB 22 döviz, altın 5 kod (gram 6.253,82, çeyrek 10.024,88 ₺). Backend 145 test. DEPLOY §2 + `.env.example`: `RATES_REFRESH_MINUTES`, konteynerin internete çıkışı gerekir.
- **Adım 3:** Birikim sayfası (`features/savings/`): `page.tsx` (özet: toplam değer + son kur zamanı, maliyet, kâr/zarar `stacked`, gerçekleşen; uyarılar: kur hiç yok / 6 saattir çekilemedi / kursuz varlık; dağılım halka grafiği `allocation-chart.tsx` lazy 6 KB, renkli liste = metin özeti; varlık listesi: elde/geçmişi olanlar + `<details>` "Diğer varlıklar"; `add-currency.tsx`), `asset-page.tsx` (`/birikim/:id`: durum kartları, `RateBox` elle kur gir/kaldır, `TxFormView` `tx-form.tsx` alım/satım: miktar × birim fiyat → tutar önerisi, elle değişen tutar korunur, yeni alımda birim fiyat = güncel kur (kuruşa yuvarlı), TL'de yalnız tutar, satışta eldeki sınırı; geçmiş: düzenle + iki adımlı sil; pasife al/etkinleştir). `lib/decimal.ts` (`parseE6`: virgül ondalık, tek nokta + 3 rakam binlik, `formatE6`, `formatRate`, `valueKurus` BigInt = backend), `lib/savings.ts` (`plRatio`, `formatPlPercent` `-%0,6` ve ≈0 gizli, `isFetchStale` 6 saat, `rateAgeDays`, `splitAssets`, `allocation`, `kindEmoji`, `qty`), `lib/assets.ts` (kancalar, hepsi `['portfolio']` yeniler), `components/decimal-input.tsx`, `formatDateTime`. Frontend 114 test; e2e 37 kontrol (kurlar DB'ye elle, `RATES_REFRESH_MINUTES=0`). Düzeltilen: `%-0,0`, TL satırında tekrar, mobil özet taşması; Tailwind keyfi alt seçici (`[&>*]`) `lg:` ile ezilemedi → sınıflar öğenin kendisine.
- **Adım 4:** Kapanış → birikim. Backend (`services/asset.rs`): `add_period_savings` (yalnız `closed`, `to_savings > 0`, dönem başına tek → 409; TL varlığı pasif olsa da; not "<ad> kapanışı", tarih son gün), `pending_period_savings`, `plan_savings_sync` (transaction öncesi: bağlı kayıt + TL geçmişi denetimi → `SavingsSync::Update/Delete`) + `apply_savings_sync` (kapatma transaction'ı içinde). `PeriodCloseResult.savings_linked`. `GET /savings/pending`, `POST /periods/{id}/to-savings`. `tests/savings_link.rs` (3), mutasyon 3/3. Frontend: kapatma sonucunda `AddToSavings` (onay düğmesi → "Birikime eklendi" + bağlantı; yeniden kapatmada "kayıt güncellendi"), `features/savings/pending.tsx` (Birikimler üstünde), Panel "Toplam birikim" = `portfolio.value_total` (`stats.total_saved` artık arayüzde kullanılmıyor; backend'de duruyor). `useClosePeriod` portföyü de yeniler. Backend 149, frontend 114 test; e2e 14 kontrol. **Faz 8 ✅.**
### 2026-11-13 — Faz 9
- **Adım 1:** Rapor verileri. `/stats/periods` → `PeriodStat` artık `summary: budget::Summary` taşır; DTO'ya `income_total`, `fixed_expense_total`, `savings_goal`, `carryover_debt/surplus`, `realized_savings` (= review 4. soru) eklendi. `services/reports.rs` + `GET /reports/breakdown` (tek `$facet`: toplam, 4 grup sabit sırada (0 dahil), **tüm** kategoriler çoktan aza, ödeme türü (`$ifNull` → `null` = girilmemiş), ay `$substrBytes`; filtre dönem/tarih aralığı (dahil, başlangıç>bitiş 400)/grup/kategori/ödeme türü (`none`)). Bütçe sapması için yeni uç nokta yok (mevcut `/budget`). `tests/reports.rs` (1, kapsamlı) + stats testi genişledi. Mutasyon 2/2. Backend 155 test.
- **Adım 2:** Raporlar sayfası (`features/reports/`): `page.tsx` (filtre adreste: `donem`/`baslangic`/`bitis`/`grup`/`kategori`/`odeme` (`yok` = girilmemiş), dönem varsa tarih yok sayılır), `filters.tsx` (Kapsam: tüm zamanlar / dönem / tarih aralığı (yıl başı → bugün varsayılan), grup seçilince kategori listesi daralır, mobilde katlanır), `section.tsx` (`ReportSection`: `data-report`, CSV indir + bölüm yazdır; `ShareTable`: satır içi CSS pay çubukları — yazdırmada da görünür), `sections.tsx` (gruplar, ödeme türleri, **tüm** kategoriler, aylar = filtreli; bütçe sapması = seçili/açık/son dönem, grup + hafta; gelir–gider + birikim oranı = son 12 dönem; varlıklar), `charts.tsx` lazy (bar + çizgi). `lib/csv.ts` (BOM, `;`, CRLF, tırnaklama, `csvMoney` binliksiz), `lib/reports.ts` (`parseFilter`/`filterToParams`/`filterToQuery`, `describeFilter` → yazdırmada kapsam satırı, `savingsRate`, `deviation`, `formatMonth`), `lib/print.ts` (`installPrintTheme`: beforeprint'te koyu tema kaldırılır; `printSection`: `data-print-one` + `data-print-target`), index.css `@media print`, layout `print:hidden`. Kayıt değişince `['reports']` yenilenir. Frontend 121 test; e2e 34 kontrol (CSV baytları, yazdırma hedefi, tema, filtreler). Not: `Blob.text()` BOM'u siler → BOM'u baytlardan doğrula.
+68 -13
View File
@@ -5,16 +5,16 @@
| Faz | Kapsam | Çıktı | Durum | | Faz | Kapsam | Çıktı | Durum |
|---|---|---|---| |---|---|---|---|
| **Faz 0** | Ortam kurulumu, VS Code yapılandırması, monorepo iskeleti, hafıza dosyaları, yerel MongoDB, veri modeli onayı | `npm run dev` ile boş uygulama tarayıcıda açılıyor | ✅ | | **Faz 0** | Ortam kurulumu, VS Code yapılandırması, monorepo iskeleti, hafıza dosyaları, yerel MongoDB, veri modeli onayı | `npm run dev` ile boş uygulama tarayıcıda açılıyor | ✅ |
| **Faz 1** | Backend temeli: config, DB bağlantısı, kimlik doğrulama, hane, kategoriler, ödeme türleri, health check | API `.http` dosyasıyla test edilebiliyor | 🔄 | | **Faz 1** | Backend temeli: config, DB bağlantısı, kimlik doğrulama, hane, kategoriler, ödeme türleri, health check | API `.http` dosyasıyla test edilebiliyor | ✅ |
| **Faz 2** | Frontend temeli: tasarım sistemi, tema, logo/ikonlar, layout, navigasyon, giriş/kayıt | Giriş yapılabilen, temalı iskelet uygulama | ⏳ | | **Faz 2** | Frontend temeli: tasarım sistemi, tema, logo/ikonlar, layout, navigasyon, giriş/kayıt | Giriş yapılabilen, temalı iskelet uygulama | ✅ |
| **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | ⏳ | | **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | ✅ |
| **Faz 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ⏳ | | **Faz 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ✅ |
| **Faz 5** | Günlük defter | Harcama girişi | ⏳ | | **Faz 5** | Günlük defter | Harcama girişi | 🔄 |
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ⏳ | | **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ✅ |
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ⏳ | | **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ |
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ⏳ | | **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ |
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | ⏳ | | **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | 🔄 |
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası | Telefona kurulabilir web uygulaması | ⏳ | | **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | ⏳ |
| **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) | Masaüstü ve mobil uygulamalar | ⏳ | | **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) | Masaüstü ve mobil uygulamalar | ⏳ |
## Faz 0 Adımları ## Faz 0 Adımları
@@ -27,6 +27,61 @@
## Faz 1 Adımları ## Faz 1 Adımları
- ✅ 1. `config.rs` + `db.rs` (MongoDB bağlantısı), Türkçe hata yapısı, health check'e DB durumu - ✅ 1. `config.rs` + `db.rs` (MongoDB bağlantısı), Türkçe hata yapısı, health check'e DB durumu
- ✅ 2. Modeller, migration mekanizması ve indeksler - ✅ 2. Modeller, migration mekanizması ve indeksler
- 🔄 3. Kimlik doğrulama: kayıt (+ hane + örnek kategori/ödeme türü), giriş, çıkış, `me`; argon2 + JWT cookie + rate limit - ✅ 3. Kimlik doğrulama: kayıt (+ hane + örnek kategori/ödeme türü), giriş, çıkış, `me`; argon2 + JWT cookie + rate limit
- ⏳ 4. Hane izolasyonu (middleware), üye davet kodu - ✅ 4. Hane izolasyonu (middleware), üye davet kodu
- ⏳ 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http` - ✅ 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http`
## Faz 2 Adımları
- ✅ 1. Tailwind v4 + shadcn/ui (radix, nova), tasarım token'ları (4 grup + durum renkleri, AA doğrulandı), Inter, tema (aydınlık/karanlık/sistem), `lib/format.ts` + Vitest
- ✅ 2. Logo ve uygulama ikonu (SVG), favicon, PWA/Apple ikonları
- ✅ 3. `lib/api.ts`, TanStack Query, ts-rs tip üretimi, oturum durumu
- ✅ 4. Router (react-router), korumalı sayfalar, layout: mobil alt menü / masaüstü sol menü
- ✅ 5. Giriş / kayıt ekranları (react-hook-form + zod, beni hatırla, davet kodu), arayüz metinleri tek dosyada
## Faz 3 Adımları
- ✅ 1. Dockerfile (çok aşamalı), Axum arayüzü sunar (SPA yönlendirmesi, önbellek başlıkları, sıkıştırma), graceful shutdown, yerelde imaj testi
- ✅ 2. Canlı MongoDB kontrolü (§12.3): kurulum şekli, replica set, ayrı kullanıcı, dış erişim
- ✅ 3. Dokploy: Gitea bağlantısı, ortam değişkenleri, alan adı + HTTPS, ilk yayın
- ✅ 4. Canlıda ilk hesap + kontrol listesi
- ⏳ 5. Yedekleme (mongodump, 14 gün) + geri yükleme denemesi — **ertelendi, Faz 5 bitmeden yapılacak**
## Faz 4 Adımları
- ✅ 1. Backend: dönem (aç, açık dönem, liste, detay + haftalar, düzenle), takvim hesapları, testler
- ✅ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar
- ✅ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
- ✅ 4. Frontend: ilk dönem sihirbazı
- ✅ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı)
- ✅ 6. Frontend: notlar (Markdown, düzenle/önizle)
## Faz 5 Adımları (taslak)
- ✅ 1. Backend: harcama kayıtları (ekle/düzenle/sil + geri al, hafta/dönem filtresi, kategori–grup tutarlılığı, yalnızca açık dönem), testler
- ✅ 2. Backend: haftalık/aylık gerçekleşen (grup bazlı bütçe–harcanan–kalan), özet ve önerilere gerçekleşen verisi
- ✅ 3. Frontend: Defter — hafta seçici + hızlı giriş formu (tutar, 4 grup düğmesi, kategori, ödeme türü, tarih, açıklama; son seçim hatırlanır)
- ✅ 4. Frontend: haftanın girişleri (gün gün), düzenle/sil + "Geri al", haftalık özet
- 🔄 5. Canlı yedekleme (betikler + test ✅, sunucuda kurulum kullanıcıda) (mongodump günlük, 14 gün) + geri yükleme denemesi
## Faz 6 Adımları (taslak)
- ✅ 1. Backend: değerlendirme (`GET /periods/{id}/review`: 4 soru, grup/kategori/hafta) + `reflection_md`
- ✅ 2. Backend: dönem kapatma (transaction: durum, devir, birikime aktarılan tutar) + yeni dönemde devrin bağlanması + yeniden açma (sahip)
- ✅ 3. Frontend: Değerlendirme sayfası (4 soru, planlanan/gerçekleşen, en çok harcanan kategoriler, haftalık dağılım, "Nasıl daha iyi yapabilirim?", notlar)
- ✅ 4. Frontend: dönem kapatma sihirbazı (+ yeni dönemi kopyalayarak açma)
- ✅ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip) + düzeltmedeki dönemi Defter/Plan/Değerlendirme'de seçme
## Faz 7 Adımları
- ✅ 1. Backend: `GET /periods/{id}/daily` (gün × grup harcama; günlük/haftalık grafik verisi) + `GET /stats/periods` (son 12 dönem: harcanabilir, harcanan, birikime aktarılan; aylık grafik ve toplam birikim)
- ✅ 2. Frontend: özet kartları (gelir, sabit gider, toplam birikim, devreden borç, grup planlanan/gerçekleşen haftalık–aylık, **anlık durum** en belirgin) + "➕ Harcama Ekle" (mobilde yüzen düğme) + skeleton ve boş durumlar
- ✅ 3. Frontend: grafikler (Recharts) — kümülatif harcanabilir vs. gerçekleşen; 4 grup + harcanabilir; Günlük / Haftalık / Aylık sekmeleri, koyu tema, erişilebilir özet
## Faz 8 Adımları
- ✅ 1. Backend: varlıklar (hane başına varsayılanlar: TL, gram/çeyrek/yarım/tam altın, ons, USD, EUR; döviz eklenebilir) + alım/satım kayıtları (satış ≤ eldeki miktar) + varlık başına miktar, **ağırlıklı ortalama maliyet**, gerçekleşen kâr/zarar; testler
- ✅ 2. Backend: kurlar — TCMB `today.xml` (döviz) + xaus.com ons fiyatı × TCMB USD (altın; çeyrek/yarım/tam has gram katsayısıyla), saatlik arka plan görevi + önbellek (`exchange_rates`), kaynak erişilemezse son kur + zamanı; **manuel kur** (hane); portföy değerleme uç noktası (güncel değer, kâr/zarar tutar ve %, toplamlar, dağılım); kaynak ayrıştırıcıları örnek dosyalarla testli
- ✅ 3. Frontend: Birikim sayfası — portföy özeti + halka grafik, varlık listesi (miktar, ortalama maliyet, güncel kur, değer, kâr/zarar), alım/satım formu, işlem geçmişi (düzenle/sil), manuel kur, eski kur uyarısı
- ✅ 4. Kapanış entegrasyonu: birikime aktarılan tutar onayla TL varlığına eklenir (`source_period_id`, tekrar eklenmez); geçmiş kapanışlar için "bekleyen birikimler" listesi; Panel "Toplam birikim" → portföyün güncel değeri
## Faz 9 Adımları
- ✅ 1. Backend: rapor verileri — dönem istatistiklerine gelir/sabit gider/devir/gerçekleşen birikim (birikim oranı için) + `GET /reports/breakdown` (grup / **tüm kategoriler** / ödeme türü / ay; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü). Bütçe sapması mevcut `budget` verisinden
- ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
- ⏳ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
- ⏳ 4. **Banka / kredi kartı ekstresi içe aktarma** (kullanıcı isteği): CSV/Excel, sütun eşleştirme (banka profili kaydedilir), harcama/gelir yönü, tekrar eden satır tespiti, açıklamadan kategori önerisi (öğrenen kural), grup/kategori eşleştirmesi kullanıcıda; eksik aylar için **geçmiş (arşiv) dönemler** — amaç yıl başından beri veri → sonraki aylara referans (ortalama önerileri)
- ⏳ 5. JSON tam hane yedeği: sürümlü dışa aktarma; geri yüklemede önizleme + onay + sunucuda otomatik geri dönüş noktası ("geri yüklemeyi geri al"); başka haneden taşıma
- ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF
+22 -3
View File
@@ -1,8 +1,27 @@
{ {
"$schema": "./node_modules/oxlint/configuration_schema.json", "$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"], "plugins": [
"react",
"typescript",
"oxc"
],
"rules": { "rules": {
"react/rules-of-hooks": "error", "react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }] "react/only-export-components": [
} "warn",
{
"allowConstantExport": true
}
]
},
"overrides": [
{
"files": [
"src/components/ui/**"
],
"rules": {
"react/only-export-components": "off"
}
}
]
} }
+5
View File
@@ -0,0 +1,5 @@
# ts-rs tarafından üretilir (npm run types)
src/types/
# shadcn/ui kaynakları (kendi biçimleriyle bırakılır)
src/components/ui/
dist/
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+24
View File
@@ -0,0 +1,24 @@
<!-- Köşesiz kare sürüm: Android "maskable" ve Apple touch icon (sistem kendi şekliyle kırpar).
İçerik %82'ye küçültülmüştür → maskable güvenli alanı (merkezde %80 çaplı daire) içinde kalır. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="kakeibo-bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#3b4aa8"/>
<stop offset="1" stop-color="#26307a"/>
</linearGradient>
</defs>
<rect width="512" height="512" fill="url(#kakeibo-bg)"/>
<g transform="translate(256 256) scale(0.82) translate(-260 -256)">
<rect x="140" y="92" width="252" height="328" rx="28" fill="#f7f3ea"/>
<rect x="140" y="92" width="52" height="328" rx="28" fill="#e6dfcf"/>
<rect x="166" y="92" width="26" height="328" fill="#e6dfcf"/>
<g fill="#26307a">
<circle cx="140" cy="150" r="13"/><circle cx="140" cy="220" r="13"/>
<circle cx="140" cy="290" r="13"/><circle cx="140" cy="360" r="13"/>
</g>
<g fill="none" stroke="#26307a" stroke-width="22" stroke-linecap="round" stroke-linejoin="round">
<path d="M238 178 L292 254 L346 178"/><path d="M292 254 V342"/>
<path d="M250 266 H334"/><path d="M250 304 H334"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

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