Compare commits
41 Commits
4cc4fa5882
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,11 @@
|
||||
# Docker build bağlamına girmeyecekler (hız + gizli bilgi sızmasın)
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/node_modules
|
||||
**/target
|
||||
**/dist
|
||||
backups/
|
||||
.vscode/
|
||||
*.log
|
||||
@@ -9,8 +9,21 @@ JWT_SECRET=degistir-beni-en-az-32-karakterlik-gizli-anahtar
|
||||
# Uygulamanın erişildiği adres
|
||||
APP_URL=http://localhost:5173
|
||||
|
||||
# Yeni hane kaydı: invite_only (varsayılan, önerilen) | open
|
||||
# invite_only → sistemde hiç hane yokken ilk kayıt yeni hane kurar (sunucu sahibi),
|
||||
# sonra yalnızca davet koduyla katılınır.
|
||||
# open → herkes /kayit ekranından yeni hane kurabilir.
|
||||
REGISTRATION=invite_only
|
||||
|
||||
# Backend portu
|
||||
PORT=8080
|
||||
|
||||
# Log seviyesi
|
||||
RUST_LOG=kakeibo=debug,tower_http=info
|
||||
|
||||
# Derlenmiş arayüz klasörü. Geliştirmede BOŞ bırakın (arayüzü Vite sunar).
|
||||
# Docker imajında /app/static olarak ayarlıdır.
|
||||
# STATIC_DIR=
|
||||
|
||||
# Otomatik kur yenileme (dakika); 0 → kapalı (çevrimdışı geliştirme)
|
||||
# RATES_REFRESH_MINUTES=60
|
||||
|
||||
@@ -53,14 +53,34 @@ npm run dev # frontend (5173) + backend (8080) birlikte, hot reload
|
||||
npm run dev:host # telefondan test için ağa aç
|
||||
npm run db:up # yerel MongoDB (Docker)
|
||||
npm run db:down
|
||||
npm run db:seed / db:reset
|
||||
npm test # backend testleri
|
||||
npm run db:seed
|
||||
npm run db:reset # kakeibo_dev + artık kakeibo_test_* veritabanlarını siler
|
||||
npm test # backend (cargo test, MongoDB açık olmalı) + frontend (vitest)
|
||||
npm run lint # clippy + oxlint
|
||||
npm run types # Rust DTO → frontend/src/types/*.ts (ts-rs; cargo test de üretir)
|
||||
npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonları (rsvg-convert + magick)
|
||||
npm run db:backup # kakeibo_dev → ./backups (canlı yedek: docs/DEPLOY.md §6)
|
||||
npm run db:restore -- <dosya|latest> <hedef_db> [--force]
|
||||
```
|
||||
- `npm run dev` öncesinde DB otomatik başlar (`predev`).
|
||||
- Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler).
|
||||
- Frontend lint: **oxlint** (Vite şablonu varsayılanı; ESLint yerine).
|
||||
- Frontend lint: **oxlint** (Vite şablonu varsayılanı; ESLint yerine). `components/ui/**` shadcn dosyaları.
|
||||
- Frontend: Tailwind v4 (`src/index.css` token'ları), shadcn/ui (style `radix-nova`, `cn` paketi), `@/` → `src/`. Renkler: `bg-needs`/`text-needs-fg`/`bg-needs-soft` (wants, culture, unexpected aynı), `text-income`/`text-expense`/`text-warning`. SVG/JS'te (Recharts vb.) ham değişken kullan: `var(--needs)`, `var(--primary)` — `@theme inline` yüzünden `--color-*` derlenmiş CSS'te olmayabilir. Tutarlarda `money` sınıfı. Butonlar ≥44 px (shadcn boyutları büyütüldü).
|
||||
- API: `lib/api.ts` (`api.get/post/...`, hata → `ApiError` Türkçe `message`), `lib/query.ts` (QueryClient + `queryKeys`), `lib/auth.ts` (`useMe` → undefined/null/MeResponse, `useLogin`/`useRegister`/`useLogout`).
|
||||
- Tipler: `frontend/src/types/` **elle düzenlenmez**; backend'de DTO'ya `#[derive(TS)] #[ts(export)]` ekle → `npm run types`. İstek yapılarında `optional_fields`.
|
||||
- Sayfalar: `src/router.tsx`; adresler `components/layout/nav-items.ts` (`paths`); sayfa bileşenleri `features/<alan>/page.tsx`; iskelet `components/layout/app-layout.tsx`. Sayfa başlığı `PageHeader` (sekme başlığını da ayarlar). Metinler `lib/strings.ts` (`t`).
|
||||
- Yönlendirme: `RequireAuth` (oturum yok → `/giris?donus=`), `RequireGuest` (oturum var → `donus` ya da panel; formlar ayrıca navigate etmez). `donus` daima `safeReturnPath` ile.
|
||||
- Geliştirmede tasarım önizlemesi: `/dev/tasarim`.
|
||||
- Formlar: react-hook-form + zod (`mode: 'onTouched'`), alanlar `components/form-field.tsx` (`FormField`, `PasswordInput`, `FormError`); sunucu hatası `setError` ile ilgili alana. Bildirim: `toast` (sonner, `components/toaster.tsx`).
|
||||
- Dönem verisi: `lib/periods.ts` (Query kancaları), tarih `lib/period-dates.ts` (arayüz son günü dahil gösterir, backend `end_date` hariç), tutar alanı `MoneyInput` + `moneyText`/`toKurus` (`lib/money-schema.ts`). İlk dönem sihirbazı `/plan/donem-ac?adim=`. Plan düzenleme: taslak → `diffPlan` → `useSavePlan`; haftalık önizleme `lib/budget-preview.ts` (backend hesabıyla aynı tutulmalı).
|
||||
- Defter: `/defter?hafta=N`, `features/ledger/` (hafta seçici, hızlı giriş), saf hesaplar `lib/ledger.ts`, kancalar `lib/entries.ts` + `lib/catalog.ts`. Kayıt değişince `queryKeys.period(id)` yenilenir (özet/bütçe `spent` dahil).
|
||||
- Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`.
|
||||
- API örnekleri: `backend/api.http`
|
||||
- Canlı: `Dockerfile` (tek imaj), `docs/DEPLOY.md` (ortam değişkenleri, Dokploy, MongoDB, yedek). `routes::app()` = API + `STATIC_DIR` varsa SPA (`routes/spa.rs`) + sıkıştırma + TraceLayer. Testler `routes::router()` kullanır.
|
||||
- Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_<uuid>` DB, `tests/common` yardımcıları).
|
||||
- Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`.
|
||||
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
|
||||
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
|
||||
|
||||
## Ortam
|
||||
- Geliştirme makinesi: CachyOS (Arch), fish kabuğu, VS Code
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Kakeibo — canlı ortam imajı (tek servis: Axum hem /api'yi hem arayüzü sunar)
|
||||
# Yerelde deneme: docker build -t kakeibo . && docker run --rm -p 8090:8080 --env-file .env kakeibo
|
||||
|
||||
# ─── 1) Arayüz (React + Vite) ─────────────────────────────────────────────
|
||||
FROM node:26-alpine AS web
|
||||
WORKDIR /app/frontend
|
||||
# Önce yalnızca paket dosyaları → bağımlılık katmanı önbellekte kalır
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ─── 2) Backend (Rust, release) ───────────────────────────────────────────
|
||||
FROM rust:1.99-slim-trixie AS api
|
||||
WORKDIR /app/backend
|
||||
# Bağımlılıkları ayrı katmanda derle (kod değişince yeniden derlenmez)
|
||||
COPY backend/Cargo.toml backend/Cargo.lock ./
|
||||
RUN mkdir src && echo 'fn main() {}' > src/main.rs && touch src/lib.rs \
|
||||
&& cargo build --release --locked \
|
||||
&& rm -rf src target/release/deps/*kakeibo* target/release/.fingerprint/kakeibo-* target/release/kakeibo*
|
||||
COPY backend/src ./src
|
||||
# touch: sahte lib.rs/main.rs'ten daha yeni olsun → Cargo gerçek kodu kesinlikle yeniden derler
|
||||
RUN touch src/lib.rs src/main.rs && cargo build --release --locked
|
||||
|
||||
# ─── 3) Çalışma imajı (küçük, root olmayan kullanıcı) ─────────────────────
|
||||
FROM debian:trixie-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --uid 10001 --no-create-home kakeibo
|
||||
COPY --from=api /app/backend/target/release/kakeibo /usr/local/bin/kakeibo
|
||||
COPY --from=web /app/frontend/dist /app/static
|
||||
|
||||
ENV STATIC_DIR=/app/static \
|
||||
PORT=8080 \
|
||||
RUST_LOG=kakeibo=info,tower_http=info
|
||||
EXPOSE 8080
|
||||
USER kakeibo
|
||||
# Dokploy/Docker sağlık kontrolü (uygulama + veritabanı)
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -fsS http://localhost:${PORT}/api/health || exit 1
|
||||
CMD ["kakeibo"]
|
||||
@@ -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
+436
-3
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -24,6 +30,21 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.6.0"
|
||||
@@ -36,6 +57,18 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
@@ -145,6 +178,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
@@ -196,6 +235,27 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "9.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "6.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bson"
|
||||
version = "2.15.0"
|
||||
@@ -203,7 +263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitvec",
|
||||
"getrandom 0.2.17",
|
||||
"getrandom 0.3.4",
|
||||
@@ -247,6 +307,12 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
@@ -274,6 +340,24 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
@@ -351,6 +435,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b929dd2a135771f40afabd1073f85eed59c800b7c61901e5c254270497dad64"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
@@ -711,6 +804,17 @@ version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -836,9 +940,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -994,6 +1100,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -1032,6 +1144,23 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1040,13 +1169,22 @@ version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1263,7 +1401,7 @@ version = "11.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.2.17",
|
||||
"hmac 0.12.1",
|
||||
@@ -1287,15 +1425,21 @@ dependencies = [
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"dotenvy",
|
||||
"http-body-util",
|
||||
"jsonwebtoken",
|
||||
"mongodb",
|
||||
"quick-xml",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http 0.7.1",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ts-rs",
|
||||
"uuid",
|
||||
"validator",
|
||||
]
|
||||
@@ -1342,6 +1486,12 @@ version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "macro_magic"
|
||||
version = "0.5.1"
|
||||
@@ -1427,6 +1577,26 @@ version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5848f5a9e8117cf94f2f90faac24a87ff81600ff8628c1e3deaab572e76bfa1"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.4"
|
||||
@@ -1479,7 +1649,7 @@ version = "3.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6466d4601ff396952e1895d88083d1ab03930b812277180eb8258bbe1a10bb0f"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags",
|
||||
"bson",
|
||||
"derive-where",
|
||||
@@ -1806,6 +1976,72 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -1909,6 +2145,15 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -1947,6 +2192,44 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http 0.6.11",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resolv-conf"
|
||||
version = "0.7.6"
|
||||
@@ -1997,6 +2280,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -2037,6 +2326,7 @@ version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "102f35f7de7b06d8c61f1cff6592f5253e0dea431c74867aad5dfd70f44663fb"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2266,6 +2556,12 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
@@ -2376,6 +2672,9 @@ name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
@@ -2427,6 +2726,15 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
@@ -2580,6 +2888,51 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
@@ -2654,6 +3007,34 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
"ts-rs-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs-macros"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-builder"
|
||||
version = "0.22.0"
|
||||
@@ -2680,6 +3061,12 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
@@ -2802,6 +3189,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
|
||||
dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -2829,6 +3225,17 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.130"
|
||||
@@ -2861,6 +3268,26 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.12"
|
||||
@@ -3140,6 +3567,12 @@ dependencies = [
|
||||
"syn 3.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
||||
@@ -10,12 +10,20 @@ axum-extra = { version = "0.12.6", features = ["cookie"] }
|
||||
dotenvy = "0.15.7"
|
||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
|
||||
mongodb = "3.10.0"
|
||||
quick-xml = { version = "0.42.0", features = ["serialize"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.154"
|
||||
thiserror = "2.0.21"
|
||||
time = "0.3.55"
|
||||
tokio = { version = "1.54.1", features = ["full"] }
|
||||
tower-http = { version = "0.7.1", features = ["fs", "compression-gzip", "compression-br", "trace"], default-features = false }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
ts-rs = "12.0.1"
|
||||
uuid = { version = "1.29.2", features = ["v4", "v7"] }
|
||||
validator = { version = "0.21.0", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1.5"
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
|
||||
+339
-3
@@ -1,5 +1,10 @@
|
||||
### Kakeibo API — örnek istekler (VS Code REST Client eklentisi)
|
||||
### Satırın üstündeki "Send Request" bağlantısına tıklayın.
|
||||
### ┌──────────────────────────────────────────────────────────────────────┐
|
||||
### │ GİRİŞ İÇİN HAZIR HESAPLAR (yalnızca yerel geliştirme veritabanı): │
|
||||
### │ demo@ornek.com / demo12345 → hane sahibi (Demo Hanesi) │
|
||||
### │ es@ornek.com / demo12345 → üye (aynı hane) │
|
||||
### └──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
@baseUrl = http://localhost:8080/api
|
||||
|
||||
@@ -9,13 +14,17 @@ GET {{baseUrl}}/health
|
||||
# ─────────────────────────── Kimlik doğrulama ───────────────────────────
|
||||
# REST Client cookie'leri otomatik saklar: kayıt/giriş sonrası diğer istekler oturumlu gider.
|
||||
|
||||
### Kayıt (kullanıcı + hane + örnek kategoriler/ödeme türleri oluşur)
|
||||
### 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": "suphi@ornek.com",
|
||||
"email": "yeni@ornek.com",
|
||||
"password": "gizli-sifre-123",
|
||||
"household_name": "Çakır Hanesi"
|
||||
}
|
||||
@@ -37,11 +46,14 @@ POST {{baseUrl}}/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "suphi@ornek.com",
|
||||
"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
|
||||
|
||||
@@ -61,3 +73,327 @@ 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
|
||||
|
||||
+43
-1
@@ -1,6 +1,6 @@
|
||||
//! Ortam değişkenlerinden uygulama ayarlarını okur.
|
||||
|
||||
use std::env;
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
@@ -8,6 +8,22 @@ pub struct Config {
|
||||
pub jwt_secret: String,
|
||||
pub app_url: String,
|
||||
pub port: u16,
|
||||
pub registration: RegistrationMode,
|
||||
/// Derlenmiş frontend klasörü (`STATIC_DIR`). Tanımlıysa Axum arayüzü de sunar
|
||||
/// (canlıda tek servis). Geliştirmede boş: arayüzü Vite sunar.
|
||||
pub static_dir: Option<PathBuf>,
|
||||
/// Otomatik kur yenileme aralığı (dakika, `RATES_REFRESH_MINUTES`, varsayılan 60); 0 → kapalı
|
||||
pub rates_refresh_minutes: u64,
|
||||
}
|
||||
|
||||
/// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RegistrationMode {
|
||||
/// Varsayılan. Sistemde hiç hane yokken ilk kayıt yeni hane kurar;
|
||||
/// sonrasında yalnızca davet koduyla katılınır.
|
||||
InviteOnly,
|
||||
/// Herkes yeni hane kurabilir.
|
||||
Open,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -32,6 +48,22 @@ impl Config {
|
||||
|
||||
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()
|
||||
@@ -39,11 +71,21 @@ impl Config {
|
||||
Err(_) => 8080,
|
||||
};
|
||||
|
||||
let rates_refresh_minutes = match env::var("RATES_REFRESH_MINUTES") {
|
||||
Ok(m) if !m.trim().is_empty() => m.trim().parse().map_err(|_| {
|
||||
ConfigError::Invalid("RATES_REFRESH_MINUTES", "dakika (sayı) olmalı")
|
||||
})?,
|
||||
_ => 60,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
mongodb_uri,
|
||||
jwt_secret,
|
||||
app_url,
|
||||
port,
|
||||
registration,
|
||||
static_dir,
|
||||
rates_refresh_minutes,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -69,16 +69,26 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
|
||||
vec![
|
||||
idx(doc! { "household_id": 1, "period_id": 1, "date": 1 }),
|
||||
idx(doc! { "household_id": 1, "date": 1 }),
|
||||
// Ekstre içe aktarmada tekrar tespiti
|
||||
idx(doc! { "household_id": 1, "import_key": 1 }),
|
||||
],
|
||||
),
|
||||
(
|
||||
col::CARRYOVERS,
|
||||
vec![idx(doc! { "household_id": 1, "to_period_id": 1 })],
|
||||
),
|
||||
(
|
||||
col::ASSETS,
|
||||
vec![idx(doc! { "household_id": 1, "sort_order": 1 })],
|
||||
),
|
||||
(
|
||||
col::ASSET_TRANSACTIONS,
|
||||
vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })],
|
||||
),
|
||||
(
|
||||
col::IMPORT_RULES,
|
||||
vec![idx(doc! { "household_id": 1, "pattern": 1 })],
|
||||
),
|
||||
(
|
||||
col::EXCHANGE_RATES,
|
||||
vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })],
|
||||
|
||||
@@ -24,11 +24,18 @@ struct Migration {
|
||||
}
|
||||
|
||||
/// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar.
|
||||
const MIGRATIONS: &[Migration] = &[Migration {
|
||||
const MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial",
|
||||
run: |db| Box::pin(m001_initial(db)),
|
||||
}];
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "default_assets",
|
||||
run: |db| Box::pin(m002_default_assets(db)),
|
||||
},
|
||||
];
|
||||
|
||||
/// Uygulanmamış migration'ları sırayla çalıştırır.
|
||||
pub async fn run(db: &Database) -> Result<()> {
|
||||
@@ -66,6 +73,36 @@ async fn m001_initial(db: &Database) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v2: Faz 8'den önce açılmış hanelere varsayılan varlıklar (TL, altınlar, USD, EUR).
|
||||
/// Varlığı olan haneye dokunmaz → tekrar çalışsa da çift kayıt olmaz.
|
||||
async fn m002_default_assets(db: &Database) -> Result<()> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Household {
|
||||
#[serde(rename = "_id")]
|
||||
id: mongodb::bson::Uuid,
|
||||
}
|
||||
let assets = db.collection::<Document>(col::ASSETS);
|
||||
let mut cursor = db
|
||||
.collection::<Household>(col::HOUSEHOLDS)
|
||||
.find(doc! { "deleted_at": null })
|
||||
.projection(doc! { "_id": 1 })
|
||||
.await?;
|
||||
while cursor.advance().await? {
|
||||
let hid = cursor.deserialize_current()?.id;
|
||||
if assets
|
||||
.find_one(doc! { "household_id": hid })
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
assets
|
||||
.insert_many(crate::services::seed::assets(hid))
|
||||
.await?;
|
||||
tracing::info!("Varsayılan varlıklar eklendi: hane {hid}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -55,7 +55,6 @@ pub async fn prepare(db: &Database) -> mongodb::error::Result<()> {
|
||||
}
|
||||
|
||||
/// Koleksiyon adları (DATA_MODEL.md §3). Yazım hatasını derleyici yakalasın diye sabit.
|
||||
#[allow(dead_code)] // Bazıları sonraki fazlarda kullanılacak
|
||||
pub mod col {
|
||||
pub const USERS: &str = "users";
|
||||
pub const HOUSEHOLDS: &str = "households";
|
||||
@@ -71,6 +70,8 @@ pub mod col {
|
||||
pub const ASSETS: &str = "assets";
|
||||
pub const ASSET_TRANSACTIONS: &str = "asset_transactions";
|
||||
pub const EXCHANGE_RATES: &str = "exchange_rates";
|
||||
/// Ekstre içe aktarmada öğrenilen açıklama → tür/kategori kuralları
|
||||
pub const IMPORT_RULES: &str = "import_rules";
|
||||
pub const SCHEMA_MIGRATIONS: &str = "schema_migrations";
|
||||
|
||||
/// Hane verisi taşıyan koleksiyonlar (`household_id` alanı var).
|
||||
@@ -88,5 +89,6 @@ pub mod col {
|
||||
NOTES,
|
||||
ASSETS,
|
||||
ASSET_TRANSACTIONS,
|
||||
IMPORT_RULES,
|
||||
];
|
||||
}
|
||||
|
||||
+28
-6
@@ -6,7 +6,24 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Tüm hata yanıtlarının gövdesi: `{ "error": { "code": "...", "message": "..." } }`
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ApiErrorBody {
|
||||
pub error: ApiErrorDetail,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ApiErrorDetail {
|
||||
/// Makine için: `unauthorized`, `bad_request`, `conflict`, `not_found`, ...
|
||||
pub code: String,
|
||||
/// Kullanıcıya gösterilecek Türkçe mesaj
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
@@ -20,6 +37,8 @@ pub enum AppError {
|
||||
InvalidCredentials,
|
||||
#[error("Bu işlem için yetkiniz yok")]
|
||||
Forbidden,
|
||||
#[error("Yeni hane kaydı kapalı. Katılmak için hane sahibinden davet bağlantısı isteyin.")]
|
||||
RegistrationClosed,
|
||||
#[error("{0}")]
|
||||
Conflict(String),
|
||||
#[error("Çok fazla deneme yapıldı, lütfen biraz sonra tekrar deneyin")]
|
||||
@@ -38,6 +57,7 @@ impl AppError {
|
||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
Self::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||
Self::RegistrationClosed => (StatusCode::FORBIDDEN, "registration_closed"),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||
Self::Database(_) | Self::Internal(_) => {
|
||||
@@ -59,11 +79,13 @@ impl IntoResponse for AppError {
|
||||
self.to_string()
|
||||
};
|
||||
|
||||
(
|
||||
status,
|
||||
Json(json!({ "error": { "code": code, "message": message } })),
|
||||
)
|
||||
.into_response()
|
||||
let body = ApiErrorBody {
|
||||
error: ApiErrorDetail {
|
||||
code: code.into(),
|
||||
message,
|
||||
},
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Kakeibo backend kütüphanesi.
|
||||
//!
|
||||
//! `main.rs` (çalıştırılabilir sunucu) ve `tests/` (entegrasyon testleri) bu modülleri kullanır.
|
||||
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod extract;
|
||||
pub mod models;
|
||||
pub mod routes;
|
||||
pub mod services;
|
||||
pub mod state;
|
||||
+34
-26
@@ -1,23 +1,8 @@
|
||||
mod auth;
|
||||
mod config;
|
||||
mod db;
|
||||
mod error;
|
||||
mod extract;
|
||||
mod models;
|
||||
mod routes;
|
||||
mod services;
|
||||
mod state;
|
||||
|
||||
use std::{net::SocketAddr, process::ExitCode, sync::Arc, time::Duration};
|
||||
use std::{net::SocketAddr, process::ExitCode};
|
||||
|
||||
use kakeibo::{config::Config, db, routes, services, state::AppState};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::{
|
||||
auth::{jwt::Jwt, limiter::LoginLimiter},
|
||||
config::Config,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
// .env dosyasını yükle (proje kökünde; üst klasörler de aranır). Yoksa sorun değil.
|
||||
@@ -56,15 +41,12 @@ async fn main() -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
// Kurlar arka planda (açılışta hemen, sonra aralıkla); hata uygulamayı durdurmaz
|
||||
services::rates::spawn_refresher(db.clone(), config.rates_refresh_minutes);
|
||||
|
||||
let port = config.port;
|
||||
let state = AppState {
|
||||
db,
|
||||
jwt: Arc::new(Jwt::new(&config.jwt_secret)),
|
||||
// Aynı e-postaya 15 dakikada en fazla 5 hatalı giriş denemesi
|
||||
login_limiter: Arc::new(LoginLimiter::new(5, Duration::from_secs(15 * 60))),
|
||||
config: Arc::new(config),
|
||||
};
|
||||
let app = routes::router(state);
|
||||
let state = AppState::new(db, config);
|
||||
let app = routes::app(state);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
@@ -78,9 +60,35 @@ async fn main() -> ExitCode {
|
||||
};
|
||||
|
||||
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
if let Err(e) = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await
|
||||
{
|
||||
tracing::error!("Sunucu hatası: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Docker/Dokploy durdururken (SIGTERM) veya Ctrl+C'de: yeni istek almayı bırak,
|
||||
/// süren istekleri bitir, sonra kapan (yarım kalan yazma olmasın).
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
{
|
||||
s.recv().await;
|
||||
}
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
tracing::info!("Kapatma sinyali alındı, sunucu kapanıyor…");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -36,15 +36,6 @@ pub fn scope(household_id: Uuid) -> Document {
|
||||
doc! { "household_id": household_id, "deleted_at": null }
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Faz 1 – Adım 5'te (kategori güncelleme) kullanılacak
|
||||
/// Güncellemelerde eklenecek ortak kısım: `updated_at` yenilenir, `version` +1.
|
||||
pub fn touch() -> Document {
|
||||
doc! {
|
||||
"$set": { "updated_at": now() },
|
||||
"$inc": { "version": 1_i32 },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +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,62 @@
|
||||
//! `periods` koleksiyonu (DATA_MODEL.md §3.4).
|
||||
|
||||
use mongodb::bson::{DateTime, Uuid};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export)]
|
||||
pub enum PeriodStatus {
|
||||
/// Girişler yapılabilir. Hanede en fazla bir tane.
|
||||
Open,
|
||||
/// Salt okunur. Sahip yeniden açarsa `Correcting` olur.
|
||||
Closed,
|
||||
/// Düzeltmede: kapanmış dönem sahip tarafından düzeltme için açıldı.
|
||||
/// Yazılabilir, ama "açık dönem" sayılmaz (yeni dönem açıkken de olabilir).
|
||||
/// "Yeniden kapat" ile devir yeniden hesaplanır.
|
||||
Correcting,
|
||||
/// Geçmiş (arşiv): ekstre içe aktarımıyla yıl başından beri olan aylar için açılır.
|
||||
/// Yalnızca kayıt tutar: plan/bütçe zorunlu değil, kapatılmaz, devir üretmez ve
|
||||
/// devir zincirinde atlanır; bütçe önerilerini ("önceki dönem", "son 3 ay") besler.
|
||||
Archived,
|
||||
}
|
||||
|
||||
impl PeriodStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
PeriodStatus::Open => "open",
|
||||
PeriodStatus::Closed => "closed",
|
||||
PeriodStatus::Correcting => "correcting",
|
||||
PeriodStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
/// Kayıt eklenip değiştirilebilir mi?
|
||||
pub fn is_writable(self) -> bool {
|
||||
self != PeriodStatus::Closed
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Period {
|
||||
#[serde(rename = "_id")]
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
/// `"YYYY-MM-DD"`, dahil
|
||||
pub start_date: String,
|
||||
/// `"YYYY-MM-DD"`, **hariç**
|
||||
pub end_date: String,
|
||||
pub status: PeriodStatus,
|
||||
/// Kuruş
|
||||
pub savings_goal: i64,
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası)
|
||||
#[serde(default)]
|
||||
pub reflection_md: Option<String>,
|
||||
/// Kapanışta birikime aktarılan tutar (kuruş). Faz 8'de varlık hareketine dönüşecek.
|
||||
#[serde(default)]
|
||||
pub to_savings: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub closed_at: Option<DateTime>,
|
||||
pub version: i32,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export)]
|
||||
pub enum Role {
|
||||
Owner,
|
||||
Member,
|
||||
|
||||
@@ -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())))
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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::{
|
||||
@@ -26,7 +27,8 @@ use crate::{
|
||||
|
||||
// ---------- İstek / yanıt yapıları ----------
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
#[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,
|
||||
@@ -41,7 +43,8 @@ pub struct RegisterRequest {
|
||||
invite_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export)]
|
||||
pub struct LoginRequest {
|
||||
#[validate(length(min = 1, message = "E-posta gerekli"))]
|
||||
email: String,
|
||||
@@ -51,13 +54,15 @@ pub struct LoginRequest {
|
||||
remember_me: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct MeResponse {
|
||||
user: UserDto,
|
||||
household: HouseholdDto,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "User")]
|
||||
struct UserDto {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -65,7 +70,8 @@ struct UserDto {
|
||||
role: Role,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "HouseholdSummary")]
|
||||
struct HouseholdDto {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -97,6 +103,7 @@ pub async fn register(
|
||||
let password_hash = password::hash(req.password).await?;
|
||||
let created = account::register(
|
||||
&state.db,
|
||||
state.config.registration,
|
||||
NewAccount {
|
||||
name,
|
||||
email,
|
||||
@@ -168,6 +175,21 @@ pub async fn login(
|
||||
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()))
|
||||
}
|
||||
|
||||
@@ -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,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()))
|
||||
}
|
||||
@@ -1,23 +1,42 @@
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
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<Value>) {
|
||||
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(json!({ "status": "ok", "db": "ok", "version": version })),
|
||||
Json(HealthResponse {
|
||||
status: "ok",
|
||||
db: "ok",
|
||||
version,
|
||||
}),
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "health: veritabanına erişilemiyor");
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "status": "degraded", "db": "error", "version": version })),
|
||||
Json(HealthResponse {
|
||||
status: "degraded",
|
||||
db: "error",
|
||||
version,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use axum::{
|
||||
};
|
||||
use mongodb::bson::{DateTime, Uuid, doc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
@@ -21,14 +22,16 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Household")]
|
||||
pub struct HouseholdResponse {
|
||||
id: String,
|
||||
name: String,
|
||||
members: Vec<MemberDto>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Member")]
|
||||
struct MemberDto {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -36,7 +39,8 @@ struct MemberDto {
|
||||
role: Role,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Invite")]
|
||||
pub struct InviteDto {
|
||||
id: String,
|
||||
code: String,
|
||||
|
||||
+120
-3
@@ -1,20 +1,50 @@
|
||||
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
|
||||
|
||||
mod asset;
|
||||
mod auth;
|
||||
mod bank_import;
|
||||
mod budget;
|
||||
mod catalog;
|
||||
mod entry;
|
||||
mod health;
|
||||
mod household;
|
||||
mod note;
|
||||
mod period;
|
||||
mod plan;
|
||||
mod rates;
|
||||
mod reports;
|
||||
mod review;
|
||||
mod spa;
|
||||
mod stats;
|
||||
mod transfer;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{delete, get, post},
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{delete, get, patch, post, put},
|
||||
};
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::{error::AppError, state::AppState};
|
||||
|
||||
/// Sunucunun tamamı: API + (STATIC_DIR tanımlıysa) arayüz dosyaları,
|
||||
/// sıkıştırma ve istek logları. `main.rs` bunu kullanır.
|
||||
pub fn app(state: AppState) -> Router {
|
||||
let static_dir = state.config.static_dir.clone();
|
||||
let mut app = router(state);
|
||||
if let Some(dir) = static_dir {
|
||||
app = app.fallback_service(spa::router(&dir));
|
||||
}
|
||||
app.layer(CompressionLayer::new())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
|
||||
/// Tüm API route'ları. Testler de bu fonksiyonu kullanır.
|
||||
pub fn router(state: AppState) -> Router {
|
||||
let api = Router::new()
|
||||
.route("/health", get(health::health))
|
||||
.route("/auth/register", post(auth::register))
|
||||
.route("/auth/registration", get(auth::registration_info))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/auth/me", get(auth::me))
|
||||
@@ -23,7 +53,94 @@ pub fn router(state: AppState) -> Router {
|
||||
"/household/invites",
|
||||
get(household::list_invites).post(household::create_invite),
|
||||
)
|
||||
.route("/household/invites/{id}", delete(household::revoke_invite));
|
||||
.route("/household/invites/{id}", delete(household::revoke_invite))
|
||||
.route(
|
||||
"/categories",
|
||||
get(catalog::list_categories).post(catalog::create_category),
|
||||
)
|
||||
.route("/categories/order", put(catalog::reorder_categories))
|
||||
.route("/categories/{id}", patch(catalog::update_category))
|
||||
.route(
|
||||
"/payment-methods",
|
||||
get(catalog::list_payment_methods).post(catalog::create_payment_method),
|
||||
)
|
||||
.route(
|
||||
"/payment-methods/order",
|
||||
put(catalog::reorder_payment_methods),
|
||||
)
|
||||
.route(
|
||||
"/payment-methods/{id}",
|
||||
patch(catalog::update_payment_method),
|
||||
)
|
||||
.route("/periods", get(period::list).post(period::create))
|
||||
.route("/periods/current", get(period::current))
|
||||
.route("/periods/{id}", get(period::get).patch(period::update))
|
||||
.route("/periods/{id}/close", post(period::close))
|
||||
.route("/periods/{id}/reopen", post(period::reopen))
|
||||
.route(
|
||||
"/periods/{id}/plan-items",
|
||||
get(plan::list).post(plan::create),
|
||||
)
|
||||
.route(
|
||||
"/periods/{id}/plan-items/copy-previous",
|
||||
post(plan::copy_previous),
|
||||
)
|
||||
.route("/plan-items/{id}", patch(plan::update).delete(plan::delete))
|
||||
.route("/periods/{id}/notes", get(note::list).post(note::create))
|
||||
.route("/periods/{id}/summary", get(budget::summary))
|
||||
.route("/periods/{id}/review", get(review::get))
|
||||
.route("/periods/{id}/daily", get(stats::daily))
|
||||
.route("/stats/periods", get(stats::periods))
|
||||
.route("/reports/breakdown", get(reports::breakdown))
|
||||
.route("/export/entries.csv", get(transfer::export_entries))
|
||||
.route("/export/plan-items.csv", get(transfer::export_plan_items))
|
||||
.route(
|
||||
"/export/asset-transactions.csv",
|
||||
get(transfer::export_asset_txs),
|
||||
)
|
||||
.route(
|
||||
"/bank-import/preview",
|
||||
post(bank_import::preview).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/bank-import/commit",
|
||||
post(bank_import::commit).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/import/{kind}",
|
||||
// Büyük CSV (≤ 20.000 satır) için gövde sınırı 5 MB
|
||||
post(transfer::import).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/periods/{id}/budget",
|
||||
get(budget::get).put(budget::set_monthly),
|
||||
)
|
||||
.route("/periods/{id}/budget/weeks", put(budget::set_week))
|
||||
.route("/periods/{id}/budget/suggestion", get(budget::suggestion))
|
||||
.route("/notes/{id}", patch(note::update).delete(note::delete))
|
||||
.route(
|
||||
"/periods/{id}/entries",
|
||||
get(entry::list).post(entry::create),
|
||||
)
|
||||
.route("/entries/{id}", patch(entry::update).delete(entry::delete))
|
||||
.route("/entries/{id}/restore", post(entry::restore))
|
||||
.route("/assets", get(asset::list).post(asset::create))
|
||||
.route("/portfolio", get(rates::portfolio))
|
||||
.route("/savings/pending", get(asset::pending))
|
||||
.route("/periods/{id}/to-savings", post(asset::add_period_savings))
|
||||
.route("/rates", post(rates::create))
|
||||
.route("/rates/{id}", delete(rates::delete))
|
||||
.route("/assets/{id}", patch(asset::update))
|
||||
.route(
|
||||
"/assets/{id}/transactions",
|
||||
get(asset::list_txs).post(asset::create_tx),
|
||||
)
|
||||
.route(
|
||||
"/asset-transactions/{id}",
|
||||
patch(asset::update_tx).delete(asset::delete_tx),
|
||||
)
|
||||
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
|
||||
.fallback(|| async { AppError::NotFound });
|
||||
|
||||
Router::new().nest("/api", api).with_state(state)
|
||||
}
|
||||
|
||||
@@ -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,42 @@
|
||||
//! Derlenmiş arayüzün (frontend/dist) sunulması — tek sayfa uygulama (SPA).
|
||||
//!
|
||||
//! - `/assets/*` : adları içerik özetli (hash) → 1 yıl önbellek, `immutable`
|
||||
//! - diğer dosyalar (favicon, ikonlar): 1 saat önbellek
|
||||
//! - olmayan yol (`/defter`, `/plan` ...) → `index.html` (yönlendirmeyi React Router yapar)
|
||||
//! - HTML hiç önbelleğe alınmaz → yeni sürüm yayınlanınca hemen yüklenir
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::Request,
|
||||
http::{HeaderValue, header},
|
||||
middleware::{self, Next},
|
||||
response::Response,
|
||||
};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
pub fn router(dir: &Path) -> Router {
|
||||
let files = ServeDir::new(dir).fallback(ServeFile::new(dir.join("index.html")));
|
||||
Router::new()
|
||||
.fallback_service(files)
|
||||
.layer(middleware::from_fn(cache_headers))
|
||||
}
|
||||
|
||||
async fn cache_headers(req: Request, next: Next) -> Response {
|
||||
let is_asset = req.uri().path().starts_with("/assets/");
|
||||
let mut res = next.run(req).await;
|
||||
|
||||
let is_html = res
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.is_some_and(|v| v.as_bytes().starts_with(b"text/html"));
|
||||
let policy = match (is_html, is_asset) {
|
||||
(true, _) => "no-cache",
|
||||
(false, true) => "public, max-age=31536000, immutable",
|
||||
(false, false) => "public, max-age=3600",
|
||||
};
|
||||
res.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static(policy));
|
||||
res
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use mongodb::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::RegistrationMode,
|
||||
db::{col, is_duplicate_key},
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
@@ -28,6 +29,21 @@ pub struct NewAccount {
|
||||
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,
|
||||
@@ -36,7 +52,16 @@ pub struct CreatedAccount {
|
||||
|
||||
/// 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, acc: NewAccount) -> AppResult<CreatedAccount> {
|
||||
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?;
|
||||
@@ -95,6 +120,10 @@ async fn create_in_tx(
|
||||
.insert_many(seed::payment_methods(household_id))
|
||||
.session(&mut *session)
|
||||
.await?;
|
||||
docs(col::ASSETS)
|
||||
.insert_many(seed::assets(household_id))
|
||||
.session(&mut *session)
|
||||
.await?;
|
||||
(household_id, Role::Owner)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
//! Banka / kredi kartı ekstresi içe aktarma (kullanıcı isteği; Faz 9 Adım 4).
|
||||
//!
|
||||
//! Dosya tarayıcıda okunur (Excel/CSV) ve sütunları eşleştirilir; buraya yalnızca
|
||||
//! **tarih, açıklama, tutar, yön** gelir. Her satır bir türe ayrılır:
|
||||
//! - `expense` → harcama (kategori gerekir)
|
||||
//! - `refund` → **iade**: aynı kategoride eksi tutarlı kayıt (harcamadan düşer)
|
||||
//! - `income` → dönemin planına **gelir** kalemi
|
||||
//! - `ignore` → aktarılmaz (kart borcu ödemesi, kendi hesaplar arası virman…)
|
||||
//!
|
||||
//! Dönem: satırın tarihini içeren yazılabilir dönem; yoksa takvim ayı kadar **arşiv** dönemi
|
||||
//! açılır (var olan dönemlerle çakışmayacak şekilde kırpılır). Kapalı dönem ve gelecek tarih → hata.
|
||||
//! Tekrar: `import_key` (tarih|yön|tutar|sadeleştirilmiş açıklama) — aynı ekstre ya da örtüşen
|
||||
//! iki ekstre yüklense de çift kayıt olmaz. Öneri: hanenin öğrenilmiş kuralları (açıklamanın ilk
|
||||
//! iki anlamlı kelimesi), yoksa hazır ipuçları ("kredi kartı ödemesi" → yoksay), yoksa yön.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use time::Date;
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
period::Period,
|
||||
plan_item::PlanKind,
|
||||
},
|
||||
services::{
|
||||
calendar::{format_date, parse_date, today_tr},
|
||||
catalog::{self, Catalog},
|
||||
entry::MAX_DESCRIPTION,
|
||||
period,
|
||||
plan::{self, NewItem},
|
||||
},
|
||||
};
|
||||
|
||||
pub const MAX_ROWS: usize = 5_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Source {
|
||||
/// Kredi kartı: çıkış = harcama, giriş = iade ya da ödeme
|
||||
Card,
|
||||
/// Banka hesabı: çıkış = harcama, giriş = gelir
|
||||
Account,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Direction {
|
||||
In,
|
||||
Out,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Kind {
|
||||
Expense,
|
||||
Refund,
|
||||
Income,
|
||||
Ignore,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Expense => "expense",
|
||||
Kind::Refund => "refund",
|
||||
Kind::Income => "income",
|
||||
Kind::Ignore => "ignore",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Row {
|
||||
pub date: String,
|
||||
pub description: String,
|
||||
/// Kuruş, > 0
|
||||
pub amount: i64,
|
||||
pub direction: Direction,
|
||||
/// Commit'te kullanıcının seçimi
|
||||
pub kind: Option<Kind>,
|
||||
pub category_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
// ---------- Metin sadeleştirme ----------
|
||||
|
||||
/// Türkçe harfleri ASCII'ye indir, küçült (bankalar çoğu zaman "ODEME", "SUT" yazar)
|
||||
pub fn fold(s: &str) -> String {
|
||||
s.chars()
|
||||
// "İ".to_lowercase() = "i" + birleşik nokta (U+0307) → önce doğrudan "i"
|
||||
.map(|c| if c == 'İ' { 'i' } else { c })
|
||||
.flat_map(|c| c.to_lowercase())
|
||||
.filter(|c| *c != '\u{307}')
|
||||
.map(|c| match c {
|
||||
'ı' | 'i' | 'î' => 'i',
|
||||
'ş' => 's',
|
||||
'ğ' => 'g',
|
||||
'ü' | 'û' => 'u',
|
||||
'ö' => 'o',
|
||||
'ç' => 'c',
|
||||
'â' => 'a',
|
||||
c => c,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Anlamlı kelimeler: harf/rakam dışı ayırıcı; yalnızca rakam ve tek harf atılır
|
||||
fn words(desc: &str) -> Vec<String> {
|
||||
fold(desc)
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| w.len() >= 2 && !w.chars().all(|c| c.is_ascii_digit()))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Kural anahtarı: ilk iki anlamlı kelime (`MIGROS 1234 ISTANBUL TR` → `migros istanbul`)
|
||||
pub fn rule_key(desc: &str) -> String {
|
||||
words(desc)
|
||||
.into_iter()
|
||||
.take(2)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Tekrar anahtarı (tüm açıklama, sadeleştirilmiş)
|
||||
pub fn import_key(date: &str, dir: Direction, amount: i64, desc: &str) -> String {
|
||||
let d = match dir {
|
||||
Direction::In => "in",
|
||||
Direction::Out => "out",
|
||||
};
|
||||
format!("{date}|{d}|{amount}|{}", words(desc).join(" "))
|
||||
}
|
||||
|
||||
/// Hazır öneri (kural yoksa): ödeme/virman → yoksay; kartta giriş → iade; hesapta giriş → gelir
|
||||
pub fn default_kind(source: Source, dir: Direction, desc: &str) -> Kind {
|
||||
let f = words(desc).join(" ");
|
||||
let transfer = [
|
||||
"kredi karti",
|
||||
"kart odeme",
|
||||
"kk odeme",
|
||||
"virman",
|
||||
"hesaplar arasi",
|
||||
"kendi hesab",
|
||||
"hesabima",
|
||||
"borc odeme",
|
||||
"odemeniz icin",
|
||||
"tahsilat",
|
||||
]
|
||||
.iter()
|
||||
.any(|h| f.contains(h));
|
||||
match (source, dir) {
|
||||
_ if transfer => Kind::Ignore,
|
||||
(_, Direction::Out) => Kind::Expense,
|
||||
(Source::Card, Direction::In) => {
|
||||
if f.contains("odeme") {
|
||||
Kind::Ignore
|
||||
} else {
|
||||
Kind::Refund
|
||||
}
|
||||
}
|
||||
(Source::Account, Direction::In) => Kind::Income,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Dönem ----------
|
||||
|
||||
fn first_of_month(d: Date) -> Date {
|
||||
d.replace_day(1).expect("1. gün")
|
||||
}
|
||||
|
||||
fn next_month(d: Date) -> Date {
|
||||
let (y, m) = if d.month() == time::Month::December {
|
||||
(d.year() + 1, time::Month::January)
|
||||
} else {
|
||||
(d.year(), d.month().next())
|
||||
};
|
||||
Date::from_calendar_date(y, m, 1).expect("geçerli ay")
|
||||
}
|
||||
|
||||
/// Satırın dönemi
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Target {
|
||||
/// Var olan yazılabilir dönem (açık, düzeltmede ya da arşiv)
|
||||
Existing {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
},
|
||||
/// Açılacak arşiv dönemi [start, end)
|
||||
Archive {
|
||||
start: String,
|
||||
end: String,
|
||||
},
|
||||
Blocked(String),
|
||||
}
|
||||
|
||||
/// Tarih → dönem: içeren dönem; yoksa takvim ayı, var olan dönemlerin dışında kalan kısmı
|
||||
pub fn target(periods: &[Period], date: &str, today: &str) -> Target {
|
||||
if date > today {
|
||||
return Target::Blocked("Gelecek tarihli satır".into());
|
||||
}
|
||||
if let Some(p) = periods
|
||||
.iter()
|
||||
.find(|p| p.start_date.as_str() <= date && date < p.end_date.as_str())
|
||||
{
|
||||
return if p.status.is_writable() {
|
||||
Target::Existing {
|
||||
id: p.id,
|
||||
name: p.name.clone(),
|
||||
}
|
||||
} else {
|
||||
Target::Blocked(format!(
|
||||
"Dönem kapalı ({}); düzeltme için yeniden açın",
|
||||
p.name
|
||||
))
|
||||
};
|
||||
}
|
||||
let d = parse_date(date).expect("doğrulanmış tarih");
|
||||
let (ms, me) = (format_date(first_of_month(d)), format_date(next_month(d)));
|
||||
// Ayın içinde, tarihten önce biten en geç dönem / tarihten sonra başlayan en erken dönem
|
||||
let start = periods
|
||||
.iter()
|
||||
.map(|p| p.end_date.as_str())
|
||||
.filter(|e| *e <= date && *e > ms.as_str())
|
||||
.max()
|
||||
.map_or(ms.clone(), str::to_string);
|
||||
let end = periods
|
||||
.iter()
|
||||
.map(|p| p.start_date.as_str())
|
||||
.filter(|s| *s > date && *s < me.as_str())
|
||||
.min()
|
||||
.map_or(me.clone(), str::to_string);
|
||||
Target::Archive { start, end }
|
||||
}
|
||||
|
||||
// ---------- Kurallar ----------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Rule {
|
||||
pattern: String,
|
||||
kind: Kind,
|
||||
#[serde(default)]
|
||||
category_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
async fn rules(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, Rule>> {
|
||||
let mut c = db
|
||||
.collection::<Rule>(col::IMPORT_RULES)
|
||||
.find(scope(household_id))
|
||||
.await?;
|
||||
let mut out = HashMap::new();
|
||||
while c.advance().await? {
|
||||
let r = c.deserialize_current()?;
|
||||
out.insert(r.pattern.clone(), r);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Var olan içe aktarma anahtarları (harcama + plan kalemi) → adet
|
||||
async fn existing_keys(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, usize>> {
|
||||
let mut out = HashMap::new();
|
||||
for coll in [col::ENTRIES, col::PLAN_ITEMS] {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("import_key", doc! { "$type": "string" });
|
||||
let mut c = db
|
||||
.collection::<Document>(coll)
|
||||
.find(f)
|
||||
.projection(doc! { "import_key": 1 })
|
||||
.await?;
|
||||
while c.advance().await? {
|
||||
if let Ok(k) = c.current().get_str("import_key") {
|
||||
*out.entry(k.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn take(m: &mut HashMap<String, usize>, k: &str) -> bool {
|
||||
match m.get_mut(k) {
|
||||
Some(n) if *n > 0 => {
|
||||
*n -= 1;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(r: &Row) -> Result<(), String> {
|
||||
parse_date(&r.date).ok_or_else(|| format!("Tarih geçersiz: {}", r.date))?;
|
||||
if r.amount <= 0 {
|
||||
return Err("Tutar sıfırdan büyük olmalı".into());
|
||||
}
|
||||
period::validate_amount(r.amount).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- Önizleme ----------
|
||||
|
||||
pub struct PreviewRow {
|
||||
pub kind: Kind,
|
||||
pub category_id: Option<Uuid>,
|
||||
/// Öneri hanenin öğrenilmiş kuralından mı?
|
||||
pub from_rule: bool,
|
||||
pub duplicate: bool,
|
||||
pub target: Target,
|
||||
}
|
||||
|
||||
pub struct Preview {
|
||||
pub rows: Vec<PreviewRow>,
|
||||
/// Açılacak arşiv dönemleri [start, end)
|
||||
pub archives: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn check_rows(rows: &[Row]) -> AppResult<()> {
|
||||
if rows.is_empty() {
|
||||
return Err(AppError::BadRequest("Aktarılacak satır yok".into()));
|
||||
}
|
||||
if rows.len() > MAX_ROWS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Bir seferde en fazla {MAX_ROWS} satır aktarılabilir"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn preview(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
source: Source,
|
||||
rows: &[Row],
|
||||
) -> AppResult<Preview> {
|
||||
check_rows(rows)?;
|
||||
let periods = period::list(db, household_id).await?;
|
||||
let rules = rules(db, household_id).await?;
|
||||
let mut keys = existing_keys(db, household_id).await?;
|
||||
let today = format_date(today_tr());
|
||||
let mut archives: Vec<(String, String)> = Vec::new();
|
||||
let out = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let target = match validate(r) {
|
||||
Err(e) => Target::Blocked(e),
|
||||
Ok(()) => target(&periods, &r.date, &today),
|
||||
};
|
||||
if let Target::Archive { start, end } = &target
|
||||
&& !archives.iter().any(|(s, _)| s == start)
|
||||
{
|
||||
archives.push((start.clone(), end.clone()));
|
||||
}
|
||||
let duplicate = matches!(target, Target::Existing { .. } | Target::Archive { .. })
|
||||
&& take(
|
||||
&mut keys,
|
||||
&import_key(&r.date, r.direction, r.amount, &r.description),
|
||||
);
|
||||
let rule = rules.get(&rule_key(&r.description));
|
||||
PreviewRow {
|
||||
kind: rule.map_or_else(
|
||||
|| default_kind(source, r.direction, &r.description),
|
||||
|x| x.kind,
|
||||
),
|
||||
category_id: rule.and_then(|x| x.category_id),
|
||||
from_rule: rule.is_some(),
|
||||
duplicate,
|
||||
target,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
archives.sort();
|
||||
Ok(Preview {
|
||||
rows: out,
|
||||
archives,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Aktarma ----------
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Report {
|
||||
pub entries: usize,
|
||||
pub refunds: usize,
|
||||
pub incomes: usize,
|
||||
pub ignored: usize,
|
||||
pub duplicates: usize,
|
||||
/// Açılan arşiv dönemlerinin adları
|
||||
pub archives: Vec<String>,
|
||||
/// (satır sırası 0'dan, neden)
|
||||
pub errors: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
pub async fn commit(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
payment_method_id: Option<Uuid>,
|
||||
rows: &[Row],
|
||||
) -> AppResult<Report> {
|
||||
check_rows(rows)?;
|
||||
if let Some(pm) = payment_method_id {
|
||||
catalog::find(db, household_id, Catalog::PaymentMethods, pm)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("Ödeme türü bulunamadı".into()))?;
|
||||
}
|
||||
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
|
||||
let mut periods = period::list(db, household_id).await?;
|
||||
let mut keys = existing_keys(db, household_id).await?;
|
||||
let today = format_date(today_tr());
|
||||
let mut report = Report::default();
|
||||
let mut entries: Vec<Document> = Vec::new();
|
||||
let mut incomes: Vec<(usize, Uuid, NewItem, String)> = Vec::new();
|
||||
let mut learned: HashMap<String, (Kind, Option<Uuid>)> = HashMap::new();
|
||||
|
||||
for (i, r) in rows.iter().enumerate() {
|
||||
let kind = r.kind.unwrap_or(Kind::Ignore);
|
||||
let pattern = rule_key(&r.description);
|
||||
if kind == Kind::Ignore {
|
||||
report.ignored += 1;
|
||||
if !pattern.is_empty() {
|
||||
learned.insert(pattern, (kind, None));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = validate(r) {
|
||||
report.errors.push((i, e));
|
||||
continue;
|
||||
}
|
||||
let key = import_key(&r.date, r.direction, r.amount, &r.description);
|
||||
let pid = match target(&periods, &r.date, &today) {
|
||||
Target::Blocked(e) => {
|
||||
report.errors.push((i, e));
|
||||
continue;
|
||||
}
|
||||
Target::Existing { id, .. } => id,
|
||||
Target::Archive { start, end } => {
|
||||
let p = period::create_archive(
|
||||
db,
|
||||
household_id,
|
||||
parse_date(&start).expect("tarih"),
|
||||
parse_date(&end).expect("tarih"),
|
||||
)
|
||||
.await?;
|
||||
report.archives.push(p.name.clone());
|
||||
let id = p.id;
|
||||
periods.push(p);
|
||||
id
|
||||
}
|
||||
};
|
||||
if take(&mut keys, &key) {
|
||||
report.duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
let desc: String = r.description.trim().chars().take(MAX_DESCRIPTION).collect();
|
||||
match kind {
|
||||
Kind::Expense | Kind::Refund => {
|
||||
let Some(cat) = r.category_id.and_then(|c| cats.iter().find(|x| x.id == c)) else {
|
||||
report.errors.push((i, "Kategori seçin".into()));
|
||||
continue;
|
||||
};
|
||||
let Some(group) = cat.group else {
|
||||
report.errors.push((i, "Kategorinin grubu yok".into()));
|
||||
continue;
|
||||
};
|
||||
let amount = if kind == Kind::Refund {
|
||||
-r.amount
|
||||
} else {
|
||||
r.amount
|
||||
};
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("period_id", pid);
|
||||
d.insert("date", &r.date);
|
||||
d.insert("amount", amount);
|
||||
d.insert("group", group.as_str());
|
||||
d.insert("category_id", cat.id);
|
||||
d.insert(
|
||||
"payment_method_id",
|
||||
payment_method_id.map_or(Bson::Null, Bson::from),
|
||||
);
|
||||
d.insert(
|
||||
"description",
|
||||
if desc.is_empty() {
|
||||
Bson::Null
|
||||
} else {
|
||||
Bson::String(desc)
|
||||
},
|
||||
);
|
||||
d.insert("created_by", user_id);
|
||||
d.insert("import_key", &key);
|
||||
entries.push(d);
|
||||
if kind == Kind::Refund {
|
||||
report.refunds += 1;
|
||||
} else {
|
||||
report.entries += 1;
|
||||
}
|
||||
if !pattern.is_empty() {
|
||||
learned.insert(pattern, (kind, Some(cat.id)));
|
||||
}
|
||||
}
|
||||
Kind::Income => {
|
||||
// Plan kalemi adı en fazla 60 karakter
|
||||
let name: String = if desc.is_empty() {
|
||||
"Gelir".into()
|
||||
} else {
|
||||
desc.chars().take(60).collect()
|
||||
};
|
||||
incomes.push((
|
||||
i,
|
||||
pid,
|
||||
NewItem {
|
||||
kind: PlanKind::Income,
|
||||
name,
|
||||
amount: r.amount,
|
||||
date: Some(r.date.clone()),
|
||||
},
|
||||
key,
|
||||
));
|
||||
if !pattern.is_empty() {
|
||||
learned.insert(pattern, (kind, None));
|
||||
}
|
||||
}
|
||||
Kind::Ignore => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
if !entries.is_empty() {
|
||||
db.collection::<Document>(col::ENTRIES)
|
||||
.insert_many(entries)
|
||||
.await?;
|
||||
}
|
||||
for (i, pid, item, key) in incomes {
|
||||
match plan::create(db, household_id, pid, item).await {
|
||||
Ok(p) => {
|
||||
db.collection::<Document>(col::PLAN_ITEMS)
|
||||
.update_one(doc! { "_id": p.id }, doc! { "$set": { "import_key": key } })
|
||||
.await?;
|
||||
report.incomes += 1;
|
||||
}
|
||||
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => report.errors.push((i, m)),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Kurallar: bu aktarımdaki seçimler sonraki ekstreler için öneri olur
|
||||
let ts = now();
|
||||
for (pattern, (kind, category_id)) in learned {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("pattern", &pattern);
|
||||
db.collection::<Document>(col::IMPORT_RULES)
|
||||
.update_one(
|
||||
f,
|
||||
doc! {
|
||||
"$set": {
|
||||
"kind": kind.as_str(),
|
||||
"category_id": category_id.map_or(Bson::Null, Bson::from),
|
||||
"updated_at": ts,
|
||||
},
|
||||
"$inc": { "uses": 1, "version": 1 },
|
||||
"$setOnInsert": { "_id": crate::models::common::new_id(), "created_at": ts },
|
||||
},
|
||||
)
|
||||
.upsert(true)
|
||||
.await?;
|
||||
}
|
||||
report.errors.sort_by_key(|(i, _)| *i);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folding_and_keys() {
|
||||
assert_eq!(fold("ÖDEME Şükrü İĞNE"), "odeme sukru igne");
|
||||
assert_eq!(rule_key("MIGROS 1234 ISTANBUL TR"), "migros istanbul");
|
||||
assert_eq!(rule_key("A101 MAGAZACILIK*456"), "a101 magazacilik");
|
||||
assert_eq!(rule_key("12/11 *"), "");
|
||||
assert_eq!(
|
||||
import_key("2026-11-13", Direction::Out, 4590, " Migros ŞUBE 12 "),
|
||||
"2026-11-13|out|4590|migros sube"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_kinds() {
|
||||
use Direction::*;
|
||||
use Source::*;
|
||||
assert_eq!(default_kind(Card, Out, "MIGROS ISTANBUL"), Kind::Expense);
|
||||
assert_eq!(default_kind(Card, In, "IADE MIGROS"), Kind::Refund);
|
||||
assert_eq!(
|
||||
default_kind(Card, In, "ÖDEMENİZ İÇİN TEŞEKKÜRLER"),
|
||||
Kind::Ignore
|
||||
);
|
||||
assert_eq!(default_kind(Account, In, "MAAŞ ÖDEMESİ ACME"), Kind::Income);
|
||||
assert_eq!(
|
||||
default_kind(Account, Out, "KREDİ KARTI ÖDEMESİ"),
|
||||
Kind::Ignore
|
||||
);
|
||||
assert_eq!(
|
||||
default_kind(Account, Out, "Hesaplar arası virman"),
|
||||
Kind::Ignore
|
||||
);
|
||||
assert_eq!(
|
||||
default_kind(Account, Out, "ELEKTRIK FATURASI"),
|
||||
Kind::Expense
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,181 @@
|
||||
//! Takvim hesapları: tarih okuma/yazma, dönem haftaları, Türkçe ay adları.
|
||||
//!
|
||||
//! Tarihler veritabanında `"YYYY-MM-DD"` metni olarak tutulur (DATA_MODEL.md §1.3).
|
||||
//! Dönem bitişi **hariçtir**: `2026-12-01 – 2027-01-01` = 1–31 Aralık.
|
||||
|
||||
use time::{Date, Duration, Month};
|
||||
|
||||
const MONTHS: [&str; 12] = [
|
||||
"Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz", "Ağustos", "Eylül", "Ekim",
|
||||
"Kasım", "Aralık",
|
||||
];
|
||||
const MONTHS_SHORT: [&str; 12] = [
|
||||
"Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara",
|
||||
];
|
||||
|
||||
/// `"2026-12-09"` → `Date`. Biçim ya da tarih geçersizse `None` (ör. `2026-02-30`).
|
||||
pub fn parse_date(s: &str) -> Option<Date> {
|
||||
let b = s.as_bytes();
|
||||
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
|
||||
return None;
|
||||
}
|
||||
let year: i32 = s[0..4].parse().ok()?;
|
||||
let month: u8 = s[5..7].parse().ok()?;
|
||||
let day: u8 = s[8..10].parse().ok()?;
|
||||
Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
|
||||
}
|
||||
|
||||
/// `Date` → `"2026-12-09"`
|
||||
/// Türkiye'de bugünün tarihi (UTC+3, yaz saati yok). Sunucu UTC'de çalışsa da gün doğru olsun.
|
||||
pub fn today_tr() -> Date {
|
||||
time::OffsetDateTime::now_utc()
|
||||
.to_offset(time::UtcOffset::from_hms(3, 0, 0).expect("geçerli ofset"))
|
||||
.date()
|
||||
}
|
||||
|
||||
pub fn format_date(d: Date) -> String {
|
||||
format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day())
|
||||
}
|
||||
|
||||
/// Bitişi hariç aralığın gün sayısı.
|
||||
pub fn days_between(start: Date, end_exclusive: Date) -> i64 {
|
||||
(end_exclusive - start).whole_days()
|
||||
}
|
||||
|
||||
/// Dönem içindeki bir hafta. Son gün dahildir (`last`), gösterim kolaylığı için.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Week {
|
||||
/// 1'den başlar
|
||||
pub index: u32,
|
||||
pub start: Date,
|
||||
pub last: Date,
|
||||
pub days: u32,
|
||||
}
|
||||
|
||||
/// Dönemi Pazartesi başlangıçlı haftalara böler.
|
||||
/// Baştaki ve sondaki kısmi haftalar ayrı hafta sayılır (KAKEIBO_PROMPT §6).
|
||||
pub fn weeks(start: Date, end_exclusive: Date) -> Vec<Week> {
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = start;
|
||||
while cursor < end_exclusive {
|
||||
// Bir sonraki Pazartesi (ya da dönem sonu, hangisi önce gelirse)
|
||||
let to_monday = 7 - i64::from(cursor.weekday().number_days_from_monday());
|
||||
let next = (cursor + Duration::days(to_monday)).min(end_exclusive);
|
||||
out.push(Week {
|
||||
index: out.len() as u32 + 1,
|
||||
start: cursor,
|
||||
last: next - Duration::days(1),
|
||||
days: days_between(cursor, next) as u32,
|
||||
});
|
||||
cursor = next;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Kullanıcı ad vermezse dönem adı:
|
||||
/// - takvim ayının tamamıysa: `"Aralık 2026"`
|
||||
/// - değilse: `"15 Ara 2026 – 14 Oca 2027"`
|
||||
pub fn default_period_name(start: Date, end_exclusive: Date) -> String {
|
||||
let last = end_exclusive - Duration::days(1);
|
||||
let full_month = start.day() == 1
|
||||
&& last.month() == start.month()
|
||||
&& last.year() == start.year()
|
||||
&& (last + Duration::days(1)).day() == 1;
|
||||
if full_month {
|
||||
return format!("{} {}", MONTHS[month_idx(start)], start.year());
|
||||
}
|
||||
format!(
|
||||
"{} {} {} – {} {} {}",
|
||||
start.day(),
|
||||
MONTHS_SHORT[month_idx(start)],
|
||||
start.year(),
|
||||
last.day(),
|
||||
MONTHS_SHORT[month_idx(last)],
|
||||
last.year()
|
||||
)
|
||||
}
|
||||
|
||||
fn month_idx(d: Date) -> usize {
|
||||
u8::from(d.month()) as usize - 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn d(s: &str) -> Date {
|
||||
parse_date(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_and_format() {
|
||||
assert_eq!(format_date(d("2026-12-09")), "2026-12-09");
|
||||
for bad in [
|
||||
"2026-02-30",
|
||||
"2026-13-01",
|
||||
"26-12-09",
|
||||
"2026/12/09",
|
||||
"",
|
||||
"2026-12-9",
|
||||
] {
|
||||
assert!(parse_date(bad).is_none(), "{bad} geçersiz olmalı");
|
||||
}
|
||||
assert!(parse_date("2028-02-29").is_some(), "artık yıl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn december_2026_weeks() {
|
||||
// 1 Aralık 2026 Salı → ilk hafta 6 gün (Sal–Paz), son hafta 4 gün (Pzt–Per)
|
||||
let w = weeks(d("2026-12-01"), d("2027-01-01"));
|
||||
let summary: Vec<(String, String, u32)> = w
|
||||
.iter()
|
||||
.map(|w| (format_date(w.start), format_date(w.last), w.days))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
summary,
|
||||
vec![
|
||||
("2026-12-01".into(), "2026-12-06".into(), 6),
|
||||
("2026-12-07".into(), "2026-12-13".into(), 7),
|
||||
("2026-12-14".into(), "2026-12-20".into(), 7),
|
||||
("2026-12-21".into(), "2026-12-27".into(), 7),
|
||||
("2026-12-28".into(), "2026-12-31".into(), 4),
|
||||
]
|
||||
);
|
||||
assert_eq!(w.iter().map(|w| w.days).sum::<u32>(), 31);
|
||||
assert_eq!(w.last().unwrap().index, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weeks_edge_cases() {
|
||||
// Pazartesi başlayan, tam 2 hafta
|
||||
let w = weeks(d("2026-11-30"), d("2026-12-14"));
|
||||
assert_eq!(w.len(), 2);
|
||||
assert!(w.iter().all(|w| w.days == 7));
|
||||
// Tek gün (Pazar)
|
||||
let w = weeks(d("2026-12-06"), d("2026-12-07"));
|
||||
assert_eq!((w.len(), w[0].days), (1, 1));
|
||||
// Boş aralık
|
||||
assert!(weeks(d("2026-12-07"), d("2026-12-07")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn period_names() {
|
||||
assert_eq!(
|
||||
default_period_name(d("2026-12-01"), d("2027-01-01")),
|
||||
"Aralık 2026"
|
||||
);
|
||||
assert_eq!(
|
||||
default_period_name(d("2027-02-01"), d("2027-03-01")),
|
||||
"Şubat 2027"
|
||||
);
|
||||
assert_eq!(
|
||||
default_period_name(d("2026-12-15"), d("2027-01-15")),
|
||||
"15 Ara 2026 – 14 Oca 2027"
|
||||
);
|
||||
// Ayın 1'inde başlayıp ay ortasında biten
|
||||
assert_eq!(
|
||||
default_period_name(d("2026-12-01"), d("2026-12-16")),
|
||||
"1 Ara 2026 – 15 Ara 2026"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
|
||||
|
||||
pub mod account;
|
||||
pub mod asset;
|
||||
pub mod bank_import;
|
||||
pub mod budget;
|
||||
pub mod calendar;
|
||||
pub mod catalog;
|
||||
pub mod closing;
|
||||
pub mod csv_io;
|
||||
pub mod entry;
|
||||
pub mod holding;
|
||||
pub mod invite;
|
||||
pub mod money;
|
||||
pub mod note;
|
||||
pub mod period;
|
||||
pub mod plan;
|
||||
pub mod rate_sources;
|
||||
pub mod rates;
|
||||
pub mod reports;
|
||||
pub mod review;
|
||||
pub mod seed;
|
||||
pub mod stats;
|
||||
pub mod transfer;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Yeni hane için örnek alt kategoriler ve ödeme türleri (DATA_MODEL.md §3.5, §3.6).
|
||||
//! Yeni hane için örnek alt kategoriler, ödeme türleri ve varsayılan varlıklar
|
||||
//! (DATA_MODEL.md §3.5, §3.6, §3.12).
|
||||
|
||||
use mongodb::bson::{Document, Uuid};
|
||||
|
||||
@@ -29,6 +30,35 @@ const PAYMENT_METHODS: &[(&str, &str)] = &[
|
||||
("Havale/EFT", "🔁"),
|
||||
];
|
||||
|
||||
/// (tür, kod, ad, birim) — kurlar Faz 8 Adım 2'de bu kodlarla eşleşir
|
||||
const ASSETS: &[(&str, &str, &str, &str)] = &[
|
||||
("cash", "TRY", "Türk Lirası", "₺"),
|
||||
("gold", "XAU_GRAM", "Gram Altın", "gr"),
|
||||
("gold", "XAU_CEYREK", "Çeyrek Altın", "adet"),
|
||||
("gold", "XAU_YARIM", "Yarım Altın", "adet"),
|
||||
("gold", "XAU_TAM", "Tam Altın", "adet"),
|
||||
("gold", "XAU_ONS", "Ons Altın", "ons"),
|
||||
("fx", "USD", "ABD Doları", "$"),
|
||||
("fx", "EUR", "Euro", "€"),
|
||||
];
|
||||
|
||||
pub fn assets(household_id: Uuid) -> Vec<Document> {
|
||||
ASSETS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (kind, code, name, unit))| {
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("kind", *kind);
|
||||
d.insert("code", *code);
|
||||
d.insert("name", *name);
|
||||
d.insert("unit_label", *unit);
|
||||
d.insert("sort_order", (i as i32 + 1) * 10);
|
||||
d.insert("is_active", true);
|
||||
d
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn categories(household_id: Uuid) -> Vec<Document> {
|
||||
CATEGORIES
|
||||
.iter()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+13
-1
@@ -1,6 +1,6 @@
|
||||
//! Tüm route'ların paylaştığı uygulama durumu.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use mongodb::Database;
|
||||
|
||||
@@ -17,3 +17,15 @@ pub struct AppState {
|
||||
pub jwt: Arc<Jwt>,
|
||||
pub login_limiter: Arc<LoginLimiter>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: Database, config: Config) -> Self {
|
||||
Self {
|
||||
db,
|
||||
jwt: Arc::new(Jwt::new(&config.jwt_secret)),
|
||||
// Aynı e-postaya 15 dakikada en fazla 5 hatalı giriş denemesi
|
||||
login_limiter: Arc::new(LoginLimiter::new(5, Duration::from_secs(15 * 60))),
|
||||
config: Arc::new(config),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,405 @@
|
||||
//! Bütçe ve dönem özeti.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use mongodb::bson::{Document, Uuid, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open_december(app: &TestApp, s: &Session, savings_goal: i64) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": savings_goal }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn add_item(app: &TestApp, s: &Session, pid: &str, kind: &str, amount: i64) {
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
s,
|
||||
json!({ "kind": kind, "name": format!("{kind}-{amount}"), "amount": amount }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
|
||||
fn group<'a>(budget: &'a Value, code: &str) -> &'a Value {
|
||||
budget
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|g| g["group"] == code)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn week_amounts(g: &Value) -> Vec<i64> {
|
||||
g["weeks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|w| w["amount"].as_i64().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn summary_computes_spendable_with_carryovers() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_december(&app, &s, 500_000).await;
|
||||
|
||||
// Boş dönem: gelir yok → harcanabilir = -birikim
|
||||
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||
assert_eq!(sum["spendable"], -500_000);
|
||||
|
||||
add_item(&app, &s, &pid, "income", 4_500_000).await;
|
||||
add_item(&app, &s, &pid, "income", 300_000).await;
|
||||
add_item(&app, &s, &pid, "expense", 1_500_000).await;
|
||||
add_item(&app, &s, &pid, "expense", 45_000).await;
|
||||
|
||||
// Önceki dönemden devir (Faz 6'da kapanış sihirbazı yazacak)
|
||||
let hid = Uuid::parse_str(&s.household_id).unwrap();
|
||||
let to = Uuid::parse_str(&pid).unwrap();
|
||||
app.db
|
||||
.collection::<Document>("carryovers")
|
||||
.insert_many([
|
||||
doc! { "household_id": hid, "to_period_id": to, "kind": "debt", "amount": 100_000_i64, "deleted_at": null },
|
||||
doc! { "household_id": hid, "to_period_id": to, "kind": "surplus", "amount": 20_000_i64, "deleted_at": null },
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (st, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert_eq!(
|
||||
sum,
|
||||
json!({
|
||||
"income_total": 4_800_000,
|
||||
"fixed_expense_total": 1_545_000,
|
||||
"savings_goal": 500_000,
|
||||
"carryover_debt": 100_000,
|
||||
"carryover_surplus": 20_000,
|
||||
// 4.800.000 − 1.545.000 − 500.000 − 100.000 + 20.000
|
||||
"spendable": 2_675_000,
|
||||
"budget_total": 0,
|
||||
"unallocated": 2_675_000,
|
||||
"spent_total": 0,
|
||||
"remaining": 2_675_000
|
||||
})
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_suggestion_splits_spendable() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_december(&app, &s, 0).await;
|
||||
let url = format!("/periods/{pid}/budget/suggestion?method=default");
|
||||
|
||||
// Gelir yokken öneri yok
|
||||
let (_, sug) = app.get(&url, &s).await;
|
||||
assert_eq!(sug["available"], false);
|
||||
assert!(sug["message"].as_str().unwrap().contains("Harcanabilir"));
|
||||
|
||||
add_item(&app, &s, &pid, "income", 1_234_567).await;
|
||||
let (_, sug) = app.get(&url, &s).await;
|
||||
assert_eq!(sug["available"], true);
|
||||
assert_eq!(
|
||||
sug["amounts"],
|
||||
json!({ "needs": 617_283, "wants": 370_370, "culture": 123_457, "unexpected": 123_457 })
|
||||
);
|
||||
|
||||
// Önceki dönem yokken previous/avg3 → veri yok
|
||||
for m in ["previous", "avg3"] {
|
||||
let (_, sug) = app
|
||||
.get(&format!("/periods/{pid}/budget/suggestion?method={m}"), &s)
|
||||
.await;
|
||||
assert_eq!(sug["available"], false, "{m}");
|
||||
}
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn monthly_budget_is_split_into_weeks_by_days() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_december(&app, &s, 0).await;
|
||||
add_item(&app, &s, &pid, "income", 300_000).await;
|
||||
let url = format!("/periods/{pid}/budget");
|
||||
|
||||
// Başta hepsi 0
|
||||
let (_, b) = app.get(&url, &s).await;
|
||||
assert_eq!(b.as_array().unwrap().len(), 4);
|
||||
assert_eq!(week_amounts(group(&b, "needs")), vec![0; 5]);
|
||||
|
||||
let (st, b) = app
|
||||
.put(
|
||||
&url,
|
||||
&s,
|
||||
json!({ "needs": 100_000, "wants": 50_000, "culture": 31, "unexpected": 0 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
let needs = group(&b, "needs");
|
||||
assert_eq!(needs["monthly"], 100_000);
|
||||
// Aralık: 6,7,7,7,4 gün → toplam tam 100.000
|
||||
assert_eq!(
|
||||
week_amounts(needs),
|
||||
vec![19_355, 22_581, 22_581, 22_580, 12_903]
|
||||
);
|
||||
assert_eq!(week_amounts(group(&b, "culture")), vec![6, 7, 7, 7, 4]);
|
||||
for g in b.as_array().unwrap() {
|
||||
assert_eq!(
|
||||
week_amounts(g).iter().sum::<i64>(),
|
||||
g["monthly"].as_i64().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
// Özet: 150.031 dağıtıldı, harcanabilir 300.000 → kalan 149.969
|
||||
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||
assert_eq!(
|
||||
(sum["budget_total"].as_i64(), sum["unallocated"].as_i64()),
|
||||
(Some(150_031), Some(149_969))
|
||||
);
|
||||
|
||||
// Fazla dağıtım → kalan negatif (arayüz uyarı gösterir)
|
||||
app.put(
|
||||
&url,
|
||||
&s,
|
||||
json!({ "needs": 400_000, "wants": 0, "culture": 0, "unexpected": 0 }),
|
||||
)
|
||||
.await;
|
||||
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||
assert_eq!(sum["unallocated"], -100_000);
|
||||
|
||||
// Negatif tutar → 400
|
||||
let (st, _) = app
|
||||
.put(
|
||||
&url,
|
||||
&s,
|
||||
json!({ "needs": -1, "wants": 0, "culture": 0, "unexpected": 0 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_week_override_redistributes_the_rest() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_december(&app, &s, 0).await;
|
||||
app.put(
|
||||
&format!("/periods/{pid}/budget"),
|
||||
&s,
|
||||
json!({ "needs": 100_000, "wants": 0, "culture": 0, "unexpected": 0 }),
|
||||
)
|
||||
.await;
|
||||
let weeks_url = format!("/periods/{pid}/budget/weeks");
|
||||
|
||||
// 2. hafta (7–13 Ara) elle 50.000 → kalan 50.000 diğer haftalara (6+7+7+4 = 24 gün)
|
||||
let (st, b) = app
|
||||
.put(
|
||||
&weeks_url,
|
||||
&s,
|
||||
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 50_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
let needs = group(&b, "needs");
|
||||
assert_eq!(
|
||||
week_amounts(needs),
|
||||
vec![12_500, 50_000, 14_584, 14_583, 8_333]
|
||||
);
|
||||
assert_eq!(week_amounts(needs).iter().sum::<i64>(), 100_000);
|
||||
let manual: Vec<bool> = needs["weeks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|w| w["is_manual"].as_bool().unwrap())
|
||||
.collect();
|
||||
assert_eq!(manual, vec![false, true, false, false, false]);
|
||||
assert_eq!(needs["weekly_over"], false);
|
||||
|
||||
// Aylık tutar değişince elle girilen hafta korunur
|
||||
let (_, b) = app
|
||||
.put(
|
||||
&format!("/periods/{pid}/budget"),
|
||||
&s,
|
||||
json!({ "needs": 74_000, "wants": 0, "culture": 0, "unexpected": 0 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(week_amounts(group(&b, "needs"))[1], 50_000);
|
||||
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
|
||||
|
||||
// Elle girilenler aylığı aşarsa uyarı; diğer haftalar 0
|
||||
let (_, b) = app
|
||||
.put(
|
||||
&weeks_url,
|
||||
&s,
|
||||
json!({ "group": "needs", "week_start": "2026-12-14", "amount": 30_000 }),
|
||||
)
|
||||
.await;
|
||||
let needs = group(&b, "needs");
|
||||
assert_eq!(needs["weekly_over"], true);
|
||||
assert_eq!(week_amounts(needs), vec![0, 50_000, 30_000, 0, 0]);
|
||||
|
||||
// null → otomatiğe dön
|
||||
app.put(
|
||||
&weeks_url,
|
||||
&s,
|
||||
json!({ "group": "needs", "week_start": "2026-12-14", "amount": null }),
|
||||
)
|
||||
.await;
|
||||
let (_, b) = app
|
||||
.put(
|
||||
&weeks_url,
|
||||
&s,
|
||||
json!({ "group": "needs", "week_start": "2026-12-07", "amount": null }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
|
||||
assert!(
|
||||
group(&b, "needs")["weeks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|w| w["is_manual"] == false)
|
||||
);
|
||||
|
||||
// Haftanın ilk günü olmayan tarih → 400
|
||||
let (st, _) = app
|
||||
.put(
|
||||
&weeks_url,
|
||||
&s,
|
||||
json!({ "group": "needs", "week_start": "2026-12-08", "amount": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn previous_and_avg3_suggestions_use_actual_spending() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let hid = Uuid::parse_str(&s.household_id).unwrap();
|
||||
|
||||
// Ekim ve Kasım kapalı, harcamaları var (Faz 5'te defterden gelecek)
|
||||
let mut closed = Vec::new();
|
||||
for (start, end) in [("2026-10-01", "2026-11-01"), ("2026-11-01", "2026-12-01")] {
|
||||
let (_, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": start, "end_date": end }),
|
||||
)
|
||||
.await;
|
||||
let id = Uuid::parse_str(p["id"].as_str().unwrap()).unwrap();
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
|
||||
.await
|
||||
.unwrap();
|
||||
closed.push(id);
|
||||
}
|
||||
let entry = |pid: Uuid, group: &str, amount: i64| {
|
||||
doc! { "household_id": hid, "period_id": pid, "group": group, "amount": amount, "date": "2026-10-10", "deleted_at": null }
|
||||
};
|
||||
app.db
|
||||
.collection::<Document>("entries")
|
||||
.insert_many([
|
||||
entry(closed[0], "needs", 100_000),
|
||||
entry(closed[0], "wants", 41_000),
|
||||
entry(closed[1], "needs", 200_000),
|
||||
entry(closed[1], "needs", 50_000),
|
||||
entry(closed[1], "culture", 10_000),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pid = open_december(&app, &s, 0).await;
|
||||
let (_, prev) = app
|
||||
.get(
|
||||
&format!("/periods/{pid}/budget/suggestion?method=previous"),
|
||||
&s,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(prev["available"], true, "{prev}");
|
||||
assert_eq!(
|
||||
prev["amounts"],
|
||||
json!({ "needs": 250_000, "wants": 0, "culture": 10_000, "unexpected": 0 })
|
||||
);
|
||||
|
||||
// avg3: 2 kapalı dönem var → ikiye bölünür (yuvarlayarak)
|
||||
let (_, avg) = app
|
||||
.get(&format!("/periods/{pid}/budget/suggestion?method=avg3"), &s)
|
||||
.await;
|
||||
assert_eq!(
|
||||
avg["amounts"],
|
||||
json!({ "needs": 175_000, "wants": 20_500, "culture": 5_000, "unexpected": 0 })
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budget_closed_period_and_isolation() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("A", None).await;
|
||||
let b = app.register("B", None).await;
|
||||
let pid = open_december(&app, &a, 0).await;
|
||||
let body = json!({ "needs": 1, "wants": 0, "culture": 0, "unexpected": 0 });
|
||||
|
||||
// B erişemez
|
||||
let checks = [
|
||||
app.get(&format!("/periods/{pid}/summary"), &b).await.0,
|
||||
app.get(&format!("/periods/{pid}/budget"), &b).await.0,
|
||||
app.put(&format!("/periods/{pid}/budget"), &b, body.clone())
|
||||
.await
|
||||
.0,
|
||||
app.put(
|
||||
&format!("/periods/{pid}/budget/weeks"),
|
||||
&b,
|
||||
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 1 }),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.get(&format!("/periods/{pid}/budget/suggestion"), &b)
|
||||
.await
|
||||
.0,
|
||||
];
|
||||
assert!(
|
||||
checks.iter().all(|s| *s == StatusCode::NOT_FOUND),
|
||||
"{checks:?}"
|
||||
);
|
||||
|
||||
// Kapalı dönem: okuma serbest, yazma 409
|
||||
let id = Uuid::parse_str(&pid).unwrap();
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.get(&format!("/periods/{pid}/budget"), &a).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
app.put(&format!("/periods/{pid}/budget"), &a, body).await.0,
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -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,198 @@
|
||||
//! Entegrasyon testleri için yardımcılar.
|
||||
//!
|
||||
//! Her test kendi geçici veritabanını (`kakeibo_test_<uuid>`) kullanır ve sonunda siler.
|
||||
//! Gereken: yerel MongoDB çalışıyor olmalı (`npm run db:up`), `.env` içinde `MONGODB_URI`.
|
||||
|
||||
#![allow(dead_code)] // her test dosyası tüm yardımcıları kullanmaz
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use kakeibo::{
|
||||
config::{Config, RegistrationMode},
|
||||
db, routes,
|
||||
state::AppState,
|
||||
};
|
||||
use mongodb::Database;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
pub struct TestApp {
|
||||
router: Router,
|
||||
pub db: Database,
|
||||
}
|
||||
|
||||
/// Oturum açmış bir test kullanıcısı
|
||||
#[derive(Clone)]
|
||||
pub struct Session {
|
||||
pub cookie: String,
|
||||
pub household_id: String,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
/// Kayıt açık (birden fazla hane gerektiren testler için).
|
||||
pub async fn new() -> Self {
|
||||
Self::with_registration(RegistrationMode::Open).await
|
||||
}
|
||||
|
||||
pub async fn with_registration(registration: RegistrationMode) -> Self {
|
||||
dotenvy::dotenv().ok();
|
||||
let uri = std::env::var("MONGODB_URI")
|
||||
.expect("MONGODB_URI tanımlı değil (.env dosyasını kontrol edin)");
|
||||
|
||||
let base = db::connect(&uri)
|
||||
.await
|
||||
.expect("MongoDB'ye bağlanılamadı — `npm run db:up` çalıştırın");
|
||||
let name = format!("kakeibo_test_{}", uuid::Uuid::new_v4().simple());
|
||||
let db = base.client().database(&name);
|
||||
db::prepare(&db).await.expect("test DB hazırlanamadı");
|
||||
|
||||
let config = Config {
|
||||
mongodb_uri: uri,
|
||||
jwt_secret: "test-jwt-secret-en-az-32-karakter-olmali!!".into(),
|
||||
app_url: "http://localhost".into(),
|
||||
port: 0,
|
||||
registration,
|
||||
static_dir: None,
|
||||
rates_refresh_minutes: 0,
|
||||
};
|
||||
let router = routes::router(AppState::new(db.clone(), config));
|
||||
Self { router, db }
|
||||
}
|
||||
|
||||
/// Test sonunda geçici veritabanını siler.
|
||||
pub async fn cleanup(self) {
|
||||
self.db.drop().await.ok();
|
||||
}
|
||||
|
||||
/// İstek gönderir; (durum kodu, JSON gövde, set-cookie) döner.
|
||||
pub async fn send(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
cookie: Option<&str>,
|
||||
body: Option<Value>,
|
||||
) -> (StatusCode, Value, Option<String>) {
|
||||
let mut req = Request::builder().method(method).uri(format!("/api{path}"));
|
||||
if let Some(c) = cookie {
|
||||
req = req.header(header::COOKIE, c);
|
||||
}
|
||||
let req = match body {
|
||||
Some(b) => req
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(b.to_string())),
|
||||
None => req.body(Body::empty()),
|
||||
}
|
||||
.unwrap();
|
||||
|
||||
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let set_cookie = res
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(';').next())
|
||||
.map(String::from);
|
||||
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||
let json = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
(status, json, set_cookie)
|
||||
}
|
||||
|
||||
/// Düz metin gövde gönderir (CSV içe aktarma)
|
||||
pub async fn post_text(&self, path: &str, s: &Session, text: &str) -> (StatusCode, Value) {
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(format!("/api{path}"))
|
||||
.header(header::COOKIE, &s.cookie)
|
||||
.header(header::CONTENT_TYPE, "text/csv; charset=utf-8")
|
||||
.body(Body::from(text.to_string()))
|
||||
.unwrap();
|
||||
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||
(
|
||||
status,
|
||||
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||
)
|
||||
}
|
||||
|
||||
/// Metin yanıt (CSV dışa aktarma): (durum, içerik türü, gövde)
|
||||
pub async fn get_text(&self, path: &str, s: &Session) -> (StatusCode, String, String) {
|
||||
let req = Request::builder()
|
||||
.uri(format!("/api{path}"))
|
||||
.header(header::COOKIE, &s.cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||
(status, ct, String::from_utf8(bytes.to_vec()).unwrap())
|
||||
}
|
||||
|
||||
pub async fn get(&self, path: &str, s: &Session) -> (StatusCode, Value) {
|
||||
let (st, v, _) = self.send(Method::GET, path, Some(&s.cookie), None).await;
|
||||
(st, v)
|
||||
}
|
||||
|
||||
pub async fn post(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) {
|
||||
let (st, v, _) = self
|
||||
.send(Method::POST, path, Some(&s.cookie), Some(body))
|
||||
.await;
|
||||
(st, v)
|
||||
}
|
||||
|
||||
pub async fn patch(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) {
|
||||
let (st, v, _) = self
|
||||
.send(Method::PATCH, path, Some(&s.cookie), Some(body))
|
||||
.await;
|
||||
(st, v)
|
||||
}
|
||||
|
||||
pub async fn put(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) {
|
||||
let (st, v, _) = self
|
||||
.send(Method::PUT, path, Some(&s.cookie), Some(body))
|
||||
.await;
|
||||
(st, v)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, path: &str, s: &Session) -> (StatusCode, Value) {
|
||||
let (st, v, _) = self.send(Method::DELETE, path, Some(&s.cookie), None).await;
|
||||
(st, v)
|
||||
}
|
||||
|
||||
/// Yeni kullanıcı kaydeder (davet koduyla veya yeni hane olarak) ve oturumunu döner.
|
||||
pub async fn register(&self, name: &str, invite_code: Option<&str>) -> Session {
|
||||
let email = format!(
|
||||
"{}-{}@test.com",
|
||||
name.to_lowercase(),
|
||||
uuid::Uuid::new_v4().simple()
|
||||
);
|
||||
let (status, body, cookie) = self
|
||||
.send(
|
||||
Method::POST,
|
||||
"/auth/register",
|
||||
None,
|
||||
Some(json!({
|
||||
"name": name,
|
||||
"email": email,
|
||||
"password": "test-sifre-123",
|
||||
"invite_code": invite_code,
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED, "kayıt başarısız: {body}");
|
||||
Session {
|
||||
cookie: cookie.expect("kayıtta cookie dönmedi"),
|
||||
household_id: body["household"]["id"].as_str().unwrap().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Günlük defter: harcama kayıtları.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use mongodb::bson::{Document, Uuid, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open_period(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": start, "end_date": end }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Hanenin ilk (örnek) kategorisi, verilen grupta
|
||||
async fn category(app: &TestApp, s: &Session, group: &str) -> String {
|
||||
let (_, cats) = app.get("/categories", s).await;
|
||||
cats.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["group"] == group)
|
||||
.unwrap_or_else(|| panic!("{group} grubunda kategori yok"))["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn payment_method(app: &TestApp, s: &Session) -> String {
|
||||
let (_, pms) = app.get("/payment-methods", s).await;
|
||||
pms[0]["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn add(app: &TestApp, s: &Session, pid: &str, body: Value) -> (StatusCode, Value) {
|
||||
app.post(&format!("/periods/{pid}/entries"), s, body).await
|
||||
}
|
||||
|
||||
async fn close(app: &TestApp, pid: &str) {
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(
|
||||
doc! { "_id": Uuid::parse_str(pid).unwrap() },
|
||||
doc! { "$set": { "status": "closed" } },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn ids(list: &Value) -> Vec<&str> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e["description"].as_str().unwrap_or(""))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crud_group_from_category_and_ordering() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let wants = category(&app, &s, "wants").await;
|
||||
let pm = payment_method(&app, &s).await;
|
||||
|
||||
let (st, e) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-09", "amount": 34_550, "category_id": needs,
|
||||
"payment_method_id": pm, "description": " Haftalık market " }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||
assert_eq!(e["group"], "needs", "grup kategoriden gelir");
|
||||
assert_eq!(e["description"], "Haftalık market", "boşluklar kırpılır");
|
||||
assert_eq!(e["payment_method_id"], pm.as_str());
|
||||
assert_eq!(e["version"], 1);
|
||||
let id = e["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Ödeme türü ve açıklama isteğe bağlı
|
||||
for (date, desc) in [
|
||||
("2026-12-09", "aynı gün, sonra"),
|
||||
("2026-12-02", "erken"),
|
||||
("2026-12-20", "geç"),
|
||||
] {
|
||||
let (st, b) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": date, "amount": 1_000, "category_id": wants, "description": desc }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
assert!(b["payment_method_id"].is_null());
|
||||
}
|
||||
|
||||
// Sıra: yeni tarih önce; aynı gün içinde son eklenen önce
|
||||
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||
assert_eq!(
|
||||
ids(&list),
|
||||
["geç", "aynı gün, sonra", "Haftalık market", "erken"]
|
||||
);
|
||||
|
||||
// Tarih aralığı (hafta) filtresi: iki uç dahil
|
||||
let (_, week) = app
|
||||
.get(
|
||||
&format!("/periods/{pid}/entries?from=2026-12-07&to=2026-12-13"),
|
||||
&s,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(ids(&week), ["aynı gün, sonra", "Haftalık market"]);
|
||||
let (st, _) = app
|
||||
.get(&format!("/periods/{pid}/entries?from=2026-13-01"), &s)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
// Düzenle: kategori değişince grup da değişir; boş metin → kaldır
|
||||
let (st, u) = app
|
||||
.patch(
|
||||
&format!("/entries/{id}"),
|
||||
&s,
|
||||
json!({ "amount": 40_000, "category_id": wants, "payment_method_id": "",
|
||||
"description": "", "version": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{u}");
|
||||
assert_eq!(u["amount"], 40_000);
|
||||
assert_eq!(u["group"], "wants");
|
||||
assert!(u["payment_method_id"].is_null());
|
||||
assert!(u["description"].is_null());
|
||||
assert_eq!(u["version"], 2);
|
||||
assert_eq!(u["date"], "2026-12-09", "gönderilmeyen alan değişmez");
|
||||
|
||||
// Eski sürümle düzenleme → 409
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/entries/{id}"),
|
||||
&s,
|
||||
json!({ "amount": 1, "version": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
// Sil → listede yok
|
||||
let (st, _) = app.delete(&format!("/entries/{id}"), &s).await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||
assert_eq!(list.as_array().unwrap().len(), 3);
|
||||
let (st, _) = app
|
||||
.patch(&format!("/entries/{id}"), &s, json!({ "amount": 5 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND, "silinmiş kayıt düzenlenemez");
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_and_restore_undo() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let (_, e) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-03", "amount": 2_500, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
let id = e["id"].as_str().unwrap();
|
||||
|
||||
// Silinmemiş kayıt geri alınamaz
|
||||
let (st, _) = app
|
||||
.post(&format!("/entries/{id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
app.delete(&format!("/entries/{id}"), &s).await;
|
||||
let (st, r) = app
|
||||
.post(&format!("/entries/{id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{r}");
|
||||
assert_eq!(r["amount"], 2_500);
|
||||
assert_eq!(r["version"], 3, "sil + geri al iki değişiklik");
|
||||
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||
assert_eq!(
|
||||
list.as_array().unwrap().len(),
|
||||
1,
|
||||
"geri gelen kayıt listede"
|
||||
);
|
||||
|
||||
// İkinci kez geri alma → 404
|
||||
let (st, _) = app
|
||||
.post(&format!("/entries/{id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_errors_are_turkish_400s() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let base = json!({ "date": "2026-12-05", "amount": 1_000, "category_id": needs });
|
||||
let with = |k: &str, v: Value| {
|
||||
let mut b = base.clone();
|
||||
b[k] = v;
|
||||
b
|
||||
};
|
||||
|
||||
let cases = [
|
||||
(with("amount", json!(0)), "Tutar sıfırdan büyük olmalı"),
|
||||
(with("amount", json!(-5)), "Tutar sıfırdan büyük olmalı"),
|
||||
(
|
||||
with("date", json!("2027-01-01")),
|
||||
"Tarih dönem içinde olmalı",
|
||||
),
|
||||
(with("date", json!("05.12.2026")), "Tarih geçersiz"),
|
||||
(with("category_id", json!("")), "Kategori geçersiz"),
|
||||
(
|
||||
with("category_id", json!(Uuid::new().to_string())),
|
||||
"Kategori bulunamadı",
|
||||
),
|
||||
(
|
||||
with("payment_method_id", json!("xyz")),
|
||||
"Ödeme türü geçersiz",
|
||||
),
|
||||
(
|
||||
with("payment_method_id", json!(Uuid::new().to_string())),
|
||||
"Ödeme türü bulunamadı",
|
||||
),
|
||||
(
|
||||
with("description", json!("a".repeat(201))),
|
||||
"Açıklama en fazla 200 karakter olabilir",
|
||||
),
|
||||
];
|
||||
for (body, msg) in cases {
|
||||
let (st, b) = add(&app, &s, &pid, body.clone()).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{body} → {b}");
|
||||
assert_eq!(b["error"]["message"], msg, "{body}");
|
||||
}
|
||||
|
||||
// 200 karakter (emoji = 1 karakter) kabul
|
||||
let (st, _) = add(&app, &s, &pid, with("description", json!("🛒".repeat(200)))).await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
// Pasif kategori: yeni kayıtta olmaz, onu zaten kullanan kayıt düzenlenebilir
|
||||
let (_, e) = add(&app, &s, &pid, base.clone()).await;
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/categories/{needs}"),
|
||||
&s,
|
||||
json!({ "is_active": false }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
let (st, b) = add(&app, &s, &pid, base.clone()).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
b["error"]["message"],
|
||||
"Bu kategori pasif; başka bir kategori seçin"
|
||||
);
|
||||
let (st, b) = app
|
||||
.patch(
|
||||
&format!("/entries/{}", e["id"].as_str().unwrap()),
|
||||
&s,
|
||||
json!({ "amount": 2_000, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::OK,
|
||||
"eski kaydın kendi kategorisi kalabilir: {b}"
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closed_period_is_read_only() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let (_, e) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1_000, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
let (_, gone) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-06", "amount": 500, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
let id = e["id"].as_str().unwrap();
|
||||
let gone_id = gone["id"].as_str().unwrap();
|
||||
app.delete(&format!("/entries/{gone_id}"), &s).await;
|
||||
close(&app, &pid).await;
|
||||
|
||||
let (st, _) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-07", "amount": 1, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
let (st, _) = app
|
||||
.patch(&format!("/entries/{id}"), &s, json!({ "amount": 5 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
let (st, _) = app.delete(&format!("/entries/{id}"), &s).await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
let (st, _) = app
|
||||
.post(&format!("/entries/{gone_id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
// Okuma serbest
|
||||
let (st, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert_eq!(list.as_array().unwrap().len(), 1);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn entries_are_isolated_between_households() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("Ali", None).await;
|
||||
let b = app.register("Banu", None).await;
|
||||
let pid = open_period(&app, &a, "2026-12-01", "2027-01-01").await;
|
||||
let b_pid = open_period(&app, &b, "2026-12-01", "2027-01-01").await;
|
||||
let a_cat = category(&app, &a, "needs").await;
|
||||
let a_pm = payment_method(&app, &a).await;
|
||||
let (_, e) = add(
|
||||
&app,
|
||||
&a,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1_000, "category_id": a_cat }),
|
||||
)
|
||||
.await;
|
||||
let id = e["id"].as_str().unwrap();
|
||||
|
||||
// B, A'nın dönemini/kaydını göremez, değiştiremez
|
||||
let (st, _) = app.get(&format!("/periods/{pid}/entries"), &b).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = add(
|
||||
&app,
|
||||
&b,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1, "category_id": a_cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = app
|
||||
.patch(&format!("/entries/{id}"), &b, json!({ "amount": 5 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = app.delete(&format!("/entries/{id}"), &b).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
app.delete(&format!("/entries/{id}"), &a).await;
|
||||
let (st, _) = app
|
||||
.post(&format!("/entries/{id}/restore"), &b, json!({}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::NOT_FOUND,
|
||||
"başkasının silinmiş kaydı geri alınamaz"
|
||||
);
|
||||
|
||||
// B kendi dönemine A'nın kategorisini / ödeme türünü kullanamaz
|
||||
let (st, body) = add(
|
||||
&app,
|
||||
&b,
|
||||
&b_pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1, "category_id": a_cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["message"], "Kategori bulunamadı");
|
||||
let b_cat = category(&app, &b, "needs").await;
|
||||
let (st, body) = add(
|
||||
&app,
|
||||
&b,
|
||||
&b_pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1, "category_id": b_cat, "payment_method_id": a_pm }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["message"], "Ödeme türü bulunamadı");
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
/// Bütçe önerisi (önceki dönem) artık API'den girilen gerçek kayıtları kullanır.
|
||||
#[tokio::test]
|
||||
async fn previous_suggestion_uses_real_entries() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let nov = open_period(&app, &s, "2026-11-01", "2026-12-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let culture = category(&app, &s, "culture").await;
|
||||
for (cat, amount) in [(&needs, 120_000), (&needs, 30_000), (&culture, 15_000)] {
|
||||
let (st, b) = add(
|
||||
&app,
|
||||
&s,
|
||||
&nov,
|
||||
json!({ "date": "2026-11-10", "amount": amount, "category_id": cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
// Silinen kayıt toplama girmez
|
||||
let (_, del) = add(
|
||||
&app,
|
||||
&s,
|
||||
&nov,
|
||||
json!({ "date": "2026-11-11", "amount": 999_999, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
app.delete(&format!("/entries/{}", del["id"].as_str().unwrap()), &s)
|
||||
.await;
|
||||
close(&app, &nov).await;
|
||||
|
||||
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let (_, sug) = app
|
||||
.get(
|
||||
&format!("/periods/{dec}/budget/suggestion?method=previous"),
|
||||
&s,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sug["available"], true, "{sug}");
|
||||
assert_eq!(
|
||||
sug["amounts"],
|
||||
json!({ "needs": 150_000, "wants": 0, "culture": 15_000, "unexpected": 0 })
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
fn spent_by_week(budget: &Value, group: &str) -> (i64, Vec<i64>) {
|
||||
let g = budget
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|g| g["group"] == group)
|
||||
.unwrap();
|
||||
let weeks = g["weeks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|w| w["spent"].as_i64().unwrap())
|
||||
.collect();
|
||||
(g["spent"].as_i64().unwrap(), weeks)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_spending_in_summary_and_budget() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
// Kasım'da bir harcama: Aralık toplamlarına girmemeli
|
||||
let nov = open_period(&app, &s, "2026-11-01", "2026-12-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let wants = category(&app, &s, "wants").await;
|
||||
let culture = category(&app, &s, "culture").await;
|
||||
let (st, _) = add(
|
||||
&app,
|
||||
&s,
|
||||
&nov,
|
||||
json!({ "date": "2026-11-30", "amount": 1_000_000, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
close(&app, &nov).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 300_000 }),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Aralık 2026 haftaları: 1–6 · 7–13 · 14–20 · 21–27 · 28–31
|
||||
for (date, amount, cat) in [
|
||||
("2026-12-01", 1_000, &needs),
|
||||
("2026-12-06", 2_000, &needs), // 1. haftanın son günü
|
||||
("2026-12-07", 500, &needs), // 2. haftanın ilk günü
|
||||
("2026-12-31", 300, &wants), // dönemin son günü
|
||||
] {
|
||||
let (st, b) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": date, "amount": amount, "category_id": cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
// Kategori değişince harcama diğer gruba geçer
|
||||
let (_, moved) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-08", "amount": 400, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
app.patch(
|
||||
&format!("/entries/{}", moved["id"].as_str().unwrap()),
|
||||
&s,
|
||||
json!({ "category_id": wants }),
|
||||
)
|
||||
.await;
|
||||
// Silinen harcama sayılmaz
|
||||
let (_, gone) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-15", "amount": 700, "category_id": culture }),
|
||||
)
|
||||
.await;
|
||||
let gone_id = gone["id"].as_str().unwrap();
|
||||
app.delete(&format!("/entries/{gone_id}"), &s).await;
|
||||
|
||||
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||
// 1.000 + 2.000 + 500 + 300 + 400 = 4.200
|
||||
assert_eq!(
|
||||
(sum["spent_total"].as_i64(), sum["remaining"].as_i64()),
|
||||
(Some(4_200), Some(295_800)),
|
||||
"{sum}"
|
||||
);
|
||||
|
||||
let (st, b) = app.get(&format!("/periods/{pid}/budget"), &s).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert_eq!(
|
||||
spent_by_week(&b, "needs"),
|
||||
(3_500, vec![3_000, 500, 0, 0, 0])
|
||||
);
|
||||
assert_eq!(spent_by_week(&b, "wants"), (700, vec![0, 400, 0, 0, 300]));
|
||||
assert_eq!(spent_by_week(&b, "culture"), (0, vec![0; 5]));
|
||||
|
||||
// Geri al → yeniden sayılır
|
||||
app.post(&format!("/entries/{gone_id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
let (_, b) = app.get(&format!("/periods/{pid}/budget"), &s).await;
|
||||
assert_eq!(spent_by_week(&b, "culture"), (700, vec![0, 0, 700, 0, 0]));
|
||||
|
||||
// Harcanabilir aşılırsa kalan negatif
|
||||
add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-20", "amount": 300_000, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
|
||||
assert_eq!(sum["remaining"], 300_000 - 304_900);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Hane izolasyonu testleri: bir hane, başka hanenin verisini göremez ve değiştiremez.
|
||||
//! (KAKEIBO_PROMPT.md §11 — "başka hanenin verisine erişim imkânsız olmalı")
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::{Method, StatusCode};
|
||||
use common::TestApp;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn ids(list: &Value) -> Vec<String> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| i["id"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn households_cannot_see_or_touch_each_other() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("Ayse", None).await;
|
||||
let b = app.register("Burak", None).await;
|
||||
assert_ne!(a.household_id, b.household_id);
|
||||
|
||||
// A'ya özel kategori ve ödeme türü
|
||||
let (st, cat) = app
|
||||
.post(
|
||||
"/categories",
|
||||
&a,
|
||||
json!({ "group": "wants", "name": "A'nın gizli kategorisi" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
let cat_id = cat["id"].as_str().unwrap();
|
||||
let (_, pm) = app
|
||||
.post("/payment-methods", &a, json!({ "name": "A'nın kartı" }))
|
||||
.await;
|
||||
let pm_id = pm["id"].as_str().unwrap();
|
||||
let (_, invite) = app.post("/household/invites", &a, json!({})).await;
|
||||
let invite_id = invite["id"].as_str().unwrap();
|
||||
|
||||
// --- Okuma: B, A'nın kayıtlarını görmez ---
|
||||
let (_, b_cats) = app.get("/categories?include_inactive=true", &b).await;
|
||||
assert!(!ids(&b_cats).contains(&cat_id.to_string()));
|
||||
let (_, b_pms) = app.get("/payment-methods?include_inactive=true", &b).await;
|
||||
assert!(!ids(&b_pms).contains(&pm_id.to_string()));
|
||||
let (_, b_hh) = app.get("/household", &b).await;
|
||||
assert_eq!(b_hh["id"], b.household_id.as_str());
|
||||
assert_eq!(b_hh["members"].as_array().unwrap().len(), 1);
|
||||
let (_, b_invites) = app.get("/household/invites", &b).await;
|
||||
assert_eq!(b_invites, json!([]));
|
||||
|
||||
// --- Yazma: B, A'nın kayıtlarını değiştiremez (404: varlığı bile sızmaz) ---
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/categories/{cat_id}"),
|
||||
&b,
|
||||
json!({ "name": "ele geçirildi" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/payment-methods/{pm_id}"),
|
||||
&b,
|
||||
json!({ "is_active": false }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = app
|
||||
.delete(&format!("/household/invites/{invite_id}"), &b)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
// B, kendi sıralama listesine A'nın kaydını sokamaz
|
||||
let mut b_wants: Vec<String> = b_cats
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|c| c["group"] == "wants")
|
||||
.map(|c| c["id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
b_wants.push(cat_id.to_string());
|
||||
let (st, _) = app
|
||||
.put(
|
||||
"/categories/order",
|
||||
&b,
|
||||
json!({ "group": "wants", "ids": b_wants }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
// --- A'nın verisi hiç değişmedi ---
|
||||
let (_, a_cats) = app.get("/categories", &a).await;
|
||||
let mine = a_cats
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["id"] == cat_id)
|
||||
.unwrap();
|
||||
assert_eq!(mine["name"], "A'nın gizli kategorisi");
|
||||
assert_eq!(mine["version"], 1);
|
||||
let (_, a_invites) = app.get("/household/invites", &a).await;
|
||||
assert_eq!(ids(&a_invites), vec![invite_id.to_string()]);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_joins_via_invite_and_shares_household_data() {
|
||||
let app = TestApp::new().await;
|
||||
let owner = app.register("Sahip", None).await;
|
||||
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
|
||||
let member = app
|
||||
.register("Uye", Some(inv["code"].as_str().unwrap()))
|
||||
.await;
|
||||
|
||||
assert_eq!(member.household_id, owner.household_id);
|
||||
|
||||
// Üye, sahibin eklediği kategoriyi görür ve düzenleyebilir (ortak defter)
|
||||
let (_, cat) = app
|
||||
.post(
|
||||
"/categories",
|
||||
&owner,
|
||||
json!({ "group": "needs", "name": "Ortak" }),
|
||||
)
|
||||
.await;
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/categories/{}", cat["id"].as_str().unwrap()),
|
||||
&member,
|
||||
json!({ "emoji": "🤝" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
|
||||
// Ama davet oluşturamaz
|
||||
let (st, _) = app.post("/household/invites", &member, json!({})).await;
|
||||
assert_eq!(st, StatusCode::FORBIDDEN);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protected_endpoints_require_session() {
|
||||
let app = TestApp::new().await;
|
||||
let endpoints = [
|
||||
(Method::GET, "/auth/me"),
|
||||
(Method::GET, "/household"),
|
||||
(Method::GET, "/household/invites"),
|
||||
(Method::POST, "/household/invites"),
|
||||
(Method::GET, "/categories"),
|
||||
(Method::POST, "/categories"),
|
||||
(Method::PUT, "/categories/order"),
|
||||
(Method::GET, "/payment-methods"),
|
||||
(Method::POST, "/payment-methods"),
|
||||
];
|
||||
for (method, path) in endpoints {
|
||||
// Oturumsuz ve sahte cookie ile
|
||||
for cookie in [None, Some("kakeibo_session=sahte.token.degeri")] {
|
||||
let (st, _, _) = app
|
||||
.send(method.clone(), path, cookie, Some(json!({})))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::UNAUTHORIZED, "{method} {path} korumasız!");
|
||||
}
|
||||
}
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Dönem kuralları (KAKEIBO_PROMPT §6).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::TestApp;
|
||||
use mongodb::bson::{Document, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open_december(app: &TestApp, s: &common::Session) -> Value {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": 500000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_period_returns_weeks_and_default_name() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
|
||||
// Başta açık dönem yok → null
|
||||
let (st, cur) = app.get("/periods/current", &s).await;
|
||||
assert_eq!((st, cur), (StatusCode::OK, Value::Null));
|
||||
|
||||
let p = open_december(&app, &s).await;
|
||||
assert_eq!(p["name"], "Aralık 2026");
|
||||
assert_eq!(p["status"], "open");
|
||||
assert_eq!(p["days"], 31);
|
||||
assert_eq!(p["last_date"], "2026-12-31");
|
||||
assert_eq!(p["savings_goal"], 500000);
|
||||
let days: Vec<i64> = p["weeks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|w| w["days"].as_i64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
days,
|
||||
vec![6, 7, 7, 7, 4],
|
||||
"Pazartesi başlangıçlı, kısmi haftalar ayrı"
|
||||
);
|
||||
|
||||
let (_, cur) = app.get("/periods/current", &s).await;
|
||||
assert_eq!(cur["id"], p["id"]);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_one_open_period_and_no_overlap() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let dec = open_december(&app, &s).await;
|
||||
|
||||
// İkinci açık dönem → 409
|
||||
let (st, body) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2027-01-01", "end_date": "2027-02-01" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
assert!(
|
||||
body["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Aralık 2026")
|
||||
);
|
||||
|
||||
// Aralık'ı kapat (Faz 6'da sihirbaz yapacak) → çakışan yeni dönem yine 409
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(
|
||||
doc! { "name": "Aralık 2026" },
|
||||
doc! { "$set": { "status": "closed" } },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (st, body) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-12-20", "end_date": "2027-01-20" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "{body}");
|
||||
assert!(
|
||||
body["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("çakışıyor")
|
||||
);
|
||||
|
||||
// Bitişik dönem (bitiş hariç olduğu için 1 Ocak serbest) → 201
|
||||
let (st, jan) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2027-01-01", "end_date": "2027-02-01" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{jan}");
|
||||
assert_eq!(jan["name"], "Ocak 2027");
|
||||
|
||||
// Liste: en yeni başta
|
||||
let (_, list) = app.get("/periods", &s).await;
|
||||
let names: Vec<&str> = list
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["Ocak 2027", "Aralık 2026"]);
|
||||
|
||||
// Kapalı dönem salt okunur
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/periods/{}", dec["id"].as_str().unwrap()),
|
||||
&s,
|
||||
json!({ "savings_goal": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_errors() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
for (start, end) in [
|
||||
("2026-12-10", "2026-12-01"), // ters
|
||||
("2026-12-01", "2026-12-01"), // boş
|
||||
("2026-02-30", "2026-03-30"), // geçersiz gün
|
||||
("01.12.2026", "2027-01-01"), // biçim
|
||||
("2026-12-01", "2027-03-01"), // 90 gün
|
||||
] {
|
||||
let (st, _) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": start, "end_date": end }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{start} → {end}");
|
||||
}
|
||||
let (st, _) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": -5 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "negatif birikim");
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_dates_name_goal_and_version() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let p = open_december(&app, &s).await;
|
||||
let path = format!("/periods/{}", p["id"].as_str().unwrap());
|
||||
|
||||
// Maaş 15'inde → dönemi kaydır; ad otomatik güncellenmez, istenirse verilir
|
||||
let (st, upd) = app
|
||||
.patch(
|
||||
&path,
|
||||
&s,
|
||||
json!({ "start_date": "2026-12-15", "end_date": "2027-01-15", "name": "", "savings_goal": 750000, "version": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{upd}");
|
||||
assert_eq!(upd["name"], "15 Ara 2026 – 14 Oca 2027");
|
||||
assert_eq!(upd["savings_goal"], 750000);
|
||||
assert_eq!(upd["version"], 2);
|
||||
|
||||
// Eski sürümle → 409
|
||||
let (st, _) = app
|
||||
.patch(&path, &s, json!({ "name": "X", "version": 1 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
// Aralığın dışında kalacak bir harcama varsa daraltma engellenir
|
||||
let hid = mongodb::bson::Uuid::parse_str(&s.household_id).unwrap();
|
||||
let pid = mongodb::bson::Uuid::parse_str(p["id"].as_str().unwrap()).unwrap();
|
||||
app.db
|
||||
.collection::<Document>("entries")
|
||||
.insert_one(doc! { "household_id": hid, "period_id": pid, "date": "2027-01-10", "deleted_at": null })
|
||||
.await
|
||||
.unwrap();
|
||||
let (st, body) = app
|
||||
.patch(&path, &s, json!({ "end_date": "2027-01-05" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "{body}");
|
||||
assert!(
|
||||
body["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("1 harcama")
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periods_are_isolated_between_households() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("A", None).await;
|
||||
let b = app.register("B", None).await;
|
||||
let p = open_december(&app, &a).await;
|
||||
let path = format!("/periods/{}", p["id"].as_str().unwrap());
|
||||
|
||||
// B, A'nın dönemini göremez/değiştiremez; kendi açık dönemi yok
|
||||
assert_eq!(app.get(&path, &b).await.0, StatusCode::NOT_FOUND);
|
||||
assert_eq!(
|
||||
app.patch(&path, &b, json!({ "name": "ele geçirildi" }))
|
||||
.await
|
||||
.0,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(app.get("/periods/current", &b).await.1, Value::Null);
|
||||
assert_eq!(app.get("/periods", &b).await.1, json!([]));
|
||||
|
||||
// A'nın açık dönemi B'nin dönem açmasını engellemez (açık dönem kuralı hane bazlı)
|
||||
let (st, _) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&b,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Plan kalemleri ve notlar.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use mongodb::bson::{Document, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open_period(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": start, "end_date": end }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn close_period(app: &TestApp, id: &str) {
|
||||
let uuid = mongodb::bson::Uuid::parse_str(id).unwrap();
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(
|
||||
doc! { "_id": uuid },
|
||||
doc! { "$set": { "status": "closed" } },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn names(list: &Value) -> Vec<String> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| {
|
||||
format!(
|
||||
"{}:{}",
|
||||
i["kind"].as_str().unwrap(),
|
||||
i["name"].as_str().unwrap()
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_items_crud_and_validation() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let items = format!("/periods/{pid}/plan-items");
|
||||
|
||||
// Gider önce eklense de liste gelirle başlar
|
||||
let (st, rent) = app
|
||||
.post(
|
||||
&items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{rent}");
|
||||
app.post(
|
||||
&items,
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000 }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "İnternet", "amount": 45_000 }),
|
||||
)
|
||||
.await;
|
||||
let (_, list) = app.get(&items, &s).await;
|
||||
assert_eq!(
|
||||
names(&list),
|
||||
vec!["income:Maaş", "expense:Kira", "expense:İnternet"]
|
||||
);
|
||||
assert_eq!(list[0]["date"], Value::Null);
|
||||
|
||||
// Doğrulamalar
|
||||
for bad in [
|
||||
json!({ "kind": "expense", "name": "X", "amount": 0 }),
|
||||
json!({ "kind": "expense", "name": " ", "amount": 100 }),
|
||||
json!({ "kind": "expense", "name": "X", "amount": 100, "date": "2027-01-01" }), // dönem dışı (bitiş hariç)
|
||||
json!({ "kind": "expense", "name": "X", "amount": 100, "date": "05.12.2026" }),
|
||||
json!({ "kind": "gift", "name": "X", "amount": 100 }),
|
||||
] {
|
||||
let (st, _) = app.post(&items, &s, bad.clone()).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{bad}");
|
||||
}
|
||||
|
||||
// Düzenle: tutar + tarihi kaldır; eski sürüm → 409
|
||||
let rent_path = format!("/plan-items/{}", rent["id"].as_str().unwrap());
|
||||
let (st, upd) = app
|
||||
.patch(
|
||||
&rent_path,
|
||||
&s,
|
||||
json!({ "amount": 1_600_000, "date": "", "version": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{upd}");
|
||||
assert_eq!(
|
||||
(upd["amount"].as_i64(), &upd["date"]),
|
||||
(Some(1_600_000), &Value::Null)
|
||||
);
|
||||
let (st, _) = app
|
||||
.patch(&rent_path, &s, json!({ "name": "Y", "version": 1 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
// Sil → listeden çıkar
|
||||
assert_eq!(app.delete(&rent_path, &s).await.0, StatusCode::NO_CONTENT);
|
||||
let (_, list) = app.get(&items, &s).await;
|
||||
assert_eq!(names(&list), vec!["income:Maaş", "expense:İnternet"]);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_previous_shifts_dates_and_skips_duplicates() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let dec_items = format!("/periods/{dec}/plan-items");
|
||||
app.post(
|
||||
&dec_items,
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000, "date": "2026-12-15" }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&dec_items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&dec_items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Aidat", "amount": 80_000 }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&dec_items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Fatura", "amount": 90_000, "date": "2026-12-31" }),
|
||||
)
|
||||
.await;
|
||||
close_period(&app, &dec).await;
|
||||
|
||||
let jan = open_period(&app, &s, "2027-01-01", "2027-02-01").await;
|
||||
let jan_items = format!("/periods/{jan}/plan-items");
|
||||
// Kullanıcı kirayı elle zaten eklemiş (farklı tutarla) → kopyada atlanmalı
|
||||
app.post(
|
||||
&jan_items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "kira", "amount": 1_700_000 }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (st, res) = app
|
||||
.post(&format!("{jan_items}/copy-previous"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{res}");
|
||||
assert_eq!(res["added"], 3);
|
||||
|
||||
let (_, list) = app.get(&jan_items, &s).await;
|
||||
let by_name = |n: &str| {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["name"] == n)
|
||||
.unwrap()
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(by_name("Maaş")["date"], "2027-01-15", "göreli gün korunur");
|
||||
assert_eq!(by_name("Aidat")["date"], Value::Null);
|
||||
assert_eq!(by_name("kira")["amount"], 1_700_000, "elle eklenen korunur");
|
||||
|
||||
assert_eq!(by_name("Fatura")["date"], "2027-01-31");
|
||||
|
||||
// Ocak → Şubat (28 gün): 31 Ocak'taki fatura dönem dışına taşar → son güne (28 Şubat) çekilir
|
||||
close_period(&app, &jan).await;
|
||||
let feb = open_period(&app, &s, "2027-02-01", "2027-03-01").await;
|
||||
let feb_items = format!("/periods/{feb}/plan-items");
|
||||
app.post(&format!("{feb_items}/copy-previous"), &s, json!({}))
|
||||
.await;
|
||||
let (_, feb_list) = app.get(&feb_items, &s).await;
|
||||
let fatura = feb_list
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["name"] == "Fatura")
|
||||
.unwrap();
|
||||
assert_eq!(fatura["date"], "2027-02-28");
|
||||
|
||||
// İkinci kez → hiçbir şey eklenmez (Ocak kapalı olduğu için Şubat'ta deniyoruz)
|
||||
let (_, res) = app
|
||||
.post(&format!("{feb_items}/copy-previous"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(res["added"], 0);
|
||||
|
||||
// İlk dönemde önceki yok → 400
|
||||
let (st, _) = app
|
||||
.post(&format!("{dec_items}/copy-previous"), &s, json!({}))
|
||||
.await;
|
||||
assert!(
|
||||
st == StatusCode::BAD_REQUEST || st == StatusCode::CONFLICT,
|
||||
"{st}"
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closed_period_is_read_only() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let (_, item) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 100 }),
|
||||
)
|
||||
.await;
|
||||
let (_, note) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/notes"),
|
||||
&s,
|
||||
json!({ "context": "planning", "title": "Hedef" }),
|
||||
)
|
||||
.await;
|
||||
close_period(&app, &pid).await;
|
||||
|
||||
let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap());
|
||||
let note_path = format!("/notes/{}", note["id"].as_str().unwrap());
|
||||
let checks = [
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "X", "amount": 1 }),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.patch(&item_path, &s, json!({ "amount": 5 })).await.0,
|
||||
app.delete(&item_path, &s).await.0,
|
||||
app.post(
|
||||
&format!("/periods/{pid}/notes"),
|
||||
&s,
|
||||
json!({ "context": "review", "title": "X" }),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.patch(¬e_path, &s, json!({ "title": "Y" })).await.0,
|
||||
app.delete(¬e_path, &s).await.0,
|
||||
];
|
||||
assert!(
|
||||
checks.iter().all(|st| *st == StatusCode::CONFLICT),
|
||||
"{checks:?}"
|
||||
);
|
||||
|
||||
// Okuma serbest
|
||||
assert_eq!(
|
||||
app.get(&format!("/periods/{pid}/plan-items"), &s).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
app.get(&format!("/periods/{pid}/notes"), &s).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notes_crud_and_context_filter() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let notes = format!("/periods/{pid}/notes");
|
||||
|
||||
let (st, n) = app
|
||||
.post(¬es, &s, json!({ "context": "planning", "title": " Tatil birikimi ", "body_md": "- Ocak'ta **5.000 ₺**" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{n}");
|
||||
assert_eq!(n["title"], "Tatil birikimi");
|
||||
app.post(¬es, &s, json!({ "context": "review", "title": "Ders" }))
|
||||
.await;
|
||||
|
||||
let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await;
|
||||
assert_eq!(planning.as_array().unwrap().len(), 1);
|
||||
let (_, all) = app.get(¬es, &s).await;
|
||||
assert_eq!(all.as_array().unwrap().len(), 2);
|
||||
|
||||
let (st, _) = app
|
||||
.post(¬es, &s, json!({ "context": "planning", "title": "" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "boş başlık");
|
||||
|
||||
let path = format!("/notes/{}", n["id"].as_str().unwrap());
|
||||
let (st, upd) = app
|
||||
.patch(&path, &s, json!({ "body_md": "Güncel", "version": 1 }))
|
||||
.await;
|
||||
assert_eq!(
|
||||
(st, upd["body_md"].as_str()),
|
||||
(StatusCode::OK, Some("Güncel"))
|
||||
);
|
||||
assert_eq!(app.delete(&path, &s).await.0, StatusCode::NO_CONTENT);
|
||||
let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await;
|
||||
assert_eq!(planning, json!([]));
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_items_and_notes_are_isolated() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("A", None).await;
|
||||
let b = app.register("B", None).await;
|
||||
let pid = open_period(&app, &a, "2026-12-01", "2027-01-01").await;
|
||||
let (_, item) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&a,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 100 }),
|
||||
)
|
||||
.await;
|
||||
let (_, note) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/notes"),
|
||||
&a,
|
||||
json!({ "context": "planning", "title": "Gizli" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap());
|
||||
let note_path = format!("/notes/{}", note["id"].as_str().unwrap());
|
||||
let checks = [
|
||||
app.get(&format!("/periods/{pid}/plan-items"), &b).await.0,
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&b,
|
||||
json!({ "kind": "income", "name": "X", "amount": 1 }),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items/copy-previous"),
|
||||
&b,
|
||||
json!({}),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.patch(&item_path, &b, json!({ "amount": 1 })).await.0,
|
||||
app.delete(&item_path, &b).await.0,
|
||||
app.get(&format!("/periods/{pid}/notes"), &b).await.0,
|
||||
app.patch(¬e_path, &b, json!({ "title": "X" })).await.0,
|
||||
app.delete(¬e_path, &b).await.0,
|
||||
];
|
||||
assert!(
|
||||
checks.iter().all(|st| *st == StatusCode::NOT_FOUND),
|
||||
"{checks:?}"
|
||||
);
|
||||
|
||||
// A'nın verisi değişmedi
|
||||
let (_, list) = app.get(&format!("/periods/{pid}/plan-items"), &a).await;
|
||||
assert_eq!(list[0]["amount"], 100);
|
||||
assert_eq!(list[0]["version"], 1);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Kurlar: kaynaklardan çekme (sahte sunucuyla, internet yok), gün başına tek kayıt,
|
||||
//! kaynak düşünce son kur, manuel kur önceliği, portföy değerlemesi ve hane izolasyonu.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
use axum::{Router, http::StatusCode, routing::get};
|
||||
use common::{Session, TestApp};
|
||||
use kakeibo::services::{
|
||||
calendar::{format_date, today_tr},
|
||||
rates::{self, Sources},
|
||||
};
|
||||
use mongodb::bson::{Document, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const TCMB: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Tarih_Date Tarih="12.11.2026" Date="11/12/2026" Bulten_No="2026/213">
|
||||
<Currency CrossOrder="0" Kod="USD" CurrencyCode="USD"><Unit>1</Unit><ForexBuying>50.1691</ForexBuying><ForexSelling>50.2595</ForexSelling></Currency>
|
||||
<Currency CrossOrder="9" Kod="JPY" CurrencyCode="JPY"><Unit>100</Unit><ForexBuying>32.4512</ForexBuying></Currency>
|
||||
<Currency CrossOrder="19" Kod="XDR" CurrencyCode="XDR"><Unit>1</Unit><ForexBuying>68.5521</ForexBuying><ForexSelling/></Currency>
|
||||
</Tarih_Date>"#;
|
||||
|
||||
/// Sahte kaynak sunucusu: `/tcmb`, `/gold`. Bayraklarla hata verdirilebilir.
|
||||
struct Mock {
|
||||
src: Sources,
|
||||
tcmb_down: Arc<AtomicBool>,
|
||||
gold_price: Arc<std::sync::Mutex<String>>,
|
||||
}
|
||||
|
||||
async fn mock() -> Mock {
|
||||
let tcmb_down = Arc::new(AtomicBool::new(false));
|
||||
let gold_price = Arc::new(std::sync::Mutex::new(
|
||||
r#"{"per_gram_usd":124.5552}"#.to_string(),
|
||||
));
|
||||
let (d, g) = (tcmb_down.clone(), gold_price.clone());
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/tcmb",
|
||||
get(move || {
|
||||
let d = d.clone();
|
||||
async move {
|
||||
if d.load(Ordering::SeqCst) {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, String::new())
|
||||
} else {
|
||||
(StatusCode::OK, TCMB.to_string())
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/gold",
|
||||
get(move || {
|
||||
let g = g.clone();
|
||||
async move { g.lock().unwrap().clone() }
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
Mock {
|
||||
src: Sources {
|
||||
tcmb_url: format!("http://{addr}/tcmb"),
|
||||
gold_url: format!("http://{addr}/gold"),
|
||||
},
|
||||
tcmb_down,
|
||||
gold_price,
|
||||
}
|
||||
}
|
||||
|
||||
async fn auto_rate(app: &TestApp, code: &str) -> Document {
|
||||
app.db
|
||||
.collection::<Document>("exchange_rates")
|
||||
.find_one(doc! { "household_id": null, "code": code })
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{code} kuru yok"))
|
||||
}
|
||||
|
||||
async fn count(app: &TestApp, filter: Document) -> u64 {
|
||||
app.db
|
||||
.collection::<Document>("exchange_rates")
|
||||
.count_documents(filter)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_writes_one_rate_per_day_and_survives_outages() {
|
||||
let app = TestApp::new().await;
|
||||
let m = mock().await;
|
||||
let client = rates::http_client();
|
||||
|
||||
let r = rates::refresh(&app.db, &client, &m.src).await.unwrap();
|
||||
assert_eq!(r.fx, Some(Ok(3)));
|
||||
assert_eq!(r.gold, Some(Ok(5)));
|
||||
let usd = auto_rate(&app, "USD").await;
|
||||
assert_eq!(usd.get_i64("rate_e6").unwrap(), 50_169_100, "alış kuru");
|
||||
assert_eq!(
|
||||
usd.get_str("rate_date").unwrap(),
|
||||
"2026-11-12",
|
||||
"bülten tarihi"
|
||||
);
|
||||
assert_eq!(usd.get_str("source").unwrap(), "tcmb");
|
||||
assert_eq!(
|
||||
auto_rate(&app, "JPY").await.get_i64("rate_e6").unwrap(),
|
||||
324_512,
|
||||
"1 yen"
|
||||
);
|
||||
let gram = auto_rate(&app, "XAU_GRAM").await;
|
||||
assert_eq!(gram.get_i64("rate_e6").unwrap(), 6_248_822_284);
|
||||
assert_eq!(gram.get_str("rate_date").unwrap(), format_date(today_tr()));
|
||||
assert_eq!(gram.get_str("source").unwrap(), "xaus");
|
||||
assert_eq!(count(&app, doc! {}).await, 8);
|
||||
|
||||
// Aynı gün yeniden: kayıt sayısı değişmez
|
||||
rates::refresh(&app.db, &client, &m.src).await.unwrap();
|
||||
assert_eq!(count(&app, doc! {}).await, 8);
|
||||
|
||||
// TCMB düştü, altın değişti → altın veritabanındaki son USD ile hesaplanır
|
||||
m.tcmb_down.store(true, Ordering::SeqCst);
|
||||
*m.gold_price.lock().unwrap() = r#"{"per_gram_usd":130.0}"#.into();
|
||||
let r = rates::refresh(&app.db, &client, &m.src).await.unwrap();
|
||||
assert!(matches!(r.fx, Some(Err(_))), "{:?}", r.fx);
|
||||
assert_eq!(r.gold, Some(Ok(5)));
|
||||
assert_eq!(
|
||||
auto_rate(&app, "XAU_GRAM")
|
||||
.await
|
||||
.get_i64("rate_e6")
|
||||
.unwrap(),
|
||||
6_521_983_000
|
||||
);
|
||||
assert_eq!(
|
||||
auto_rate(&app, "USD").await.get_i64("rate_e6").unwrap(),
|
||||
50_169_100,
|
||||
"son kur kalır"
|
||||
);
|
||||
|
||||
// Bozuk altın yanıtı: hata raporlanır, eski kur kalır
|
||||
*m.gold_price.lock().unwrap() = "<html>bakım</html>".into();
|
||||
let r = rates::refresh(&app.db, &client, &m.src).await.unwrap();
|
||||
assert!(matches!(r.gold, Some(Err(_))));
|
||||
assert_eq!(
|
||||
auto_rate(&app, "XAU_GRAM")
|
||||
.await
|
||||
.get_i64("rate_e6")
|
||||
.unwrap(),
|
||||
6_521_983_000
|
||||
);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
async fn asset_id(app: &TestApp, s: &Session, code: &str) -> String {
|
||||
let (_, list) = app.get("/assets", s).await;
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|a| a["code"] == code)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn buy(app: &TestApp, s: &Session, code: &str, quantity_e6: i64, total: i64) {
|
||||
let id = asset_id(app, s, code).await;
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/assets/{id}/transactions"),
|
||||
s,
|
||||
json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": quantity_e6, "total": total }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
|
||||
fn item<'a>(p: &'a Value, code: &str) -> &'a Value {
|
||||
p["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["asset"]["code"] == code)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn portfolio_values_and_manual_rates() {
|
||||
let app = TestApp::new().await;
|
||||
let m = mock().await;
|
||||
rates::refresh(&app.db, &rates::http_client(), &m.src)
|
||||
.await
|
||||
.unwrap();
|
||||
let s = app.register("Test", None).await;
|
||||
buy(&app, &s, "XAU_GRAM", 10_000_000, 6_000_000).await; // 10 gr, 60.000 TL
|
||||
buy(&app, &s, "USD", 100_000_000, 450_000).await; // 100 $, 4.500 TL
|
||||
buy(&app, &s, "TRY", 0, 100_000).await; // 1.000 TL
|
||||
buy(&app, &s, "EUR", 10_000_000, 50_000).await; // kuru yok
|
||||
|
||||
let (st, p) = app.get("/portfolio", &s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{p}");
|
||||
let g = item(&p, "XAU_GRAM");
|
||||
assert_eq!(g["value"], 6_248_822, "10 × 6.248,82 TL");
|
||||
assert_eq!(g["unrealized_pl"], 248_822);
|
||||
assert_eq!(g["rate"]["source"], "xaus");
|
||||
assert_eq!(item(&p, "USD")["value"], 501_691);
|
||||
assert_eq!(item(&p, "TRY")["value"], 100_000);
|
||||
assert_eq!(item(&p, "TRY")["rate"], Value::Null);
|
||||
assert_eq!(item(&p, "EUR")["value"], Value::Null);
|
||||
assert_eq!(p["cost_total"], 6_600_000);
|
||||
assert_eq!(p["value_total"], 6_850_513);
|
||||
assert_eq!(p["unrealized_pl_total"], 300_513);
|
||||
assert_eq!(p["unpriced"], 1, "EUR elde ama kuru yok");
|
||||
assert!(p["last_fetch"].is_string());
|
||||
|
||||
// Manuel, otomatikle aynı gün → manuel geçerli
|
||||
let (st, man) = app
|
||||
.post(
|
||||
"/rates",
|
||||
&s,
|
||||
json!({ "code": "xau_gram", "rate_e6": 6_500_000_000_i64 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{man}");
|
||||
assert_eq!(man["source"], "manual");
|
||||
let (_, p) = app.get("/portfolio", &s).await;
|
||||
assert_eq!(item(&p, "XAU_GRAM")["value"], 6_500_000);
|
||||
assert_eq!(item(&p, "XAU_GRAM")["rate"]["is_manual"], true);
|
||||
|
||||
// Eski tarihli manuel, daha yeni otomatiğin önüne geçmez
|
||||
app.post(
|
||||
"/rates",
|
||||
&s,
|
||||
json!({ "code": "USD", "rate_e6": 60_000_000, "rate_date": "2026-11-01" }),
|
||||
)
|
||||
.await;
|
||||
let (_, p) = app.get("/portfolio", &s).await;
|
||||
assert_eq!(item(&p, "USD")["value"], 501_691);
|
||||
// Daha yeni manuel geçerli; aynı gün tekrar → güncellenir (çift kayıt yok)
|
||||
app.post(
|
||||
"/rates",
|
||||
&s,
|
||||
json!({ "code": "USD", "rate_e6": 60_000_000, "rate_date": "2026-12-31" }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
"/rates",
|
||||
&s,
|
||||
json!({ "code": "USD", "rate_e6": 61_000_000, "rate_date": "2026-12-31" }),
|
||||
)
|
||||
.await;
|
||||
let (_, p) = app.get("/portfolio", &s).await;
|
||||
assert_eq!(item(&p, "USD")["value"], 610_000);
|
||||
assert_eq!(
|
||||
count(&app, doc! { "code": "USD", "rate_date": "2026-12-31" }).await,
|
||||
1
|
||||
);
|
||||
|
||||
// Başka hane: manuel kurları görmez, silemez
|
||||
let other = app.register("Diger", None).await;
|
||||
let (_, op) = app.get("/portfolio", &other).await;
|
||||
assert_eq!(item(&op, "USD")["rate"]["rate_e6"], 50_169_100);
|
||||
assert_eq!(item(&op, "XAU_GRAM")["rate"]["source"], "xaus");
|
||||
let man_id = man["id"].as_str().unwrap();
|
||||
let (st, _) = app.delete(&format!("/rates/{man_id}"), &other).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
// Manuel silinince otomatiğe dönülür
|
||||
let (st, _) = app.delete(&format!("/rates/{man_id}"), &s).await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
let (_, p) = app.get("/portfolio", &s).await;
|
||||
assert_eq!(item(&p, "XAU_GRAM")["value"], 6_248_822);
|
||||
|
||||
// Hatalı girişler
|
||||
for body in [
|
||||
json!({ "code": "TRY", "rate_e6": 1_000_000 }),
|
||||
json!({ "code": "XYZ", "rate_e6": 1_000_000 }),
|
||||
json!({ "code": "USD", "rate_e6": 0 }),
|
||||
json!({ "code": "USD", "rate_e6": 1, "rate_date": "2026-02-30" }),
|
||||
] {
|
||||
let (st, b) = app.post("/rates", &s, body.clone()).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{body} → {b}");
|
||||
}
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Kayıt politikası: REGISTRATION=invite_only (varsayılan).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::{Method, StatusCode};
|
||||
use common::TestApp;
|
||||
use kakeibo::config::RegistrationMode;
|
||||
use serde_json::json;
|
||||
|
||||
async fn new_household_allowed(app: &TestApp) -> bool {
|
||||
let (st, body, _) = app
|
||||
.send(Method::GET, "/auth/registration", None, None)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
body["new_household_allowed"].as_bool().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invite_only_allows_first_household_then_only_invites() {
|
||||
let app = TestApp::with_registration(RegistrationMode::InviteOnly).await;
|
||||
|
||||
// Boş sistem: ilk kurulum serbest
|
||||
assert!(new_household_allowed(&app).await);
|
||||
let owner = app.register("Sahip", None).await;
|
||||
|
||||
// Artık yeni hane kurulamaz (arayüz bilgisi + API zorunluluğu)
|
||||
assert!(!new_household_allowed(&app).await);
|
||||
let (st, body, _) = app
|
||||
.send(
|
||||
Method::POST,
|
||||
"/auth/register",
|
||||
None,
|
||||
Some(
|
||||
json!({ "name": "Yabancı", "email": "yabanci@test.com", "password": "sifre12345" }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["error"]["code"], "registration_closed");
|
||||
|
||||
// Davetle katılım serbest
|
||||
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
|
||||
let member = app
|
||||
.register("Es", Some(inv["code"].as_str().unwrap()))
|
||||
.await;
|
||||
assert_eq!(member.household_id, owner.household_id);
|
||||
|
||||
// Geçersiz davet kodu yine 400 (politika hatası değil)
|
||||
let (st, _, _) = app
|
||||
.send(
|
||||
Method::POST,
|
||||
"/auth/register",
|
||||
None,
|
||||
Some(json!({ "name": "X", "email": "x@test.com", "password": "sifre12345", "invite_code": "ZZZZZZZZ" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_mode_allows_many_households() {
|
||||
let app = TestApp::with_registration(RegistrationMode::Open).await;
|
||||
let a = app.register("A", None).await;
|
||||
let b = app.register("B", None).await;
|
||||
assert_ne!(a.household_id, b.household_id);
|
||||
assert!(new_household_allowed(&app).await);
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Raporlar: harcama kırılımı (grup, tüm kategoriler, ödeme türü, ay) ve filtreler.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn id_by_name(app: &TestApp, s: &Session, path: &str, name: &str) -> String {
|
||||
let (_, list) = app.get(path, s).await;
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["name"] == name)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn spend(
|
||||
app: &TestApp,
|
||||
s: &Session,
|
||||
pid: &str,
|
||||
date: &str,
|
||||
amount: i64,
|
||||
cat: &str,
|
||||
pm: Option<&str>,
|
||||
) -> Value {
|
||||
let mut body = json!({ "date": date, "amount": amount, "category_id": cat });
|
||||
if let Some(pm) = pm {
|
||||
body["payment_method_id"] = json!(pm);
|
||||
}
|
||||
let (st, e) = app.post(&format!("/periods/{pid}/entries"), s, body).await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||
e
|
||||
}
|
||||
|
||||
async fn report(app: &TestApp, s: &Session, query: &str) -> Value {
|
||||
let (st, b) = app.get(&format!("/reports/breakdown{query}"), s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{query} → {b}");
|
||||
b
|
||||
}
|
||||
|
||||
fn totals(list: &Value, key: &str) -> Vec<(String, i64)> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r[key].as_str().unwrap_or("-").to_string(),
|
||||
r["total"].as_i64().unwrap(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn breakdown_with_filters() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let market = id_by_name(&app, &s, "/categories", "Market").await;
|
||||
let giyim = id_by_name(&app, &s, "/categories", "Giyim").await;
|
||||
let kitap = id_by_name(&app, &s, "/categories", "Kitap").await;
|
||||
let nakit = id_by_name(&app, &s, "/payment-methods", "Nakit").await;
|
||||
let kart = id_by_name(&app, &s, "/payment-methods", "Kredi Kartı").await;
|
||||
|
||||
let (_, oct) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
|
||||
)
|
||||
.await;
|
||||
let oct = oct["id"].as_str().unwrap().to_string();
|
||||
spend(&app, &s, &oct, "2026-10-05", 100_000, &market, Some(&nakit)).await;
|
||||
spend(&app, &s, &oct, "2026-10-20", 50_000, &giyim, None).await;
|
||||
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
|
||||
.await;
|
||||
let (_, nov) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
|
||||
)
|
||||
.await;
|
||||
let nov = nov["id"].as_str().unwrap().to_string();
|
||||
spend(&app, &s, &nov, "2026-11-02", 30_000, &market, Some(&kart)).await;
|
||||
spend(&app, &s, &nov, "2026-11-03", 20_000, &kitap, Some(&nakit)).await;
|
||||
spend(&app, &s, &nov, "2026-11-04", 10_000, &market, Some(&nakit)).await;
|
||||
let gone = spend(&app, &s, &nov, "2026-11-05", 99_000, &kitap, None).await;
|
||||
app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
|
||||
.await;
|
||||
|
||||
// Filtresiz: tüm zamanlar
|
||||
let r = report(&app, &s, "").await;
|
||||
assert_eq!(r["total"], 210_000);
|
||||
assert_eq!(r["count"], 5, "silinen sayılmaz");
|
||||
assert_eq!(
|
||||
totals(&r["groups"], "group"),
|
||||
[
|
||||
("needs".into(), 140_000),
|
||||
("wants".into(), 50_000),
|
||||
("culture".into(), 20_000),
|
||||
("unexpected".into(), 0)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
totals(&r["categories"], "category_id"),
|
||||
[
|
||||
(market.clone(), 140_000),
|
||||
(giyim.clone(), 50_000),
|
||||
(kitap.clone(), 20_000)
|
||||
]
|
||||
);
|
||||
assert_eq!(r["categories"][0]["count"], 3);
|
||||
assert_eq!(r["categories"][0]["group"], "needs");
|
||||
assert_eq!(
|
||||
totals(&r["payment_methods"], "payment_method_id"),
|
||||
[
|
||||
(nakit.clone(), 130_000),
|
||||
("-".into(), 50_000),
|
||||
(kart.clone(), 30_000)
|
||||
],
|
||||
"ödeme türü girilmemiş → null"
|
||||
);
|
||||
assert_eq!(
|
||||
totals(&r["months"], "month"),
|
||||
[("2026-10".into(), 150_000), ("2026-11".into(), 60_000)]
|
||||
);
|
||||
|
||||
// Dönem
|
||||
assert_eq!(
|
||||
report(&app, &s, &format!("?period_id={nov}")).await["total"],
|
||||
60_000
|
||||
);
|
||||
// Tarih aralığı iki dönemi keser (ikisi de dahil)
|
||||
let r = report(&app, &s, "?from=2026-10-15&to=2026-11-03").await;
|
||||
assert_eq!(
|
||||
(r["total"].as_i64(), r["count"].as_i64()),
|
||||
(Some(100_000), Some(3))
|
||||
);
|
||||
// Grup, kategori, ödeme türü (+ girilmemiş)
|
||||
assert_eq!(report(&app, &s, "?group=needs").await["total"], 140_000);
|
||||
assert_eq!(
|
||||
report(&app, &s, &format!("?category_id={kitap}")).await["total"],
|
||||
20_000
|
||||
);
|
||||
assert_eq!(
|
||||
report(&app, &s, &format!("?payment_method_id={nakit}")).await["total"],
|
||||
130_000
|
||||
);
|
||||
assert_eq!(
|
||||
report(&app, &s, "?payment_method_id=none").await["total"],
|
||||
50_000
|
||||
);
|
||||
// Birleşik
|
||||
let r = report(
|
||||
&app,
|
||||
&s,
|
||||
&format!("?period_id={nov}&group=needs&payment_method_id={nakit}"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
(r["total"].as_i64(), r["count"].as_i64()),
|
||||
(Some(10_000), Some(1))
|
||||
);
|
||||
|
||||
// Hatalı filtreler
|
||||
for q in [
|
||||
"?from=2026-11-10&to=2026-11-01",
|
||||
"?from=2026-02-30",
|
||||
"?group=xyz",
|
||||
"?category_id=abc",
|
||||
] {
|
||||
let (st, b) = app.get(&format!("/reports/breakdown{q}"), &s).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{q} → {b}");
|
||||
}
|
||||
|
||||
// Başka hane: kendi verisi (boş); bu hanenin dönemi → 404
|
||||
let other = app.register("Diger", None).await;
|
||||
let r = report(&app, &other, "").await;
|
||||
assert_eq!(r["total"], 0);
|
||||
assert_eq!(r["groups"].as_array().unwrap().len(), 4);
|
||||
assert_eq!(r["categories"], json!([]));
|
||||
let (st, _) = app
|
||||
.get(&format!("/reports/breakdown?period_id={nov}"), &other)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Ay sonu değerlendirmesi: dört soru, grup/kategori/hafta dökümü, "Nasıl daha iyi yapabilirim?".
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use mongodb::bson::{Document, Uuid, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open_december(app: &TestApp, s: &Session, savings_goal: i64) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": savings_goal }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Gruptaki n'inci (0'dan) örnek kategori
|
||||
async fn category(app: &TestApp, s: &Session, group: &str, n: usize) -> String {
|
||||
let (_, cats) = app.get("/categories", s).await;
|
||||
cats.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|c| c["group"] == group)
|
||||
.nth(n)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64, cat: &str) -> Value {
|
||||
let (st, e) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/entries"),
|
||||
s,
|
||||
json!({ "date": date, "amount": amount, "category_id": cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||
e
|
||||
}
|
||||
|
||||
async fn plan(app: &TestApp, s: &Session, pid: &str, kind: &str, amount: i64) {
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
s,
|
||||
json!({ "kind": kind, "name": format!("{kind}-{amount}"), "amount": amount }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
|
||||
fn column(list: &Value, key: &str) -> Vec<i64> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| x[key].as_i64().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_questions_and_breakdowns() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_december(&app, &s, 500_000).await;
|
||||
plan(&app, &s, &pid, "income", 4_800_000).await;
|
||||
plan(&app, &s, &pid, "expense", 1_545_000).await;
|
||||
app.db
|
||||
.collection::<Document>("carryovers")
|
||||
.insert_one(doc! {
|
||||
"household_id": Uuid::parse_str(&s.household_id).unwrap(),
|
||||
"to_period_id": Uuid::parse_str(&pid).unwrap(),
|
||||
"kind": "debt", "amount": 100_000_i64, "deleted_at": null,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
app.put(
|
||||
&format!("/periods/{pid}/budget"),
|
||||
&s,
|
||||
json!({ "needs": 1_000_000, "wants": 500_000, "culture": 200_000, "unexpected": 100_000 }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (market, rent) = (
|
||||
category(&app, &s, "needs", 0).await,
|
||||
category(&app, &s, "needs", 1).await,
|
||||
);
|
||||
let wants = category(&app, &s, "wants", 0).await;
|
||||
let culture = category(&app, &s, "culture", 0).await;
|
||||
// Aralık 2026 haftaları: 1–6 · 7–13 · 14–20 · 21–27 · 28–31
|
||||
spend(&app, &s, &pid, "2026-12-01", 100_000, &market).await;
|
||||
spend(&app, &s, &pid, "2026-12-08", 50_000, &market).await;
|
||||
spend(&app, &s, &pid, "2026-12-08", 300_000, &rent).await;
|
||||
spend(&app, &s, &pid, "2026-12-15", 40_000, &wants).await;
|
||||
spend(&app, &s, &pid, "2026-12-31", 10_000, &culture).await;
|
||||
let gone = spend(&app, &s, &pid, "2026-12-15", 99_999, &wants).await;
|
||||
app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
|
||||
.await;
|
||||
|
||||
let (st, r) = app.get(&format!("/periods/{pid}/review"), &s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{r}");
|
||||
// 1: 4.800.000 − 1.545.000 − 100.000 = 3.155.000 · 3: 500.000 · 4: 3.155.000 − 500.000
|
||||
for (k, v) in [
|
||||
("available", 3_155_000),
|
||||
("savings_goal", 500_000),
|
||||
("spent_total", 500_000),
|
||||
("realized_savings", 2_655_000),
|
||||
("savings_gap", 2_155_000),
|
||||
("carryover_debt", 100_000),
|
||||
] {
|
||||
assert_eq!(r[k], v, "{k}: {r}");
|
||||
}
|
||||
assert_eq!(
|
||||
r["groups"],
|
||||
json!([
|
||||
{ "group": "needs", "planned": 1_000_000, "spent": 450_000 },
|
||||
{ "group": "wants", "planned": 500_000, "spent": 40_000 },
|
||||
{ "group": "culture", "planned": 200_000, "spent": 10_000 },
|
||||
{ "group": "unexpected", "planned": 100_000, "spent": 0 },
|
||||
])
|
||||
);
|
||||
// Kategoriler çoktan aza; silinen kayıt sayılmaz
|
||||
let cats = r["categories"].as_array().unwrap();
|
||||
assert_eq!(
|
||||
column(&r["categories"], "spent"),
|
||||
vec![300_000, 150_000, 40_000, 10_000]
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
cats[0]["category_id"].as_str(),
|
||||
cats[1]["category_id"].as_str()
|
||||
),
|
||||
(Some(rent.as_str()), Some(market.as_str()))
|
||||
);
|
||||
assert_eq!(
|
||||
(cats[1]["count"].as_i64(), cats[1]["group"].as_str()),
|
||||
(Some(2), Some("needs"))
|
||||
);
|
||||
// Haftalar: 4 grubun toplamı; planlananların toplamı aylık bütçelerin toplamı
|
||||
assert_eq!(
|
||||
column(&r["weeks"], "spent"),
|
||||
vec![100_000, 350_000, 40_000, 0, 10_000]
|
||||
);
|
||||
assert_eq!(
|
||||
column(&r["weeks"], "planned").iter().sum::<i64>(),
|
||||
1_800_000
|
||||
);
|
||||
assert_eq!(r["weeks"][4]["last_date"], "2026-12-31");
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overspending_gives_negative_savings() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_december(&app, &s, 100_000).await;
|
||||
plan(&app, &s, &pid, "income", 200_000).await;
|
||||
let needs = category(&app, &s, "needs", 0).await;
|
||||
spend(&app, &s, &pid, "2026-12-10", 250_000, &needs).await;
|
||||
|
||||
let (_, r) = app.get(&format!("/periods/{pid}/review"), &s).await;
|
||||
assert_eq!(
|
||||
(
|
||||
r["available"].as_i64(),
|
||||
r["realized_savings"].as_i64(),
|
||||
r["savings_gap"].as_i64()
|
||||
),
|
||||
(Some(200_000), Some(-50_000), Some(-150_000))
|
||||
);
|
||||
// Hiç bütçe yoksa: gruplar 0 planlı, haftalar yine dönem takviminden
|
||||
assert_eq!(column(&r["groups"], "planned"), vec![0; 4]);
|
||||
assert_eq!(r["weeks"].as_array().unwrap().len(), 5);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflection_text_and_access_rules() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let other = app.register("Diger", None).await;
|
||||
let pid = open_december(&app, &s, 0).await;
|
||||
let url = format!("/periods/{pid}");
|
||||
|
||||
let (st, p) = app
|
||||
.patch(
|
||||
&url,
|
||||
&s,
|
||||
json!({ "reflection_md": " **Dışarıda yemeği** azalt. " }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{p}");
|
||||
assert_eq!(p["reflection_md"], "**Dışarıda yemeği** azalt.");
|
||||
let (_, p) = app.get(&url, &s).await;
|
||||
assert_eq!(p["reflection_md"], "**Dışarıda yemeği** azalt.");
|
||||
|
||||
// Türkçe karakterler tek sayılır; sınırın üstü 400
|
||||
let (st, _) = app
|
||||
.patch(&url, &s, json!({ "reflection_md": "ş".repeat(20_000) }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
let (st, b) = app
|
||||
.patch(&url, &s, json!({ "reflection_md": "a".repeat(20_001) }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
b["error"]["message"],
|
||||
"Metin en fazla 20.000 karakter olabilir"
|
||||
);
|
||||
|
||||
// Boş metin → kaldırılır
|
||||
let (_, p) = app.patch(&url, &s, json!({ "reflection_md": "" })).await;
|
||||
assert_eq!(p["reflection_md"], Value::Null);
|
||||
|
||||
// Başka hane göremez
|
||||
let (st, _) = app.get(&format!("{url}/review"), &other).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
// Kapalı dönem: değerlendirme okunur, metin değiştirilemez
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(
|
||||
doc! { "_id": Uuid::parse_str(&pid).unwrap() },
|
||||
doc! { "$set": { "status": "closed" } },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (st, _) = app.get(&format!("{url}/review"), &s).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
let (st, _) = app.patch(&url, &s, json!({ "reflection_md": "geç" })).await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Dönem kapanışı → birikim: onayla TL varlığına ekleme, bekleyenler, yeniden kapatmada
|
||||
//! bağlı kaydın güncellenmesi / silinmesi, birikimden çıkış varsa reddedilmesi.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Ekim: gelir 10.000, harcama 3.000 → gerçekleşen 7.000; `to_savings` ile kapatılır
|
||||
async fn october(app: &TestApp, s: &Session, to_savings: i64) -> String {
|
||||
let (_, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
|
||||
)
|
||||
.await;
|
||||
let id = p["id"].as_str().unwrap().to_string();
|
||||
app.post(
|
||||
&format!("/periods/{id}/plan-items"),
|
||||
s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
|
||||
)
|
||||
.await;
|
||||
let (_, cats) = app.get("/categories", s).await;
|
||||
let cat = cats[0]["id"].as_str().unwrap();
|
||||
app.post(
|
||||
&format!("/periods/{id}/entries"),
|
||||
s,
|
||||
json!({ "date": "2026-10-10", "amount": 300_000, "category_id": cat }),
|
||||
)
|
||||
.await;
|
||||
close(app, s, &id, to_savings).await;
|
||||
id
|
||||
}
|
||||
|
||||
async fn close(app: &TestApp, s: &Session, id: &str, to_savings: i64) -> Value {
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{id}/close"),
|
||||
s,
|
||||
json!({ "to_savings": to_savings }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
b
|
||||
}
|
||||
|
||||
async fn reopen(app: &TestApp, s: &Session, id: &str) {
|
||||
let (st, b) = app
|
||||
.post(&format!("/periods/{id}/reopen"), s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
}
|
||||
|
||||
async fn cash(app: &TestApp, s: &Session) -> Value {
|
||||
let (_, list) = app.get("/assets", s).await;
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|a| a["code"] == "TRY")
|
||||
.unwrap()
|
||||
.clone()
|
||||
}
|
||||
|
||||
async fn cash_txs(app: &TestApp, s: &Session) -> Vec<Value> {
|
||||
let id = cash(app, s).await["id"].as_str().unwrap().to_string();
|
||||
let (_, list) = app.get(&format!("/assets/{id}/transactions"), s).await;
|
||||
list.as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_closing_savings_once() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let oct = october(&app, &s, 500_000).await;
|
||||
|
||||
let (_, pending) = app.get("/savings/pending", &s).await;
|
||||
assert_eq!(
|
||||
pending,
|
||||
json!([{ "period_id": oct, "name": "Ekim 2026", "last_date": "2026-10-31", "to_savings": 500_000 }])
|
||||
);
|
||||
|
||||
let (st, t) = app
|
||||
.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{t}");
|
||||
assert_eq!(t["type"], "buy");
|
||||
assert_eq!(t["total"], 500_000);
|
||||
assert_eq!(t["date"], "2026-10-31");
|
||||
assert_eq!(t["note"], "Ekim 2026 kapanışı");
|
||||
assert_eq!(t["source_period_id"], oct.as_str());
|
||||
assert_eq!(cash(&app, &s).await["cost"], 500_000);
|
||||
|
||||
let (st, b) = app
|
||||
.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "{b}");
|
||||
let (_, pending) = app.get("/savings/pending", &s).await;
|
||||
assert_eq!(pending, json!([]));
|
||||
|
||||
// Açık dönem eklenemez; başka hane göremez
|
||||
let (_, nov) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
|
||||
)
|
||||
.await;
|
||||
let (st, _) = app
|
||||
.post(
|
||||
&format!("/periods/{}/to-savings", nov["id"].as_str().unwrap()),
|
||||
&s,
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
let other = app.register("Diger", None).await;
|
||||
let (st, _) = app
|
||||
.post(&format!("/periods/{oct}/to-savings"), &other, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (_, op) = app.get("/savings/pending", &other).await;
|
||||
assert_eq!(op, json!([]));
|
||||
|
||||
// Silinen kayıt → dönem yeniden "bekleyen" olur
|
||||
let (st, _) = app
|
||||
.delete(
|
||||
&format!("/asset-transactions/{}", t["id"].as_str().unwrap()),
|
||||
&s,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
let (_, pending) = app.get("/savings/pending", &s).await;
|
||||
assert_eq!(pending.as_array().unwrap().len(), 1);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclosing_updates_or_removes_linked_savings() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let oct = october(&app, &s, 500_000).await;
|
||||
app.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
|
||||
.await;
|
||||
|
||||
// Yeni tutarla yeniden kapat → kayıt güncellenir
|
||||
reopen(&app, &s, &oct).await;
|
||||
let b = close(&app, &s, &oct, 400_000).await;
|
||||
assert_eq!(b["savings_linked"], true);
|
||||
let txs = cash_txs(&app, &s).await;
|
||||
assert_eq!(txs.len(), 1);
|
||||
assert_eq!(txs[0]["total"], 400_000);
|
||||
assert_eq!(txs[0]["version"], 2);
|
||||
assert_eq!(cash(&app, &s).await["cost"], 400_000);
|
||||
|
||||
// Birikim 0 → kayıt silinir; bekleyen de yok
|
||||
reopen(&app, &s, &oct).await;
|
||||
let b = close(&app, &s, &oct, 0).await;
|
||||
assert_eq!(b["savings_linked"], false);
|
||||
assert!(cash_txs(&app, &s).await.is_empty());
|
||||
let (_, pending) = app.get("/savings/pending", &s).await;
|
||||
assert_eq!(pending, json!([]));
|
||||
|
||||
// Kayıt yokken yeniden kapat → otomatik eklenmez, bekleyenlere düşer
|
||||
reopen(&app, &s, &oct).await;
|
||||
let b = close(&app, &s, &oct, 600_000).await;
|
||||
assert_eq!(b["savings_linked"], false);
|
||||
assert!(cash_txs(&app, &s).await.is_empty());
|
||||
let (_, pending) = app.get("/savings/pending", &s).await;
|
||||
assert_eq!(pending[0]["to_savings"], 600_000);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclosing_is_refused_when_savings_were_spent() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let oct = october(&app, &s, 500_000).await;
|
||||
app.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
|
||||
.await;
|
||||
// Birikimden 4.500 TL çıkış
|
||||
let cash_id = cash(&app, &s).await["id"].as_str().unwrap().to_string();
|
||||
let (st, _) = app
|
||||
.post(
|
||||
&format!("/assets/{cash_id}/transactions"),
|
||||
&s,
|
||||
json!({ "type": "sell", "date": "2026-11-05", "total": 450_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
reopen(&app, &s, &oct).await;
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{oct}/close"),
|
||||
&s,
|
||||
json!({ "to_savings": 300_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "{b}");
|
||||
assert!(
|
||||
b["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("TL çıkışları"),
|
||||
"{b}"
|
||||
);
|
||||
// Hiçbir şey değişmedi: dönem düzeltmede, kayıt 5.000 TL
|
||||
let (_, p) = app.get(&format!("/periods/{oct}"), &s).await;
|
||||
assert_eq!(p["status"], "correcting");
|
||||
let buy = cash_txs(&app, &s)
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|t| t["type"] == "buy")
|
||||
.unwrap();
|
||||
assert_eq!(buy["total"], 500_000);
|
||||
// Yeterli tutarla kapanır
|
||||
let b = close(&app, &s, &oct, 450_000).await;
|
||||
assert_eq!(b["savings_linked"], true);
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Panel verileri: günlük harcama ve dönem istatistikleri (toplam birikim dahil).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open(app: &TestApp, s: &Session, start: &str, end: &str, goal: i64) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": start, "end_date": end, "savings_goal": goal }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn category(app: &TestApp, s: &Session, group: &str) -> String {
|
||||
let (_, cats) = app.get("/categories", s).await;
|
||||
cats.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["group"] == group)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64, cat: &str) -> Value {
|
||||
let (st, e) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/entries"),
|
||||
s,
|
||||
json!({ "date": date, "amount": amount, "category_id": cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||
e
|
||||
}
|
||||
|
||||
async fn income(app: &TestApp, s: &Session, pid: &str, amount: i64) {
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": amount }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
|
||||
/// Kasım: gelir 10.000, harcama 3.000, 5.000 birikime → 2.000 artı Aralık'a.
|
||||
/// Aralık: gelir 20.000, hedef 1.000 → harcanabilir 21.000.
|
||||
async fn two_periods(app: &TestApp, s: &Session) -> (String, String) {
|
||||
let needs = category(app, s, "needs").await;
|
||||
let nov = open(app, s, "2026-11-01", "2026-12-01", 0).await;
|
||||
income(app, s, &nov, 1_000_000).await;
|
||||
spend(app, s, &nov, "2026-11-30", 300_000, &needs).await;
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{nov}/close"),
|
||||
s,
|
||||
json!({ "to_savings": 500_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
let dec = open(app, s, "2026-12-01", "2027-01-01", 100_000).await;
|
||||
income(app, s, &dec, 2_000_000).await;
|
||||
(nov, dec)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn daily_spending_by_group() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let (_, dec) = two_periods(&app, &s).await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let wants = category(&app, &s, "wants").await;
|
||||
spend(&app, &s, &dec, "2026-12-01", 100_000, &needs).await;
|
||||
spend(&app, &s, &dec, "2026-12-01", 50_000, &wants).await;
|
||||
spend(&app, &s, &dec, "2026-12-07", 20_000, &needs).await;
|
||||
spend(&app, &s, &dec, "2026-12-31", 7_000, &needs).await;
|
||||
let gone = spend(&app, &s, &dec, "2026-12-02", 99_000, &needs).await;
|
||||
let (st, _) = app
|
||||
.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
|
||||
let (st, days) = app.get(&format!("/periods/{dec}/daily"), &s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{days}");
|
||||
let days = days.as_array().unwrap();
|
||||
assert_eq!(days.len(), 31, "her gün, harcama olmasa da");
|
||||
assert_eq!(days[0]["date"], "2026-12-01");
|
||||
assert_eq!(
|
||||
days[0]["spent"],
|
||||
json!({ "needs": 100_000, "wants": 50_000, "culture": 0, "unexpected": 0 })
|
||||
);
|
||||
assert_eq!(days[1]["spent"]["needs"], 0, "silinen kayıt sayılmaz");
|
||||
assert_eq!(days[6]["spent"]["needs"], 20_000);
|
||||
assert_eq!(days[30]["date"], "2026-12-31");
|
||||
assert_eq!(days[30]["spent"]["needs"], 7_000, "son gün dahil");
|
||||
// Kasım'ın 30.11 harcaması Aralık'a karışmaz
|
||||
let total: i64 = days
|
||||
.iter()
|
||||
.map(|d| d["spent"]["needs"].as_i64().unwrap())
|
||||
.sum();
|
||||
assert_eq!(total, 127_000);
|
||||
|
||||
// Başka hane göremez
|
||||
let other = app.register("Diger", None).await;
|
||||
let (st, _) = app.get(&format!("/periods/{dec}/daily"), &other).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn period_stats_and_total_savings() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let (nov, dec) = two_periods(&app, &s).await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let culture = category(&app, &s, "culture").await;
|
||||
spend(&app, &s, &dec, "2026-12-05", 40_000, &needs).await;
|
||||
spend(&app, &s, &dec, "2026-12-06", 2_500, &culture).await;
|
||||
|
||||
let (st, b) = app.get("/stats/periods", &s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
assert_eq!(b["total_saved"], 500_000);
|
||||
let list = b["periods"].as_array().unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
// Eskiden yeniye
|
||||
assert_eq!(list[0]["id"], nov.as_str());
|
||||
assert_eq!(list[0]["status"], "closed");
|
||||
assert_eq!(list[0]["spendable"], 1_000_000);
|
||||
assert_eq!(list[0]["spent"], 300_000);
|
||||
assert_eq!(
|
||||
list[0]["spent_by_group"],
|
||||
json!({ "needs": 300_000, "wants": 0, "culture": 0, "unexpected": 0 })
|
||||
);
|
||||
assert_eq!(list[0]["to_savings"], 500_000);
|
||||
assert_eq!(list[0]["last_date"], "2026-11-30");
|
||||
assert_eq!(list[1]["id"], dec.as_str());
|
||||
assert_eq!(
|
||||
list[1]["spendable"], 2_100_000,
|
||||
"20.000 − 1.000 hedef + 2.000 artı"
|
||||
);
|
||||
assert_eq!(list[1]["spent"], 42_500);
|
||||
assert_eq!(list[1]["spent_by_group"]["needs"], 40_000);
|
||||
assert_eq!(list[1]["spent_by_group"]["culture"], 2_500);
|
||||
assert_eq!(list[1]["income_total"], 2_000_000);
|
||||
assert_eq!(list[1]["savings_goal"], 100_000);
|
||||
assert_eq!(list[1]["carryover_surplus"], 200_000);
|
||||
assert_eq!(
|
||||
list[0]["realized_savings"], 700_000,
|
||||
"Kasım: 10.000 − 3.000"
|
||||
);
|
||||
assert_eq!(
|
||||
list[1]["realized_savings"], 2_157_500,
|
||||
"20.000 + 2.000 artı − 425 harcanan (hedef düşülmez)"
|
||||
);
|
||||
assert_eq!(list[1]["to_savings"], Value::Null);
|
||||
|
||||
// limit: en yeni N dönem
|
||||
let (_, b) = app.get("/stats/periods?limit=1", &s).await;
|
||||
let list = b["periods"].as_array().unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0]["id"], dec.as_str());
|
||||
assert_eq!(
|
||||
b["total_saved"], 500_000,
|
||||
"toplam birikim limitten bağımsız"
|
||||
);
|
||||
|
||||
// Başka hane: boş
|
||||
let other = app.register("Diger", None).await;
|
||||
let (_, b) = app.get("/stats/periods", &other).await;
|
||||
assert_eq!(b["total_saved"], 0);
|
||||
assert_eq!(b["periods"], json!([]));
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! CSV dışa / içe aktarma: biçim, deneme → aktarma, satır hataları, tekrar atlama,
|
||||
//! varlıkta tarih sırasıyla eldekinden fazla satış denetimi.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn id_by(app: &TestApp, s: &Session, path: &str, key: &str, value: &str) -> String {
|
||||
let (_, list) = app.get(path, s).await;
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c[key] == value)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn open(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": start, "end_date": end }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn import(app: &TestApp, s: &Session, kind: &str, csv: &str, commit: bool) -> Value {
|
||||
let (st, r) = app
|
||||
.post_text(&format!("/import/{kind}?commit={commit}"), s, csv)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{r}");
|
||||
r
|
||||
}
|
||||
|
||||
fn errors(r: &Value) -> Vec<(u64, String)> {
|
||||
r["errors"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e["line"].as_u64().unwrap(),
|
||||
e["message"].as_str().unwrap().to_string(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn entries_export_and_import() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let market = id_by(&app, &s, "/categories", "name", "Market").await;
|
||||
let nakit = id_by(&app, &s, "/payment-methods", "name", "Nakit").await;
|
||||
// Ekim kapalı, Kasım açık
|
||||
let oct = open(&app, &s, "2026-10-01", "2026-11-01").await;
|
||||
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
|
||||
.await;
|
||||
let nov = open(&app, &s, "2026-11-01", "2026-12-01").await;
|
||||
app.post(&format!("/periods/{nov}/entries"), &s, json!({ "date": "2026-11-05", "amount": 125_050, "category_id": market, "payment_method_id": nakit, "description": "Kafe; tatlı" })).await;
|
||||
|
||||
let (st, ct, csv) = app.get_text("/export/entries.csv", &s).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert!(ct.starts_with("text/csv"), "{ct}");
|
||||
assert_eq!(
|
||||
csv,
|
||||
"\u{FEFF}Tarih;Tutar;Grup;Kategori;Ödeme türü;Açıklama\r\n05.11.2026;1250,50;Zorunlu;Market;Nakit;\"Kafe; tatlı\"\r\n"
|
||||
);
|
||||
|
||||
// Aynı dosya geri yüklenirse çift kayıt olmaz
|
||||
let r = import(&app, &s, "entries", &csv, true).await;
|
||||
assert_eq!(
|
||||
(
|
||||
r["total"].as_u64(),
|
||||
r["valid"].as_u64(),
|
||||
r["duplicates"].as_u64(),
|
||||
r["imported"].as_u64()
|
||||
),
|
||||
(Some(1), Some(0), Some(1), Some(0))
|
||||
);
|
||||
|
||||
// Sütun sırası serbest, ayrı yazımlar; hatalı satırlar raporlanır
|
||||
let file = "Açıklama;Kategori;Tutar;Tarih;Grup;Ödeme türü\r\n\
|
||||
Ekmek;market;45,90;06.11.2026;;\r\n\
|
||||
x;Market;10;31.02.2026;;\r\n\
|
||||
x;Market;10;15.09.2026;;\r\n\
|
||||
x;Market;10;15.10.2026;;\r\n\
|
||||
x;Uzay;10;06.11.2026;;\r\n\
|
||||
x;Market;0;06.11.2026;;\r\n\
|
||||
x;Market;10;06.11.2026;;Kripto\r\n\
|
||||
x;Market;10;06.11.2026;İstek;\r\n\
|
||||
Kitaplar;Kitap;1.200;2026-11-07;Kültürel;nakit\r\n\
|
||||
Kafe; tatlı;Market;1.250,50;05.11.2026;Zorunlu;Nakit\r\n";
|
||||
// Son satırda tırnaksız ';' → sütunlar kayar: açıkça hata olmalı
|
||||
let dry = import(&app, &s, "entries", file, false).await;
|
||||
assert_eq!(dry["total"], 10);
|
||||
assert_eq!(dry["valid"], 2);
|
||||
assert_eq!(dry["imported"], 0, "deneme yazmaz");
|
||||
assert_eq!(
|
||||
errors(&dry),
|
||||
vec![
|
||||
(3, "Tarih geçersiz: 31.02.2026".into()),
|
||||
(4, "Bu tarih için dönem yok".into()),
|
||||
(5, "Dönem kapalı (Ekim 2026)".into()),
|
||||
(6, "Kategori bulunamadı: Uzay".into()),
|
||||
(7, "Tutar sıfırdan büyük olmalı".into()),
|
||||
(8, "Ödeme türü bulunamadı: Kripto".into()),
|
||||
(9, "Kategori bulunamadı: Market".into()),
|
||||
(11, "Tarih geçersiz: 1.250,50".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
dry["preview"][0],
|
||||
json!(["06.11.2026", "45,90", "Market", "Ekmek"])
|
||||
);
|
||||
let (_, before) = app.get(&format!("/periods/{nov}/entries"), &s).await;
|
||||
assert_eq!(before.as_array().unwrap().len(), 1);
|
||||
|
||||
let done = import(&app, &s, "entries", file, true).await;
|
||||
assert_eq!(done["imported"], 2);
|
||||
let (_, list) = app.get(&format!("/periods/{nov}/entries"), &s).await;
|
||||
let list = list.as_array().unwrap();
|
||||
assert_eq!(list.len(), 3);
|
||||
let book = list
|
||||
.iter()
|
||||
.find(|e| e["description"] == "Kitaplar")
|
||||
.unwrap();
|
||||
assert_eq!(book["amount"], 120_000, "1.200 → bin iki yüz");
|
||||
assert_eq!(book["group"], "culture");
|
||||
assert_eq!(
|
||||
book["payment_method_id"],
|
||||
nakit.as_str(),
|
||||
"ad büyük/küçük harf duyarsız"
|
||||
);
|
||||
|
||||
// Dosyada 2 aynı satır, veritabanında 1 → biri eklenir
|
||||
let twice = "Tarih;Tutar;Kategori;Açıklama\r\n06.11.2026;45,90;Market;Ekmek\r\n06.11.2026;45,90;Market;Ekmek\r\n";
|
||||
let r = import(&app, &s, "entries", twice, true).await;
|
||||
assert_eq!(
|
||||
(r["duplicates"].as_u64(), r["imported"].as_u64()),
|
||||
(Some(1), Some(1))
|
||||
);
|
||||
|
||||
// Biçim hataları
|
||||
let (st, b) = app
|
||||
.post_text(
|
||||
"/import/entries?commit=false",
|
||||
&s,
|
||||
"Tarih;Tutar\r\n01.11.2026;5\r\n",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(b["error"]["message"], "Gerekli sütun eksik: Kategori");
|
||||
let (st, _) = app.post_text("/import/entries?commit=false", &s, "").await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
let (st, _) = app.post_text("/import/bilinmeyen", &s, "a\r\n").await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
// Başka hane yalnızca kendi verisini dışa aktarır
|
||||
let other = app.register("Diger", None).await;
|
||||
let (_, _, csv) = app.get_text("/export/entries.csv", &other).await;
|
||||
assert_eq!(csv.lines().count(), 1, "yalnızca başlık");
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_items_roundtrip() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let nov = open(&app, &s, "2026-11-01", "2026-12-01").await;
|
||||
app.post(
|
||||
&format!("/periods/{nov}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000 }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&format!("/periods/{nov}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-11-05" }),
|
||||
)
|
||||
.await;
|
||||
let (_, _, csv) = app.get_text("/export/plan-items.csv", &s).await;
|
||||
assert_eq!(
|
||||
csv,
|
||||
"\u{FEFF}Dönem başlangıcı;Dönem;Tür;Ad;Tutar;Tarih\r\n01.11.2026;Kasım 2026;Gelir;Maaş;45000,00;\r\n01.11.2026;Kasım 2026;Sabit gider;Kira;15000,00;05.11.2026\r\n"
|
||||
);
|
||||
|
||||
// Başka hane: aynı dönemi açıp içe aktarır
|
||||
let other = app.register("Diger", None).await;
|
||||
let onov = open(&app, &other, "2026-11-01", "2026-12-01").await;
|
||||
let with_errors = format!(
|
||||
"{csv}01.11.2026;Kasım 2026;Prim;Bonus;100,00;\r\n01.01.2026;;Gelir;Eski;1,00;\r\n"
|
||||
);
|
||||
let r = import(&app, &other, "plan-items", &with_errors, true).await;
|
||||
assert_eq!(r["imported"], 2);
|
||||
assert_eq!(
|
||||
errors(&r),
|
||||
vec![
|
||||
(4, "Tür \"Gelir\" ya da \"Sabit gider\" olmalı: Prim".into()),
|
||||
(5, "Bu tarih için dönem yok".into()),
|
||||
]
|
||||
);
|
||||
let (_, items) = app
|
||||
.get(&format!("/periods/{onov}/plan-items"), &other)
|
||||
.await;
|
||||
assert_eq!(items.as_array().unwrap().len(), 2);
|
||||
// Tekrar → atlanır
|
||||
let r = import(&app, &other, "plan-items", &csv, true).await;
|
||||
assert_eq!(
|
||||
(r["duplicates"].as_u64(), r["imported"].as_u64()),
|
||||
(Some(2), Some(0))
|
||||
);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn asset_transactions_in_date_order() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
// Satış dosyada alımdan önce ama tarihi sonra → geçerli; fazla satış → hata
|
||||
let file = "Tarih;Varlık kodu;İşlem;Miktar;Birim fiyat;Tutar;Not\r\n\
|
||||
10.11.2026;XAU_GRAM;Satım;4;;26.000;\r\n\
|
||||
01.11.2026;XAU_GRAM;Alım;10;6.300;63.000;Kuyumcu\r\n\
|
||||
12.11.2026;XAU_GRAM;Satış;7;;45.000;\r\n\
|
||||
02.11.2026;TRY;Alım;;;5.000;\r\n\
|
||||
02.11.2026;BTC;Alım;1;;1;\r\n\
|
||||
03.11.2026;USD;Alım;abc;;100;\r\n";
|
||||
let dry = import(&app, &s, "asset-transactions", file, false).await;
|
||||
assert_eq!(dry["valid"], 3);
|
||||
assert_eq!(
|
||||
errors(&dry),
|
||||
vec![
|
||||
(4, "Satış miktarı o tarihte eldeki miktarı aşıyor".into()),
|
||||
(6, "Varlık bulunamadı: BTC".into()),
|
||||
(7, "Miktar geçersiz: abc".into()),
|
||||
]
|
||||
);
|
||||
let r = import(&app, &s, "asset-transactions", file, true).await;
|
||||
assert_eq!(r["imported"], 3);
|
||||
let (_, assets) = app.get("/assets", &s).await;
|
||||
let gram = assets
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|a| a["code"] == "XAU_GRAM")
|
||||
.unwrap();
|
||||
assert_eq!(gram["quantity_e6"], 6_000_000);
|
||||
assert_eq!(gram["realized_pl"], 80_000);
|
||||
let tl = assets
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|a| a["code"] == "TRY")
|
||||
.unwrap();
|
||||
assert_eq!(tl["cost"], 500_000);
|
||||
|
||||
// Dışa aktar → geri yükle: hepsi tekrar
|
||||
let (_, _, csv) = app.get_text("/export/asset-transactions.csv", &s).await;
|
||||
assert!(
|
||||
csv.contains("01.11.2026;XAU_GRAM;Gram Altın;Alım;10;6300;63000,00;Kuyumcu"),
|
||||
"{csv}"
|
||||
);
|
||||
let r = import(&app, &s, "asset-transactions", &csv, false).await;
|
||||
assert_eq!(
|
||||
(r["duplicates"].as_u64(), r["valid"].as_u64()),
|
||||
(Some(3), Some(0))
|
||||
);
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Bu bilgisayar: ~/.config/systemd/user/kakeibo-backup-pull.service
|
||||
[Unit]
|
||||
Description=Kakeibo yedeklerini sunucudan çek
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# ~/.ssh/config'teki sunucu adı "atlas" değilse başındaki # işaretini kaldırıp düzeltin
|
||||
#Environment=KAKEIBO_BACKUP_HOST=atlas
|
||||
ExecStart=%h/Developer/Rust/kakeibo/scripts/backup-pull.sh
|
||||
@@ -0,0 +1,12 @@
|
||||
# Bu bilgisayar: ~/.config/systemd/user/kakeibo-backup-pull.timer
|
||||
[Unit]
|
||||
Description=Kakeibo yedeklerini her gün çek
|
||||
|
||||
[Timer]
|
||||
# Sunucudaki yedekten sonra; bilgisayar kapalıysa açıldıktan 10 dk sonra
|
||||
OnCalendar=*-*-* 09:00:00
|
||||
OnStartupSec=10min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,13 @@
|
||||
# Atlas (sunucu): /etc/systemd/system/kakeibo-backup.service
|
||||
[Unit]
|
||||
Description=Kakeibo MongoDB günlük yedeği
|
||||
Wants=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Yedeği çeken SSH kullanıcısı root değilse: başındaki # işaretini kaldırıp grubunu yazın.
|
||||
#Environment=BACKUP_GROUP=<ssh-kullanıcınız>
|
||||
ExecStart=/usr/local/lib/kakeibo/backup.sh
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
@@ -0,0 +1,12 @@
|
||||
# Atlas (sunucu): /etc/systemd/system/kakeibo-backup.timer
|
||||
[Unit]
|
||||
Description=Kakeibo yedeği her gece 03:30
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:30:00
|
||||
# Sunucu o saatte kapalıysa açılınca hemen çalışır
|
||||
Persistent=true
|
||||
RandomizedDelaySec=5m
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
+26
-8
@@ -81,17 +81,19 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
```js
|
||||
{ _id, household_id, name /* "Aralık 2026" */,
|
||||
start_date: "2026-12-01", end_date: "2027-01-01", // bitiş HARİÇ
|
||||
status: "open" | "closed",
|
||||
status: "open" | "closed" | "correcting" | "archived",
|
||||
savings_goal: 500000, // kuruş — birikim hedefi
|
||||
reflection_md: "...", // "Nasıl daha iyi yapabilirim?"
|
||||
closed_at, closed_by, reopened_at,
|
||||
to_savings: 450000, // kapanışta birikime aktarılan (Faz 8'de varlık hareketi)
|
||||
closed_at, closed_by, reopened_at, reopened_by,
|
||||
previous_period_id,
|
||||
created_at, updated_at, deleted_at, version }
|
||||
```
|
||||
- **Bitiş tarihi hariç:** `01.12.2026 – 01.01.2027` → 1–31 Aralık. Dönemler arka arkaya boşluksuz ve çakışmasız eklenir.
|
||||
- Hanede en fazla **bir** `open` dönem (servis kontrolü + kısmi benzersiz indeks).
|
||||
- Yeniden açılan dönemin durumu tekrar `open` olur. O sırada başka açık dönem varsa önce o kapatılmalı mı, yoksa geçici olarak iki açık dönem mi olsun? → **Açık konu, Faz 6'da netleşecek.**
|
||||
- **Yeniden açma (karar 2026-11-12, "düzeltme modu"):** sahip kapalı dönemi açınca durum `correcting` olur; yazılabilir ama "açık dönem" sayılmaz → yeni dönem açık kalabilir (tek `open` kuralı korunur). Mevcut devir "yeniden kapat"a kadar yerinde kalır; yeniden kapatınca eskisi soft delete, yenisi hesaplanır. Ardından kapanmış dönem varsa açılamaz (zincirleme devirler bozulmasın).
|
||||
- **Haftalar saklanmaz.** `start_date`/`end_date`'ten hesaplanır (Pazartesi başlangıçlı; ilk/son kısmi haftalar ayrı).
|
||||
- **Arşiv (`archived`, karar 2026-11-13):** ekstre içe aktarımında dönemi olmayan aylar için takvim ayı kadar açılır (var olan dönemlerle çakışmayacak şekilde kırpılır). Yazılabilir (kayıt düzeltme), ama kapatılmaz, devir üretmez; devir zincirinde (yeni dönemin önceki dönemi, kapatmada sonraki dönem, yeniden açma engeli) **atlanır**. Bütçe önerileri ("önceki dönem", "son 3 ay") kapalı **ve** arşiv dönemleri kullanır.
|
||||
|
||||
### 3.5 `categories` — Alt kategoriler
|
||||
```js
|
||||
@@ -136,6 +138,15 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
description: "Haftalık market",
|
||||
created_by /* user_id */, created_at, ... }
|
||||
```
|
||||
- **İade** (ekstreden, karar 2026-11-13): aynı kategoride **eksi** `amount`; toplamlar net olur. Elle girişte tutar > 0; iade düzenlemede eksi kalır.
|
||||
- `import_key` (isteğe bağlı): ekstreden gelen kayıtta `tarih|yön|tutar|sadeleştirilmiş açıklama` → aynı satır ikinci kez aktarılmaz (plan kalemlerinde de, gelir için).
|
||||
|
||||
### 3.9a `import_rules` — Ekstre öğrenilmiş kuralları
|
||||
```js
|
||||
{ _id, household_id, pattern /* açıklamanın ilk 2 anlamlı kelimesi, ASCII küçük: "migros istanbul" */,
|
||||
kind: "expense" | "refund" | "income" | "ignore", category_id, uses, created_at, updated_at, version }
|
||||
```
|
||||
- Aktarmada her seçim kural olarak yazılır (upsert); sonraki ekstrede aynı `pattern` öneri olur.
|
||||
|
||||
### 3.10 `carryovers` — Devirler
|
||||
```js
|
||||
@@ -143,7 +154,8 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
kind: "debt" | "surplus", amount: 120000,
|
||||
created_at, ... }
|
||||
```
|
||||
- Dönem kapanışında oluşur. Dönem yeniden açılırsa soft delete edilir ve kapanışta yeniden hesaplanır.
|
||||
- Dönem kapanışında oluşur. Sonraki dönem henüz yoksa `to_period_id: null`; o dönem açılınca (aynı transaction'da) bağlanır.
|
||||
- Yeniden kapatmada eskisi soft delete edilir, yenisi yazılır (düzeltme süresince eskisi geçerli kalır).
|
||||
|
||||
### 3.11 `notes`
|
||||
```js
|
||||
@@ -158,8 +170,9 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
kind: "cash" | "gold" | "fx",
|
||||
code: "TRY" | "XAU_GRAM" | "XAU_CEYREK" | "XAU_YARIM" | "XAU_TAM" | "XAU_ONS" | "USD" | "EUR" | ...,
|
||||
name: "Gram Altın", unit_label: "gr",
|
||||
is_active, created_at, ... }
|
||||
sort_order, is_active, created_at, ... }
|
||||
```
|
||||
- Varsayılanlar hane açılınca gelir (migration v2 eski hanelere ekler): TRY, XAU_GRAM, XAU_CEYREK, XAU_YARIM, XAU_TAM, XAU_ONS, USD, EUR. Kullanıcı yalnızca **döviz** (`fx`, 3 harfli kod) ekler; kod hanede tekil. Pasif varlık listede kalır, yeni işlem almaz.
|
||||
|
||||
### 3.13 `asset_transactions` — Alım / satım
|
||||
```js
|
||||
@@ -172,7 +185,10 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
note, source_period_id /* dönem kapanışından geldiyse */,
|
||||
created_at, ... }
|
||||
```
|
||||
- Ortalama maliyet ve kâr/zarar bu kayıtlardan hesaplanır, saklanmaz.
|
||||
- Ortalama maliyet ve kâr/zarar bu kayıtlardan hesaplanır, saklanmaz: **ağırlıklı ortalama maliyet** (satışta çıkan maliyet = maliyet × satılan / eldeki; gerçekleşen kâr/zarar = satış tutarı − çıkan maliyet). Maliyet `total`'dan hesaplanır (birim fiyat bilgi amaçlı; verilmezse `total ÷ quantity`).
|
||||
- Tarih sırasıyla (aynı gün: ekleniş sırası) hiçbir anda eldeki miktar eksiye düşemez; ekleme, düzenleme ve silmede yeniden denetlenir.
|
||||
- TL varlığında `quantity_e6 = total × 10.000`, `unit_price_e6 = 1.000.000`.
|
||||
- **Kapanıştan birikim:** kullanıcı onaylayınca (`POST /periods/{id}/to-savings`) dönemin `to_savings` tutarı TL varlığına `buy` olarak eklenir: tarih = dönemin son günü, `source_period_id` = dönem; dönem başına tek. Eklenmemiş kapalı dönemler `GET /savings/pending`. **Yeniden kapatmada** bağlı kayıt yeni tutar ve tarihe güncellenir (tutar 0 → silinir), kapatma transaction'ı içinde; TL'den sonraki çıkışlar yeni tutarı aşacaksa kapatma reddedilir (kullanıcı kararı "a", 2026-11-13). Bağlı kayıt yoksa otomatik eklenmez (onay gerekir).
|
||||
|
||||
### 3.14 `exchange_rates` — Kurlar
|
||||
```js
|
||||
@@ -182,7 +198,9 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
rate_date: "2026-12-15", fetched_at,
|
||||
created_at, ... }
|
||||
```
|
||||
- Güncel kur sırası: hanenin elle girdiği en yeni kur → en yeni otomatik kur.
|
||||
- Güncel kur: kod başına **en yeni `rate_date`**; aynı gün hem otomatik hem manuel varsa manuel. (Eski bir manuel kur, daha yeni otomatik kurun önüne geçmez.) Kur = 1 birimin TL karşılığı × 1e6; döviz **alış** kuru (değerleme).
|
||||
- Otomatik kurlar gün başına tek kayıt (kod × kaynak × gün; aynı gün yeniden çekilince güncellenir) → günlük geçmiş birikir. TCMB `rate_date` = bülten tarihi; altın = Türkiye'de bugün.
|
||||
- Altın: xaus.com gram (saf) USD × TCMB USD alış; çeyrek/yarım/tam = has gram (1,603 / 3,206 / 6,412) × gram, ons = 31,1034768 gr. İşçilik payı yok → gerekirse manuel.
|
||||
|
||||
### 3.15 `schema_migrations`
|
||||
```js
|
||||
@@ -223,7 +241,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
## 6. Dönem Kapanışı (tek transaction)
|
||||
1. `periods.status = "closed"`, `closed_at`, `closed_by`
|
||||
2. `carryovers` kaydı (borç veya artı) → sonraki dönem
|
||||
3. (Onaylıysa) gerçekleşen birikim → `asset_transactions` (`TRY`, `buy`, `source_period_id`)
|
||||
3. Gerçekleşen birikimin `to_savings` kadarı birikime: şimdilik `periods.to_savings`; Faz 8'de → `asset_transactions` (`TRY`, `buy`, `source_period_id`)
|
||||
4. (İstenirse) yeni dönem: `plan_items` ve aylık `budgets` kopyalanarak açılır
|
||||
|
||||
Transaction'ın bir adımı hata verirse önceki adımlar da geri alınır, hiçbir şey değişmez.
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
# 🚀 Canlıya Alma (Atlas Homeserver + Dokploy)
|
||||
|
||||
> Durum: ✅ Yayında — https://kakeibo.ta1sph.website (2026-11-12) · Yedekleme: betikler hazır, sunucuda kurulum bekliyor (§6)
|
||||
|
||||
## 1. Mimari
|
||||
|
||||
```
|
||||
İnternet ──HTTPS──▶ Traefik (Dokploy) ──▶ kakeibo konteyneri :8080 ──▶ MongoDB (rs0)
|
||||
├─ /api/* → Rust (Axum) API
|
||||
└─ diğer → derlenmiş arayüz (React)
|
||||
```
|
||||
|
||||
- **Tek imaj, tek servis, tek alan adı** → CORS yok, cookie aynı origin'de.
|
||||
- İmaj: çok aşamalı `Dockerfile` (Node → arayüz derlemesi, Rust → release derleme, `debian:trixie-slim` çalışma imajı, root olmayan kullanıcı `kakeibo`, ~175 MB).
|
||||
- Sağlık kontrolü: `GET /api/health` → `200 {"status":"ok","db":"ok"}`; veritabanı yoksa `503`.
|
||||
- Kapanış: `SIGTERM` gelince süren istekler bitirilir, sonra kapanır.
|
||||
|
||||
## 2. Ortam değişkenleri
|
||||
|
||||
| Değişken | Zorunlu | Örnek / varsayılan | Açıklama |
|
||||
|---|---|---|---|
|
||||
| `MONGODB_URI` | ✅ | `mongodb://kakeibo_app:<şifre>@<host>:27017/kakeibo?authSource=kakeibo&replicaSet=rs0` | Ayrı, yalnızca `kakeibo` DB'sinde `readWrite` yetkili kullanıcı |
|
||||
| `JWT_SECRET` | ✅ | `openssl rand -hex 32` çıktısı | En az 32 karakter. **Değişirse tüm oturumlar kapanır** |
|
||||
| `APP_URL` | ✅ | `https://kakeibo.<alan-adı>` | `https://` ile başlarsa cookie `Secure` olur |
|
||||
| `REGISTRATION` | – | `invite_only` | `invite_only`: ilk hane serbest, sonra yalnızca davet · `open`: herkes |
|
||||
| `RUST_LOG` | – | `kakeibo=info,tower_http=info` | Log seviyesi |
|
||||
| `RATES_REFRESH_MINUTES` | – | `60` | Otomatik kur yenileme aralığı (dk); `0` → kapalı. Konteynerin internete çıkışı gerekir: `www.tcmb.gov.tr` (döviz), `xaus.com` (altın). Erişilemezse son kur kalır, elle kur girilebilir |
|
||||
| `PORT` | – | `8080` | İmajda ayarlı, değiştirmeyin |
|
||||
| `STATIC_DIR` | – | `/app/static` | İmajda ayarlı, değiştirmeyin |
|
||||
|
||||
## 3. Yerelde imajı deneme
|
||||
|
||||
```bash
|
||||
docker build -t kakeibo .
|
||||
# Yerel MongoDB (27020) ile, geliştirme sunucusuyla çakışmasın diye 8090'da:
|
||||
docker run --rm --network host --env-file .env -e PORT=8090 kakeibo
|
||||
# → http://localhost:8090
|
||||
```
|
||||
|
||||
Kontrol listesi (Faz 3 – Adım 1'de doğrulandı):
|
||||
- [x] `/` ve `/defter` → `index.html` (`Cache-Control: no-cache`)
|
||||
- [x] `/assets/*.js` → brotli/gzip, `max-age=31536000, immutable`
|
||||
- [x] `/api/health` → `db: ok`; bilinmeyen `/api/...` → JSON 404
|
||||
- [x] Docker `HEALTHCHECK` → healthy; süreç `kakeibo` kullanıcısıyla çalışır
|
||||
- [x] `docker stop` → düzgün kapanış logu
|
||||
|
||||
## 4. Canlı MongoDB
|
||||
|
||||
- Dokploy'da **ayrı** MongoDB servisi (Bütçem'in veritabanı paylaşılmaz): proje `Kakeibo` → Database → MongoDB `kakeibo-mongo`.
|
||||
- Servis (iç ağ) adı: **`kakeibo-kakeibomongo-fibzhc`**, imaj `mongo:7`, ağ `dokploy-network`, volume `kakeibo-kakeibomongo-fibzhc-data → /data/db`.
|
||||
- Replica set: `rs0`, üye `kakeibo-kakeibomongo-fibzhc:27017` (Dokploy "Replica Sets" açık).
|
||||
- Kullanıcılar: `kakeibo_root` (admin, `root`) · `kakeibo_app` (`kakeibo` DB, yalnızca `readWrite`). Şifreler 2026-11-12'de yenilendi (eskileri açığa çıkmıştı).
|
||||
- Dış erişim: `docker service inspect … .Endpoint.Ports` → `null` (port yayınlanmıyor) ✅. External Port alanı boş kalmalı.
|
||||
- Bağlantı:
|
||||
```
|
||||
mongodb://kakeibo_app:<APP_ŞİFRESİ>@kakeibo-kakeibomongo-fibzhc:27017/kakeibo?authSource=kakeibo&replicaSet=rs0
|
||||
```
|
||||
|
||||
### ⚠️ Açık konu: MongoDB kimlik doğrulaması kapalı
|
||||
Dokploy'un replica set betiği `mongod --replSet rs0 --bind_ip_all` ile başlatıyor (`--keyFile` yok) ve her deploy'da
|
||||
Advanced → Command/Args'ı yok sayıp kendi betiğini yazıyor. Sonuç: kullanıcılar tanımlı ama şifre **zorunlu değil**;
|
||||
`dokploy-network`'teki herhangi bir konteyner erişebilir. İnternetten erişim yok.
|
||||
Çözüm adayları (ileride): Dokploy'un MongoDB'si yerine Compose servisi (kendi komutumuzla `--keyFile`) ya da Dokploy'da keyFile desteği.
|
||||
Uygulama zaten kullanıcı/şifreyle bağlandığı için auth açıldığında uygulamada değişiklik gerekmez.
|
||||
|
||||
### Şifre girerken dikkat
|
||||
- `mongosh` içinde `passwordPrompt()`'a **yapıştırmayın** (terminal görünmez karakter ekleyebiliyor → `U_STRINGPREP_PROHIBITED_ERROR`).
|
||||
Bunun yerine: `const p = require('crypto').randomBytes(24).toString('hex'); db.changeUserPassword("kakeibo_app", p); print(p)`
|
||||
- Şifreyi komuta düz yazmayın; sonra `rm -f /root/.mongodb/mongosh/mongosh_repl_history`.
|
||||
- "Rebuild Database" veriyi siler ve Dokploy betiği yeni root şifresini servis ayarına düz metin yazar.
|
||||
|
||||
## 5. Dokploy kurulumu
|
||||
|
||||
Proje `Kakeibo` → Create Service → **Application** `kakeibo-app` (iç ad: `kakeibo-kakeiboapp-nd5kek`).
|
||||
|
||||
| Bölüm | Ayar |
|
||||
|---|---|
|
||||
| General → Provider | **Gitea** sekmesi (varsayılan GitHub sekmesi seçili kalırsa: `Github Provider not found`) → repo `suphicakir/kakeibo`, branch `main`, Build Path `/` → **Save** |
|
||||
| General → Build Type | **Dockerfile**, dosya `Dockerfile`, context `.` |
|
||||
| Environment | §2'deki değişkenler (`MONGODB_URI`, `JWT_SECRET`, `APP_URL=https://kakeibo.ta1sph.website`, `REGISTRATION=invite_only`, `RUST_LOG`) |
|
||||
| Domains | Host `kakeibo.ta1sph.website`, Path `/`, **Container Port `8080`**, HTTPS açık, Let's Encrypt |
|
||||
| Auto Deploy | Açık → Gitea'ya push = otomatik yayın |
|
||||
|
||||
Notlar:
|
||||
- İlk derleme ~6–10 dk (Rust bağımlılıkları); sonrakiler katman önbelleğiyle daha hızlı.
|
||||
- Derleme hatası **Deployments** → ilgili deploy logunda görünür; **Logs** sekmesi yalnızca çalışan konteyneri gösterir.
|
||||
- Dokploy, Gitea'daki son commit'i derler → değişiklikler önce push edilmeli.
|
||||
- Geri dönüş (rollback): Deployments listesinden önceki başarılı deploy seçilerek yeniden yayınlanır.
|
||||
- DNS: `kakeibo.ta1sph.website` → ev dış IP'si; modemde 80/443 → Atlas yönlendirmesi Let's Encrypt için gerekli.
|
||||
- Sade metin `404 page not found` + "Not secure" = Traefik'in kendi yanıtı (bu host için router yok): domain eklenip **Redeploy** yapılmamış ya da konteyner çalışmıyor demektir.
|
||||
|
||||
### İlk yayın kontrolü (2026-11-12) ✅
|
||||
- `/api/health` → `{"status":"ok","db":"ok"}`, servis `1/1` healthy, migration v1 + indeksler canlı DB'de oluştu.
|
||||
- TLS: Let's Encrypt (otomatik yenileme), `/kayit` doğrudan açılıyor (SPA).
|
||||
- İlk hesap açıldı → `GET /api/auth/registration` = `new_household_allowed: false` (yalnızca davet).
|
||||
|
||||
## 6. Yedekleme ve geri yükleme
|
||||
|
||||
```
|
||||
Atlas 03:30 backup.sh ──▶ /var/backups/kakeibo/kakeibo-YYYY-MM-DD_HHMM.archive.gz (son 14)
|
||||
│ rsync (SSH)
|
||||
Bu bilgisayar 09:00 / açılışta ──▶ ~/Yedekler/kakeibo/ (son 60, 2 günden eskiyse masaüstü uyarısı)
|
||||
```
|
||||
|
||||
| Dosya | Nerede çalışır | İş |
|
||||
|---|---|---|
|
||||
| `scripts/backup.sh` | Atlas → `/usr/local/lib/kakeibo/` | `mongodump --archive --gzip` → gzip + `mongorestore --dryRun` denetimi → son 14'ü tutar |
|
||||
| `scripts/restore.sh` | Atlas (ve yerelde `npm run db:restore`) | yedeği **başka adla** geri yükler, kayıt sayılarını gösterir/karşılaştırır |
|
||||
| `scripts/backup-pull.sh` | bu bilgisayar | yeni yedekleri çeker, eskiyse uyarır |
|
||||
| `deploy/systemd/*` | ikisi | zamanlayıcılar (`Persistent=true`: kapalıyken kaçan çalışma açılınca yapılır) |
|
||||
|
||||
Yerel deneme: `npm run db:backup` (kakeibo_dev → `./backups`, git'e girmez) ·
|
||||
`COMPARE_DB=kakeibo_dev npm run db:restore -- latest kakeibo_restore_test`.
|
||||
|
||||
### 6.1 Atlas'ta kurulum (bir kez)
|
||||
Bu bilgisayarda, proje klasöründe:
|
||||
```bash
|
||||
scp scripts/backup.sh scripts/restore.sh deploy/systemd/kakeibo-backup.service deploy/systemd/kakeibo-backup.timer atlas:/tmp/
|
||||
ssh atlas
|
||||
```
|
||||
Atlas'ta:
|
||||
```bash
|
||||
sudo install -Dm755 /tmp/backup.sh /usr/local/lib/kakeibo/backup.sh
|
||||
sudo install -Dm755 /tmp/restore.sh /usr/local/lib/kakeibo/restore.sh
|
||||
sudo install -m644 /tmp/kakeibo-backup.service /tmp/kakeibo-backup.timer /etc/systemd/system/
|
||||
```
|
||||
- SSH'ye **root olmayan** bir kullanıcıyla bağlanıyorsanız: `sudo systemctl edit --full kakeibo-backup.service` →
|
||||
`#Environment=BACKUP_GROUP=…` satırının başındaki `#`'i kaldırıp kendi kullanıcı grubunuzu yazın (yoksa yedekleri çekemezsiniz).
|
||||
|
||||
İlk yedek ve kontrol:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl start kakeibo-backup.service
|
||||
journalctl -u kakeibo-backup -n 20 --no-pager # "tamam: …" görülmeli
|
||||
```
|
||||
|
||||
### 6.2 Geri yükleme denemesi (kurulumda bir kez, sonra ayda bir önerilir)
|
||||
Canlı veriye dokunmaz; ayrı `kakeibo_restore_test` veritabanına yükler ve sayıları karşılaştırır:
|
||||
```bash
|
||||
sudo COMPARE_DB=kakeibo /usr/local/lib/kakeibo/restore.sh latest kakeibo_restore_test
|
||||
# "✅ kakeibo ile aynı" görülmeli. Sonra deneme veritabanını silin:
|
||||
sudo docker exec $(sudo docker ps -q -f name=kakeibo-kakeibomongo-fibzhc) mongosh --quiet kakeibo_restore_test --eval 'db.dropDatabase()'
|
||||
```
|
||||
Sonra zamanlayıcıyı açın:
|
||||
```bash
|
||||
sudo systemctl enable --now kakeibo-backup.timer
|
||||
systemctl list-timers kakeibo-backup.timer # bir sonraki çalışma saati
|
||||
```
|
||||
|
||||
### 6.3 Bu bilgisayarda (sunucu dışı kopya)
|
||||
Önce şifresiz SSH çalışmalı: `ssh -o BatchMode=yes atlas ls /var/backups/kakeibo` yedek dosyasını listelemeli.
|
||||
```bash
|
||||
scripts/backup-pull.sh # elle bir kez: ~/Yedekler/kakeibo/
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp deploy/systemd/kakeibo-backup-pull.service deploy/systemd/kakeibo-backup-pull.timer ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now kakeibo-backup-pull.timer
|
||||
```
|
||||
- SSH'de sunucunun adı `atlas` değilse: `systemctl --user edit --full kakeibo-backup-pull.service` → `KAKEIBO_BACKUP_HOST` satırı.
|
||||
- Günlük: `journalctl --user -u kakeibo-backup-pull -n 20`.
|
||||
|
||||
### 6.4 Felaket sonrası geri yükleme (canlı veritabanının yerine)
|
||||
1. Dokploy → `kakeibo-app` → **Stop** (yükleme sırasında yazma olmasın).
|
||||
2. Gerekirse yedeği sunucuya kopyalayın: `scp ~/Yedekler/kakeibo/<dosya> atlas:/tmp/`.
|
||||
3. Atlas'ta: `sudo /usr/local/lib/kakeibo/restore.sh /tmp/<dosya> kakeibo --force` (önce mevcut `kakeibo` silinir).
|
||||
4. `kakeibo-app` → **Start**, `/api/health` ve giriş kontrolü.
|
||||
- MongoDB servisi tamamen yeniden kurulduysa önce §4'teki kullanıcıları (`kakeibo_app`) yeniden oluşturun.
|
||||
- Yedekte kullanıcı şifre özetleri (argon2) ve oturum bilgisi bulunur → yedek dosyalarını paylaşmayın.
|
||||
|
||||
### Notlar
|
||||
- MongoDB'de kimlik doğrulaması zorunlu olmadığından (§4) `mongodump` konteynerin içinden şifresiz çalışır.
|
||||
Auth açıldığında yedek için ayrı bir `backup` rolü kullanıcısı ve betiğe `--config` ile şifre dosyası eklenmeli.
|
||||
- Yedek boyutu şu an birkaç KB–MB; 14 + 60 dosya önemsiz yer tutar.
|
||||
+101
-3
@@ -1,9 +1,20 @@
|
||||
# 🧠 Proje Hafızası
|
||||
|
||||
## ▶️ Sıradaki Adım
|
||||
**Faz 1 – Adım 5:** Kategori (`/api/categories`) ve ödeme türü (`/api/payment-methods`) uç noktaları: listele, ekle, düzenle, sırala, pasife al (silme yok). Otomatik hane izolasyonu entegrasyon testleri (gerçek DB, ayrı test veritabanı). `api.http` tamamlanır → Faz 1 kapanır.
|
||||
**Faz 9 – Adım 4b:** Ekstre arayüzü (Ayarlar → Veri ya da ayrı sayfa `/ice-aktar`). Excel okuma: SheetJS (`xlsx` npm sürümü eski/açıklı → `https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz` tarball; lazy; gerekçe ver) — .xlsx/.xls (+ HTML-xls), CSV `lib/decode.ts`. Akış: dosya → sayfa/başlık satırı seç → sütun eşleştir (tarih, açıklama, tutar ya da Borç/Alacak iki sütun, işaret: kart/hesap) + **profil** kaydet (localStorage, banka adıyla) → `POST /bank-import/preview` → tablo: satır başına tür (harcama/iade/gelir/yoksay) + kategori seçimi (öneri, `from_rule` rozeti), tekrar/engelli satırlar soluk, açılacak arşivler listesi, toplu "aynı açıklamaya uygula" → ödeme türü seç → `commit` → rapor. Tarih biçimleri (GG.AA.YYYY, GG/AA/YYYY, Excel seri sayı), tutar (1.250,50 / -45,90 / Borç-Alacak). Defter: iade satırı "İade" rozeti; iade düzenlemede tutar alanı eksi/mutlak (şu an istemci şeması eksiyi reddeder → düzelt).
|
||||
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
|
||||
|
||||
## 📌 Alınan Kararlar
|
||||
- **Ekstre kararları (kullanıcı, 2026-11-13):** biçim Excel (.xlsx/.xls) + CSV (kullanıcı tam bilmiyor; ilk gerçek ekstreyle denenecek), hesaba gelenler → **gelir** (plan kalemi), arşiv dönemi **takvim ayı**, iadeler **aynı kategoriden düşülür** (eksi tutarlı kayıt).
|
||||
- **Dikkat (2026-11-13):** Claude'un hızlı ardışık `backend/src` değişiklikleri (bilerek bozup geri alma, toplu düzenleme) kullanıcının `npm run dev` içindeki `cargo watch`'ını backend kapalı halde bırakabiliyor (giriş: "Beklenmeyen hata", 5173 → 502). Böyle denemelerden sonra `curl localhost:8080/api/health`; kapalıysa `touch backend/src/main.rs` (cargo watch yeniden başlatır). Arayüz artık 502/503/504'te "Sunucu şu an yanıt vermiyor" der.
|
||||
- **Faz 9 kararları (kullanıcı, 2026-11-13):** (1) Aylık rapor biçimini **Claude tasarlar**: müşteri gözüyle ne yaptım / ne yapılabilirdi / ne yapılmalıydı. (2) JSON yedek: Claude best practice analizi yapıp kullanıcıyı üzmeyen çözüm üretir (öneri: sunucuda otomatik geri dönüş noktası + "geri al", önizleme, sahip). (3) **Tüm kredi kartı ve nakit/banka hesabı ekstreleri** içe aktarılabilsin; amaç **yıl başından beri** veriyi toplayıp sonraki aylara referans; grup/kategori eşleştirmesi kullanıcıya bırakılabilir → ayrı adım (Faz 9 Adım 4), geçmiş dönemler gerekir.
|
||||
- **Yeniden kapatmada birikim (kullanıcı "a", 2026-11-13):** bağlı TL alımı yeni tutara güncellenir (0 → silinir).
|
||||
- **Faz 8 kararları (kullanıcı, 2026-11-13):** kur kaynakları TCMB (döviz) + xaus.com ons×TCMB USD (altın; çeyrek/yarım/tam has gram ile) + manuel; değerleme **alış kuru**; kapanış birikimi onayla TL varlığına alım (tekrar eklenmez), geçmiş kapanışlar "bekleyen birikimler".
|
||||
- **Kategori yönetimi ekranı (kullanıcı, 2026-11-13): Faz 10'da** (seçenek b). Faz 8 önce.
|
||||
- **Kur kaynağı araştırması (2026-11-13):** TCMB `today.xml` döviz var, **altın yok** (USD ForexBuying/Selling, 22 kod). xaus.com `/api/v1/spot` anahtarsız çalışıyor (`spot_usd_oz`, `per_gram_usd`), resmi değil → yedek: son kur + manuel. Truncgil doğrulanamadı.
|
||||
- **Kategoriler (kullanıcı, 2026-11-12):** 3. kat alt kategori **yok** (grup → kategori). Şartı: raporlarda **tüm** kategorilerin dağılımı görünmeli (yalnızca ilk 5 değil). Amaç "market ne için?" gibi kırılım → kategoriyi bölerek (ör. Market · Gıda / Temizlik); bunun için Ayarlar'da kategori yönetimi ekranı gerekli.
|
||||
- **Canlı sürüm eski (2026-11-12 kontrol):** push'lar Gitea'da ama Dokploy yeniden derlemiyor (Faz 4+ yok, `/api/stats/periods` 404). Auto Deploy için Gitea webhook'u gerekiyor → kullanıcıya adım adım anlatıldı.
|
||||
- **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`).
|
||||
- Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`.
|
||||
- Her adım sonunda Claude commit atar ve haber verir; push'u kullanıcı kendi uygulamasıyla yapar (Claude `git push` çalıştırmaz).
|
||||
- Node v26 (LTS) sistemde mevcut; ayrı kurulum gerekmedi.
|
||||
@@ -18,11 +29,38 @@
|
||||
- Hane izolasyonu kuralı: hane kimliği **her zaman** `AuthUser.household_id`'den alınır, asla URL/gövdeden. Başka hanenin kaydı → 404 (403 değil; varlığı sızdırılmaz).
|
||||
- `AuthUser` her istekte DB'den kullanıcıyı doğrular (silinen kullanıcı → 401, rol DB'den). Owner-only işlemler: `auth.require_owner()?`.
|
||||
- Davet: 8 karakter (31 harflik okunaklı alfabe), 7 gün, tek kullanım; `find_one_and_update` ile atomik claim, kayıt transaction'ı içinde.
|
||||
- Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7.
|
||||
- Kategoriler/ödeme türleri: tüm üyeler yönetir; silme yok (pasife alma); grup sonradan değişmez; ad grup içinde benzersiz (büyük/küçük harf duyarsız, pasifler dahil); PATCH'te `version` → iyimser kilit (409); sıralama tam liste ister.
|
||||
- Tasarım: ana renk indigo (`oklch(0.42 0.13 268)`), grup renkleri Okabe-Ito (needs #0072b2, wants #cc79a7, culture #009e73, unexpected #d55e00), *-fg tonları AA (açık ≥5.0, koyu ≥9) hesaplanarak doğrulandı. Gelir #15803d, gider #c62828, uyarı #b45309.
|
||||
- Tema seçimi localStorage (`kakeibo-theme`), yalnızca cihaz kolaylığı; ileride `users.preferences.theme` ile senkron olabilir.
|
||||
- `parseMoney`: virgül varsa ondalık; virgül yoksa son noktadan sonra 1–2 rakam → ondalık ("1.25" = 1,25 ₺; "1.250" = 1250 ₺).
|
||||
- Logo: Taslak A (indigo zemin, spiral krem defter, ¥) seçildi. Kaynak `frontend/brand/logo.svg` + `logo-fullbleed.svg` (maskable/Apple, içerik %82). Uygulama içi `components/logo.tsx` aynı çizim (elle senkron tutulmalı). Manifest Faz 10'da (vite-plugin-pwa).
|
||||
- ts-rs 12: `backend/.cargo/config.toml` → `TS_RS_EXPORT_DIR=../frontend/src/types`. TS adları: User, HouseholdSummary, Household, Member, Invite, CatalogItem, Catalog{Create,Update,Order}Request, RegisterRequest, LoginRequest, MeResponse, HealthResponse, ApiErrorBody, Group, Role.
|
||||
- QueryClient: 4xx tekrar denenmez; herhangi bir sorgu 401 alırsa `me` = null. Çıkışta `qc.clear()`.
|
||||
- Adresler Türkçe: / (Panel), /defter, /plan, /birikim, /diger (mobil), /degerlendirme, /raporlar, /ayarlar, /giris, /kayit. Mobil alt menü: Panel, Defter, Plan, Birikim, Diğer.
|
||||
- Çıkış: `qc.clear()` KULLANMA (açık ekranlar silinen sorguya bağlı kalıyor) → önce `me=null`, sonra `removeQueries` (me hariç).
|
||||
- Canlıda SPA yönlendirmesi (Axum: bilinmeyen yol → index.html) Faz 3'te yapılmalı.
|
||||
- Kayıt ekranı: "Yeni hane kur" / "Davetle katıl"; davet bağlantısı `/kayit?davet=KOD`. Sunucu 409 → e-posta alanı, "Davet…" 400 → kod alanı.
|
||||
- Yönlendirme sonrası toast için `mutateAsync` + await kullanıldı (mutate() callback'leri bileşen kalkınca çalışmayabilir).
|
||||
- **Kayıt politikası (kullanıcı kararı, 2026-11-12): Seçenek 1** → `REGISTRATION=invite_only` varsayılan; sistemde hiç hane yokken ilk kayıt yeni hane kurar (sunucu sahibi), sonra yalnızca davetle. `open` isteğe bağlı. Canlıda ilk hesabı kullanıcı `/kayit`'tan açar; demo hesaplar canlıya taşınmaz.
|
||||
- Prettier ayarı eklendi (önceden yoktu → VS Code kaydedince çift tırnağa çevirebilirdi).
|
||||
- Canlı imaj: node:26-alpine → rust:1.99-slim-trixie → debian:trixie-slim (ca-certificates + curl, uid 10001 `kakeibo`), 174 MB. Docker katman önbelleği için sahte lib/main ile bağımlılık derlemesi; kalıntılar `*kakeibo*` + `.fingerprint` silinir, gerçek kaynak `touch`lanır (yoksa sahte lib kullanılıyordu).
|
||||
- Önbellek: HTML `no-cache`, `/assets/*` 1 yıl immutable, diğer statik 1 saat. Bilinmeyen `/api/*` → JSON 404.
|
||||
- Canlı: domain `kakeibo.ta1sph.website` (DNS → 159.146.61.151, ev dış IP'si olmalı), Gitea Dokploy'a bağlı. Canlı MongoDB = ayrı Dokploy servisi `kakeibo-kakeibomongo-fibzhc` (rs0), kullanıcı `kakeibo_app` (readWrite/kakeibo).
|
||||
- Dönem: bitiş hariç, en fazla 62 gün, hane başına tek açık, çakışma yok (metin karşılaştırması), kapalı salt okunur, daraltmada dışarıda harcama kalırsa 409. Ad boşsa otomatik (`calendar::default_period_name`). Tüm üyeler açabilir/düzenleyebilir.
|
||||
- ts-rs: `i64` tutarlar `#[ts(type = "number")]`; isteklerde `Option<i64>` için `#[ts(type = "number", optional)]` (yoksa `?` kayboluyor).
|
||||
- Plan kalemleri: tarih isteğe bağlı (dönem içinde), tutar > 0, kopyalama göreli gün + dönem sonuna kırpma, aynı tür+ad (büyük/küçük harf duyarsız) atlanır. Notlar: başlık 1–120, metin ≤ 20.000. Kapalı dönemde hepsi 409 (`period::get_open`). Silme = soft delete.
|
||||
- İzolasyon iki katmanlı: kayıt okuma `scope()` + dönem `get_open(hid, period_id)` + yazma filtresi `scope()`. Tek katmanı bozmak açık oluşturmuyor (mutasyon denemesiyle görüldü).
|
||||
- Bütçe: aylık = `budgets{scope:"month"}`; haftalık otomatik = (aylık − elle girilenler) gün oranında, `money::split_proportional` (en büyük kalan, toplam tam eşit). Elle hafta = `scope:"week", week_start`; `null` → soft delete. Aylık değişince elle haftalar korunur; elle toplam > aylık → `weekly_over`.
|
||||
- Özet: spendable = gelir − sabit gider − birikim − borç + artı (`carryovers.to_period_id`). `unallocated` = spendable − bütçe toplamı (negatif = fazla dağıtım).
|
||||
- Öneri: default = hane `settings.budget_split_pct` × spendable (≤0 → available:false); previous/avg3 = önceki kapalı dönem(ler)in `entries` toplamı / dönem sayısı (yuvarlayarak).
|
||||
- Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7, Tailwind 4.3, shadcn 4.23, Vitest 5, react-router 8, TanStack Query 5.
|
||||
|
||||
## ❓ Açık Konular
|
||||
- Geliştirme makinesi: CachyOS nodejs 26.13.0-2 ↔ simdjson .so.36 uyumsuzluğu → simdjson 2:5.0.5-1'e düşürüldü (2026-11-12). Düzeltilmiş nodejs gelene kadar `pacman -Syu --ignore simdjson`. `node: libsimdjson.so.34` hatası görülürse sebep budur.
|
||||
- **Canlı yedekleme kurulumu bekliyor** (betikler hazır, `docs/DEPLOY.md` §6) → gerçek veri girişinden önce.
|
||||
- **Canlı MongoDB auth kapalı** (Dokploy replica set betiği keyFile kullanmıyor, özel Command'ı yok sayıyor). İç ağda, port kapalı. Ayrıntı: `docs/DEPLOY.md` §4.
|
||||
- Üye çıkarma / rol değiştirme / sahipliği devretme henüz yok (Ayarlar ekranıyla birlikte, Faz 2+).
|
||||
- Kapalı dönem yeniden açılırken başka açık dönem varsa davranış (Faz 6).
|
||||
- ~~Kapalı dönem yeniden açma~~ → çözüldü: düzeltme modu (`correcting`). Arayüzde düzeltmedeki dönem için Defter/Plan dönem seçimi Faz 6 Adım 5'te.
|
||||
- Altın kuru için veri kaynağı seçimi (Faz 8).
|
||||
- Aylık değerlendirme çıktı formatı kullanıcıdan gelecek (Faz 9).
|
||||
- Canlı MongoDB kurulum şekli (konteyner mi, doğrudan mı) Faz 3'te birlikte kontrol edilecek.
|
||||
@@ -41,3 +79,63 @@
|
||||
- **Adım 2:** `db/` modülü: `col` sabitleri, `migrations.rs` (`schema_migrations`, v1 = koleksiyonları oluştur), `indexes.rs` (DATA_MODEL §4 + tüm hane koleksiyonlarına `(household_id, updated_at)`). `models/common.rs`: UUID v7, `new_meta`, `scope`, `touch`. Açılışta `db::prepare`. 4 birim testi; idempotency ve tek açık dönem indeksi elle doğrulandı. `uuid` (v7) eklendi.
|
||||
- **Adım 3:** Kimlik doğrulama: `auth/` (password argon2id + spawn_blocking, jwt, limiter, session cookie + `AuthUser` extractor), `extract.rs` (`ValidJson` = JSON + validator, Türkçe 400), `services/account.rs` (kayıt tek transaction: hane + owner + 13 kategori + 4 ödeme türü), `services/seed.rs`, `routes/auth.rs` (register/login/logout/me). Yeni crate'ler: argon2, jsonwebtoken, axum-extra(cookie), validator, time. 11 birim testi; 13 senaryo curl ile, rollback DB'de doğrulandı.
|
||||
- **Adım 4:** `AuthUser` DB doğrulaması + `require_owner`, `services/invite.rs` (create/list_active/revoke/claim), kayıt davet koduyla `member` olarak katılma (`services/account.rs` yeniden düzenlendi), `routes/household.rs` (GET /household, invites GET/POST/DELETE). 13 birim testi; 15 senaryo e2e (izolasyon 404, member 403, kod tekrar kullanımı, rollback) geçti.
|
||||
- **Adım 5:** `models/group.rs`, `services/catalog.rs` (kategori + ödeme türü ortak: list/create/update/reorder), `routes/catalog.rs`. Backend lib+bin'e ayrıldı (`AppState::new`). Entegrasyon testleri: `tests/isolation.rs` (3), `tests/catalog.rs` (4) — toplam 22 test. İzolasyon testi, kasıtlı açık eklenerek doğrulandı (yakaladı). `db:reset` artık test DB'lerini de siler. Dev-deps: tower, http-body-util. **Faz 1 tamamlandı.**
|
||||
|
||||
### 2026-11-12 — Faz 2
|
||||
- **Adım 1:** Tailwind v4 (@tailwindcss/vite), shadcn init (radix, preset nova; `cn` paketi = shadcn'in clsx+tailwind-merge yerine geçeni, incelendi), Inter (@fontsource-variable, çevrimdışı). `index.css`: grup + durum token'ları, odak halkası, `money` utility, reduced-motion. `lib/format.ts` (+22 vitest), `lib/theme.tsx`, `lib/groups.ts`, `components/theme-toggle.tsx`. Butonlar 44/48 px'e büyütüldü. Geçici önizleme ekranı (App.tsx), 375 px headless ekran görüntüsüyle iki temada kontrol edildi.
|
||||
- **Adım 2:** 3 logo taslağı (A indigo defter, B açık defter + para, C hanko) 512/64/32/16 px önizlemeyle sunuldu; kullanıcı A'yı seçti. `scripts/icons.sh` (`npm run icons`): favicon.svg/.ico, icon-192/512, icon-maskable-512, apple-touch-icon (180), brand/icon-1024 (Tauri). Maskable güvenli alan görsel olarak doğrulandı. `index.html`: ikon linkleri, theme-color, description.
|
||||
- **Adım 3:** ts-rs eklendi (17 tip), health ve hata gövdesi struct'a çevrildi (`ApiErrorBody`). Frontend: @tanstack/react-query, `lib/api.ts` (+7 vitest), `lib/query.ts`, `lib/auth.ts`, main.tsx'te QueryClientProvider. Geçici oturum paneli. Tarayıcı e2e (CDP betiği): yanlış şifre mesajı, giriş, yenilemede kalıcılık, httpOnly (document.cookie'de yok), çıkış — hepsi geçti.
|
||||
- **Adım 4:** react-router 8 (createBrowserRouter), `RequireAuth`/`RequireGuest`/`Splash`, `AppLayout` (masaüstü kenar çubuğu + mobil üst çubuk + 5'li alt menü, safe-area), Diğer sayfası, 7 yer tutucu sayfa, 404, `lib/strings.ts`, `useDocumentTitle`, `lib/return-path.ts` (+10 test, açık yönlendirme koruması), ThemeToggle `compact`. Tasarım önizlemesi → `/dev/tasarim` (yalnızca DEV). CDP e2e (10 kontrol) iki hata buldu, düzeltildi: giriş sonrası `donus` yarışı, çıkışta `qc.clear()`. Masaüstü içerik boşluğu düzeltildi.
|
||||
- **Adım 5:** react-hook-form, zod 4, @hookform/resolvers, sonner; shadcn input/label/checkbox/card (44 px / 20 px'e büyütüldü). `components/form-field.tsx`, `components/toaster.tsx`, `features/auth/` (auth-shell, login-page, register-page, schemas + 8 vitest). Metinler `t.auth`. CDP e2e 11 kontrol geçti (boş form, yanlış şifre, giriş + toast, davet linki, kayıtlı e-posta → alan hatası, geçersiz davet → alan hatası, yeni hane). Frontend 47 test. **Faz 2 tamamlandı.**
|
||||
|
||||
### 2026-11-12 — Faz 3 öncesi
|
||||
- Kullanıcı `api.http`'deki kayıt örneğini (yeni@ornek.com) hesap sandı → dosya başlığına hazır hesaplar kutusu ve "bu bir KAYIT isteğidir" uyarısı eklendi.
|
||||
- Kayıt politikası: `config.rs` `RegistrationMode`, `AppError::RegistrationClosed` (403), `account::new_household_allowed`, `GET /api/auth/registration` (`RegistrationInfo` TS tipi). `tests/registration.rs` (2). Kayıt ekranı kapalıyken yalnızca davet modu + açıklama. `.env`/`.env.example`: `REGISTRATION=invite_only`. Prettier (`.prettierrc.json`, `.prettierignore`, `format` / `format:check`), tüm src biçimlendirildi (yalnızca boşluk).
|
||||
|
||||
### 2026-11-12 — Faz 3
|
||||
- **Adım 1:** tower-http (fs, compression gzip/br, trace). `routes::app()` + `routes/spa.rs` (ServeDir + index.html fallback + cache middleware), API fallback JSON 404, `STATIC_DIR` config, graceful shutdown (SIGTERM/Ctrl+C). `Dockerfile`, `.dockerignore`, `docs/DEPLOY.md` başlatıldı. İmaj yerelde çalıştırıldı (host network, 8090): 10 kontrol + tarayıcı ekran görüntüsü geçti. İlk derlemede Docker önbellek hilesi hatası (sahte lib.rs) bulundu, düzeltildi.
|
||||
- **Adım 2:** Mevcut tek MongoDB (butcem-mongo) Bütçem'e ait → kullanıcı ayrı MongoDB seçti. Dokploy MongoDB (replica set) kuruldu, `kakeibo_app` oluşturuldu, port yayını yok (iki servis de `null`). Sorunlar: kullanıcı şifreyi komuta düz yazıp paylaştı; Dokploy betiği root şifresini servis Args'ına düz yazıyor → iki şifre de mongosh içinde üretilerek yenilendi. passwordPrompt yapıştırma hatası (U_STRINGPREP). Auth açma denemesi: Dokploy özel Command'ı uygulamadı → auth kapalı kabul edildi (açık konu).
|
||||
- **Adım 3–4:** Dokploy Application `kakeibo-app` (iç ad `kakeibo-kakeiboapp-nd5kek`). Sorunlar: push yapılmamıştı (Dockerfile yok); Provider GitHub sekmesinde kalmıştı (`Github Provider not found`, kullanıcı çözdü); Traefik 404 (kullanıcı çözdü, büyük olasılıkla domain sonrası redeploy). **Yayında:** https://kakeibo.ta1sph.website — health ok, Let's Encrypt, ilk hesap açıldı. **Faz 3 tamamlandı (yedekleme hariç).**
|
||||
|
||||
### 2026-11-12 — Faz 4
|
||||
- **Adım 1:** `services/calendar.rs` (parse/format, `weeks` Pazartesi başlangıçlı + kısmi haftalar, Türkçe ay adları; 4 test), `models/period.rs`, `services/period.rs` (2 test), `routes/period.rs` (list/current/create/get/patch; DTO'da `last_date`, `days`, `weeks`). `tests/periods.rs` (5) — çakışma kontrolü bilerek bozulunca test yakaladı. Backend toplam 58 test.
|
||||
- **Adım 2:** `models/plan_item.rs`, `models/note.rs`, `services/plan.rs` (list/create/update/delete/copy_previous), `services/note.rs`, `routes/plan.rs`, `routes/note.rs`, `period::get_open` + `closed()`. `tests/plan_notes.rs` (5): CRUD + doğrulama, kopyalama (göreli gün, 28 Şubat'a kırpma, tekrar yok), kapalı dönem salt okunur, not filtresi, izolasyon. Backend 72 test.
|
||||
- **Adım 3:** `services/money.rs` (`split_proportional` + 3 test; beklenen değer hatası testteydi, elle hesapla düzeltildi), `services/budget.rs` (summary, view, set_monthly, set_week, suggest), `routes/budget.rs` (5 uç nokta, 9 TS tipi). `tests/budget.rs` (6) — özet formülü bilerek bozulunca yakaladı. Backend 88 test.
|
||||
- **Adım 4:** İlk dönem sihirbazı `/plan/donem-ac?adim=1..5` (`features/period-wizard/`): Dönem → Gelirler → Giderler → Birikim → Bütçe. Dönem 1. adımda açılır, her adım hemen kaydedilir; adım adreste (yenileme/geri tuşu çalışır). `lib/periods.ts` (Query kancaları; değişince `queryKeys.period(id)` öneki yenilenir), `lib/period-dates.ts` (son gün dahil ↔ backend bitiş hariç), `lib/money-schema.ts` (`moneyText`, `toKurus`), `components/money-input.tsx` (inputMode=decimal, blur'da `1.250,50`), `PageLoading`/`PageError`, `SummaryTiles`, `NoPeriodCard` (Panel + Plan). Frontend 60 test; CDP e2e 43 kontrol geçti, test verisi demo hanesinden silindi.
|
||||
- Not: üretim JS paketi ~620 kB (gzip 195 kB) → Faz 10'da sayfa bazlı bölme (lazy route).
|
||||
- **Adım 5:** Plan ekranı (`features/planning/`): `plan-read.tsx` (salt okunur: kalemler, 4 grup + haftalık döküm, "elle" etiketi), `plan-edit.tsx` (taslak formu, Kaydet/Vazgeç, `useBlocker` + beforeunload ile kaydedilmemiş değişiklik onayı). `lib/plan-draft.ts` (`toDraft`/`diffPlan`: yalnızca değişenler gönderilir), `lib/budget-preview.ts` (backend `split_proportional` + `view` ile birebir; aynı test örnekleri), `useSavePlan` (sıralı istekler; hata olursa yeniden yükler, transaction değil). Ortak: `DistributionBar`, `SuggestionPicker` (sihirbaz da kullanıyor). Frontend 69 test.
|
||||
- E2E artık ayrı sunucuda: `REGISTRATION=open PORT=8091 STATIC_DIR=frontend/dist MONGODB_URI=…/kakeibo_test_e2e` (kullanıcının dev verisine dokunulmaz; bitince DB silinir). 31 kontrol geçti; ekran görüntüsüyle 3 düzen sorunu bulundu/düzeltildi (dar satırlar, mobil tutar/tarih, hafta etiketi).
|
||||
- **Adım 6:** Notlar: `features/notes/` (`NotesSection` — Plan'da `context="planning"`, Değerlendirme'de `review` ile yeniden kullanılacak; `NoteEditor` Yaz/Önizle sekmeleri; zod şeması karakteri backend gibi sayar), `lib/notes.ts` (+ `useHousehold` yazar adı için), `components/markdown.tsx` (ham HTML yok, bağlantılar yeni sekme), `components/ui/textarea.tsx`, `index.css` `.markdown` stilleri. Yeni bağımlılık: react-markdown 10, remark-gfm 4. E2E 16 kontrol (XSS, 409 çakışma dahil). `DistributionBar` 0/0 durumunda "tamamı dağıtıldı" demiyor. **Faz 4 tamamlandı.**
|
||||
- Not: JS paketi 820 kB (gzip 254 kB; markdown ~200 kB) → Faz 10'da route bazlı lazy + Markdown'u ayrı parçaya al. `npm audit` uyarıları shadcn CLI bağımlılıklarından (deepmerge, minimatch), uygulamaya girmiyor; `--force` shadcn'i 1.0'a düşürür, yapılmadı.
|
||||
|
||||
### 2026-11-12 — Faz 5
|
||||
- **Adım 1:** Harcamalar backend: `models/entry.rs`, `services/entry.rs` (list + `from`/`to` filtresi, sıra `date` ↓ `_id` ↓; create/update/delete soft/`restore`), `routes/entry.rs` (5 uç nokta), `catalog::find`. Grup **kategoriden türetilir** (istemci göndermez). Pasif kategori/ödeme türü yeni seçimde reddedilir, kaydın mevcut değeri olarak kalabilir. Ödeme türü isteğe bağlı; düzenlemede `""` → kaldır (projedeki "boş metin = kaldır" kuralı; ts-rs `deserialize_with` desteklemediği için çift-Option yerine). `tests/entries.rs` (6) — `previous` önerisi artık gerçek kayıtla test edildi. Mutasyon: grup türetme bozulunca yakalandı; restore'da hane filtresi tek başına kaldırılınca `get_open` ikinci katman olarak korudu, ikisi birden kaldırılınca 2 test yakaladı. Backend 97 test.
|
||||
- **Adım 2:** Gerçekleşen harcama (saklanmaz, her istekte aggregation): `GET /summary` → `spent_total`, `remaining` (= harcanabilir − harcanan, negatifse aşılmış). `GET /budget` → grupta `spent` (dönem), haftada `spent`. `spent_by_group_and_date` (grup×gün toplanır, Rust'ta haftalara dağıtılır; tarih metin karşılaştırması). Silinen kayıt sayılmaz, kategori değişince harcama yeni gruba geçer. Test `actual_spending_in_summary_and_budget` (hafta sınırları 6/7 Aralık, dönem son günü, önceki dönem hariç, geri al). Mutasyon: hafta sınırı `<=`→`<` ve silinenleri sayma → ikisi de yakalandı. Backend 98 test.
|
||||
- **Adım 3:** Defter ekranı (`features/ledger/`): `WeekPicker` (◀ ▶, "Bu hafta", haftanın harcanan/bütçe toplamı), `EntryForm` (büyük `MoneyInput`, 4 renkli grup düğmesi, kategori/ödeme türü çipleri, tarih, açıklama; kayıttan sonra tutar+açıklama temizlenir, odak tutara döner), `schema.ts` (`entrySchema(start,last)`, `fieldForError`: sunucu mesajı → alan). Hafta adreste: `/defter?hafta=N` (bugünün haftasında parametre yok); başka haftaya tarihli kayıt → o haftaya geçer; hafta değişince tarih o haftaya çekilir. `lib/ledger.ts` (`weekIndexFor`, `defaultEntryDate`, `parseWeekParam`, son seçim localStorage `kakeibo.ledger.last`: grup + **her grubun son kategorisi** + ödeme türü; pasifleşen seçim düşer). `lib/catalog.ts` (`useCategories`, `usePaymentMethods`, 5 dk staleTime), `lib/entries.ts` (`useEntries`, `useCreateEntry`), `queryKeys.entries`. Frontend 79 test; e2e 31 kontrol (ayrı 8091 sunucu, DB silindi). Mobilde "Beklenmeyen" taşması düzeltildi.
|
||||
- **Adım 4:** Defter listesi ve özet: `entry-list.tsx` (gün gün `groupByDay`, gün adı `formatWeekday` = Intl tr-TR UTC; satır: kategori emojisi grup renginde, grup · ödeme · açıklama; düzenle/sil 44 px), sil → sonner `action` "↩️ Geri al" (8 sn) → `useRestoreEntry`. Düzenleme aynı `EntryForm` (`entry` + `key`, Güncelle/Vazgeç; `version` gönderilir, 409 Türkçe root hatası; `''` → ödeme/açıklama kaldır). Katalog **pasifler dahil** okunur (eski kayıtların adı; seçenekte yalnız aktifler + kaydın mevcut değeri). `week-summary.tsx` grup bazlı bütçe/harcanan/kalan + çubuk, aşımda "Aşıldı" kırmızı. Masaüstü iki sütun (form solda yapışkan); dar sütunda taşmaya karşı **container query** (`@container`, `@lg:grid-cols-4`). Frontend 81 test; e2e 29 kontrol (+ Adım 3'ün 31'i yeniden geçti). **Not:** e2e betikleri aynı DB'yi paylaşırsa birbirini bozar → her biri temiz DB ile.
|
||||
- **Adım 5:** Yedekleme betikleri: `scripts/backup.sh` (Swarm etiketiyle konteyneri bulur, `mongodump --archive --gzip` → `.partial` → `gzip -t` + `mongorestore --dryRun` → ad verilir; son `KEEP=14`, `BACKUP_GROUP` ile okuma izni), `scripts/restore.sh` (dosya adından kaynak DB, `--nsFrom/--nsTo` ile **başka adla**; mevcut hedefe yalnızca `--force` + `--drop`; `COMPARE_DB` ile sayım karşılaştırma), `scripts/backup-pull.sh` (rsync SSH BatchMode, `~/Yedekler/kakeibo`, son 60, 48 saatten eski → `notify-send`). `deploy/systemd/` (sunucu 03:30, bu bilgisayar 09:00 + açılış, `Persistent=true`). Kullanıcı kararı: sunucu dışı kopya **bu bilgisayara** (S3 değil). `npm run db:backup` / `db:restore` (yerel, `backups/` gitignore). Yerel test: yedek → geri yükleme sayıları kakeibo_dev ile aynı, koruma, bozuk dosya hatası, saklama ✅.
|
||||
|
||||
### 2026-11-12 — Faz 6
|
||||
- **Adım 1:** Değerlendirme backend: `services/review.rs` (`budget::summary` + `budget::view` + kategori aggregation), `routes/review.rs` → `GET /periods/{id}/review` (`PeriodReview`: `available` = gelir − sabit − borç + artı, `savings_goal`, `spent_total`, `realized_savings` = available − harcanan, `savings_gap`; `groups` planned/spent, `categories` çoktan aza (+count), `weeks` 4 grubun toplamı). Kapalı dönemde okunur. `periods.reflection_md` (PATCH, ≤ 20.000 karakter, `""` → null, yalnızca açık dönem). `tests/review.rs` (3). Mutasyon: 4. soruya hedef düşmek / silinenleri saymak → yakalandı. Not: `TestApp::register` adı e-postaya çevirir → Türkçe karakterli ad kullanma.
|
||||
- **Adım 2:** Kapatma backend: `services/closing.rs` — `settle(realized, to_savings)` (< 0 → borç, birikim istenemez; ≥ 0 → varsayılan tamamı birikime, kalan artı), `close` (tek transaction: dönem `closed` + `closed_at/by` + `to_savings`, bu dönemden eski devirler soft delete, yeni devir; `to_period_id` = sonraki dönem ya da null), `reopen` (sahip; `closed` → **`correcting`**; ardından kapalı/düzeltmede dönem varsa 409). `PeriodStatus::Correcting` + `is_writable()` → `get_open`/`update` buna bakar; `current()` yalnız `open`. `period::create` artık transaction: önceki dönemin bağsız devrini bağlar. `POST /periods/{id}/close` (`PeriodCloseRequest`/`PeriodCloseResult`/`Carryover`), `POST /periods/{id}/reopen`. Period DTO: `to_savings`, `closed_at`. `tests/closing.rs` (4) + `settle` birim testi. Mutasyon 3/3 yakalandı; hata zorlanınca transaction geri aldı (dönem açık, sürüm aynı). Backend 113 test. Karar: yeniden açma = düzeltme modu (kullanıcı "a").
|
||||
- **Adım 3:** Değerlendirme sayfası (`features/review/`): `Questions` (4 kart; 1. kart hesap açıklaması, 3. bütçe yüzdesi, 4. hedefe göre durum `lib/review.ts` → `savingsStatus`: above/below/deficit), `breakdowns.tsx` (`GroupComparison`, `TopCategories` ilk 5 + "Tümünü göster", `WeeklyDistribution`; ortak `Bar`, `ratio`), `Reflection` (görüntüle/düzenle, `useUpdatePeriod` + `version`, 409 formda; şema `features/review/schema.ts`), `NotesSection collapsed` (yalnız başlık, `aria-expanded` ile açılır). Ortak `components/markdown-tabs.tsx` (Yaz/Önizle, `useId` kimlikler; `NoteEditor` da kullanıyor). `useReview` + `queryKeys.review` (dönem önekinde → kayıt değişince yenilenir). Dönem sürüyorsa bilgi satırı. Frontend 84 test; e2e 24 kontrol (Ekim kapatılıp Kasım'a artı devri dahil). Mobil haftalık satır sıkıştırıldı.
|
||||
- **Adım 4:** Kapatma sihirbazı `/degerlendirme/kapat` (`features/review/close-page.tsx`): Özet (4 soru, erken kapatma uyarısı, "Nasıl daha iyi" boşsa uyarı + bağlantı) → Birikim (artıda `MoneyInput` varsayılan tamamı, Tamamı/Hiçbiri, canlı önizleme; açıkta/sıfırda yalnız bilgi) → Onay (`useClosePeriod`, `version`; 409 formda, `onSettled` tüm `['period']` + liste yenilenir → ikinci deneme güncel sürümle) → Sonuç (sayfa durumunda; kapanınca `current` null). `lib/review.ts` → `settlePreview` (backend `settle` ile aynı tutulmalı), şema `close-schema.ts` (0..gerçekleşen). `Stepper` artık `steps`/`label` alıyor. Dönem açma sihirbazı tarih önerisi: `suggestRanges(today, prevEnd)` / `rangeAfter` — son dönemin bitişinden başlar, çakışan ay önerilmez (erken kapatılan ay tekrar önerilmezdi hatası önlendi). Not: `moneyToText(0)` → `''` (plan taslağı için); 0 göstermek gerekiyorsa `formatNumber`. Frontend 89 test; e2e 33 kontrol (DB silindi, 8091 kapatıldı).
|
||||
- **Adım 5:** Dönem seçimi: `lib/period-select.ts` (`useSelectedPeriod`: `?donem=` → listeden; yoksa açık dönem, o da yoksa düzeltmedeki; `pickPeriod`, `isWritable`, `canReopen` = backend kuralı, `periodPath`), `features/periods/` (`PeriodPicker` başlıkta `<select>`, açık döneme dönünce parametre kalkar; `SelectedPeriod` yükleniyor/hata/bulunamadı/yok kapısı). Defter, Plan, Değerlendirme, kapatma sihirbazı buna geçti (Panel ve dönem sihirbazı hâlâ `useCurrentPeriod`). Defter kapalı dönemde salt okunur (form yok, `EntryList readOnly`); hafta değişimi `donem`'i korur. Plan: düzenleme `isWritable`, kopyalama yalnız açık. `PeriodHeading` durum rozeti. Değerlendirme sonu `ClosingSection` (açık → Kapat · düzeltmede → Defter'de/Plan'da düzelt + Yeniden Kapat · kapalı → kapanış bilgisi + **sahip** için satır içi onaylı "Düzeltme için yeniden aç") + `PeriodHistory`. `useReopenPeriod`; `useUpdatePeriod` liste önbelleğini de günceller (seçili dönem listeden → art arda kayıtta 409 olmasın). **Ders:** başarıda bileşen kalkıyorsa `mutate(…, {onSuccess})` çalışmaz → `mutateAsync` kullan. Frontend 92 test; e2e 36 + kapatma 33 yeniden (DB silindi). Faz 6 ✅.
|
||||
|
||||
### 2026-11-12 — Faz 7
|
||||
- Kararlar (kullanıcı onayı): toplam birikim Faz 8'e kadar = kapanışlarda birikime aktarılanların toplamı; sekmeler Günlük/Haftalık = bu dönem (kümülatif, harcanabilir günlere eşit dağıtılır), Aylık = son 12 dönem; açık dönem yoksa son dönem salt okunur.
|
||||
- **Adım 1:** `services/stats.rs` + `routes/stats.rs`: `GET /periods/{id}/daily` (`DaySpent[]`: dönemin her günü, `spent` = `GroupAmounts`, harcama yoksa 0; `budget::spent_by_group_and_date` artık `pub(crate)`), `GET /stats/periods?limit=12` (`PeriodStats`: `total_saved` tüm dönemlerin `to_savings` toplamı — limitten bağımsız, yeniden açılan dönemin eski tutarı sayılır; `periods` eskiden yeniye, her biri `budget::summary` ile → dönem başına ~5 sorgu, en fazla 24). `tests/stats.rs` (2). Mutasyon 2/2 yakalandı. Backend 118 test.
|
||||
- **Adım 2:** Panel (`features/dashboard/`): `page.tsx` (açık dönem; yoksa `NoPeriodCard` + son dönem salt okunur, `lib/dashboard.ts` → `dashboardPeriod`), `overview.tsx` (kartlar: gelir, sabit, toplam birikim `useStats().total_saved`, borç yalnızca varsa — 3 kartta ızgara 3 sütun; **Anlık durum** DOM'da önce/mobilde üstte, masaüstünde sağda: kalan harcanabilir + `dailyAllowance` = kalan ÷ kalan gün (bugün dahil, kuruş aşağı); gruplar `WeekSummary` artık `index: number | 'month'`, Haftalık/Aylık geçişi; boş durumlar: bütçe dağıtılmamış → Plan, harcama yok → Defter). "Harcama Ekle": masaüstünde başlıkta, mobilde yüzen (alt menünün üstü) → `/defter` `state.focus` → `EntryForm autoFocus`. `components/skeleton.tsx`. `queryKeys.stats = ['periods','stats']` (kapatma/plan ile yenilenir), kayıt mutasyonları da stats'ı yeniler. Frontend 95 test; e2e 22 kontrol (DB silindi). Canlı yayın 2026-11-12 güncellendi (kullanıcı Deploy yaptı; `/api/stats/periods` 401).
|
||||
- **Adım 3:** Grafikler (`features/dashboard/charts.tsx`, Recharts 3, **lazy** ayrı parça ~110 KB gz): Günlük/Haftalık/Aylık (`Segmented` — `components/segmented.tsx`, Panel grup kartı da kullanıyor). Seriler `lib/charts.ts` (`dailySeries`: harcanabilir günlere eşit + kümülatif harcanan, gelecek gün `null`; `weeklySeries`: hafta sonu planı, içinde bulunulan hafta bugüne kadar; `monthlySeries`: `useStats` dönemleri; `lastPoint`) + test. Backend: `budget::Summary.spent_by_group` → `PeriodStat.spent_by_group` (aylık grup grafiği). Grup grafiğinde harcanabilir çizgisi varsayılan kapalı (ölçeği ezip grup çizgilerini sıfıra yapıştırıyordu), `aria-pressed` düğmeyle açılır. Lejant/ipucu sırası `itemSorter` ile sabit (Recharts 3 varsayılanı alfabetik). Grafik `aria-hidden`, altında metin özeti (figcaption). `formatCompact` (eksen, bölünmez boşluk). **Ders:** `--color-*` değişkenleri `@theme inline` yüzünden derlenmiş CSS'te yok → SVG'de ham `var(--needs)` (CLAUDE.md'ye eklendi); e2e'de çizgi rengi `getComputedStyle` ile denetlenir. Tam sayfa ekran görüntüsü (`captureBeyondViewport`) ResponsiveContainer'ı küçültebilir → grafik için görünen alan görüntüsü al. Frontend 101, backend 118 test; e2e 18 kontrol. `npm audit` uyarıları shadcn CLI'dan (geliştirme aracı; `--force` shadcn'i 1.0'a düşürür → uygulanmadı). **Faz 7 ✅.**
|
||||
|
||||
### 2026-11-13 — Faz 8
|
||||
- **Adım 1:** Varlıklar backend: `models/asset.rs` (`AssetKind` cash/gold/fx, `TxType` buy/sell), `services/holding.rs` (saf: ağırlıklı ortalama maliyet, tam satışta artık maliyet sıfırlanır, `Oversold`, i128 `mul_div`; 5 birim test), `services/asset.rs` (liste + hesaplanmış durum, döviz ekleme 3 harf/tekil, ad/aktiflik, işlem ekle/düzenle/sil; her yazmada tarih sıralı yeniden denetim; TL: miktar = tutar), `routes/asset.rs` (7 uç nokta). Varsayılan 8 varlık `seed::assets` (kayıtta) + **migration v2** (eski haneler, idempotent → canlıdaki hane uygulama yeniden başlayınca alır). `tests/assets.rs` (4). Mutasyon 3/3 yakalandı. Backend 135 test.
|
||||
- **Adım 2:** Kurlar: `reqwest` 0.12 (`rustls-tls` → MongoDB ile aynı `ring`; 0.13 `aws-lc-rs` getiriyordu), `quick-xml` (serialize). `services/rate_sources.rs` (saf: `parse_tcmb` ForexBuying ÷ Unit, boş alış atlanır, `decimal_e6` kayan noktasız; `parse_gold` xaus `per_gram_usd`; `gold_rates` has gram katsayıları; testler örnek metinle), `services/rates.rs` (`refresh`: iki kaynak bağımsız, TCMB düşerse DB'deki son USD; gün başına upsert; `spawn_refresher` main'de, `RATES_REFRESH_MINUTES` varsayılan 60 / 0 kapalı; `current`: aggregation kod×(oto|hane) en yeni → en yeni tarih, eşitlikte manuel; `set_manual`/`delete_manual`; `portfolio` + `value_of` yarım yukarı). `GET /portfolio`, `POST /rates`, `DELETE /rates/{id}`. `calendar::today_tr` (UTC+3). Testler sahte axum sunucusuyla (internet yok): `tests/rates.rs` (2) + birim (4). Mutasyon: öncelik, USD yedeği yakalandı; yuvarlama kaçtı → `value_of` birim testi eklendi, artık yakalıyor. Gerçek kaynak denemesi (test DB): TCMB 22 döviz, altın 5 kod (gram 6.253,82, çeyrek 10.024,88 ₺). Backend 145 test. DEPLOY §2 + `.env.example`: `RATES_REFRESH_MINUTES`, konteynerin internete çıkışı gerekir.
|
||||
- **Adım 3:** Birikim sayfası (`features/savings/`): `page.tsx` (özet: toplam değer + son kur zamanı, maliyet, kâr/zarar `stacked`, gerçekleşen; uyarılar: kur hiç yok / 6 saattir çekilemedi / kursuz varlık; dağılım halka grafiği `allocation-chart.tsx` lazy 6 KB, renkli liste = metin özeti; varlık listesi: elde/geçmişi olanlar + `<details>` "Diğer varlıklar"; `add-currency.tsx`), `asset-page.tsx` (`/birikim/:id`: durum kartları, `RateBox` elle kur gir/kaldır, `TxFormView` `tx-form.tsx` alım/satım: miktar × birim fiyat → tutar önerisi, elle değişen tutar korunur, yeni alımda birim fiyat = güncel kur (kuruşa yuvarlı), TL'de yalnız tutar, satışta eldeki sınırı; geçmiş: düzenle + iki adımlı sil; pasife al/etkinleştir). `lib/decimal.ts` (`parseE6`: virgül ondalık, tek nokta + 3 rakam binlik, `formatE6`, `formatRate`, `valueKurus` BigInt = backend), `lib/savings.ts` (`plRatio`, `formatPlPercent` `-%0,6` ve ≈0 gizli, `isFetchStale` 6 saat, `rateAgeDays`, `splitAssets`, `allocation`, `kindEmoji`, `qty`), `lib/assets.ts` (kancalar, hepsi `['portfolio']` yeniler), `components/decimal-input.tsx`, `formatDateTime`. Frontend 114 test; e2e 37 kontrol (kurlar DB'ye elle, `RATES_REFRESH_MINUTES=0`). Düzeltilen: `%-0,0`, TL satırında tekrar, mobil özet taşması; Tailwind keyfi alt seçici (`[&>*]`) `lg:` ile ezilemedi → sınıflar öğenin kendisine.
|
||||
- **Adım 4:** Kapanış → birikim. Backend (`services/asset.rs`): `add_period_savings` (yalnız `closed`, `to_savings > 0`, dönem başına tek → 409; TL varlığı pasif olsa da; not "<ad> kapanışı", tarih son gün), `pending_period_savings`, `plan_savings_sync` (transaction öncesi: bağlı kayıt + TL geçmişi denetimi → `SavingsSync::Update/Delete`) + `apply_savings_sync` (kapatma transaction'ı içinde). `PeriodCloseResult.savings_linked`. `GET /savings/pending`, `POST /periods/{id}/to-savings`. `tests/savings_link.rs` (3), mutasyon 3/3. Frontend: kapatma sonucunda `AddToSavings` (onay düğmesi → "Birikime eklendi" + bağlantı; yeniden kapatmada "kayıt güncellendi"), `features/savings/pending.tsx` (Birikimler üstünde), Panel "Toplam birikim" = `portfolio.value_total` (`stats.total_saved` artık arayüzde kullanılmıyor; backend'de duruyor). `useClosePeriod` portföyü de yeniler. Backend 149, frontend 114 test; e2e 14 kontrol. **Faz 8 ✅.**
|
||||
|
||||
### 2026-11-13 — Faz 9
|
||||
- **Adım 1:** Rapor verileri. `/stats/periods` → `PeriodStat` artık `summary: budget::Summary` taşır; DTO'ya `income_total`, `fixed_expense_total`, `savings_goal`, `carryover_debt/surplus`, `realized_savings` (= review 4. soru) eklendi. `services/reports.rs` + `GET /reports/breakdown` (tek `$facet`: toplam, 4 grup sabit sırada (0 dahil), **tüm** kategoriler çoktan aza, ödeme türü (`$ifNull` → `null` = girilmemiş), ay `$substrBytes`; filtre dönem/tarih aralığı (dahil, başlangıç>bitiş 400)/grup/kategori/ödeme türü (`none`)). Bütçe sapması için yeni uç nokta yok (mevcut `/budget`). `tests/reports.rs` (1, kapsamlı) + stats testi genişledi. Mutasyon 2/2. Backend 155 test.
|
||||
- **Adım 2:** Raporlar sayfası (`features/reports/`): `page.tsx` (filtre adreste: `donem`/`baslangic`/`bitis`/`grup`/`kategori`/`odeme` (`yok` = girilmemiş), dönem varsa tarih yok sayılır), `filters.tsx` (Kapsam: tüm zamanlar / dönem / tarih aralığı (yıl başı → bugün varsayılan), grup seçilince kategori listesi daralır, mobilde katlanır), `section.tsx` (`ReportSection`: `data-report`, CSV indir + bölüm yazdır; `ShareTable`: satır içi CSS pay çubukları — yazdırmada da görünür), `sections.tsx` (gruplar, ödeme türleri, **tüm** kategoriler, aylar = filtreli; bütçe sapması = seçili/açık/son dönem, grup + hafta; gelir–gider + birikim oranı = son 12 dönem; varlıklar), `charts.tsx` lazy (bar + çizgi). `lib/csv.ts` (BOM, `;`, CRLF, tırnaklama, `csvMoney` binliksiz), `lib/reports.ts` (`parseFilter`/`filterToParams`/`filterToQuery`, `describeFilter` → yazdırmada kapsam satırı, `savingsRate`, `deviation`, `formatMonth`), `lib/print.ts` (`installPrintTheme`: beforeprint'te koyu tema kaldırılır; `printSection`: `data-print-one` + `data-print-target`), index.css `@media print`, layout `print:hidden`. Kayıt değişince `['reports']` yenilenir. Frontend 121 test; e2e 34 kontrol (CSV baytları, yazdırma hedefi, tema, filtreler). Not: `Blob.text()` BOM'u siler → BOM'u baytlardan doğrula.
|
||||
- **Adım 3:** CSV dışa/içe aktarma. Backend `services/csv_io.rs` (saf: yazma BOM/`;`/CRLF/`1250,50`/GG.AA.YYYY; okuma: BOM, `;`/`,`/sekme otomatik, tırnak, satır numarası, `norm` başlık eşleme (Türkçe İ/ı), `parse_money` (1.250,50 · 1250.50 · 1.000=bin), `parse_e6`, `parse_date_any`), `services/transfer.rs` (harcama/plan/varlık: dışa aktar; içe aktar `commit=false` deneme → rapor {total, valid, duplicates, imported, errors[(satır, neden)], preview}; tekrar sayacı `Existing` (dosyada 2, DB'de 1 → 1 eklenir); harcamada dönem tarihten, kapalı/yok → satır hatası, kategori adla (+grup), aynı ad 2 grupta → hata; plan `plan::create` ile; varlıkta tarih+dosya sırasına göre eldekinden fazla satış simülasyonu, yazma `asset::create_tx`). `Group::label()`. Rotalar `routes/transfer.rs` (`/export/*.csv`, `POST /import/{tür}` 5 MB). `tests/transfer.rs` (3) + csv_io birim (4); mutasyon 3/3. Frontend: Ayarlar → Veri (`features/settings/data-section.tsx`: indirme bağlantıları, içe aktarma: tür seç → dosya → deneme raporu → onay), `api.postCsv`, `lib/decode.ts` (UTF-8 fatal → Windows-1254), `lib/transfer.ts`. Backend 164, frontend 125 test; e2e 16 kontrol.
|
||||
- **Adım 4a:** Ekstre backend. `PeriodStatus::Archived` (yazılabilir; kapatılmaz 409; devir zincirinde atlanır: `period::create` önceki, `closing::next_period`, reopen engeli; `budget::previous_closed` kapalı+arşiv), `period::create_archive`. `services/bank_import.rs`: `fold` (Türkçe → ASCII; **"İ".to_lowercase() = i + U+0307** düzeltildi), `rule_key` (ilk 2 anlamlı kelime), `import_key`, `default_kind` (ödeme/virman → yoksay; kartta giriş → iade, hesapta → gelir), `target` (içeren yazılabilir dönem / takvim ayı arşivi (kırpılmış) / gelecek-kapalı engel), `preview`, `commit` (arşivleri açar, harcama/iade insert_many + `import_key`, gelir `plan::create` + `import_key`, kurallar upsert `import_rules`). Entry update: iade eksi kalır. Rotalar `routes/bank_import.rs` (`/bank-import/preview|commit`, 5 MB). `tests/bank_import.rs` (3, devir zinciri dahil); mutasyon 5/5. Frontend: `archived` durum metni, Değerlendirme'de arşiv bilgisi, kapatma sihirbazı arşivi reddeder. Backend 177 test.
|
||||
|
||||
+66
-11
@@ -5,16 +5,16 @@
|
||||
| Faz | Kapsam | Çıktı | Durum |
|
||||
|---|---|---|---|
|
||||
| **Faz 0** | Ortam kurulumu, VS Code yapılandırması, monorepo iskeleti, hafıza dosyaları, yerel MongoDB, veri modeli onayı | `npm run dev` ile boş uygulama tarayıcıda açılıyor | ✅ |
|
||||
| **Faz 1** | Backend temeli: config, DB bağlantısı, kimlik doğrulama, hane, kategoriler, ödeme türleri, health check | API `.http` dosyasıyla test edilebiliyor | 🔄 |
|
||||
| **Faz 2** | Frontend temeli: tasarım sistemi, tema, logo/ikonlar, layout, navigasyon, giriş/kayıt | Giriş yapılabilen, temalı iskelet uygulama | ⏳ |
|
||||
| **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | ⏳ |
|
||||
| **Faz 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ⏳ |
|
||||
| **Faz 5** | Günlük defter | Harcama girişi | ⏳ |
|
||||
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ⏳ |
|
||||
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ⏳ |
|
||||
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ⏳ |
|
||||
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | ⏳ |
|
||||
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası | Telefona kurulabilir web uygulaması | ⏳ |
|
||||
| **Faz 1** | Backend temeli: config, DB bağlantısı, kimlik doğrulama, hane, kategoriler, ödeme türleri, health check | API `.http` dosyasıyla test edilebiliyor | ✅ |
|
||||
| **Faz 2** | Frontend temeli: tasarım sistemi, tema, logo/ikonlar, layout, navigasyon, giriş/kayıt | Giriş yapılabilen, temalı iskelet uygulama | ✅ |
|
||||
| **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | ✅ |
|
||||
| **Faz 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ✅ |
|
||||
| **Faz 5** | Günlük defter | Harcama girişi | 🔄 |
|
||||
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ✅ |
|
||||
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ |
|
||||
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ |
|
||||
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | 🔄 |
|
||||
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | ⏳ |
|
||||
| **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) | Masaüstü ve mobil uygulamalar | ⏳ |
|
||||
|
||||
## Faz 0 Adımları
|
||||
@@ -29,4 +29,59 @@
|
||||
- ✅ 2. Modeller, migration mekanizması ve indeksler
|
||||
- ✅ 3. Kimlik doğrulama: kayıt (+ hane + örnek kategori/ödeme türü), giriş, çıkış, `me`; argon2 + JWT cookie + rate limit
|
||||
- ✅ 4. Hane izolasyonu (middleware), üye davet kodu
|
||||
- 🔄 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http`
|
||||
- ✅ 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http`
|
||||
|
||||
## Faz 2 Adımları
|
||||
- ✅ 1. Tailwind v4 + shadcn/ui (radix, nova), tasarım token'ları (4 grup + durum renkleri, AA doğrulandı), Inter, tema (aydınlık/karanlık/sistem), `lib/format.ts` + Vitest
|
||||
- ✅ 2. Logo ve uygulama ikonu (SVG), favicon, PWA/Apple ikonları
|
||||
- ✅ 3. `lib/api.ts`, TanStack Query, ts-rs tip üretimi, oturum durumu
|
||||
- ✅ 4. Router (react-router), korumalı sayfalar, layout: mobil alt menü / masaüstü sol menü
|
||||
- ✅ 5. Giriş / kayıt ekranları (react-hook-form + zod, beni hatırla, davet kodu), arayüz metinleri tek dosyada
|
||||
|
||||
## Faz 3 Adımları
|
||||
- ✅ 1. Dockerfile (çok aşamalı), Axum arayüzü sunar (SPA yönlendirmesi, önbellek başlıkları, sıkıştırma), graceful shutdown, yerelde imaj testi
|
||||
- ✅ 2. Canlı MongoDB kontrolü (§12.3): kurulum şekli, replica set, ayrı kullanıcı, dış erişim
|
||||
- ✅ 3. Dokploy: Gitea bağlantısı, ortam değişkenleri, alan adı + HTTPS, ilk yayın
|
||||
- ✅ 4. Canlıda ilk hesap + kontrol listesi
|
||||
- ⏳ 5. Yedekleme (mongodump, 14 gün) + geri yükleme denemesi — **ertelendi, Faz 5 bitmeden yapılacak**
|
||||
|
||||
## Faz 4 Adımları
|
||||
- ✅ 1. Backend: dönem (aç, açık dönem, liste, detay + haftalar, düzenle), takvim hesapları, testler
|
||||
- ✅ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar
|
||||
- ✅ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
|
||||
- ✅ 4. Frontend: ilk dönem sihirbazı
|
||||
- ✅ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı)
|
||||
- ✅ 6. Frontend: notlar (Markdown, düzenle/önizle)
|
||||
|
||||
## Faz 5 Adımları (taslak)
|
||||
- ✅ 1. Backend: harcama kayıtları (ekle/düzenle/sil + geri al, hafta/dönem filtresi, kategori–grup tutarlılığı, yalnızca açık dönem), testler
|
||||
- ✅ 2. Backend: haftalık/aylık gerçekleşen (grup bazlı bütçe–harcanan–kalan), özet ve önerilere gerçekleşen verisi
|
||||
- ✅ 3. Frontend: Defter — hafta seçici + hızlı giriş formu (tutar, 4 grup düğmesi, kategori, ödeme türü, tarih, açıklama; son seçim hatırlanır)
|
||||
- ✅ 4. Frontend: haftanın girişleri (gün gün), düzenle/sil + "Geri al", haftalık özet
|
||||
- 🔄 5. Canlı yedekleme (betikler + test ✅, sunucuda kurulum kullanıcıda) (mongodump günlük, 14 gün) + geri yükleme denemesi
|
||||
|
||||
## Faz 6 Adımları (taslak)
|
||||
- ✅ 1. Backend: değerlendirme (`GET /periods/{id}/review`: 4 soru, grup/kategori/hafta) + `reflection_md`
|
||||
- ✅ 2. Backend: dönem kapatma (transaction: durum, devir, birikime aktarılan tutar) + yeni dönemde devrin bağlanması + yeniden açma (sahip)
|
||||
- ✅ 3. Frontend: Değerlendirme sayfası (4 soru, planlanan/gerçekleşen, en çok harcanan kategoriler, haftalık dağılım, "Nasıl daha iyi yapabilirim?", notlar)
|
||||
- ✅ 4. Frontend: dönem kapatma sihirbazı (+ yeni dönemi kopyalayarak açma)
|
||||
- ✅ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip) + düzeltmedeki dönemi Defter/Plan/Değerlendirme'de seçme
|
||||
|
||||
## Faz 7 Adımları
|
||||
- ✅ 1. Backend: `GET /periods/{id}/daily` (gün × grup harcama; günlük/haftalık grafik verisi) + `GET /stats/periods` (son 12 dönem: harcanabilir, harcanan, birikime aktarılan; aylık grafik ve toplam birikim)
|
||||
- ✅ 2. Frontend: özet kartları (gelir, sabit gider, toplam birikim, devreden borç, grup planlanan/gerçekleşen haftalık–aylık, **anlık durum** en belirgin) + "➕ Harcama Ekle" (mobilde yüzen düğme) + skeleton ve boş durumlar
|
||||
- ✅ 3. Frontend: grafikler (Recharts) — kümülatif harcanabilir vs. gerçekleşen; 4 grup + harcanabilir; Günlük / Haftalık / Aylık sekmeleri, koyu tema, erişilebilir özet
|
||||
|
||||
## Faz 8 Adımları
|
||||
- ✅ 1. Backend: varlıklar (hane başına varsayılanlar: TL, gram/çeyrek/yarım/tam altın, ons, USD, EUR; döviz eklenebilir) + alım/satım kayıtları (satış ≤ eldeki miktar) + varlık başına miktar, **ağırlıklı ortalama maliyet**, gerçekleşen kâr/zarar; testler
|
||||
- ✅ 2. Backend: kurlar — TCMB `today.xml` (döviz) + xaus.com ons fiyatı × TCMB USD (altın; çeyrek/yarım/tam has gram katsayısıyla), saatlik arka plan görevi + önbellek (`exchange_rates`), kaynak erişilemezse son kur + zamanı; **manuel kur** (hane); portföy değerleme uç noktası (güncel değer, kâr/zarar tutar ve %, toplamlar, dağılım); kaynak ayrıştırıcıları örnek dosyalarla testli
|
||||
- ✅ 3. Frontend: Birikim sayfası — portföy özeti + halka grafik, varlık listesi (miktar, ortalama maliyet, güncel kur, değer, kâr/zarar), alım/satım formu, işlem geçmişi (düzenle/sil), manuel kur, eski kur uyarısı
|
||||
- ✅ 4. Kapanış entegrasyonu: birikime aktarılan tutar onayla TL varlığına eklenir (`source_period_id`, tekrar eklenmez); geçmiş kapanışlar için "bekleyen birikimler" listesi; Panel "Toplam birikim" → portföyün güncel değeri
|
||||
|
||||
## Faz 9 Adımları
|
||||
- ✅ 1. Backend: rapor verileri — dönem istatistiklerine gelir/sabit gider/devir/gerçekleşen birikim (birikim oranı için) + `GET /reports/breakdown` (grup / **tüm kategoriler** / ödeme türü / ay; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü). Bütçe sapması mevcut `budget` verisinden
|
||||
- ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
|
||||
- ✅ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
|
||||
- 🔄 4. **Banka / kredi kartı ekstresi içe aktarma** — ✅ 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) · ⏳ 4b arayüz (Excel/CSV okuma, sütun eşleştirme + banka profili, satır gözden geçirme)
|
||||
- ⏳ 5. JSON tam hane yedeği: sürümlü dışa aktarma; geri yüklemede önizleme + onay + sunucuda otomatik geri dönüş noktası ("geri yüklemeyi geri al"); başka haneden taşıma
|
||||
- ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF
|
||||
|
||||
+21
-2
@@ -1,8 +1,27 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"plugins": [
|
||||
"react",
|
||||
"typescript",
|
||||
"oxc"
|
||||
],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
"react/only-export-components": [
|
||||
"warn",
|
||||
{
|
||||
"allowConstantExport": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"src/components/ui/**"
|
||||
],
|
||||
"rules": {
|
||||
"react/only-export-components": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# ts-rs tarafından üretilir (npm run types)
|
||||
src/types/
|
||||
# shadcn/ui kaynakları (kendi biçimleriyle bırakılır)
|
||||
src/components/ui/
|
||||
dist/
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,24 @@
|
||||
<!-- Köşesiz kare sürüm: Android "maskable" ve Apple touch icon (sistem kendi şekliyle kırpar).
|
||||
İçerik %82'ye küçültülmüştür → maskable güvenli alanı (merkezde %80 çaplı daire) içinde kalır. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="kakeibo-bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#3b4aa8"/>
|
||||
<stop offset="1" stop-color="#26307a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" fill="url(#kakeibo-bg)"/>
|
||||
<g transform="translate(256 256) scale(0.82) translate(-260 -256)">
|
||||
<rect x="140" y="92" width="252" height="328" rx="28" fill="#f7f3ea"/>
|
||||
<rect x="140" y="92" width="52" height="328" rx="28" fill="#e6dfcf"/>
|
||||
<rect x="166" y="92" width="26" height="328" fill="#e6dfcf"/>
|
||||
<g fill="#26307a">
|
||||
<circle cx="140" cy="150" r="13"/><circle cx="140" cy="220" r="13"/>
|
||||
<circle cx="140" cy="290" r="13"/><circle cx="140" cy="360" r="13"/>
|
||||
</g>
|
||||
<g fill="none" stroke="#26307a" stroke-width="22" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M238 178 L292 254 L346 178"/><path d="M292 254 V342"/>
|
||||
<path d="M250 266 H334"/><path d="M250 304 H334"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,23 @@
|
||||
<!-- Kakeibo logosu (ana kaynak). Değiştirirseniz: scripts/icons.sh -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="kakeibo-bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#3b4aa8"/>
|
||||
<stop offset="1" stop-color="#26307a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#kakeibo-bg)"/>
|
||||
<g id="mark">
|
||||
<rect x="140" y="92" width="252" height="328" rx="28" fill="#f7f3ea"/>
|
||||
<rect x="140" y="92" width="52" height="328" rx="28" fill="#e6dfcf"/>
|
||||
<rect x="166" y="92" width="26" height="328" fill="#e6dfcf"/>
|
||||
<g fill="#26307a">
|
||||
<circle cx="140" cy="150" r="13"/><circle cx="140" cy="220" r="13"/>
|
||||
<circle cx="140" cy="290" r="13"/><circle cx="140" cy="360" r="13"/>
|
||||
</g>
|
||||
<g fill="none" stroke="#26307a" stroke-width="22" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M238 178 L292 254 L346 178"/><path d="M292 254 V342"/>
|
||||
<path d="M250 266 H334"/><path d="M250 304 H334"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
+18
-2
@@ -2,9 +2,25 @@
|
||||
<html lang="tr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Kakeibo</title>
|
||||
<meta name="description" content="Kakeibo — hane bazlı kişisel ev finans defteri" />
|
||||
<!-- İkonlar: scripts/icons.sh ile frontend/brand/*.svg kaynaklarından üretilir -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<!-- Mobil tarayıcı çubuğu rengi (tema arka planıyla aynı) -->
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
|
||||
<!-- Tema: React yüklenmeden önce uygulanır → ilk açılışta "beyaz parlama" olmaz (lib/theme.tsx ile aynı mantık) -->
|
||||
<script>
|
||||
try {
|
||||
var t = localStorage.getItem('kakeibo-theme')
|
||||
if (t === 'dark' || ((!t || t === 'system') && matchMedia('(prefers-color-scheme: dark)').matches))
|
||||
document.documentElement.classList.add('dark')
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+7335
-60
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user