Compare commits
65 Commits
af2c01dbee
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d3e012f50b | |||
| 4f072634b1 | |||
| dbed3da1b1 | |||
| c063186e80 | |||
| 11402ba660 | |||
| a2eff1a984 | |||
| 5cbf97d884 | |||
| 8ee3672d57 | |||
| 57272a4c7f | |||
| 77d7a081b3 | |||
| 9daeff3eb5 | |||
| c3ec91b2af | |||
| dc0d783e46 | |||
| b974525cf6 | |||
| 0d70576df7 | |||
| 6e1000afd2 | |||
| 233907e3d9 | |||
| d1a24afc3c | |||
| 4d764689e2 | |||
| 33f64a0459 | |||
| feb2e1d16c | |||
| 87409590a6 | |||
| 8c9c3c2506 | |||
| 48554e0ff6 | |||
| 6b7b69c987 | |||
| 3f60efdc33 | |||
| 267d292a90 | |||
| 1ef28b80a3 | |||
| 191c3e6db5 | |||
| 64c185ab96 | |||
| 30136ed1d3 | |||
| 861bfba85a | |||
| 2c16cf4163 | |||
| 311cfc8c60 | |||
| 62e92f552a | |||
| 8ec1fd6f25 | |||
| e4c8d971fc | |||
| 16535ad7f3 | |||
| f930f2fbcc | |||
| 7143613ab2 | |||
| 2003b8fad7 | |||
| eca0bb27a0 | |||
| 9004a7cc42 | |||
| ca0d3a505c | |||
| 95231cd4d6 | |||
| 7f3fde074c | |||
| b883530f76 | |||
| a2b4119f3e | |||
| f86cff4755 | |||
| ad90551c6d | |||
| e188922c0f | |||
| 9cbeaaaf76 | |||
| b269411823 | |||
| c6d4c30461 | |||
| 906f05f62f | |||
| f9be08114d | |||
| 440523b176 | |||
| 6eb5440d4d | |||
| 4cc4fa5882 | |||
| d6136959b1 | |||
| 4103b90e4b | |||
| 5550c7c43d | |||
| 8ec24ecec2 | |||
| 125bb7f476 | |||
| 306c550b28 |
@@ -0,0 +1,12 @@
|
|||||||
|
# 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
|
||||||
|
samples/
|
||||||
@@ -9,8 +9,29 @@ 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
|
||||||
|
|
||||||
|
# Bot doğrulaması (Cloudflare Turnstile). İkisi de boşsa kapalı (geliştirme).
|
||||||
|
# Yerel deneme için Cloudflare test anahtarları (her zaman geçer):
|
||||||
|
# TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||||
|
# TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
||||||
|
|
||||||
|
# Ters vekil (Dokploy/Traefik) arkasında gerçek IP X-Forwarded-For'dan; vekil yoksa false
|
||||||
|
# TRUST_PROXY=true
|
||||||
|
|||||||
@@ -17,3 +17,6 @@ dist/
|
|||||||
|
|
||||||
# Yedekler
|
# Yedekler
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
|
# Örnek ekstreler (kişisel veri; depoya girmez)
|
||||||
|
samples/
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Tam gereksinimler: `KAKEIBO_PROMPT.md` (yalnızca ilgili bölümü oku).
|
|||||||
- Değişikliklerde dosyayı baştan yazma; yalnızca değişen kısmı düzenle.
|
- Değişikliklerde dosyayı baştan yazma; yalnızca değişen kısmı düzenle.
|
||||||
- Yanıt formatı: ne yapıldı → nasıl test edilir → sıradaki adım.
|
- Yanıt formatı: ne yapıldı → nasıl test edilir → sıradaki adım.
|
||||||
- Her adım sonunda `docs/MEMORY.md`'ye 3–5 satır not ekle.
|
- Her adım sonunda `docs/MEMORY.md`'ye 3–5 satır not ekle.
|
||||||
- Her faz sonunda commit + `git push origin main` (Claude yapar).
|
- Her adım sonunda Claude commit atar ve "push zamanı" diye haber verir; push'u kullanıcı kendi uygulamasıyla yapar (Claude `git push` çalıştırmaz).
|
||||||
- Bir hata 2 denemede çözülmezse dur, seçenek sun.
|
- Bir hata 2 denemede çözülmezse dur, seçenek sun.
|
||||||
- Kullanıcı Rust'a yeni: kavramları ihtiyaç anında, kısa örnekle açıkla.
|
- Kullanıcı Rust'a yeni: kavramları ihtiyaç anında, kısa örnekle açıkla.
|
||||||
- Her yeni bağımlılık için kısa gerekçe ver.
|
- Her yeni bağımlılık için kısa gerekçe ver.
|
||||||
@@ -43,7 +43,7 @@ scripts/ seed, reset, backup
|
|||||||
- Her sorgu `household_id` ile filtrelenir (ortak middleware).
|
- Her sorgu `household_id` ile filtrelenir (ortak middleware).
|
||||||
- Hesaplanan değerler saklanmaz; aggregation ile hesaplanır.
|
- Hesaplanan değerler saklanmaz; aggregation ile hesaplanır.
|
||||||
- Format: `1.250,00 ₺`, tarih `GG.AA.YYYY`; tümü `lib/format.ts` üzerinden.
|
- Format: `1.250,00 ₺`, tarih `GG.AA.YYYY`; tümü `lib/format.ts` üzerinden.
|
||||||
- Haftalar Pazartesi başlar; kısmi haftalar gün sayısına göre orantılanır.
|
- Haftalar dönemin günlerine göre: 1–8, 9–15, 16–22, 23–son gün (28–31 günlük dönem = 4 hafta; genel: `max(1, gün/7)` hafta, ilk 8 gün, ara 7, son kalan). Bütçe haftalara gün sayısıyla orantılanır. Tek kaynak: `calendar::weeks`.
|
||||||
- Hatalar kullanıcıya Türkçe ve sade; teknik detay yalnızca logda.
|
- Hatalar kullanıcıya Türkçe ve sade; teknik detay yalnızca logda.
|
||||||
- Commit mesajları Türkçe: `feat(defter): haftalık giriş listesi eklendi`
|
- Commit mesajları Türkçe: `feat(defter): haftalık giriş listesi eklendi`
|
||||||
|
|
||||||
@@ -53,14 +53,43 @@ 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`); `dev:api` önce `scripts/stop-stale-api.sh` ile 8080'de yetim kalmış eski `kakeibo` sürecini kapatır (yalnız adı kakeibo ise). Belirti: yeni uç nokta "Kayıt bulunamadı" (404) → eski backend çalışıyor.
|
||||||
- Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler).
|
- Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler).
|
||||||
- Frontend lint: **oxlint** (Vite şablonu varsayılanı; ESLint yerine).
|
- 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` — her sayfa **ayrı JS parçası** (`lazy: page(() => import(...), 'XPage')`; yeni sayfa ekleyince statik import etme). `main.tsx` `prefetchMe` (oturum isteği sayfa koduyla paralel), `AppLayout` alt menü sayfalarını boşta önden indirir + gezinmede ince ilerleme çizgisi. İlk yükleme yedeği `Splash`.
|
||||||
|
- Sayfa adresleri: adresler `components/layout/nav-items.ts` (`paths`); sayfa bileşenleri `features/<alan>/page.tsx`; iskelet `components/layout/app-layout.tsx`. Sayfa başlığı `PageHeader` (sekme başlığını da ayarlar). Metinler `lib/strings.ts` (`t`).
|
||||||
|
- Yönlendirme: `RequireAuth` (oturum yok → `/giris?donus=`), `RequireGuest` (oturum var → `donus` ya da panel; formlar ayrıca navigate etmez). `donus` daima `safeReturnPath` ile.
|
||||||
|
- Geliştirmede tasarım önizlemesi: `/dev/tasarim`.
|
||||||
|
- Formlar: react-hook-form + zod (`mode: 'onTouched'`), alanlar `components/form-field.tsx` (`FormField`, `PasswordInput`, `FormError`); sunucu hatası `setError` ile ilgili alana. Bildirim: `toast` (sonner, `components/toaster.tsx`).
|
||||||
|
- Dönem verisi: `lib/periods.ts` (Query kancaları), tarih `lib/period-dates.ts` (arayüz son günü dahil gösterir, backend `end_date` hariç), tutar alanı `MoneyInput` + `moneyText`/`toKurus` (`lib/money-schema.ts`). İlk dönem sihirbazı `/plan/donem-ac?adim=`. Plan düzenleme: taslak → `diffPlan` → `useSavePlan`; haftalık önizleme `lib/budget-preview.ts` (backend hesabıyla aynı tutulmalı).
|
||||||
|
- Defter: `/defter` her açılışta bugünün haftası (hafta adrese yazılmaz), `features/ledger/` (hafta seçici, hızlı giriş), saf hesaplar `lib/ledger.ts`, kancalar `lib/entries.ts` + `lib/catalog.ts`. Kayıt değişince `queryKeys.period(id)` yenilenir (özet/bütçe `spent` dahil).
|
||||||
|
- Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`.
|
||||||
- API örnekleri: `backend/api.http`
|
- API örnekleri: `backend/api.http`
|
||||||
|
- Canlı: `Dockerfile` (tek imaj), `docs/DEPLOY.md` (ortam değişkenleri, Dokploy, MongoDB, yedek). `routes::app()` = API + `STATIC_DIR` varsa SPA (`routes/spa.rs`) + sıkıştırma + TraceLayer. Testler `routes::router()` kullanır.
|
||||||
|
- Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_<uuid>` DB, `tests/common` yardımcıları).
|
||||||
|
- Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`.
|
||||||
|
- Excel okuma: SheetJS **yerel paket** `frontend/vendor/xlsx-0.20.3.tgz` (npm 12 uzak tarball'u engeller; npm'deki `xlsx` eski/açıklı). Güncellerken: cdn.sheetjs.com'dan indir, içeriği/sha256'yı kontrol et, `npm i ./vendor/…`; Dockerfile `vendor/`'u `npm ci`'den önce kopyalar. Lazy yüklenir.
|
||||||
|
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
|
||||||
|
- JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. Arayüz: `features/settings/backup-section.tsx`, `lib/backup.ts` (geri yükleme/geri alma sonrası tüm sorgular yenilenir).
|
||||||
|
- Aylık rapor: `services/monthly_report.rs` (`gather` → `Facts` → saf `analyze` → `render`), şablon `templates/monthly_report.md` (`include_str!`; Dockerfile `templates/`'u kopyalar). Filtreler `tl`/`pct`/`cell`. Şablonda ham HTML yok (arayüz göstermez). Arayüz `features/review/report-page.tsx` (`/degerlendirme/rapor?donem=`), `lib/monthly-report.ts`; `api.getText`; `Markdown` tabloları `.table-wrap` içinde kayar; yazdırma `@page A4` + `PageHeader printHidden`. Cümlelerde sayıdan sonra ek kullanma ("%429'u" gibi ünlü uyumu gerekir) → "X / Y", "kullanımı %N".
|
||||||
|
- Ayarlar: `/ayarlar` menü (+ tema) → alt sayfalar `SettingsSubpage` ("‹ Ayarlar"): `/ayarlar/kategoriler`, `/odeme-turleri`, `/butce` (varsayılan yüzdeler + öneri yöntemi), `/hane` (ad, üyeler, davet bağlantısı `/kayit?davet=`), `/hesap` (ad, şifre), `/veri` (yedek, CSV, ekstre). Kancalar `lib/household.ts`. Şifre değişince `users.sessions_valid_from` → eski jetonlar 401 (`AuthUser`). Katalog yönetimi `features/settings/catalog-editor.tsx` + `lib/catalog.ts` (`useCreateCatalog`/`useUpdateCatalog`/`useReorderCatalog`, `moveId`); sıralama isteği grubun **tüm** kimlikleri (pasifler dahil); grup sonradan değişmez.
|
||||||
|
- Erişilebilirlik kuralları: `--muted-foreground` açık temada 0.52 (gri zeminde AA); kart bileşenleri (`Section`, rapor bölümü) `min-w-0` — ızgara öğesi içeriğine uzayıp telefonda taşmasın; `Button` `sm`/`icon-sm` dokunmatikte (`pointer-coarse`) 44 px; tutarlar `formatMoney` ile **bölünmez boşluklu** (`1.250,00\u00a0₺`; testlerde `\u00a0`); bütçe çubuğu `budgetLevel` (≥ %80 turuncu + "Bütçe kullanımı %N", aşım kırmızı); `Markdown shiftHeadings` (sayfada tek h1). Cümlelerde sayıdan sonra ek yok.
|
||||||
|
- PWA: `vite.config.ts` `VitePWA` (registerType `prompt`, `/api` asla önbelleğe alınmaz, `navigateFallbackDenylist /api`, xlsx + yabancı font alt kümeleri ön önbellekte yok); `lib/pwa.tsx` (`PwaUpdater` "Yeni sürüm hazır → Yenile", saatlik yoklama; `OfflineBanner`), `lib/online.ts`. `main.tsx` `onlineManager.setOnline(navigator.onLine)`. Oturum sorgusu ağ hatası/duraklama → `RequireAuth` giriş sayfasına **atmaz**, "Tekrar dene" gösterir. Geliştirmede SW yok. DEPLOY §7.
|
||||||
|
- Giriş güvenliği: `auth/guard.rs` (IP başına üst üste 3 hata → giriş+kayıt 15 dk kilit, bellek içi; e-postaya göre kilit YOK), `auth/captcha.rs` (Cloudflare Turnstile; anahtar yoksa kapalı; testte `Captcha::Fixed`), `auth/client_ip.rs` (`TRUST_PROXY` → en sağdaki X-Forwarded-For). Hata kodları `locked` (429 + Retry-After), `captcha` (400), `invalid_credentials` (kalan hak mesajda). `GET /auth/guard`. Arayüz `features/auth/guard.tsx` (`useAuthSecurity`), `lock-notice.tsx`, `components/turnstile.tsx`. Testler `TestApp::with_captcha`, `send_with` (X-Forwarded-For). DEPLOY §8.
|
||||||
|
- Borç takibi (docs/BORC_TAKIBI.md): `services/debt.rs` (saf: `total`, `installment_load`, `remaining_after`, `summarize`, `period_debt`; içe aktarma `parse_import` kaynak belge JSON + `asOf`, TL → kuruş `tl_to_kurus` kayan noktasız), `routes/debt.rs` (`/debt*`, `/periods/{id}/debt`, `/periods/{id}/opening-debt`). Koleksiyonlar `debt_snapshots`, `installment_plans` (`first_month`), `debt_targets`; toplam saklanmaz. Kakeibo `carryovers` ile karıştırma. Kişisel seed verisi repoya girmez. Arayüz `features/debt/` (`debt-card.tsx` Panel en üstü + /borc, `debt-chart.tsx` lazy Recharts, `snapshot-form.tsx` kalan taksit önerisi `remainingAfter`, `history.tsx`, `targets-editor.tsx`, `import-section.tsx`, `installments-page.tsx`), `lib/debt.ts` (saf hesaplar backend ile aynı). Adresler `/borc`, `/taksitler`; menüde "Borç" (masaüstü + Diğer). Plan: `features/debt/period-debt.tsx` (`PeriodDebtSection`: ay başı [elle düzelt], devreden banka borcu, borç ödemesi, taksit, hedef ay sonu + yol haritası, dönem içi ilerleme, "Taksitler" sabit gideri önerisi `findInstallmentItem`); borç > 0 iken `SummaryTiles debtPayment` → "Borç ödemesi" (Plan, düzenleme, sihirbaz). Kakeibo'nun "Devreden borç"u ile karışmasın diye banka borcu "Devreden banka borcu".
|
||||||
|
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
|
||||||
|
|
||||||
## Ortam
|
## Ortam
|
||||||
- Geliştirme makinesi: CachyOS (Arch), fish kabuğu, VS Code
|
- Geliştirme makinesi: CachyOS (Arch), fish kabuğu, VS Code
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# 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 ./
|
||||||
|
# Yerel paketler (SheetJS resmi dağıtımı; npm 12 uzak adres indirmeyi engeller)
|
||||||
|
COPY frontend/vendor/ ./vendor/
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ─── 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*
|
||||||
|
# Aylık rapor şablonu derleme anında ikiliye gömülür (include_str!)
|
||||||
|
COPY templates/ /app/templates/
|
||||||
|
COPY backend/src ./src
|
||||||
|
# touch: sahte lib.rs/main.rs'ten daha yeni olsun → Cargo gerçek kodu kesinlikle yeniden derler
|
||||||
|
RUN touch src/lib.rs src/main.rs && cargo build --release --locked
|
||||||
|
|
||||||
|
# ─── 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"]
|
||||||
+1
-1
@@ -162,7 +162,7 @@ kakeibo/
|
|||||||
|
|
||||||
- Dönem **başlangıç ve bitiş tarihiyle** açılır. Örnek: `01.12.2026` – `01.01.2027`.
|
- Dönem **başlangıç ve bitiş tarihiyle** açılır. Örnek: `01.12.2026` – `01.01.2027`.
|
||||||
- Aynı anda yalnızca **bir açık dönem** olur; girişler yalnızca açık döneme yapılır.
|
- Aynı anda yalnızca **bir açık dönem** olur; girişler yalnızca açık döneme yapılır.
|
||||||
- **Haftalar:** Pazartesi başlangıçlı. Dönemin başı/sonundaki kısmi haftalar ayrı hafta sayılır ve bütçeleri gün sayısına göre orantılanır.
|
- **Haftalar:** (kullanıcı kararı 2026-11-13, eski "Pazartesi başlangıçlı" kuralının yerine) dönemin 1–8. günü 1. hafta, 9–15 2., 16–22 3., 23–son gün 4. hafta. Bütçeleri gün sayısına göre orantılanır.
|
||||||
- **Dönem kapatma** (sihirbaz adımları):
|
- **Dönem kapatma** (sihirbaz adımları):
|
||||||
1. Özet ve planlanan/gerçekleşen karşılaştırması gösterilir.
|
1. Özet ve planlanan/gerçekleşen karşılaştırması gösterilir.
|
||||||
2. Kalan bakiye ve borç hesaplanır.
|
2. Kalan bakiye ve borç hesaplanır.
|
||||||
|
|||||||
@@ -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 }
|
||||||
Generated
+2892
-5
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,29 @@ 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"
|
||||||
|
flate2 = "1"
|
||||||
|
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
|
||||||
|
minijinja = { version = "3.1.0", features = ["serde"] }
|
||||||
|
mongodb = "3.10.0"
|
||||||
|
quick-xml = { version = "0.42.0", features = ["serialize"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.154"
|
serde_json = "1.0.154"
|
||||||
|
sha2 = "0.10"
|
||||||
|
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"] }
|
||||||
|
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"] }
|
||||||
|
|||||||
+465
-1
@@ -1,7 +1,471 @@
|
|||||||
### 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ü
|
### 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
|
||||||
|
|
||||||
|
# ─────────────────────────── CSV dışa / içe aktarma ───────────────────────────
|
||||||
|
|
||||||
|
### Harcamaları CSV olarak indir (tarih aralığı isteğe bağlı)
|
||||||
|
GET {{baseUrl}}/export/entries.csv?from=2026-01-01&to=2026-12-31
|
||||||
|
|
||||||
|
### İçe aktarma denemesi (hiçbir şey yazılmaz); commit=true → geçerli satırlar yazılır
|
||||||
|
POST {{baseUrl}}/import/entries?commit=false
|
||||||
|
Content-Type: text/csv; charset=utf-8
|
||||||
|
|
||||||
|
Tarih;Tutar;Kategori;Açıklama
|
||||||
|
13.11.2026;45,90;Market;Ekmek
|
||||||
|
|
||||||
|
# ─────────────────────────── JSON tam hane yedeği ───────────────────────────
|
||||||
|
|
||||||
|
### Yedek dosyasını indir (her üye)
|
||||||
|
GET {{baseUrl}}/backup/download
|
||||||
|
|
||||||
|
### Son yedek zamanı + geri dönüş noktaları (noktaları yalnız sahip görür)
|
||||||
|
GET {{baseUrl}}/backup/status
|
||||||
|
|
||||||
|
### Önizleme (sahip; hiçbir şey yazılmaz) — gövde: indirilen dosyanın içeriği
|
||||||
|
POST {{baseUrl}}/backup/preview
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
< ./kakeibo-yedek.json
|
||||||
|
|
||||||
|
### Geri yükle (sahip): önce geri dönüş noktası, sonra tam değiştirme
|
||||||
|
POST {{baseUrl}}/backup/restore
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
< ./kakeibo-yedek.json
|
||||||
|
|
||||||
|
### Geri al: geri dönüş noktasına dön (bu da yeni bir nokta bırakır)
|
||||||
|
POST {{baseUrl}}/backup/restore-points/{{pointId}}/restore
|
||||||
|
|
||||||
|
# ─────────────────────────── Aylık değerlendirme raporu ───────────────────────────
|
||||||
|
|
||||||
|
### Dönemin raporu (Markdown; açık dönem → ara rapor)
|
||||||
|
GET {{baseUrl}}/periods/{{periodId}}/report.md
|
||||||
|
|
||||||
|
# ─────────────────────────── Hesap ve hane ayarları ───────────────────────────
|
||||||
|
|
||||||
|
### Profil adı
|
||||||
|
PATCH {{baseUrl}}/auth/me
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "name": "Suphi" }
|
||||||
|
|
||||||
|
### Şifre değiştir (diğer cihazlarda oturum kapanır, bu cihaza yeni çerez)
|
||||||
|
POST {{baseUrl}}/auth/me/password
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "current_password": "…", "new_password": "…" }
|
||||||
|
|
||||||
|
### Hane adı + bütçe varsayılanları (sahip; toplam 100)
|
||||||
|
PATCH {{baseUrl}}/household
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "name": "Çakır Hanesi", "budget_split_pct": { "needs": 50, "wants": 30, "culture": 10, "unexpected": 10 }, "budget_suggestion": "avg3" }
|
||||||
|
|
||||||
|
### Üyeyi çıkar (sahip)
|
||||||
|
DELETE {{baseUrl}}/household/members/{{memberId}}
|
||||||
|
|
||||||
|
# ─────────────────────────── Borç takibi ───────────────────────────
|
||||||
|
|
||||||
|
### Özet (son ölçüm, değişim, ilerleme, taksitler, hedefler, 6 aylık yük)
|
||||||
|
GET {{baseUrl}}/debt
|
||||||
|
|
||||||
|
### Yeni ölçüm (kuruş)
|
||||||
|
POST {{baseUrl}}/debt/snapshots
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "date": "2026-11-30", "kmh": 0, "positive_balance": 0, "card_balance": 0, "remaining_installments": 0, "note": "maaş sonrası" }
|
||||||
|
|
||||||
|
### İçe aktarma ön izleme (commit=true → yazar); gövde: docs/BORC_TAKIBI.md biçimi + asOf
|
||||||
|
POST {{baseUrl}}/debt/import?commit=false
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "asOf": "2026-11", "debtSnapshots": [], "installmentPlans": [], "roadmap": [] }
|
||||||
|
|
||||||
|
### Dönemin borcu
|
||||||
|
GET {{baseUrl}}/periods/{{periodId}}/debt
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
//! Bot doğrulaması: Cloudflare Turnstile (kullanıcı kararı 2026-11-14).
|
||||||
|
//!
|
||||||
|
//! Tarayıcı Cloudflare'den tek kullanımlık bir jeton alır; sunucu bunu gizli anahtarla
|
||||||
|
//! `siteverify` uç noktasına sorar. Anahtar tanımlı değilse (geliştirme, testler) kapalıdır.
|
||||||
|
//! Cloudflare'e ulaşılamazsa güvenlik için **reddedilir** (deneme hakkından düşmez).
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
const VERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||||
|
|
||||||
|
pub enum Captcha {
|
||||||
|
Disabled,
|
||||||
|
Turnstile {
|
||||||
|
secret: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
},
|
||||||
|
/// Testler için: ağ olmadan her zaman geçer (`true`) ya da kalır (`false`)
|
||||||
|
Fixed(bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Doğrulama sonucu: `Rejected` deneme hakkından düşer, `Unavailable` düşmez
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CaptchaError {
|
||||||
|
Rejected,
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptchaError {
|
||||||
|
pub fn into_app_error(self) -> AppError {
|
||||||
|
AppError::Captcha(match self {
|
||||||
|
Self::Rejected => {
|
||||||
|
"Robot olmadığını doğrulayamadık. Doğrulama kutusunu yeniden tamamlayıp tekrar dene."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
Self::Unavailable => {
|
||||||
|
"Doğrulama servisine şu an ulaşılamıyor. Birkaç dakika sonra tekrar dene.".into()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct VerifyResponse {
|
||||||
|
success: bool,
|
||||||
|
#[serde(default, rename = "error-codes")]
|
||||||
|
error_codes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Captcha {
|
||||||
|
pub fn from_secret(secret: Option<&str>) -> Self {
|
||||||
|
match secret {
|
||||||
|
Some(s) => Self::Turnstile {
|
||||||
|
secret: s.to_string(),
|
||||||
|
client: reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(8))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
None => Self::Disabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enabled(&self) -> bool {
|
||||||
|
!matches!(self, Self::Disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify(&self, token: Option<&str>, ip: &str) -> Result<(), CaptchaError> {
|
||||||
|
let token = token.map(str::trim).filter(|t| !t.is_empty());
|
||||||
|
match self {
|
||||||
|
Self::Disabled => Ok(()),
|
||||||
|
Self::Fixed(ok) => {
|
||||||
|
if *ok && token.is_some() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(CaptchaError::Rejected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::Turnstile { secret, client } => {
|
||||||
|
// Jeton en fazla 2048 karakter (Cloudflare belgesi)
|
||||||
|
let Some(token) = token.filter(|t| t.len() <= 2048) else {
|
||||||
|
return Err(CaptchaError::Rejected);
|
||||||
|
};
|
||||||
|
let mut form = vec![("secret", secret.as_str()), ("response", token)];
|
||||||
|
if ip.parse::<std::net::IpAddr>().is_ok() {
|
||||||
|
form.push(("remoteip", ip));
|
||||||
|
}
|
||||||
|
let res = client.post(VERIFY_URL).form(&form).send().await;
|
||||||
|
let body: VerifyResponse = match res {
|
||||||
|
Ok(r) => r.json().await.map_err(|e| {
|
||||||
|
tracing::error!("Turnstile yanıtı okunamadı: {e}");
|
||||||
|
CaptchaError::Unavailable
|
||||||
|
})?,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Turnstile'a ulaşılamadı: {e}");
|
||||||
|
return Err(CaptchaError::Unavailable);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if body.success {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
tracing::warn!(ip, codes = ?body.error_codes, "Turnstile doğrulaması başarısız");
|
||||||
|
if body.error_codes.iter().any(|c| c == "internal-error") {
|
||||||
|
Err(CaptchaError::Unavailable)
|
||||||
|
} else {
|
||||||
|
Err(CaptchaError::Rejected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//! İstemcinin IP adresi (deneme kilidi ve bot doğrulaması için).
|
||||||
|
//!
|
||||||
|
//! Canlıda uygulama Dokploy'un Traefik vekilinin arkasındadır: bağlantı adresi vekilinkidir.
|
||||||
|
//! `TRUST_PROXY` açıkken (varsayılan) gerçek adres `X-Forwarded-For`'un **en sağdaki** girdisidir —
|
||||||
|
//! onu vekil ekler; istemcinin kendi yazdığı (sahte) girdiler solda kalır ve dikkate alınmaz.
|
||||||
|
//! Vekil yoksa `TRUST_PROXY=false`: bağlantı adresi kullanılır.
|
||||||
|
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{ConnectInfo, FromRequestParts},
|
||||||
|
http::{HeaderMap, request::Parts},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ClientIp(pub String);
|
||||||
|
|
||||||
|
/// `X-Forwarded-For: sahte, gerçek` → `gerçek`
|
||||||
|
pub fn from_forwarded(headers: &HeaderMap) -> Option<IpAddr> {
|
||||||
|
headers
|
||||||
|
.get_all("x-forwarded-for")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.to_str().ok())
|
||||||
|
.flat_map(|v| v.split(','))
|
||||||
|
.filter_map(|p| p.trim().parse::<IpAddr>().ok())
|
||||||
|
.next_back()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for ClientIp {
|
||||||
|
type Rejection = std::convert::Infallible;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let forwarded = state
|
||||||
|
.config
|
||||||
|
.trust_proxy
|
||||||
|
.then(|| from_forwarded(&parts.headers))
|
||||||
|
.flatten();
|
||||||
|
let socket = parts
|
||||||
|
.extensions
|
||||||
|
.get::<ConnectInfo<SocketAddr>>()
|
||||||
|
.map(|c| c.0.ip());
|
||||||
|
Ok(Self(forwarded.or(socket).map_or_else(
|
||||||
|
|| "bilinmiyor".to_string(),
|
||||||
|
|ip| ip.to_string(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rightmost_forwarded_address_wins() {
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
assert_eq!(from_forwarded(&h), None);
|
||||||
|
h.insert("x-forwarded-for", "6.6.6.6, 203.0.113.7".parse().unwrap());
|
||||||
|
assert_eq!(from_forwarded(&h), Some("203.0.113.7".parse().unwrap()));
|
||||||
|
h.append("x-forwarded-for", "198.51.100.1".parse().unwrap());
|
||||||
|
assert_eq!(from_forwarded(&h), Some("198.51.100.1".parse().unwrap()));
|
||||||
|
let mut bad = HeaderMap::new();
|
||||||
|
bad.insert("x-forwarded-for", "sahte".parse().unwrap());
|
||||||
|
assert_eq!(from_forwarded(&bad), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
//! Giriş / kayıt deneme kilidi (IP başına, kullanıcı kararı 2026-11-14).
|
||||||
|
//!
|
||||||
|
//! Üst üste `max` hatalı deneme (yanlış şifre, geçersiz davet kodu, bot doğrulaması…) →
|
||||||
|
//! o IP için giriş **ve** kayıt `lock` süresince kapanır. Başarılı girişte sayaç sıfırlanır.
|
||||||
|
//! IP'ye göre olması bilinçli: e-postaya göre kilit, adresi bilen birinin hane sahibini
|
||||||
|
//! dışarıda bırakmasına izin verirdi. Bellek içi (tek sunucu); yeniden başlatınca sıfırlanır.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::Mutex,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MAX_FAILURES: u32 = 3;
|
||||||
|
pub const LOCK_FOR: Duration = Duration::from_secs(15 * 60);
|
||||||
|
/// Kilitsiz ve bu kadar süredir dokunulmamış kayıtlar silinir (bellek büyümesin)
|
||||||
|
const FORGET_AFTER: Duration = Duration::from_secs(60 * 60);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Outcome {
|
||||||
|
/// Kilide kalan deneme
|
||||||
|
Remaining(u32),
|
||||||
|
/// Kilitlendi; kalan süre (sn)
|
||||||
|
Locked(u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Entry {
|
||||||
|
failures: u32,
|
||||||
|
locked_until: Option<Instant>,
|
||||||
|
last: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AttemptGuard {
|
||||||
|
map: Mutex<HashMap<String, Entry>>,
|
||||||
|
max: u32,
|
||||||
|
lock: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AttemptGuard {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(MAX_FAILURES, LOCK_FOR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AttemptGuard {
|
||||||
|
pub fn new(max: u32, lock: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
map: Mutex::new(HashMap::new()),
|
||||||
|
max,
|
||||||
|
lock,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kilitliyse kalan süre (sn, en az 1)
|
||||||
|
pub fn locked_for(&self, key: &str) -> Option<u64> {
|
||||||
|
let map = self.map.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
map.get(key)
|
||||||
|
.and_then(|e| e.locked_until)
|
||||||
|
.and_then(|until| until.checked_duration_since(Instant::now()))
|
||||||
|
.map(|left| left.as_secs().max(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn failure(&self, key: &str) -> Outcome {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut map = self.map.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
map.retain(|_, e| {
|
||||||
|
e.locked_until.is_some_and(|u| u > now) || now.duration_since(e.last) < FORGET_AFTER
|
||||||
|
});
|
||||||
|
let e = map.entry(key.to_string()).or_insert(Entry {
|
||||||
|
failures: 0,
|
||||||
|
locked_until: None,
|
||||||
|
last: now,
|
||||||
|
});
|
||||||
|
// Süresi biten kilit: sayaç baştan
|
||||||
|
if e.locked_until.is_some_and(|u| u <= now) {
|
||||||
|
e.locked_until = None;
|
||||||
|
e.failures = 0;
|
||||||
|
}
|
||||||
|
e.failures += 1;
|
||||||
|
e.last = now;
|
||||||
|
if e.failures >= self.max {
|
||||||
|
e.locked_until = Some(now + self.lock);
|
||||||
|
e.failures = 0;
|
||||||
|
Outcome::Locked(self.lock.as_secs())
|
||||||
|
} else {
|
||||||
|
Outcome::Remaining(self.max - e.failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn success(&self, key: &str) {
|
||||||
|
self.map
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn three_consecutive_failures_lock() {
|
||||||
|
let g = AttemptGuard::default();
|
||||||
|
assert_eq!(g.failure("1.2.3.4"), Outcome::Remaining(2));
|
||||||
|
assert_eq!(g.failure("1.2.3.4"), Outcome::Remaining(1));
|
||||||
|
assert!(g.locked_for("1.2.3.4").is_none());
|
||||||
|
assert_eq!(g.failure("1.2.3.4"), Outcome::Locked(900));
|
||||||
|
assert!(g.locked_for("1.2.3.4").is_some_and(|s| s > 890));
|
||||||
|
assert!(g.locked_for("5.6.7.8").is_none(), "başka IP etkilenmez");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn success_resets_and_lock_expires() {
|
||||||
|
let g = AttemptGuard::new(3, Duration::from_millis(50));
|
||||||
|
g.failure("ip");
|
||||||
|
g.failure("ip");
|
||||||
|
g.success("ip");
|
||||||
|
assert_eq!(
|
||||||
|
g.failure("ip"),
|
||||||
|
Outcome::Remaining(2),
|
||||||
|
"başarı sayacı sıfırlar"
|
||||||
|
);
|
||||||
|
g.failure("ip");
|
||||||
|
assert!(matches!(g.failure("ip"), Outcome::Locked(_)));
|
||||||
|
std::thread::sleep(Duration::from_millis(60));
|
||||||
|
assert!(g.locked_for("ip").is_none(), "süre dolunca açılır");
|
||||||
|
assert_eq!(g.failure("ip"), Outcome::Remaining(2), "yeni tur");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//! Kimlik doğrulama: şifre hash'leme, JWT, oturum cookie'si, deneme kilidi (IP), bot doğrulaması.
|
||||||
|
|
||||||
|
pub mod captcha;
|
||||||
|
pub mod client_ip;
|
||||||
|
pub mod guard;
|
||||||
|
pub mod jwt;
|
||||||
|
pub mod limiter;
|
||||||
|
pub mod password;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
pub use session::AuthUser;
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
//! 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),
|
||||||
|
//! 3. jeton şifre değişikliğinden önce verilmişse reddeder (`sessions_valid_from`).
|
||||||
|
//!
|
||||||
|
//! Herhangi biri başarısızsa route hiç çalışmaz, 401 döner.
|
||||||
|
//! **Hane izolasyonu:** route'lar hane kimliğini her zaman `AuthUser.household_id`'den alır,
|
||||||
|
//! 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,
|
||||||
|
/// Şifre değişince "şimdi": bundan önce verilmiş jetonlar geçersiz
|
||||||
|
#[serde(default)]
|
||||||
|
sessions_valid_from: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "sessions_valid_from": 1 })
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
if current
|
||||||
|
.sessions_valid_from
|
||||||
|
.is_some_and(|from| (claims.iat as i64) < from)
|
||||||
|
{
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
user_id,
|
||||||
|
household_id,
|
||||||
|
role: current.role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//! Ortam değişkenlerinden uygulama ayarlarını okur.
|
||||||
|
|
||||||
|
use std::{env, path::PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub mongodb_uri: String,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
pub app_url: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub registration: RegistrationMode,
|
||||||
|
/// Derlenmiş frontend klasörü (`STATIC_DIR`). Tanımlıysa Axum arayüzü de sunar
|
||||||
|
/// (canlıda tek servis). Geliştirmede boş: arayüzü Vite sunar.
|
||||||
|
pub static_dir: Option<PathBuf>,
|
||||||
|
/// Otomatik kur yenileme aralığı (dakika, `RATES_REFRESH_MINUTES`, varsayılan 60); 0 → kapalı
|
||||||
|
pub rates_refresh_minutes: u64,
|
||||||
|
/// Cloudflare Turnstile (`TURNSTILE_SITE_KEY` + `TURNSTILE_SECRET_KEY`); ikisi de yoksa kapalı
|
||||||
|
pub turnstile_site_key: Option<String>,
|
||||||
|
pub turnstile_secret_key: Option<String>,
|
||||||
|
/// Ters vekil arkasında (Dokploy/Traefik) gerçek IP `X-Forwarded-For`'dan (`TRUST_PROXY`, varsayılan açık)
|
||||||
|
pub trust_proxy: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RegistrationMode {
|
||||||
|
/// Varsayılan. Sistemde hiç hane yokken ilk kayıt yeni hane kurar;
|
||||||
|
/// sonrasında yalnızca davet koduyla katılınır.
|
||||||
|
InviteOnly,
|
||||||
|
/// Herkes yeni hane kurabilir.
|
||||||
|
Open,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("{0} ortam değişkeni tanımlı değil (.env dosyasını kontrol edin)")]
|
||||||
|
Missing(&'static str),
|
||||||
|
#[error("{0} geçersiz: {1}")]
|
||||||
|
Invalid(&'static str, &'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
let mongodb_uri = required("MONGODB_URI")?;
|
||||||
|
|
||||||
|
let jwt_secret = required("JWT_SECRET")?;
|
||||||
|
if jwt_secret.len() < 32 {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"JWT_SECRET",
|
||||||
|
"en az 32 karakter olmalı (openssl rand -hex 32)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_url = env::var("APP_URL").unwrap_or_else(|_| "http://localhost:5173".into());
|
||||||
|
|
||||||
|
let registration = match env::var("REGISTRATION").as_deref() {
|
||||||
|
Err(_) | Ok("") | Ok("invite_only") => RegistrationMode::InviteOnly,
|
||||||
|
Ok("open") => RegistrationMode::Open,
|
||||||
|
Ok(_) => {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"REGISTRATION",
|
||||||
|
"\"invite_only\" veya \"open\" olmalı",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let static_dir = env::var("STATIC_DIR")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
.map(PathBuf::from);
|
||||||
|
|
||||||
|
let port = match env::var("PORT") {
|
||||||
|
Ok(p) => p
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| ConfigError::Invalid("PORT", "sayı olmalı"))?,
|
||||||
|
Err(_) => 8080,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rates_refresh_minutes = match env::var("RATES_REFRESH_MINUTES") {
|
||||||
|
Ok(m) if !m.trim().is_empty() => m.trim().parse().map_err(|_| {
|
||||||
|
ConfigError::Invalid("RATES_REFRESH_MINUTES", "dakika (sayı) olmalı")
|
||||||
|
})?,
|
||||||
|
_ => 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
let opt = |k: &str| {
|
||||||
|
env::var(k)
|
||||||
|
.ok()
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
};
|
||||||
|
let turnstile_site_key = opt("TURNSTILE_SITE_KEY");
|
||||||
|
let turnstile_secret_key = opt("TURNSTILE_SECRET_KEY");
|
||||||
|
if turnstile_site_key.is_some() != turnstile_secret_key.is_some() {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY",
|
||||||
|
"ikisi birlikte tanımlanmalı (ya da ikisi de boş)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let trust_proxy = match env::var("TRUST_PROXY").as_deref().map(str::trim) {
|
||||||
|
Err(_) | Ok("") | Ok("true") | Ok("1") => true,
|
||||||
|
Ok("false") | Ok("0") => false,
|
||||||
|
Ok(_) => {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"TRUST_PROXY",
|
||||||
|
"\"true\" veya \"false\" olmalı",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
mongodb_uri,
|
||||||
|
jwt_secret,
|
||||||
|
app_url,
|
||||||
|
port,
|
||||||
|
registration,
|
||||||
|
static_dir,
|
||||||
|
rates_refresh_minutes,
|
||||||
|
turnstile_site_key,
|
||||||
|
turnstile_secret_key,
|
||||||
|
trust_proxy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cookie'lerde `Secure` bayrağı yalnızca HTTPS'te açılır.
|
||||||
|
pub fn is_https(&self) -> bool {
|
||||||
|
self.app_url.starts_with("https://")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required(key: &'static str) -> Result<String, ConfigError> {
|
||||||
|
env::var(key)
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
.ok_or(ConfigError::Missing(key))
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! İndeksler (DATA_MODEL.md §4). Açılışta her seferinde çağrılır;
|
||||||
|
//! MongoDB zaten var olan aynı indeksi yeniden oluşturmaz.
|
||||||
|
|
||||||
|
use mongodb::{
|
||||||
|
Database, IndexModel,
|
||||||
|
bson::{Document, doc},
|
||||||
|
error::Result,
|
||||||
|
options::IndexOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::col;
|
||||||
|
|
||||||
|
pub async fn ensure(db: &Database) -> Result<()> {
|
||||||
|
for (collection, indexes) in definitions() {
|
||||||
|
db.collection::<Document>(collection)
|
||||||
|
.create_indexes(indexes)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tracing::info!("İndeksler hazır");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn idx(keys: Document) -> IndexModel {
|
||||||
|
IndexModel::builder().keys(keys).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn idx_opts(keys: Document, options: IndexOptions) -> IndexModel {
|
||||||
|
IndexModel::builder().keys(keys).options(options).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique() -> IndexOptions {
|
||||||
|
IndexOptions::builder().unique(true).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
|
||||||
|
let hp = || idx(doc! { "household_id": 1, "period_id": 1 });
|
||||||
|
|
||||||
|
let mut defs = vec![
|
||||||
|
(col::USERS, vec![idx_opts(doc! { "email": 1 }, unique())]),
|
||||||
|
(col::INVITES, vec![idx_opts(doc! { "code": 1 }, unique())]),
|
||||||
|
(
|
||||||
|
col::PERIODS,
|
||||||
|
vec![
|
||||||
|
idx(doc! { "household_id": 1, "start_date": 1 }),
|
||||||
|
// Hanede en fazla bir açık dönem
|
||||||
|
idx_opts(
|
||||||
|
doc! { "household_id": 1 },
|
||||||
|
IndexOptions::builder()
|
||||||
|
.name("one_open_period_per_household".to_string())
|
||||||
|
.unique(true)
|
||||||
|
.partial_filter_expression(doc! { "status": "open" })
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
col::CATEGORIES,
|
||||||
|
vec![idx(doc! { "household_id": 1, "sort_order": 1 })],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
col::PAYMENT_METHODS,
|
||||||
|
vec![idx(doc! { "household_id": 1, "sort_order": 1 })],
|
||||||
|
),
|
||||||
|
(col::PLAN_ITEMS, vec![hp()]),
|
||||||
|
(col::BUDGETS, vec![hp()]),
|
||||||
|
(col::NOTES, vec![hp()]),
|
||||||
|
(
|
||||||
|
col::ENTRIES,
|
||||||
|
vec![
|
||||||
|
idx(doc! { "household_id": 1, "period_id": 1, "date": 1 }),
|
||||||
|
idx(doc! { "household_id": 1, "date": 1 }),
|
||||||
|
// Ekstre içe aktarmada tekrar tespiti
|
||||||
|
idx(doc! { "household_id": 1, "import_key": 1 }),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
col::CARRYOVERS,
|
||||||
|
vec![idx(doc! { "household_id": 1, "to_period_id": 1 })],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
col::ASSETS,
|
||||||
|
vec![idx(doc! { "household_id": 1, "sort_order": 1 })],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
col::ASSET_TRANSACTIONS,
|
||||||
|
vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
col::IMPORT_RULES,
|
||||||
|
vec![idx(doc! { "household_id": 1, "pattern": 1 })],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
col::DEBT_SNAPSHOTS,
|
||||||
|
vec![idx(doc! { "household_id": 1, "date": -1 })],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
col::INSTALLMENT_PLANS,
|
||||||
|
vec![idx(doc! { "household_id": 1, "first_month": 1 })],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
col::DEBT_TARGETS,
|
||||||
|
vec![idx(doc! { "household_id": 1, "month": 1 })],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
col::RESTORE_POINTS,
|
||||||
|
vec![idx(doc! { "household_id": 1, "created_at": -1 })],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
col::EXCHANGE_RATES,
|
||||||
|
vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Senkronizasyon (Faz 11): "şu tarihten sonra değişenler" sorgusu.
|
||||||
|
for &name in col::HOUSEHOLD_SCOPED {
|
||||||
|
let sync = idx(doc! { "household_id": 1, "updated_at": 1 });
|
||||||
|
match defs.iter_mut().find(|(c, _)| *c == name) {
|
||||||
|
Some((_, list)) => list.push(sync),
|
||||||
|
None => defs.push((name, vec![sync])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_household_collection_has_sync_index() {
|
||||||
|
let defs = definitions();
|
||||||
|
for &name in col::HOUSEHOLD_SCOPED {
|
||||||
|
let (_, list) = defs.iter().find(|(c, _)| *c == name).unwrap();
|
||||||
|
assert!(
|
||||||
|
list.iter()
|
||||||
|
.any(|i| i.keys == doc! { "household_id": 1, "updated_at": 1 }),
|
||||||
|
"{name} koleksiyonunda senkronizasyon indeksi yok"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
//! Basit şema migration mekanizması (DATA_MODEL.md §3.15).
|
||||||
|
//!
|
||||||
|
//! Her migration bir kez çalışır; `schema_migrations` koleksiyonuna
|
||||||
|
//! `{ _id: sürüm, name, applied_at }` olarak kaydedilir.
|
||||||
|
//! Yeni migration eklemek: `MIGRATIONS` listesine bir sonraki sürüm numarasıyla ekle.
|
||||||
|
|
||||||
|
use std::{future::Future, pin::Pin};
|
||||||
|
|
||||||
|
use mongodb::{
|
||||||
|
Database,
|
||||||
|
bson::{DateTime, Document, doc},
|
||||||
|
error::Result,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::col;
|
||||||
|
|
||||||
|
type MigrationFn =
|
||||||
|
for<'a> fn(&'a Database) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
|
||||||
|
|
||||||
|
struct Migration {
|
||||||
|
version: i32,
|
||||||
|
name: &'static str,
|
||||||
|
run: MigrationFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar.
|
||||||
|
const MIGRATIONS: &[Migration] = &[
|
||||||
|
Migration {
|
||||||
|
version: 1,
|
||||||
|
name: "initial",
|
||||||
|
run: |db| Box::pin(m001_initial(db)),
|
||||||
|
},
|
||||||
|
Migration {
|
||||||
|
version: 2,
|
||||||
|
name: "default_assets",
|
||||||
|
run: |db| Box::pin(m002_default_assets(db)),
|
||||||
|
},
|
||||||
|
Migration {
|
||||||
|
version: 3,
|
||||||
|
name: "restore_points",
|
||||||
|
run: |db| Box::pin(m003_restore_points(db)),
|
||||||
|
},
|
||||||
|
Migration {
|
||||||
|
version: 4,
|
||||||
|
name: "debt_tracking",
|
||||||
|
run: |db| Box::pin(m004_debt_tracking(db)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// En son şema sürümü (JSON yedeğine yazılır; daha yeni yedek reddedilir).
|
||||||
|
pub fn latest_version() -> i32 {
|
||||||
|
MIGRATIONS.last().map_or(0, |m| m.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uygulanmamış migration'ları sırayla çalıştırır.
|
||||||
|
pub async fn run(db: &Database) -> Result<()> {
|
||||||
|
let coll = db.collection::<Document>(col::SCHEMA_MIGRATIONS);
|
||||||
|
|
||||||
|
for m in MIGRATIONS {
|
||||||
|
if coll.find_one(doc! { "_id": m.version }).await?.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tracing::info!("Migration çalışıyor: v{} ({})", m.version, m.name);
|
||||||
|
(m.run)(db).await?;
|
||||||
|
coll.insert_one(doc! {
|
||||||
|
"_id": m.version,
|
||||||
|
"name": m.name,
|
||||||
|
"applied_at": DateTime::now(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v1: Tüm koleksiyonları açıkça oluşturur.
|
||||||
|
/// (Transaction içinde ilk kez yazılan koleksiyonlar sorun çıkarmasın diye önceden var olsunlar.)
|
||||||
|
async fn m001_initial(db: &Database) -> Result<()> {
|
||||||
|
let existing = db.list_collection_names().await?;
|
||||||
|
let all = col::HOUSEHOLD_SCOPED
|
||||||
|
.iter()
|
||||||
|
.chain(&[col::EXCHANGE_RATES, col::SCHEMA_MIGRATIONS]);
|
||||||
|
|
||||||
|
for name in all {
|
||||||
|
if !existing.iter().any(|e| e == name) {
|
||||||
|
db.create_collection(*name).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v2: Faz 8'den önce açılmış hanelere varsayılan varlıklar (TL, altınlar, USD, EUR).
|
||||||
|
/// Varlığı olan haneye dokunmaz → tekrar çalışsa da çift kayıt olmaz.
|
||||||
|
async fn m002_default_assets(db: &Database) -> Result<()> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Household {
|
||||||
|
#[serde(rename = "_id")]
|
||||||
|
id: mongodb::bson::Uuid,
|
||||||
|
}
|
||||||
|
let assets = db.collection::<Document>(col::ASSETS);
|
||||||
|
let mut cursor = db
|
||||||
|
.collection::<Household>(col::HOUSEHOLDS)
|
||||||
|
.find(doc! { "deleted_at": null })
|
||||||
|
.projection(doc! { "_id": 1 })
|
||||||
|
.await?;
|
||||||
|
while cursor.advance().await? {
|
||||||
|
let hid = cursor.deserialize_current()?.id;
|
||||||
|
if assets
|
||||||
|
.find_one(doc! { "household_id": hid })
|
||||||
|
.await?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
assets
|
||||||
|
.insert_many(crate::services::seed::assets(hid))
|
||||||
|
.await?;
|
||||||
|
tracing::info!("Varsayılan varlıklar eklendi: hane {hid}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v3: geri dönüş noktaları koleksiyonu (transaction içinde ilk yazımdan önce var olmalı).
|
||||||
|
async fn m003_restore_points(db: &Database) -> Result<()> {
|
||||||
|
if !db
|
||||||
|
.list_collection_names()
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.any(|n| n == col::RESTORE_POINTS)
|
||||||
|
{
|
||||||
|
db.create_collection(col::RESTORE_POINTS).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v4: borç takibi koleksiyonları (transaction içinde ilk yazımdan önce var olmalı).
|
||||||
|
async fn m004_debt_tracking(db: &Database) -> Result<()> {
|
||||||
|
let existing = db.list_collection_names().await?;
|
||||||
|
for name in [
|
||||||
|
col::DEBT_SNAPSHOTS,
|
||||||
|
col::INSTALLMENT_PLANS,
|
||||||
|
col::DEBT_TARGETS,
|
||||||
|
] {
|
||||||
|
if !existing.iter().any(|n| n == name) {
|
||||||
|
db.create_collection(name).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn versions_start_at_1_and_are_sequential() {
|
||||||
|
for (i, m) in MIGRATIONS.iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
m.version,
|
||||||
|
i as i32 + 1,
|
||||||
|
"migration sürümleri ardışık olmalı"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
//! MongoDB bağlantısı, koleksiyon adları, migration ve indeksler.
|
||||||
|
|
||||||
|
pub mod indexes;
|
||||||
|
pub mod migrations;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use mongodb::{Client, Database, bson::doc, options::ClientOptions};
|
||||||
|
|
||||||
|
/// URI'de veritabanı adı yoksa kullanılacak ad.
|
||||||
|
const DEFAULT_DB_NAME: &str = "kakeibo";
|
||||||
|
|
||||||
|
/// Bağlantıyı kurar ve bir `ping` ile sunucunun gerçekten erişilebilir olduğunu doğrular.
|
||||||
|
pub async fn connect(uri: &str) -> mongodb::error::Result<Database> {
|
||||||
|
let mut options = ClientOptions::parse(uri).await?;
|
||||||
|
options.app_name = Some("kakeibo".into());
|
||||||
|
// Sunucu yoksa 30 sn yerine 5 sn içinde hata ver.
|
||||||
|
options.server_selection_timeout = Some(Duration::from_secs(5));
|
||||||
|
|
||||||
|
let db_name = options
|
||||||
|
.default_database
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| DEFAULT_DB_NAME.into());
|
||||||
|
|
||||||
|
let client = Client::with_options(options)?;
|
||||||
|
let db = client.database(&db_name);
|
||||||
|
ping(&db).await?;
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ping(db: &Database) -> mongodb::error::Result<()> {
|
||||||
|
db.run_command(doc! { "ping": 1 }).await.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Benzersiz indeks ihlali mi? (ör. aynı e-postayla ikinci kayıt — hata kodu 11000)
|
||||||
|
pub fn is_duplicate_key(e: &mongodb::error::Error) -> bool {
|
||||||
|
use mongodb::error::{ErrorKind, WriteFailure};
|
||||||
|
match e.kind.as_ref() {
|
||||||
|
ErrorKind::Write(WriteFailure::WriteError(we)) => we.code == 11000,
|
||||||
|
ErrorKind::Command(ce) => ce.code == 11000,
|
||||||
|
ErrorKind::InsertMany(ime) => ime
|
||||||
|
.write_errors
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|errs| errs.iter().any(|we| we.code == 11000)),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Açılışta çalışır: bekleyen migration'lar, ardından indeksler.
|
||||||
|
/// İkisi de tekrar çalıştırılabilir (idempotent); zaten yapılmış olanı atlar.
|
||||||
|
pub async fn prepare(db: &Database) -> mongodb::error::Result<()> {
|
||||||
|
migrations::run(db).await?;
|
||||||
|
indexes::ensure(db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Koleksiyon adları (DATA_MODEL.md §3). Yazım hatasını derleyici yakalasın diye sabit.
|
||||||
|
pub mod col {
|
||||||
|
pub const USERS: &str = "users";
|
||||||
|
pub const HOUSEHOLDS: &str = "households";
|
||||||
|
pub const INVITES: &str = "invites";
|
||||||
|
pub const PERIODS: &str = "periods";
|
||||||
|
pub const CATEGORIES: &str = "categories";
|
||||||
|
pub const PAYMENT_METHODS: &str = "payment_methods";
|
||||||
|
pub const PLAN_ITEMS: &str = "plan_items";
|
||||||
|
pub const BUDGETS: &str = "budgets";
|
||||||
|
pub const ENTRIES: &str = "entries";
|
||||||
|
pub const CARRYOVERS: &str = "carryovers";
|
||||||
|
pub const NOTES: &str = "notes";
|
||||||
|
pub const ASSETS: &str = "assets";
|
||||||
|
pub const ASSET_TRANSACTIONS: &str = "asset_transactions";
|
||||||
|
pub const EXCHANGE_RATES: &str = "exchange_rates";
|
||||||
|
/// Ekstre içe aktarmada öğrenilen açıklama → tür/kategori kuralları
|
||||||
|
pub const IMPORT_RULES: &str = "import_rules";
|
||||||
|
pub const SCHEMA_MIGRATIONS: &str = "schema_migrations";
|
||||||
|
/// JSON geri yüklemeden önce alınan, sıkıştırılmış hane kopyaları ("geri al")
|
||||||
|
pub const RESTORE_POINTS: &str = "restore_points";
|
||||||
|
/// Banka borcu ölçümleri (KMH, kart, kalan taksit, artı bakiye)
|
||||||
|
pub const DEBT_SNAPSHOTS: &str = "debt_snapshots";
|
||||||
|
/// Taksit planları (ilk taksit ayı + adet → aylık yük kendiliğinden)
|
||||||
|
pub const INSTALLMENT_PLANS: &str = "installment_plans";
|
||||||
|
/// Borç yol haritası: ay sonu toplam borç hedefleri
|
||||||
|
pub const DEBT_TARGETS: &str = "debt_targets";
|
||||||
|
|
||||||
|
/// Hane verisi taşıyan koleksiyonlar (`household_id` alanı var).
|
||||||
|
pub const HOUSEHOLD_SCOPED: &[&str] = &[
|
||||||
|
USERS,
|
||||||
|
HOUSEHOLDS,
|
||||||
|
INVITES,
|
||||||
|
PERIODS,
|
||||||
|
CATEGORIES,
|
||||||
|
PAYMENT_METHODS,
|
||||||
|
PLAN_ITEMS,
|
||||||
|
BUDGETS,
|
||||||
|
ENTRIES,
|
||||||
|
CARRYOVERS,
|
||||||
|
NOTES,
|
||||||
|
ASSETS,
|
||||||
|
ASSET_TRANSACTIONS,
|
||||||
|
IMPORT_RULES,
|
||||||
|
DEBT_SNAPSHOTS,
|
||||||
|
INSTALLMENT_PLANS,
|
||||||
|
DEBT_TARGETS,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//! Uygulama genelindeki hata türü.
|
||||||
|
//! Kullanıcıya Türkçe ve sade mesaj döner; teknik detay yalnızca loga yazılır.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
/// Tüm hata yanıtlarının gövdesi: `{ "error": { "code": "...", "message": "..." } }`
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct ApiErrorBody {
|
||||||
|
pub error: ApiErrorDetail,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct ApiErrorDetail {
|
||||||
|
/// Makine için: `unauthorized`, `bad_request`, `conflict`, `not_found`, ...
|
||||||
|
pub code: String,
|
||||||
|
/// Kullanıcıya gösterilecek Türkçe mesaj
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("Kayıt bulunamadı")]
|
||||||
|
NotFound,
|
||||||
|
#[error("{0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
#[error("Oturum açmanız gerekiyor")]
|
||||||
|
Unauthorized,
|
||||||
|
#[error("E-posta veya şifre hatalı")]
|
||||||
|
InvalidCredentials,
|
||||||
|
/// Hatalı giriş; kilide kalan deneme sayısı mesajda
|
||||||
|
#[error("E-posta veya şifre hatalı. Kalan deneme hakkı: {0}")]
|
||||||
|
InvalidCredentialsLeft(u32),
|
||||||
|
/// IP kilidi (üst üste hatalı deneme); saniye cinsinden kalan süre
|
||||||
|
#[error("Çok fazla hatalı deneme yapıldı. Güvenlik için giriş ve kayıt {} dakika kapalı.", .0.div_ceil(60))]
|
||||||
|
Locked(u64),
|
||||||
|
/// Bot doğrulaması (Turnstile) geçilemedi
|
||||||
|
#[error("{0}")]
|
||||||
|
Captcha(String),
|
||||||
|
#[error("Bu işlem için yetkiniz yok")]
|
||||||
|
Forbidden,
|
||||||
|
#[error("Yeni hane kaydı kapalı. Katılmak için hane sahibinden davet bağlantısı isteyin.")]
|
||||||
|
RegistrationClosed,
|
||||||
|
#[error("{0}")]
|
||||||
|
Conflict(String),
|
||||||
|
#[error("Çok fazla deneme yapıldı, lütfen biraz sonra tekrar deneyin")]
|
||||||
|
TooManyRequests,
|
||||||
|
#[error("veritabanı hatası: {0}")]
|
||||||
|
Database(#[from] mongodb::error::Error),
|
||||||
|
#[error("iç hata: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppError {
|
||||||
|
fn status_and_code(&self) -> (StatusCode, &'static str) {
|
||||||
|
match self {
|
||||||
|
Self::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||||
|
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
||||||
|
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||||
|
Self::InvalidCredentials | Self::InvalidCredentialsLeft(_) => {
|
||||||
|
(StatusCode::UNAUTHORIZED, "invalid_credentials")
|
||||||
|
}
|
||||||
|
Self::Locked(_) => (StatusCode::TOO_MANY_REQUESTS, "locked"),
|
||||||
|
Self::Captcha(_) => (StatusCode::BAD_REQUEST, "captcha"),
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||||
|
Self::RegistrationClosed => (StatusCode::FORBIDDEN, "registration_closed"),
|
||||||
|
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||||
|
Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||||
|
Self::Database(_) | Self::Internal(_) => {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, code) = self.status_and_code();
|
||||||
|
let retry_after = match &self {
|
||||||
|
Self::Locked(secs) => Some(*secs),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = if status.is_server_error() {
|
||||||
|
// Teknik detay sadece logda; kullanıcıya genel mesaj.
|
||||||
|
tracing::error!(error = %self, "sunucu hatası");
|
||||||
|
"Beklenmeyen bir hata oluştu, lütfen tekrar deneyin".to_string()
|
||||||
|
} else {
|
||||||
|
self.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = ApiErrorBody {
|
||||||
|
error: ApiErrorDetail {
|
||||||
|
code: code.into(),
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut res = (status, Json(body)).into_response();
|
||||||
|
if let Some(secs) = retry_after {
|
||||||
|
res.headers_mut()
|
||||||
|
.insert(axum::http::header::RETRY_AFTER, secs.into());
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Doğrulama hatası → ilk alanın Türkçe mesajıyla 400.
|
||||||
|
impl From<validator::ValidationErrors> for AppError {
|
||||||
|
fn from(errors: validator::ValidationErrors) -> Self {
|
||||||
|
let message = errors
|
||||||
|
.field_errors()
|
||||||
|
.values()
|
||||||
|
.flat_map(|list| list.iter())
|
||||||
|
.find_map(|e| e.message.as_ref().map(|m| m.to_string()))
|
||||||
|
.unwrap_or_else(|| "Girilen bilgiler geçersiz".into());
|
||||||
|
AppError::BadRequest(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route fonksiyonlarında kısa dönüş tipi: `AppResult<Json<T>>`
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
+94
-16
@@ -1,11 +1,10 @@
|
|||||||
use std::net::SocketAddr;
|
use std::{net::SocketAddr, process::ExitCode};
|
||||||
|
|
||||||
use axum::{Json, Router, routing::get};
|
use kakeibo::{config::Config, db, routes, services, state::AppState};
|
||||||
use serde_json::{Value, json};
|
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> ExitCode {
|
||||||
// .env dosyasını yükle (proje kökünde; üst klasörler de aranır). Yoksa sorun değil.
|
// .env dosyasını yükle (proje kökünde; üst klasörler de aranır). Yoksa sorun değil.
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
@@ -15,22 +14,101 @@ async fn main() {
|
|||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let port: u16 = std::env::var("PORT")
|
let config = match Config::from_env() {
|
||||||
.ok()
|
Ok(c) => c,
|
||||||
.and_then(|p| p.parse().ok())
|
Err(e) => {
|
||||||
.unwrap_or(8080);
|
tracing::error!("Ayar hatası: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let app = Router::new().route("/api/health", get(health));
|
let db = match db::connect(&config.mongodb_uri).await {
|
||||||
|
Ok(db) => {
|
||||||
|
tracing::info!("MongoDB bağlantısı kuruldu (veritabanı: {})", db.name());
|
||||||
|
db
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"MongoDB'ye bağlanılamadı. MONGODB_URI doğru mu, veritabanı çalışıyor mu? \
|
||||||
|
(yerelde: npm run db:up)\nDetay: {e}"
|
||||||
|
);
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = db::prepare(&db).await {
|
||||||
|
tracing::error!("Veritabanı hazırlanamadı (migration/indeks): {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurlar arka planda (açılışta hemen, sonra aralıkla); hata uygulamayı durdurmaz
|
||||||
|
services::rates::spawn_refresher(db.clone(), config.rates_refresh_minutes);
|
||||||
|
|
||||||
|
if config.turnstile_secret_key.is_some() {
|
||||||
|
tracing::info!("Bot doğrulaması (Cloudflare Turnstile) açık");
|
||||||
|
} else if config.is_https() {
|
||||||
|
tracing::warn!(
|
||||||
|
"Bot doğrulaması KAPALI: TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY tanımlayın (docs/DEPLOY.md)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
"İstemci IP'si: {}",
|
||||||
|
if config.trust_proxy {
|
||||||
|
"X-Forwarded-For (vekil arkasında, TRUST_PROXY=true)"
|
||||||
|
} else {
|
||||||
|
"bağlantı adresi (TRUST_PROXY=false)"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let port = config.port;
|
||||||
|
let state = AppState::new(db, config);
|
||||||
|
let app = routes::app(state);
|
||||||
|
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
let listener = tokio::net::TcpListener::bind(addr)
|
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||||
.await
|
Ok(l) => l,
|
||||||
.expect("Port dinlenemedi");
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"{port} portu dinlenemedi (başka bir uygulama kullanıyor olabilir): {e}"
|
||||||
|
);
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
|
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
|
||||||
axum::serve(listener, app).await.expect("Sunucu hatası");
|
// ConnectInfo: vekil yoksa istemci IP'si bağlantı adresinden (deneme kilidi)
|
||||||
|
if let Err(e) = axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!("Sunucu hatası: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sağlık kontrolü. Faz 1'de veritabanı durumu da eklenecek.
|
/// Docker/Dokploy durdururken (SIGTERM) veya Ctrl+C'de: yeni istek almayı bırak,
|
||||||
async fn health() -> Json<Value> {
|
/// süren istekleri bitir, sonra kapan (yarım kalan yazma olmasın).
|
||||||
Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") }))
|
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…");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! Tüm koleksiyonlarda ortak alanlar için yardımcılar (DATA_MODEL.md §1.1).
|
||||||
|
//!
|
||||||
|
//! Her kayıt şu alanları taşır:
|
||||||
|
//! `_id` (UUID v7), `household_id`, `created_at`, `updated_at`, `deleted_at`, `version`
|
||||||
|
|
||||||
|
use mongodb::bson::{DateTime, Document, Uuid, doc};
|
||||||
|
|
||||||
|
/// Yeni UUID v7 üretir. İlk 48 bit zaman damgası olduğu için
|
||||||
|
/// sonradan üretilen kimlik her zaman daha büyüktür (zamana göre sıralanabilir).
|
||||||
|
pub fn new_id() -> Uuid {
|
||||||
|
Uuid::from_bytes(uuid::Uuid::now_v7().into_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Şu anki zaman (UTC, BSON DateTime).
|
||||||
|
pub fn now() -> DateTime {
|
||||||
|
DateTime::now()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yeni kayıt için ortak alanlar. Modele özgü alanlarla birleştirilir:
|
||||||
|
/// `let mut d = new_meta(hid); d.insert("name", "Market");`
|
||||||
|
pub fn new_meta(household_id: Uuid) -> Document {
|
||||||
|
let ts = now();
|
||||||
|
doc! {
|
||||||
|
"_id": new_id(),
|
||||||
|
"household_id": household_id,
|
||||||
|
"created_at": ts,
|
||||||
|
"updated_at": ts,
|
||||||
|
"deleted_at": null,
|
||||||
|
"version": 1_i32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hane kapsamlı, silinmemiş kayıtlar için temel filtre.
|
||||||
|
/// Tüm okuma sorguları bununla başlar → başka hanenin verisi asla dönmez.
|
||||||
|
pub fn scope(household_id: Uuid) -> Document {
|
||||||
|
doc! { "household_id": household_id, "deleted_at": null }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_id_is_v7_and_sortable() {
|
||||||
|
let a = uuid::Uuid::from_bytes(new_id().bytes());
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
|
let b = uuid::Uuid::from_bytes(new_id().bytes());
|
||||||
|
|
||||||
|
assert_eq!(a.get_version_num(), 7);
|
||||||
|
assert!(b > a, "sonra üretilen UUID daha büyük olmalı");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_always_filters_household_and_deleted() {
|
||||||
|
let hid = new_id();
|
||||||
|
let f = scope(hid);
|
||||||
|
assert_eq!(f.get("household_id"), Some(&hid.into()));
|
||||||
|
assert!(f.contains_key("deleted_at"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! 4 sabit ana grup (DATA_MODEL.md §2). Değiştirilemez, silinemez; veritabanında değil kodda.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[ts(export)]
|
||||||
|
pub enum Group {
|
||||||
|
/// 🏠 Zorunlu
|
||||||
|
Needs,
|
||||||
|
/// 🛍️ İstek
|
||||||
|
Wants,
|
||||||
|
/// 📚 Kültürel
|
||||||
|
Culture,
|
||||||
|
/// ⚡ Beklenmeyen
|
||||||
|
Unexpected,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Group {
|
||||||
|
pub const ALL: [Group; 4] = [
|
||||||
|
Group::Needs,
|
||||||
|
Group::Wants,
|
||||||
|
Group::Culture,
|
||||||
|
Group::Unexpected,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Group::Needs => "needs",
|
||||||
|
Group::Wants => "wants",
|
||||||
|
Group::Culture => "culture",
|
||||||
|
Group::Unexpected => "unexpected",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Türkçe ad (CSV dışa/içe aktarma; arayüz metinleri `frontend/src/lib/groups.ts`)
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Group::Needs => "Zorunlu",
|
||||||
|
Group::Wants => "İstek",
|
||||||
|
Group::Culture => "Kültürel",
|
||||||
|
Group::Unexpected => "Beklenmeyen",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ekranda gösterim sırası
|
||||||
|
pub fn order(self) -> usize {
|
||||||
|
Self::ALL.iter().position(|g| *g == self).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//! Veritabanı modelleri. Her koleksiyonun yapısı, onu kullanan adımda eklenir.
|
||||||
|
//! Şema: `docs/DATA_MODEL.md`
|
||||||
|
|
||||||
|
pub mod asset;
|
||||||
|
pub mod common;
|
||||||
|
pub mod entry;
|
||||||
|
pub mod group;
|
||||||
|
pub mod household;
|
||||||
|
pub mod invite;
|
||||||
|
pub mod note;
|
||||||
|
pub mod period;
|
||||||
|
pub mod plan_item;
|
||||||
|
pub mod rate;
|
||||||
|
pub mod user;
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! `periods` koleksiyonu (DATA_MODEL.md §3.4).
|
||||||
|
|
||||||
|
use mongodb::bson::{DateTime, Uuid};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[ts(export)]
|
||||||
|
pub enum PeriodStatus {
|
||||||
|
/// Girişler yapılabilir. Hanede en fazla bir tane.
|
||||||
|
Open,
|
||||||
|
/// Salt okunur. Sahip yeniden açarsa `Correcting` olur.
|
||||||
|
Closed,
|
||||||
|
/// Düzeltmede: kapanmış dönem sahip tarafından düzeltme için açıldı.
|
||||||
|
/// Yazılabilir, ama "açık dönem" sayılmaz (yeni dönem açıkken de olabilir).
|
||||||
|
/// "Yeniden kapat" ile devir yeniden hesaplanır.
|
||||||
|
Correcting,
|
||||||
|
/// Geçmiş (arşiv): ekstre içe aktarımıyla yıl başından beri olan aylar için açılır.
|
||||||
|
/// Yalnızca kayıt tutar: plan/bütçe zorunlu değil, kapatılmaz, devir üretmez ve
|
||||||
|
/// devir zincirinde atlanır; bütçe önerilerini ("önceki dönem", "son 3 ay") besler.
|
||||||
|
Archived,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PeriodStatus {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
PeriodStatus::Open => "open",
|
||||||
|
PeriodStatus::Closed => "closed",
|
||||||
|
PeriodStatus::Correcting => "correcting",
|
||||||
|
PeriodStatus::Archived => "archived",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kayıt eklenip değiştirilebilir mi?
|
||||||
|
pub fn is_writable(self) -> bool {
|
||||||
|
self != PeriodStatus::Closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Period {
|
||||||
|
#[serde(rename = "_id")]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
/// `"YYYY-MM-DD"`, dahil
|
||||||
|
pub start_date: String,
|
||||||
|
/// `"YYYY-MM-DD"`, **hariç**
|
||||||
|
pub end_date: String,
|
||||||
|
pub status: PeriodStatus,
|
||||||
|
/// Kuruş
|
||||||
|
pub savings_goal: i64,
|
||||||
|
/// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası)
|
||||||
|
#[serde(default)]
|
||||||
|
pub reflection_md: Option<String>,
|
||||||
|
/// Kapanışta birikime aktarılan tutar (kuruş). Faz 8'de varlık hareketine dönüşecek.
|
||||||
|
#[serde(default)]
|
||||||
|
pub to_savings: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub closed_at: Option<DateTime>,
|
||||||
|
/// Elle girilen ay başı toplam banka borcu (kuruş); yoksa son ölçümden (borç takibi)
|
||||||
|
#[serde(default)]
|
||||||
|
pub opening_debt: Option<i64>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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())))
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
//! Kimlik doğrulama uç noktaları:
|
||||||
|
//! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`,
|
||||||
|
//! `PATCH /api/auth/me` (ad), `POST /api/auth/me/password` (şifre değiştir → diğer oturumlar kapanır),
|
||||||
|
//! `GET /api/auth/guard` (bot doğrulama anahtarı + bu IP'nin kilidi).
|
||||||
|
//!
|
||||||
|
//! **Güvenlik (giriş + kayıt):** Cloudflare Turnstile + IP başına üst üste 3 hatada 15 dk kilit
|
||||||
|
//! (`auth::guard`). Doğrulama hataları (`ValidJson`) sayılmaz; yanlış şifre, geçersiz davet,
|
||||||
|
//! kapalı kayıt, kayıtlı e-posta ve başarısız bot doğrulaması sayılır.
|
||||||
|
|
||||||
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use mongodb::bson::{Uuid, doc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::{
|
||||||
|
AuthUser,
|
||||||
|
captcha::CaptchaError,
|
||||||
|
client_ip::ClientIp,
|
||||||
|
guard::Outcome,
|
||||||
|
password,
|
||||||
|
session::{COOKIE_NAME, 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>,
|
||||||
|
/// Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
|
||||||
|
captcha_token: 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,
|
||||||
|
/// Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
captcha_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct ProfileRequest {
|
||||||
|
#[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))]
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct PasswordChangeRequest {
|
||||||
|
#[validate(length(min = 1, message = "Mevcut şifreyi girin"))]
|
||||||
|
current_password: String,
|
||||||
|
#[validate(length(min = 8, max = 128, message = "Yeni şifre en az 8 karakter olmalı"))]
|
||||||
|
new_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Giriş/kayıt sayfası: bot doğrulaması açık mı, bu IP kilitli mi?
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct AuthGuardInfo {
|
||||||
|
/// Turnstile site anahtarı (herkese açık); `null` → doğrulama kapalı
|
||||||
|
captcha_site_key: Option<String>,
|
||||||
|
/// Kilit varsa kalan süre (sn); yoksa 0
|
||||||
|
#[ts(type = "number")]
|
||||||
|
locked_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Deneme kilidi + bot doğrulaması (giriş ve kayıt ortak) ----------
|
||||||
|
|
||||||
|
/// Kilitliyse 429; değilse bot doğrulaması (başarısızsa hata sayılır)
|
||||||
|
async fn precheck(state: &AppState, ip: &str, token: Option<&str>) -> AppResult<()> {
|
||||||
|
if let Some(secs) = state.guard.locked_for(ip) {
|
||||||
|
return Err(AppError::Locked(secs));
|
||||||
|
}
|
||||||
|
match state.captcha.verify(token, ip).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e @ CaptchaError::Rejected) => {
|
||||||
|
if let Outcome::Locked(secs) = state.guard.failure(ip) {
|
||||||
|
tracing::warn!(ip, "deneme kilidi: bot doğrulaması");
|
||||||
|
return Err(AppError::Locked(secs));
|
||||||
|
}
|
||||||
|
Err(e.into_app_error())
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into_app_error()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hatalı denemeyi say; kilide ulaşıldıysa kilit hatası, değilse kalan hakla birlikte döner
|
||||||
|
fn count_failure(state: &AppState, ip: &str, what: &str) -> Result<u32, AppError> {
|
||||||
|
match state.guard.failure(ip) {
|
||||||
|
Outcome::Remaining(n) => Ok(n),
|
||||||
|
Outcome::Locked(secs) => {
|
||||||
|
tracing::warn!(ip, "deneme kilidi: {what}");
|
||||||
|
Err(AppError::Locked(secs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Uç noktalar ----------
|
||||||
|
|
||||||
|
pub async fn guard_info(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ClientIp(ip): ClientIp,
|
||||||
|
) -> Json<AuthGuardInfo> {
|
||||||
|
Json(AuthGuardInfo {
|
||||||
|
captcha_site_key: state.config.turnstile_site_key.clone(),
|
||||||
|
locked_seconds: state.guard.locked_for(&ip).unwrap_or(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ClientIp(ip): ClientIp,
|
||||||
|
jar: CookieJar,
|
||||||
|
ValidJson(req): ValidJson<RegisterRequest>,
|
||||||
|
) -> AppResult<(StatusCode, CookieJar, Json<MeResponse>)> {
|
||||||
|
precheck(&state, &ip, req.captcha_token.as_deref()).await?;
|
||||||
|
let name = req.name.trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("Ad boş olamaz".into()));
|
||||||
|
}
|
||||||
|
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 = match account::register(
|
||||||
|
&state.db,
|
||||||
|
state.config.registration,
|
||||||
|
NewAccount {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
household_name,
|
||||||
|
invite_code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
// Davet kodu tahmini, kapalı kayıt denemesi, kayıtlı e-posta yoklama → hata sayılır
|
||||||
|
Err(
|
||||||
|
e @ (AppError::RegistrationClosed | AppError::BadRequest(_) | AppError::Conflict(_)),
|
||||||
|
) => {
|
||||||
|
count_failure(&state, &ip, "kayıt")?;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
state.guard.success(&ip);
|
||||||
|
|
||||||
|
let token = state.jwt.issue(
|
||||||
|
created.user_id,
|
||||||
|
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>,
|
||||||
|
ClientIp(ip): ClientIp,
|
||||||
|
jar: CookieJar,
|
||||||
|
ValidJson(req): ValidJson<LoginRequest>,
|
||||||
|
) -> AppResult<(CookieJar, Json<MeResponse>)> {
|
||||||
|
precheck(&state, &ip, req.captcha_token.as_deref()).await?;
|
||||||
|
let email = normalize_email(&req.email);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let left = count_failure(&state, &ip, "giriş")?;
|
||||||
|
return Err(AppError::InvalidCredentialsLeft(left));
|
||||||
|
}
|
||||||
|
// verify() true döndüyse kullanıcı kesin vardır
|
||||||
|
let user = user.ok_or(AppError::InvalidCredentials)?;
|
||||||
|
state.guard.success(&ip);
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
ValidJson(req): ValidJson<ProfileRequest>,
|
||||||
|
) -> AppResult<Json<MeResponse>> {
|
||||||
|
account::update_name(&state.db, auth.household_id, auth.user_id, &req.name).await?;
|
||||||
|
load_me(&state, auth.user_id, auth.household_id)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Şifre değiştir: mevcut şifre doğrulanır (deneme sınırlı), diğer cihazlardaki oturumlar
|
||||||
|
/// kapanır, bu cihaza yeni oturum çerezi verilir ("beni hatırla" durumu korunur).
|
||||||
|
pub async fn change_password(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
auth: AuthUser,
|
||||||
|
ValidJson(req): ValidJson<PasswordChangeRequest>,
|
||||||
|
) -> AppResult<(StatusCode, CookieJar)> {
|
||||||
|
let key = format!("password:{}", auth.user_id);
|
||||||
|
if !state.login_limiter.is_allowed(&key) {
|
||||||
|
return Err(AppError::TooManyRequests);
|
||||||
|
}
|
||||||
|
let remember = jar
|
||||||
|
.get(COOKIE_NAME)
|
||||||
|
.and_then(|c| state.jwt.verify(c.value()))
|
||||||
|
.is_some_and(|c| c.exp.saturating_sub(c.iat) > ttl(false).as_secs());
|
||||||
|
|
||||||
|
match account::change_password(
|
||||||
|
&state.db,
|
||||||
|
auth.household_id,
|
||||||
|
auth.user_id,
|
||||||
|
req.current_password,
|
||||||
|
req.new_password,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => state.login_limiter.reset(&key),
|
||||||
|
Err(e) => {
|
||||||
|
if matches!(&e, AppError::BadRequest(m) if m.contains("Mevcut şifre")) {
|
||||||
|
state.login_limiter.record_failure(&key);
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = state
|
||||||
|
.jwt
|
||||||
|
.issue(auth.user_id, auth.household_id, auth.role, ttl(remember))?;
|
||||||
|
let jar = jar.add(session_cookie(token, remember, state.config.is_https()));
|
||||||
|
Ok((StatusCode::NO_CONTENT, jar))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Yardımcılar ----------
|
||||||
|
|
||||||
|
fn normalize_email(email: &str) -> String {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! JSON tam hane yedeği (`services::backup`).
|
||||||
|
//!
|
||||||
|
//! | Yöntem | Yol | Kim | İş |
|
||||||
|
//! |---|---|---|---|
|
||||||
|
//! | GET | `/api/backup/download` | üye | yedek dosyası (indirme) |
|
||||||
|
//! | GET | `/api/backup/status` | üye | son yedek zamanı + geri dönüş noktaları (yalnız sahip görür) |
|
||||||
|
//! | POST | `/api/backup/preview` | sahip | gövde: yedek dosyası → ne değişecek (hiçbir şey yazılmaz) |
|
||||||
|
//! | POST | `/api/backup/restore` | sahip | gövde: yedek dosyası → hane verisi değiştirilir |
|
||||||
|
//! | POST | `/api/backup/restore-points/{id}/restore` | sahip | geri dönüş noktasına dön ("geri al") |
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
body::Bytes,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::header,
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use mongodb::bson::DateTime;
|
||||||
|
use serde::Serialize;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthUser,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::user::Role,
|
||||||
|
services::{
|
||||||
|
backup::{self, Reason},
|
||||||
|
calendar::{format_date, today_tr},
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn iso(d: DateTime) -> String {
|
||||||
|
d.try_to_rfc3339_string().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_id(id: &str) -> AppResult<mongodb::bson::Uuid> {
|
||||||
|
mongodb::bson::Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download(State(s): State<AppState>, auth: AuthUser) -> AppResult<impl IntoResponse> {
|
||||||
|
let snap = backup::collect(&s.db, auth.household_id).await?;
|
||||||
|
let body = backup::to_json(&snap)?;
|
||||||
|
backup::mark_exported(&s.db, auth.household_id).await?;
|
||||||
|
let file = format!(
|
||||||
|
"kakeibo-yedek-{}-{}.json",
|
||||||
|
backup::slug(&snap.household_name),
|
||||||
|
format_date(today_tr())
|
||||||
|
);
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
"application/json; charset=utf-8".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{file}\""),
|
||||||
|
),
|
||||||
|
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||||
|
],
|
||||||
|
body,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct RestorePoint {
|
||||||
|
id: String,
|
||||||
|
/// RFC 3339
|
||||||
|
created_at: String,
|
||||||
|
/// `before_restore`: dosyadan geri yüklemeden önce · `before_undo`: geri almadan önce
|
||||||
|
#[ts(type = "\"before_restore\" | \"before_undo\"")]
|
||||||
|
reason: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
periods: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
entries: i64,
|
||||||
|
/// Sıkıştırılmış boyut (bayt)
|
||||||
|
#[ts(type = "number")]
|
||||||
|
size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct BackupStatus {
|
||||||
|
/// Son dışa aktarma (RFC 3339); hiç alınmadıysa `null`
|
||||||
|
last_backup_at: Option<String>,
|
||||||
|
/// En yeniden eskiye; sahip olmayana boş
|
||||||
|
restore_points: Vec<RestorePoint>,
|
||||||
|
keep_points: u32,
|
||||||
|
keep_days: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<BackupStatus>> {
|
||||||
|
let points = if auth.role == Role::Owner {
|
||||||
|
backup::points(&s.db, auth.household_id).await?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
Ok(Json(BackupStatus {
|
||||||
|
last_backup_at: backup::last_exported(&s.db, auth.household_id)
|
||||||
|
.await?
|
||||||
|
.map(iso),
|
||||||
|
restore_points: points
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| RestorePoint {
|
||||||
|
id: p.id.to_string(),
|
||||||
|
created_at: iso(p.created_at),
|
||||||
|
reason: p.reason,
|
||||||
|
periods: p.periods,
|
||||||
|
entries: p.entries,
|
||||||
|
size: p.size,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
keep_points: backup::KEEP_POINTS as u32,
|
||||||
|
keep_days: backup::KEEP_DAYS as u32,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct BackupCount {
|
||||||
|
/// Koleksiyon adı (`entries`, `periods`, …)
|
||||||
|
kind: String,
|
||||||
|
/// Türkçe ad
|
||||||
|
label: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
backup: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
current: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct BackupPreview {
|
||||||
|
source_name: String,
|
||||||
|
same_household: bool,
|
||||||
|
/// RFC 3339
|
||||||
|
exported_at: String,
|
||||||
|
app_version: String,
|
||||||
|
counts: Vec<BackupCount>,
|
||||||
|
/// Yedekteki dönemlerin ilk ve son günü
|
||||||
|
first_date: Option<String>,
|
||||||
|
last_date: Option<String>,
|
||||||
|
/// Aynı hane: yedekten sonra eklenen/değişen (kaybolacak) kayıt
|
||||||
|
#[ts(type = "number")]
|
||||||
|
changed_since: i64,
|
||||||
|
warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn preview(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
body: Bytes,
|
||||||
|
) -> AppResult<Json<BackupPreview>> {
|
||||||
|
auth.require_owner()?;
|
||||||
|
let snap = backup::parse(&body)?;
|
||||||
|
let p = backup::preview(&s.db, auth.household_id, &snap).await?;
|
||||||
|
Ok(Json(BackupPreview {
|
||||||
|
source_name: p.source_name,
|
||||||
|
same_household: p.same_household,
|
||||||
|
exported_at: iso(p.exported_at),
|
||||||
|
app_version: p.app_version,
|
||||||
|
counts: p
|
||||||
|
.counts
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| BackupCount {
|
||||||
|
kind: c.kind.into(),
|
||||||
|
label: c.label.into(),
|
||||||
|
backup: c.backup,
|
||||||
|
current: c.current,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
first_date: p.first_date,
|
||||||
|
last_date: p.last_date,
|
||||||
|
changed_since: p.changed_since,
|
||||||
|
warnings: p.warnings,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct RestoreResult {
|
||||||
|
/// İşlemden önceki durumun geri dönüş noktası ("Geri al" bununla)
|
||||||
|
restore_point_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restore(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
body: Bytes,
|
||||||
|
) -> AppResult<Json<RestoreResult>> {
|
||||||
|
auth.require_owner()?;
|
||||||
|
let snap = backup::parse(&body)?;
|
||||||
|
let id = backup::restore(
|
||||||
|
&s.db,
|
||||||
|
auth.household_id,
|
||||||
|
auth.user_id,
|
||||||
|
&snap,
|
||||||
|
Reason::BeforeRestore,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(RestoreResult {
|
||||||
|
restore_point_id: id.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn undo(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<RestoreResult>> {
|
||||||
|
auth.require_owner()?;
|
||||||
|
let id = backup::undo(&s.db, auth.household_id, auth.user_id, parse_id(&id)?).await?;
|
||||||
|
Ok(Json(RestoreResult {
|
||||||
|
restore_point_id: id.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
//! Banka / kart ekstresi içe aktarma (hanenin tüm üyeleri). Dosya tarayıcıda okunur;
|
||||||
|
//! buraya eşleştirilmiş satırlar gelir.
|
||||||
|
//!
|
||||||
|
//! | Yöntem | Yol | İş |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | POST | `/api/bank-import/preview` | satır başına öneri (tür, kategori), tekrar, dönem; açılacak arşiv dönemleri |
|
||||||
|
//! | POST | `/api/bank-import/commit` | kullanıcının seçimiyle aktar; seçimleri kural olarak öğren |
|
||||||
|
|
||||||
|
use axum::{Json, extract::State};
|
||||||
|
use mongodb::bson::Uuid;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthUser,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
extract::ValidJson,
|
||||||
|
services::{
|
||||||
|
bank_import::{self, Direction, Kind, Row, Source, Target},
|
||||||
|
calendar::{default_period_name, format_date, parse_date},
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, rename = "BankRowIn")]
|
||||||
|
pub struct RowIn {
|
||||||
|
/// `"YYYY-MM-DD"`
|
||||||
|
date: String,
|
||||||
|
description: String,
|
||||||
|
/// Kuruş, > 0
|
||||||
|
#[ts(type = "number")]
|
||||||
|
amount: i64,
|
||||||
|
#[ts(type = "\"in\" | \"out\"")]
|
||||||
|
direction: Direction,
|
||||||
|
/// Yalnızca aktarmada (kullanıcının seçimi)
|
||||||
|
#[ts(type = "\"expense\" | \"refund\" | \"income\" | \"ignore\"", optional)]
|
||||||
|
kind: Option<Kind>,
|
||||||
|
#[ts(optional)]
|
||||||
|
category_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, TS)]
|
||||||
|
#[ts(export, rename = "BankPreviewRequest")]
|
||||||
|
pub struct PreviewRequest {
|
||||||
|
#[ts(type = "\"card\" | \"account\"")]
|
||||||
|
source: Source,
|
||||||
|
rows: Vec<RowIn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, TS)]
|
||||||
|
#[ts(export, rename = "BankCommitRequest", optional_fields)]
|
||||||
|
pub struct CommitRequest {
|
||||||
|
/// Kayıtlara yazılacak ödeme türü (ör. Kredi Kartı)
|
||||||
|
payment_method_id: Option<String>,
|
||||||
|
rows: Vec<RowIn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "BankPreviewRow")]
|
||||||
|
struct PreviewRowDto {
|
||||||
|
#[ts(type = "\"expense\" | \"refund\" | \"income\" | \"ignore\"")]
|
||||||
|
kind: &'static str,
|
||||||
|
category_id: Option<String>,
|
||||||
|
/// Öneri hanenin öğrendiği kuraldan
|
||||||
|
from_rule: bool,
|
||||||
|
/// Daha önce aktarılmış (atlanacak)
|
||||||
|
duplicate: bool,
|
||||||
|
/// `existing` · `archive` · `blocked`
|
||||||
|
#[ts(type = "\"existing\" | \"archive\" | \"blocked\"")]
|
||||||
|
period: &'static str,
|
||||||
|
/// Dönem adı (var olan ya da açılacak arşiv)
|
||||||
|
period_name: Option<String>,
|
||||||
|
/// Engel nedeni
|
||||||
|
message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "BankPreview")]
|
||||||
|
pub struct PreviewDto {
|
||||||
|
rows: Vec<PreviewRowDto>,
|
||||||
|
/// Açılacak arşiv dönemleri
|
||||||
|
archives: Vec<ArchiveDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "BankArchive")]
|
||||||
|
struct ArchiveDto {
|
||||||
|
name: String,
|
||||||
|
start_date: String,
|
||||||
|
/// Son gün, dahil
|
||||||
|
last_date: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "BankImportReport")]
|
||||||
|
pub struct ReportDto {
|
||||||
|
entries: u32,
|
||||||
|
refunds: u32,
|
||||||
|
incomes: u32,
|
||||||
|
ignored: u32,
|
||||||
|
duplicates: u32,
|
||||||
|
/// Açılan arşiv dönemleri
|
||||||
|
archives: Vec<String>,
|
||||||
|
errors: Vec<RowError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "BankRowError")]
|
||||||
|
struct RowError {
|
||||||
|
/// Satır sırası (0'dan)
|
||||||
|
index: u32,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rows_of(rows: Vec<RowIn>) -> AppResult<Vec<Row>> {
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let category_id = match r.category_id.as_deref().map(str::trim) {
|
||||||
|
None | Some("") => None,
|
||||||
|
Some(id) => Some(
|
||||||
|
Uuid::parse_str(id)
|
||||||
|
.map_err(|_| AppError::BadRequest("Kategori geçersiz".into()))?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
Ok(Row {
|
||||||
|
date: r.date.trim().to_string(),
|
||||||
|
description: r.description,
|
||||||
|
amount: r.amount,
|
||||||
|
direction: r.direction,
|
||||||
|
kind: r.kind,
|
||||||
|
category_id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn archive_name(start: &str, end: &str) -> String {
|
||||||
|
match (parse_date(start), parse_date(end)) {
|
||||||
|
(Some(s), Some(e)) => default_period_name(s, e),
|
||||||
|
_ => start.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn preview(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
ValidJson(req): ValidJson<PreviewRequest>,
|
||||||
|
) -> AppResult<Json<PreviewDto>> {
|
||||||
|
let rows = rows_of(req.rows)?;
|
||||||
|
let p = bank_import::preview(&s.db, auth.household_id, req.source, &rows).await?;
|
||||||
|
Ok(Json(PreviewDto {
|
||||||
|
rows: p
|
||||||
|
.rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let (period, period_name, message) = match r.target {
|
||||||
|
Target::Existing { name, .. } => ("existing", Some(name), None),
|
||||||
|
Target::Archive { start, end } => {
|
||||||
|
("archive", Some(archive_name(&start, &end)), None)
|
||||||
|
}
|
||||||
|
Target::Blocked(m) => ("blocked", None, Some(m)),
|
||||||
|
};
|
||||||
|
PreviewRowDto {
|
||||||
|
kind: r.kind.as_str(),
|
||||||
|
category_id: r.category_id.map(|c| c.to_string()),
|
||||||
|
from_rule: r.from_rule,
|
||||||
|
duplicate: r.duplicate,
|
||||||
|
period,
|
||||||
|
period_name,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
archives: p
|
||||||
|
.archives
|
||||||
|
.into_iter()
|
||||||
|
.map(|(start, end)| {
|
||||||
|
let last = parse_date(&end)
|
||||||
|
.map_or(end.clone(), |e| format_date(e - time::Duration::days(1)));
|
||||||
|
ArchiveDto {
|
||||||
|
name: archive_name(&start, &end),
|
||||||
|
start_date: start,
|
||||||
|
last_date: last,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn commit(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
ValidJson(req): ValidJson<CommitRequest>,
|
||||||
|
) -> AppResult<Json<ReportDto>> {
|
||||||
|
let pm = match req.payment_method_id.as_deref().map(str::trim) {
|
||||||
|
None | Some("") => None,
|
||||||
|
Some(id) => Some(
|
||||||
|
Uuid::parse_str(id).map_err(|_| AppError::BadRequest("Ödeme türü geçersiz".into()))?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let rows = rows_of(req.rows)?;
|
||||||
|
let r = bank_import::commit(&s.db, auth.household_id, auth.user_id, pm, &rows).await?;
|
||||||
|
Ok(Json(ReportDto {
|
||||||
|
entries: r.entries as u32,
|
||||||
|
refunds: r.refunds as u32,
|
||||||
|
incomes: r.incomes as u32,
|
||||||
|
ignored: r.ignored as u32,
|
||||||
|
duplicates: r.duplicates as u32,
|
||||||
|
archives: r.archives,
|
||||||
|
errors: r
|
||||||
|
.errors
|
||||||
|
.into_iter()
|
||||||
|
.map(|(i, message)| RowError {
|
||||||
|
index: i as u32,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
//! Borç takibi (`services::debt`, docs/BORC_TAKIBI.md). Hanenin tüm üyeleri.
|
||||||
|
//!
|
||||||
|
//! | Yöntem | Yol | İş |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | GET | `/api/debt` | özet: son ölçüm, değişim, ilerleme, tahmini bitiş, ölçümler, taksitler, hedefler, 6 aylık yük |
|
||||||
|
//! | POST | `/api/debt/snapshots` · PATCH/DELETE `/api/debt/snapshots/{id}` | ölçüm |
|
||||||
|
//! | POST | `/api/debt/installments` · PATCH/DELETE `/api/debt/installments/{id}` | taksit planı |
|
||||||
|
//! | PUT | `/api/debt/targets` | yol haritası (liste olduğu gibi kaydedilir) |
|
||||||
|
//! | POST | `/api/debt/import?commit=false\|true` | kaynak belgedeki JSON → ön izleme / aktarma |
|
||||||
|
//! | GET | `/api/periods/{id}/debt` | dönemin ay başı borcu, devreden, taksit yükü, hedef |
|
||||||
|
//! | PUT | `/api/periods/{id}/opening-debt` | elle ay başı toplam borç (`null` → son ölçümden) |
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use mongodb::bson::Uuid;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthUser,
|
||||||
|
db::col,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
services::{
|
||||||
|
calendar::{format_date, parse_date, today_tr},
|
||||||
|
debt::{self, InstallmentPlan, PlanInput, Snapshot, SnapshotInput},
|
||||||
|
period,
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn parse_id(id: &str) -> AppResult<Uuid> {
|
||||||
|
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- DTO ----------
|
||||||
|
|
||||||
|
#[derive(Serialize, TS, Clone)]
|
||||||
|
#[ts(export, rename = "DebtSnapshot")]
|
||||||
|
pub struct SnapshotDto {
|
||||||
|
id: String,
|
||||||
|
date: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
kmh: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
positive_balance: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
card_balance: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
remaining_installments: i64,
|
||||||
|
/// Hesaplanır: KMH + kart + kalan taksit − artı bakiye
|
||||||
|
#[ts(type = "number")]
|
||||||
|
total: i64,
|
||||||
|
/// Bir önceki ölçüme göre
|
||||||
|
#[ts(type = "number | null")]
|
||||||
|
change: Option<i64>,
|
||||||
|
note: Option<String>,
|
||||||
|
source: String,
|
||||||
|
version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snap_dto(s: &Snapshot, prev: Option<&Snapshot>) -> SnapshotDto {
|
||||||
|
SnapshotDto {
|
||||||
|
id: s.id.to_string(),
|
||||||
|
date: s.date.clone(),
|
||||||
|
kmh: s.kmh,
|
||||||
|
positive_balance: s.positive_balance,
|
||||||
|
card_balance: s.card_balance,
|
||||||
|
remaining_installments: s.remaining_installments,
|
||||||
|
total: s.total(),
|
||||||
|
change: prev.map(|p| s.total() - p.total()),
|
||||||
|
note: s.note.clone(),
|
||||||
|
source: s.source.clone(),
|
||||||
|
version: s.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "InstallmentPlan")]
|
||||||
|
pub struct PlanDto {
|
||||||
|
id: String,
|
||||||
|
merchant: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
total_amount: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
monthly_amount: i64,
|
||||||
|
total_count: i32,
|
||||||
|
/// `"YYYY-MM"`
|
||||||
|
first_month: String,
|
||||||
|
last_month: String,
|
||||||
|
/// Bu ay dahil ekstreye gelen
|
||||||
|
paid_count: i32,
|
||||||
|
remaining_count: i32,
|
||||||
|
/// Kalan taksitlerin toplamı (bu aydan sonra)
|
||||||
|
#[ts(type = "number")]
|
||||||
|
remaining_amount: i64,
|
||||||
|
version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_dto(p: &InstallmentPlan, month: i32) -> PlanDto {
|
||||||
|
PlanDto {
|
||||||
|
id: p.id.to_string(),
|
||||||
|
merchant: p.merchant.clone(),
|
||||||
|
total_amount: p.total_amount,
|
||||||
|
monthly_amount: p.monthly_amount,
|
||||||
|
total_count: p.total_count,
|
||||||
|
first_month: p.first_month.clone(),
|
||||||
|
last_month: p.last_month().unwrap_or_default(),
|
||||||
|
paid_count: p.paid_count(month),
|
||||||
|
remaining_count: p.remaining_count(month),
|
||||||
|
remaining_amount: i64::from(p.remaining_count(month)) * p.monthly_amount,
|
||||||
|
version: p.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export, rename = "DebtTarget")]
|
||||||
|
pub struct TargetDto {
|
||||||
|
/// `"YYYY-MM"`
|
||||||
|
month: String,
|
||||||
|
/// Ay sonu toplam borç hedefi (kuruş)
|
||||||
|
#[ts(type = "number")]
|
||||||
|
closing_target: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "MonthLoad")]
|
||||||
|
pub struct MonthLoadDto {
|
||||||
|
month: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
amount: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct DebtSummary {
|
||||||
|
/// En yeni önce
|
||||||
|
snapshots: Vec<SnapshotDto>,
|
||||||
|
latest: Option<SnapshotDto>,
|
||||||
|
/// Son ölçüm − önceki ayın son ölçümü (eksi → azaldı)
|
||||||
|
#[ts(type = "number | null")]
|
||||||
|
change_vs_last_month: Option<i64>,
|
||||||
|
/// İlk ölçüm (yolculuğun başı)
|
||||||
|
start: Option<SnapshotDto>,
|
||||||
|
/// 0..1
|
||||||
|
progress: Option<f64>,
|
||||||
|
/// `"YYYY-MM"`
|
||||||
|
estimated_end: Option<String>,
|
||||||
|
installments: Vec<PlanDto>,
|
||||||
|
targets: Vec<TargetDto>,
|
||||||
|
/// Bu ay dahil 6 ay
|
||||||
|
schedule: Vec<MonthLoadDto>,
|
||||||
|
/// Bu ay (`"YYYY-MM"`)
|
||||||
|
this_month: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Özet ----------
|
||||||
|
|
||||||
|
pub async fn summary(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<DebtSummary>> {
|
||||||
|
let today = format_date(today_tr());
|
||||||
|
let x = debt::summary(&s.db, auth.household_id, &today).await?;
|
||||||
|
let list: Vec<SnapshotDto> = x
|
||||||
|
.snapshots
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, sn)| snap_dto(sn, i.checked_sub(1).map(|j| &x.snapshots[j])))
|
||||||
|
.rev()
|
||||||
|
.collect();
|
||||||
|
Ok(Json(DebtSummary {
|
||||||
|
latest: list.first().cloned(),
|
||||||
|
start: x.start.as_ref().map(|s0| snap_dto(s0, None)),
|
||||||
|
snapshots: list,
|
||||||
|
change_vs_last_month: x.change_vs_last_month,
|
||||||
|
progress: x.progress,
|
||||||
|
estimated_end: x.estimated_end,
|
||||||
|
installments: x.plans.iter().map(|p| plan_dto(p, x.this_month)).collect(),
|
||||||
|
targets: x
|
||||||
|
.targets
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| TargetDto {
|
||||||
|
month: t.month,
|
||||||
|
closing_target: t.closing_target,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
schedule: x
|
||||||
|
.schedule
|
||||||
|
.into_iter()
|
||||||
|
.map(|(month, amount)| MonthLoadDto { month, amount })
|
||||||
|
.collect(),
|
||||||
|
this_month: debt::month_name(x.this_month),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Ölçümler ----------
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, rename = "DebtSnapshotRequest", optional_fields)]
|
||||||
|
pub struct SnapshotRequest {
|
||||||
|
/// `"YYYY-MM-DD"`
|
||||||
|
date: Option<String>,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
kmh: Option<i64>,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
positive_balance: Option<i64>,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
card_balance: Option<i64>,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
remaining_installments: Option<i64>,
|
||||||
|
/// Boş metin → notu kaldır
|
||||||
|
note: Option<String>,
|
||||||
|
version: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SnapshotRequest> for SnapshotInput {
|
||||||
|
fn from(r: SnapshotRequest) -> Self {
|
||||||
|
SnapshotInput {
|
||||||
|
date: r.date,
|
||||||
|
kmh: r.kmh,
|
||||||
|
positive_balance: r.positive_balance,
|
||||||
|
card_balance: r.card_balance,
|
||||||
|
remaining_installments: r.remaining_installments,
|
||||||
|
note: r.note.map(Some),
|
||||||
|
version: r.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_snapshot(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Json(req): Json<SnapshotRequest>,
|
||||||
|
) -> AppResult<(StatusCode, Json<SnapshotDto>)> {
|
||||||
|
let sn = debt::create_snapshot(&s.db, auth.household_id, req.into(), "manual").await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(snap_dto(&sn, None))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_snapshot(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(req): Json<SnapshotRequest>,
|
||||||
|
) -> AppResult<Json<SnapshotDto>> {
|
||||||
|
let sn = debt::update_snapshot(&s.db, auth.household_id, parse_id(&id)?, req.into()).await?;
|
||||||
|
Ok(Json(snap_dto(&sn, None)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_snapshot(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
debt::delete(
|
||||||
|
&s.db,
|
||||||
|
col::DEBT_SNAPSHOTS,
|
||||||
|
auth.household_id,
|
||||||
|
parse_id(&id)?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Taksitler ----------
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, rename = "InstallmentRequest", optional_fields)]
|
||||||
|
pub struct PlanRequest {
|
||||||
|
merchant: Option<String>,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
total_amount: Option<i64>,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
monthly_amount: Option<i64>,
|
||||||
|
total_count: Option<i32>,
|
||||||
|
/// `"YYYY-MM"`
|
||||||
|
first_month: Option<String>,
|
||||||
|
version: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PlanRequest> for PlanInput {
|
||||||
|
fn from(r: PlanRequest) -> Self {
|
||||||
|
PlanInput {
|
||||||
|
merchant: r.merchant,
|
||||||
|
total_amount: r.total_amount,
|
||||||
|
monthly_amount: r.monthly_amount,
|
||||||
|
total_count: r.total_count,
|
||||||
|
first_month: r.first_month,
|
||||||
|
version: r.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn this_month() -> i32 {
|
||||||
|
debt::month_index(debt::month_of(&format_date(today_tr()))).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_plan(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Json(req): Json<PlanRequest>,
|
||||||
|
) -> AppResult<(StatusCode, Json<PlanDto>)> {
|
||||||
|
let p = debt::create_plan(&s.db, auth.household_id, req.into()).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(plan_dto(&p, this_month()))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_plan(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(req): Json<PlanRequest>,
|
||||||
|
) -> AppResult<Json<PlanDto>> {
|
||||||
|
let p = debt::update_plan(&s.db, auth.household_id, parse_id(&id)?, req.into()).await?;
|
||||||
|
Ok(Json(plan_dto(&p, this_month())))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_plan(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
debt::delete(
|
||||||
|
&s.db,
|
||||||
|
col::INSTALLMENT_PLANS,
|
||||||
|
auth.household_id,
|
||||||
|
parse_id(&id)?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Hedefler ----------
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, rename = "DebtTargetsRequest")]
|
||||||
|
pub struct TargetsRequest {
|
||||||
|
targets: Vec<TargetDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_targets(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Json(req): Json<TargetsRequest>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
debt::set_targets(
|
||||||
|
&s.db,
|
||||||
|
auth.household_id,
|
||||||
|
req.targets
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| (t.month, t.closing_target))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- İçe aktarma ----------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ImportQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
commit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "DebtImportRow")]
|
||||||
|
pub struct ImportRowDto {
|
||||||
|
/// `snapshot` | `installment` | `target`
|
||||||
|
kind: String,
|
||||||
|
label: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
amount: i64,
|
||||||
|
/// `new` | `exists` | `update` | `error`
|
||||||
|
status: String,
|
||||||
|
message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct DebtImportReport {
|
||||||
|
committed: bool,
|
||||||
|
rows: Vec<ImportRowDto>,
|
||||||
|
errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Query(q): Query<ImportQuery>,
|
||||||
|
Json(body): Json<serde_json::Value>,
|
||||||
|
) -> AppResult<Json<DebtImportReport>> {
|
||||||
|
let (rows, errors) = debt::import(&s.db, auth.household_id, &body, q.commit).await?;
|
||||||
|
Ok(Json(DebtImportReport {
|
||||||
|
committed: q.commit,
|
||||||
|
rows: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ImportRowDto {
|
||||||
|
kind: r.kind.into(),
|
||||||
|
label: r.label,
|
||||||
|
amount: r.amount,
|
||||||
|
status: r.status.into(),
|
||||||
|
message: r.message,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
errors,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Dönem ----------
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct PeriodDebt {
|
||||||
|
/// Ay başı toplam borç
|
||||||
|
#[ts(type = "number | null")]
|
||||||
|
opening_total: Option<i64>,
|
||||||
|
/// `manual` | `snapshot` | `none`
|
||||||
|
opening_source: String,
|
||||||
|
opening_date: Option<String>,
|
||||||
|
/// Devreden borç = ay başı toplam − kalan taksitler
|
||||||
|
#[ts(type = "number | null")]
|
||||||
|
carried: Option<i64>,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
installment_load: i64,
|
||||||
|
/// Borç ödemesi (dönemin birikim hedefi)
|
||||||
|
#[ts(type = "number")]
|
||||||
|
payment: i64,
|
||||||
|
/// Ay başı − borç ödemesi − taksit yükü
|
||||||
|
#[ts(type = "number | null")]
|
||||||
|
closing_target: Option<i64>,
|
||||||
|
#[ts(type = "number | null")]
|
||||||
|
roadmap_target: Option<i64>,
|
||||||
|
latest_date: Option<String>,
|
||||||
|
#[ts(type = "number | null")]
|
||||||
|
latest_total: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn period_debt(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<PeriodDebt>> {
|
||||||
|
let hid = auth.household_id;
|
||||||
|
let p = period::get(&s.db, hid, parse_id(&id)?).await?;
|
||||||
|
let last = parse_date(&p.end_date)
|
||||||
|
.map(|d| format_date(d - time::Duration::days(1)))
|
||||||
|
.unwrap_or_else(|| p.end_date.clone());
|
||||||
|
let d = debt::period_debt(
|
||||||
|
&p,
|
||||||
|
&last,
|
||||||
|
&debt::snapshots(&s.db, hid).await?,
|
||||||
|
&debt::plans(&s.db, hid).await?,
|
||||||
|
&debt::targets(&s.db, hid).await?,
|
||||||
|
);
|
||||||
|
Ok(Json(PeriodDebt {
|
||||||
|
opening_total: d.opening_total,
|
||||||
|
opening_source: d.opening_source.into(),
|
||||||
|
opening_date: d.opening_date,
|
||||||
|
carried: d.carried,
|
||||||
|
installment_load: d.installment_load,
|
||||||
|
payment: d.payment,
|
||||||
|
closing_target: d.closing_target,
|
||||||
|
roadmap_target: d.roadmap_target,
|
||||||
|
latest_date: d.latest_in_period.as_ref().map(|x| x.0.clone()),
|
||||||
|
latest_total: d.latest_in_period.map(|x| x.1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, rename = "OpeningDebtRequest")]
|
||||||
|
pub struct OpeningRequest {
|
||||||
|
/// Kuruş; `null` → son ölçümden
|
||||||
|
#[ts(type = "number | null")]
|
||||||
|
amount: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_opening(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(req): Json<OpeningRequest>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
let pid = parse_id(&id)?;
|
||||||
|
period::get_open(&s.db, auth.household_id, pid).await?; // kapalı dönem değişmez
|
||||||
|
debt::set_opening(&s.db, auth.household_id, pid, req.amount).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
|
use serde::Serialize;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
use crate::{db, state::AppState};
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct HealthResponse {
|
||||||
|
/// `ok` veya `degraded`
|
||||||
|
status: &'static str,
|
||||||
|
/// `ok` veya `error`
|
||||||
|
db: &'static str,
|
||||||
|
version: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sağlık kontrolü (Dokploy health check bunu kullanır).
|
||||||
|
/// DB erişilemezse 503 döner.
|
||||||
|
pub async fn health(State(state): State<AppState>) -> (StatusCode, Json<HealthResponse>) {
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
match db::ping(&state.db).await {
|
||||||
|
Ok(()) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: "ok",
|
||||||
|
db: "ok",
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "health: veritabanına erişilemiyor");
|
||||||
|
(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: "degraded",
|
||||||
|
db: "error",
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
//! 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)
|
||||||
|
//! - `PATCH /api/household` → ad + bütçe varsayılanları (yalnızca owner)
|
||||||
|
//! - `DELETE /api/household/members/{id}` → üyeyi çıkar (yalnızca owner)
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use mongodb::bson::{DateTime, Uuid, doc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthUser,
|
||||||
|
db::col,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
extract::ValidJson,
|
||||||
|
models::{common::scope, household::Household, invite::Invite, user::Role},
|
||||||
|
services::{account, invite},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "Household")]
|
||||||
|
pub struct HouseholdResponse {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
members: Vec<MemberDto>,
|
||||||
|
settings: SettingsDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Validate, TS, Clone, Copy)]
|
||||||
|
#[ts(export, rename = "BudgetSplitPct")]
|
||||||
|
pub struct SplitDto {
|
||||||
|
#[validate(range(max = 100))]
|
||||||
|
needs: u32,
|
||||||
|
#[validate(range(max = 100))]
|
||||||
|
wants: u32,
|
||||||
|
#[validate(range(max = 100))]
|
||||||
|
culture: u32,
|
||||||
|
#[validate(range(max = 100))]
|
||||||
|
unexpected: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "HouseholdSettings")]
|
||||||
|
struct SettingsDto {
|
||||||
|
/// Varsayılan bütçe dağılımı (yüzde, toplam 100)
|
||||||
|
budget_split_pct: SplitDto,
|
||||||
|
/// Plan ekranında önce seçili öneri yöntemi
|
||||||
|
#[ts(type = "\"default\" | \"previous\" | \"avg3\"")]
|
||||||
|
budget_suggestion: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, TS)]
|
||||||
|
#[ts(export, optional_fields)]
|
||||||
|
pub struct HouseholdUpdateRequest {
|
||||||
|
#[validate(length(min = 1, max = 80, message = "Hane adı 1–80 karakter olmalı"))]
|
||||||
|
name: Option<String>,
|
||||||
|
#[validate(nested)]
|
||||||
|
budget_split_pct: Option<SplitDto>,
|
||||||
|
#[ts(type = "\"default\" | \"previous\" | \"avg3\"", optional)]
|
||||||
|
budget_suggestion: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[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.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
let settings = state
|
||||||
|
.db
|
||||||
|
.collection::<mongodb::bson::Document>(col::HOUSEHOLDS)
|
||||||
|
.find_one(hh_filter)
|
||||||
|
.await?
|
||||||
|
.and_then(|d| d.get_document("settings").ok().cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut cursor = state
|
||||||
|
.db
|
||||||
|
.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,
|
||||||
|
settings: settings_dto(&settings),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_dto(s: &mongodb::bson::Document) -> SettingsDto {
|
||||||
|
let pct = s.get_document("budget_split_pct").ok();
|
||||||
|
let v = |k: &str, d: u32| {
|
||||||
|
pct.and_then(|p| match p.get(k) {
|
||||||
|
Some(mongodb::bson::Bson::Int32(v)) => u32::try_from(*v).ok(),
|
||||||
|
Some(mongodb::bson::Bson::Int64(v)) => u32::try_from(*v).ok(),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or(d)
|
||||||
|
};
|
||||||
|
SettingsDto {
|
||||||
|
budget_split_pct: SplitDto {
|
||||||
|
needs: v("needs", 50),
|
||||||
|
wants: v("wants", 30),
|
||||||
|
culture: v("culture", 10),
|
||||||
|
unexpected: v("unexpected", 10),
|
||||||
|
},
|
||||||
|
budget_suggestion: s
|
||||||
|
.get_str("budget_suggestion")
|
||||||
|
.unwrap_or("default")
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
ValidJson(req): ValidJson<HouseholdUpdateRequest>,
|
||||||
|
) -> AppResult<Json<HouseholdResponse>> {
|
||||||
|
auth.require_owner()?;
|
||||||
|
account::update_household(
|
||||||
|
&state.db,
|
||||||
|
auth.household_id,
|
||||||
|
account::HouseholdChanges {
|
||||||
|
name: req.name,
|
||||||
|
split_pct: req
|
||||||
|
.budget_split_pct
|
||||||
|
.map(|p| [p.needs, p.wants, p.culture, p.unexpected]),
|
||||||
|
suggestion: req.budget_suggestion,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
get(State(state), auth).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_member(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
auth.require_owner()?;
|
||||||
|
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
|
||||||
|
account::remove_member(&state.db, auth.household_id, auth.user_id, id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_invites(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> 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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
|
||||||
|
|
||||||
|
mod asset;
|
||||||
|
mod auth;
|
||||||
|
mod backup;
|
||||||
|
mod bank_import;
|
||||||
|
mod budget;
|
||||||
|
mod catalog;
|
||||||
|
mod debt;
|
||||||
|
mod entry;
|
||||||
|
mod health;
|
||||||
|
mod household;
|
||||||
|
mod monthly_report;
|
||||||
|
mod note;
|
||||||
|
mod period;
|
||||||
|
mod plan;
|
||||||
|
mod rates;
|
||||||
|
mod reports;
|
||||||
|
mod review;
|
||||||
|
mod spa;
|
||||||
|
mod stats;
|
||||||
|
mod transfer;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
routing::{delete, get, patch, post, put},
|
||||||
|
};
|
||||||
|
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||||
|
|
||||||
|
use crate::{error::AppError, state::AppState};
|
||||||
|
|
||||||
|
/// Sunucunun tamamı: API + (STATIC_DIR tanımlıysa) arayüz dosyaları,
|
||||||
|
/// sıkıştırma ve istek logları. `main.rs` bunu kullanır.
|
||||||
|
pub fn app(state: AppState) -> Router {
|
||||||
|
let static_dir = state.config.static_dir.clone();
|
||||||
|
let mut app = router(state);
|
||||||
|
if let Some(dir) = static_dir {
|
||||||
|
app = app.fallback_service(spa::router(&dir));
|
||||||
|
}
|
||||||
|
app.layer(CompressionLayer::new())
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tüm API route'ları. Testler de bu fonksiyonu kullanır.
|
||||||
|
pub fn router(state: AppState) -> Router {
|
||||||
|
let api = Router::new()
|
||||||
|
.route("/health", get(health::health))
|
||||||
|
.route("/auth/register", post(auth::register))
|
||||||
|
.route("/auth/registration", get(auth::registration_info))
|
||||||
|
.route("/auth/guard", get(auth::guard_info))
|
||||||
|
.route("/auth/login", post(auth::login))
|
||||||
|
.route("/auth/logout", post(auth::logout))
|
||||||
|
.route("/auth/me", get(auth::me).patch(auth::update_me))
|
||||||
|
.route("/household", get(household::get).patch(household::update))
|
||||||
|
.route("/household/members/{id}", delete(household::remove_member))
|
||||||
|
.route("/auth/me/password", post(auth::change_password))
|
||||||
|
.route(
|
||||||
|
"/household/invites",
|
||||||
|
get(household::list_invites).post(household::create_invite),
|
||||||
|
)
|
||||||
|
.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}/debt", get(debt::period_debt))
|
||||||
|
.route("/periods/{id}/opening-debt", put(debt::set_opening))
|
||||||
|
.route("/debt", get(debt::summary))
|
||||||
|
.route("/debt/snapshots", post(debt::create_snapshot))
|
||||||
|
.route(
|
||||||
|
"/debt/snapshots/{id}",
|
||||||
|
patch(debt::update_snapshot).delete(debt::delete_snapshot),
|
||||||
|
)
|
||||||
|
.route("/debt/installments", post(debt::create_plan))
|
||||||
|
.route(
|
||||||
|
"/debt/installments/{id}",
|
||||||
|
patch(debt::update_plan).delete(debt::delete_plan),
|
||||||
|
)
|
||||||
|
.route("/debt/targets", put(debt::set_targets))
|
||||||
|
.route("/debt/import", post(debt::import))
|
||||||
|
.route("/periods/{id}/report.md", get(monthly_report::get))
|
||||||
|
.route("/periods/{id}/daily", get(stats::daily))
|
||||||
|
.route("/stats/periods", get(stats::periods))
|
||||||
|
.route("/reports/breakdown", get(reports::breakdown))
|
||||||
|
.route("/backup/download", get(backup::download))
|
||||||
|
.route("/backup/status", get(backup::status))
|
||||||
|
.route(
|
||||||
|
"/backup/preview",
|
||||||
|
post(backup::preview).layer(DefaultBodyLimit::max(backup_limit())),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/backup/restore",
|
||||||
|
post(backup::restore).layer(DefaultBodyLimit::max(backup_limit())),
|
||||||
|
)
|
||||||
|
.route("/backup/restore-points/{id}/restore", post(backup::undo))
|
||||||
|
.route("/export/entries.csv", get(transfer::export_entries))
|
||||||
|
.route("/export/plan-items.csv", get(transfer::export_plan_items))
|
||||||
|
.route(
|
||||||
|
"/export/asset-transactions.csv",
|
||||||
|
get(transfer::export_asset_txs),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/bank-import/preview",
|
||||||
|
post(bank_import::preview).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/bank-import/commit",
|
||||||
|
post(bank_import::commit).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/import/{kind}",
|
||||||
|
// Büyük CSV (≤ 20.000 satır) için gövde sınırı 5 MB
|
||||||
|
post(transfer::import).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/periods/{id}/budget",
|
||||||
|
get(budget::get).put(budget::set_monthly),
|
||||||
|
)
|
||||||
|
.route("/periods/{id}/budget/weeks", put(budget::set_week))
|
||||||
|
.route("/periods/{id}/budget/suggestion", get(budget::suggestion))
|
||||||
|
.route("/notes/{id}", patch(note::update).delete(note::delete))
|
||||||
|
.route(
|
||||||
|
"/periods/{id}/entries",
|
||||||
|
get(entry::list).post(entry::create),
|
||||||
|
)
|
||||||
|
.route("/entries/{id}", patch(entry::update).delete(entry::delete))
|
||||||
|
.route("/entries/{id}/restore", post(entry::restore))
|
||||||
|
.route("/assets", get(asset::list).post(asset::create))
|
||||||
|
.route("/portfolio", get(rates::portfolio))
|
||||||
|
.route("/savings/pending", get(asset::pending))
|
||||||
|
.route("/periods/{id}/to-savings", post(asset::add_period_savings))
|
||||||
|
.route("/rates", post(rates::create))
|
||||||
|
.route("/rates/{id}", delete(rates::delete))
|
||||||
|
.route("/assets/{id}", patch(asset::update))
|
||||||
|
.route(
|
||||||
|
"/assets/{id}/transactions",
|
||||||
|
get(asset::list_txs).post(asset::create_tx),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/asset-transactions/{id}",
|
||||||
|
patch(asset::update_tx).delete(asset::delete_tx),
|
||||||
|
)
|
||||||
|
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
|
||||||
|
.fallback(|| async { AppError::NotFound });
|
||||||
|
|
||||||
|
Router::new().nest("/api", api).with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON yedeği gövde sınırı (`services::backup::MAX_BYTES`)
|
||||||
|
fn backup_limit() -> usize {
|
||||||
|
crate::services::backup::MAX_BYTES
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//! Aylık değerlendirme raporu (`services::monthly_report`, şablon `templates/monthly_report.md`).
|
||||||
|
//!
|
||||||
|
//! | Yöntem | Yol | İş |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | GET | `/api/periods/{id}/report.md` | dönemin raporu (Markdown); açık dönem → ara rapor |
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::header,
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use mongodb::bson::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthUser,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
services::{backup::slug, monthly_report, period},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<impl IntoResponse> {
|
||||||
|
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
|
||||||
|
let p = period::get(&s.db, auth.household_id, id).await?;
|
||||||
|
let body = monthly_report::markdown(&s.db, auth.household_id, id).await?;
|
||||||
|
let file = format!("kakeibo-rapor-{}.md", slug(&p.name));
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
"text/markdown; charset=utf-8".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("inline; filename=\"{file}\""),
|
||||||
|
),
|
||||||
|
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||||
|
],
|
||||||
|
body,
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//! Derlenmiş arayüzün (frontend/dist) sunulması — tek sayfa uygulama (SPA).
|
||||||
|
//!
|
||||||
|
//! - `/assets/*` : adları içerik özetli (hash) → 1 yıl önbellek, `immutable`
|
||||||
|
//! - PWA: `sw.js`, `manifest.webmanifest` → hiç önbelleğe alınmaz (yeni sürüm hemen fark edilsin)
|
||||||
|
//! - diğer dosyalar (favicon, ikonlar, `workbox-<hash>.js`): 1 saat önbellek
|
||||||
|
//! - olmayan yol (`/defter`, `/plan` ...) → `index.html` (yönlendirmeyi React Router yapar)
|
||||||
|
//! - HTML hiç önbelleğe alınmaz → yeni sürüm yayınlanınca hemen yüklenir
|
||||||
|
|
||||||
|
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 path = req.uri().path().to_string();
|
||||||
|
let mut res = next.run(req).await;
|
||||||
|
|
||||||
|
let is_html = res
|
||||||
|
.headers()
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.is_some_and(|v| v.as_bytes().starts_with(b"text/html"));
|
||||||
|
res.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static(cache_policy(&path, is_html)),
|
||||||
|
);
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yol ve içerik türüne göre `Cache-Control`
|
||||||
|
fn cache_policy(path: &str, is_html: bool) -> &'static str {
|
||||||
|
const NO_CACHE: &str = "no-cache";
|
||||||
|
if is_html || matches!(path, "/sw.js" | "/manifest.webmanifest" | "/registerSW.js") {
|
||||||
|
NO_CACHE
|
||||||
|
} else if path.starts_with("/assets/") {
|
||||||
|
"public, max-age=31536000, immutable"
|
||||||
|
} else {
|
||||||
|
"public, max-age=3600"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::cache_policy;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_rules() {
|
||||||
|
assert_eq!(cache_policy("/", true), "no-cache");
|
||||||
|
assert_eq!(
|
||||||
|
cache_policy("/defter", true),
|
||||||
|
"no-cache",
|
||||||
|
"SPA → index.html"
|
||||||
|
);
|
||||||
|
assert_eq!(cache_policy("/sw.js", false), "no-cache");
|
||||||
|
assert_eq!(cache_policy("/manifest.webmanifest", false), "no-cache");
|
||||||
|
assert_eq!(
|
||||||
|
cache_policy("/assets/index-AbC123.js", false),
|
||||||
|
"public, max-age=31536000, immutable"
|
||||||
|
);
|
||||||
|
assert_eq!(cache_policy("/icon-192.png", false), "public, max-age=3600");
|
||||||
|
assert_eq!(
|
||||||
|
cache_policy("/workbox-2ff6bd68.js", false),
|
||||||
|
"public, max-age=3600"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! CSV dışa / içe aktarma (hanenin tüm üyeleri):
|
||||||
|
//!
|
||||||
|
//! | Yöntem | Yol | İş |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | GET | `/api/export/entries.csv?from=&to=` | harcamalar |
|
||||||
|
//! | GET | `/api/export/plan-items.csv` | plan kalemleri (tüm dönemler) |
|
||||||
|
//! | GET | `/api/export/asset-transactions.csv` | varlık işlemleri |
|
||||||
|
//! | POST | `/api/import/{tür}?commit=false\|true` | gövde: CSV metni → rapor; `commit=true` geçerli satırları yazar |
|
||||||
|
//!
|
||||||
|
//! `{tür}`: `entries` · `plan-items` · `asset-transactions`.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::header,
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthUser,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
services::{
|
||||||
|
calendar::{format_date, today_tr},
|
||||||
|
transfer,
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn csv_response(name: &str, body: String) -> impl IntoResponse {
|
||||||
|
let file = format!("kakeibo-{name}-{}.csv", format_date(today_tr()));
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{file}\""),
|
||||||
|
),
|
||||||
|
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||||
|
],
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RangeQuery {
|
||||||
|
from: Option<String>,
|
||||||
|
to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn export_entries(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Query(q): Query<RangeQuery>,
|
||||||
|
) -> AppResult<impl IntoResponse> {
|
||||||
|
let body = transfer::export_entries(
|
||||||
|
&s.db,
|
||||||
|
auth.household_id,
|
||||||
|
q.from.as_deref().filter(|x| !x.is_empty()),
|
||||||
|
q.to.as_deref().filter(|x| !x.is_empty()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(csv_response("harcamalar", body))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn export_plan_items(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> AppResult<impl IntoResponse> {
|
||||||
|
let body = transfer::export_plan_items(&s.db, auth.household_id).await?;
|
||||||
|
Ok(csv_response("plan-kalemleri", body))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn export_asset_txs(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> AppResult<impl IntoResponse> {
|
||||||
|
let body = transfer::export_asset_txs(&s.db, auth.household_id).await?;
|
||||||
|
Ok(csv_response("varlik-islemleri", body))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ImportQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
commit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "ImportReport")]
|
||||||
|
pub struct ReportDto {
|
||||||
|
/// Başlık hariç dolu satır
|
||||||
|
total: u32,
|
||||||
|
/// Eklenebilir (tekrarlar hariç); yazıldıysa yazılan
|
||||||
|
valid: u32,
|
||||||
|
/// Veritabanında zaten olduğu için atlanan
|
||||||
|
duplicates: u32,
|
||||||
|
/// Yazılan (denemede 0)
|
||||||
|
imported: u32,
|
||||||
|
errors: Vec<ImportError>,
|
||||||
|
/// İlk geçerli satırlar (gösterime hazır)
|
||||||
|
preview: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct ImportError {
|
||||||
|
/// Dosyadaki satır numarası (başlık 1)
|
||||||
|
line: u32,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(kind): Path<String>,
|
||||||
|
Query(q): Query<ImportQuery>,
|
||||||
|
body: String,
|
||||||
|
) -> AppResult<Json<ReportDto>> {
|
||||||
|
let r = match kind.as_str() {
|
||||||
|
"entries" => {
|
||||||
|
transfer::import_entries(&s.db, auth.household_id, auth.user_id, &body, q.commit)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
"plan-items" => {
|
||||||
|
transfer::import_plan_items(&s.db, auth.household_id, &body, q.commit).await?
|
||||||
|
}
|
||||||
|
"asset-transactions" => {
|
||||||
|
transfer::import_asset_txs(&s.db, auth.household_id, auth.user_id, &body, q.commit)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
_ => return Err(AppError::NotFound),
|
||||||
|
};
|
||||||
|
Ok(Json(ReportDto {
|
||||||
|
total: r.total as u32,
|
||||||
|
valid: r.valid as u32,
|
||||||
|
duplicates: r.duplicates as u32,
|
||||||
|
imported: r.imported as u32,
|
||||||
|
errors: r
|
||||||
|
.errors
|
||||||
|
.into_iter()
|
||||||
|
.map(|(line, message)| ImportError {
|
||||||
|
line: line as u32,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
preview: r.preview,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
//! Hesap işlemleri: kayıt; profil, şifre, hane ayarları, üye çıkarma.
|
||||||
|
//!
|
||||||
|
//! İki yol var:
|
||||||
|
//! - **Davetsiz:** yeni hane + `owner` kullanıcı + örnek kategori/ödeme türleri
|
||||||
|
//! - **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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Profil, şifre, hane ayarları, üyeler (Faz 10) ----------
|
||||||
|
|
||||||
|
/// Oturumların geçerlilik başlangıcı (Unix saniye). Şifre değişince "şimdi" yapılır:
|
||||||
|
/// daha önce verilmiş tüm oturum jetonları (`iat` daha küçük) reddedilir.
|
||||||
|
pub const SESSIONS_VALID_FROM: &str = "sessions_valid_from";
|
||||||
|
|
||||||
|
fn unix_now() -> i64 {
|
||||||
|
time::OffsetDateTime::now_utc().unix_timestamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_text(v: &str, what: &str, max: usize) -> AppResult<String> {
|
||||||
|
let v = v.trim().to_string();
|
||||||
|
if v.is_empty() {
|
||||||
|
return Err(AppError::BadRequest(format!("{what} boş olamaz")));
|
||||||
|
}
|
||||||
|
if v.chars().count() > max {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"{what} en fazla {max} karakter olabilir"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_name(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let name = clean_text(name, "Ad", 80)?;
|
||||||
|
let r = db
|
||||||
|
.collection::<Document>(col::USERS)
|
||||||
|
.update_one(
|
||||||
|
doc! { "_id": user_id, "household_id": household_id, "deleted_at": null },
|
||||||
|
doc! { "$set": { "name": name, "updated_at": crate::models::common::now() }, "$inc": { "version": 1 } },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if r.matched_count == 0 {
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mevcut şifre doğruysa yenisini yazar ve bu andan önce verilmiş tüm oturumları geçersiz
|
||||||
|
/// kılar (route bu cihaza hemen yeni bir oturum verir).
|
||||||
|
pub async fn change_password(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
current: String,
|
||||||
|
new: String,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
if new.chars().count() < 8 || new.chars().count() > 128 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Yeni şifre en az 8 karakter olmalı".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if new == current {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Yeni şifre mevcut şifreden farklı olmalı".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let users = db.collection::<Document>(col::USERS);
|
||||||
|
let filter = doc! { "_id": user_id, "household_id": household_id, "deleted_at": null };
|
||||||
|
let user = users
|
||||||
|
.find_one(filter.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
let hash = user.get_str("password_hash").ok().map(str::to_string);
|
||||||
|
if !crate::auth::password::verify(current, hash).await {
|
||||||
|
return Err(AppError::BadRequest("Mevcut şifre hatalı".into()));
|
||||||
|
}
|
||||||
|
let new_hash = crate::auth::password::hash(new).await?;
|
||||||
|
users
|
||||||
|
.update_one(
|
||||||
|
filter,
|
||||||
|
doc! {
|
||||||
|
"$set": {
|
||||||
|
"password_hash": new_hash,
|
||||||
|
SESSIONS_VALID_FROM: unix_now(),
|
||||||
|
"updated_at": crate::models::common::now(),
|
||||||
|
},
|
||||||
|
"$inc": { "version": 1 },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tracing::info!("Kullanıcı {user_id} şifresini değiştirdi; diğer oturumlar kapatıldı");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hane ayarlarında değiştirilebilenler (`None` → dokunma)
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct HouseholdChanges {
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// `Group::ALL` sırasıyla, toplamı 100
|
||||||
|
pub split_pct: Option<[u32; 4]>,
|
||||||
|
/// `default` | `previous` | `avg3`
|
||||||
|
pub suggestion: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_household(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
c: HouseholdChanges,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let mut set = doc! { "updated_at": crate::models::common::now() };
|
||||||
|
if let Some(name) = c.name {
|
||||||
|
set.insert("name", clean_text(&name, "Hane adı", 80)?);
|
||||||
|
}
|
||||||
|
if let Some(p) = c.split_pct {
|
||||||
|
if p.iter().any(|v| *v > 100) || p.iter().sum::<u32>() != 100 {
|
||||||
|
return Err(AppError::BadRequest("Yüzdelerin toplamı 100 olmalı".into()));
|
||||||
|
}
|
||||||
|
let mut pct = Document::new();
|
||||||
|
for (g, v) in crate::models::group::Group::ALL.iter().zip(p) {
|
||||||
|
pct.insert(g.as_str(), v as i32);
|
||||||
|
}
|
||||||
|
set.insert("settings.budget_split_pct", pct);
|
||||||
|
}
|
||||||
|
if let Some(m) = c.suggestion {
|
||||||
|
if !["default", "previous", "avg3"].contains(&m.as_str()) {
|
||||||
|
return Err(AppError::BadRequest("Geçersiz öneri yöntemi".into()));
|
||||||
|
}
|
||||||
|
set.insert("settings.budget_suggestion", m);
|
||||||
|
}
|
||||||
|
db.collection::<Document>(col::HOUSEHOLDS)
|
||||||
|
.update_one(
|
||||||
|
doc! { "_id": household_id, "deleted_at": null },
|
||||||
|
doc! { "$set": set, "$inc": { "version": 1 } },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sahip bir üyeyi çıkarır: kullanıcı silinmiş işaretlenir (oturumu anında geçersiz),
|
||||||
|
/// e-postası serbest kalır (aynı adresle yeniden davet edilebilir). Kayıtları hanede kalır.
|
||||||
|
pub async fn remove_member(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
owner_id: Uuid,
|
||||||
|
member_id: Uuid,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
if member_id == owner_id {
|
||||||
|
return Err(AppError::BadRequest("Hane sahibi kendini çıkaramaz".into()));
|
||||||
|
}
|
||||||
|
let users = db.collection::<Document>(col::USERS);
|
||||||
|
let filter = doc! { "_id": member_id, "household_id": household_id, "deleted_at": null };
|
||||||
|
let user = users
|
||||||
|
.find_one(filter.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
if user.get_str("role") == Ok(Role::Owner.as_str()) {
|
||||||
|
return Err(AppError::BadRequest("Hane sahibi çıkarılamaz".into()));
|
||||||
|
}
|
||||||
|
let email = user.get_str("email").unwrap_or_default().to_string();
|
||||||
|
let ts = crate::models::common::now();
|
||||||
|
users
|
||||||
|
.update_one(
|
||||||
|
filter,
|
||||||
|
doc! {
|
||||||
|
"$set": {
|
||||||
|
"deleted_at": ts,
|
||||||
|
"updated_at": ts,
|
||||||
|
// Benzersiz e-posta indeksi: adres serbest kalsın, kayıtta iz kalsın
|
||||||
|
"email": format!("{email}#removed-{member_id}"),
|
||||||
|
"removed_email": email,
|
||||||
|
},
|
||||||
|
"$inc": { "version": 1 },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tracing::info!("Hane {household_id}: üye {member_id} çıkarıldı");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
//! Birikimler / varlıklar ve alım-satım kayıtları (KAKEIBO_PROMPT §8.7, DATA_MODEL.md §3.12–3.13).
|
||||||
|
//!
|
||||||
|
//! Kurallar:
|
||||||
|
//! - Varsayılan varlıklar hane açılınca gelir (TL, altınlar, USD, EUR); kullanıcı **döviz** ekleyebilir.
|
||||||
|
//! - Miktar/fiyat ölçekli tam sayı (`_e6`), tutar kuruş. TL varlığında miktar = tutar, birim fiyat 1.
|
||||||
|
//! - Eldeki miktar, maliyet ve kâr/zarar saklanmaz; işlemlerden hesaplanır (`holding`).
|
||||||
|
//! - Ekleme, düzenleme ve silmede tüm işlemler **tarih sırasıyla** yeniden denetlenir:
|
||||||
|
//! hiçbir tarihte eldekinden fazla satılmış olamaz (geriye tarihli kayıt / alım silme dahil).
|
||||||
|
//! - Silme soft delete. Hanenin tüm üyeleri yazabilir.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use mongodb::{
|
||||||
|
Database,
|
||||||
|
bson::{Bson, Document, Uuid, doc},
|
||||||
|
options::ReturnDocument,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::col,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{
|
||||||
|
asset::{Asset, AssetKind, AssetTx, TxType},
|
||||||
|
common::{new_meta, now, scope},
|
||||||
|
},
|
||||||
|
services::{
|
||||||
|
calendar::parse_date,
|
||||||
|
holding::{self, Holding, Move, Oversold},
|
||||||
|
period::MAX_AMOUNT,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Not üst sınırı (karakter)
|
||||||
|
pub const MAX_NOTE: usize = 200;
|
||||||
|
/// Miktar ve birim fiyat üst sınırı (e6): 1 milyar birim / 1 milyar TL
|
||||||
|
const MAX_E6: i64 = 1_000_000_000_000_000;
|
||||||
|
/// TL varlığında birim fiyat (1 TL)
|
||||||
|
const ONE_E6: i64 = 1_000_000;
|
||||||
|
|
||||||
|
pub struct NewTx {
|
||||||
|
pub tx_type: TxType,
|
||||||
|
pub date: String,
|
||||||
|
/// TL varlığında yok sayılır (tutardan türetilir)
|
||||||
|
pub quantity_e6: i64,
|
||||||
|
/// Verilmezse tutar ÷ miktar
|
||||||
|
pub unit_price_e6: Option<i64>,
|
||||||
|
pub total: i64,
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct TxChanges {
|
||||||
|
pub date: Option<String>,
|
||||||
|
pub quantity_e6: Option<i64>,
|
||||||
|
pub unit_price_e6: Option<i64>,
|
||||||
|
pub total: Option<i64>,
|
||||||
|
/// `Some("")` → notu kaldır
|
||||||
|
pub note: Option<String>,
|
||||||
|
pub version: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assets(db: &Database) -> mongodb::Collection<Asset> {
|
||||||
|
db.collection(col::ASSETS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn txs(db: &Database) -> mongodb::Collection<AssetTx> {
|
||||||
|
db.collection(col::ASSET_TRANSACTIONS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Varlıklar ----------
|
||||||
|
|
||||||
|
/// Hanenin varlıkları (sıralı) ve her birinin hesaplanmış durumu + işlem sayısı
|
||||||
|
pub async fn list(db: &Database, household_id: Uuid) -> AppResult<Vec<(Asset, Holding, usize)>> {
|
||||||
|
let mut cursor = assets(db)
|
||||||
|
.find(scope(household_id))
|
||||||
|
.sort(doc! { "sort_order": 1, "_id": 1 })
|
||||||
|
.await?;
|
||||||
|
let mut list = Vec::new();
|
||||||
|
while cursor.advance().await? {
|
||||||
|
list.push(cursor.deserialize_current()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut by_asset: HashMap<Uuid, Vec<AssetTx>> = HashMap::new();
|
||||||
|
for t in all_txs(db, household_id, None).await? {
|
||||||
|
by_asset.entry(t.asset_id).or_default().push(t);
|
||||||
|
}
|
||||||
|
list.into_iter()
|
||||||
|
.map(|a: Asset| {
|
||||||
|
let ts = by_asset.remove(&a.id).unwrap_or_default();
|
||||||
|
let h = holding::holding(&moves(&ts))
|
||||||
|
.map_err(|_| AppError::Internal(format!("varlık {} eksi bakiyede", a.id)))?;
|
||||||
|
Ok((a, h, ts.len()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Asset> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", id);
|
||||||
|
assets(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kullanıcının eklediği döviz (ör. GBP). Kod hanede tekil.
|
||||||
|
pub async fn create(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
code: &str,
|
||||||
|
name: &str,
|
||||||
|
unit_label: Option<&str>,
|
||||||
|
) -> AppResult<Asset> {
|
||||||
|
let code = code.trim().to_uppercase();
|
||||||
|
if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Döviz kodu 3 harf olmalı (ör. GBP)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let name = clean_name(name)?;
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("code", &code);
|
||||||
|
if assets(db).find_one(f).await?.is_some() {
|
||||||
|
return Err(AppError::Conflict("Bu varlık zaten var".into()));
|
||||||
|
}
|
||||||
|
let last = assets(db)
|
||||||
|
.find_one(scope(household_id))
|
||||||
|
.sort(doc! { "sort_order": -1 })
|
||||||
|
.await?
|
||||||
|
.map_or(0, |a| a.sort_order);
|
||||||
|
|
||||||
|
let mut d = new_meta(household_id);
|
||||||
|
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||||
|
d.insert("kind", "fx");
|
||||||
|
d.insert("code", &code);
|
||||||
|
d.insert("name", name);
|
||||||
|
d.insert(
|
||||||
|
"unit_label",
|
||||||
|
unit_label
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|u| !u.is_empty())
|
||||||
|
.unwrap_or(&code),
|
||||||
|
);
|
||||||
|
d.insert("sort_order", last + 10);
|
||||||
|
d.insert("is_active", true);
|
||||||
|
db.collection::<Document>(col::ASSETS).insert_one(d).await?;
|
||||||
|
assets(db)
|
||||||
|
.find_one(doc! { "_id": id })
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::Internal("eklenen varlık okunamadı".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ad ve aktiflik. Pasif varlık listede kalır (geçmiş işlemler) ama yeni işlem alınmaz.
|
||||||
|
pub async fn update(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
name: Option<String>,
|
||||||
|
is_active: Option<bool>,
|
||||||
|
version: Option<i32>,
|
||||||
|
) -> AppResult<Asset> {
|
||||||
|
let current = get(db, household_id, id).await?;
|
||||||
|
if version.is_some_and(|v| v != current.version) {
|
||||||
|
return Err(stale());
|
||||||
|
}
|
||||||
|
let mut set = doc! { "updated_at": now() };
|
||||||
|
if let Some(n) = name {
|
||||||
|
set.insert("name", clean_name(&n)?);
|
||||||
|
}
|
||||||
|
if let Some(a) = is_active {
|
||||||
|
set.insert("is_active", a);
|
||||||
|
}
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", id);
|
||||||
|
f.insert("version", current.version);
|
||||||
|
assets(db)
|
||||||
|
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||||
|
.return_document(ReturnDocument::After)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- İşlemler ----------
|
||||||
|
|
||||||
|
/// Varlığın işlemleri, en yeni başta
|
||||||
|
pub async fn list_txs(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
asset_id: Uuid,
|
||||||
|
) -> AppResult<Vec<AssetTx>> {
|
||||||
|
get(db, household_id, asset_id).await?;
|
||||||
|
let mut list = all_txs(db, household_id, Some(asset_id)).await?;
|
||||||
|
list.reverse();
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_tx(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
asset_id: Uuid,
|
||||||
|
t: NewTx,
|
||||||
|
) -> AppResult<AssetTx> {
|
||||||
|
let asset = get(db, household_id, asset_id).await?;
|
||||||
|
if !asset.is_active {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Bu varlık pasif; önce etkinleştirin".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let date = clean_date(&t.date)?;
|
||||||
|
let (quantity_e6, unit_price_e6, total) =
|
||||||
|
clean_amounts(&asset, t.quantity_e6, t.unit_price_e6, t.total)?;
|
||||||
|
let note = clean_note(t.note.as_deref())?;
|
||||||
|
|
||||||
|
// Yeni kayıt dahil sıralı denetim (yeni kayıt aynı günün sonuna eklenir)
|
||||||
|
let list = all_txs(db, household_id, Some(asset_id)).await?;
|
||||||
|
let pos = list.partition_point(|x| x.date <= date);
|
||||||
|
let mut ms = moves(&list);
|
||||||
|
ms.insert(
|
||||||
|
pos,
|
||||||
|
Move {
|
||||||
|
tx_type: t.tx_type,
|
||||||
|
quantity_e6,
|
||||||
|
total,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
check(&ms)?;
|
||||||
|
|
||||||
|
let mut d = new_meta(household_id);
|
||||||
|
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||||
|
d.insert("asset_id", asset_id);
|
||||||
|
d.insert("type", t.tx_type.as_str());
|
||||||
|
d.insert("date", date);
|
||||||
|
d.insert("quantity_e6", quantity_e6);
|
||||||
|
d.insert("unit_price_e6", unit_price_e6);
|
||||||
|
d.insert("total", total);
|
||||||
|
d.insert("note", note.map_or(Bson::Null, Bson::String));
|
||||||
|
d.insert("source_period_id", Bson::Null);
|
||||||
|
d.insert("created_by", user_id);
|
||||||
|
db.collection::<Document>(col::ASSET_TRANSACTIONS)
|
||||||
|
.insert_one(d)
|
||||||
|
.await?;
|
||||||
|
txs(db)
|
||||||
|
.find_one(doc! { "_id": id })
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::Internal("eklenen işlem okunamadı".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_tx(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
ch: TxChanges,
|
||||||
|
) -> AppResult<AssetTx> {
|
||||||
|
let current = get_tx(db, household_id, id).await?;
|
||||||
|
if ch.version.is_some_and(|v| v != current.version) {
|
||||||
|
return Err(stale());
|
||||||
|
}
|
||||||
|
let asset = get(db, household_id, current.asset_id).await?;
|
||||||
|
let date = match ch.date {
|
||||||
|
Some(d) => clean_date(&d)?,
|
||||||
|
None => current.date.clone(),
|
||||||
|
};
|
||||||
|
// Miktar ya da tutar değiştiyse birim fiyat (verilmediyse) yeniden hesaplanır
|
||||||
|
let amounts_changed = ch.quantity_e6.is_some() || ch.total.is_some();
|
||||||
|
let (quantity_e6, unit_price_e6, total) = clean_amounts(
|
||||||
|
&asset,
|
||||||
|
ch.quantity_e6.unwrap_or(current.quantity_e6),
|
||||||
|
ch.unit_price_e6
|
||||||
|
.or((!amounts_changed).then_some(current.unit_price_e6)),
|
||||||
|
ch.total.unwrap_or(current.total),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Değişmiş haliyle sıralı denetim
|
||||||
|
let mut list: Vec<AssetTx> = all_txs(db, household_id, Some(current.asset_id))
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|x| x.id != id)
|
||||||
|
.collect();
|
||||||
|
let mut changed = current.clone();
|
||||||
|
changed.date = date.clone();
|
||||||
|
changed.quantity_e6 = quantity_e6;
|
||||||
|
changed.total = total;
|
||||||
|
// MongoDB `_id` (UUID) sırası bayt sırasıdır
|
||||||
|
let pos =
|
||||||
|
list.partition_point(|x| (x.date.as_str(), x.id.bytes()) <= (date.as_str(), id.bytes()));
|
||||||
|
list.insert(pos, changed);
|
||||||
|
check(&moves(&list))?;
|
||||||
|
|
||||||
|
let mut set = doc! {
|
||||||
|
"updated_at": now(),
|
||||||
|
"date": date,
|
||||||
|
"quantity_e6": quantity_e6,
|
||||||
|
"unit_price_e6": unit_price_e6,
|
||||||
|
"total": total,
|
||||||
|
};
|
||||||
|
if let Some(n) = ch.note {
|
||||||
|
set.insert(
|
||||||
|
"note",
|
||||||
|
clean_note(Some(&n))?.map_or(Bson::Null, Bson::String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", id);
|
||||||
|
f.insert("version", current.version);
|
||||||
|
txs(db)
|
||||||
|
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||||
|
.return_document(ReturnDocument::After)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft delete. Bir alımı silmek sonraki satışları eksiye düşürecekse reddedilir.
|
||||||
|
pub async fn delete_tx(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
|
||||||
|
let current = get_tx(db, household_id, id).await?;
|
||||||
|
let rest: Vec<AssetTx> = all_txs(db, household_id, Some(current.asset_id))
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|x| x.id != id)
|
||||||
|
.collect();
|
||||||
|
if holding::holding(&moves(&rest)).is_err() {
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Bu alım silinirse sonraki satışlar eldeki miktarı aşar; önce satışı düzeltin".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let ts = now();
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", id);
|
||||||
|
txs(db)
|
||||||
|
.update_one(
|
||||||
|
f,
|
||||||
|
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- yardımcılar ----------
|
||||||
|
|
||||||
|
async fn get_tx(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<AssetTx> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", id);
|
||||||
|
txs(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hanenin (ya da bir varlığın) işlemleri, **tarih sırasıyla** (aynı gün: ekleniş sırası;
|
||||||
|
/// UUID v7 zamana göre sıralı)
|
||||||
|
async fn all_txs(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
asset_id: Option<Uuid>,
|
||||||
|
) -> AppResult<Vec<AssetTx>> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
if let Some(a) = asset_id {
|
||||||
|
f.insert("asset_id", a);
|
||||||
|
}
|
||||||
|
let mut cursor = txs(db).find(f).sort(doc! { "date": 1, "_id": 1 }).await?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while cursor.advance().await? {
|
||||||
|
out.push(cursor.deserialize_current()?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn moves(list: &[AssetTx]) -> Vec<Move> {
|
||||||
|
list.iter()
|
||||||
|
.map(|t| Move {
|
||||||
|
tx_type: t.tx_type,
|
||||||
|
quantity_e6: t.quantity_e6,
|
||||||
|
total: t.total,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(ms: &[Move]) -> AppResult<()> {
|
||||||
|
holding::holding(ms).map(|_| ()).map_err(|Oversold(_)| {
|
||||||
|
AppError::BadRequest("Satış miktarı o tarihte eldeki miktarı aşıyor".into())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (miktar, birim fiyat, tutar). TL varlığında miktar = tutar, fiyat 1.
|
||||||
|
fn clean_amounts(
|
||||||
|
asset: &Asset,
|
||||||
|
quantity_e6: i64,
|
||||||
|
unit_price_e6: Option<i64>,
|
||||||
|
total: i64,
|
||||||
|
) -> AppResult<(i64, i64, i64)> {
|
||||||
|
if total <= 0 || total > MAX_AMOUNT {
|
||||||
|
return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into()));
|
||||||
|
}
|
||||||
|
if asset.kind == AssetKind::Cash {
|
||||||
|
// kuruş → TL × 1e6
|
||||||
|
return Ok((total * 10_000, ONE_E6, total));
|
||||||
|
}
|
||||||
|
if quantity_e6 <= 0 || quantity_e6 > MAX_E6 {
|
||||||
|
return Err(AppError::BadRequest("Miktar sıfırdan büyük olmalı".into()));
|
||||||
|
}
|
||||||
|
let price = match unit_price_e6 {
|
||||||
|
Some(p) if p <= 0 || p > MAX_E6 => {
|
||||||
|
return Err(AppError::BadRequest("Birim fiyat geçersiz".into()));
|
||||||
|
}
|
||||||
|
Some(p) => p,
|
||||||
|
None => holding::unit_price_e6(total, quantity_e6),
|
||||||
|
};
|
||||||
|
Ok((quantity_e6, price, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_date(s: &str) -> AppResult<String> {
|
||||||
|
let s = s.trim();
|
||||||
|
parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
|
||||||
|
Ok(s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_name(s: &str) -> AppResult<String> {
|
||||||
|
let s = s.trim();
|
||||||
|
if s.is_empty() || s.chars().count() > 60 {
|
||||||
|
return Err(AppError::BadRequest("Ad 1–60 karakter olmalı".into()));
|
||||||
|
}
|
||||||
|
Ok(s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_note(n: Option<&str>) -> AppResult<Option<String>> {
|
||||||
|
match n.map(str::trim) {
|
||||||
|
None | Some("") => Ok(None),
|
||||||
|
Some(s) if s.chars().count() > MAX_NOTE => Err(AppError::BadRequest(format!(
|
||||||
|
"Not en fazla {MAX_NOTE} karakter olabilir"
|
||||||
|
))),
|
||||||
|
Some(s) => Ok(Some(s.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stale() -> AppError {
|
||||||
|
AppError::Conflict("Kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Dönem kapanışından birikim ----------
|
||||||
|
|
||||||
|
/// Kapanışta birikime aktarılan tutarın TL varlığına eklenmiş kaydı (dönem başına tek)
|
||||||
|
pub async fn period_savings_tx(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
) -> AppResult<Option<AssetTx>> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("source_period_id", period_id);
|
||||||
|
Ok(txs(db).find_one(f).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cash_asset(db: &Database, household_id: Uuid) -> AppResult<Asset> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("kind", "cash");
|
||||||
|
assets(db)
|
||||||
|
.find_one(f)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::Internal("hanenin TL varlığı yok".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kapalı dönemin `to_savings` tutarını TL varlığına **alım** olarak ekler (kullanıcı onayıyla).
|
||||||
|
/// Tarih = dönemin son günü. Aynı dönem ikinci kez eklenemez.
|
||||||
|
pub async fn add_period_savings(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
) -> AppResult<AssetTx> {
|
||||||
|
let p = crate::services::period::get(db, household_id, period_id).await?;
|
||||||
|
if p.status != crate::models::period::PeriodStatus::Closed {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Dönem kapanmadan birikime eklenemez".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let amount = p.to_savings.unwrap_or(0);
|
||||||
|
if amount <= 0 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Bu dönemde birikime aktarılan tutar yok".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if period_savings_tx(db, household_id, period_id)
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Bu dönemin birikimi zaten eklendi".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let cash = cash_asset(db, household_id).await?;
|
||||||
|
|
||||||
|
let mut d = new_meta(household_id);
|
||||||
|
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||||
|
d.insert("asset_id", cash.id);
|
||||||
|
d.insert("type", TxType::Buy.as_str());
|
||||||
|
d.insert("date", last_day(&p.end_date)?);
|
||||||
|
d.insert("quantity_e6", amount * 10_000);
|
||||||
|
d.insert("unit_price_e6", ONE_E6);
|
||||||
|
d.insert("total", amount);
|
||||||
|
d.insert("note", format!("{} kapanışı", p.name));
|
||||||
|
d.insert("source_period_id", period_id);
|
||||||
|
d.insert("created_by", user_id);
|
||||||
|
db.collection::<Document>(col::ASSET_TRANSACTIONS)
|
||||||
|
.insert_one(d)
|
||||||
|
.await?;
|
||||||
|
txs(db)
|
||||||
|
.find_one(doc! { "_id": id })
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::Internal("eklenen işlem okunamadı".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kapalı, birikime aktarılan tutarı olan ama henüz eklenmemiş dönemler (en yeni başta)
|
||||||
|
pub async fn pending_period_savings(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
) -> AppResult<Vec<crate::models::period::Period>> {
|
||||||
|
let linked: Vec<Uuid> = {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("source_period_id", doc! { "$ne": null });
|
||||||
|
let mut c = txs(db).find(f).await?;
|
||||||
|
let mut v = Vec::new();
|
||||||
|
while c.advance().await? {
|
||||||
|
if let Some(p) = c.deserialize_current()?.source_period_id {
|
||||||
|
v.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
};
|
||||||
|
Ok(crate::services::period::list(db, household_id)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| {
|
||||||
|
p.status == crate::models::period::PeriodStatus::Closed
|
||||||
|
&& p.to_savings.unwrap_or(0) > 0
|
||||||
|
&& !linked.contains(&p.id)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yeniden kapatmada bağlı kayda yapılacak iş (transaction'dan önce hazırlanıp denetlenir)
|
||||||
|
pub enum SavingsSync {
|
||||||
|
Update { id: Uuid, amount: i64, date: String },
|
||||||
|
Delete { id: Uuid },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bağlı kayıt varsa yeni tutar/tarihe göre işi hazırlar. Tutar 0 → silinir.
|
||||||
|
/// TL'de sonraki satışlar eldeki miktarı aşacaksa reddedilir (kapatma da yapılmaz).
|
||||||
|
pub async fn plan_savings_sync(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
amount: i64,
|
||||||
|
end_date: &str,
|
||||||
|
) -> AppResult<Option<SavingsSync>> {
|
||||||
|
let Some(linked) = period_savings_tx(db, household_id, period_id).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let date = last_day(end_date)?;
|
||||||
|
let mut list: Vec<AssetTx> = all_txs(db, household_id, Some(linked.asset_id))
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|x| x.id != linked.id)
|
||||||
|
.collect();
|
||||||
|
if amount > 0 {
|
||||||
|
let mut changed = linked.clone();
|
||||||
|
changed.date = date.clone();
|
||||||
|
changed.quantity_e6 = amount * 10_000;
|
||||||
|
changed.total = amount;
|
||||||
|
let pos = list.partition_point(|x| {
|
||||||
|
(x.date.as_str(), x.id.bytes()) <= (date.as_str(), linked.id.bytes())
|
||||||
|
});
|
||||||
|
list.insert(pos, changed);
|
||||||
|
}
|
||||||
|
if holding::holding(&moves(&list)).is_err() {
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Birikimden yapılan TL çıkışları yeni tutarı aşıyor; önce Birikimler'deki kayıtları düzeltin"
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(if amount > 0 {
|
||||||
|
SavingsSync::Update {
|
||||||
|
id: linked.id,
|
||||||
|
amount,
|
||||||
|
date,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SavingsSync::Delete { id: linked.id }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hazırlanan işi kapatma transaction'ı içinde uygular
|
||||||
|
pub async fn apply_savings_sync(
|
||||||
|
db: &Database,
|
||||||
|
session: &mut mongodb::ClientSession,
|
||||||
|
household_id: Uuid,
|
||||||
|
sync: &SavingsSync,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let ts = now();
|
||||||
|
let (id, set) = match sync {
|
||||||
|
SavingsSync::Update { id, amount, date } => (
|
||||||
|
*id,
|
||||||
|
doc! {
|
||||||
|
"date": date,
|
||||||
|
"quantity_e6": amount * 10_000,
|
||||||
|
"unit_price_e6": ONE_E6,
|
||||||
|
"total": amount,
|
||||||
|
"updated_at": ts,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SavingsSync::Delete { id } => (*id, doc! { "deleted_at": ts, "updated_at": ts }),
|
||||||
|
};
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", id);
|
||||||
|
db.collection::<Document>(col::ASSET_TRANSACTIONS)
|
||||||
|
.update_one(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||||
|
.session(session)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hariç bitiş → son gün (dahil)
|
||||||
|
fn last_day(end_date: &str) -> AppResult<String> {
|
||||||
|
let end =
|
||||||
|
parse_date(end_date).ok_or_else(|| AppError::Internal("dönem bitişi geçersiz".into()))?;
|
||||||
|
Ok(crate::services::calendar::format_date(
|
||||||
|
end - time::Duration::days(1),
|
||||||
|
))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,687 @@
|
|||||||
|
//! Banka / kredi kartı ekstresi içe aktarma (kullanıcı isteği; Faz 9 Adım 4).
|
||||||
|
//!
|
||||||
|
//! Dosya tarayıcıda okunur (Excel/CSV) ve sütunları eşleştirilir; buraya yalnızca
|
||||||
|
//! **tarih, açıklama, tutar, yön** gelir. Her satır bir türe ayrılır:
|
||||||
|
//! - `expense` → harcama (kategori gerekir)
|
||||||
|
//! - `refund` → **iade**: aynı kategoride eksi tutarlı kayıt (harcamadan düşer)
|
||||||
|
//! - `income` → dönemin planına **gelir** kalemi
|
||||||
|
//! - `ignore` → aktarılmaz (kart borcu ödemesi, kendi hesaplar arası virman…)
|
||||||
|
//!
|
||||||
|
//! Dönem: satırın tarihini içeren yazılabilir dönem; yoksa takvim ayı kadar **arşiv** dönemi
|
||||||
|
//! açılır (var olan dönemlerle çakışmayacak şekilde kırpılır). Kapalı dönem ve gelecek tarih → hata.
|
||||||
|
//! Tekrar: `import_key` (tarih|yön|tutar|sadeleştirilmiş açıklama) — aynı ekstre ya da örtüşen
|
||||||
|
//! iki ekstre yüklense de çift kayıt olmaz. Öneri: hanenin öğrenilmiş kuralları (açıklamanın ilk
|
||||||
|
//! iki anlamlı kelimesi), yoksa hazır ipuçları ("kredi kartı ödemesi" → yoksay), yoksa yön.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use mongodb::{
|
||||||
|
Database,
|
||||||
|
bson::{Bson, Document, Uuid, doc},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use time::Date;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::col,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{
|
||||||
|
common::{new_meta, now, scope},
|
||||||
|
period::Period,
|
||||||
|
plan_item::PlanKind,
|
||||||
|
},
|
||||||
|
services::{
|
||||||
|
calendar::{format_date, parse_date, today_tr},
|
||||||
|
catalog::{self, Catalog},
|
||||||
|
entry::MAX_DESCRIPTION,
|
||||||
|
period,
|
||||||
|
plan::{self, NewItem},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MAX_ROWS: usize = 5_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Source {
|
||||||
|
/// Kredi kartı: çıkış = harcama, giriş = iade ya da ödeme
|
||||||
|
Card,
|
||||||
|
/// Banka hesabı: çıkış = harcama, giriş = gelir
|
||||||
|
Account,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Direction {
|
||||||
|
In,
|
||||||
|
Out,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Kind {
|
||||||
|
Expense,
|
||||||
|
Refund,
|
||||||
|
Income,
|
||||||
|
Ignore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kind {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Kind::Expense => "expense",
|
||||||
|
Kind::Refund => "refund",
|
||||||
|
Kind::Income => "income",
|
||||||
|
Kind::Ignore => "ignore",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Row {
|
||||||
|
pub date: String,
|
||||||
|
pub description: String,
|
||||||
|
/// Kuruş, > 0
|
||||||
|
pub amount: i64,
|
||||||
|
pub direction: Direction,
|
||||||
|
/// Commit'te kullanıcının seçimi
|
||||||
|
pub kind: Option<Kind>,
|
||||||
|
pub category_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Metin sadeleştirme ----------
|
||||||
|
|
||||||
|
/// Türkçe harfleri ASCII'ye indir, küçült (bankalar çoğu zaman "ODEME", "SUT" yazar)
|
||||||
|
pub fn fold(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
// "İ".to_lowercase() = "i" + birleşik nokta (U+0307) → önce doğrudan "i"
|
||||||
|
.map(|c| if c == 'İ' { 'i' } else { c })
|
||||||
|
.flat_map(|c| c.to_lowercase())
|
||||||
|
.filter(|c| *c != '\u{307}')
|
||||||
|
.map(|c| match c {
|
||||||
|
'ı' | 'i' | 'î' => 'i',
|
||||||
|
'ş' => 's',
|
||||||
|
'ğ' => 'g',
|
||||||
|
'ü' | 'û' => 'u',
|
||||||
|
'ö' => 'o',
|
||||||
|
'ç' => 'c',
|
||||||
|
'â' => 'a',
|
||||||
|
c => c,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anlamlı kelimeler: harf/rakam dışı ayırıcı; yalnızca rakam ve tek harf atılır
|
||||||
|
fn words(desc: &str) -> Vec<String> {
|
||||||
|
fold(desc)
|
||||||
|
.split(|c: char| !c.is_alphanumeric())
|
||||||
|
.filter(|w| w.len() >= 2 && !w.chars().all(|c| c.is_ascii_digit()))
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kural anahtarı: ilk iki anlamlı kelime (`MIGROS 1234 ISTANBUL TR` → `migros istanbul`)
|
||||||
|
pub fn rule_key(desc: &str) -> String {
|
||||||
|
words(desc)
|
||||||
|
.into_iter()
|
||||||
|
.take(2)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// İlk kelime eşleşmesinde kullanılmayacak genel sözcükler (satıcı adı değil)
|
||||||
|
const GENERIC: &[&str] = &[
|
||||||
|
"iade",
|
||||||
|
"odeme",
|
||||||
|
"pos",
|
||||||
|
"isyeri",
|
||||||
|
"eft",
|
||||||
|
"havale",
|
||||||
|
"fast",
|
||||||
|
"virman",
|
||||||
|
"kredi",
|
||||||
|
"kart",
|
||||||
|
"hesap",
|
||||||
|
"para",
|
||||||
|
"borc",
|
||||||
|
"harcama",
|
||||||
|
"alisveris",
|
||||||
|
"nakit",
|
||||||
|
"atm",
|
||||||
|
"taksit",
|
||||||
|
"ucret",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Öneri: tam kural (ilk 2 kelime); yoksa ilk kelimesi aynı kurallar hepsi aynı şeyi
|
||||||
|
/// söylüyorsa o (ör. "migros istanbul" öğrenildi → "MIGROS KADIKOY" de Market)
|
||||||
|
fn suggest<'a>(rules: &'a HashMap<String, Rule>, desc: &str) -> Option<&'a Rule> {
|
||||||
|
if let Some(r) = rules.get(&rule_key(desc)) {
|
||||||
|
return Some(r);
|
||||||
|
}
|
||||||
|
let first = words(desc).into_iter().next()?;
|
||||||
|
if first.len() < 3 || GENERIC.contains(&first.as_str()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut same = rules
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.pattern.split(' ').next() == Some(first.as_str()));
|
||||||
|
let r = same.next()?;
|
||||||
|
same.all(|x| x.kind == r.kind && x.category_id == r.category_id)
|
||||||
|
.then_some(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tekrar anahtarı (tüm açıklama, sadeleştirilmiş)
|
||||||
|
pub fn import_key(date: &str, dir: Direction, amount: i64, desc: &str) -> String {
|
||||||
|
let d = match dir {
|
||||||
|
Direction::In => "in",
|
||||||
|
Direction::Out => "out",
|
||||||
|
};
|
||||||
|
format!("{date}|{d}|{amount}|{}", words(desc).join(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hazır öneri (kural yoksa): ödeme/virman → yoksay; kartta giriş → iade; hesapta giriş → gelir
|
||||||
|
pub fn default_kind(source: Source, dir: Direction, desc: &str) -> Kind {
|
||||||
|
let f = words(desc).join(" ");
|
||||||
|
let transfer = [
|
||||||
|
"kredi karti",
|
||||||
|
"kart odeme",
|
||||||
|
"kk odeme",
|
||||||
|
"virman",
|
||||||
|
"hesaplar arasi",
|
||||||
|
"kendi hesab",
|
||||||
|
"hesabima",
|
||||||
|
"borc odeme",
|
||||||
|
"odemeniz icin",
|
||||||
|
"tahsilat",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|h| f.contains(h));
|
||||||
|
match (source, dir) {
|
||||||
|
_ if transfer => Kind::Ignore,
|
||||||
|
(_, Direction::Out) => Kind::Expense,
|
||||||
|
(Source::Card, Direction::In) => {
|
||||||
|
if f.contains("odeme") {
|
||||||
|
Kind::Ignore
|
||||||
|
} else {
|
||||||
|
Kind::Refund
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Source::Account, Direction::In) => Kind::Income,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Dönem ----------
|
||||||
|
|
||||||
|
fn first_of_month(d: Date) -> Date {
|
||||||
|
d.replace_day(1).expect("1. gün")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_month(d: Date) -> Date {
|
||||||
|
let (y, m) = if d.month() == time::Month::December {
|
||||||
|
(d.year() + 1, time::Month::January)
|
||||||
|
} else {
|
||||||
|
(d.year(), d.month().next())
|
||||||
|
};
|
||||||
|
Date::from_calendar_date(y, m, 1).expect("geçerli ay")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Satırın dönemi
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Target {
|
||||||
|
/// Var olan yazılabilir dönem (açık, düzeltmede ya da arşiv)
|
||||||
|
Existing {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Açılacak arşiv dönemi [start, end)
|
||||||
|
Archive {
|
||||||
|
start: String,
|
||||||
|
end: String,
|
||||||
|
},
|
||||||
|
Blocked(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tarih → dönem: içeren dönem; yoksa takvim ayı, var olan dönemlerin dışında kalan kısmı
|
||||||
|
pub fn target(periods: &[Period], date: &str, today: &str) -> Target {
|
||||||
|
if date > today {
|
||||||
|
return Target::Blocked("Gelecek tarihli satır".into());
|
||||||
|
}
|
||||||
|
if let Some(p) = periods
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.start_date.as_str() <= date && date < p.end_date.as_str())
|
||||||
|
{
|
||||||
|
return if p.status.is_writable() {
|
||||||
|
Target::Existing {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Target::Blocked(format!(
|
||||||
|
"Dönem kapalı ({}); düzeltme için yeniden açın",
|
||||||
|
p.name
|
||||||
|
))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let d = parse_date(date).expect("doğrulanmış tarih");
|
||||||
|
let (ms, me) = (format_date(first_of_month(d)), format_date(next_month(d)));
|
||||||
|
// Ayın içinde, tarihten önce biten en geç dönem / tarihten sonra başlayan en erken dönem
|
||||||
|
let start = periods
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.end_date.as_str())
|
||||||
|
.filter(|e| *e <= date && *e > ms.as_str())
|
||||||
|
.max()
|
||||||
|
.map_or(ms.clone(), str::to_string);
|
||||||
|
let end = periods
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.start_date.as_str())
|
||||||
|
.filter(|s| *s > date && *s < me.as_str())
|
||||||
|
.min()
|
||||||
|
.map_or(me.clone(), str::to_string);
|
||||||
|
Target::Archive { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Kurallar ----------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct Rule {
|
||||||
|
pattern: String,
|
||||||
|
kind: Kind,
|
||||||
|
#[serde(default)]
|
||||||
|
category_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rules(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, Rule>> {
|
||||||
|
let mut c = db
|
||||||
|
.collection::<Rule>(col::IMPORT_RULES)
|
||||||
|
.find(scope(household_id))
|
||||||
|
.await?;
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
while c.advance().await? {
|
||||||
|
let r = c.deserialize_current()?;
|
||||||
|
out.insert(r.pattern.clone(), r);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Var olan içe aktarma anahtarları (harcama + plan kalemi) → adet
|
||||||
|
async fn existing_keys(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, usize>> {
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
for coll in [col::ENTRIES, col::PLAN_ITEMS] {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("import_key", doc! { "$type": "string" });
|
||||||
|
let mut c = db
|
||||||
|
.collection::<Document>(coll)
|
||||||
|
.find(f)
|
||||||
|
.projection(doc! { "import_key": 1 })
|
||||||
|
.await?;
|
||||||
|
while c.advance().await? {
|
||||||
|
if let Ok(k) = c.current().get_str("import_key") {
|
||||||
|
*out.entry(k.to_string()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(m: &mut HashMap<String, usize>, k: &str) -> bool {
|
||||||
|
match m.get_mut(k) {
|
||||||
|
Some(n) if *n > 0 => {
|
||||||
|
*n -= 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(r: &Row) -> Result<(), String> {
|
||||||
|
parse_date(&r.date).ok_or_else(|| format!("Tarih geçersiz: {}", r.date))?;
|
||||||
|
if r.amount <= 0 {
|
||||||
|
return Err("Tutar sıfırdan büyük olmalı".into());
|
||||||
|
}
|
||||||
|
period::validate_amount(r.amount).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Önizleme ----------
|
||||||
|
|
||||||
|
pub struct PreviewRow {
|
||||||
|
pub kind: Kind,
|
||||||
|
pub category_id: Option<Uuid>,
|
||||||
|
/// Öneri hanenin öğrenilmiş kuralından mı?
|
||||||
|
pub from_rule: bool,
|
||||||
|
pub duplicate: bool,
|
||||||
|
pub target: Target,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Preview {
|
||||||
|
pub rows: Vec<PreviewRow>,
|
||||||
|
/// Açılacak arşiv dönemleri [start, end)
|
||||||
|
pub archives: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_rows(rows: &[Row]) -> AppResult<()> {
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("Aktarılacak satır yok".into()));
|
||||||
|
}
|
||||||
|
if rows.len() > MAX_ROWS {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Bir seferde en fazla {MAX_ROWS} satır aktarılabilir"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn preview(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
source: Source,
|
||||||
|
rows: &[Row],
|
||||||
|
) -> AppResult<Preview> {
|
||||||
|
check_rows(rows)?;
|
||||||
|
let periods = period::list(db, household_id).await?;
|
||||||
|
let rules = rules(db, household_id).await?;
|
||||||
|
let mut keys = existing_keys(db, household_id).await?;
|
||||||
|
let today = format_date(today_tr());
|
||||||
|
let mut archives: Vec<(String, String)> = Vec::new();
|
||||||
|
let out = rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
let target = match validate(r) {
|
||||||
|
Err(e) => Target::Blocked(e),
|
||||||
|
Ok(()) => target(&periods, &r.date, &today),
|
||||||
|
};
|
||||||
|
if let Target::Archive { start, end } = &target
|
||||||
|
&& !archives.iter().any(|(s, _)| s == start)
|
||||||
|
{
|
||||||
|
archives.push((start.clone(), end.clone()));
|
||||||
|
}
|
||||||
|
let duplicate = matches!(target, Target::Existing { .. } | Target::Archive { .. })
|
||||||
|
&& take(
|
||||||
|
&mut keys,
|
||||||
|
&import_key(&r.date, r.direction, r.amount, &r.description),
|
||||||
|
);
|
||||||
|
let rule = suggest(&rules, &r.description);
|
||||||
|
PreviewRow {
|
||||||
|
kind: rule.map_or_else(
|
||||||
|
|| default_kind(source, r.direction, &r.description),
|
||||||
|
|x| x.kind,
|
||||||
|
),
|
||||||
|
category_id: rule.and_then(|x| x.category_id),
|
||||||
|
from_rule: rule.is_some(),
|
||||||
|
duplicate,
|
||||||
|
target,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
archives.sort();
|
||||||
|
Ok(Preview {
|
||||||
|
rows: out,
|
||||||
|
archives,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Aktarma ----------
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Report {
|
||||||
|
pub entries: usize,
|
||||||
|
pub refunds: usize,
|
||||||
|
pub incomes: usize,
|
||||||
|
pub ignored: usize,
|
||||||
|
pub duplicates: usize,
|
||||||
|
/// Açılan arşiv dönemlerinin adları
|
||||||
|
pub archives: Vec<String>,
|
||||||
|
/// (satır sırası 0'dan, neden)
|
||||||
|
pub errors: Vec<(usize, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn commit(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
payment_method_id: Option<Uuid>,
|
||||||
|
rows: &[Row],
|
||||||
|
) -> AppResult<Report> {
|
||||||
|
check_rows(rows)?;
|
||||||
|
if let Some(pm) = payment_method_id {
|
||||||
|
catalog::find(db, household_id, Catalog::PaymentMethods, pm)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::BadRequest("Ödeme türü bulunamadı".into()))?;
|
||||||
|
}
|
||||||
|
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
|
||||||
|
let mut periods = period::list(db, household_id).await?;
|
||||||
|
let mut keys = existing_keys(db, household_id).await?;
|
||||||
|
let today = format_date(today_tr());
|
||||||
|
let mut report = Report::default();
|
||||||
|
let mut entries: Vec<Document> = Vec::new();
|
||||||
|
let mut incomes: Vec<(usize, Uuid, NewItem, String)> = Vec::new();
|
||||||
|
let mut learned: HashMap<String, (Kind, Option<Uuid>)> = HashMap::new();
|
||||||
|
|
||||||
|
for (i, r) in rows.iter().enumerate() {
|
||||||
|
let kind = r.kind.unwrap_or(Kind::Ignore);
|
||||||
|
let pattern = rule_key(&r.description);
|
||||||
|
if kind == Kind::Ignore {
|
||||||
|
report.ignored += 1;
|
||||||
|
if !pattern.is_empty() {
|
||||||
|
learned.insert(pattern, (kind, None));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(e) = validate(r) {
|
||||||
|
report.errors.push((i, e));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = import_key(&r.date, r.direction, r.amount, &r.description);
|
||||||
|
let pid = match target(&periods, &r.date, &today) {
|
||||||
|
Target::Blocked(e) => {
|
||||||
|
report.errors.push((i, e));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Target::Existing { id, .. } => id,
|
||||||
|
Target::Archive { start, end } => {
|
||||||
|
let p = period::create_archive(
|
||||||
|
db,
|
||||||
|
household_id,
|
||||||
|
parse_date(&start).expect("tarih"),
|
||||||
|
parse_date(&end).expect("tarih"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
report.archives.push(p.name.clone());
|
||||||
|
let id = p.id;
|
||||||
|
periods.push(p);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if take(&mut keys, &key) {
|
||||||
|
report.duplicates += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let desc: String = r.description.trim().chars().take(MAX_DESCRIPTION).collect();
|
||||||
|
match kind {
|
||||||
|
Kind::Expense | Kind::Refund => {
|
||||||
|
let Some(cat) = r.category_id.and_then(|c| cats.iter().find(|x| x.id == c)) else {
|
||||||
|
report.errors.push((i, "Kategori seçin".into()));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(group) = cat.group else {
|
||||||
|
report.errors.push((i, "Kategorinin grubu yok".into()));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let amount = if kind == Kind::Refund {
|
||||||
|
-r.amount
|
||||||
|
} else {
|
||||||
|
r.amount
|
||||||
|
};
|
||||||
|
let mut d = new_meta(household_id);
|
||||||
|
d.insert("period_id", pid);
|
||||||
|
d.insert("date", &r.date);
|
||||||
|
d.insert("amount", amount);
|
||||||
|
d.insert("group", group.as_str());
|
||||||
|
d.insert("category_id", cat.id);
|
||||||
|
d.insert(
|
||||||
|
"payment_method_id",
|
||||||
|
payment_method_id.map_or(Bson::Null, Bson::from),
|
||||||
|
);
|
||||||
|
d.insert(
|
||||||
|
"description",
|
||||||
|
if desc.is_empty() {
|
||||||
|
Bson::Null
|
||||||
|
} else {
|
||||||
|
Bson::String(desc)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
d.insert("created_by", user_id);
|
||||||
|
d.insert("import_key", &key);
|
||||||
|
entries.push(d);
|
||||||
|
if kind == Kind::Refund {
|
||||||
|
report.refunds += 1;
|
||||||
|
} else {
|
||||||
|
report.entries += 1;
|
||||||
|
}
|
||||||
|
if !pattern.is_empty() {
|
||||||
|
learned.insert(pattern, (kind, Some(cat.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Income => {
|
||||||
|
// Plan kalemi adı en fazla 60 karakter
|
||||||
|
let name: String = if desc.is_empty() {
|
||||||
|
"Gelir".into()
|
||||||
|
} else {
|
||||||
|
desc.chars().take(60).collect()
|
||||||
|
};
|
||||||
|
incomes.push((
|
||||||
|
i,
|
||||||
|
pid,
|
||||||
|
NewItem {
|
||||||
|
kind: PlanKind::Income,
|
||||||
|
name,
|
||||||
|
amount: r.amount,
|
||||||
|
date: Some(r.date.clone()),
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
));
|
||||||
|
if !pattern.is_empty() {
|
||||||
|
learned.insert(pattern, (kind, None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Ignore => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !entries.is_empty() {
|
||||||
|
db.collection::<Document>(col::ENTRIES)
|
||||||
|
.insert_many(entries)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
for (i, pid, item, key) in incomes {
|
||||||
|
match plan::create(db, household_id, pid, item).await {
|
||||||
|
Ok(p) => {
|
||||||
|
db.collection::<Document>(col::PLAN_ITEMS)
|
||||||
|
.update_one(doc! { "_id": p.id }, doc! { "$set": { "import_key": key } })
|
||||||
|
.await?;
|
||||||
|
report.incomes += 1;
|
||||||
|
}
|
||||||
|
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => report.errors.push((i, m)),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurallar: bu aktarımdaki seçimler sonraki ekstreler için öneri olur
|
||||||
|
let ts = now();
|
||||||
|
for (pattern, (kind, category_id)) in learned {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("pattern", &pattern);
|
||||||
|
db.collection::<Document>(col::IMPORT_RULES)
|
||||||
|
.update_one(
|
||||||
|
f,
|
||||||
|
doc! {
|
||||||
|
"$set": {
|
||||||
|
"kind": kind.as_str(),
|
||||||
|
"category_id": category_id.map_or(Bson::Null, Bson::from),
|
||||||
|
"updated_at": ts,
|
||||||
|
},
|
||||||
|
"$inc": { "uses": 1, "version": 1 },
|
||||||
|
"$setOnInsert": { "_id": crate::models::common::new_id(), "created_at": ts },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.upsert(true)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
report.errors.sort_by_key(|(i, _)| *i);
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folding_and_keys() {
|
||||||
|
assert_eq!(fold("ÖDEME Şükrü İĞNE"), "odeme sukru igne");
|
||||||
|
assert_eq!(rule_key("MIGROS 1234 ISTANBUL TR"), "migros istanbul");
|
||||||
|
assert_eq!(rule_key("A101 MAGAZACILIK*456"), "a101 magazacilik");
|
||||||
|
assert_eq!(rule_key("12/11 *"), "");
|
||||||
|
assert_eq!(
|
||||||
|
import_key("2026-11-13", Direction::Out, 4590, " Migros ŞUBE 12 "),
|
||||||
|
"2026-11-13|out|4590|migros sube"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suggestion_falls_back_to_first_word() {
|
||||||
|
let market = Uuid::new();
|
||||||
|
let ulasim = Uuid::new();
|
||||||
|
let rule = |p: &str, c: Uuid| {
|
||||||
|
(
|
||||||
|
p.to_string(),
|
||||||
|
Rule {
|
||||||
|
pattern: p.into(),
|
||||||
|
kind: Kind::Expense,
|
||||||
|
category_id: Some(c),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut rules: HashMap<String, Rule> =
|
||||||
|
[rule("migros istanbul", market), rule("iade migros", market)].into();
|
||||||
|
assert_eq!(
|
||||||
|
suggest(&rules, "MIGROS 99 ISTANBUL").unwrap().category_id,
|
||||||
|
Some(market),
|
||||||
|
"tam"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
suggest(&rules, "MİGROS KADIKÖY").unwrap().category_id,
|
||||||
|
Some(market),
|
||||||
|
"ilk kelime"
|
||||||
|
);
|
||||||
|
assert!(suggest(&rules, "IADE SHELL").is_none(), "genel ilk kelime");
|
||||||
|
assert!(suggest(&rules, "A101 SUBE").is_none());
|
||||||
|
// İlk kelimesi aynı ama farklı kategoriler → öneri yok
|
||||||
|
rules.extend([rule("migros jet", ulasim)]);
|
||||||
|
assert!(suggest(&rules, "MIGROS ANKARA").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_kinds() {
|
||||||
|
use Direction::*;
|
||||||
|
use Source::*;
|
||||||
|
assert_eq!(default_kind(Card, Out, "MIGROS ISTANBUL"), Kind::Expense);
|
||||||
|
assert_eq!(default_kind(Card, In, "IADE MIGROS"), Kind::Refund);
|
||||||
|
assert_eq!(
|
||||||
|
default_kind(Card, In, "ÖDEMENİZ İÇİN TEŞEKKÜRLER"),
|
||||||
|
Kind::Ignore
|
||||||
|
);
|
||||||
|
assert_eq!(default_kind(Account, In, "MAAŞ ÖDEMESİ ACME"), Kind::Income);
|
||||||
|
assert_eq!(
|
||||||
|
default_kind(Account, Out, "KREDİ KARTI ÖDEMESİ"),
|
||||||
|
Kind::Ignore
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
default_kind(Account, Out, "Hesaplar arası virman"),
|
||||||
|
Kind::Ignore
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
default_kind(Account, Out, "ELEKTRIK FATURASI"),
|
||||||
|
Kind::Expense
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
//! Bütçe ve dönem özeti (KAKEIBO_PROMPT §8.3, DATA_MODEL.md §3.8, §5).
|
||||||
|
//!
|
||||||
|
//! - Her grup için **aylık** tutar (`budgets`, `scope: "month"`).
|
||||||
|
//! - Haftalık tutar saklanmaz; aylık tutar hafta gün sayısına göre bölünür.
|
||||||
|
//! Kullanıcı bir haftayı elle değiştirirse yalnızca o kayıt saklanır (`scope: "week"`);
|
||||||
|
//! kalan tutar diğer haftalara gün oranında dağıtılır → haftaların toplamı = aylık.
|
||||||
|
//! - Özet: gelir − sabit gider − birikim − devreden borç + devreden artı = **harcanabilir**.
|
||||||
|
//! - Gerçekleşen harcama (`entries`) saklanmaz; her istekte toplanır:
|
||||||
|
//! özette toplam + kalan, bütçe görünümünde grup ve hafta bazında.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use mongodb::{
|
||||||
|
Database,
|
||||||
|
bson::{Bson, Document, Uuid, doc},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::col,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{
|
||||||
|
common::{new_meta, now, scope},
|
||||||
|
group::Group,
|
||||||
|
period::{Period, PeriodStatus},
|
||||||
|
},
|
||||||
|
services::{
|
||||||
|
calendar::{format_date, parse_date, weeks},
|
||||||
|
money::split_proportional,
|
||||||
|
period,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Gruplara göre tutarlar (kuruş), `Group::ALL` sırasıyla.
|
||||||
|
pub type GroupAmounts = [i64; 4];
|
||||||
|
|
||||||
|
pub struct Summary {
|
||||||
|
pub income_total: i64,
|
||||||
|
pub fixed_expense_total: i64,
|
||||||
|
pub savings_goal: i64,
|
||||||
|
pub carryover_debt: i64,
|
||||||
|
pub carryover_surplus: i64,
|
||||||
|
pub spendable: i64,
|
||||||
|
pub budget_total: i64,
|
||||||
|
/// Dönemde gerçekleşen harcama (silinenler hariç)
|
||||||
|
pub spent_total: i64,
|
||||||
|
/// Aynısı gruplara göre (`Group::ALL` sırasıyla)
|
||||||
|
pub spent_by_group: GroupAmounts,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WeekBudget {
|
||||||
|
pub index: u32,
|
||||||
|
pub start_date: String,
|
||||||
|
pub last_date: String,
|
||||||
|
pub days: u32,
|
||||||
|
pub amount: i64,
|
||||||
|
pub is_manual: bool,
|
||||||
|
/// Bu haftada bu grupta gerçekleşen harcama
|
||||||
|
pub spent: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GroupBudget {
|
||||||
|
pub group: Group,
|
||||||
|
pub monthly: i64,
|
||||||
|
/// Dönem boyunca bu grupta gerçekleşen harcama
|
||||||
|
pub spent: i64,
|
||||||
|
pub weeks: Vec<WeekBudget>,
|
||||||
|
/// Elle girilen haftaların toplamı aylık tutarı aşıyor mu?
|
||||||
|
pub weekly_over: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SuggestMethod {
|
||||||
|
/// Hanenin varsayılan yüzdeleri × harcanabilir tutar
|
||||||
|
Default,
|
||||||
|
/// Önceki dönemde gerçekleşen harcamalar
|
||||||
|
Previous,
|
||||||
|
/// Son 3 dönemin gerçekleşen harcama ortalaması
|
||||||
|
Avg3,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Suggestion {
|
||||||
|
pub available: bool,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub amounts: GroupAmounts,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BudgetDoc {
|
||||||
|
group: Group,
|
||||||
|
scope: String,
|
||||||
|
#[serde(default)]
|
||||||
|
week_start: Option<String>,
|
||||||
|
amount: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Özet ----------
|
||||||
|
|
||||||
|
pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Summary> {
|
||||||
|
let p = period::get(db, household_id, period_id).await?;
|
||||||
|
|
||||||
|
let mut income_total = 0;
|
||||||
|
let mut fixed_expense_total = 0;
|
||||||
|
for (kind, total) in sum_by(
|
||||||
|
db,
|
||||||
|
col::PLAN_ITEMS,
|
||||||
|
household_id,
|
||||||
|
doc! { "period_id": period_id },
|
||||||
|
"$kind",
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
match kind.as_str() {
|
||||||
|
"income" => income_total = total,
|
||||||
|
"expense" => fixed_expense_total = total,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut carryover_debt = 0;
|
||||||
|
let mut carryover_surplus = 0;
|
||||||
|
for (kind, total) in sum_by(
|
||||||
|
db,
|
||||||
|
col::CARRYOVERS,
|
||||||
|
household_id,
|
||||||
|
doc! { "to_period_id": period_id },
|
||||||
|
"$kind",
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
match kind.as_str() {
|
||||||
|
"debt" => carryover_debt = total,
|
||||||
|
"surplus" => carryover_surplus = total,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let budget_total = monthly_amounts(db, household_id, period_id)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.sum();
|
||||||
|
let spendable =
|
||||||
|
income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus;
|
||||||
|
let by_group = sum_by(
|
||||||
|
db,
|
||||||
|
col::ENTRIES,
|
||||||
|
household_id,
|
||||||
|
doc! { "period_id": period_id },
|
||||||
|
"$group",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let spent_by_group = Group::ALL.map(|g| by_group.get(g.as_str()).copied().unwrap_or(0));
|
||||||
|
let spent_total = spent_by_group.iter().sum();
|
||||||
|
|
||||||
|
Ok(Summary {
|
||||||
|
income_total,
|
||||||
|
fixed_expense_total,
|
||||||
|
savings_goal: p.savings_goal,
|
||||||
|
carryover_debt,
|
||||||
|
carryover_surplus,
|
||||||
|
spendable,
|
||||||
|
budget_total,
|
||||||
|
spent_total,
|
||||||
|
spent_by_group,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Bütçe görünümü ----------
|
||||||
|
|
||||||
|
pub async fn view(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
) -> AppResult<Vec<GroupBudget>> {
|
||||||
|
let p = period::get(db, household_id, period_id).await?;
|
||||||
|
let docs = load(db, household_id, period_id).await?;
|
||||||
|
let (start, end) = dates(&p)?;
|
||||||
|
let period_weeks = weeks(start, end);
|
||||||
|
let spent = spent_by_group_and_date(db, household_id, period_id).await?;
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(4);
|
||||||
|
for group in Group::ALL {
|
||||||
|
let monthly = docs
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.group == group && d.scope == "month")
|
||||||
|
.map_or(0, |d| d.amount);
|
||||||
|
// Elle girilmiş haftalar (dönem tarihleri değiştiyse eşleşmeyen eski kayıtlar yok sayılır)
|
||||||
|
let manual: HashMap<String, i64> = docs
|
||||||
|
.iter()
|
||||||
|
.filter(|d| d.group == group && d.scope == "week")
|
||||||
|
.filter_map(|d| d.week_start.clone().map(|w| (w, d.amount)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let manual_total: i64 = period_weeks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|w| manual.get(&format_date(w.start)))
|
||||||
|
.sum();
|
||||||
|
let auto_days: Vec<u64> = period_weeks
|
||||||
|
.iter()
|
||||||
|
.filter(|w| !manual.contains_key(&format_date(w.start)))
|
||||||
|
.map(|w| u64::from(w.days))
|
||||||
|
.collect();
|
||||||
|
let mut auto = split_proportional(monthly - manual_total, &auto_days).into_iter();
|
||||||
|
|
||||||
|
let weeks = period_weeks
|
||||||
|
.iter()
|
||||||
|
.map(|w| {
|
||||||
|
let key = format_date(w.start);
|
||||||
|
let last = format_date(w.last);
|
||||||
|
// Tarihler "YYYY-MM-DD" → metin karşılaştırması tarih sırasıyla aynı
|
||||||
|
let week_spent = spent
|
||||||
|
.iter()
|
||||||
|
.filter(|(g, d, _)| *g == group && *d >= key && *d <= last)
|
||||||
|
.map(|(_, _, a)| a)
|
||||||
|
.sum();
|
||||||
|
let (amount, is_manual) = match manual.get(&key) {
|
||||||
|
Some(&a) => (a, true),
|
||||||
|
None => (auto.next().unwrap_or(0), false),
|
||||||
|
};
|
||||||
|
WeekBudget {
|
||||||
|
index: w.index,
|
||||||
|
start_date: key,
|
||||||
|
last_date: last,
|
||||||
|
days: w.days,
|
||||||
|
amount,
|
||||||
|
is_manual,
|
||||||
|
spent: week_spent,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
out.push(GroupBudget {
|
||||||
|
group,
|
||||||
|
monthly,
|
||||||
|
spent: spent
|
||||||
|
.iter()
|
||||||
|
.filter(|(g, _, _)| *g == group)
|
||||||
|
.map(|(_, _, a)| a)
|
||||||
|
.sum(),
|
||||||
|
weeks,
|
||||||
|
weekly_over: manual_total > monthly,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Yazma ----------
|
||||||
|
|
||||||
|
/// 4 grubun aylık tutarını birlikte kaydeder (elle girilmiş haftalar korunur).
|
||||||
|
pub async fn set_monthly(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
amounts: GroupAmounts,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
period::get_open(db, household_id, period_id).await?;
|
||||||
|
for a in amounts {
|
||||||
|
period::validate_amount(a)?;
|
||||||
|
}
|
||||||
|
for (group, amount) in Group::ALL.into_iter().zip(amounts) {
|
||||||
|
upsert(db, household_id, period_id, group, "month", None, amount).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bir haftayı elle ayarlar; `amount = None` → otomatiğe döner.
|
||||||
|
pub async fn set_week(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
group: Group,
|
||||||
|
week_start: &str,
|
||||||
|
amount: Option<i64>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let p = period::get_open(db, household_id, period_id).await?;
|
||||||
|
let (start, end) = dates(&p)?;
|
||||||
|
let valid =
|
||||||
|
parse_date(week_start).is_some_and(|d| weeks(start, end).iter().any(|w| w.start == d));
|
||||||
|
if !valid {
|
||||||
|
return Err(AppError::BadRequest("Hafta bu döneme ait değil".into()));
|
||||||
|
}
|
||||||
|
match amount {
|
||||||
|
Some(a) => {
|
||||||
|
period::validate_amount(a)?;
|
||||||
|
upsert(
|
||||||
|
db,
|
||||||
|
household_id,
|
||||||
|
period_id,
|
||||||
|
group,
|
||||||
|
"week",
|
||||||
|
Some(week_start),
|
||||||
|
a,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("period_id", period_id);
|
||||||
|
f.insert("group", group.as_str());
|
||||||
|
f.insert("scope", "week");
|
||||||
|
f.insert("week_start", week_start);
|
||||||
|
let ts = now();
|
||||||
|
db.collection::<Document>(col::BUDGETS)
|
||||||
|
.update_many(
|
||||||
|
f,
|
||||||
|
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Öneri ----------
|
||||||
|
|
||||||
|
pub async fn suggest(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
method: SuggestMethod,
|
||||||
|
) -> AppResult<Suggestion> {
|
||||||
|
let p = period::get(db, household_id, period_id).await?;
|
||||||
|
match method {
|
||||||
|
SuggestMethod::Default => {
|
||||||
|
let s = summary(db, household_id, period_id).await?;
|
||||||
|
if s.spendable <= 0 {
|
||||||
|
return Ok(unavailable(
|
||||||
|
"Harcanabilir tutar sıfır ya da negatif. Önce gelir, gider ve birikim hedefini girin.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let pct = split_percentages(db, household_id).await?;
|
||||||
|
let parts = split_proportional(s.spendable, &pct);
|
||||||
|
Ok(Suggestion {
|
||||||
|
available: true,
|
||||||
|
message: None,
|
||||||
|
amounts: [parts[0], parts[1], parts[2], parts[3]],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
SuggestMethod::Previous | SuggestMethod::Avg3 => {
|
||||||
|
let n = if method == SuggestMethod::Previous {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
3
|
||||||
|
};
|
||||||
|
let previous = previous_closed(db, household_id, &p, n).await?;
|
||||||
|
if previous.is_empty() {
|
||||||
|
return Ok(unavailable("Henüz kapanmış bir önceki dönem yok."));
|
||||||
|
}
|
||||||
|
let ids: Vec<Bson> = previous.iter().map(|id| Bson::from(*id)).collect();
|
||||||
|
let sums = sum_by(
|
||||||
|
db,
|
||||||
|
col::ENTRIES,
|
||||||
|
household_id,
|
||||||
|
doc! { "period_id": { "$in": ids } },
|
||||||
|
"$group",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let total: i64 = sums.values().sum();
|
||||||
|
if total == 0 {
|
||||||
|
return Ok(unavailable("Önceki dönemlerde henüz harcama kaydı yok."));
|
||||||
|
}
|
||||||
|
let count = previous.len() as i64;
|
||||||
|
let avg = |g: Group| {
|
||||||
|
let s = sums.get(g.as_str()).copied().unwrap_or(0);
|
||||||
|
(s + count / 2) / count // yuvarlayarak bölme
|
||||||
|
};
|
||||||
|
Ok(Suggestion {
|
||||||
|
available: true,
|
||||||
|
message: None,
|
||||||
|
amounts: Group::ALL.map(avg),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- yardımcılar ----------
|
||||||
|
|
||||||
|
fn dates(p: &Period) -> AppResult<(time::Date, time::Date)> {
|
||||||
|
let bad = || AppError::Internal("dönem tarihi okunamadı".into());
|
||||||
|
Ok((
|
||||||
|
parse_date(&p.start_date).ok_or_else(bad)?,
|
||||||
|
parse_date(&p.end_date).ok_or_else(bad)?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unavailable(msg: &str) -> Suggestion {
|
||||||
|
Suggestion {
|
||||||
|
available: false,
|
||||||
|
message: Some(msg.into()),
|
||||||
|
amounts: [0; 4],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Vec<BudgetDoc>> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("period_id", period_id);
|
||||||
|
let mut cursor = db.collection::<BudgetDoc>(col::BUDGETS).find(f).await?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while cursor.advance().await? {
|
||||||
|
out.push(cursor.deserialize_current()?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn monthly_amounts(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
) -> AppResult<GroupAmounts> {
|
||||||
|
let docs = load(db, household_id, period_id).await?;
|
||||||
|
Ok(Group::ALL.map(|g| {
|
||||||
|
docs.iter()
|
||||||
|
.find(|d| d.group == g && d.scope == "month")
|
||||||
|
.map_or(0, |d| d.amount)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
group: Group,
|
||||||
|
level: &str,
|
||||||
|
week_start: Option<&str>,
|
||||||
|
amount: i64,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let coll = db.collection::<Document>(col::BUDGETS);
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("period_id", period_id);
|
||||||
|
f.insert("group", group.as_str());
|
||||||
|
f.insert("scope", level);
|
||||||
|
f.insert(
|
||||||
|
"week_start",
|
||||||
|
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
|
||||||
|
);
|
||||||
|
|
||||||
|
let res = coll
|
||||||
|
.update_one(
|
||||||
|
f,
|
||||||
|
doc! { "$set": { "amount": amount, "updated_at": now() }, "$inc": { "version": 1 } },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if res.matched_count == 0 {
|
||||||
|
let mut d = new_meta(household_id);
|
||||||
|
d.insert("period_id", period_id);
|
||||||
|
d.insert("group", group.as_str());
|
||||||
|
d.insert("scope", level);
|
||||||
|
d.insert(
|
||||||
|
"week_start",
|
||||||
|
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
|
||||||
|
);
|
||||||
|
d.insert("amount", amount);
|
||||||
|
coll.insert_one(d).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bir koleksiyonda `amount` alanını bir alana göre toplar: `{ "income": 450000, ... }`
|
||||||
|
async fn sum_by(
|
||||||
|
db: &Database,
|
||||||
|
collection: &str,
|
||||||
|
household_id: Uuid,
|
||||||
|
filter: Document,
|
||||||
|
key: &str,
|
||||||
|
) -> AppResult<HashMap<String, i64>> {
|
||||||
|
let mut m = scope(household_id);
|
||||||
|
m.extend(filter);
|
||||||
|
let pipeline = vec![
|
||||||
|
doc! { "$match": m },
|
||||||
|
doc! { "$group": { "_id": key, "total": { "$sum": "$amount" } } },
|
||||||
|
];
|
||||||
|
let mut cursor = db
|
||||||
|
.collection::<Document>(collection)
|
||||||
|
.aggregate(pipeline)
|
||||||
|
.await?;
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
while cursor.advance().await? {
|
||||||
|
let d = cursor.deserialize_current()?;
|
||||||
|
let k = d.get_str("_id").unwrap_or_default().to_string();
|
||||||
|
let v = match d.get("total") {
|
||||||
|
Some(Bson::Int64(v)) => *v,
|
||||||
|
Some(Bson::Int32(v)) => i64::from(*v),
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
out.insert(k, v);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dönemin harcamaları (grup, gün) bazında toplanmış: en fazla 4 × 62 satır.
|
||||||
|
pub(crate) async fn spent_by_group_and_date(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
) -> AppResult<Vec<(Group, String, i64)>> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Key {
|
||||||
|
group: Group,
|
||||||
|
date: String,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Row {
|
||||||
|
#[serde(rename = "_id")]
|
||||||
|
key: Key,
|
||||||
|
total: i64,
|
||||||
|
}
|
||||||
|
let mut m = scope(household_id);
|
||||||
|
m.insert("period_id", period_id);
|
||||||
|
let pipeline = vec![
|
||||||
|
doc! { "$match": m },
|
||||||
|
doc! { "$group": {
|
||||||
|
"_id": { "group": "$group", "date": "$date" },
|
||||||
|
"total": { "$sum": "$amount" },
|
||||||
|
} },
|
||||||
|
];
|
||||||
|
let mut cursor = db
|
||||||
|
.collection::<Document>(col::ENTRIES)
|
||||||
|
.aggregate(pipeline)
|
||||||
|
.with_type::<Row>()
|
||||||
|
.await?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while cursor.advance().await? {
|
||||||
|
let r = cursor.deserialize_current()?;
|
||||||
|
out.push((r.key.group, r.key.date, r.total));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hane ayarındaki varsayılan yüzdeler (yoksa 50/30/10/10).
|
||||||
|
async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult<Vec<u64>> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", household_id);
|
||||||
|
let hh = db
|
||||||
|
.collection::<Document>(col::HOUSEHOLDS)
|
||||||
|
.find_one(f)
|
||||||
|
.await?;
|
||||||
|
let pct = hh
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|h| h.get_document("settings").ok())
|
||||||
|
.and_then(|s| s.get_document("budget_split_pct").ok());
|
||||||
|
let default = [50u64, 30, 10, 10];
|
||||||
|
Ok(Group::ALL
|
||||||
|
.iter()
|
||||||
|
.zip(default)
|
||||||
|
.map(|(g, d)| {
|
||||||
|
pct.and_then(|p| match p.get(g.as_str()) {
|
||||||
|
Some(Bson::Int32(v)) => Some(*v as u64),
|
||||||
|
Some(Bson::Int64(v)) => Some(*v as u64),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or(d)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bu dönemden önceki en yeni `n` kapalı **ya da arşiv** dönemin kimlikleri
|
||||||
|
/// (arşiv: ekstreden aktarılan geçmiş aylar → öneriler gerçek harcamaya dayanır).
|
||||||
|
async fn previous_closed(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
p: &Period,
|
||||||
|
n: i64,
|
||||||
|
) -> AppResult<Vec<Uuid>> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("start_date", doc! { "$lt": &p.start_date });
|
||||||
|
f.insert(
|
||||||
|
"status",
|
||||||
|
doc! { "$in": [PeriodStatus::Closed.as_str(), PeriodStatus::Archived.as_str()] },
|
||||||
|
);
|
||||||
|
let mut cursor = db
|
||||||
|
.collection::<Period>(col::PERIODS)
|
||||||
|
.find(f)
|
||||||
|
.sort(doc! { "start_date": -1 })
|
||||||
|
.limit(n)
|
||||||
|
.await?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while cursor.advance().await? {
|
||||||
|
out.push(cursor.deserialize_current()?.id);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
//! 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 **ayın günlerine göre** haftalara böler (kullanıcı kararı, 2026-11-13):
|
||||||
|
/// 1–8 → 1. hafta, 9–15 → 2., 16–22 → 3., 23–son gün → 4. hafta.
|
||||||
|
///
|
||||||
|
/// Gün numarası dönem başından sayılır (dönem 15'inde başlıyorsa 15–22 = 1. hafta).
|
||||||
|
/// Genel kural: hafta sayısı `max(1, gün / 7)`; ilk hafta 8 gün, aradakiler 7 gün,
|
||||||
|
/// son hafta kalan günler (6–12 gün). 28–31 günlük dönem → daima 4 hafta.
|
||||||
|
/// Bütçe haftalara gün sayısıyla orantılı dağıtılır (`days`).
|
||||||
|
pub fn weeks(start: Date, end_exclusive: Date) -> Vec<Week> {
|
||||||
|
let total = days_between(start, end_exclusive);
|
||||||
|
if total <= 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let count = (total / 7).max(1);
|
||||||
|
let mut out = Vec::with_capacity(count as usize);
|
||||||
|
let mut cursor = start;
|
||||||
|
for i in 0..count {
|
||||||
|
let len = if i == count - 1 {
|
||||||
|
days_between(cursor, end_exclusive)
|
||||||
|
} else if i == 0 {
|
||||||
|
8
|
||||||
|
} else {
|
||||||
|
7
|
||||||
|
};
|
||||||
|
let next = cursor + Duration::days(len);
|
||||||
|
out.push(Week {
|
||||||
|
index: i as u32 + 1,
|
||||||
|
start: cursor,
|
||||||
|
last: next - Duration::days(1),
|
||||||
|
days: len 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summary(w: &[Week]) -> Vec<(u32, String, String, u32)> {
|
||||||
|
w.iter()
|
||||||
|
.map(|w| (w.index, format_date(w.start), format_date(w.last), w.days))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn month_weeks_follow_day_of_month() {
|
||||||
|
// Kasım 2026 (1'i Pazar): 1–8, 9–15, 16–22, 23–30 → 4 hafta
|
||||||
|
let w = weeks(d("2026-11-01"), d("2026-12-01"));
|
||||||
|
assert_eq!(
|
||||||
|
summary(&w),
|
||||||
|
vec![
|
||||||
|
(1, "2026-11-01".into(), "2026-11-08".into(), 8),
|
||||||
|
(2, "2026-11-09".into(), "2026-11-15".into(), 7),
|
||||||
|
(3, "2026-11-16".into(), "2026-11-22".into(), 7),
|
||||||
|
(4, "2026-11-23".into(), "2026-11-30".into(), 8),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// 31 gün → son hafta 23–31 (9 gün); Şubat → 23–28 (6 gün); artık yıl → 7 gün
|
||||||
|
for (start, end, last_days) in [
|
||||||
|
("2026-12-01", "2027-01-01", 9),
|
||||||
|
("2027-02-01", "2027-03-01", 6),
|
||||||
|
("2028-02-01", "2028-03-01", 7),
|
||||||
|
] {
|
||||||
|
let w = weeks(d(start), d(end));
|
||||||
|
assert_eq!(w.len(), 4, "{start}");
|
||||||
|
assert_eq!(w[3].start.day(), 23, "{start}");
|
||||||
|
assert_eq!(w[3].days, last_days, "{start}");
|
||||||
|
assert_eq!(
|
||||||
|
w.iter().map(|w| i64::from(w.days)).sum::<i64>(),
|
||||||
|
days_between(d(start), d(end))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weeks_edge_cases() {
|
||||||
|
// Ay ortasında başlayan dönem: günler dönem başından sayılır
|
||||||
|
let w = weeks(d("2026-12-15"), d("2027-01-15"));
|
||||||
|
assert_eq!(
|
||||||
|
summary(&w)
|
||||||
|
.iter()
|
||||||
|
.map(|x| (x.1.as_str(), x.3))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
("2026-12-15", 8),
|
||||||
|
("2026-12-23", 7),
|
||||||
|
("2026-12-30", 7),
|
||||||
|
("2027-01-06", 9)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// Kısa dönemler: 13 gün → tek hafta, 14 gün → 8 + 6
|
||||||
|
assert_eq!(summary(&weeks(d("2026-12-01"), d("2026-12-14"))).len(), 1);
|
||||||
|
let w = weeks(d("2026-12-01"), d("2026-12-15"));
|
||||||
|
assert_eq!((w[0].days, w[1].days), (8, 6));
|
||||||
|
// Tek gün
|
||||||
|
let w = weeks(d("2026-12-06"), d("2026-12-07"));
|
||||||
|
assert_eq!((w.len(), w[0].days), (1, 1));
|
||||||
|
// Uzun dönem (62 gün) → 8 hafta, son hafta ≤ 12 gün
|
||||||
|
let w = weeks(d("2026-11-01"), d("2027-01-02"));
|
||||||
|
assert_eq!(w.len(), 8);
|
||||||
|
assert!(w.iter().all(|w| (6..=12).contains(&w.days)));
|
||||||
|
// Boş aralık
|
||||||
|
assert!(weeks(d("2026-12-07"), d("2026-12-07")).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
//! Dönem kapatma ve yeniden açma (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.10, §6).
|
||||||
|
//!
|
||||||
|
//! Kapatma (tek transaction):
|
||||||
|
//! 1. Dönem `closed` olur (`closed_at`, `closed_by`, `to_savings`).
|
||||||
|
//! 2. Bu dönemden önceki devirler (yeniden kapatmada) soft delete edilir.
|
||||||
|
//! 3. Gerçekleşen birikim (değerlendirmenin 4. sorusu) dağıtılır:
|
||||||
|
//! - < 0 → açık **borç** olarak devreder (`debt`).
|
||||||
|
//! - ≥ 0 → `to_savings` kadarı birikime (varsayılan: tamamı), kalanı **artı** olarak devreder (`surplus`).
|
||||||
|
//! 4. Devir sonraki döneme bağlanır; sonraki dönem henüz yoksa `to_period_id: null` kalır
|
||||||
|
//! ve o dönem açılınca bağlanır (`period::create`).
|
||||||
|
//!
|
||||||
|
//! Yeniden açma (yalnızca sahip): dönem `correcting` olur, yeni açık dönem varsa o da açık kalır.
|
||||||
|
//! Mevcut devir, "yeniden kapat" denene kadar yerinde kalır (sonraki dönemin rakamları oynamaz).
|
||||||
|
//! Ardından kapanmış dönem olan bir dönem açılamaz: sonraki devirler zincirleme bozulurdu.
|
||||||
|
|
||||||
|
use mongodb::{
|
||||||
|
Database,
|
||||||
|
bson::{Bson, Document, Uuid, doc},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::col,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{
|
||||||
|
common::{new_meta, now, scope},
|
||||||
|
period::{Period, PeriodStatus},
|
||||||
|
},
|
||||||
|
services::{asset, period, review},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CarryKind {
|
||||||
|
Debt,
|
||||||
|
Surplus,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CarryKind {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
CarryKind::Debt => "debt",
|
||||||
|
CarryKind::Surplus => "surplus",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Carry {
|
||||||
|
pub kind: CarryKind,
|
||||||
|
pub amount: i64,
|
||||||
|
pub to_period_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Closing {
|
||||||
|
pub period: Period,
|
||||||
|
pub realized_savings: i64,
|
||||||
|
pub to_savings: i64,
|
||||||
|
pub carryover: Option<Carry>,
|
||||||
|
/// Birikime eklenmiş kayıt var (yeniden kapatmada güncellendi)
|
||||||
|
pub savings_linked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gerçekleşen birikimi birikim / devir olarak böler. `to_savings` verilmezse tamamı birikime.
|
||||||
|
pub fn settle(
|
||||||
|
realized: i64,
|
||||||
|
to_savings: Option<i64>,
|
||||||
|
) -> AppResult<(i64, Option<(CarryKind, i64)>)> {
|
||||||
|
if realized < 0 {
|
||||||
|
if to_savings.unwrap_or(0) != 0 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Bu dönemde birikim yok; açık, borç olarak sonraki döneme devreder".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok((0, Some((CarryKind::Debt, -realized))));
|
||||||
|
}
|
||||||
|
let t = to_savings.unwrap_or(realized);
|
||||||
|
if !(0..=realized).contains(&t) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Birikime aktarılacak tutar 0 ile gerçekleşen birikim arasında olmalı".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let surplus = realized - t;
|
||||||
|
Ok((t, (surplus > 0).then_some((CarryKind::Surplus, surplus))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn close(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
to_savings: Option<i64>,
|
||||||
|
version: Option<i32>,
|
||||||
|
) -> AppResult<Closing> {
|
||||||
|
let p = period::get(db, household_id, period_id).await?;
|
||||||
|
if p.status == PeriodStatus::Archived {
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Arşiv dönemi kapatılmaz; yalnızca kayıt tutar".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if p.status == PeriodStatus::Closed {
|
||||||
|
return Err(AppError::Conflict("Bu dönem zaten kapalı".into()));
|
||||||
|
}
|
||||||
|
if version.is_some_and(|v| v != p.version) {
|
||||||
|
return Err(period::stale());
|
||||||
|
}
|
||||||
|
let realized = review::review(db, household_id, period_id)
|
||||||
|
.await?
|
||||||
|
.realized_savings;
|
||||||
|
let (to_savings, carry) = settle(realized, to_savings)?;
|
||||||
|
let next = next_period(db, household_id, &p).await?;
|
||||||
|
// Yeniden kapatma: birikime eklenmiş kayıt varsa yeni tutara uyarlanır (önce denetlenir)
|
||||||
|
let savings_sync =
|
||||||
|
asset::plan_savings_sync(db, household_id, period_id, to_savings, &p.end_date).await?;
|
||||||
|
let ts = now();
|
||||||
|
|
||||||
|
let mut session = db.client().start_session().await?;
|
||||||
|
session.start_transaction().await?;
|
||||||
|
let result = async {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", period_id);
|
||||||
|
f.insert("version", p.version);
|
||||||
|
let r = db
|
||||||
|
.collection::<Document>(col::PERIODS)
|
||||||
|
.update_one(
|
||||||
|
f,
|
||||||
|
doc! {
|
||||||
|
"$set": {
|
||||||
|
"status": PeriodStatus::Closed.as_str(),
|
||||||
|
"closed_at": ts,
|
||||||
|
"closed_by": user_id,
|
||||||
|
"to_savings": to_savings,
|
||||||
|
"updated_at": ts,
|
||||||
|
},
|
||||||
|
"$inc": { "version": 1 },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.session(&mut session)
|
||||||
|
.await?;
|
||||||
|
if r.matched_count == 0 {
|
||||||
|
return Err(period::stale());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut old = scope(household_id);
|
||||||
|
old.insert("from_period_id", period_id);
|
||||||
|
db.collection::<Document>(col::CARRYOVERS)
|
||||||
|
.update_many(
|
||||||
|
old,
|
||||||
|
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||||
|
)
|
||||||
|
.session(&mut session)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(sync) = &savings_sync {
|
||||||
|
asset::apply_savings_sync(db, &mut session, household_id, sync).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((kind, amount)) = carry {
|
||||||
|
let mut d = new_meta(household_id);
|
||||||
|
d.insert("from_period_id", period_id);
|
||||||
|
d.insert(
|
||||||
|
"to_period_id",
|
||||||
|
next.as_ref().map_or(Bson::Null, |n| n.id.into()),
|
||||||
|
);
|
||||||
|
d.insert("kind", kind.as_str());
|
||||||
|
d.insert("amount", amount);
|
||||||
|
db.collection::<Document>(col::CARRYOVERS)
|
||||||
|
.insert_one(d)
|
||||||
|
.session(&mut session)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(()) => session.commit_transaction().await?,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = session.abort_transaction().await;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Closing {
|
||||||
|
savings_linked: matches!(savings_sync, Some(asset::SavingsSync::Update { .. })),
|
||||||
|
period: period::get(db, household_id, period_id).await?,
|
||||||
|
realized_savings: realized,
|
||||||
|
to_savings,
|
||||||
|
carryover: carry.map(|(kind, amount)| Carry {
|
||||||
|
kind,
|
||||||
|
amount,
|
||||||
|
to_period_id: next.map(|n| n.id),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kapalı dönemi düzeltme için açar (yalnızca sahip — rota denetler).
|
||||||
|
pub async fn reopen(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
) -> AppResult<Period> {
|
||||||
|
let p = period::get(db, household_id, period_id).await?;
|
||||||
|
if p.status != PeriodStatus::Closed {
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Yalnızca kapalı bir dönem yeniden açılabilir".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut later = scope(household_id);
|
||||||
|
later.insert("start_date", doc! { "$gte": &p.end_date });
|
||||||
|
// Arşiv dönemleri devir üretmediği için engel değildir
|
||||||
|
later.insert(
|
||||||
|
"status",
|
||||||
|
doc! { "$nin": [PeriodStatus::Open.as_str(), PeriodStatus::Archived.as_str()] },
|
||||||
|
);
|
||||||
|
if db
|
||||||
|
.collection::<Document>(col::PERIODS)
|
||||||
|
.find_one(later)
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Bu dönemden sonra kapanmış bir dönem var. Yalnızca en son kapanan dönem yeniden açılabilir."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ts = now();
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", period_id);
|
||||||
|
f.insert("version", p.version);
|
||||||
|
let r = db
|
||||||
|
.collection::<Document>(col::PERIODS)
|
||||||
|
.update_one(
|
||||||
|
f,
|
||||||
|
doc! {
|
||||||
|
"$set": {
|
||||||
|
"status": PeriodStatus::Correcting.as_str(),
|
||||||
|
"reopened_at": ts,
|
||||||
|
"reopened_by": user_id,
|
||||||
|
"updated_at": ts,
|
||||||
|
},
|
||||||
|
"$inc": { "version": 1 },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if r.matched_count == 0 {
|
||||||
|
return Err(period::stale());
|
||||||
|
}
|
||||||
|
period::get(db, household_id, period_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bu dönemden hemen sonra gelen dönem (varsa)
|
||||||
|
async fn next_period(db: &Database, household_id: Uuid, p: &Period) -> AppResult<Option<Period>> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("start_date", doc! { "$gte": &p.end_date });
|
||||||
|
// Devir arşiv dönemine bağlanmaz (arşiv devir zincirinde yok)
|
||||||
|
f.insert("status", doc! { "$ne": PeriodStatus::Archived.as_str() });
|
||||||
|
Ok(db
|
||||||
|
.collection::<Period>(col::PERIODS)
|
||||||
|
.find_one(f)
|
||||||
|
.sort(doc! { "start_date": 1 })
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settle_rules() {
|
||||||
|
// Artı: varsayılan tamamı birikime
|
||||||
|
assert_eq!(settle(700, None).unwrap(), (700, None));
|
||||||
|
assert_eq!(
|
||||||
|
settle(700, Some(500)).unwrap(),
|
||||||
|
(500, Some((CarryKind::Surplus, 200)))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
settle(700, Some(0)).unwrap(),
|
||||||
|
(0, Some((CarryKind::Surplus, 700)))
|
||||||
|
);
|
||||||
|
assert!(settle(700, Some(701)).is_err());
|
||||||
|
assert!(settle(700, Some(-1)).is_err());
|
||||||
|
// Sıfır: devir yok
|
||||||
|
assert_eq!(settle(0, None).unwrap(), (0, None));
|
||||||
|
// Açık: borç; birikim istenemez
|
||||||
|
assert_eq!(settle(-50, None).unwrap(), (0, Some((CarryKind::Debt, 50))));
|
||||||
|
assert_eq!(
|
||||||
|
settle(-50, Some(0)).unwrap(),
|
||||||
|
(0, Some((CarryKind::Debt, 50)))
|
||||||
|
);
|
||||||
|
assert!(settle(-50, Some(10)).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
//! CSV okuma/yazma yardımcıları (saf; veritabanı yok). Türkçe Excel uyumlu:
|
||||||
|
//! yazarken UTF-8 BOM, `;` ayırıcı, CRLF, tutar `1250,50`, tarih `GG.AA.YYYY`.
|
||||||
|
//! Okurken esnek: BOM, `;` / `,` / sekme (başlık satırından anlaşılır), tırnaklı alanlar,
|
||||||
|
//! tutar `1.250,50` · `1250,50` · `1250.50`, tarih `13.11.2026` · `13/11/2026` · `2026-11-13`.
|
||||||
|
|
||||||
|
use crate::services::calendar::{format_date, parse_date};
|
||||||
|
|
||||||
|
pub const BOM: &str = "\u{FEFF}";
|
||||||
|
|
||||||
|
// ---------- Yazma ----------
|
||||||
|
|
||||||
|
fn quote(s: &str) -> String {
|
||||||
|
if s.contains([';', '"', '\n', '\r']) {
|
||||||
|
format!("\"{}\"", s.replace('"', "\"\""))
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Başlık + satırlar → CSV metni (BOM'lu)
|
||||||
|
pub fn write(headers: &[&str], rows: &[Vec<String>]) -> String {
|
||||||
|
let mut out = String::from(BOM);
|
||||||
|
let line = |cells: Vec<String>| cells.iter().map(|c| quote(c)).collect::<Vec<_>>().join(";");
|
||||||
|
out.push_str(&line(headers.iter().map(|h| h.to_string()).collect()));
|
||||||
|
out.push_str("\r\n");
|
||||||
|
for r in rows {
|
||||||
|
out.push_str(&line(r.clone()));
|
||||||
|
out.push_str("\r\n");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kuruş → `1250,50`
|
||||||
|
pub fn money(kurus: i64) -> String {
|
||||||
|
let sign = if kurus < 0 { "-" } else { "" };
|
||||||
|
let a = kurus.unsigned_abs();
|
||||||
|
format!("{sign}{},{:02}", a / 100, a % 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// × 1e6 → `5,5` (gereksiz sıfırsız)
|
||||||
|
pub fn e6(v: i64) -> String {
|
||||||
|
let sign = if v < 0 { "-" } else { "" };
|
||||||
|
let a = v.unsigned_abs();
|
||||||
|
let frac = format!("{:06}", a % 1_000_000);
|
||||||
|
let frac = frac.trim_end_matches('0');
|
||||||
|
if frac.is_empty() {
|
||||||
|
format!("{sign}{}", a / 1_000_000)
|
||||||
|
} else {
|
||||||
|
format!("{sign}{},{frac}", a / 1_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `"2026-11-13"` → `13.11.2026`
|
||||||
|
pub fn date(iso: &str) -> String {
|
||||||
|
match iso.split('-').collect::<Vec<_>>().as_slice() {
|
||||||
|
[y, m, d] => format!("{d}.{m}.{y}"),
|
||||||
|
_ => iso.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Okuma ----------
|
||||||
|
|
||||||
|
/// CSV metni → satırlar (başlık dahil). Boş satırlar atlanır; her satırın dosyadaki numarası.
|
||||||
|
pub fn parse(text: &str) -> Vec<(usize, Vec<String>)> {
|
||||||
|
let text = text.strip_prefix(BOM).unwrap_or(text);
|
||||||
|
let first = text.lines().next().unwrap_or("");
|
||||||
|
let delim = [';', '\t', ',']
|
||||||
|
.into_iter()
|
||||||
|
.max_by_key(|d| first.matches(*d).count())
|
||||||
|
.unwrap_or(';');
|
||||||
|
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut row = Vec::new();
|
||||||
|
let mut cell = String::new();
|
||||||
|
let mut in_quotes = false;
|
||||||
|
let mut line = 1;
|
||||||
|
let mut row_line = 1;
|
||||||
|
let mut chars = text.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
match c {
|
||||||
|
'"' if in_quotes && chars.peek() == Some(&'"') => {
|
||||||
|
cell.push('"');
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
'"' if in_quotes => in_quotes = false,
|
||||||
|
'"' if cell.is_empty() => in_quotes = true,
|
||||||
|
c if c == delim && !in_quotes => row.push(std::mem::take(&mut cell)),
|
||||||
|
'\r' if !in_quotes => {}
|
||||||
|
'\n' if !in_quotes => {
|
||||||
|
row.push(std::mem::take(&mut cell));
|
||||||
|
if row.iter().any(|c| !c.trim().is_empty()) {
|
||||||
|
rows.push((row_line, std::mem::take(&mut row)));
|
||||||
|
}
|
||||||
|
row.clear();
|
||||||
|
line += 1;
|
||||||
|
row_line = line;
|
||||||
|
}
|
||||||
|
'\n' => {
|
||||||
|
cell.push(c);
|
||||||
|
line += 1;
|
||||||
|
}
|
||||||
|
c => cell.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.push(cell);
|
||||||
|
if row.iter().any(|c| !c.trim().is_empty()) {
|
||||||
|
rows.push((row_line, row));
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Başlık adı karşılaştırması: küçük harf (Türkçe İ/I), boşluksuz
|
||||||
|
pub fn norm(s: &str) -> String {
|
||||||
|
s.trim()
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'İ' => 'i',
|
||||||
|
'I' => 'ı',
|
||||||
|
c => c.to_lowercase().next().unwrap_or(c),
|
||||||
|
})
|
||||||
|
.filter(|c| !c.is_whitespace())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Başlık satırında adı verilen sütunların sırası (ilk eşleşen ad)
|
||||||
|
pub fn column(headers: &[String], names: &[&str]) -> Option<usize> {
|
||||||
|
let hs: Vec<String> = headers.iter().map(|h| norm(h)).collect();
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.find_map(|n| hs.iter().position(|h| *h == norm(n)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ondalık metin → (tam, kesir) basamakları; Türkçe ve İngilizce yazımı tanır
|
||||||
|
fn split_decimal(s: &str, max_frac: usize) -> Option<(bool, String, String)> {
|
||||||
|
let s: String = s
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_whitespace() && *c != '₺' && *c != '\u{a0}')
|
||||||
|
.collect();
|
||||||
|
let (neg, s) = match s.strip_prefix('-') {
|
||||||
|
Some(r) => (true, r.to_string()),
|
||||||
|
None => (false, s),
|
||||||
|
};
|
||||||
|
if s.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (int, frac) = if let Some((i, f)) = s.rsplit_once(',') {
|
||||||
|
// Virgül ondalık; noktalar binlik
|
||||||
|
(i.replace('.', ""), f.to_string())
|
||||||
|
} else if s.matches('.').count() == 1 {
|
||||||
|
let (i, f) = s.split_once('.').unwrap();
|
||||||
|
// Tek nokta + tam 3 rakam → binlik (1.000), değilse ondalık (12.50)
|
||||||
|
if f.len() == 3 && i != "0" && !i.is_empty() {
|
||||||
|
(format!("{i}{f}"), String::new())
|
||||||
|
} else {
|
||||||
|
(i.to_string(), f.to_string())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(s.replace('.', ""), String::new())
|
||||||
|
};
|
||||||
|
let int = if int.is_empty() { "0".to_string() } else { int };
|
||||||
|
if !int.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
|| !frac.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
|| frac.len() > max_frac
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((neg, int, frac))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `1.250,50` → 125050 kuruş (en fazla 2 ondalık)
|
||||||
|
pub fn parse_money(s: &str) -> Option<i64> {
|
||||||
|
let (neg, int, frac) = split_decimal(s, 2)?;
|
||||||
|
let v =
|
||||||
|
int.parse::<i64>().ok()?.checked_mul(100)? + format!("{frac:0<2}").parse::<i64>().ok()?;
|
||||||
|
Some(if neg { -v } else { v })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `5,5` → 5_500_000 (en fazla 6 ondalık; eksi yok)
|
||||||
|
pub fn parse_e6(s: &str) -> Option<i64> {
|
||||||
|
let (neg, int, frac) = split_decimal(s, 6)?;
|
||||||
|
if neg {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
int.parse::<i64>()
|
||||||
|
.ok()?
|
||||||
|
.checked_mul(1_000_000)?
|
||||||
|
.checked_add(format!("{frac:0<6}").parse::<i64>().ok()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `13.11.2026` · `13/11/2026` · `2026-11-13` → `"2026-11-13"`
|
||||||
|
pub fn parse_date_any(s: &str) -> Option<String> {
|
||||||
|
let s = s.trim();
|
||||||
|
let iso = if s.contains('-') && s.len() == 10 && s.as_bytes()[4] == b'-' {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
let p: Vec<&str> = s.split(['.', '/']).collect();
|
||||||
|
match p.as_slice() {
|
||||||
|
[d, m, y] if y.len() == 4 => format!("{y}-{m:0>2}-{d:0>2}"),
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
parse_date(&iso).map(format_date)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_excel_friendly() {
|
||||||
|
let csv = write(
|
||||||
|
&["Ad", "Not"],
|
||||||
|
&[
|
||||||
|
vec!["Market".into(), "a;b".into()],
|
||||||
|
vec!["\"x\"".into(), String::new()],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
csv,
|
||||||
|
"\u{FEFF}Ad;Not\r\nMarket;\"a;b\"\r\n\"\"\"x\"\"\";\r\n"
|
||||||
|
);
|
||||||
|
assert_eq!(money(125_050), "1250,50");
|
||||||
|
assert_eq!(money(-5), "-0,05");
|
||||||
|
assert_eq!(e6(5_500_000), "5,5");
|
||||||
|
assert_eq!(e6(10_000_000), "10");
|
||||||
|
assert_eq!(e6(125_000), "0,125");
|
||||||
|
assert_eq!(date("2026-11-13"), "13.11.2026");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_quotes_delimiters_and_line_numbers() {
|
||||||
|
let rows = parse(
|
||||||
|
"\u{FEFF}Tarih;Tutar;Açıklama\r\n13.11.2026;1.250,50;\"Kafe; tatlı\"\r\n\r\n14.11.2026;10;\"iki\nsatır\"\n",
|
||||||
|
);
|
||||||
|
assert_eq!(rows.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
rows[1],
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
vec!["13.11.2026".into(), "1.250,50".into(), "Kafe; tatlı".into()]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(rows[2].0, 4, "boş satır sayılır ama atlanır");
|
||||||
|
assert_eq!(rows[2].1[2], "iki\nsatır");
|
||||||
|
// Virgül ayraçlı dosya
|
||||||
|
let rows = parse("date,amount\n2026-11-13,\"1,5\"\n");
|
||||||
|
assert_eq!(rows[1].1, vec!["2026-11-13", "1,5"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn columns_by_name() {
|
||||||
|
let h: Vec<String> = ["TARİH", "Tutar ", "Ödeme Türü"]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(column(&h, &["Tarih"]), Some(0));
|
||||||
|
assert_eq!(column(&h, &["ödeme türü"]), Some(2));
|
||||||
|
assert_eq!(column(&h, &["Kategori"]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn money_and_dates() {
|
||||||
|
assert_eq!(parse_money("1.250,50"), Some(125_050));
|
||||||
|
assert_eq!(parse_money("1250,5"), Some(125_050));
|
||||||
|
assert_eq!(parse_money("1250.50"), Some(125_050));
|
||||||
|
assert_eq!(parse_money("1.000"), Some(100_000));
|
||||||
|
assert_eq!(parse_money("-45,90 ₺"), Some(-4_590));
|
||||||
|
assert_eq!(parse_money("1,234"), None, "3 ondalık para olmaz");
|
||||||
|
assert_eq!(parse_money("abc"), None);
|
||||||
|
assert_eq!(parse_e6("5,5"), Some(5_500_000));
|
||||||
|
assert_eq!(parse_e6("0.125"), Some(125_000));
|
||||||
|
assert_eq!(parse_e6("-1"), None);
|
||||||
|
assert_eq!(parse_date_any("13.11.2026").as_deref(), Some("2026-11-13"));
|
||||||
|
assert_eq!(parse_date_any("3/1/2026").as_deref(), Some("2026-01-03"));
|
||||||
|
assert_eq!(parse_date_any("2026-11-13").as_deref(), Some("2026-11-13"));
|
||||||
|
assert_eq!(parse_date_any("30.02.2026"), None);
|
||||||
|
assert_eq!(parse_date_any("13.11.26"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,300 @@
|
|||||||
|
//! Günlük defter: harcama kayıtları (KAKEIBO_PROMPT §8.4).
|
||||||
|
//!
|
||||||
|
//! Kurallar:
|
||||||
|
//! - Tutar kuruş, > 0. Tarih kaydın döneminin içinde olmalı. İstisna: ekstreden gelen **iade**
|
||||||
|
//! kayıtları eksi tutarlıdır (kategorinin harcamasından düşer); düzenlemede eksi kalır.
|
||||||
|
//! - Grup **kategoriden** alınır (istemci grup göndermez) → kategori ile grup hiç çelişmez.
|
||||||
|
//! - Kategori ve ödeme türü bu haneye ait olmalı; yeni seçimde **aktif** olmalı
|
||||||
|
//! (pasife alınmış kategoride kalan eski kayıt düzenlenebilir).
|
||||||
|
//! - Yalnızca açık dönemde yazma. Silme soft delete; "Geri al" için `restore`.
|
||||||
|
|
||||||
|
use mongodb::{
|
||||||
|
Database,
|
||||||
|
bson::{Bson, Document, Uuid, doc},
|
||||||
|
options::ReturnDocument,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::col,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{
|
||||||
|
common::{new_meta, now, scope},
|
||||||
|
entry::Entry,
|
||||||
|
group::Group,
|
||||||
|
period::Period,
|
||||||
|
},
|
||||||
|
services::{calendar::parse_date, catalog, period},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Açıklama üst sınırı (karakter)
|
||||||
|
pub const MAX_DESCRIPTION: usize = 200;
|
||||||
|
|
||||||
|
pub struct NewEntry {
|
||||||
|
pub date: String,
|
||||||
|
pub amount: i64,
|
||||||
|
pub category_id: Uuid,
|
||||||
|
pub payment_method_id: Option<Uuid>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct EntryChanges {
|
||||||
|
pub date: Option<String>,
|
||||||
|
pub amount: Option<i64>,
|
||||||
|
pub category_id: Option<Uuid>,
|
||||||
|
/// `Some(None)` → ödeme türünü kaldır (istekte boş metin)
|
||||||
|
pub payment_method_id: Option<Option<Uuid>>,
|
||||||
|
/// `Some("")` → açıklamayı kaldır
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub version: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liste filtresi: tarih aralığı (ikisi de dahil, isteğe bağlı)
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct EntryFilter {
|
||||||
|
pub from: Option<String>,
|
||||||
|
pub to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coll(db: &Database) -> mongodb::Collection<Entry> {
|
||||||
|
db.collection(col::ENTRIES)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dönemin kayıtları: en yeni tarih başta, aynı gün içinde en son eklenen başta.
|
||||||
|
pub async fn list(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
filter: EntryFilter,
|
||||||
|
) -> AppResult<Vec<Entry>> {
|
||||||
|
period::get(db, household_id, period_id).await?; // dönem bu haneye ait mi?
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("period_id", period_id);
|
||||||
|
let mut range = Document::new();
|
||||||
|
if let Some(from) = filter.from {
|
||||||
|
range.insert("$gte", valid_date(&from)?);
|
||||||
|
}
|
||||||
|
if let Some(to) = filter.to {
|
||||||
|
range.insert("$lte", valid_date(&to)?);
|
||||||
|
}
|
||||||
|
if !range.is_empty() {
|
||||||
|
f.insert("date", range);
|
||||||
|
}
|
||||||
|
// UUID v7 zamana göre sıralı → `_id` azalan = en son eklenen önce
|
||||||
|
let mut cursor = coll(db)
|
||||||
|
.find(f)
|
||||||
|
.sort(doc! { "date": -1, "_id": -1 })
|
||||||
|
.await?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while cursor.advance().await? {
|
||||||
|
out.push(cursor.deserialize_current()?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
e: NewEntry,
|
||||||
|
) -> AppResult<Entry> {
|
||||||
|
let p = period::get_open(db, household_id, period_id).await?;
|
||||||
|
let date = clean_date(&e.date, &p)?;
|
||||||
|
let amount = clean_amount(e.amount)?;
|
||||||
|
let group = category_group(db, household_id, e.category_id, None).await?;
|
||||||
|
if let Some(pm) = e.payment_method_id {
|
||||||
|
check_payment_method(db, household_id, pm, None).await?;
|
||||||
|
}
|
||||||
|
let description = clean_description(e.description.as_deref())?;
|
||||||
|
|
||||||
|
let mut d = new_meta(household_id);
|
||||||
|
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||||
|
d.insert("period_id", period_id);
|
||||||
|
d.insert("date", date);
|
||||||
|
d.insert("amount", amount);
|
||||||
|
d.insert("group", group.as_str());
|
||||||
|
d.insert("category_id", e.category_id);
|
||||||
|
d.insert(
|
||||||
|
"payment_method_id",
|
||||||
|
e.payment_method_id.map_or(Bson::Null, Bson::from),
|
||||||
|
);
|
||||||
|
d.insert("description", description.map_or(Bson::Null, Bson::String));
|
||||||
|
d.insert("created_by", user_id);
|
||||||
|
db.collection::<Document>(col::ENTRIES)
|
||||||
|
.insert_one(d)
|
||||||
|
.await?;
|
||||||
|
coll(db)
|
||||||
|
.find_one(doc! { "_id": id })
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::Internal("eklenen kayıt okunamadı".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
ch: EntryChanges,
|
||||||
|
) -> AppResult<Entry> {
|
||||||
|
let current = get(db, household_id, id).await?;
|
||||||
|
let p = period::get_open(db, household_id, current.period_id).await?;
|
||||||
|
if ch.version.is_some_and(|v| v != current.version) {
|
||||||
|
return Err(stale());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut set = doc! { "updated_at": now() };
|
||||||
|
if let Some(date) = ch.date {
|
||||||
|
set.insert("date", clean_date(&date, &p)?);
|
||||||
|
}
|
||||||
|
if let Some(amount) = ch.amount {
|
||||||
|
// İade kaydı (ekstreden, eksi tutar) iade olarak kalır: tutar eksi girilir
|
||||||
|
let amount = if current.amount < 0 {
|
||||||
|
if amount >= 0 {
|
||||||
|
return Err(AppError::BadRequest("İade tutarı eksi olmalı".into()));
|
||||||
|
}
|
||||||
|
-clean_amount(-amount)?
|
||||||
|
} else {
|
||||||
|
clean_amount(amount)?
|
||||||
|
};
|
||||||
|
set.insert("amount", amount);
|
||||||
|
}
|
||||||
|
if let Some(cid) = ch.category_id {
|
||||||
|
let group = category_group(db, household_id, cid, Some(current.category_id)).await?;
|
||||||
|
set.insert("category_id", cid);
|
||||||
|
set.insert("group", group.as_str());
|
||||||
|
}
|
||||||
|
if let Some(pm) = ch.payment_method_id {
|
||||||
|
if let Some(pm) = pm {
|
||||||
|
check_payment_method(db, household_id, pm, current.payment_method_id).await?;
|
||||||
|
}
|
||||||
|
set.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
|
||||||
|
}
|
||||||
|
if let Some(desc) = ch.description {
|
||||||
|
set.insert(
|
||||||
|
"description",
|
||||||
|
clean_description(Some(&desc))?.map_or(Bson::Null, Bson::String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", id);
|
||||||
|
f.insert("version", current.version);
|
||||||
|
coll(db)
|
||||||
|
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||||
|
.return_document(ReturnDocument::After)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft delete. Silinen kayıt `restore` ile geri getirilebilir ("Geri al").
|
||||||
|
pub async fn delete(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
|
||||||
|
let current = get(db, household_id, id).await?;
|
||||||
|
period::get_open(db, household_id, current.period_id).await?;
|
||||||
|
let ts = now();
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", id);
|
||||||
|
coll(db)
|
||||||
|
.update_one(
|
||||||
|
f,
|
||||||
|
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Silinmiş kaydı geri getirir. Silinmemiş ya da başka hanenin kaydı → 404.
|
||||||
|
pub async fn restore(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Entry> {
|
||||||
|
let f = doc! { "_id": id, "household_id": household_id, "deleted_at": { "$ne": null } };
|
||||||
|
let deleted = coll(db)
|
||||||
|
.find_one(f.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
period::get_open(db, household_id, deleted.period_id).await?;
|
||||||
|
coll(db)
|
||||||
|
.find_one_and_update(
|
||||||
|
f,
|
||||||
|
doc! { "$set": { "deleted_at": null, "updated_at": now() }, "$inc": { "version": 1 } },
|
||||||
|
)
|
||||||
|
.return_document(ReturnDocument::After)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- yardımcılar ----------
|
||||||
|
|
||||||
|
async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Entry> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", id);
|
||||||
|
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kategori bu haneye ait mi, aktif mi (ya da kaydın zaten kullandığı kategori mi)? → grubu
|
||||||
|
async fn category_group(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
category_id: Uuid,
|
||||||
|
current: Option<Uuid>,
|
||||||
|
) -> AppResult<Group> {
|
||||||
|
let c = catalog::find(db, household_id, catalog::Catalog::Categories, category_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::BadRequest("Kategori bulunamadı".into()))?;
|
||||||
|
if !c.is_active && current != Some(category_id) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Bu kategori pasif; başka bir kategori seçin".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
c.group
|
||||||
|
.ok_or_else(|| AppError::Internal("kategorinin grubu yok".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_payment_method(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
current: Option<Uuid>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let m = catalog::find(db, household_id, catalog::Catalog::PaymentMethods, id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::BadRequest("Ödeme türü bulunamadı".into()))?;
|
||||||
|
if !m.is_active && current != Some(id) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Bu ödeme türü pasif; başka birini seçin".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_date(s: &str) -> AppResult<String> {
|
||||||
|
let s = s.trim();
|
||||||
|
parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
|
||||||
|
Ok(s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_date(s: &str, p: &Period) -> AppResult<String> {
|
||||||
|
let s = valid_date(s)?;
|
||||||
|
if s < p.start_date || s >= p.end_date {
|
||||||
|
return Err(AppError::BadRequest("Tarih dönem içinde olmalı".into()));
|
||||||
|
}
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_amount(v: i64) -> AppResult<i64> {
|
||||||
|
if v <= 0 {
|
||||||
|
return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into()));
|
||||||
|
}
|
||||||
|
period::validate_amount(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_description(d: Option<&str>) -> AppResult<Option<String>> {
|
||||||
|
match d.map(str::trim) {
|
||||||
|
None | Some("") => Ok(None),
|
||||||
|
Some(s) if s.chars().count() > MAX_DESCRIPTION => Err(AppError::BadRequest(format!(
|
||||||
|
"Açıklama en fazla {MAX_DESCRIPTION} karakter olabilir"
|
||||||
|
))),
|
||||||
|
Some(s) => Ok(Some(s.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stale() -> AppError {
|
||||||
|
AppError::Conflict("Kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
|
||||||
|
|
||||||
|
pub mod account;
|
||||||
|
pub mod asset;
|
||||||
|
pub mod backup;
|
||||||
|
pub mod bank_import;
|
||||||
|
pub mod budget;
|
||||||
|
pub mod calendar;
|
||||||
|
pub mod catalog;
|
||||||
|
pub mod closing;
|
||||||
|
pub mod csv_io;
|
||||||
|
pub mod debt;
|
||||||
|
pub mod entry;
|
||||||
|
pub mod holding;
|
||||||
|
pub mod invite;
|
||||||
|
pub mod money;
|
||||||
|
pub mod monthly_report;
|
||||||
|
pub mod note;
|
||||||
|
pub mod period;
|
||||||
|
pub mod plan;
|
||||||
|
pub mod rate_sources;
|
||||||
|
pub mod rates;
|
||||||
|
pub mod reports;
|
||||||
|
pub mod review;
|
||||||
|
pub mod seed;
|
||||||
|
pub mod stats;
|
||||||
|
pub mod transfer;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//! Para hesapları (kuruş, `i64`). Float yok.
|
||||||
|
|
||||||
|
/// `total` kuruşu ağırlıklara orantılı böler; **toplam her zaman `total`'a eşittir**.
|
||||||
|
///
|
||||||
|
/// "En büyük kalan" yöntemi: herkes tam payını alır, artan kuruşlar
|
||||||
|
/// küsuratı en büyük olana (eşitlikte öndekine) birer birer verilir.
|
||||||
|
/// Ağırlıkların hepsi 0 ise hepsine 0 döner (ve `total` dağıtılamaz → çağıran kontrol eder).
|
||||||
|
pub fn split_proportional(total: i64, weights: &[u64]) -> Vec<i64> {
|
||||||
|
let sum: u128 = weights.iter().map(|&w| u128::from(w)).sum();
|
||||||
|
if sum == 0 || total <= 0 {
|
||||||
|
return vec![0; weights.len()];
|
||||||
|
}
|
||||||
|
let total_u = total as u128;
|
||||||
|
let mut parts: Vec<i64> = Vec::with_capacity(weights.len());
|
||||||
|
let mut remainders: Vec<(u128, usize)> = Vec::with_capacity(weights.len());
|
||||||
|
for (i, &w) in weights.iter().enumerate() {
|
||||||
|
let exact = total_u * u128::from(w);
|
||||||
|
parts.push((exact / sum) as i64);
|
||||||
|
remainders.push((exact % sum, i));
|
||||||
|
}
|
||||||
|
let mut left = total - parts.iter().sum::<i64>();
|
||||||
|
// Büyük kalan önce; eşitlikte küçük indeks önce
|
||||||
|
remainders.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||||
|
for (_, i) in remainders {
|
||||||
|
if left == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parts[i] += 1;
|
||||||
|
left -= 1;
|
||||||
|
}
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sums_exactly_and_is_proportional() {
|
||||||
|
// 1.000,00 ₺ Aralık haftalarına (6,7,7,7,4 gün)
|
||||||
|
let p = split_proportional(100_000, &[6, 7, 7, 7, 4]);
|
||||||
|
assert_eq!(p.iter().sum::<i64>(), 100_000);
|
||||||
|
assert_eq!(p, vec![19_355, 22_581, 22_581, 22_580, 12_903]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_percentages() {
|
||||||
|
// Tam paylar 617.283,5 / 370.370,1 / 123.456,7 / 123.456,7 → artan 2 kuruş
|
||||||
|
// küsuratı en büyük olan ikisine (0,7'ler) gider
|
||||||
|
assert_eq!(
|
||||||
|
split_proportional(1_234_567, &[50, 30, 10, 10]),
|
||||||
|
vec![617_283, 370_370, 123_457, 123_457]
|
||||||
|
);
|
||||||
|
assert_eq!(split_proportional(1, &[50, 30, 10, 10]), vec![1, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edge_cases() {
|
||||||
|
assert_eq!(split_proportional(0, &[1, 2]), vec![0, 0]);
|
||||||
|
assert_eq!(split_proportional(-500, &[1, 2]), vec![0, 0]);
|
||||||
|
assert_eq!(split_proportional(100, &[0, 0]), vec![0, 0]);
|
||||||
|
assert_eq!(split_proportional(100, &[]), Vec::<i64>::new());
|
||||||
|
assert_eq!(split_proportional(7, &[0, 3, 0]), vec![0, 7, 0]);
|
||||||
|
// Çok büyük tutarlarda taşma yok
|
||||||
|
let big = 10_000_000_000_000;
|
||||||
|
assert_eq!(split_proportional(big, &[31, 31]).iter().sum::<i64>(), big);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
//! Dönem iş mantığı (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.4).
|
||||||
|
//!
|
||||||
|
//! Kurallar:
|
||||||
|
//! - Hanede aynı anda en fazla **bir açık** dönem (servis kontrolü + kısmi benzersiz indeks).
|
||||||
|
//! - Dönemler çakışmaz. Bitiş tarihi hariçtir.
|
||||||
|
//! - Kapalı dönem salt okunur (yeniden açma: Faz 6, yalnızca sahip).
|
||||||
|
//! - Tarih aralığı daraltılırken dışarıda harcama kalıyorsa engellenir.
|
||||||
|
|
||||||
|
use mongodb::{
|
||||||
|
Database,
|
||||||
|
bson::{Bson, Document, Uuid, doc},
|
||||||
|
options::ReturnDocument,
|
||||||
|
};
|
||||||
|
use time::Date;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{col, is_duplicate_key},
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{
|
||||||
|
common::{new_meta, now, scope},
|
||||||
|
period::{Period, PeriodStatus},
|
||||||
|
},
|
||||||
|
services::calendar::{days_between, default_period_name, format_date, parse_date},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// En uzun dönem (gün). Aylık defter için ~2 ay pay bırakır.
|
||||||
|
pub const MAX_DAYS: i64 = 62;
|
||||||
|
/// Birikim hedefi üst sınırı (kuruş) — yazım hatalarını yakalamak için: 100 milyar ₺
|
||||||
|
pub const MAX_AMOUNT: i64 = 10_000_000_000_000;
|
||||||
|
|
||||||
|
pub struct NewPeriod {
|
||||||
|
pub start_date: String,
|
||||||
|
pub end_date: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub savings_goal: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct PeriodChanges {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub start_date: Option<String>,
|
||||||
|
pub end_date: Option<String>,
|
||||||
|
pub savings_goal: Option<i64>,
|
||||||
|
/// `Some("")` → metni kaldır
|
||||||
|
pub reflection_md: Option<String>,
|
||||||
|
pub version: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Nasıl daha iyi yapabilirim?" üst sınırı (karakter) — notlarla aynı
|
||||||
|
pub const MAX_REFLECTION: usize = 20_000;
|
||||||
|
|
||||||
|
fn coll(db: &Database) -> mongodb::Collection<Period> {
|
||||||
|
db.collection(col::PERIODS)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn current(db: &Database, household_id: Uuid) -> AppResult<Option<Period>> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("status", PeriodStatus::Open.as_str());
|
||||||
|
Ok(coll(db).find_one(f).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// En yeni dönem başta.
|
||||||
|
pub async fn list(db: &Database, household_id: Uuid) -> AppResult<Vec<Period>> {
|
||||||
|
let mut cursor = coll(db)
|
||||||
|
.find(scope(household_id))
|
||||||
|
.sort(doc! { "start_date": -1 })
|
||||||
|
.await?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while cursor.advance().await? {
|
||||||
|
out.push(cursor.deserialize_current()?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", id);
|
||||||
|
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yazma işlemleri için: dönem bu haneye ait ve **açık** olmalı.
|
||||||
|
/// Plan kalemleri, notlar ve (Faz 5) harcamalar bunu kullanır.
|
||||||
|
pub async fn get_open(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
|
||||||
|
let p = get(db, household_id, id).await?;
|
||||||
|
if !p.status.is_writable() {
|
||||||
|
return Err(closed());
|
||||||
|
}
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn closed() -> AppError {
|
||||||
|
AppError::Conflict(
|
||||||
|
"Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResult<Period> {
|
||||||
|
let (start, end) = validate_range(&p.start_date, &p.end_date)?;
|
||||||
|
let savings_goal = validate_amount(p.savings_goal.unwrap_or(0))?;
|
||||||
|
let name = clean_name(p.name.as_deref(), start, end)?;
|
||||||
|
|
||||||
|
if let Some(open) = current(db, household_id).await? {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"Zaten açık bir dönem var ({}). Yeni dönem açmadan önce onu kapatın.",
|
||||||
|
open.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
ensure_no_overlap(db, household_id, start, end, None).await?;
|
||||||
|
|
||||||
|
// Bir önceki dönem (devir ve "önceki aydan kopyala" için)
|
||||||
|
let mut prev_f = scope(household_id);
|
||||||
|
prev_f.insert("start_date", doc! { "$lt": format_date(start) });
|
||||||
|
// Arşiv dönemleri devir zincirinde yok
|
||||||
|
prev_f.insert("status", doc! { "$ne": PeriodStatus::Archived.as_str() });
|
||||||
|
let previous = coll(db)
|
||||||
|
.find_one(prev_f)
|
||||||
|
.sort(doc! { "start_date": -1 })
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut d = new_meta(household_id);
|
||||||
|
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||||
|
d.insert("name", name);
|
||||||
|
d.insert("start_date", format_date(start));
|
||||||
|
d.insert("end_date", format_date(end));
|
||||||
|
d.insert("status", PeriodStatus::Open.as_str());
|
||||||
|
d.insert("savings_goal", savings_goal);
|
||||||
|
d.insert("reflection_md", Bson::Null);
|
||||||
|
d.insert("to_savings", Bson::Null);
|
||||||
|
d.insert(
|
||||||
|
"previous_period_id",
|
||||||
|
previous.as_ref().map_or(Bson::Null, |p| p.id.into()),
|
||||||
|
);
|
||||||
|
d.insert("closed_at", Bson::Null);
|
||||||
|
d.insert("closed_by", Bson::Null);
|
||||||
|
d.insert("reopened_at", Bson::Null);
|
||||||
|
|
||||||
|
// Tek transaction: dönem + önceki dönemden bekleyen devrin bu döneme bağlanması
|
||||||
|
let mut session = db.client().start_session().await?;
|
||||||
|
session.start_transaction().await?;
|
||||||
|
let result = async {
|
||||||
|
db.collection::<Document>(col::PERIODS)
|
||||||
|
.insert_one(d)
|
||||||
|
.session(&mut session)
|
||||||
|
.await?;
|
||||||
|
if let Some(prev) = &previous {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("from_period_id", prev.id);
|
||||||
|
f.insert("to_period_id", Bson::Null);
|
||||||
|
db.collection::<Document>(col::CARRYOVERS)
|
||||||
|
.update_many(
|
||||||
|
f,
|
||||||
|
doc! { "$set": { "to_period_id": id.clone(), "updated_at": now() }, "$inc": { "version": 1 } },
|
||||||
|
)
|
||||||
|
.session(&mut session)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok::<_, mongodb::error::Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(()) => session.commit_transaction().await?,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = session.abort_transaction().await;
|
||||||
|
// Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller
|
||||||
|
if is_duplicate_key(&e) {
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Zaten açık bir dönem var. Sayfayı yenileyin.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
coll(db)
|
||||||
|
.find_one(doc! { "_id": id })
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::Internal("açılan dönem okunamadı".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
ch: PeriodChanges,
|
||||||
|
) -> AppResult<Period> {
|
||||||
|
let current = get(db, household_id, id).await?;
|
||||||
|
if !current.status.is_writable() {
|
||||||
|
return Err(closed());
|
||||||
|
}
|
||||||
|
if ch.version.is_some_and(|v| v != current.version) {
|
||||||
|
return Err(stale());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut set = doc! { "updated_at": now() };
|
||||||
|
|
||||||
|
let start_s = ch.start_date.as_deref().unwrap_or(¤t.start_date);
|
||||||
|
let end_s = ch.end_date.as_deref().unwrap_or(¤t.end_date);
|
||||||
|
let (start, end) = validate_range(start_s, end_s)?;
|
||||||
|
let dates_changed =
|
||||||
|
format_date(start) != current.start_date || format_date(end) != current.end_date;
|
||||||
|
if dates_changed {
|
||||||
|
ensure_no_overlap(db, household_id, start, end, Some(id)).await?;
|
||||||
|
ensure_entries_inside(db, household_id, id, start, end).await?;
|
||||||
|
set.insert("start_date", format_date(start));
|
||||||
|
set.insert("end_date", format_date(end));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = ch.name {
|
||||||
|
set.insert("name", clean_name(Some(&name), start, end)?);
|
||||||
|
}
|
||||||
|
if let Some(goal) = ch.savings_goal {
|
||||||
|
set.insert("savings_goal", validate_amount(goal)?);
|
||||||
|
}
|
||||||
|
if let Some(text) = ch.reflection_md {
|
||||||
|
set.insert("reflection_md", clean_reflection(&text)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", id);
|
||||||
|
f.insert("version", current.version);
|
||||||
|
coll(db)
|
||||||
|
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||||
|
.return_document(ReturnDocument::After)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- doğrulamalar ----------
|
||||||
|
|
||||||
|
fn clean_reflection(text: &str) -> AppResult<Bson> {
|
||||||
|
let t = text.trim();
|
||||||
|
if t.is_empty() {
|
||||||
|
return Ok(Bson::Null);
|
||||||
|
}
|
||||||
|
if t.chars().count() > MAX_REFLECTION {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Metin en fazla 20.000 karakter olabilir".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Bson::String(t.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> {
|
||||||
|
let bad = |m: &str| AppError::BadRequest(m.into());
|
||||||
|
let start = parse_date(start).ok_or_else(|| bad("Başlangıç tarihi geçersiz"))?;
|
||||||
|
let end = parse_date(end).ok_or_else(|| bad("Bitiş tarihi geçersiz"))?;
|
||||||
|
let days = days_between(start, end);
|
||||||
|
if days < 1 {
|
||||||
|
return Err(bad("Bitiş tarihi başlangıçtan sonra olmalı"));
|
||||||
|
}
|
||||||
|
if days > MAX_DAYS {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Dönem en fazla {MAX_DAYS} gün olabilir"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok((start, end))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_amount(v: i64) -> AppResult<i64> {
|
||||||
|
if !(0..=MAX_AMOUNT).contains(&v) {
|
||||||
|
return Err(AppError::BadRequest("Tutar geçersiz".into()));
|
||||||
|
}
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_name(name: Option<&str>, start: Date, end: Date) -> AppResult<String> {
|
||||||
|
match name.map(str::trim) {
|
||||||
|
None | Some("") => Ok(default_period_name(start, end)),
|
||||||
|
Some(n) if n.chars().count() > 60 => Err(AppError::BadRequest(
|
||||||
|
"Dönem adı en fazla 60 karakter olabilir".into(),
|
||||||
|
)),
|
||||||
|
Some(n) => Ok(n.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Başka bir dönemle çakışma: `mevcut.başlangıç < yeni.bitiş && mevcut.bitiş > yeni.başlangıç`
|
||||||
|
/// (`YYYY-MM-DD` metinleri sözlük sırasıyla karşılaştırılabilir).
|
||||||
|
/// Geçmiş (arşiv) dönem açar: ekstre içe aktarımında dönemi olmayan aylar için.
|
||||||
|
/// Açık dönem kuralına takılmaz (arşiv açık sayılmaz); çakışma yine yasak. Plan, bütçe, devir yok.
|
||||||
|
pub async fn create_archive(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
start: Date,
|
||||||
|
end: Date,
|
||||||
|
) -> AppResult<Period> {
|
||||||
|
if end <= start {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Arşiv dönemi tarihleri geçersiz".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ensure_no_overlap(db, household_id, start, end, None).await?;
|
||||||
|
let mut d = new_meta(household_id);
|
||||||
|
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||||
|
d.insert("name", clean_name(None, start, end)?);
|
||||||
|
d.insert("start_date", format_date(start));
|
||||||
|
d.insert("end_date", format_date(end));
|
||||||
|
d.insert("status", PeriodStatus::Archived.as_str());
|
||||||
|
d.insert("savings_goal", 0_i64);
|
||||||
|
d.insert("reflection_md", Bson::Null);
|
||||||
|
d.insert("to_savings", Bson::Null);
|
||||||
|
d.insert("previous_period_id", Bson::Null);
|
||||||
|
d.insert("closed_at", Bson::Null);
|
||||||
|
d.insert("closed_by", Bson::Null);
|
||||||
|
d.insert("reopened_at", Bson::Null);
|
||||||
|
db.collection::<Document>(col::PERIODS)
|
||||||
|
.insert_one(d)
|
||||||
|
.await?;
|
||||||
|
coll(db)
|
||||||
|
.find_one(doc! { "_id": id })
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::Internal("arşiv dönemi okunamadı".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_no_overlap(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
start: Date,
|
||||||
|
end: Date,
|
||||||
|
exclude: Option<Uuid>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("start_date", doc! { "$lt": format_date(end) });
|
||||||
|
f.insert("end_date", doc! { "$gt": format_date(start) });
|
||||||
|
if let Some(id) = exclude {
|
||||||
|
f.insert("_id", doc! { "$ne": id });
|
||||||
|
}
|
||||||
|
if let Some(other) = coll(db).find_one(f).await? {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"Bu tarihler \"{}\" dönemiyle çakışıyor",
|
||||||
|
other.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dönem daraltılırken dışarıda harcama kalmasın.
|
||||||
|
async fn ensure_entries_inside(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
start: Date,
|
||||||
|
end: Date,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("period_id", period_id);
|
||||||
|
f.insert(
|
||||||
|
"$or",
|
||||||
|
vec![
|
||||||
|
doc! { "date": { "$lt": format_date(start) } },
|
||||||
|
doc! { "date": { "$gte": format_date(end) } },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let outside = db
|
||||||
|
.collection::<Document>(col::ENTRIES)
|
||||||
|
.count_documents(f)
|
||||||
|
.await?;
|
||||||
|
if outside > 0 {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"Yeni tarih aralığının dışında kalan {outside} harcama var. Önce onları taşıyın ya da silin."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stale() -> AppError {
|
||||||
|
AppError::Conflict("Dönem başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn range_rules() {
|
||||||
|
assert!(validate_range("2026-12-01", "2027-01-01").is_ok());
|
||||||
|
assert!(
|
||||||
|
validate_range("2026-12-01", "2026-12-01").is_err(),
|
||||||
|
"boş aralık"
|
||||||
|
);
|
||||||
|
assert!(validate_range("2026-12-10", "2026-12-01").is_err(), "ters");
|
||||||
|
assert!(
|
||||||
|
validate_range("2026-12-01", "2027-02-02").is_err(),
|
||||||
|
"63 gün"
|
||||||
|
);
|
||||||
|
assert!(validate_range("2026-12-01", "2027-02-01").is_ok(), "62 gün");
|
||||||
|
assert!(validate_range("01.12.2026", "2027-01-01").is_err(), "biçim");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn amount_rules() {
|
||||||
|
assert!(validate_amount(0).is_ok());
|
||||||
|
assert!(validate_amount(-1).is_err());
|
||||||
|
assert!(validate_amount(MAX_AMOUNT + 1).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,805 @@
|
|||||||
|
//! Uygulamanın kendi CSV biçimiyle dışa / içe aktarma (KAKEIBO_PROMPT §8.9):
|
||||||
|
//! harcamalar, plan kalemleri, varlık işlemleri.
|
||||||
|
//!
|
||||||
|
//! İçe aktarma iki aşamalı: `commit = false` → yalnızca rapor (deneme); `true` → geçerli satırlar
|
||||||
|
//! yazılır. Hatalı satırlar satır numarası ve nedeniyle raporlanır, diğerleri etkilenmez.
|
||||||
|
//! **Aynı dosya iki kez yüklense de çift kayıt olmaz:** veritabanında aynısı olan satır atlanır
|
||||||
|
//! (dosyada aynı satırdan iki tane, veritabanında bir tane varsa biri eklenir).
|
||||||
|
//! Sütunlar başlık adıyla bulunur (sıra serbest); kategori / ödeme türü / varlık adla eşleşir.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use mongodb::{
|
||||||
|
Database,
|
||||||
|
bson::{Bson, Document, Uuid, doc},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::col,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{
|
||||||
|
asset::{Asset, AssetKind, AssetTx, TxType},
|
||||||
|
common::{new_meta, scope},
|
||||||
|
entry::Entry,
|
||||||
|
group::Group,
|
||||||
|
period::Period,
|
||||||
|
plan_item::{PlanItem, PlanKind},
|
||||||
|
},
|
||||||
|
services::{
|
||||||
|
asset,
|
||||||
|
catalog::{self, Catalog},
|
||||||
|
csv_io::{self, column, norm},
|
||||||
|
entry::MAX_DESCRIPTION,
|
||||||
|
holding::{self, Move},
|
||||||
|
period,
|
||||||
|
plan::{self, NewItem},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Bir dosyada en fazla satır
|
||||||
|
pub const MAX_ROWS: usize = 20_000;
|
||||||
|
/// Önizlemede gösterilen geçerli satır sayısı
|
||||||
|
const PREVIEW: usize = 10;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Report {
|
||||||
|
/// Başlık hariç dolu satır sayısı
|
||||||
|
pub total: usize,
|
||||||
|
/// Eklenebilir satırlar (tekrarlar hariç)
|
||||||
|
pub valid: usize,
|
||||||
|
/// Veritabanında zaten olduğu için atlanan
|
||||||
|
pub duplicates: usize,
|
||||||
|
/// Yazılan (deneme: 0)
|
||||||
|
pub imported: usize,
|
||||||
|
/// (satır numarası, neden)
|
||||||
|
pub errors: Vec<(usize, String)>,
|
||||||
|
/// İlk geçerli satırlar, gösterime hazır hücreler
|
||||||
|
pub preview: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (satır numarası, hücreler)
|
||||||
|
type Rows = Vec<(usize, Vec<String>)>;
|
||||||
|
|
||||||
|
fn rows_of(text: &str) -> AppResult<(Vec<String>, Rows)> {
|
||||||
|
let mut rows = csv_io::parse(text);
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("Dosya boş".into()));
|
||||||
|
}
|
||||||
|
if rows.len() - 1 > MAX_ROWS {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Dosya en fazla {MAX_ROWS} satır olabilir; parçalara bölün"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let header = rows.remove(0).1;
|
||||||
|
Ok((header, rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zorunlu sütunlar: (anahtar adlar) → sıra; eksikse anlaşılır hata
|
||||||
|
fn require(header: &[String], cols: &[(&str, &[&str])]) -> AppResult<Vec<usize>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
for (label, names) in cols {
|
||||||
|
match column(header, names) {
|
||||||
|
Some(i) => out.push(i),
|
||||||
|
None => missing.push(*label),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !missing.is_empty() {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Gerekli sütun eksik: {}",
|
||||||
|
missing.join(", ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cell(row: &[String], i: Option<usize>) -> &str {
|
||||||
|
i.and_then(|i| row.get(i)).map_or("", |s| s.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn period_for<'a>(periods: &'a [Period], date: &str) -> Result<&'a Period, String> {
|
||||||
|
let p = periods
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.start_date.as_str() <= date && date < p.end_date.as_str())
|
||||||
|
.ok_or_else(|| "Bu tarih için dönem yok".to_string())?;
|
||||||
|
if !p.status.is_writable() {
|
||||||
|
return Err(format!("Dönem kapalı ({})", p.name));
|
||||||
|
}
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_from(s: &str) -> Option<Group> {
|
||||||
|
let n = norm(s);
|
||||||
|
Group::ALL
|
||||||
|
.into_iter()
|
||||||
|
.find(|g| norm(g.label()) == n || g.as_str() == n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ada göre katalog kaydı (grup verilmişse o grupta). Aynı ad birden çok grupta → hata.
|
||||||
|
fn find_by_name<'a>(
|
||||||
|
items: &'a [catalog::Item],
|
||||||
|
name: &str,
|
||||||
|
group: Option<Group>,
|
||||||
|
what: &str,
|
||||||
|
) -> Result<&'a catalog::Item, String> {
|
||||||
|
let n = norm(name);
|
||||||
|
let matches: Vec<&catalog::Item> = items
|
||||||
|
.iter()
|
||||||
|
.filter(|c| norm(&c.name) == n && (group.is_none() || c.group == group))
|
||||||
|
.collect();
|
||||||
|
match matches.as_slice() {
|
||||||
|
[one] => Ok(one),
|
||||||
|
[] => Err(format!("{what} bulunamadı: {name}")),
|
||||||
|
_ => Err(format!(
|
||||||
|
"\"{name}\" birden fazla grupta var; Grup sütununu doldurun"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tekrar sayacı: anahtar → veritabanında kaç tane var (eşleştikçe azalır)
|
||||||
|
struct Existing(HashMap<String, usize>);
|
||||||
|
|
||||||
|
impl Existing {
|
||||||
|
fn new(keys: impl Iterator<Item = String>) -> Self {
|
||||||
|
let mut m = HashMap::new();
|
||||||
|
for k in keys {
|
||||||
|
*m.entry(k).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
Self(m)
|
||||||
|
}
|
||||||
|
/// Varsa bir tanesini "tüket" ve `true` dön
|
||||||
|
fn take(&mut self, key: &str) -> bool {
|
||||||
|
match self.0.get_mut(key) {
|
||||||
|
Some(n) if *n > 0 => {
|
||||||
|
*n -= 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= Harcamalar =================
|
||||||
|
|
||||||
|
const E_HEADERS: [&str; 6] = [
|
||||||
|
"Tarih",
|
||||||
|
"Tutar",
|
||||||
|
"Grup",
|
||||||
|
"Kategori",
|
||||||
|
"Ödeme türü",
|
||||||
|
"Açıklama",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn entry_key(date: &str, amount: i64, category: Uuid, desc: Option<&str>) -> String {
|
||||||
|
format!("{date}|{amount}|{category}|{}", desc.unwrap_or("").trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn export_entries(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
from: Option<&str>,
|
||||||
|
to: Option<&str>,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
|
||||||
|
let pms = catalog::list(db, Catalog::PaymentMethods, household_id, true).await?;
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
let mut range = Document::new();
|
||||||
|
if let Some(d) = from {
|
||||||
|
range.insert("$gte", d);
|
||||||
|
}
|
||||||
|
if let Some(d) = to {
|
||||||
|
range.insert("$lte", d);
|
||||||
|
}
|
||||||
|
if !range.is_empty() {
|
||||||
|
f.insert("date", range);
|
||||||
|
}
|
||||||
|
let mut c = db
|
||||||
|
.collection::<Entry>(col::ENTRIES)
|
||||||
|
.find(f)
|
||||||
|
.sort(doc! { "date": 1, "_id": 1 })
|
||||||
|
.await?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
while c.advance().await? {
|
||||||
|
let e = c.deserialize_current()?;
|
||||||
|
let cat = cats.iter().find(|x| x.id == e.category_id);
|
||||||
|
let pm = e
|
||||||
|
.payment_method_id
|
||||||
|
.and_then(|id| pms.iter().find(|x| x.id == id));
|
||||||
|
rows.push(vec![
|
||||||
|
csv_io::date(&e.date),
|
||||||
|
csv_io::money(e.amount),
|
||||||
|
e.group.label().to_string(),
|
||||||
|
cat.map_or(String::new(), |x| x.name.clone()),
|
||||||
|
pm.map_or(String::new(), |x| x.name.clone()),
|
||||||
|
e.description.unwrap_or_default(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Ok(csv_io::write(&E_HEADERS, &rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_entries(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
text: &str,
|
||||||
|
commit: bool,
|
||||||
|
) -> AppResult<Report> {
|
||||||
|
let (header, rows) = rows_of(text)?;
|
||||||
|
let req = require(
|
||||||
|
&header,
|
||||||
|
&[
|
||||||
|
("Tarih", &["Tarih", "date"]),
|
||||||
|
("Tutar", &["Tutar", "amount"]),
|
||||||
|
("Kategori", &["Kategori", "category"]),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let (c_date, c_amount, c_cat) = (Some(req[0]), Some(req[1]), Some(req[2]));
|
||||||
|
let c_group = column(&header, &["Grup", "group"]);
|
||||||
|
let c_pm = column(&header, &["Ödeme türü", "Ödeme", "payment_method"]);
|
||||||
|
let c_desc = column(&header, &["Açıklama", "description"]);
|
||||||
|
|
||||||
|
let periods = period::list(db, household_id).await?;
|
||||||
|
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
|
||||||
|
let pms = catalog::list(db, Catalog::PaymentMethods, household_id, true).await?;
|
||||||
|
let mut existing = {
|
||||||
|
let mut c = db
|
||||||
|
.collection::<Entry>(col::ENTRIES)
|
||||||
|
.find(scope(household_id))
|
||||||
|
.await?;
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
while c.advance().await? {
|
||||||
|
let e = c.deserialize_current()?;
|
||||||
|
keys.push(entry_key(
|
||||||
|
&e.date,
|
||||||
|
e.amount,
|
||||||
|
e.category_id,
|
||||||
|
e.description.as_deref(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Existing::new(keys.into_iter())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut report = Report {
|
||||||
|
total: rows.len(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut docs = Vec::new();
|
||||||
|
for (line, row) in &rows {
|
||||||
|
let parsed = (|| -> Result<Document, String> {
|
||||||
|
let date = csv_io::parse_date_any(cell(row, c_date))
|
||||||
|
.ok_or_else(|| format!("Tarih geçersiz: {}", cell(row, c_date)))?;
|
||||||
|
let p = period_for(&periods, &date)?;
|
||||||
|
let amount = csv_io::parse_money(cell(row, c_amount))
|
||||||
|
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_amount)))?;
|
||||||
|
if amount <= 0 {
|
||||||
|
return Err("Tutar sıfırdan büyük olmalı".into());
|
||||||
|
}
|
||||||
|
period::validate_amount(amount).map_err(|e| e.to_string())?;
|
||||||
|
let group = match cell(row, c_group) {
|
||||||
|
"" => None,
|
||||||
|
g => Some(group_from(g).ok_or_else(|| format!("Grup tanınmadı: {g}"))?),
|
||||||
|
};
|
||||||
|
let cat = find_by_name(&cats, cell(row, c_cat), group, "Kategori")?;
|
||||||
|
let cat_group = cat.group.ok_or("Kategorinin grubu yok")?;
|
||||||
|
let pm = match cell(row, c_pm) {
|
||||||
|
"" => None,
|
||||||
|
n => Some(find_by_name(&pms, n, None, "Ödeme türü")?.id),
|
||||||
|
};
|
||||||
|
let desc = match cell(row, c_desc) {
|
||||||
|
"" => None,
|
||||||
|
d if d.chars().count() > MAX_DESCRIPTION => {
|
||||||
|
return Err(format!(
|
||||||
|
"Açıklama en fazla {MAX_DESCRIPTION} karakter olabilir"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
d => Some(d.to_string()),
|
||||||
|
};
|
||||||
|
let mut d = new_meta(household_id);
|
||||||
|
d.insert("period_id", p.id);
|
||||||
|
d.insert("date", &date);
|
||||||
|
d.insert("amount", amount);
|
||||||
|
d.insert("group", cat_group.as_str());
|
||||||
|
d.insert("category_id", cat.id);
|
||||||
|
d.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
|
||||||
|
d.insert("description", desc.clone().map_or(Bson::Null, Bson::String));
|
||||||
|
d.insert("created_by", user_id);
|
||||||
|
d.insert("__key", entry_key(&date, amount, cat.id, desc.as_deref()));
|
||||||
|
d.insert(
|
||||||
|
"__preview",
|
||||||
|
vec![
|
||||||
|
csv_io::date(&date),
|
||||||
|
csv_io::money(amount),
|
||||||
|
cat.name.clone(),
|
||||||
|
desc.unwrap_or_default(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
Ok(d)
|
||||||
|
})();
|
||||||
|
match parsed {
|
||||||
|
Err(e) => report.errors.push((*line, e)),
|
||||||
|
Ok(mut d) => {
|
||||||
|
let key = d.get_str("__key").unwrap_or_default().to_string();
|
||||||
|
if existing.take(&key) {
|
||||||
|
report.duplicates += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if report.preview.len() < PREVIEW
|
||||||
|
&& let Ok(p) = d.get_array("__preview")
|
||||||
|
{
|
||||||
|
report.preview.push(
|
||||||
|
p.iter()
|
||||||
|
.map(|x| x.as_str().unwrap_or_default().to_string())
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
d.remove("__key");
|
||||||
|
d.remove("__preview");
|
||||||
|
docs.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.valid = docs.len();
|
||||||
|
if commit && !docs.is_empty() {
|
||||||
|
db.collection::<Document>(col::ENTRIES)
|
||||||
|
.insert_many(docs)
|
||||||
|
.await?;
|
||||||
|
report.imported = report.valid;
|
||||||
|
}
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= Plan kalemleri =================
|
||||||
|
|
||||||
|
const P_HEADERS: [&str; 6] = ["Dönem başlangıcı", "Dönem", "Tür", "Ad", "Tutar", "Tarih"];
|
||||||
|
|
||||||
|
fn kind_label(k: PlanKind) -> &'static str {
|
||||||
|
match k {
|
||||||
|
PlanKind::Income => "Gelir",
|
||||||
|
PlanKind::Expense => "Sabit gider",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_from(s: &str) -> Option<PlanKind> {
|
||||||
|
match norm(s).as_str() {
|
||||||
|
"gelir" | "income" => Some(PlanKind::Income),
|
||||||
|
"gider" | "sabitgider" | "expense" => Some(PlanKind::Expense),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_key(period: Uuid, kind: PlanKind, name: &str, amount: i64) -> String {
|
||||||
|
format!("{period}|{}|{}|{amount}", kind.as_str(), norm(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn all_plan_items(db: &Database, household_id: Uuid) -> AppResult<Vec<PlanItem>> {
|
||||||
|
let mut c = db
|
||||||
|
.collection::<PlanItem>(col::PLAN_ITEMS)
|
||||||
|
.find(scope(household_id))
|
||||||
|
.sort(doc! { "period_id": 1, "kind": 1, "sort_order": 1 })
|
||||||
|
.await?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while c.advance().await? {
|
||||||
|
out.push(c.deserialize_current()?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn export_plan_items(db: &Database, household_id: Uuid) -> AppResult<String> {
|
||||||
|
let mut periods = period::list(db, household_id).await?;
|
||||||
|
periods.reverse(); // eskiden yeniye
|
||||||
|
let items = all_plan_items(db, household_id).await?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for p in &periods {
|
||||||
|
// Uygulamadaki sıra: önce gelirler, sonra sabit giderler
|
||||||
|
let mut list: Vec<&PlanItem> = items.iter().filter(|i| i.period_id == p.id).collect();
|
||||||
|
list.sort_by_key(|i| (i.kind != PlanKind::Income, i.sort_order));
|
||||||
|
for i in list {
|
||||||
|
rows.push(vec![
|
||||||
|
csv_io::date(&p.start_date),
|
||||||
|
p.name.clone(),
|
||||||
|
kind_label(i.kind).to_string(),
|
||||||
|
i.name.clone(),
|
||||||
|
csv_io::money(i.amount),
|
||||||
|
i.date.as_deref().map(csv_io::date).unwrap_or_default(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(csv_io::write(&P_HEADERS, &rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_plan_items(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
text: &str,
|
||||||
|
commit: bool,
|
||||||
|
) -> AppResult<Report> {
|
||||||
|
let (header, rows) = rows_of(text)?;
|
||||||
|
let req = require(
|
||||||
|
&header,
|
||||||
|
&[
|
||||||
|
("Dönem başlangıcı", &["Dönem başlangıcı", "period_start"]),
|
||||||
|
("Tür", &["Tür", "kind"]),
|
||||||
|
("Ad", &["Ad", "name"]),
|
||||||
|
("Tutar", &["Tutar", "amount"]),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let (c_period, c_kind, c_name, c_amount) =
|
||||||
|
(Some(req[0]), Some(req[1]), Some(req[2]), Some(req[3]));
|
||||||
|
let c_date = column(&header, &["Tarih", "date"]);
|
||||||
|
|
||||||
|
let periods = period::list(db, household_id).await?;
|
||||||
|
let mut existing = Existing::new(
|
||||||
|
all_plan_items(db, household_id)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|i| plan_key(i.period_id, i.kind, &i.name, i.amount)),
|
||||||
|
);
|
||||||
|
let mut report = Report {
|
||||||
|
total: rows.len(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut todo: Vec<(usize, Uuid, NewItem)> = Vec::new();
|
||||||
|
for (line, row) in &rows {
|
||||||
|
let parsed = (|| -> Result<(Uuid, NewItem, Vec<String>), String> {
|
||||||
|
let start = csv_io::parse_date_any(cell(row, c_period))
|
||||||
|
.ok_or_else(|| format!("Dönem başlangıcı geçersiz: {}", cell(row, c_period)))?;
|
||||||
|
let p = period_for(&periods, &start)?;
|
||||||
|
let kind = kind_from(cell(row, c_kind)).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Tür \"Gelir\" ya da \"Sabit gider\" olmalı: {}",
|
||||||
|
cell(row, c_kind)
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let name = cell(row, c_name);
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("Ad boş".into());
|
||||||
|
}
|
||||||
|
let amount = csv_io::parse_money(cell(row, c_amount))
|
||||||
|
.filter(|a| *a > 0)
|
||||||
|
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_amount)))?;
|
||||||
|
let date = match cell(row, c_date) {
|
||||||
|
"" => None,
|
||||||
|
d => Some(csv_io::parse_date_any(d).ok_or_else(|| format!("Tarih geçersiz: {d}"))?),
|
||||||
|
};
|
||||||
|
let preview = vec![
|
||||||
|
p.name.clone(),
|
||||||
|
kind_label(kind).into(),
|
||||||
|
name.into(),
|
||||||
|
csv_io::money(amount),
|
||||||
|
];
|
||||||
|
Ok((
|
||||||
|
p.id,
|
||||||
|
NewItem {
|
||||||
|
kind,
|
||||||
|
name: name.to_string(),
|
||||||
|
amount,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
preview,
|
||||||
|
))
|
||||||
|
})();
|
||||||
|
match parsed {
|
||||||
|
Err(e) => report.errors.push((*line, e)),
|
||||||
|
Ok((pid, item, preview)) => {
|
||||||
|
if existing.take(&plan_key(pid, item.kind, &item.name, item.amount)) {
|
||||||
|
report.duplicates += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if report.preview.len() < PREVIEW {
|
||||||
|
report.preview.push(preview);
|
||||||
|
}
|
||||||
|
todo.push((*line, pid, item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.valid = todo.len();
|
||||||
|
if commit {
|
||||||
|
// Ad uzunluğu, tarihin dönem içinde olması vb. son denetim `plan::create`'te
|
||||||
|
for (line, pid, item) in todo {
|
||||||
|
match plan::create(db, household_id, pid, item).await {
|
||||||
|
Ok(_) => report.imported += 1,
|
||||||
|
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => {
|
||||||
|
report.errors.push((line, m));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.valid = report.imported;
|
||||||
|
report.errors.sort_by_key(|(l, _)| *l);
|
||||||
|
}
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= Varlık işlemleri =================
|
||||||
|
|
||||||
|
const A_HEADERS: [&str; 8] = [
|
||||||
|
"Tarih",
|
||||||
|
"Varlık kodu",
|
||||||
|
"Varlık",
|
||||||
|
"İşlem",
|
||||||
|
"Miktar",
|
||||||
|
"Birim fiyat",
|
||||||
|
"Tutar",
|
||||||
|
"Not",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn tx_key(asset: Uuid, date: &str, t: TxType, q: i64, total: i64) -> String {
|
||||||
|
format!("{asset}|{date}|{}|{q}|{total}", t.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn household_assets(db: &Database, household_id: Uuid) -> AppResult<Vec<Asset>> {
|
||||||
|
let mut c = db
|
||||||
|
.collection::<Asset>(col::ASSETS)
|
||||||
|
.find(scope(household_id))
|
||||||
|
.sort(doc! { "sort_order": 1 })
|
||||||
|
.await?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while c.advance().await? {
|
||||||
|
out.push(c.deserialize_current()?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn household_txs(db: &Database, household_id: Uuid) -> AppResult<Vec<AssetTx>> {
|
||||||
|
let mut c = db
|
||||||
|
.collection::<AssetTx>(col::ASSET_TRANSACTIONS)
|
||||||
|
.find(scope(household_id))
|
||||||
|
.sort(doc! { "date": 1, "_id": 1 })
|
||||||
|
.await?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while c.advance().await? {
|
||||||
|
out.push(c.deserialize_current()?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn export_asset_txs(db: &Database, household_id: Uuid) -> AppResult<String> {
|
||||||
|
let assets = household_assets(db, household_id).await?;
|
||||||
|
let rows = household_txs(db, household_id)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| {
|
||||||
|
let a = assets.iter().find(|a| a.id == t.asset_id);
|
||||||
|
vec![
|
||||||
|
csv_io::date(&t.date),
|
||||||
|
a.map_or(String::new(), |a| a.code.clone()),
|
||||||
|
a.map_or(String::new(), |a| a.name.clone()),
|
||||||
|
match t.tx_type {
|
||||||
|
TxType::Buy => "Alım",
|
||||||
|
TxType::Sell => "Satım",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
csv_io::e6(t.quantity_e6),
|
||||||
|
csv_io::e6(t.unit_price_e6),
|
||||||
|
csv_io::money(t.total),
|
||||||
|
t.note.unwrap_or_default(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Ok(csv_io::write(&A_HEADERS, &rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewTxRow {
|
||||||
|
line: usize,
|
||||||
|
asset: Uuid,
|
||||||
|
tx: asset::NewTx,
|
||||||
|
quantity_e6: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_asset_txs(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
text: &str,
|
||||||
|
commit: bool,
|
||||||
|
) -> AppResult<Report> {
|
||||||
|
let (header, rows) = rows_of(text)?;
|
||||||
|
let req = require(
|
||||||
|
&header,
|
||||||
|
&[
|
||||||
|
("Tarih", &["Tarih", "date"]),
|
||||||
|
("İşlem", &["İşlem", "type"]),
|
||||||
|
("Tutar", &["Tutar", "total"]),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let (c_date, c_type, c_total) = (Some(req[0]), Some(req[1]), Some(req[2]));
|
||||||
|
let c_code = column(&header, &["Varlık kodu", "Kod", "code"]);
|
||||||
|
let c_name = column(&header, &["Varlık", "asset"]);
|
||||||
|
if c_code.is_none() && c_name.is_none() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Gerekli sütun eksik: Varlık kodu (ya da Varlık)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let c_qty = column(&header, &["Miktar", "quantity"]);
|
||||||
|
let c_price = column(&header, &["Birim fiyat", "unit_price"]);
|
||||||
|
let c_note = column(&header, &["Not", "note"]);
|
||||||
|
|
||||||
|
let assets = household_assets(db, household_id).await?;
|
||||||
|
let existing_txs = household_txs(db, household_id).await?;
|
||||||
|
let mut existing = Existing::new(
|
||||||
|
existing_txs
|
||||||
|
.iter()
|
||||||
|
.map(|t| tx_key(t.asset_id, &t.date, t.tx_type, t.quantity_e6, t.total)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut report = Report {
|
||||||
|
total: rows.len(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut todo: Vec<NewTxRow> = Vec::new();
|
||||||
|
for (line, row) in &rows {
|
||||||
|
let parsed = (|| -> Result<(NewTxRow, Vec<String>), String> {
|
||||||
|
let a = {
|
||||||
|
let code = norm(cell(row, c_code));
|
||||||
|
let name = norm(cell(row, c_name));
|
||||||
|
assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| {
|
||||||
|
(!code.is_empty() && norm(&a.code) == code)
|
||||||
|
|| (code.is_empty() && norm(&a.name) == name)
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Varlık bulunamadı: {}",
|
||||||
|
if code.is_empty() {
|
||||||
|
cell(row, c_name)
|
||||||
|
} else {
|
||||||
|
cell(row, c_code)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
if !a.is_active {
|
||||||
|
return Err(format!("Varlık pasif: {}", a.name));
|
||||||
|
}
|
||||||
|
let date = csv_io::parse_date_any(cell(row, c_date))
|
||||||
|
.ok_or_else(|| format!("Tarih geçersiz: {}", cell(row, c_date)))?;
|
||||||
|
let tx_type = match norm(cell(row, c_type)).as_str() {
|
||||||
|
"alım" | "alis" | "alış" | "buy" => TxType::Buy,
|
||||||
|
"satım" | "satış" | "satis" | "sell" => TxType::Sell,
|
||||||
|
t => return Err(format!("İşlem \"Alım\" ya da \"Satım\" olmalı: {t}")),
|
||||||
|
};
|
||||||
|
let total = csv_io::parse_money(cell(row, c_total))
|
||||||
|
.filter(|t| *t > 0)
|
||||||
|
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_total)))?;
|
||||||
|
let quantity_e6 = if a.kind == AssetKind::Cash {
|
||||||
|
total * 10_000
|
||||||
|
} else {
|
||||||
|
csv_io::parse_e6(cell(row, c_qty))
|
||||||
|
.filter(|q| *q > 0)
|
||||||
|
.ok_or_else(|| format!("Miktar geçersiz: {}", cell(row, c_qty)))?
|
||||||
|
};
|
||||||
|
let unit_price_e6 = match cell(row, c_price) {
|
||||||
|
"" => None,
|
||||||
|
p => Some(
|
||||||
|
csv_io::parse_e6(p)
|
||||||
|
.filter(|p| *p > 0)
|
||||||
|
.ok_or_else(|| format!("Birim fiyat geçersiz: {p}"))?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let note = match cell(row, c_note) {
|
||||||
|
"" => None,
|
||||||
|
n => Some(n.to_string()),
|
||||||
|
};
|
||||||
|
let preview = vec![
|
||||||
|
csv_io::date(&date),
|
||||||
|
a.name.clone(),
|
||||||
|
if tx_type == TxType::Buy {
|
||||||
|
"Alım"
|
||||||
|
} else {
|
||||||
|
"Satım"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
if a.kind == AssetKind::Cash {
|
||||||
|
csv_io::money(total)
|
||||||
|
} else {
|
||||||
|
format!("{} {}", csv_io::e6(quantity_e6), a.unit_label)
|
||||||
|
},
|
||||||
|
];
|
||||||
|
Ok((
|
||||||
|
NewTxRow {
|
||||||
|
line: *line,
|
||||||
|
asset: a.id,
|
||||||
|
quantity_e6,
|
||||||
|
tx: asset::NewTx {
|
||||||
|
tx_type,
|
||||||
|
date,
|
||||||
|
quantity_e6,
|
||||||
|
unit_price_e6,
|
||||||
|
total,
|
||||||
|
note,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview,
|
||||||
|
))
|
||||||
|
})();
|
||||||
|
match parsed {
|
||||||
|
Err(e) => report.errors.push((*line, e)),
|
||||||
|
Ok((r, preview)) => {
|
||||||
|
if existing.take(&tx_key(
|
||||||
|
r.asset,
|
||||||
|
&r.tx.date,
|
||||||
|
r.tx.tx_type,
|
||||||
|
r.quantity_e6,
|
||||||
|
r.tx.total,
|
||||||
|
)) {
|
||||||
|
report.duplicates += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if report.preview.len() < PREVIEW {
|
||||||
|
report.preview.push(preview);
|
||||||
|
}
|
||||||
|
todo.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tarih sırasıyla (aynı gün dosya sırası) eldekinden fazla satış denetimi:
|
||||||
|
// mevcut işlemler + kabul edilen yeniler; aşan satır reddedilir, diğerleri sürer.
|
||||||
|
todo.sort_by(|a, b| (a.tx.date.as_str(), a.line).cmp(&(b.tx.date.as_str(), b.line)));
|
||||||
|
let mut accepted: Vec<NewTxRow> = Vec::new();
|
||||||
|
for r in todo {
|
||||||
|
let mut list: Vec<(String, u8, usize, Move)> = existing_txs
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.asset_id == r.asset)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, t)| {
|
||||||
|
(
|
||||||
|
t.date.clone(),
|
||||||
|
0,
|
||||||
|
i,
|
||||||
|
Move {
|
||||||
|
tx_type: t.tx_type,
|
||||||
|
quantity_e6: t.quantity_e6,
|
||||||
|
total: t.total,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.chain(
|
||||||
|
accepted
|
||||||
|
.iter()
|
||||||
|
.chain(std::iter::once(&r))
|
||||||
|
.filter(|x| x.asset == r.asset)
|
||||||
|
.map(|x| {
|
||||||
|
(
|
||||||
|
x.tx.date.clone(),
|
||||||
|
1,
|
||||||
|
x.line,
|
||||||
|
Move {
|
||||||
|
tx_type: x.tx.tx_type,
|
||||||
|
quantity_e6: x.quantity_e6,
|
||||||
|
total: x.tx.total,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
list.sort_by(|a, b| (a.0.as_str(), a.1, a.2).cmp(&(b.0.as_str(), b.1, b.2)));
|
||||||
|
let moves: Vec<Move> = list.into_iter().map(|x| x.3).collect();
|
||||||
|
if holding::holding(&moves).is_err() {
|
||||||
|
report.errors.push((
|
||||||
|
r.line,
|
||||||
|
"Satış miktarı o tarihte eldeki miktarı aşıyor".into(),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
accepted.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.errors.sort_by_key(|(l, _)| *l);
|
||||||
|
report.valid = accepted.len();
|
||||||
|
if commit {
|
||||||
|
report.valid = 0;
|
||||||
|
for r in accepted {
|
||||||
|
match asset::create_tx(db, household_id, user_id, r.asset, r.tx).await {
|
||||||
|
Ok(_) => report.imported += 1,
|
||||||
|
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => {
|
||||||
|
report.errors.push((r.line, m))
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.valid = report.imported;
|
||||||
|
report.errors.sort_by_key(|(l, _)| *l);
|
||||||
|
}
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//! Tüm route'ların paylaştığı uygulama durumu.
|
||||||
|
|
||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use mongodb::Database;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::{captcha::Captcha, guard::AttemptGuard, jwt::Jwt, limiter::LoginLimiter},
|
||||||
|
config::Config,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `Clone` ucuzdur: `Database` ve `Arc` içte referans sayımı kullanır, veri kopyalanmaz.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: Database,
|
||||||
|
pub config: Arc<Config>,
|
||||||
|
pub jwt: Arc<Jwt>,
|
||||||
|
/// Şifre değiştirmede mevcut şifre denemeleri (kullanıcı başına)
|
||||||
|
pub login_limiter: Arc<LoginLimiter>,
|
||||||
|
/// Giriş / kayıt: IP başına üst üste 3 hata → 15 dk kilit
|
||||||
|
pub guard: Arc<AttemptGuard>,
|
||||||
|
pub captcha: Arc<Captcha>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(db: Database, config: Config) -> Self {
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
jwt: Arc::new(Jwt::new(&config.jwt_secret)),
|
||||||
|
login_limiter: Arc::new(LoginLimiter::new(5, Duration::from_secs(15 * 60))),
|
||||||
|
guard: Arc::new(AttemptGuard::default()),
|
||||||
|
captcha: Arc::new(Captcha::from_secret(config.turnstile_secret_key.as_deref())),
|
||||||
|
config: Arc::new(config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// Testler: bot doğrulamasını ağsız sabit sonuçla değiştir
|
||||||
|
pub fn with_captcha(mut self, captcha: Captcha) -> Self {
|
||||||
|
self.captcha = Arc::new(captcha);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
//! Hesap ve hane ayarları: profil adı, şifre değiştirme (diğer oturumlar kapanır),
|
||||||
|
//! hane adı + bütçe varsayılanları, üye çıkarma, yetkiler.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::http::{Method, StatusCode};
|
||||||
|
use common::{Session, TestApp};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
async fn login(app: &TestApp, email: &str, password: &str) -> (StatusCode, Option<String>) {
|
||||||
|
let (st, _, cookie) = app
|
||||||
|
.send(
|
||||||
|
Method::POST,
|
||||||
|
"/auth/login",
|
||||||
|
None,
|
||||||
|
Some(json!({ "email": email, "password": password, "remember_me": true })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
(st, cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn me(app: &TestApp, s: &Session) -> (StatusCode, Value) {
|
||||||
|
app.get("/auth/me", s).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_cookie(s: &Session, cookie: String) -> Session {
|
||||||
|
Session {
|
||||||
|
cookie,
|
||||||
|
household_id: s.household_id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn profile_name_and_password_change_closes_other_sessions() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let a = app.register("Ayse", None).await;
|
||||||
|
let (_, m) = me(&app, &a).await;
|
||||||
|
let email = m["user"]["email"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// Ad
|
||||||
|
let (st, m) = app
|
||||||
|
.patch("/auth/me", &a, json!({ "name": " Ayşe Ç. " }))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
(st, m["user"]["name"].as_str()),
|
||||||
|
(StatusCode::OK, Some("Ayşe Ç."))
|
||||||
|
);
|
||||||
|
let (st, _) = app.patch("/auth/me", &a, json!({ "name": " " })).await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// İkinci cihaz
|
||||||
|
let (st, other) = login(&app, &email, "test-sifre-123").await;
|
||||||
|
assert_eq!(st, StatusCode::OK);
|
||||||
|
let other = with_cookie(&a, other.unwrap());
|
||||||
|
tokio::time::sleep(Duration::from_millis(1100)).await; // iat saniye hassasiyetinde
|
||||||
|
|
||||||
|
// Yanlış mevcut şifre / kısa / aynı şifre
|
||||||
|
let pw = |cur: &str, new: &str| json!({ "current_password": cur, "new_password": new });
|
||||||
|
let (st, b) = app
|
||||||
|
.post(
|
||||||
|
"/auth/me/password",
|
||||||
|
&a,
|
||||||
|
pw("yanlis-sifre", "yeni-sifre-456"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(b["error"]["message"], "Mevcut şifre hatalı");
|
||||||
|
let (st, _) = app
|
||||||
|
.post("/auth/me/password", &a, pw("test-sifre-123", "kisa"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
let (st, b) = app
|
||||||
|
.post(
|
||||||
|
"/auth/me/password",
|
||||||
|
&a,
|
||||||
|
pw("test-sifre-123", "test-sifre-123"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST, "{b}");
|
||||||
|
|
||||||
|
// Doğru → bu cihaz yeni çerezle devam, diğer cihaz düşer
|
||||||
|
let (st, _, cookie) = app
|
||||||
|
.send(
|
||||||
|
Method::POST,
|
||||||
|
"/auth/me/password",
|
||||||
|
Some(&a.cookie),
|
||||||
|
Some(pw("test-sifre-123", "yeni-sifre-456")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||||
|
let fresh = with_cookie(&a, cookie.expect("yeni oturum çerezi"));
|
||||||
|
assert_eq!(me(&app, &fresh).await.0, StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
me(&app, &other).await.0,
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"diğer cihaz"
|
||||||
|
);
|
||||||
|
assert_eq!(me(&app, &a).await.0, StatusCode::UNAUTHORIZED, "eski çerez");
|
||||||
|
|
||||||
|
// Giriş: eski şifre geçmez, yeni geçer
|
||||||
|
assert_eq!(
|
||||||
|
login(&app, &email, "test-sifre-123").await.0,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
login(&app, &email, "yeni-sifre-456").await.0,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wrong_current_password_is_rate_limited() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let a = app.register("Ayse", None).await;
|
||||||
|
let body = json!({ "current_password": "yanlis", "new_password": "yeni-sifre-456" });
|
||||||
|
for _ in 0..5 {
|
||||||
|
let (st, _) = app.post("/auth/me/password", &a, body.clone()).await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
let (st, _) = app
|
||||||
|
.post(
|
||||||
|
"/auth/me/password",
|
||||||
|
&a,
|
||||||
|
json!({ "current_password": "test-sifre-123", "new_password": "yeni-sifre-456" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS, "doğru şifre de bekler");
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn household_settings_and_budget_default_split() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let owner = app.register("Sahip", None).await;
|
||||||
|
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
|
||||||
|
let member = app
|
||||||
|
.register("Uye", Some(inv["code"].as_str().unwrap()))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (_, h) = app.get("/household", &owner).await;
|
||||||
|
assert_eq!(
|
||||||
|
h["settings"],
|
||||||
|
json!({ "budget_split_pct": { "needs": 50, "wants": 30, "culture": 10, "unexpected": 10 }, "budget_suggestion": "default" })
|
||||||
|
);
|
||||||
|
|
||||||
|
let update = json!({
|
||||||
|
"name": "Çakır Ailesi",
|
||||||
|
"budget_split_pct": { "needs": 60, "wants": 20, "culture": 10, "unexpected": 10 },
|
||||||
|
"budget_suggestion": "avg3",
|
||||||
|
});
|
||||||
|
let (st, _) = app.patch("/household", &member, update.clone()).await;
|
||||||
|
assert_eq!(st, StatusCode::FORBIDDEN, "üye değiştiremez");
|
||||||
|
let (st, h) = app.patch("/household", &owner, update).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{h}");
|
||||||
|
assert_eq!(h["name"], "Çakır Ailesi");
|
||||||
|
assert_eq!(h["settings"]["budget_split_pct"]["needs"], 60);
|
||||||
|
assert_eq!(h["settings"]["budget_suggestion"], "avg3");
|
||||||
|
let (_, m) = me(&app, &member).await;
|
||||||
|
assert_eq!(
|
||||||
|
m["household"]["name"], "Çakır Ailesi",
|
||||||
|
"üye de yeni adı görür"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toplam 100 değil / geçersiz yöntem
|
||||||
|
for bad in [
|
||||||
|
json!({ "budget_split_pct": { "needs": 60, "wants": 30, "culture": 10, "unexpected": 10 } }),
|
||||||
|
json!({ "budget_suggestion": "hepsi" }),
|
||||||
|
json!({ "name": "" }),
|
||||||
|
] {
|
||||||
|
let (st, b) = app.patch("/household", &owner, bad).await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST, "{b}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Varsayılan öneri yeni yüzdeleri kullanır: harcanabilir 100.000 → 60/20/10/10
|
||||||
|
let (_, p) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
&owner,
|
||||||
|
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let pid = p["id"].as_str().unwrap();
|
||||||
|
app.post(
|
||||||
|
&format!("/periods/{pid}/plan-items"),
|
||||||
|
&owner,
|
||||||
|
json!({ "kind": "income", "name": "Maaş", "amount": 100_000 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, sug) = app
|
||||||
|
.get(
|
||||||
|
&format!("/periods/{pid}/budget/suggestion?method=default"),
|
||||||
|
&owner,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
sug["amounts"],
|
||||||
|
json!({ "needs": 60_000, "wants": 20_000, "culture": 10_000, "unexpected": 10_000 })
|
||||||
|
);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn owner_removes_member_who_can_be_invited_again() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let owner = app.register("Sahip", None).await;
|
||||||
|
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
|
||||||
|
let member = app
|
||||||
|
.register("Uye", Some(inv["code"].as_str().unwrap()))
|
||||||
|
.await;
|
||||||
|
let (_, mm) = me(&app, &member).await;
|
||||||
|
let member_id = mm["user"]["id"].as_str().unwrap().to_string();
|
||||||
|
let member_email = mm["user"]["email"].as_str().unwrap().to_string();
|
||||||
|
let (_, om) = me(&app, &owner).await;
|
||||||
|
let owner_id = om["user"]["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// Üye kimseyi çıkaramaz; sahip kendini çıkaramaz; başka hane bulunamaz
|
||||||
|
let (st, _) = app
|
||||||
|
.delete(&format!("/household/members/{owner_id}"), &member)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::FORBIDDEN);
|
||||||
|
let (st, _) = app
|
||||||
|
.delete(&format!("/household/members/{owner_id}"), &owner)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
let stranger = app.register("Yabanci", None).await;
|
||||||
|
let (st, _) = app
|
||||||
|
.delete(&format!("/household/members/{member_id}"), &stranger)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
// Çıkar → oturumu anında düşer, listede yok, giriş yapamaz
|
||||||
|
let (st, _) = app
|
||||||
|
.delete(&format!("/household/members/{member_id}"), &owner)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||||
|
assert_eq!(me(&app, &member).await.0, StatusCode::UNAUTHORIZED);
|
||||||
|
let (_, h) = app.get("/household", &owner).await;
|
||||||
|
assert_eq!(h["members"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
login(&app, &member_email, "test-sifre-123").await.0,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aynı e-posta yeni davetle yeniden katılabilir
|
||||||
|
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
|
||||||
|
let (st, b, _) = app
|
||||||
|
.send(
|
||||||
|
Method::POST,
|
||||||
|
"/auth/register",
|
||||||
|
None,
|
||||||
|
Some(json!({
|
||||||
|
"name": "Uye",
|
||||||
|
"email": member_email,
|
||||||
|
"password": "test-sifre-123",
|
||||||
|
"invite_code": inv["code"],
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
//! JSON tam hane yedeği: dışa aktarma, önizleme, aynı / başka haneye geri yükleme,
|
||||||
|
//! geri dönüş noktaları ("geri al"), bozuk dosya, yetki.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use common::{Session, TestApp};
|
||||||
|
use mongodb::bson::{Bson, Document, doc};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
async fn id_by(app: &TestApp, s: &Session, path: &str, name: &str) -> String {
|
||||||
|
let (_, list) = app.get(path, s).await;
|
||||||
|
list.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|c| c["name"] == name)
|
||||||
|
.unwrap_or_else(|| panic!("{name} yok"))["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id(v: &Value) -> String {
|
||||||
|
v["id"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ekim (kapalı, birikim TL'ye aktarılmış, devirli) + Kasım (açık, harcama, not, bütçe)
|
||||||
|
async fn build(app: &TestApp, s: &Session) -> (String, String) {
|
||||||
|
let market = id_by(app, s, "/categories", "Market").await;
|
||||||
|
let (_, oct) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
s,
|
||||||
|
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let oct = id(&oct);
|
||||||
|
app.post(
|
||||||
|
&format!("/periods/{oct}/plan-items"),
|
||||||
|
s,
|
||||||
|
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
for (date, amount) in [("2026-10-05", 200_000), ("2026-10-06", 1)] {
|
||||||
|
let (st, e) = app
|
||||||
|
.post(
|
||||||
|
&format!("/periods/{oct}/entries"),
|
||||||
|
s,
|
||||||
|
json!({ "date": date, "amount": amount, "category_id": market, "description": "Ekim" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||||
|
if amount == 1 {
|
||||||
|
app.delete(&format!("/entries/{}", id(&e)), s).await; // silinmiş kayıt da taşınır
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (st, b) = app
|
||||||
|
.post(
|
||||||
|
&format!("/periods/{oct}/close"),
|
||||||
|
s,
|
||||||
|
json!({ "to_savings": 500_000 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{b}");
|
||||||
|
let (st, b) = app
|
||||||
|
.post(&format!("/periods/{oct}/to-savings"), s, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||||
|
|
||||||
|
let (_, nov) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
s,
|
||||||
|
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let nov = id(&nov);
|
||||||
|
app.put(
|
||||||
|
&format!("/periods/{nov}/budget"),
|
||||||
|
s,
|
||||||
|
json!({ "needs": 100_000, "wants": 50_000, "culture": 0, "unexpected": 0 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
app.post(
|
||||||
|
&format!("/periods/{nov}/entries"),
|
||||||
|
s,
|
||||||
|
json!({ "date": "2026-11-03", "amount": 45_090, "category_id": market, "description": "Migros" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
app.post(
|
||||||
|
&format!("/periods/{nov}/notes"),
|
||||||
|
s,
|
||||||
|
json!({ "context": "planning", "title": "Hedef" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
(oct, nov)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download(app: &TestApp, s: &Session) -> String {
|
||||||
|
let (st, ct, body) = app.get_text("/backup/download", s).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{body}");
|
||||||
|
assert!(ct.starts_with("application/json"), "{ct}");
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data(file: &str) -> Value {
|
||||||
|
serde_json::from_str::<Value>(file).unwrap()["data"].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bir dönemin özet + harcama listesi (karşılaştırma için)
|
||||||
|
async fn view(app: &TestApp, s: &Session, pid: &str) -> (Value, Value) {
|
||||||
|
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), s).await;
|
||||||
|
let (_, list) = app.get(&format!("/periods/{pid}/entries"), s).await;
|
||||||
|
(sum, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn points(app: &TestApp, s: &Session) -> Vec<Value> {
|
||||||
|
let (_, st) = app.get("/backup/status", s).await;
|
||||||
|
st["restore_points"].as_array().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn same_household_restore_and_undo_chain() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Sahip", None).await;
|
||||||
|
let (oct, nov) = build(&app, &s).await;
|
||||||
|
let before = view(&app, &s, &nov).await;
|
||||||
|
|
||||||
|
let (_, st) = app.get("/backup/status", &s).await;
|
||||||
|
assert!(st["last_backup_at"].is_null(), "henüz yedek yok");
|
||||||
|
let file = download(&app, &s).await;
|
||||||
|
let v: Value = serde_json::from_str(&file).unwrap();
|
||||||
|
assert_eq!(v["format"], "kakeibo-backup");
|
||||||
|
assert_eq!(v["counts"]["entries"], 2, "silinmiş sayılmaz");
|
||||||
|
assert_eq!(
|
||||||
|
v["data"]["entries"].as_array().unwrap().len(),
|
||||||
|
3,
|
||||||
|
"ama dosyada var"
|
||||||
|
);
|
||||||
|
assert!(v["data"].get("users").is_none() && !file.contains("password"));
|
||||||
|
let (_, st) = app.get("/backup/status", &s).await;
|
||||||
|
assert!(st["last_backup_at"].is_string());
|
||||||
|
|
||||||
|
// Yedekten sonra değişiklikler
|
||||||
|
let market = id_by(&app, &s, "/categories", "Market").await;
|
||||||
|
app.post(
|
||||||
|
&format!("/periods/{nov}/entries"),
|
||||||
|
&s,
|
||||||
|
json!({ "date": "2026-11-10", "amount": 999, "category_id": market }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
app.patch(
|
||||||
|
&format!("/categories/{market}"),
|
||||||
|
&s,
|
||||||
|
json!({ "name": "Market (yeni)" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Önizleme: hiçbir şey yazmaz
|
||||||
|
let (st, p) = app.post_text("/backup/preview", &s, &file).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{p}");
|
||||||
|
assert_eq!(p["same_household"], true);
|
||||||
|
assert_eq!(
|
||||||
|
(p["first_date"].as_str(), p["last_date"].as_str()),
|
||||||
|
(Some("2026-10-01"), Some("2026-11-30"))
|
||||||
|
);
|
||||||
|
let entries = p["counts"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|c| c["kind"] == "entries")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(entries["backup"].as_i64(), entries["current"].as_i64()),
|
||||||
|
(Some(2), Some(3))
|
||||||
|
);
|
||||||
|
assert_eq!(p["changed_since"], 2, "{p}");
|
||||||
|
assert!(p["warnings"][0].as_str().unwrap().contains("2 kayıt"));
|
||||||
|
assert!(points(&app, &s).await.is_empty());
|
||||||
|
|
||||||
|
// Geri yükle → yedekteki durum birebir
|
||||||
|
let (st, r) = app.post_text("/backup/restore", &s, &file).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{r}");
|
||||||
|
assert_eq!(view(&app, &s, &nov).await, before);
|
||||||
|
assert_eq!(
|
||||||
|
data(&download(&app, &s).await),
|
||||||
|
data(&file),
|
||||||
|
"kayıtlar birebir"
|
||||||
|
);
|
||||||
|
let (_, oct_p) = app.get(&format!("/periods/{oct}"), &s).await;
|
||||||
|
assert_eq!(oct_p["status"], "closed");
|
||||||
|
// Tür korunur: version i32, tutar i64
|
||||||
|
let e: Document = app
|
||||||
|
.db
|
||||||
|
.collection::<Document>("entries")
|
||||||
|
.find_one(doc! { "amount": 45_090_i64 })
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(e.get("version"), Some(Bson::Int32(_))));
|
||||||
|
assert!(matches!(e.get("amount"), Some(Bson::Int64(_))));
|
||||||
|
|
||||||
|
let list = points(&app, &s).await;
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
assert_eq!(list[0]["reason"], "before_restore");
|
||||||
|
assert_eq!(list[0]["entries"], 3, "geri yüklemeden önceki durum");
|
||||||
|
assert_eq!(list[0]["id"], r["restore_point_id"]);
|
||||||
|
|
||||||
|
// Geri al → değişiklikler geri gelir; bu da bir nokta bırakır
|
||||||
|
let (st, u) = app
|
||||||
|
.post(
|
||||||
|
&format!(
|
||||||
|
"/backup/restore-points/{}/restore",
|
||||||
|
r["restore_point_id"].as_str().unwrap()
|
||||||
|
),
|
||||||
|
&s,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{u}");
|
||||||
|
let (_, list) = app.get(&format!("/periods/{nov}/entries"), &s).await;
|
||||||
|
assert_eq!(list.as_array().unwrap().len(), 2);
|
||||||
|
assert!(
|
||||||
|
app.get("/categories", &s)
|
||||||
|
.await
|
||||||
|
.1
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|c| c["name"] == "Market (yeni)")
|
||||||
|
);
|
||||||
|
let list = points(&app, &s).await;
|
||||||
|
assert_eq!(
|
||||||
|
list.iter().map(|p| p["reason"].clone()).collect::<Vec<_>>(),
|
||||||
|
vec![json!("before_undo"), json!("before_restore")]
|
||||||
|
);
|
||||||
|
|
||||||
|
// "Geri al"ı geri al → yine yedekteki durum
|
||||||
|
let (st, _) = app
|
||||||
|
.post(
|
||||||
|
&format!(
|
||||||
|
"/backup/restore-points/{}/restore",
|
||||||
|
u["restore_point_id"].as_str().unwrap()
|
||||||
|
),
|
||||||
|
&s,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK);
|
||||||
|
assert_eq!(view(&app, &s, &nov).await, before);
|
||||||
|
|
||||||
|
// En fazla 3 nokta saklanır
|
||||||
|
app.post_text("/backup/restore", &s, &file).await;
|
||||||
|
assert_eq!(points(&app, &s).await.len(), 3);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn copy_to_another_household_gets_new_ids() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let a = app.register("Kaynak", None).await;
|
||||||
|
let (oct, nov) = build(&app, &a).await;
|
||||||
|
let file = download(&app, &a).await;
|
||||||
|
let a_before = view(&app, &a, &nov).await;
|
||||||
|
|
||||||
|
let b = app.register("Hedef", None).await;
|
||||||
|
let (st, p) = app.post_text("/backup/preview", &b, &file).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{p}");
|
||||||
|
assert_eq!(p["same_household"], false);
|
||||||
|
assert_eq!(p["source_name"], "Kaynak Hanesi");
|
||||||
|
assert!(
|
||||||
|
p["warnings"][0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("başka bir haneden")
|
||||||
|
);
|
||||||
|
|
||||||
|
for round in 0..2 {
|
||||||
|
// İki kez yüklense de kimlik çakışmaz
|
||||||
|
let (st, r) = app.post_text("/backup/restore", &b, &file).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "tur {round}: {r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, periods) = app.get("/periods", &b).await;
|
||||||
|
let periods = periods.as_array().unwrap();
|
||||||
|
assert_eq!(periods.len(), 2);
|
||||||
|
assert!(
|
||||||
|
periods
|
||||||
|
.iter()
|
||||||
|
.all(|p| p["id"] != oct.as_str() && p["id"] != nov.as_str())
|
||||||
|
);
|
||||||
|
let b_nov = periods.iter().find(|p| p["status"] == "open").unwrap();
|
||||||
|
let b_nov_id = id(b_nov);
|
||||||
|
|
||||||
|
// Aynı rakamlar, yeni kimlikler; referanslar bu hanenin kayıtlarına
|
||||||
|
let (sum, list) = view(&app, &b, &b_nov_id).await;
|
||||||
|
assert_eq!(sum, a_before.0);
|
||||||
|
let entry = &list[0];
|
||||||
|
assert_eq!(entry["amount"], 45_090);
|
||||||
|
assert_ne!(entry["id"], a_before.1[0]["id"]);
|
||||||
|
let (_, cats) = app.get("/categories", &b).await;
|
||||||
|
assert!(
|
||||||
|
cats.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|c| c["id"] == entry["category_id"])
|
||||||
|
);
|
||||||
|
let (_, me) = app.get("/auth/me", &b).await;
|
||||||
|
assert_eq!(
|
||||||
|
entry["created_by"], me["user"]["id"],
|
||||||
|
"kaynak kişi → geri yükleyen"
|
||||||
|
);
|
||||||
|
let (_, pf) = app.get("/portfolio", &b).await;
|
||||||
|
assert!(
|
||||||
|
pf.to_string().contains("500000"),
|
||||||
|
"birikim aktarımı da taşındı: {pf}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kaynak hane değişmedi; hedef kaynağın kayıtlarını göremez
|
||||||
|
assert_eq!(view(&app, &a, &nov).await, a_before);
|
||||||
|
let (st, _) = app.get(&format!("/periods/{nov}"), &b).await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bad_files_and_permissions() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let owner = app.register("Sahip", None).await;
|
||||||
|
let (_, nov) = build(&app, &owner).await;
|
||||||
|
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
|
||||||
|
let member = app
|
||||||
|
.register("Uye", Some(inv["code"].as_str().unwrap()))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Üye yedek alabilir, geri yükleyemez
|
||||||
|
let file = download(&app, &member).await;
|
||||||
|
for path in ["/backup/preview", "/backup/restore"] {
|
||||||
|
let (st, _) = app.post_text(path, &member, &file).await;
|
||||||
|
assert_eq!(st, StatusCode::FORBIDDEN, "{path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bozuk / değiştirilmiş / yabancı dosya → anlaşılır hata, hiçbir şey yazılmaz
|
||||||
|
let before = view(&app, &owner, &nov).await;
|
||||||
|
let tampered = file.replacen("45090", "45091", 1);
|
||||||
|
let cases = [
|
||||||
|
(tampered.as_str(), "bozuk"),
|
||||||
|
(&file[..file.len() / 2], "Kakeibo yedeği değil"),
|
||||||
|
("Tarih;Tutar\n01.11.2026;5", "Kakeibo yedeği değil"),
|
||||||
|
];
|
||||||
|
for (body, msg) in cases {
|
||||||
|
let (st, b) = app.post_text("/backup/restore", &owner, body).await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST, "{b}");
|
||||||
|
assert!(b["error"]["message"].as_str().unwrap().contains(msg), "{b}");
|
||||||
|
}
|
||||||
|
let newer = file.replacen("\"schema_version\": ", "\"schema_version\": 99", 1);
|
||||||
|
let (st, b) = app.post_text("/backup/preview", &owner, &newer).await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
assert!(
|
||||||
|
b["error"]["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("daha yeni"),
|
||||||
|
"{b}"
|
||||||
|
);
|
||||||
|
assert_eq!(view(&app, &owner, &nov).await, before);
|
||||||
|
assert!(points(&app, &owner).await.is_empty());
|
||||||
|
|
||||||
|
// Geri dönüş noktası: üye göremez; başka hane kullanamaz
|
||||||
|
let (_, r) = app.post_text("/backup/restore", &owner, &file).await;
|
||||||
|
let pid = r["restore_point_id"].as_str().unwrap();
|
||||||
|
assert!(points(&app, &member).await.is_empty());
|
||||||
|
let (st, _) = app
|
||||||
|
.post(
|
||||||
|
&format!("/backup/restore-points/{pid}/restore"),
|
||||||
|
&member,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::FORBIDDEN);
|
||||||
|
let other = app.register("Diger", None).await;
|
||||||
|
let (st, _) = app
|
||||||
|
.post(
|
||||||
|
&format!("/backup/restore-points/{pid}/restore"),
|
||||||
|
&other,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
//! Ekstre içe aktarma: öneri (tür/kategori/kural), arşiv dönemi, iade, gelir, yoksay,
|
||||||
|
//! tekrar tespiti, kapalı/gelecek dönem engeli, arşiv kuralları.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use common::{Session, TestApp};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
async fn id_by(app: &TestApp, s: &Session, path: &str, name: &str) -> String {
|
||||||
|
let (_, list) = app.get(path, s).await;
|
||||||
|
list.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|c| c["name"] == name)
|
||||||
|
.unwrap()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row(date: &str, desc: &str, amount: i64, dir: &str) -> Value {
|
||||||
|
json!({ "date": date, "description": desc, "amount": amount, "direction": dir })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn preview(app: &TestApp, s: &Session, source: &str, rows: &[Value]) -> Value {
|
||||||
|
let (st, b) = app
|
||||||
|
.post(
|
||||||
|
"/bank-import/preview",
|
||||||
|
s,
|
||||||
|
json!({ "source": source, "rows": rows }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{b}");
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commit(app: &TestApp, s: &Session, pm: Option<&str>, rows: Vec<Value>) -> Value {
|
||||||
|
let (st, b) = app
|
||||||
|
.post(
|
||||||
|
"/bank-import/commit",
|
||||||
|
s,
|
||||||
|
json!({ "payment_method_id": pm, "rows": rows }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{b}");
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with(mut r: Value, kind: &str, cat: Option<&str>) -> Value {
|
||||||
|
r["kind"] = json!(kind);
|
||||||
|
if let Some(c) = cat {
|
||||||
|
r["category_id"] = json!(c);
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn card_statement_with_archive_refund_and_rules() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
let market = id_by(&app, &s, "/categories", "Market").await;
|
||||||
|
let kart = id_by(&app, &s, "/payment-methods", "Kredi Kartı").await;
|
||||||
|
// Ekim kapalı, Kasım açık
|
||||||
|
let (_, oct) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
&s,
|
||||||
|
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let oct = oct["id"].as_str().unwrap().to_string();
|
||||||
|
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
|
||||||
|
.await;
|
||||||
|
let (_, nov) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
&s,
|
||||||
|
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let nov = nov["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
let rows = vec![
|
||||||
|
row("2026-09-10", "MİGROS 1234 İSTANBUL TR", 45_000, "out"),
|
||||||
|
row("2026-09-12", "IADE MIGROS ISTANBUL", 10_000, "in"),
|
||||||
|
row("2026-09-30", "ÖDEMENİZ İÇİN TEŞEKKÜRLER", 500_000, "in"),
|
||||||
|
row("2026-11-05", "A101 MAGAZACILIK", 20_000, "out"),
|
||||||
|
row("2099-12-20", "GELECEK", 1_000, "out"),
|
||||||
|
row("2026-10-05", "EKIM", 1_000, "out"),
|
||||||
|
];
|
||||||
|
let p = preview(&app, &s, "card", &rows).await;
|
||||||
|
let kinds: Vec<&str> = p["rows"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r["kind"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
kinds,
|
||||||
|
[
|
||||||
|
"expense", "refund", "ignore", "expense", "expense", "expense"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(p["rows"][0]["period"], "archive");
|
||||||
|
assert_eq!(p["rows"][0]["period_name"], "Eylül 2026");
|
||||||
|
assert_eq!(p["rows"][3]["period"], "existing");
|
||||||
|
assert_eq!(p["rows"][3]["period_name"], "Kasım 2026");
|
||||||
|
assert_eq!(p["rows"][4]["message"], "Gelecek tarihli satır");
|
||||||
|
assert_eq!(
|
||||||
|
p["rows"][5]["message"],
|
||||||
|
"Dönem kapalı (Ekim 2026); düzeltme için yeniden açın"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
p["archives"],
|
||||||
|
json!([{ "name": "Eylül 2026", "start_date": "2026-09-01", "last_date": "2026-09-30" }])
|
||||||
|
);
|
||||||
|
assert_eq!(p["rows"][0]["from_rule"], false);
|
||||||
|
|
||||||
|
// Aktar: kullanıcı seçimleri
|
||||||
|
let chosen = vec![
|
||||||
|
with(rows[0].clone(), "expense", Some(&market)),
|
||||||
|
with(rows[1].clone(), "refund", Some(&market)),
|
||||||
|
with(rows[2].clone(), "ignore", None),
|
||||||
|
with(rows[3].clone(), "expense", Some(&market)),
|
||||||
|
with(rows[4].clone(), "expense", Some(&market)),
|
||||||
|
with(rows[5].clone(), "expense", Some(&market)),
|
||||||
|
];
|
||||||
|
let r = commit(&app, &s, Some(&kart), chosen.clone()).await;
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
r["entries"].as_u64(),
|
||||||
|
r["refunds"].as_u64(),
|
||||||
|
r["ignored"].as_u64()
|
||||||
|
),
|
||||||
|
(Some(2), Some(1), Some(1))
|
||||||
|
);
|
||||||
|
assert_eq!(r["archives"], json!(["Eylül 2026"]));
|
||||||
|
assert_eq!(
|
||||||
|
r["errors"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|e| e["index"].as_u64().unwrap())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[4, 5]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Arşiv dönemi: Eylül, net harcama 450 − 100 = 350
|
||||||
|
let (_, periods) = app.get("/periods", &s).await;
|
||||||
|
let sep = periods
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|p| p["name"] == "Eylül 2026")
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
assert_eq!(sep["status"], "archived");
|
||||||
|
let sep_id = sep["id"].as_str().unwrap();
|
||||||
|
let (_, sum) = app.get(&format!("/periods/{sep_id}/summary"), &s).await;
|
||||||
|
assert_eq!(sum["spent_total"], 35_000, "iade harcamadan düşer");
|
||||||
|
let (_, entries) = app.get(&format!("/periods/{sep_id}/entries"), &s).await;
|
||||||
|
let refund = entries
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|e| e["amount"] == -10_000)
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
assert_eq!(refund["payment_method_id"], kart.as_str());
|
||||||
|
assert_eq!(refund["description"], "IADE MIGROS ISTANBUL");
|
||||||
|
let (_, nov_entries) = app.get(&format!("/periods/{nov}/entries"), &s).await;
|
||||||
|
assert_eq!(nov_entries.as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
// Aynı ekstre tekrar: öğrenilen kural + tekrar işareti; aktarma çift kayıt yapmaz
|
||||||
|
let p = preview(&app, &s, "card", &rows).await;
|
||||||
|
assert_eq!(p["rows"][0]["from_rule"], true);
|
||||||
|
assert_eq!(p["rows"][0]["category_id"], market.as_str());
|
||||||
|
assert_eq!(p["rows"][1]["kind"], "refund");
|
||||||
|
assert_eq!(p["rows"][2]["kind"], "ignore", "yoksay kuralı da öğrenildi");
|
||||||
|
assert_eq!(
|
||||||
|
p["rows"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r["duplicate"].as_bool().unwrap())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[true, true, false, true, false, false]
|
||||||
|
);
|
||||||
|
assert_eq!(p["archives"], json!([]), "Eylül artık var");
|
||||||
|
let r = commit(&app, &s, Some(&kart), chosen).await;
|
||||||
|
assert_eq!(
|
||||||
|
(r["entries"].as_u64(), r["duplicates"].as_u64()),
|
||||||
|
(Some(0), Some(3))
|
||||||
|
);
|
||||||
|
|
||||||
|
// İade düzenlemede eksi kalır
|
||||||
|
let rid = refund["id"].as_str().unwrap();
|
||||||
|
let (st, _) = app
|
||||||
|
.patch(&format!("/entries/{rid}"), &s, json!({ "amount": 5_000 }))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
let (st, e) = app
|
||||||
|
.patch(&format!("/entries/{rid}"), &s, json!({ "amount": -5_000 }))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{e}");
|
||||||
|
|
||||||
|
// Arşiv kapatılmaz; öneriler arşivi kullanır (Ekim 0 + Eylül 400 net) / 2 dönem
|
||||||
|
let (st, b) = app
|
||||||
|
.post(&format!("/periods/{sep_id}/close"), &s, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CONFLICT, "{b}");
|
||||||
|
let (_, sug) = app
|
||||||
|
.get(&format!("/periods/{nov}/budget/suggestion?method=avg3"), &s)
|
||||||
|
.await;
|
||||||
|
assert_eq!(sug["available"], true, "{sug}");
|
||||||
|
assert_eq!(sug["amounts"]["needs"], 20_000, "(450 − 50) / 2");
|
||||||
|
|
||||||
|
// Başka hane kuralları görmez
|
||||||
|
let other = app.register("Diger", None).await;
|
||||||
|
let p = preview(&app, &other, "card", &rows[..1]).await;
|
||||||
|
assert_eq!(p["rows"][0]["from_rule"], false);
|
||||||
|
assert_eq!(p["rows"][0]["duplicate"], false);
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn account_statement_income_and_transfers() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
let fatura = id_by(&app, &s, "/categories", "Faturalar").await;
|
||||||
|
let rows = vec![
|
||||||
|
row("2026-08-01", "MAAŞ ÖDEMESİ ACME A.Ş.", 4_500_000, "in"),
|
||||||
|
row("2026-08-05", "KREDİ KARTI ÖDEMESİ", 1_200_000, "out"),
|
||||||
|
row("2026-08-07", "ELEKTRIK FATURASI", 85_000, "out"),
|
||||||
|
row("2026-08-31", "HESAPLAR ARASI VİRMAN", 100_000, "out"),
|
||||||
|
];
|
||||||
|
let p = preview(&app, &s, "account", &rows).await;
|
||||||
|
let kinds: Vec<&str> = p["rows"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r["kind"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(kinds, ["income", "ignore", "expense", "ignore"]);
|
||||||
|
|
||||||
|
let r = commit(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
None,
|
||||||
|
vec![
|
||||||
|
with(rows[0].clone(), "income", None),
|
||||||
|
with(rows[1].clone(), "ignore", None),
|
||||||
|
with(rows[2].clone(), "expense", Some(&fatura)),
|
||||||
|
with(rows[3].clone(), "ignore", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
r["incomes"].as_u64(),
|
||||||
|
r["entries"].as_u64(),
|
||||||
|
r["ignored"].as_u64()
|
||||||
|
),
|
||||||
|
(Some(1), Some(1), Some(2))
|
||||||
|
);
|
||||||
|
let (_, periods) = app.get("/periods", &s).await;
|
||||||
|
let aug = periods
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|p| p["name"] == "Ağustos 2026")
|
||||||
|
.unwrap()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
let (_, sum) = app.get(&format!("/periods/{aug}/summary"), &s).await;
|
||||||
|
assert_eq!(sum["income_total"], 4_500_000);
|
||||||
|
assert_eq!(sum["spent_total"], 85_000);
|
||||||
|
let (_, items) = app.get(&format!("/periods/{aug}/plan-items"), &s).await;
|
||||||
|
assert_eq!(items[0]["name"], "MAAŞ ÖDEMESİ ACME A.Ş.");
|
||||||
|
assert_eq!(items[0]["date"], "2026-08-01");
|
||||||
|
// Gelir de tekrar etmez
|
||||||
|
let p = preview(&app, &s, "account", &rows[..1]).await;
|
||||||
|
assert_eq!(p["rows"][0]["duplicate"], true);
|
||||||
|
|
||||||
|
// Kategori seçilmemiş harcama → satır hatası; boş istek → 400
|
||||||
|
let r = commit(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
None,
|
||||||
|
vec![with(row("2026-08-09", "SU", 1_000, "out"), "expense", None)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r["errors"][0]["message"], "Kategori seçin");
|
||||||
|
let (st, _) = app
|
||||||
|
.post(
|
||||||
|
"/bank-import/preview",
|
||||||
|
&s,
|
||||||
|
json!({ "source": "card", "rows": [] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn archives_are_skipped_in_the_carryover_chain() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
let market = id_by(&app, &s, "/categories", "Market").await;
|
||||||
|
// Ekim: gelir 10.000, 5.000 birikime → 5.000 artı devreder (bağlanacak dönem yok)
|
||||||
|
let (_, oct) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
&s,
|
||||||
|
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let oct = oct["id"].as_str().unwrap().to_string();
|
||||||
|
app.post(
|
||||||
|
&format!("/periods/{oct}/plan-items"),
|
||||||
|
&s,
|
||||||
|
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
app.post(
|
||||||
|
&format!("/periods/{oct}/close"),
|
||||||
|
&s,
|
||||||
|
json!({ "to_savings": 500_000 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Kasım ekstresi → Kasım arşiv dönemi
|
||||||
|
let r = commit(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
None,
|
||||||
|
vec![with(
|
||||||
|
row("2026-11-03", "MIGROS", 10_000, "out"),
|
||||||
|
"expense",
|
||||||
|
Some(&market),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r["archives"], json!(["Kasım 2026"]));
|
||||||
|
|
||||||
|
// Aralık açılır: önceki (devir) dönemi arşiv değil Ekim → artı Aralık'a bağlanır
|
||||||
|
let (st, dec) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
&s,
|
||||||
|
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{dec}");
|
||||||
|
let dec = dec["id"].as_str().unwrap().to_string();
|
||||||
|
let (_, sum) = app.get(&format!("/periods/{dec}/summary"), &s).await;
|
||||||
|
assert_eq!(sum["carryover_surplus"], 500_000);
|
||||||
|
|
||||||
|
// Ekim yeniden açılıp kapatılırsa yeni devir yine Aralık'a (arşiv Kasım değil)
|
||||||
|
app.post(&format!("/periods/{oct}/reopen"), &s, json!({}))
|
||||||
|
.await;
|
||||||
|
let (st, b) = app
|
||||||
|
.post(
|
||||||
|
&format!("/periods/{oct}/close"),
|
||||||
|
&s,
|
||||||
|
json!({ "to_savings": 400_000 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{b}");
|
||||||
|
assert_eq!(b["carryover"]["to_period_id"], dec.as_str());
|
||||||
|
let (_, sum) = app.get(&format!("/periods/{dec}/summary"), &s).await;
|
||||||
|
assert_eq!(sum["carryover_surplus"], 600_000);
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
//! 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; 4]);
|
||||||
|
|
||||||
|
let (st, b) = app
|
||||||
|
.put(
|
||||||
|
&url,
|
||||||
|
&s,
|
||||||
|
json!({ "needs": 100_000, "wants": 50_000, "culture": 31, "unexpected": 0 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{b}");
|
||||||
|
let needs = group(&b, "needs");
|
||||||
|
assert_eq!(needs["monthly"], 100_000);
|
||||||
|
// Aralık: 1–8, 9–15, 16–22, 23–31 → 8,7,7,9 gün; toplam tam 100.000
|
||||||
|
assert_eq!(week_amounts(needs), vec![25_806, 22_581, 22_581, 29_032]);
|
||||||
|
assert_eq!(week_amounts(group(&b, "culture")), vec![8, 7, 7, 9]);
|
||||||
|
for g in b.as_array().unwrap() {
|
||||||
|
assert_eq!(
|
||||||
|
week_amounts(g).iter().sum::<i64>(),
|
||||||
|
g["monthly"].as_i64().unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Özet: 150.031 dağıtıldı, harcanabilir 300.000 → kalan 149.969
|
||||||
|
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||||
|
assert_eq!(
|
||||||
|
(sum["budget_total"].as_i64(), sum["unallocated"].as_i64()),
|
||||||
|
(Some(150_031), Some(149_969))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fazla dağıtım → kalan negatif (arayüz uyarı gösterir)
|
||||||
|
app.put(
|
||||||
|
&url,
|
||||||
|
&s,
|
||||||
|
json!({ "needs": 400_000, "wants": 0, "culture": 0, "unexpected": 0 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||||
|
assert_eq!(sum["unallocated"], -100_000);
|
||||||
|
|
||||||
|
// Negatif tutar → 400
|
||||||
|
let (st, _) = app
|
||||||
|
.put(
|
||||||
|
&url,
|
||||||
|
&s,
|
||||||
|
json!({ "needs": -1, "wants": 0, "culture": 0, "unexpected": 0 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn manual_week_override_redistributes_the_rest() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
let pid = open_december(&app, &s, 0).await;
|
||||||
|
app.put(
|
||||||
|
&format!("/periods/{pid}/budget"),
|
||||||
|
&s,
|
||||||
|
json!({ "needs": 100_000, "wants": 0, "culture": 0, "unexpected": 0 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let weeks_url = format!("/periods/{pid}/budget/weeks");
|
||||||
|
|
||||||
|
// 2. hafta (9–15 Ara) elle 50.000 → kalan 50.000 diğer haftalara (8+7+9 = 24 gün)
|
||||||
|
let (st, b) = app
|
||||||
|
.put(
|
||||||
|
&weeks_url,
|
||||||
|
&s,
|
||||||
|
json!({ "group": "needs", "week_start": "2026-12-09", "amount": 50_000 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{b}");
|
||||||
|
let needs = group(&b, "needs");
|
||||||
|
assert_eq!(week_amounts(needs), vec![16_667, 50_000, 14_583, 18_750]);
|
||||||
|
assert_eq!(week_amounts(needs).iter().sum::<i64>(), 100_000);
|
||||||
|
let manual: Vec<bool> = needs["weeks"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|w| w["is_manual"].as_bool().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(manual, vec![false, true, false, false]);
|
||||||
|
assert_eq!(needs["weekly_over"], false);
|
||||||
|
|
||||||
|
// Aylık tutar değişince elle girilen hafta korunur
|
||||||
|
let (_, b) = app
|
||||||
|
.put(
|
||||||
|
&format!("/periods/{pid}/budget"),
|
||||||
|
&s,
|
||||||
|
json!({ "needs": 74_000, "wants": 0, "culture": 0, "unexpected": 0 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(week_amounts(group(&b, "needs"))[1], 50_000);
|
||||||
|
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
|
||||||
|
|
||||||
|
// Elle girilenler aylığı aşarsa uyarı; diğer haftalar 0
|
||||||
|
let (_, b) = app
|
||||||
|
.put(
|
||||||
|
&weeks_url,
|
||||||
|
&s,
|
||||||
|
json!({ "group": "needs", "week_start": "2026-12-16", "amount": 30_000 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let needs = group(&b, "needs");
|
||||||
|
assert_eq!(needs["weekly_over"], true);
|
||||||
|
assert_eq!(week_amounts(needs), vec![0, 50_000, 30_000, 0]);
|
||||||
|
|
||||||
|
// null → otomatiğe dön
|
||||||
|
app.put(
|
||||||
|
&weeks_url,
|
||||||
|
&s,
|
||||||
|
json!({ "group": "needs", "week_start": "2026-12-16", "amount": null }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, b) = app
|
||||||
|
.put(
|
||||||
|
&weeks_url,
|
||||||
|
&s,
|
||||||
|
json!({ "group": "needs", "week_start": "2026-12-09", "amount": null }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
|
||||||
|
assert!(
|
||||||
|
group(&b, "needs")["weeks"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|w| w["is_manual"] == false)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Haftanın ilk günü olmayan tarih → 400
|
||||||
|
let (st, _) = app
|
||||||
|
.put(
|
||||||
|
&weeks_url,
|
||||||
|
&s,
|
||||||
|
json!({ "group": "needs", "week_start": "2026-12-08", "amount": 1 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn previous_and_avg3_suggestions_use_actual_spending() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
let hid = Uuid::parse_str(&s.household_id).unwrap();
|
||||||
|
|
||||||
|
// Ekim ve Kasım kapalı, harcamaları var (Faz 5'te defterden gelecek)
|
||||||
|
let mut closed = Vec::new();
|
||||||
|
for (start, end) in [("2026-10-01", "2026-11-01"), ("2026-11-01", "2026-12-01")] {
|
||||||
|
let (_, p) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
&s,
|
||||||
|
json!({ "start_date": start, "end_date": end }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let id = Uuid::parse_str(p["id"].as_str().unwrap()).unwrap();
|
||||||
|
app.db
|
||||||
|
.collection::<Document>("periods")
|
||||||
|
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
closed.push(id);
|
||||||
|
}
|
||||||
|
let entry = |pid: Uuid, group: &str, amount: i64| {
|
||||||
|
doc! { "household_id": hid, "period_id": pid, "group": group, "amount": amount, "date": "2026-10-10", "deleted_at": null }
|
||||||
|
};
|
||||||
|
app.db
|
||||||
|
.collection::<Document>("entries")
|
||||||
|
.insert_many([
|
||||||
|
entry(closed[0], "needs", 100_000),
|
||||||
|
entry(closed[0], "wants", 41_000),
|
||||||
|
entry(closed[1], "needs", 200_000),
|
||||||
|
entry(closed[1], "needs", 50_000),
|
||||||
|
entry(closed[1], "culture", 10_000),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pid = open_december(&app, &s, 0).await;
|
||||||
|
let (_, prev) = app
|
||||||
|
.get(
|
||||||
|
&format!("/periods/{pid}/budget/suggestion?method=previous"),
|
||||||
|
&s,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(prev["available"], true, "{prev}");
|
||||||
|
assert_eq!(
|
||||||
|
prev["amounts"],
|
||||||
|
json!({ "needs": 250_000, "wants": 0, "culture": 10_000, "unexpected": 0 })
|
||||||
|
);
|
||||||
|
|
||||||
|
// avg3: 2 kapalı dönem var → ikiye bölünür (yuvarlayarak)
|
||||||
|
let (_, avg) = app
|
||||||
|
.get(&format!("/periods/{pid}/budget/suggestion?method=avg3"), &s)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
avg["amounts"],
|
||||||
|
json!({ "needs": 175_000, "wants": 20_500, "culture": 5_000, "unexpected": 0 })
|
||||||
|
);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn budget_closed_period_and_isolation() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let a = app.register("A", None).await;
|
||||||
|
let b = app.register("B", None).await;
|
||||||
|
let pid = open_december(&app, &a, 0).await;
|
||||||
|
let body = json!({ "needs": 1, "wants": 0, "culture": 0, "unexpected": 0 });
|
||||||
|
|
||||||
|
// B erişemez
|
||||||
|
let checks = [
|
||||||
|
app.get(&format!("/periods/{pid}/summary"), &b).await.0,
|
||||||
|
app.get(&format!("/periods/{pid}/budget"), &b).await.0,
|
||||||
|
app.put(&format!("/periods/{pid}/budget"), &b, body.clone())
|
||||||
|
.await
|
||||||
|
.0,
|
||||||
|
app.put(
|
||||||
|
&format!("/periods/{pid}/budget/weeks"),
|
||||||
|
&b,
|
||||||
|
json!({ "group": "needs", "week_start": "2026-12-09", "amount": 1 }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.0,
|
||||||
|
app.get(&format!("/periods/{pid}/budget/suggestion"), &b)
|
||||||
|
.await
|
||||||
|
.0,
|
||||||
|
];
|
||||||
|
assert!(
|
||||||
|
checks.iter().all(|s| *s == StatusCode::NOT_FOUND),
|
||||||
|
"{checks:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kapalı dönem: okuma serbest, yazma 409
|
||||||
|
let id = Uuid::parse_str(&pid).unwrap();
|
||||||
|
app.db
|
||||||
|
.collection::<Document>("periods")
|
||||||
|
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
app.get(&format!("/periods/{pid}/budget"), &a).await.0,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.put(&format!("/periods/{pid}/budget"), &a, body).await.0,
|
||||||
|
StatusCode::CONFLICT
|
||||||
|
);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
//! 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,
|
||||||
|
turnstile_site_key: None,
|
||||||
|
turnstile_secret_key: None,
|
||||||
|
trust_proxy: true,
|
||||||
|
};
|
||||||
|
let router = routes::router(AppState::new(db.clone(), config));
|
||||||
|
Self { router, db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bot doğrulaması açık (ağsız sabit sonuç) bir uygulama: giriş/kayıt güvenlik testleri
|
||||||
|
pub async fn with_captcha(pass: bool) -> Self {
|
||||||
|
let app = Self::new().await;
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let config = Config {
|
||||||
|
mongodb_uri: std::env::var("MONGODB_URI").unwrap(),
|
||||||
|
jwt_secret: "test-jwt-secret-en-az-32-karakter-olmali!!".into(),
|
||||||
|
app_url: "http://localhost".into(),
|
||||||
|
port: 0,
|
||||||
|
registration: RegistrationMode::Open,
|
||||||
|
static_dir: None,
|
||||||
|
rates_refresh_minutes: 0,
|
||||||
|
turnstile_site_key: Some("test-site-key".into()),
|
||||||
|
turnstile_secret_key: Some("test-secret".into()),
|
||||||
|
trust_proxy: true,
|
||||||
|
};
|
||||||
|
let state = AppState::new(app.db.clone(), config)
|
||||||
|
.with_captcha(kakeibo::auth::captcha::Captcha::Fixed(pass));
|
||||||
|
Self {
|
||||||
|
router: routes::router(state),
|
||||||
|
db: app.db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router_clone(&self) -> Router {
|
||||||
|
self.router.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ham istek (başlık eklemek için): (durum, gövde, çerez)
|
||||||
|
pub async fn send_with(
|
||||||
|
&self,
|
||||||
|
req: axum::http::Request<Body>,
|
||||||
|
) -> (StatusCode, Value, Option<String>) {
|
||||||
|
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||||
|
let status = res.status();
|
||||||
|
let set_cookie = res
|
||||||
|
.headers()
|
||||||
|
.get(header::SET_COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.split(';').next())
|
||||||
|
.map(String::from);
|
||||||
|
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||||
|
set_cookie,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test sonunda geçici veritabanını siler.
|
||||||
|
pub async fn cleanup(self) {
|
||||||
|
self.db.drop().await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// İstek gönderir; (durum kodu, JSON gövde, set-cookie) döner.
|
||||||
|
pub async fn send(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
cookie: Option<&str>,
|
||||||
|
body: Option<Value>,
|
||||||
|
) -> (StatusCode, Value, Option<String>) {
|
||||||
|
let mut req = Request::builder().method(method).uri(format!("/api{path}"));
|
||||||
|
if let Some(c) = cookie {
|
||||||
|
req = req.header(header::COOKIE, c);
|
||||||
|
}
|
||||||
|
let req = match body {
|
||||||
|
Some(b) => req
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(b.to_string())),
|
||||||
|
None => req.body(Body::empty()),
|
||||||
|
}
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||||
|
let status = res.status();
|
||||||
|
let set_cookie = res
|
||||||
|
.headers()
|
||||||
|
.get(header::SET_COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.split(';').next())
|
||||||
|
.map(String::from);
|
||||||
|
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let json = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||||
|
(status, json, set_cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Düz metin gövde gönderir (CSV içe aktarma)
|
||||||
|
pub async fn post_text(&self, path: &str, s: &Session, text: &str) -> (StatusCode, Value) {
|
||||||
|
let req = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri(format!("/api{path}"))
|
||||||
|
.header(header::COOKIE, &s.cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "text/csv; charset=utf-8")
|
||||||
|
.body(Body::from(text.to_string()))
|
||||||
|
.unwrap();
|
||||||
|
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||||
|
let status = res.status();
|
||||||
|
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metin yanıt (CSV dışa aktarma): (durum, içerik türü, gövde)
|
||||||
|
pub async fn get_text(&self, path: &str, s: &Session) -> (StatusCode, String, String) {
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri(format!("/api{path}"))
|
||||||
|
.header(header::COOKIE, &s.cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||||
|
let status = res.status();
|
||||||
|
let ct = res
|
||||||
|
.headers()
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
(status, ct, String::from_utf8(bytes.to_vec()).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, path: &str, s: &Session) -> (StatusCode, Value) {
|
||||||
|
let (st, v, _) = self.send(Method::GET, path, Some(&s.cookie), None).await;
|
||||||
|
(st, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) {
|
||||||
|
let (st, v, _) = self
|
||||||
|
.send(Method::POST, path, Some(&s.cookie), Some(body))
|
||||||
|
.await;
|
||||||
|
(st, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn patch(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) {
|
||||||
|
let (st, v, _) = self
|
||||||
|
.send(Method::PATCH, path, Some(&s.cookie), Some(body))
|
||||||
|
.await;
|
||||||
|
(st, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) {
|
||||||
|
let (st, v, _) = self
|
||||||
|
.send(Method::PUT, path, Some(&s.cookie), Some(body))
|
||||||
|
.await;
|
||||||
|
(st, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, path: &str, s: &Session) -> (StatusCode, Value) {
|
||||||
|
let (st, v, _) = self.send(Method::DELETE, path, Some(&s.cookie), None).await;
|
||||||
|
(st, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yeni kullanıcı kaydeder (davet koduyla veya yeni hane olarak) ve oturumunu döner.
|
||||||
|
pub async fn register(&self, name: &str, invite_code: Option<&str>) -> Session {
|
||||||
|
let email = format!(
|
||||||
|
"{}-{}@test.com",
|
||||||
|
name.to_lowercase(),
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
);
|
||||||
|
let (status, body, cookie) = self
|
||||||
|
.send(
|
||||||
|
Method::POST,
|
||||||
|
"/auth/register",
|
||||||
|
None,
|
||||||
|
Some(json!({
|
||||||
|
"name": name,
|
||||||
|
"email": email,
|
||||||
|
"password": "test-sifre-123",
|
||||||
|
"invite_code": invite_code,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::CREATED, "kayıt başarısız: {body}");
|
||||||
|
Session {
|
||||||
|
cookie: cookie.expect("kayıtta cookie dönmedi"),
|
||||||
|
household_id: body["household"]["id"].as_str().unwrap().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
//! Borç takibi (docs/BORC_TAKIBI.md): kaynak belgedeki JSON'un içe aktarılması (kabul rakamları),
|
||||||
|
//! dönemin ay başı / devreden borcu, elle düzeltme, ölçüm ve taksit işlemleri, güvenlik, yalıtım.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use common::{Session, TestApp};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// Kaynak belgedeki başlangıç verisi (+ `asOf`: paidCount'ların ait olduğu ay)
|
||||||
|
fn source_json() -> Value {
|
||||||
|
json!({
|
||||||
|
"asOf": "2026-11",
|
||||||
|
"debtSnapshots": [
|
||||||
|
{ "date": "2026-10-31", "kmh": 0, "positiveBalance": 2363.11, "cardBalance": 66715.90, "remainingInstallments": 14177.69, "total": 78530.48, "note": "Kasım başı" },
|
||||||
|
{ "date": "2026-11-13", "kmh": 39264.44, "positiveBalance": 0, "cardBalance": 29948.54, "remainingInstallments": 9865.87, "total": 79078.85, "note": "ara ölçüm" }
|
||||||
|
],
|
||||||
|
"installmentPlans": [
|
||||||
|
{ "merchant": "Koçtaş/Chippin", "totalAmount": 19023.29, "monthlyAmount": 2717.61, "totalCount": 7, "paidCount": 5 },
|
||||||
|
{ "merchant": "Hepsiburada", "totalAmount": 2999.15, "monthlyAmount": 333.24, "totalCount": 9, "paidCount": 5 },
|
||||||
|
{ "merchant": "Koçtaş/Chippin", "totalAmount": 836.14, "monthlyAmount": 119.45, "totalCount": 7, "paidCount": 5 },
|
||||||
|
{ "merchant": "Koçtaş/Chippin", "totalAmount": 890.19, "monthlyAmount": 127.17, "totalCount": 7, "paidCount": 4 },
|
||||||
|
{ "merchant": "Koçtaş Fix", "totalAmount": 1194.70, "monthlyAmount": 170.67, "totalCount": 7, "paidCount": 3 },
|
||||||
|
{ "merchant": "Koçtaş/Chippin", "totalAmount": 519.94, "monthlyAmount": 74.28, "totalCount": 7, "paidCount": 3 },
|
||||||
|
{ "merchant": "Koçtaş/Chippin", "totalAmount": 1274.13, "monthlyAmount": 182.02, "totalCount": 7, "paidCount": 2 },
|
||||||
|
{ "merchant": "Trendyol", "totalAmount": 1762.15, "monthlyAmount": 587.38, "totalCount": 3, "paidCount": 2 }
|
||||||
|
],
|
||||||
|
"roadmap": [
|
||||||
|
{ "month": "2026-11", "closingDebtTarget": 66995 },
|
||||||
|
{ "month": "2026-12", "closingDebtTarget": 49890 },
|
||||||
|
{ "month": "2027-01", "closingDebtTarget": 33286 },
|
||||||
|
{ "month": "2027-02", "closingDebtTarget": 15681 },
|
||||||
|
{ "month": "2027-03", "closingDebtTarget": 0 }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn statuses(r: &Value) -> Vec<String> {
|
||||||
|
r["rows"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|x| x["status"].as_str().unwrap().to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn import(app: &TestApp, s: &Session, commit: bool) -> Value {
|
||||||
|
let (st, r) = app
|
||||||
|
.post(&format!("/debt/import?commit={commit}"), s, source_json())
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{r}");
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn import_source_document_and_acceptance_numbers() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Sahip", None).await;
|
||||||
|
|
||||||
|
// Ön izleme: hiçbir şey yazılmaz, toplamlar belgedekiyle aynı
|
||||||
|
let r = import(&app, &s, false).await;
|
||||||
|
assert_eq!(r["errors"], json!([]));
|
||||||
|
assert_eq!(statuses(&r), vec!["new"; 15]);
|
||||||
|
let rows = r["rows"].as_array().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(rows[0]["amount"].as_i64(), rows[1]["amount"].as_i64()),
|
||||||
|
(Some(7_853_048), Some(7_907_885))
|
||||||
|
);
|
||||||
|
assert_eq!(rows[2]["label"], "Koçtaş/Chippin · 7 taksit · ilk 2026-07");
|
||||||
|
let (_, d) = app.get("/debt", &s).await;
|
||||||
|
assert_eq!(d["snapshots"], json!([]), "ön izleme yazmaz");
|
||||||
|
|
||||||
|
// Aktar
|
||||||
|
let r = import(&app, &s, true).await;
|
||||||
|
assert_eq!(r["committed"], true);
|
||||||
|
let (_, d) = app.get("/debt", &s).await;
|
||||||
|
assert_eq!(d["latest"]["total"], 7_907_885);
|
||||||
|
assert_eq!(d["latest"]["change"], 7_907_885 - 7_853_048);
|
||||||
|
assert_eq!(d["change_vs_last_month"], 54_837);
|
||||||
|
assert_eq!(d["start"]["total"], 7_853_048);
|
||||||
|
assert_eq!(d["estimated_end"], "2027-03");
|
||||||
|
assert_eq!(d["installments"].as_array().unwrap().len(), 8);
|
||||||
|
assert_eq!(d["targets"].as_array().unwrap().len(), 5);
|
||||||
|
assert_eq!(d["schedule"].as_array().unwrap().len(), 6);
|
||||||
|
assert_eq!(d["snapshots"][0]["date"], "2026-11-13", "en yeni önce");
|
||||||
|
|
||||||
|
// Tekrar aktarınca çift kayıt yok
|
||||||
|
let r = import(&app, &s, true).await;
|
||||||
|
assert!(statuses(&r).iter().all(|x| x == "exists"), "{r}");
|
||||||
|
let (_, d) = app.get("/debt", &s).await;
|
||||||
|
assert_eq!(d["snapshots"].as_array().unwrap().len(), 2);
|
||||||
|
|
||||||
|
// Kasım planı: devreden borç 64.352,79; taksit yükü 4.311,82
|
||||||
|
let (_, p) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
&s,
|
||||||
|
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01", "savings_goal": 700_000 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let pid = p["id"].as_str().unwrap();
|
||||||
|
let (st, pd) = app.get(&format!("/periods/{pid}/debt"), &s).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{pd}");
|
||||||
|
assert_eq!(pd["opening_total"], 7_853_048);
|
||||||
|
assert_eq!(pd["opening_source"], "snapshot");
|
||||||
|
assert_eq!(pd["carried"], 6_435_279);
|
||||||
|
assert_eq!(pd["installment_load"], 431_182);
|
||||||
|
assert_eq!(pd["payment"], 700_000);
|
||||||
|
assert_eq!(pd["closing_target"], 7_853_048 - 700_000 - 431_182);
|
||||||
|
assert_eq!(pd["roadmap_target"], 6_699_500);
|
||||||
|
assert_eq!(
|
||||||
|
(pd["latest_date"].as_str(), pd["latest_total"].as_i64()),
|
||||||
|
(Some("2026-11-13"), Some(7_907_885))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Elle ay başı → manual; null → yine ölçümden
|
||||||
|
let (st, _) = app
|
||||||
|
.put(
|
||||||
|
&format!("/periods/{pid}/opening-debt"),
|
||||||
|
&s,
|
||||||
|
json!({ "amount": 8_000_000 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||||
|
let (_, pd) = app.get(&format!("/periods/{pid}/debt"), &s).await;
|
||||||
|
assert_eq!(
|
||||||
|
(pd["opening_source"].as_str(), pd["carried"].as_i64()),
|
||||||
|
(Some("manual"), Some(8_000_000 - 1_417_769))
|
||||||
|
);
|
||||||
|
app.put(
|
||||||
|
&format!("/periods/{pid}/opening-debt"),
|
||||||
|
&s,
|
||||||
|
json!({ "amount": null }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, pd) = app.get(&format!("/periods/{pid}/debt"), &s).await;
|
||||||
|
assert_eq!(pd["opening_source"], "snapshot");
|
||||||
|
|
||||||
|
// JSON yedeği borç verisini de taşır
|
||||||
|
let (_, _, file) = app.get_text("/backup/download", &s).await;
|
||||||
|
let v: Value = serde_json::from_str(&file).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
v["counts"]["debt_snapshots"].as_i64(),
|
||||||
|
v["counts"]["installment_plans"].as_i64(),
|
||||||
|
v["counts"]["debt_targets"].as_i64()
|
||||||
|
),
|
||||||
|
(Some(2), Some(8), Some(5))
|
||||||
|
);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn import_rejects_inconsistent_file() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Sahip", None).await;
|
||||||
|
let mut bad = source_json();
|
||||||
|
bad["debtSnapshots"][0]["total"] = json!(78530.49);
|
||||||
|
bad["installmentPlans"][0]["monthlyAmount"] = json!(99999);
|
||||||
|
bad["installmentPlans"][1]
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("paidCount");
|
||||||
|
bad["roadmap"][0]["closingDebtTarget"] = json!("çok");
|
||||||
|
let (_, r) = app.post("/debt/import?commit=true", &s, bad).await;
|
||||||
|
let rows = r["rows"].as_array().unwrap();
|
||||||
|
assert_eq!(rows[0]["status"], "error", "toplam tutmuyor");
|
||||||
|
assert!(rows[0]["message"].as_str().unwrap().contains("tutmuyor"));
|
||||||
|
assert_eq!(rows[2]["status"], "error", "aylık > toplam");
|
||||||
|
assert_eq!(r["errors"].as_array().unwrap().len(), 2, "{r}");
|
||||||
|
let (_, d) = app.get("/debt", &s).await;
|
||||||
|
assert_eq!(
|
||||||
|
d["snapshots"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"yalnız geçerli ölçüm yazıldı"
|
||||||
|
);
|
||||||
|
let (_, r) = app.post("/debt/import", &s, json!({ "baska": 1 })).await;
|
||||||
|
assert_eq!(
|
||||||
|
r["errors"][0],
|
||||||
|
"Dosyada ölçüm, taksit ya da hedef bulunamadı"
|
||||||
|
);
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn snapshots_installments_targets_and_safety() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Sahip", None).await;
|
||||||
|
|
||||||
|
let (st, sn) = app
|
||||||
|
.post(
|
||||||
|
"/debt/snapshots",
|
||||||
|
&s,
|
||||||
|
json!({ "date": "2026-11-30", "kmh": 1_000_000, "positive_balance": 0, "card_balance": 2_500_000, "remaining_installments": 986_587, "note": "maaş sonrası" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{sn}");
|
||||||
|
assert_eq!(sn["total"], 1_000_000 + 2_500_000 + 986_587);
|
||||||
|
let id = sn["id"].as_str().unwrap();
|
||||||
|
let (st, u) = app
|
||||||
|
.patch(
|
||||||
|
&format!("/debt/snapshots/{id}"),
|
||||||
|
&s,
|
||||||
|
json!({ "kmh": 0, "version": 1 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!((st, u["total"].as_i64()), (StatusCode::OK, Some(3_486_587)));
|
||||||
|
let (st, _) = app
|
||||||
|
.patch(
|
||||||
|
&format!("/debt/snapshots/{id}"),
|
||||||
|
&s,
|
||||||
|
json!({ "kmh": 5, "version": 1 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CONFLICT, "eski sürüm");
|
||||||
|
|
||||||
|
// Kart / IBAN numarası yazılamaz
|
||||||
|
for note in ["kart 4543 1234 5678 9010", "TR120006400000112345678901"] {
|
||||||
|
let (st, b) = app
|
||||||
|
.post(
|
||||||
|
"/debt/snapshots",
|
||||||
|
&s,
|
||||||
|
json!({ "date": "2026-12-01", "note": note }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
assert!(
|
||||||
|
b["error"]["message"].as_str().unwrap().contains("IBAN"),
|
||||||
|
"{b}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (st, _) = app
|
||||||
|
.post("/debt/snapshots", &s, json!({ "date": "2026-02-30" }))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// Taksit
|
||||||
|
let (st, p) = app
|
||||||
|
.post(
|
||||||
|
"/debt/installments",
|
||||||
|
&s,
|
||||||
|
json!({ "merchant": "Mağaza", "total_amount": 120_000, "monthly_amount": 20_000, "total_count": 6, "first_month": "2026-10" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||||
|
assert_eq!(p["last_month"], "2027-03");
|
||||||
|
for bad in [
|
||||||
|
json!({ "merchant": "X", "total_amount": 100, "monthly_amount": 200, "total_count": 2, "first_month": "2026-10" }),
|
||||||
|
json!({ "merchant": "X", "total_amount": 100, "monthly_amount": 50, "total_count": 0, "first_month": "2026-10" }),
|
||||||
|
json!({ "merchant": "X", "total_amount": 100, "monthly_amount": 50, "total_count": 2, "first_month": "2026-13" }),
|
||||||
|
] {
|
||||||
|
let (st, _) = app.post("/debt/installments", &s, bad).await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hedefler: liste olduğu gibi
|
||||||
|
let (st, _) = app
|
||||||
|
.put("/debt/targets", &s, json!({ "targets": [{ "month": "2026-12", "closing_target": 100 }, { "month": "2027-01", "closing_target": 0 }] }))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||||
|
app.put(
|
||||||
|
"/debt/targets",
|
||||||
|
&s,
|
||||||
|
json!({ "targets": [{ "month": "2027-01", "closing_target": 0 }] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, d) = app.get("/debt", &s).await;
|
||||||
|
assert_eq!(
|
||||||
|
d["targets"],
|
||||||
|
json!([{ "month": "2027-01", "closing_target": 0 }])
|
||||||
|
);
|
||||||
|
let (st, _) = app
|
||||||
|
.put("/debt/targets", &s, json!({ "targets": [{ "month": "2027-01", "closing_target": 0 }, { "month": "2027-01", "closing_target": 5 }] }))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST, "aynı ay iki kez");
|
||||||
|
|
||||||
|
// Başka hane göremez / silemez; silinen listeden çıkar
|
||||||
|
let other = app.register("Diger", None).await;
|
||||||
|
let (_, od) = app.get("/debt", &other).await;
|
||||||
|
assert_eq!(od["snapshots"], json!([]));
|
||||||
|
let (st, _) = app.delete(&format!("/debt/snapshots/{id}"), &other).await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
let (st, _) = app.delete(&format!("/debt/snapshots/{id}"), &s).await;
|
||||||
|
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||||
|
let (_, d) = app.get("/debt", &s).await;
|
||||||
|
assert_eq!(d["snapshots"], json!([]));
|
||||||
|
assert!(d["latest"].is_null());
|
||||||
|
|
||||||
|
// Kapalı dönemde ay başı borç değişmez
|
||||||
|
let (_, p) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
&s,
|
||||||
|
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let pid = p["id"].as_str().unwrap();
|
||||||
|
app.post(&format!("/periods/{pid}/close"), &s, json!({}))
|
||||||
|
.await;
|
||||||
|
let (st, _) = app
|
||||||
|
.put(
|
||||||
|
&format!("/periods/{pid}/opening-debt"),
|
||||||
|
&s,
|
||||||
|
json!({ "amount": 1 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CONFLICT);
|
||||||
|
let (_, pd) = app.get(&format!("/periods/{pid}/debt"), &s).await;
|
||||||
|
assert_eq!(pd["opening_source"], "none");
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
//! Günlük defter: harcama kayıtları.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use common::{Session, TestApp};
|
||||||
|
use mongodb::bson::{Document, Uuid, doc};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
async fn open_period(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
|
||||||
|
let (st, p) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
s,
|
||||||
|
json!({ "start_date": start, "end_date": end }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||||
|
p["id"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hanenin ilk (örnek) kategorisi, verilen grupta
|
||||||
|
async fn category(app: &TestApp, s: &Session, group: &str) -> String {
|
||||||
|
let (_, cats) = app.get("/categories", s).await;
|
||||||
|
cats.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|c| c["group"] == group)
|
||||||
|
.unwrap_or_else(|| panic!("{group} grubunda kategori yok"))["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn payment_method(app: &TestApp, s: &Session) -> String {
|
||||||
|
let (_, pms) = app.get("/payment-methods", s).await;
|
||||||
|
pms[0]["id"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add(app: &TestApp, s: &Session, pid: &str, body: Value) -> (StatusCode, Value) {
|
||||||
|
app.post(&format!("/periods/{pid}/entries"), s, body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(app: &TestApp, pid: &str) {
|
||||||
|
app.db
|
||||||
|
.collection::<Document>("periods")
|
||||||
|
.update_one(
|
||||||
|
doc! { "_id": Uuid::parse_str(pid).unwrap() },
|
||||||
|
doc! { "$set": { "status": "closed" } },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ids(list: &Value) -> Vec<&str> {
|
||||||
|
list.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|e| e["description"].as_str().unwrap_or(""))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn crud_group_from_category_and_ordering() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||||
|
let needs = category(&app, &s, "needs").await;
|
||||||
|
let wants = category(&app, &s, "wants").await;
|
||||||
|
let pm = payment_method(&app, &s).await;
|
||||||
|
|
||||||
|
let (st, e) = add(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&pid,
|
||||||
|
json!({ "date": "2026-12-09", "amount": 34_550, "category_id": needs,
|
||||||
|
"payment_method_id": pm, "description": " Haftalık market " }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||||
|
assert_eq!(e["group"], "needs", "grup kategoriden gelir");
|
||||||
|
assert_eq!(e["description"], "Haftalık market", "boşluklar kırpılır");
|
||||||
|
assert_eq!(e["payment_method_id"], pm.as_str());
|
||||||
|
assert_eq!(e["version"], 1);
|
||||||
|
let id = e["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// Ödeme türü ve açıklama isteğe bağlı
|
||||||
|
for (date, desc) in [
|
||||||
|
("2026-12-09", "aynı gün, sonra"),
|
||||||
|
("2026-12-02", "erken"),
|
||||||
|
("2026-12-20", "geç"),
|
||||||
|
] {
|
||||||
|
let (st, b) = add(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&pid,
|
||||||
|
json!({ "date": date, "amount": 1_000, "category_id": wants, "description": desc }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||||
|
assert!(b["payment_method_id"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sıra: yeni tarih önce; aynı gün içinde son eklenen önce
|
||||||
|
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||||
|
assert_eq!(
|
||||||
|
ids(&list),
|
||||||
|
["geç", "aynı gün, sonra", "Haftalık market", "erken"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tarih aralığı (hafta) filtresi: iki uç dahil
|
||||||
|
let (_, week) = app
|
||||||
|
.get(
|
||||||
|
&format!("/periods/{pid}/entries?from=2026-12-07&to=2026-12-13"),
|
||||||
|
&s,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(ids(&week), ["aynı gün, sonra", "Haftalık market"]);
|
||||||
|
let (st, _) = app
|
||||||
|
.get(&format!("/periods/{pid}/entries?from=2026-13-01"), &s)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// Düzenle: kategori değişince grup da değişir; boş metin → kaldır
|
||||||
|
let (st, u) = app
|
||||||
|
.patch(
|
||||||
|
&format!("/entries/{id}"),
|
||||||
|
&s,
|
||||||
|
json!({ "amount": 40_000, "category_id": wants, "payment_method_id": "",
|
||||||
|
"description": "", "version": 1 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{u}");
|
||||||
|
assert_eq!(u["amount"], 40_000);
|
||||||
|
assert_eq!(u["group"], "wants");
|
||||||
|
assert!(u["payment_method_id"].is_null());
|
||||||
|
assert!(u["description"].is_null());
|
||||||
|
assert_eq!(u["version"], 2);
|
||||||
|
assert_eq!(u["date"], "2026-12-09", "gönderilmeyen alan değişmez");
|
||||||
|
|
||||||
|
// Eski sürümle düzenleme → 409
|
||||||
|
let (st, _) = app
|
||||||
|
.patch(
|
||||||
|
&format!("/entries/{id}"),
|
||||||
|
&s,
|
||||||
|
json!({ "amount": 1, "version": 1 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
// Sil → listede yok
|
||||||
|
let (st, _) = app.delete(&format!("/entries/{id}"), &s).await;
|
||||||
|
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||||
|
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||||
|
assert_eq!(list.as_array().unwrap().len(), 3);
|
||||||
|
let (st, _) = app
|
||||||
|
.patch(&format!("/entries/{id}"), &s, json!({ "amount": 5 }))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND, "silinmiş kayıt düzenlenemez");
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_and_restore_undo() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||||
|
let needs = category(&app, &s, "needs").await;
|
||||||
|
let (_, e) = add(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&pid,
|
||||||
|
json!({ "date": "2026-12-03", "amount": 2_500, "category_id": needs }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let id = e["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
// Silinmemiş kayıt geri alınamaz
|
||||||
|
let (st, _) = app
|
||||||
|
.post(&format!("/entries/{id}/restore"), &s, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
app.delete(&format!("/entries/{id}"), &s).await;
|
||||||
|
let (st, r) = app
|
||||||
|
.post(&format!("/entries/{id}/restore"), &s, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{r}");
|
||||||
|
assert_eq!(r["amount"], 2_500);
|
||||||
|
assert_eq!(r["version"], 3, "sil + geri al iki değişiklik");
|
||||||
|
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||||
|
assert_eq!(
|
||||||
|
list.as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"geri gelen kayıt listede"
|
||||||
|
);
|
||||||
|
|
||||||
|
// İkinci kez geri alma → 404
|
||||||
|
let (st, _) = app
|
||||||
|
.post(&format!("/entries/{id}/restore"), &s, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn validation_errors_are_turkish_400s() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||||
|
let needs = category(&app, &s, "needs").await;
|
||||||
|
let base = json!({ "date": "2026-12-05", "amount": 1_000, "category_id": needs });
|
||||||
|
let with = |k: &str, v: Value| {
|
||||||
|
let mut b = base.clone();
|
||||||
|
b[k] = v;
|
||||||
|
b
|
||||||
|
};
|
||||||
|
|
||||||
|
let cases = [
|
||||||
|
(with("amount", json!(0)), "Tutar sıfırdan büyük olmalı"),
|
||||||
|
(with("amount", json!(-5)), "Tutar sıfırdan büyük olmalı"),
|
||||||
|
(
|
||||||
|
with("date", json!("2027-01-01")),
|
||||||
|
"Tarih dönem içinde olmalı",
|
||||||
|
),
|
||||||
|
(with("date", json!("05.12.2026")), "Tarih geçersiz"),
|
||||||
|
(with("category_id", json!("")), "Kategori geçersiz"),
|
||||||
|
(
|
||||||
|
with("category_id", json!(Uuid::new().to_string())),
|
||||||
|
"Kategori bulunamadı",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
with("payment_method_id", json!("xyz")),
|
||||||
|
"Ödeme türü geçersiz",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
with("payment_method_id", json!(Uuid::new().to_string())),
|
||||||
|
"Ödeme türü bulunamadı",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
with("description", json!("a".repeat(201))),
|
||||||
|
"Açıklama en fazla 200 karakter olabilir",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (body, msg) in cases {
|
||||||
|
let (st, b) = add(&app, &s, &pid, body.clone()).await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST, "{body} → {b}");
|
||||||
|
assert_eq!(b["error"]["message"], msg, "{body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 200 karakter (emoji = 1 karakter) kabul
|
||||||
|
let (st, _) = add(&app, &s, &pid, with("description", json!("🛒".repeat(200)))).await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Pasif kategori: yeni kayıtta olmaz, onu zaten kullanan kayıt düzenlenebilir
|
||||||
|
let (_, e) = add(&app, &s, &pid, base.clone()).await;
|
||||||
|
let (st, _) = app
|
||||||
|
.patch(
|
||||||
|
&format!("/categories/{needs}"),
|
||||||
|
&s,
|
||||||
|
json!({ "is_active": false }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK);
|
||||||
|
let (st, b) = add(&app, &s, &pid, base.clone()).await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(
|
||||||
|
b["error"]["message"],
|
||||||
|
"Bu kategori pasif; başka bir kategori seçin"
|
||||||
|
);
|
||||||
|
let (st, b) = app
|
||||||
|
.patch(
|
||||||
|
&format!("/entries/{}", e["id"].as_str().unwrap()),
|
||||||
|
&s,
|
||||||
|
json!({ "amount": 2_000, "category_id": needs }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
st,
|
||||||
|
StatusCode::OK,
|
||||||
|
"eski kaydın kendi kategorisi kalabilir: {b}"
|
||||||
|
);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn closed_period_is_read_only() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||||
|
let needs = category(&app, &s, "needs").await;
|
||||||
|
let (_, e) = add(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&pid,
|
||||||
|
json!({ "date": "2026-12-05", "amount": 1_000, "category_id": needs }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, gone) = add(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&pid,
|
||||||
|
json!({ "date": "2026-12-06", "amount": 500, "category_id": needs }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let id = e["id"].as_str().unwrap();
|
||||||
|
let gone_id = gone["id"].as_str().unwrap();
|
||||||
|
app.delete(&format!("/entries/{gone_id}"), &s).await;
|
||||||
|
close(&app, &pid).await;
|
||||||
|
|
||||||
|
let (st, _) = add(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&pid,
|
||||||
|
json!({ "date": "2026-12-07", "amount": 1, "category_id": needs }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CONFLICT);
|
||||||
|
let (st, _) = app
|
||||||
|
.patch(&format!("/entries/{id}"), &s, json!({ "amount": 5 }))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CONFLICT);
|
||||||
|
let (st, _) = app.delete(&format!("/entries/{id}"), &s).await;
|
||||||
|
assert_eq!(st, StatusCode::CONFLICT);
|
||||||
|
let (st, _) = app
|
||||||
|
.post(&format!("/entries/{gone_id}/restore"), &s, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CONFLICT);
|
||||||
|
// Okuma serbest
|
||||||
|
let (st, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||||
|
assert_eq!(st, StatusCode::OK);
|
||||||
|
assert_eq!(list.as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn entries_are_isolated_between_households() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let a = app.register("Ali", None).await;
|
||||||
|
let b = app.register("Banu", None).await;
|
||||||
|
let pid = open_period(&app, &a, "2026-12-01", "2027-01-01").await;
|
||||||
|
let b_pid = open_period(&app, &b, "2026-12-01", "2027-01-01").await;
|
||||||
|
let a_cat = category(&app, &a, "needs").await;
|
||||||
|
let a_pm = payment_method(&app, &a).await;
|
||||||
|
let (_, e) = add(
|
||||||
|
&app,
|
||||||
|
&a,
|
||||||
|
&pid,
|
||||||
|
json!({ "date": "2026-12-05", "amount": 1_000, "category_id": a_cat }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let id = e["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
// B, A'nın dönemini/kaydını göremez, değiştiremez
|
||||||
|
let (st, _) = app.get(&format!("/periods/{pid}/entries"), &b).await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
let (st, _) = add(
|
||||||
|
&app,
|
||||||
|
&b,
|
||||||
|
&pid,
|
||||||
|
json!({ "date": "2026-12-05", "amount": 1, "category_id": a_cat }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
let (st, _) = app
|
||||||
|
.patch(&format!("/entries/{id}"), &b, json!({ "amount": 5 }))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
let (st, _) = app.delete(&format!("/entries/{id}"), &b).await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
app.delete(&format!("/entries/{id}"), &a).await;
|
||||||
|
let (st, _) = app
|
||||||
|
.post(&format!("/entries/{id}/restore"), &b, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
st,
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"başkasının silinmiş kaydı geri alınamaz"
|
||||||
|
);
|
||||||
|
|
||||||
|
// B kendi dönemine A'nın kategorisini / ödeme türünü kullanamaz
|
||||||
|
let (st, body) = add(
|
||||||
|
&app,
|
||||||
|
&b,
|
||||||
|
&b_pid,
|
||||||
|
json!({ "date": "2026-12-05", "amount": 1, "category_id": a_cat }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(body["error"]["message"], "Kategori bulunamadı");
|
||||||
|
let b_cat = category(&app, &b, "needs").await;
|
||||||
|
let (st, body) = add(
|
||||||
|
&app,
|
||||||
|
&b,
|
||||||
|
&b_pid,
|
||||||
|
json!({ "date": "2026-12-05", "amount": 1, "category_id": b_cat, "payment_method_id": a_pm }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(body["error"]["message"], "Ödeme türü bulunamadı");
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bütçe önerisi (önceki dönem) artık API'den girilen gerçek kayıtları kullanır.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn previous_suggestion_uses_real_entries() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
let nov = open_period(&app, &s, "2026-11-01", "2026-12-01").await;
|
||||||
|
let needs = category(&app, &s, "needs").await;
|
||||||
|
let culture = category(&app, &s, "culture").await;
|
||||||
|
for (cat, amount) in [(&needs, 120_000), (&needs, 30_000), (&culture, 15_000)] {
|
||||||
|
let (st, b) = add(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&nov,
|
||||||
|
json!({ "date": "2026-11-10", "amount": amount, "category_id": cat }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||||
|
}
|
||||||
|
// Silinen kayıt toplama girmez
|
||||||
|
let (_, del) = add(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&nov,
|
||||||
|
json!({ "date": "2026-11-11", "amount": 999_999, "category_id": needs }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
app.delete(&format!("/entries/{}", del["id"].as_str().unwrap()), &s)
|
||||||
|
.await;
|
||||||
|
close(&app, &nov).await;
|
||||||
|
|
||||||
|
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||||
|
let (_, sug) = app
|
||||||
|
.get(
|
||||||
|
&format!("/periods/{dec}/budget/suggestion?method=previous"),
|
||||||
|
&s,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(sug["available"], true, "{sug}");
|
||||||
|
assert_eq!(
|
||||||
|
sug["amounts"],
|
||||||
|
json!({ "needs": 150_000, "wants": 0, "culture": 15_000, "unexpected": 0 })
|
||||||
|
);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spent_by_week(budget: &Value, group: &str) -> (i64, Vec<i64>) {
|
||||||
|
let g = budget
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|g| g["group"] == group)
|
||||||
|
.unwrap();
|
||||||
|
let weeks = g["weeks"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|w| w["spent"].as_i64().unwrap())
|
||||||
|
.collect();
|
||||||
|
(g["spent"].as_i64().unwrap(), weeks)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn actual_spending_in_summary_and_budget() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
// Kasım'da bir harcama: Aralık toplamlarına girmemeli
|
||||||
|
let nov = open_period(&app, &s, "2026-11-01", "2026-12-01").await;
|
||||||
|
let needs = category(&app, &s, "needs").await;
|
||||||
|
let wants = category(&app, &s, "wants").await;
|
||||||
|
let culture = category(&app, &s, "culture").await;
|
||||||
|
let (st, _) = add(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&nov,
|
||||||
|
json!({ "date": "2026-11-30", "amount": 1_000_000, "category_id": needs }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED);
|
||||||
|
close(&app, &nov).await;
|
||||||
|
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
&format!("/periods/{pid}/plan-items"),
|
||||||
|
&s,
|
||||||
|
json!({ "kind": "income", "name": "Maaş", "amount": 300_000 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Aralık 2026 haftaları: 1–8 · 9–15 · 16–22 · 23–31
|
||||||
|
for (date, amount, cat) in [
|
||||||
|
("2026-12-01", 1_000, &needs),
|
||||||
|
("2026-12-08", 2_000, &needs), // 1. haftanın son günü
|
||||||
|
("2026-12-09", 500, &needs), // 2. haftanın ilk günü
|
||||||
|
("2026-12-31", 300, &wants), // dönemin son günü
|
||||||
|
] {
|
||||||
|
let (st, b) = add(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&pid,
|
||||||
|
json!({ "date": date, "amount": amount, "category_id": cat }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||||
|
}
|
||||||
|
// Kategori değişince harcama diğer gruba geçer
|
||||||
|
let (_, moved) = add(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&pid,
|
||||||
|
json!({ "date": "2026-12-10", "amount": 400, "category_id": needs }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
app.patch(
|
||||||
|
&format!("/entries/{}", moved["id"].as_str().unwrap()),
|
||||||
|
&s,
|
||||||
|
json!({ "category_id": wants }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// Silinen harcama sayılmaz
|
||||||
|
let (_, gone) = add(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&pid,
|
||||||
|
json!({ "date": "2026-12-16", "amount": 700, "category_id": culture }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let gone_id = gone["id"].as_str().unwrap();
|
||||||
|
app.delete(&format!("/entries/{gone_id}"), &s).await;
|
||||||
|
|
||||||
|
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||||
|
// 1.000 + 2.000 + 500 + 300 + 400 = 4.200
|
||||||
|
assert_eq!(
|
||||||
|
(sum["spent_total"].as_i64(), sum["remaining"].as_i64()),
|
||||||
|
(Some(4_200), Some(295_800)),
|
||||||
|
"{sum}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (st, b) = app.get(&format!("/periods/{pid}/budget"), &s).await;
|
||||||
|
assert_eq!(st, StatusCode::OK);
|
||||||
|
assert_eq!(spent_by_week(&b, "needs"), (3_500, vec![3_000, 500, 0, 0]));
|
||||||
|
assert_eq!(spent_by_week(&b, "wants"), (700, vec![0, 400, 0, 300]));
|
||||||
|
assert_eq!(spent_by_week(&b, "culture"), (0, vec![0; 4]));
|
||||||
|
|
||||||
|
// Geri al → yeniden sayılır
|
||||||
|
app.post(&format!("/entries/{gone_id}/restore"), &s, json!({}))
|
||||||
|
.await;
|
||||||
|
let (_, b) = app.get(&format!("/periods/{pid}/budget"), &s).await;
|
||||||
|
assert_eq!(spent_by_week(&b, "culture"), (700, vec![0, 0, 700, 0]));
|
||||||
|
|
||||||
|
// Harcanabilir aşılırsa kalan negatif
|
||||||
|
add(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&pid,
|
||||||
|
json!({ "date": "2026-12-20", "amount": 300_000, "category_id": needs }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||||
|
assert_eq!(sum["remaining"], 300_000 - 304_900);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user