Compare commits
35 Commits
f9be08114d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
|
||||
@@ -57,15 +57,29 @@ 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). `components/ui/**` shadcn dosyaları.
|
||||
- Frontend: Tailwind v4 (`src/index.css` token'ları), shadcn/ui (style `radix-nova`, `cn` paketi), `@/` → `src/`. Renkler: `bg-needs`/`text-needs-fg`/`bg-needs-soft` (wants, culture, unexpected aynı), `text-income`/`text-expense`/`text-warning`. Tutarlarda `money` sınıfı. Butonlar ≥44 px (shadcn boyutları büyütüldü).
|
||||
- Frontend: Tailwind v4 (`src/index.css` token'ları), shadcn/ui (style `radix-nova`, `cn` paketi), `@/` → `src/`. Renkler: `bg-needs`/`text-needs-fg`/`bg-needs-soft` (wants, culture, unexpected aynı), `text-income`/`text-expense`/`text-warning`. SVG/JS'te (Recharts vb.) ham değişken kullan: `var(--needs)`, `var(--primary)` — `@theme inline` yüzünden `--color-*` derlenmiş CSS'te olmayabilir. Tutarlarda `money` sınıfı. Butonlar ≥44 px (shadcn boyutları büyütüldü).
|
||||
- API: `lib/api.ts` (`api.get/post/...`, hata → `ApiError` Türkçe `message`), `lib/query.ts` (QueryClient + `queryKeys`), `lib/auth.ts` (`useMe` → undefined/null/MeResponse, `useLogin`/`useRegister`/`useLogout`).
|
||||
- Tipler: `frontend/src/types/` **elle düzenlenmez**; backend'de DTO'ya `#[derive(TS)] #[ts(export)]` ekle → `npm run types`. İstek yapılarında `optional_fields`.
|
||||
- Sayfalar: `src/router.tsx`; adresler `components/layout/nav-items.ts` (`paths`); sayfa bileşenleri `features/<alan>/page.tsx`; iskelet `components/layout/app-layout.tsx`. Sayfa başlığı `PageHeader` (sekme başlığını da ayarlar). Metinler `lib/strings.ts` (`t`).
|
||||
- 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
|
||||
|
||||
+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
+434
-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",
|
||||
@@ -1290,14 +1428,18 @@ dependencies = [
|
||||
"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",
|
||||
]
|
||||
@@ -1344,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"
|
||||
@@ -1429,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"
|
||||
@@ -1481,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",
|
||||
@@ -1808,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"
|
||||
@@ -1911,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"
|
||||
@@ -1949,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"
|
||||
@@ -1999,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"
|
||||
@@ -2039,6 +2326,7 @@ version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "102f35f7de7b06d8c61f1cff6592f5253e0dea431c74867aad5dfd70f44663fb"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2268,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"
|
||||
@@ -2378,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"
|
||||
@@ -2429,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"
|
||||
@@ -2582,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"
|
||||
@@ -2656,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"
|
||||
@@ -2682,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"
|
||||
@@ -2804,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"
|
||||
@@ -2831,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"
|
||||
@@ -2863,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"
|
||||
@@ -3142,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,13 +10,17 @@ 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"] }
|
||||
|
||||
|
||||
+261
-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
|
||||
|
||||
@@ -127,3 +139,249 @@ 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
|
||||
|
||||
+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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
|
||||
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 })],
|
||||
|
||||
@@ -24,11 +24,18 @@ struct Migration {
|
||||
}
|
||||
|
||||
/// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar.
|
||||
const MIGRATIONS: &[Migration] = &[Migration {
|
||||
version: 1,
|
||||
name: "initial",
|
||||
run: |db| Box::pin(m001_initial(db)),
|
||||
}];
|
||||
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::*;
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-3
@@ -1,6 +1,6 @@
|
||||
use std::{net::SocketAddr, process::ExitCode};
|
||||
|
||||
use kakeibo::{config::Config, db, routes, state::AppState};
|
||||
use kakeibo::{config::Config, db, routes, services, state::AppState};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -41,9 +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::new(db, config);
|
||||
let app = routes::router(state);
|
||||
let app = routes::app(state);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
@@ -57,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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
//! 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)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export)]
|
||||
pub enum Group {
|
||||
/// 🏠 Zorunlu
|
||||
Needs,
|
||||
|
||||
@@ -1,8 +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,57 @@
|
||||
//! `periods` koleksiyonu (DATA_MODEL.md §3.4).
|
||||
|
||||
use mongodb::bson::{DateTime, Uuid};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export)]
|
||||
pub enum PeriodStatus {
|
||||
/// Girişler yapılabilir. Hanede en fazla bir tane.
|
||||
Open,
|
||||
/// Salt okunur. Sahip yeniden açarsa `Correcting` olur.
|
||||
Closed,
|
||||
/// Düzeltmede: kapanmış dönem sahip tarafından düzeltme için açıldı.
|
||||
/// Yazılabilir, ama "açık dönem" sayılmaz (yeni dönem açıkken de olabilir).
|
||||
/// "Yeniden kapat" ile devir yeniden hesaplanır.
|
||||
Correcting,
|
||||
}
|
||||
|
||||
impl PeriodStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
PeriodStatus::Open => "open",
|
||||
PeriodStatus::Closed => "closed",
|
||||
PeriodStatus::Correcting => "correcting",
|
||||
}
|
||||
}
|
||||
|
||||
/// Kayıt eklenip değiştirilebilir mi?
|
||||
pub fn is_writable(self) -> bool {
|
||||
self != PeriodStatus::Closed
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Period {
|
||||
#[serde(rename = "_id")]
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
/// `"YYYY-MM-DD"`, dahil
|
||||
pub start_date: String,
|
||||
/// `"YYYY-MM-DD"`, **hariç**
|
||||
pub end_date: String,
|
||||
pub status: PeriodStatus,
|
||||
/// Kuruş
|
||||
pub savings_goal: i64,
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası)
|
||||
#[serde(default)]
|
||||
pub reflection_md: Option<String>,
|
||||
/// Kapanışta birikime aktarılan tutar (kuruş). Faz 8'de varlık hareketine dönüşecek.
|
||||
#[serde(default)]
|
||||
pub to_savings: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub closed_at: Option<DateTime>,
|
||||
pub version: i32,
|
||||
}
|
||||
@@ -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,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(),
|
||||
}))
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use axum::{
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
@@ -35,7 +36,8 @@ pub struct ListQuery {
|
||||
include_inactive: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
#[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"))]
|
||||
@@ -44,7 +46,8 @@ pub struct CreateRequest {
|
||||
emoji: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
#[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>,
|
||||
@@ -54,17 +57,20 @@ pub struct UpdateRequest {
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
#[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)]
|
||||
#[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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,22 +1,47 @@
|
||||
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
|
||||
|
||||
mod asset;
|
||||
mod auth;
|
||||
mod budget;
|
||||
mod catalog;
|
||||
mod entry;
|
||||
mod health;
|
||||
mod household;
|
||||
mod note;
|
||||
mod period;
|
||||
mod plan;
|
||||
mod rates;
|
||||
mod reports;
|
||||
mod review;
|
||||
mod spa;
|
||||
mod stats;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{delete, get, patch, post, put},
|
||||
};
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::{error::AppError, state::AppState};
|
||||
|
||||
/// Sunucunun tamamı: API + (STATIC_DIR tanımlıysa) arayüz dosyaları,
|
||||
/// sıkıştırma ve istek logları. `main.rs` bunu kullanır.
|
||||
pub fn app(state: AppState) -> Router {
|
||||
let static_dir = state.config.static_dir.clone();
|
||||
let mut app = router(state);
|
||||
if let Some(dir) = static_dir {
|
||||
app = app.fallback_service(spa::router(&dir));
|
||||
}
|
||||
app.layer(CompressionLayer::new())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
|
||||
/// Tüm API route'ları. Testler de bu fonksiyonu kullanır.
|
||||
pub fn router(state: AppState) -> Router {
|
||||
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))
|
||||
@@ -43,7 +68,57 @@ pub fn router(state: AppState) -> Router {
|
||||
.route(
|
||||
"/payment-methods/{id}",
|
||||
patch(catalog::update_payment_method),
|
||||
);
|
||||
)
|
||||
.route("/periods", get(period::list).post(period::create))
|
||||
.route("/periods/current", get(period::current))
|
||||
.route("/periods/{id}", get(period::get).patch(period::update))
|
||||
.route("/periods/{id}/close", post(period::close))
|
||||
.route("/periods/{id}/reopen", post(period::reopen))
|
||||
.route(
|
||||
"/periods/{id}/plan-items",
|
||||
get(plan::list).post(plan::create),
|
||||
)
|
||||
.route(
|
||||
"/periods/{id}/plan-items/copy-previous",
|
||||
post(plan::copy_previous),
|
||||
)
|
||||
.route("/plan-items/{id}", patch(plan::update).delete(plan::delete))
|
||||
.route("/periods/{id}/notes", get(note::list).post(note::create))
|
||||
.route("/periods/{id}/summary", get(budget::summary))
|
||||
.route("/periods/{id}/review", get(review::get))
|
||||
.route("/periods/{id}/daily", get(stats::daily))
|
||||
.route("/stats/periods", get(stats::periods))
|
||||
.route("/reports/breakdown", get(reports::breakdown))
|
||||
.route(
|
||||
"/periods/{id}/budget",
|
||||
get(budget::get).put(budget::set_monthly),
|
||||
)
|
||||
.route("/periods/{id}/budget/weeks", put(budget::set_week))
|
||||
.route("/periods/{id}/budget/suggestion", get(budget::suggestion))
|
||||
.route("/notes/{id}", patch(note::update).delete(note::delete))
|
||||
.route(
|
||||
"/periods/{id}/entries",
|
||||
get(entry::list).post(entry::create),
|
||||
)
|
||||
.route("/entries/{id}", patch(entry::update).delete(entry::delete))
|
||||
.route("/entries/{id}/restore", post(entry::restore))
|
||||
.route("/assets", get(asset::list).post(asset::create))
|
||||
.route("/portfolio", get(rates::portfolio))
|
||||
.route("/savings/pending", get(asset::pending))
|
||||
.route("/periods/{id}/to-savings", post(asset::add_period_savings))
|
||||
.route("/rates", post(rates::create))
|
||||
.route("/rates/{id}", delete(rates::delete))
|
||||
.route("/assets/{id}", patch(asset::update))
|
||||
.route(
|
||||
"/assets/{id}/transactions",
|
||||
get(asset::list_txs).post(asset::create_tx),
|
||||
)
|
||||
.route(
|
||||
"/asset-transactions/{id}",
|
||||
patch(asset::update_tx).delete(asset::delete_tx),
|
||||
)
|
||||
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
|
||||
.fallback(|| async { AppError::NotFound });
|
||||
|
||||
Router::new().nest("/api", api).with_state(state)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
@@ -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,577 @@
|
||||
//! Bütçe ve dönem özeti (KAKEIBO_PROMPT §8.3, DATA_MODEL.md §3.8, §5).
|
||||
//!
|
||||
//! - Her grup için **aylık** tutar (`budgets`, `scope: "month"`).
|
||||
//! - Haftalık tutar saklanmaz; aylık tutar hafta gün sayısına göre bölünür.
|
||||
//! Kullanıcı bir haftayı elle değiştirirse yalnızca o kayıt saklanır (`scope: "week"`);
|
||||
//! kalan tutar diğer haftalara gün oranında dağıtılır → haftaların toplamı = aylık.
|
||||
//! - Özet: gelir − sabit gider − birikim − devreden borç + devreden artı = **harcanabilir**.
|
||||
//! - Gerçekleşen harcama (`entries`) saklanmaz; her istekte toplanır:
|
||||
//! özette toplam + kalan, bütçe görünümünde grup ve hafta bazında.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
group::Group,
|
||||
period::{Period, PeriodStatus},
|
||||
},
|
||||
services::{
|
||||
calendar::{format_date, parse_date, weeks},
|
||||
money::split_proportional,
|
||||
period,
|
||||
},
|
||||
};
|
||||
|
||||
/// Gruplara göre tutarlar (kuruş), `Group::ALL` sırasıyla.
|
||||
pub type GroupAmounts = [i64; 4];
|
||||
|
||||
pub struct Summary {
|
||||
pub income_total: i64,
|
||||
pub fixed_expense_total: i64,
|
||||
pub savings_goal: i64,
|
||||
pub carryover_debt: i64,
|
||||
pub carryover_surplus: i64,
|
||||
pub spendable: i64,
|
||||
pub budget_total: i64,
|
||||
/// Dönemde gerçekleşen harcama (silinenler hariç)
|
||||
pub spent_total: i64,
|
||||
/// Aynısı gruplara göre (`Group::ALL` sırasıyla)
|
||||
pub spent_by_group: GroupAmounts,
|
||||
}
|
||||
|
||||
pub struct WeekBudget {
|
||||
pub index: u32,
|
||||
pub start_date: String,
|
||||
pub last_date: String,
|
||||
pub days: u32,
|
||||
pub amount: i64,
|
||||
pub is_manual: bool,
|
||||
/// Bu haftada bu grupta gerçekleşen harcama
|
||||
pub spent: i64,
|
||||
}
|
||||
|
||||
pub struct GroupBudget {
|
||||
pub group: Group,
|
||||
pub monthly: i64,
|
||||
/// Dönem boyunca bu grupta gerçekleşen harcama
|
||||
pub spent: i64,
|
||||
pub weeks: Vec<WeekBudget>,
|
||||
/// Elle girilen haftaların toplamı aylık tutarı aşıyor mu?
|
||||
pub weekly_over: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SuggestMethod {
|
||||
/// Hanenin varsayılan yüzdeleri × harcanabilir tutar
|
||||
Default,
|
||||
/// Önceki dönemde gerçekleşen harcamalar
|
||||
Previous,
|
||||
/// Son 3 dönemin gerçekleşen harcama ortalaması
|
||||
Avg3,
|
||||
}
|
||||
|
||||
pub struct Suggestion {
|
||||
pub available: bool,
|
||||
pub message: Option<String>,
|
||||
pub amounts: GroupAmounts,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BudgetDoc {
|
||||
group: Group,
|
||||
scope: String,
|
||||
#[serde(default)]
|
||||
week_start: Option<String>,
|
||||
amount: i64,
|
||||
}
|
||||
|
||||
// ---------- Özet ----------
|
||||
|
||||
pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Summary> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
|
||||
let mut income_total = 0;
|
||||
let mut fixed_expense_total = 0;
|
||||
for (kind, total) in sum_by(
|
||||
db,
|
||||
col::PLAN_ITEMS,
|
||||
household_id,
|
||||
doc! { "period_id": period_id },
|
||||
"$kind",
|
||||
)
|
||||
.await?
|
||||
{
|
||||
match kind.as_str() {
|
||||
"income" => income_total = total,
|
||||
"expense" => fixed_expense_total = total,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut carryover_debt = 0;
|
||||
let mut carryover_surplus = 0;
|
||||
for (kind, total) in sum_by(
|
||||
db,
|
||||
col::CARRYOVERS,
|
||||
household_id,
|
||||
doc! { "to_period_id": period_id },
|
||||
"$kind",
|
||||
)
|
||||
.await?
|
||||
{
|
||||
match kind.as_str() {
|
||||
"debt" => carryover_debt = total,
|
||||
"surplus" => carryover_surplus = total,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let budget_total = monthly_amounts(db, household_id, period_id)
|
||||
.await?
|
||||
.iter()
|
||||
.sum();
|
||||
let spendable =
|
||||
income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus;
|
||||
let by_group = sum_by(
|
||||
db,
|
||||
col::ENTRIES,
|
||||
household_id,
|
||||
doc! { "period_id": period_id },
|
||||
"$group",
|
||||
)
|
||||
.await?;
|
||||
let spent_by_group = Group::ALL.map(|g| by_group.get(g.as_str()).copied().unwrap_or(0));
|
||||
let spent_total = spent_by_group.iter().sum();
|
||||
|
||||
Ok(Summary {
|
||||
income_total,
|
||||
fixed_expense_total,
|
||||
savings_goal: p.savings_goal,
|
||||
carryover_debt,
|
||||
carryover_surplus,
|
||||
spendable,
|
||||
budget_total,
|
||||
spent_total,
|
||||
spent_by_group,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Bütçe görünümü ----------
|
||||
|
||||
pub async fn view(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
) -> AppResult<Vec<GroupBudget>> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
let docs = load(db, household_id, period_id).await?;
|
||||
let (start, end) = dates(&p)?;
|
||||
let period_weeks = weeks(start, end);
|
||||
let spent = spent_by_group_and_date(db, household_id, period_id).await?;
|
||||
|
||||
let mut out = Vec::with_capacity(4);
|
||||
for group in Group::ALL {
|
||||
let monthly = docs
|
||||
.iter()
|
||||
.find(|d| d.group == group && d.scope == "month")
|
||||
.map_or(0, |d| d.amount);
|
||||
// Elle girilmiş haftalar (dönem tarihleri değiştiyse eşleşmeyen eski kayıtlar yok sayılır)
|
||||
let manual: HashMap<String, i64> = docs
|
||||
.iter()
|
||||
.filter(|d| d.group == group && d.scope == "week")
|
||||
.filter_map(|d| d.week_start.clone().map(|w| (w, d.amount)))
|
||||
.collect();
|
||||
|
||||
let manual_total: i64 = period_weeks
|
||||
.iter()
|
||||
.filter_map(|w| manual.get(&format_date(w.start)))
|
||||
.sum();
|
||||
let auto_days: Vec<u64> = period_weeks
|
||||
.iter()
|
||||
.filter(|w| !manual.contains_key(&format_date(w.start)))
|
||||
.map(|w| u64::from(w.days))
|
||||
.collect();
|
||||
let mut auto = split_proportional(monthly - manual_total, &auto_days).into_iter();
|
||||
|
||||
let weeks = period_weeks
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let key = format_date(w.start);
|
||||
let last = format_date(w.last);
|
||||
// Tarihler "YYYY-MM-DD" → metin karşılaştırması tarih sırasıyla aynı
|
||||
let week_spent = spent
|
||||
.iter()
|
||||
.filter(|(g, d, _)| *g == group && *d >= key && *d <= last)
|
||||
.map(|(_, _, a)| a)
|
||||
.sum();
|
||||
let (amount, is_manual) = match manual.get(&key) {
|
||||
Some(&a) => (a, true),
|
||||
None => (auto.next().unwrap_or(0), false),
|
||||
};
|
||||
WeekBudget {
|
||||
index: w.index,
|
||||
start_date: key,
|
||||
last_date: last,
|
||||
days: w.days,
|
||||
amount,
|
||||
is_manual,
|
||||
spent: week_spent,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
out.push(GroupBudget {
|
||||
group,
|
||||
monthly,
|
||||
spent: spent
|
||||
.iter()
|
||||
.filter(|(g, _, _)| *g == group)
|
||||
.map(|(_, _, a)| a)
|
||||
.sum(),
|
||||
weeks,
|
||||
weekly_over: manual_total > monthly,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------- Yazma ----------
|
||||
|
||||
/// 4 grubun aylık tutarını birlikte kaydeder (elle girilmiş haftalar korunur).
|
||||
pub async fn set_monthly(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
amounts: GroupAmounts,
|
||||
) -> AppResult<()> {
|
||||
period::get_open(db, household_id, period_id).await?;
|
||||
for a in amounts {
|
||||
period::validate_amount(a)?;
|
||||
}
|
||||
for (group, amount) in Group::ALL.into_iter().zip(amounts) {
|
||||
upsert(db, household_id, period_id, group, "month", None, amount).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bir haftayı elle ayarlar; `amount = None` → otomatiğe döner.
|
||||
pub async fn set_week(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
group: Group,
|
||||
week_start: &str,
|
||||
amount: Option<i64>,
|
||||
) -> AppResult<()> {
|
||||
let p = period::get_open(db, household_id, period_id).await?;
|
||||
let (start, end) = dates(&p)?;
|
||||
let valid =
|
||||
parse_date(week_start).is_some_and(|d| weeks(start, end).iter().any(|w| w.start == d));
|
||||
if !valid {
|
||||
return Err(AppError::BadRequest("Hafta bu döneme ait değil".into()));
|
||||
}
|
||||
match amount {
|
||||
Some(a) => {
|
||||
period::validate_amount(a)?;
|
||||
upsert(
|
||||
db,
|
||||
household_id,
|
||||
period_id,
|
||||
group,
|
||||
"week",
|
||||
Some(week_start),
|
||||
a,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
f.insert("group", group.as_str());
|
||||
f.insert("scope", "week");
|
||||
f.insert("week_start", week_start);
|
||||
let ts = now();
|
||||
db.collection::<Document>(col::BUDGETS)
|
||||
.update_many(
|
||||
f,
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Öneri ----------
|
||||
|
||||
pub async fn suggest(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
method: SuggestMethod,
|
||||
) -> AppResult<Suggestion> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
match method {
|
||||
SuggestMethod::Default => {
|
||||
let s = summary(db, household_id, period_id).await?;
|
||||
if s.spendable <= 0 {
|
||||
return Ok(unavailable(
|
||||
"Harcanabilir tutar sıfır ya da negatif. Önce gelir, gider ve birikim hedefini girin.",
|
||||
));
|
||||
}
|
||||
let pct = split_percentages(db, household_id).await?;
|
||||
let parts = split_proportional(s.spendable, &pct);
|
||||
Ok(Suggestion {
|
||||
available: true,
|
||||
message: None,
|
||||
amounts: [parts[0], parts[1], parts[2], parts[3]],
|
||||
})
|
||||
}
|
||||
SuggestMethod::Previous | SuggestMethod::Avg3 => {
|
||||
let n = if method == SuggestMethod::Previous {
|
||||
1
|
||||
} else {
|
||||
3
|
||||
};
|
||||
let previous = previous_closed(db, household_id, &p, n).await?;
|
||||
if previous.is_empty() {
|
||||
return Ok(unavailable("Henüz kapanmış bir önceki dönem yok."));
|
||||
}
|
||||
let ids: Vec<Bson> = previous.iter().map(|id| Bson::from(*id)).collect();
|
||||
let sums = sum_by(
|
||||
db,
|
||||
col::ENTRIES,
|
||||
household_id,
|
||||
doc! { "period_id": { "$in": ids } },
|
||||
"$group",
|
||||
)
|
||||
.await?;
|
||||
let total: i64 = sums.values().sum();
|
||||
if total == 0 {
|
||||
return Ok(unavailable("Önceki dönemlerde henüz harcama kaydı yok."));
|
||||
}
|
||||
let count = previous.len() as i64;
|
||||
let avg = |g: Group| {
|
||||
let s = sums.get(g.as_str()).copied().unwrap_or(0);
|
||||
(s + count / 2) / count // yuvarlayarak bölme
|
||||
};
|
||||
Ok(Suggestion {
|
||||
available: true,
|
||||
message: None,
|
||||
amounts: Group::ALL.map(avg),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- yardımcılar ----------
|
||||
|
||||
fn dates(p: &Period) -> AppResult<(time::Date, time::Date)> {
|
||||
let bad = || AppError::Internal("dönem tarihi okunamadı".into());
|
||||
Ok((
|
||||
parse_date(&p.start_date).ok_or_else(bad)?,
|
||||
parse_date(&p.end_date).ok_or_else(bad)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn unavailable(msg: &str) -> Suggestion {
|
||||
Suggestion {
|
||||
available: false,
|
||||
message: Some(msg.into()),
|
||||
amounts: [0; 4],
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Vec<BudgetDoc>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
let mut cursor = db.collection::<BudgetDoc>(col::BUDGETS).find(f).await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
out.push(cursor.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn monthly_amounts(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
) -> AppResult<GroupAmounts> {
|
||||
let docs = load(db, household_id, period_id).await?;
|
||||
Ok(Group::ALL.map(|g| {
|
||||
docs.iter()
|
||||
.find(|d| d.group == g && d.scope == "month")
|
||||
.map_or(0, |d| d.amount)
|
||||
}))
|
||||
}
|
||||
|
||||
async fn upsert(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
group: Group,
|
||||
level: &str,
|
||||
week_start: Option<&str>,
|
||||
amount: i64,
|
||||
) -> AppResult<()> {
|
||||
let coll = db.collection::<Document>(col::BUDGETS);
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
f.insert("group", group.as_str());
|
||||
f.insert("scope", level);
|
||||
f.insert(
|
||||
"week_start",
|
||||
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
|
||||
);
|
||||
|
||||
let res = coll
|
||||
.update_one(
|
||||
f,
|
||||
doc! { "$set": { "amount": amount, "updated_at": now() }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
if res.matched_count == 0 {
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("period_id", period_id);
|
||||
d.insert("group", group.as_str());
|
||||
d.insert("scope", level);
|
||||
d.insert(
|
||||
"week_start",
|
||||
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
|
||||
);
|
||||
d.insert("amount", amount);
|
||||
coll.insert_one(d).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bir koleksiyonda `amount` alanını bir alana göre toplar: `{ "income": 450000, ... }`
|
||||
async fn sum_by(
|
||||
db: &Database,
|
||||
collection: &str,
|
||||
household_id: Uuid,
|
||||
filter: Document,
|
||||
key: &str,
|
||||
) -> AppResult<HashMap<String, i64>> {
|
||||
let mut m = scope(household_id);
|
||||
m.extend(filter);
|
||||
let pipeline = vec![
|
||||
doc! { "$match": m },
|
||||
doc! { "$group": { "_id": key, "total": { "$sum": "$amount" } } },
|
||||
];
|
||||
let mut cursor = db
|
||||
.collection::<Document>(collection)
|
||||
.aggregate(pipeline)
|
||||
.await?;
|
||||
let mut out = HashMap::new();
|
||||
while cursor.advance().await? {
|
||||
let d = cursor.deserialize_current()?;
|
||||
let k = d.get_str("_id").unwrap_or_default().to_string();
|
||||
let v = match d.get("total") {
|
||||
Some(Bson::Int64(v)) => *v,
|
||||
Some(Bson::Int32(v)) => i64::from(*v),
|
||||
_ => 0,
|
||||
};
|
||||
out.insert(k, v);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Dönemin harcamaları (grup, gün) bazında toplanmış: en fazla 4 × 62 satır.
|
||||
pub(crate) async fn spent_by_group_and_date(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
) -> AppResult<Vec<(Group, String, i64)>> {
|
||||
#[derive(Deserialize)]
|
||||
struct Key {
|
||||
group: Group,
|
||||
date: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Row {
|
||||
#[serde(rename = "_id")]
|
||||
key: Key,
|
||||
total: i64,
|
||||
}
|
||||
let mut m = scope(household_id);
|
||||
m.insert("period_id", period_id);
|
||||
let pipeline = vec![
|
||||
doc! { "$match": m },
|
||||
doc! { "$group": {
|
||||
"_id": { "group": "$group", "date": "$date" },
|
||||
"total": { "$sum": "$amount" },
|
||||
} },
|
||||
];
|
||||
let mut cursor = db
|
||||
.collection::<Document>(col::ENTRIES)
|
||||
.aggregate(pipeline)
|
||||
.with_type::<Row>()
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
let r = cursor.deserialize_current()?;
|
||||
out.push((r.key.group, r.key.date, r.total));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Hane ayarındaki varsayılan yüzdeler (yoksa 50/30/10/10).
|
||||
async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult<Vec<u64>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", household_id);
|
||||
let hh = db
|
||||
.collection::<Document>(col::HOUSEHOLDS)
|
||||
.find_one(f)
|
||||
.await?;
|
||||
let pct = hh
|
||||
.as_ref()
|
||||
.and_then(|h| h.get_document("settings").ok())
|
||||
.and_then(|s| s.get_document("budget_split_pct").ok());
|
||||
let default = [50u64, 30, 10, 10];
|
||||
Ok(Group::ALL
|
||||
.iter()
|
||||
.zip(default)
|
||||
.map(|(g, d)| {
|
||||
pct.and_then(|p| match p.get(g.as_str()) {
|
||||
Some(Bson::Int32(v)) => Some(*v as u64),
|
||||
Some(Bson::Int64(v)) => Some(*v as u64),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(d)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Bu dönemden önceki en yeni `n` kapalı dönemin kimlikleri.
|
||||
async fn previous_closed(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
p: &Period,
|
||||
n: i64,
|
||||
) -> AppResult<Vec<Uuid>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("start_date", doc! { "$lt": &p.start_date });
|
||||
f.insert("status", PeriodStatus::Closed.as_str());
|
||||
let mut cursor = db
|
||||
.collection::<Period>(col::PERIODS)
|
||||
.find(f)
|
||||
.sort(doc! { "start_date": -1 })
|
||||
.limit(n)
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
out.push(cursor.deserialize_current()?.id);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,18 @@ fn coll(db: &Database, c: Catalog) -> mongodb::Collection<Item> {
|
||||
db.collection(c.collection())
|
||||
}
|
||||
|
||||
/// Hanenin tek kaydı (pasifler dahil). Başka hanenin ya da olmayan kayıt → `None`.
|
||||
pub async fn find(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
c: Catalog,
|
||||
id: Uuid,
|
||||
) -> AppResult<Option<Item>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
Ok(coll(db, c).find_one(f).await?)
|
||||
}
|
||||
|
||||
/// Hanenin kayıtları. Kategoriler grup sırasına, sonra `sort_order`'a göre sıralanır.
|
||||
pub async fn list(
|
||||
db: &Database,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Dönem kapatma ve yeniden açma (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.10, §6).
|
||||
//!
|
||||
//! Kapatma (tek transaction):
|
||||
//! 1. Dönem `closed` olur (`closed_at`, `closed_by`, `to_savings`).
|
||||
//! 2. Bu dönemden önceki devirler (yeniden kapatmada) soft delete edilir.
|
||||
//! 3. Gerçekleşen birikim (değerlendirmenin 4. sorusu) dağıtılır:
|
||||
//! - < 0 → açık **borç** olarak devreder (`debt`).
|
||||
//! - ≥ 0 → `to_savings` kadarı birikime (varsayılan: tamamı), kalanı **artı** olarak devreder (`surplus`).
|
||||
//! 4. Devir sonraki döneme bağlanır; sonraki dönem henüz yoksa `to_period_id: null` kalır
|
||||
//! ve o dönem açılınca bağlanır (`period::create`).
|
||||
//!
|
||||
//! Yeniden açma (yalnızca sahip): dönem `correcting` olur, yeni açık dönem varsa o da açık kalır.
|
||||
//! Mevcut devir, "yeniden kapat" denene kadar yerinde kalır (sonraki dönemin rakamları oynamaz).
|
||||
//! Ardından kapanmış dönem olan bir dönem açılamaz: sonraki devirler zincirleme bozulurdu.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
period::{Period, PeriodStatus},
|
||||
},
|
||||
services::{asset, period, review},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CarryKind {
|
||||
Debt,
|
||||
Surplus,
|
||||
}
|
||||
|
||||
impl CarryKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
CarryKind::Debt => "debt",
|
||||
CarryKind::Surplus => "surplus",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Carry {
|
||||
pub kind: CarryKind,
|
||||
pub amount: i64,
|
||||
pub to_period_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub struct Closing {
|
||||
pub period: Period,
|
||||
pub realized_savings: i64,
|
||||
pub to_savings: i64,
|
||||
pub carryover: Option<Carry>,
|
||||
/// Birikime eklenmiş kayıt var (yeniden kapatmada güncellendi)
|
||||
pub savings_linked: bool,
|
||||
}
|
||||
|
||||
/// Gerçekleşen birikimi birikim / devir olarak böler. `to_savings` verilmezse tamamı birikime.
|
||||
pub fn settle(
|
||||
realized: i64,
|
||||
to_savings: Option<i64>,
|
||||
) -> AppResult<(i64, Option<(CarryKind, i64)>)> {
|
||||
if realized < 0 {
|
||||
if to_savings.unwrap_or(0) != 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Bu dönemde birikim yok; açık, borç olarak sonraki döneme devreder".into(),
|
||||
));
|
||||
}
|
||||
return Ok((0, Some((CarryKind::Debt, -realized))));
|
||||
}
|
||||
let t = to_savings.unwrap_or(realized);
|
||||
if !(0..=realized).contains(&t) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Birikime aktarılacak tutar 0 ile gerçekleşen birikim arasında olmalı".into(),
|
||||
));
|
||||
}
|
||||
let surplus = realized - t;
|
||||
Ok((t, (surplus > 0).then_some((CarryKind::Surplus, surplus))))
|
||||
}
|
||||
|
||||
pub async fn close(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
period_id: Uuid,
|
||||
to_savings: Option<i64>,
|
||||
version: Option<i32>,
|
||||
) -> AppResult<Closing> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
if p.status == PeriodStatus::Closed {
|
||||
return Err(AppError::Conflict("Bu dönem zaten kapalı".into()));
|
||||
}
|
||||
if version.is_some_and(|v| v != p.version) {
|
||||
return Err(period::stale());
|
||||
}
|
||||
let realized = review::review(db, household_id, period_id)
|
||||
.await?
|
||||
.realized_savings;
|
||||
let (to_savings, carry) = settle(realized, to_savings)?;
|
||||
let next = next_period(db, household_id, &p).await?;
|
||||
// Yeniden kapatma: birikime eklenmiş kayıt varsa yeni tutara uyarlanır (önce denetlenir)
|
||||
let savings_sync =
|
||||
asset::plan_savings_sync(db, household_id, period_id, to_savings, &p.end_date).await?;
|
||||
let ts = now();
|
||||
|
||||
let mut session = db.client().start_session().await?;
|
||||
session.start_transaction().await?;
|
||||
let result = async {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", period_id);
|
||||
f.insert("version", p.version);
|
||||
let r = db
|
||||
.collection::<Document>(col::PERIODS)
|
||||
.update_one(
|
||||
f,
|
||||
doc! {
|
||||
"$set": {
|
||||
"status": PeriodStatus::Closed.as_str(),
|
||||
"closed_at": ts,
|
||||
"closed_by": user_id,
|
||||
"to_savings": to_savings,
|
||||
"updated_at": ts,
|
||||
},
|
||||
"$inc": { "version": 1 },
|
||||
},
|
||||
)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
if r.matched_count == 0 {
|
||||
return Err(period::stale());
|
||||
}
|
||||
|
||||
let mut old = scope(household_id);
|
||||
old.insert("from_period_id", period_id);
|
||||
db.collection::<Document>(col::CARRYOVERS)
|
||||
.update_many(
|
||||
old,
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
|
||||
if let Some(sync) = &savings_sync {
|
||||
asset::apply_savings_sync(db, &mut session, household_id, sync).await?;
|
||||
}
|
||||
|
||||
if let Some((kind, amount)) = carry {
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("from_period_id", period_id);
|
||||
d.insert(
|
||||
"to_period_id",
|
||||
next.as_ref().map_or(Bson::Null, |n| n.id.into()),
|
||||
);
|
||||
d.insert("kind", kind.as_str());
|
||||
d.insert("amount", amount);
|
||||
db.collection::<Document>(col::CARRYOVERS)
|
||||
.insert_one(d)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => session.commit_transaction().await?,
|
||||
Err(e) => {
|
||||
let _ = session.abort_transaction().await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Closing {
|
||||
savings_linked: matches!(savings_sync, Some(asset::SavingsSync::Update { .. })),
|
||||
period: period::get(db, household_id, period_id).await?,
|
||||
realized_savings: realized,
|
||||
to_savings,
|
||||
carryover: carry.map(|(kind, amount)| Carry {
|
||||
kind,
|
||||
amount,
|
||||
to_period_id: next.map(|n| n.id),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Kapalı dönemi düzeltme için açar (yalnızca sahip — rota denetler).
|
||||
pub async fn reopen(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
period_id: Uuid,
|
||||
) -> AppResult<Period> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
if p.status != PeriodStatus::Closed {
|
||||
return Err(AppError::Conflict(
|
||||
"Yalnızca kapalı bir dönem yeniden açılabilir".into(),
|
||||
));
|
||||
}
|
||||
let mut later = scope(household_id);
|
||||
later.insert("start_date", doc! { "$gte": &p.end_date });
|
||||
later.insert("status", doc! { "$ne": PeriodStatus::Open.as_str() });
|
||||
if db
|
||||
.collection::<Document>(col::PERIODS)
|
||||
.find_one(later)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::Conflict(
|
||||
"Bu dönemden sonra kapanmış bir dönem var. Yalnızca en son kapanan dönem yeniden açılabilir."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
let ts = now();
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", period_id);
|
||||
f.insert("version", p.version);
|
||||
let r = db
|
||||
.collection::<Document>(col::PERIODS)
|
||||
.update_one(
|
||||
f,
|
||||
doc! {
|
||||
"$set": {
|
||||
"status": PeriodStatus::Correcting.as_str(),
|
||||
"reopened_at": ts,
|
||||
"reopened_by": user_id,
|
||||
"updated_at": ts,
|
||||
},
|
||||
"$inc": { "version": 1 },
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if r.matched_count == 0 {
|
||||
return Err(period::stale());
|
||||
}
|
||||
period::get(db, household_id, period_id).await
|
||||
}
|
||||
|
||||
/// Bu dönemden hemen sonra gelen dönem (varsa)
|
||||
async fn next_period(db: &Database, household_id: Uuid, p: &Period) -> AppResult<Option<Period>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("start_date", doc! { "$gte": &p.end_date });
|
||||
Ok(db
|
||||
.collection::<Period>(col::PERIODS)
|
||||
.find_one(f)
|
||||
.sort(doc! { "start_date": 1 })
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settle_rules() {
|
||||
// Artı: varsayılan tamamı birikime
|
||||
assert_eq!(settle(700, None).unwrap(), (700, None));
|
||||
assert_eq!(
|
||||
settle(700, Some(500)).unwrap(),
|
||||
(500, Some((CarryKind::Surplus, 200)))
|
||||
);
|
||||
assert_eq!(
|
||||
settle(700, Some(0)).unwrap(),
|
||||
(0, Some((CarryKind::Surplus, 700)))
|
||||
);
|
||||
assert!(settle(700, Some(701)).is_err());
|
||||
assert!(settle(700, Some(-1)).is_err());
|
||||
// Sıfır: devir yok
|
||||
assert_eq!(settle(0, None).unwrap(), (0, None));
|
||||
// Açık: borç; birikim istenemez
|
||||
assert_eq!(settle(-50, None).unwrap(), (0, Some((CarryKind::Debt, 50))));
|
||||
assert_eq!(
|
||||
settle(-50, Some(0)).unwrap(),
|
||||
(0, Some((CarryKind::Debt, 50)))
|
||||
);
|
||||
assert!(settle(-50, Some(10)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Günlük defter: harcama kayıtları (KAKEIBO_PROMPT §8.4).
|
||||
//!
|
||||
//! Kurallar:
|
||||
//! - Tutar kuruş, > 0. Tarih kaydın döneminin içinde olmalı.
|
||||
//! - Grup **kategoriden** alınır (istemci grup göndermez) → kategori ile grup hiç çelişmez.
|
||||
//! - Kategori ve ödeme türü bu haneye ait olmalı; yeni seçimde **aktif** olmalı
|
||||
//! (pasife alınmış kategoride kalan eski kayıt düzenlenebilir).
|
||||
//! - Yalnızca açık dönemde yazma. Silme soft delete; "Geri al" için `restore`.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
options::ReturnDocument,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
entry::Entry,
|
||||
group::Group,
|
||||
period::Period,
|
||||
},
|
||||
services::{calendar::parse_date, catalog, period},
|
||||
};
|
||||
|
||||
/// Açıklama üst sınırı (karakter)
|
||||
pub const MAX_DESCRIPTION: usize = 200;
|
||||
|
||||
pub struct NewEntry {
|
||||
pub date: String,
|
||||
pub amount: i64,
|
||||
pub category_id: Uuid,
|
||||
pub payment_method_id: Option<Uuid>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EntryChanges {
|
||||
pub date: Option<String>,
|
||||
pub amount: Option<i64>,
|
||||
pub category_id: Option<Uuid>,
|
||||
/// `Some(None)` → ödeme türünü kaldır (istekte boş metin)
|
||||
pub payment_method_id: Option<Option<Uuid>>,
|
||||
/// `Some("")` → açıklamayı kaldır
|
||||
pub description: Option<String>,
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
/// Liste filtresi: tarih aralığı (ikisi de dahil, isteğe bağlı)
|
||||
#[derive(Default)]
|
||||
pub struct EntryFilter {
|
||||
pub from: Option<String>,
|
||||
pub to: Option<String>,
|
||||
}
|
||||
|
||||
fn coll(db: &Database) -> mongodb::Collection<Entry> {
|
||||
db.collection(col::ENTRIES)
|
||||
}
|
||||
|
||||
/// Dönemin kayıtları: en yeni tarih başta, aynı gün içinde en son eklenen başta.
|
||||
pub async fn list(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
filter: EntryFilter,
|
||||
) -> AppResult<Vec<Entry>> {
|
||||
period::get(db, household_id, period_id).await?; // dönem bu haneye ait mi?
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
let mut range = Document::new();
|
||||
if let Some(from) = filter.from {
|
||||
range.insert("$gte", valid_date(&from)?);
|
||||
}
|
||||
if let Some(to) = filter.to {
|
||||
range.insert("$lte", valid_date(&to)?);
|
||||
}
|
||||
if !range.is_empty() {
|
||||
f.insert("date", range);
|
||||
}
|
||||
// UUID v7 zamana göre sıralı → `_id` azalan = en son eklenen önce
|
||||
let mut cursor = coll(db)
|
||||
.find(f)
|
||||
.sort(doc! { "date": -1, "_id": -1 })
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
out.push(cursor.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
period_id: Uuid,
|
||||
e: NewEntry,
|
||||
) -> AppResult<Entry> {
|
||||
let p = period::get_open(db, household_id, period_id).await?;
|
||||
let date = clean_date(&e.date, &p)?;
|
||||
let amount = clean_amount(e.amount)?;
|
||||
let group = category_group(db, household_id, e.category_id, None).await?;
|
||||
if let Some(pm) = e.payment_method_id {
|
||||
check_payment_method(db, household_id, pm, None).await?;
|
||||
}
|
||||
let description = clean_description(e.description.as_deref())?;
|
||||
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("period_id", period_id);
|
||||
d.insert("date", date);
|
||||
d.insert("amount", amount);
|
||||
d.insert("group", group.as_str());
|
||||
d.insert("category_id", e.category_id);
|
||||
d.insert(
|
||||
"payment_method_id",
|
||||
e.payment_method_id.map_or(Bson::Null, Bson::from),
|
||||
);
|
||||
d.insert("description", description.map_or(Bson::Null, Bson::String));
|
||||
d.insert("created_by", user_id);
|
||||
db.collection::<Document>(col::ENTRIES)
|
||||
.insert_one(d)
|
||||
.await?;
|
||||
coll(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("eklenen kayıt okunamadı".into()))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
ch: EntryChanges,
|
||||
) -> AppResult<Entry> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
let p = period::get_open(db, household_id, current.period_id).await?;
|
||||
if ch.version.is_some_and(|v| v != current.version) {
|
||||
return Err(stale());
|
||||
}
|
||||
|
||||
let mut set = doc! { "updated_at": now() };
|
||||
if let Some(date) = ch.date {
|
||||
set.insert("date", clean_date(&date, &p)?);
|
||||
}
|
||||
if let Some(amount) = ch.amount {
|
||||
set.insert("amount", clean_amount(amount)?);
|
||||
}
|
||||
if let Some(cid) = ch.category_id {
|
||||
let group = category_group(db, household_id, cid, Some(current.category_id)).await?;
|
||||
set.insert("category_id", cid);
|
||||
set.insert("group", group.as_str());
|
||||
}
|
||||
if let Some(pm) = ch.payment_method_id {
|
||||
if let Some(pm) = pm {
|
||||
check_payment_method(db, household_id, pm, current.payment_method_id).await?;
|
||||
}
|
||||
set.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
|
||||
}
|
||||
if let Some(desc) = ch.description {
|
||||
set.insert(
|
||||
"description",
|
||||
clean_description(Some(&desc))?.map_or(Bson::Null, Bson::String),
|
||||
);
|
||||
}
|
||||
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
f.insert("version", current.version);
|
||||
coll(db)
|
||||
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?
|
||||
.ok_or_else(stale)
|
||||
}
|
||||
|
||||
/// Soft delete. Silinen kayıt `restore` ile geri getirilebilir ("Geri al").
|
||||
pub async fn delete(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
period::get_open(db, household_id, current.period_id).await?;
|
||||
let ts = now();
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
coll(db)
|
||||
.update_one(
|
||||
f,
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Silinmiş kaydı geri getirir. Silinmemiş ya da başka hanenin kaydı → 404.
|
||||
pub async fn restore(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Entry> {
|
||||
let f = doc! { "_id": id, "household_id": household_id, "deleted_at": { "$ne": null } };
|
||||
let deleted = coll(db)
|
||||
.find_one(f.clone())
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
period::get_open(db, household_id, deleted.period_id).await?;
|
||||
coll(db)
|
||||
.find_one_and_update(
|
||||
f,
|
||||
doc! { "$set": { "deleted_at": null, "updated_at": now() }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
// ---------- yardımcılar ----------
|
||||
|
||||
async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Entry> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Kategori bu haneye ait mi, aktif mi (ya da kaydın zaten kullandığı kategori mi)? → grubu
|
||||
async fn category_group(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
category_id: Uuid,
|
||||
current: Option<Uuid>,
|
||||
) -> AppResult<Group> {
|
||||
let c = catalog::find(db, household_id, catalog::Catalog::Categories, category_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("Kategori bulunamadı".into()))?;
|
||||
if !c.is_active && current != Some(category_id) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Bu kategori pasif; başka bir kategori seçin".into(),
|
||||
));
|
||||
}
|
||||
c.group
|
||||
.ok_or_else(|| AppError::Internal("kategorinin grubu yok".into()))
|
||||
}
|
||||
|
||||
async fn check_payment_method(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
current: Option<Uuid>,
|
||||
) -> AppResult<()> {
|
||||
let m = catalog::find(db, household_id, catalog::Catalog::PaymentMethods, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("Ödeme türü bulunamadı".into()))?;
|
||||
if !m.is_active && current != Some(id) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Bu ödeme türü pasif; başka birini seçin".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_date(s: &str) -> AppResult<String> {
|
||||
let s = s.trim();
|
||||
parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
fn clean_date(s: &str, p: &Period) -> AppResult<String> {
|
||||
let s = valid_date(s)?;
|
||||
if s < p.start_date || s >= p.end_date {
|
||||
return Err(AppError::BadRequest("Tarih dönem içinde olmalı".into()));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn clean_amount(v: i64) -> AppResult<i64> {
|
||||
if v <= 0 {
|
||||
return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into()));
|
||||
}
|
||||
period::validate_amount(v)
|
||||
}
|
||||
|
||||
fn clean_description(d: Option<&str>) -> AppResult<Option<String>> {
|
||||
match d.map(str::trim) {
|
||||
None | Some("") => Ok(None),
|
||||
Some(s) if s.chars().count() > MAX_DESCRIPTION => Err(AppError::BadRequest(format!(
|
||||
"Açıklama en fazla {MAX_DESCRIPTION} karakter olabilir"
|
||||
))),
|
||||
Some(s) => Ok(Some(s.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn stale() -> AppError {
|
||||
AppError::Conflict("Kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
||||
}
|
||||
@@ -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,6 +1,21 @@
|
||||
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
|
||||
|
||||
pub mod account;
|
||||
pub mod asset;
|
||||
pub mod budget;
|
||||
pub mod calendar;
|
||||
pub mod catalog;
|
||||
pub mod closing;
|
||||
pub mod entry;
|
||||
pub mod holding;
|
||||
pub mod invite;
|
||||
pub mod money;
|
||||
pub mod note;
|
||||
pub mod period;
|
||||
pub mod plan;
|
||||
pub mod rate_sources;
|
||||
pub mod rates;
|
||||
pub mod reports;
|
||||
pub mod review;
|
||||
pub mod seed;
|
||||
pub mod stats;
|
||||
|
||||
@@ -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,357 @@
|
||||
//! Dönem iş mantığı (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.4).
|
||||
//!
|
||||
//! Kurallar:
|
||||
//! - Hanede aynı anda en fazla **bir açık** dönem (servis kontrolü + kısmi benzersiz indeks).
|
||||
//! - Dönemler çakışmaz. Bitiş tarihi hariçtir.
|
||||
//! - Kapalı dönem salt okunur (yeniden açma: Faz 6, yalnızca sahip).
|
||||
//! - Tarih aralığı daraltılırken dışarıda harcama kalıyorsa engellenir.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
options::ReturnDocument,
|
||||
};
|
||||
use time::Date;
|
||||
|
||||
use crate::{
|
||||
db::{col, is_duplicate_key},
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
period::{Period, PeriodStatus},
|
||||
},
|
||||
services::calendar::{days_between, default_period_name, format_date, parse_date},
|
||||
};
|
||||
|
||||
/// En uzun dönem (gün). Aylık defter için ~2 ay pay bırakır.
|
||||
pub const MAX_DAYS: i64 = 62;
|
||||
/// Birikim hedefi üst sınırı (kuruş) — yazım hatalarını yakalamak için: 100 milyar ₺
|
||||
pub const MAX_AMOUNT: i64 = 10_000_000_000_000;
|
||||
|
||||
pub struct NewPeriod {
|
||||
pub start_date: String,
|
||||
pub end_date: String,
|
||||
pub name: Option<String>,
|
||||
pub savings_goal: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PeriodChanges {
|
||||
pub name: Option<String>,
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
pub savings_goal: Option<i64>,
|
||||
/// `Some("")` → metni kaldır
|
||||
pub reflection_md: Option<String>,
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
/// "Nasıl daha iyi yapabilirim?" üst sınırı (karakter) — notlarla aynı
|
||||
pub const MAX_REFLECTION: usize = 20_000;
|
||||
|
||||
fn coll(db: &Database) -> mongodb::Collection<Period> {
|
||||
db.collection(col::PERIODS)
|
||||
}
|
||||
|
||||
pub async fn current(db: &Database, household_id: Uuid) -> AppResult<Option<Period>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("status", PeriodStatus::Open.as_str());
|
||||
Ok(coll(db).find_one(f).await?)
|
||||
}
|
||||
|
||||
/// En yeni dönem başta.
|
||||
pub async fn list(db: &Database, household_id: Uuid) -> AppResult<Vec<Period>> {
|
||||
let mut cursor = coll(db)
|
||||
.find(scope(household_id))
|
||||
.sort(doc! { "start_date": -1 })
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
out.push(cursor.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Yazma işlemleri için: dönem bu haneye ait ve **açık** olmalı.
|
||||
/// Plan kalemleri, notlar ve (Faz 5) harcamalar bunu kullanır.
|
||||
pub async fn get_open(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
|
||||
let p = get(db, household_id, id).await?;
|
||||
if !p.status.is_writable() {
|
||||
return Err(closed());
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
pub fn closed() -> AppError {
|
||||
AppError::Conflict(
|
||||
"Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResult<Period> {
|
||||
let (start, end) = validate_range(&p.start_date, &p.end_date)?;
|
||||
let savings_goal = validate_amount(p.savings_goal.unwrap_or(0))?;
|
||||
let name = clean_name(p.name.as_deref(), start, end)?;
|
||||
|
||||
if let Some(open) = current(db, household_id).await? {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Zaten açık bir dönem var ({}). Yeni dönem açmadan önce onu kapatın.",
|
||||
open.name
|
||||
)));
|
||||
}
|
||||
ensure_no_overlap(db, household_id, start, end, None).await?;
|
||||
|
||||
// Bir önceki dönem (devir ve "önceki aydan kopyala" için)
|
||||
let mut prev_f = scope(household_id);
|
||||
prev_f.insert("start_date", doc! { "$lt": format_date(start) });
|
||||
let previous = coll(db)
|
||||
.find_one(prev_f)
|
||||
.sort(doc! { "start_date": -1 })
|
||||
.await?;
|
||||
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("name", name);
|
||||
d.insert("start_date", format_date(start));
|
||||
d.insert("end_date", format_date(end));
|
||||
d.insert("status", PeriodStatus::Open.as_str());
|
||||
d.insert("savings_goal", savings_goal);
|
||||
d.insert("reflection_md", Bson::Null);
|
||||
d.insert("to_savings", Bson::Null);
|
||||
d.insert(
|
||||
"previous_period_id",
|
||||
previous.as_ref().map_or(Bson::Null, |p| p.id.into()),
|
||||
);
|
||||
d.insert("closed_at", Bson::Null);
|
||||
d.insert("closed_by", Bson::Null);
|
||||
d.insert("reopened_at", Bson::Null);
|
||||
|
||||
// Tek transaction: dönem + önceki dönemden bekleyen devrin bu döneme bağlanması
|
||||
let mut session = db.client().start_session().await?;
|
||||
session.start_transaction().await?;
|
||||
let result = async {
|
||||
db.collection::<Document>(col::PERIODS)
|
||||
.insert_one(d)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
if let Some(prev) = &previous {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("from_period_id", prev.id);
|
||||
f.insert("to_period_id", Bson::Null);
|
||||
db.collection::<Document>(col::CARRYOVERS)
|
||||
.update_many(
|
||||
f,
|
||||
doc! { "$set": { "to_period_id": id.clone(), "updated_at": now() }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
}
|
||||
Ok::<_, mongodb::error::Error>(())
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => session.commit_transaction().await?,
|
||||
Err(e) => {
|
||||
let _ = session.abort_transaction().await;
|
||||
// Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller
|
||||
if is_duplicate_key(&e) {
|
||||
return Err(AppError::Conflict(
|
||||
"Zaten açık bir dönem var. Sayfayı yenileyin.".into(),
|
||||
));
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
coll(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("açılan dönem okunamadı".into()))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
ch: PeriodChanges,
|
||||
) -> AppResult<Period> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
if !current.status.is_writable() {
|
||||
return Err(closed());
|
||||
}
|
||||
if ch.version.is_some_and(|v| v != current.version) {
|
||||
return Err(stale());
|
||||
}
|
||||
|
||||
let mut set = doc! { "updated_at": now() };
|
||||
|
||||
let start_s = ch.start_date.as_deref().unwrap_or(¤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).
|
||||
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,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,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,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;
|
||||
}
|
||||
@@ -11,7 +11,11 @@ use axum::{
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use kakeibo::{config::Config, db, routes, state::AppState};
|
||||
use kakeibo::{
|
||||
config::{Config, RegistrationMode},
|
||||
db, routes,
|
||||
state::AppState,
|
||||
};
|
||||
use mongodb::Database;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
@@ -29,7 +33,12 @@ pub struct Session {
|
||||
}
|
||||
|
||||
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)");
|
||||
@@ -46,6 +55,9 @@ impl TestApp {
|
||||
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 }
|
||||
|
||||
@@ -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,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,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
|
||||
+16
-8
@@ -81,16 +81,17 @@ 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",
|
||||
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ı).
|
||||
|
||||
### 3.5 `categories` — Alt kategoriler
|
||||
@@ -143,7 +144,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 +160,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 +175,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 +188,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 +231,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.
|
||||
+87
-3
@@ -1,9 +1,18 @@
|
||||
# 🧠 Proje Hafızası
|
||||
|
||||
## ▶️ Sıradaki Adım
|
||||
**Faz 2 – Adım 3:** `lib/api.ts` (fetch sarmalayıcı, `{error:{code,message}}` → `ApiError`), TanStack Query kurulumu, ts-rs ile Rust DTO → `frontend/src/types/` tip üretimi, oturum durumu (`useMe` / auth context).
|
||||
**Faz 9 – Adım 3:** Uygulamanın kendi CSV'si (harcamalar, plan kalemleri, varlık işlemleri): dışa aktarma (backend ya da frontend; `lib/csv.ts` biçimi: BOM, `;`, `1250,50`, GG.AA.YYYY) + içe aktarma (önizleme → satır satır hata → geçerliler aktarılır; kategori/ödeme türü **adla** eşleşir; dönem tarihten bulunur, yazılamaz dönem → satır hatası). Adım 4 (ekstre) bunun üstüne kurulacak: ortak satır doğrulama/aktarma servisi tasarla.
|
||||
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
|
||||
|
||||
## 📌 Alınan Kararlar
|
||||
- **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.
|
||||
@@ -23,11 +32,33 @@
|
||||
- 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).
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -51,3 +82,56 @@
|
||||
### 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.
|
||||
|
||||
+60
-12
@@ -6,15 +6,15 @@
|
||||
|---|---|---|---|
|
||||
| **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 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ı
|
||||
@@ -34,6 +34,54 @@
|
||||
## 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
|
||||
- ✅ 3. `lib/api.ts`, TanStack Query, ts-rs tip üretimi, oturum durumu
|
||||
- ✅ 4. Router (react-router), korumalı sayfalar, layout: mobil alt menü / masaüstü sol menü
|
||||
- ✅ 5. Giriş / kayıt ekranları (react-hook-form + zod, beni hatırla, davet kodu), arayüz metinleri tek dosyada
|
||||
|
||||
## Faz 3 Adımları
|
||||
- ✅ 1. Dockerfile (çok aşamalı), Axum arayüzü sunar (SPA yönlendirmesi, önbellek başlıkları, sıkıştırma), graceful shutdown, yerelde imaj testi
|
||||
- ✅ 2. Canlı MongoDB kontrolü (§12.3): kurulum şekli, replica set, ayrı kullanıcı, dış erişim
|
||||
- ✅ 3. Dokploy: Gitea bağlantısı, ortam değişkenleri, alan adı + HTTPS, ilk yayın
|
||||
- ✅ 4. Canlıda ilk hesap + kontrol listesi
|
||||
- ⏳ 5. Yedekleme (mongodump, 14 gün) + geri yükleme denemesi — **ertelendi, Faz 5 bitmeden yapılacak**
|
||||
|
||||
## Faz 4 Adımları
|
||||
- ✅ 1. Backend: dönem (aç, açık dönem, liste, detay + haftalar, düzenle), takvim hesapları, testler
|
||||
- ✅ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar
|
||||
- ✅ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
|
||||
- ✅ 4. Frontend: ilk dönem sihirbazı
|
||||
- ✅ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı)
|
||||
- ✅ 6. Frontend: notlar (Markdown, düzenle/önizle)
|
||||
|
||||
## Faz 5 Adımları (taslak)
|
||||
- ✅ 1. Backend: harcama kayıtları (ekle/düzenle/sil + geri al, hafta/dönem filtresi, kategori–grup tutarlılığı, yalnızca açık dönem), testler
|
||||
- ✅ 2. Backend: haftalık/aylık gerçekleşen (grup bazlı bütçe–harcanan–kalan), özet ve önerilere gerçekleşen verisi
|
||||
- ✅ 3. Frontend: Defter — hafta seçici + hızlı giriş formu (tutar, 4 grup düğmesi, kategori, ödeme türü, tarih, açıklama; son seçim hatırlanır)
|
||||
- ✅ 4. Frontend: haftanın girişleri (gün gün), düzenle/sil + "Geri al", haftalık özet
|
||||
- 🔄 5. Canlı yedekleme (betikler + test ✅, sunucuda kurulum kullanıcıda) (mongodump günlük, 14 gün) + geri yükleme denemesi
|
||||
|
||||
## Faz 6 Adımları (taslak)
|
||||
- ✅ 1. Backend: değerlendirme (`GET /periods/{id}/review`: 4 soru, grup/kategori/hafta) + `reflection_md`
|
||||
- ✅ 2. Backend: dönem kapatma (transaction: durum, devir, birikime aktarılan tutar) + yeni dönemde devrin bağlanması + yeniden açma (sahip)
|
||||
- ✅ 3. Frontend: Değerlendirme sayfası (4 soru, planlanan/gerçekleşen, en çok harcanan kategoriler, haftalık dağılım, "Nasıl daha iyi yapabilirim?", notlar)
|
||||
- ✅ 4. Frontend: dönem kapatma sihirbazı (+ yeni dönemi kopyalayarak açma)
|
||||
- ✅ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip) + düzeltmedeki dönemi Defter/Plan/Değerlendirme'de seçme
|
||||
|
||||
## Faz 7 Adımları
|
||||
- ✅ 1. Backend: `GET /periods/{id}/daily` (gün × grup harcama; günlük/haftalık grafik verisi) + `GET /stats/periods` (son 12 dönem: harcanabilir, harcanan, birikime aktarılan; aylık grafik ve toplam birikim)
|
||||
- ✅ 2. Frontend: özet kartları (gelir, sabit gider, toplam birikim, devreden borç, grup planlanan/gerçekleşen haftalık–aylık, **anlık durum** en belirgin) + "➕ Harcama Ekle" (mobilde yüzen düğme) + skeleton ve boş durumlar
|
||||
- ✅ 3. Frontend: grafikler (Recharts) — kümülatif harcanabilir vs. gerçekleşen; 4 grup + harcanabilir; Günlük / Haftalık / Aylık sekmeleri, koyu tema, erişilebilir özet
|
||||
|
||||
## Faz 8 Adımları
|
||||
- ✅ 1. Backend: varlıklar (hane başına varsayılanlar: TL, gram/çeyrek/yarım/tam altın, ons, USD, EUR; döviz eklenebilir) + alım/satım kayıtları (satış ≤ eldeki miktar) + varlık başına miktar, **ağırlıklı ortalama maliyet**, gerçekleşen kâr/zarar; testler
|
||||
- ✅ 2. Backend: kurlar — TCMB `today.xml` (döviz) + xaus.com ons fiyatı × TCMB USD (altın; çeyrek/yarım/tam has gram katsayısıyla), saatlik arka plan görevi + önbellek (`exchange_rates`), kaynak erişilemezse son kur + zamanı; **manuel kur** (hane); portföy değerleme uç noktası (güncel değer, kâr/zarar tutar ve %, toplamlar, dağılım); kaynak ayrıştırıcıları örnek dosyalarla testli
|
||||
- ✅ 3. Frontend: Birikim sayfası — portföy özeti + halka grafik, varlık listesi (miktar, ortalama maliyet, güncel kur, değer, kâr/zarar), alım/satım formu, işlem geçmişi (düzenle/sil), manuel kur, eski kur uyarısı
|
||||
- ✅ 4. Kapanış entegrasyonu: birikime aktarılan tutar onayla TL varlığına eklenir (`source_period_id`, tekrar eklenmez); geçmiş kapanışlar için "bekleyen birikimler" listesi; Panel "Toplam birikim" → portföyün güncel değeri
|
||||
|
||||
## Faz 9 Adımları
|
||||
- ✅ 1. Backend: rapor verileri — dönem istatistiklerine gelir/sabit gider/devir/gerçekleşen birikim (birikim oranı için) + `GET /reports/breakdown` (grup / **tüm kategoriler** / ödeme türü / ay; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü). Bütçe sapması mevcut `budget` verisinden
|
||||
- ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
|
||||
- ⏳ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
|
||||
- ⏳ 4. **Banka / kredi kartı ekstresi içe aktarma** (kullanıcı isteği): CSV/Excel, sütun eşleştirme (banka profili kaydedilir), harcama/gelir yönü, tekrar eden satır tespiti, açıklamadan kategori önerisi (öğrenen kural), grup/kategori eşleştirmesi kullanıcıda; eksik aylar için **geçmiş (arşiv) dönemler** — amaç yıl başından beri veri → sonraki aylara referans (ortalama önerileri)
|
||||
- ⏳ 5. JSON tam hane yedeği: sürümlü dışa aktarma; geri yüklemede önizleme + onay + sunucuda otomatik geri dönüş noktası ("geri yüklemeyi geri al"); başka haneden taşıma
|
||||
- ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Generated
+2103
-4
File diff suppressed because it is too large
Load Diff
+14
-2
@@ -9,20 +9,31 @@
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@hookform/resolvers": "^7.0.5",
|
||||
"@tailwindcss/vite": "^4.3.5",
|
||||
"@tanstack/react-query": "^5.107.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.5.2",
|
||||
"lucide-react": "^1.72.0",
|
||||
"radix-ui": "^1.8.1",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.92.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^8.5.1",
|
||||
"recharts": "^3.10.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.23.0",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwindcss": "^4.3.5",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.19.2",
|
||||
@@ -30,6 +41,7 @@
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"oxlint": "^1.90.0",
|
||||
"prettier": "^3.10.0",
|
||||
"typescript": "~7.0.2",
|
||||
"vite": "^8.3.6",
|
||||
"vitest": "^5.1.0"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { formatE6, parseE6 } from '@/lib/decimal'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Input>, 'value' | 'onChange' | 'type'> & {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
/** Sağda gösterilen birim: `gr`, `$`, `₺`… */
|
||||
suffix: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Miktar / kur alanı (6 ondalığa kadar). `MoneyInput` gibi: sayısal klavye, alandan çıkınca
|
||||
* Türkçe biçim (`5.5` → `5,5`, `1000` → `1.000`). Değer metin; `parseE6` ile × 1e6'ya çevrilir.
|
||||
*/
|
||||
export function DecimalInput({ value, onChange, onBlur, suffix, className, ...props }: Props) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...props}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const v = parseE6(value)
|
||||
if (v !== null && value.trim() !== '') onChange(formatE6(v))
|
||||
onBlur?.(e)
|
||||
}}
|
||||
className={cn('money text-right', className)}
|
||||
style={{ paddingRight: `${Math.max(2.25, suffix.length * 0.6 + 1.25)}rem` }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-muted-foreground"
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useId, useState, type ComponentProps, type ReactNode } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import type { FieldError } from 'react-hook-form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Etiket + giriş alanı + (varsa) hata/ipucu metni.
|
||||
* Erişilebilirlik: hata `aria-invalid` ve `aria-describedby` ile alana bağlanır,
|
||||
* ekran okuyucu alanı odaklayınca hatayı okur.
|
||||
*/
|
||||
export function FormField({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
error?: FieldError
|
||||
hint?: string
|
||||
className?: string
|
||||
/** Alanı üreten fonksiyon: kimlik ve aria özelliklerini alır */
|
||||
children: (field: {
|
||||
id: string
|
||||
'aria-invalid': boolean
|
||||
'aria-describedby'?: string
|
||||
}) => ReactNode
|
||||
}) {
|
||||
const id = useId()
|
||||
const messageId = `${id}-message`
|
||||
const message = error?.message ?? hint
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{children({
|
||||
id,
|
||||
'aria-invalid': !!error,
|
||||
'aria-describedby': message ? messageId : undefined,
|
||||
})}
|
||||
{message && (
|
||||
<p
|
||||
id={messageId}
|
||||
className={cn('text-sm', error ? 'text-expense' : 'text-muted-foreground')}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Şifre alanı: göster/gizle düğmeli. */
|
||||
export function PasswordInput(props: ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...props}
|
||||
type={visible ? 'text' : 'password'}
|
||||
className={cn('pr-12', props.className)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-muted-foreground hover:text-foreground"
|
||||
aria-label={visible ? 'Şifreyi gizle' : 'Şifreyi göster'}
|
||||
aria-pressed={visible}
|
||||
>
|
||||
{visible ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Sunucudan gelen, belirli bir alana ait olmayan hata (ör. "E-posta veya şifre hatalı"). */
|
||||
export function FormError({ message }: { message?: string }) {
|
||||
if (!message) return null
|
||||
return (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-lg border border-expense/30 bg-expense/10 px-3 py-2.5 text-sm text-expense"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { LogOut } from 'lucide-react'
|
||||
import { NavLink, Outlet, ScrollRestoration, useLocation } from 'react-router'
|
||||
import { Logo } from '@/components/logo'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useLogout, useMe } from '@/lib/auth'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { bottomNav, moreNav, paths, sideNav } from './nav-items'
|
||||
|
||||
/**
|
||||
* Oturum açılmış sayfaların iskeleti.
|
||||
* - Mobil (< md): üstte logo, altta 5 öğeli sabit menü
|
||||
* - Masaüstü (≥ md): solda kenar çubuğu
|
||||
*/
|
||||
export function AppLayout() {
|
||||
return (
|
||||
<div className="min-h-dvh">
|
||||
<Sidebar />
|
||||
|
||||
{/* Mobil üst çubuk */}
|
||||
<header className="sticky top-0 z-20 flex h-14 items-center border-b bg-background/90 px-4 backdrop-blur md:hidden print:hidden">
|
||||
<Logo size={28} withText />
|
||||
</header>
|
||||
|
||||
{/* md:pl-64 → kenar çubuğu kadar boşluk; içerik bu alanda ortalanır.
|
||||
pb-28 → mobilde alt menünün altında içerik kalmasın. */}
|
||||
<div className="md:pl-64 print:pl-0">
|
||||
<main className="mx-auto w-full max-w-5xl px-4 pt-6 pb-28 md:px-8 md:pt-8 md:pb-10 print:max-w-none print:p-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
<ScrollRestoration />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-sidebar md:flex print:hidden">
|
||||
<div className="flex h-16 items-center px-5">
|
||||
<Logo size={32} withText />
|
||||
</div>
|
||||
<nav aria-label={t.nav.main} className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
|
||||
{sideNav.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === paths.dashboard}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex min-h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium text-sidebar-foreground/80 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground',
|
||||
isActive && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="size-5" aria-hidden />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="space-y-3 border-t p-4">
|
||||
<AccountSummary />
|
||||
<ThemeToggle compact className="w-full" />
|
||||
<LogoutButton className="w-full justify-start" />
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function BottomNav() {
|
||||
const { pathname } = useLocation()
|
||||
// "Diğer" altındaki sayfalardayken de "Diğer" seçili görünsün
|
||||
const inMore = moreNav.some((i) => pathname.startsWith(i.to))
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={t.nav.main}
|
||||
className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden print:hidden"
|
||||
>
|
||||
<ul className="grid grid-cols-5">
|
||||
{bottomNav.map(({ to, label, icon: Icon }) => (
|
||||
<li key={to}>
|
||||
<NavLink
|
||||
to={to}
|
||||
end={to === paths.dashboard}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex min-h-16 flex-col items-center justify-center gap-1 text-xs font-medium text-muted-foreground transition-colors',
|
||||
(isActive || (to === paths.more && inMore)) && 'text-primary',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-8 w-14 items-center justify-center rounded-full transition-colors',
|
||||
(isActive || (to === paths.more && inMore)) && 'bg-primary/10',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccountSummary() {
|
||||
const { data: me } = useMe()
|
||||
if (!me) return null
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{me.user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{me.household.name} · {me.user.role === 'owner' ? t.account.owner : t.account.member}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogoutButton({ className }: { className?: string }) {
|
||||
const logout = useLogout()
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('text-muted-foreground', className)}
|
||||
onClick={() => logout.mutate()}
|
||||
disabled={logout.isPending}
|
||||
>
|
||||
<LogOut /> {t.account.logout}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
CalendarDays,
|
||||
ChartLine,
|
||||
ClipboardCheck,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
NotebookPen,
|
||||
PiggyBank,
|
||||
Settings,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
export type NavItem = { to: string; label: string; icon: LucideIcon }
|
||||
|
||||
/** Sayfa adresleri tek yerde */
|
||||
export const paths = {
|
||||
dashboard: '/',
|
||||
ledger: '/defter',
|
||||
planning: '/plan',
|
||||
/** İlk dönem sihirbazı; `?adim=1..5` */
|
||||
periodWizard: '/plan/donem-ac',
|
||||
savings: '/birikim',
|
||||
/** Varlık ayrıntısı; adres için `assetPath(id)` */
|
||||
asset: '/birikim/:id',
|
||||
more: '/diger',
|
||||
review: '/degerlendirme',
|
||||
/** Dönem kapatma sihirbazı */
|
||||
closePeriod: '/degerlendirme/kapat',
|
||||
reports: '/raporlar',
|
||||
settings: '/ayarlar',
|
||||
login: '/giris',
|
||||
register: '/kayit',
|
||||
} as const
|
||||
|
||||
/** Varlık ayrıntı sayfası adresi */
|
||||
export const assetPath = (id: string) => `/birikim/${id}`
|
||||
|
||||
const dashboard: NavItem = { to: paths.dashboard, label: t.nav.dashboard, icon: LayoutDashboard }
|
||||
const ledger: NavItem = { to: paths.ledger, label: t.nav.ledger, icon: NotebookPen }
|
||||
const planning: NavItem = { to: paths.planning, label: t.nav.planning, icon: CalendarDays }
|
||||
const savings: NavItem = { to: paths.savings, label: t.nav.savings, icon: PiggyBank }
|
||||
const review: NavItem = { to: paths.review, label: t.nav.review, icon: ClipboardCheck }
|
||||
const reports: NavItem = { to: paths.reports, label: t.nav.reports, icon: ChartLine }
|
||||
const settings: NavItem = { to: paths.settings, label: t.nav.settings, icon: Settings }
|
||||
|
||||
/** Mobil alt menü: 5 öğe (KAKEIBO_PROMPT §9) */
|
||||
export const bottomNav: NavItem[] = [
|
||||
dashboard,
|
||||
ledger,
|
||||
planning,
|
||||
savings,
|
||||
{ to: paths.more, label: t.nav.more, icon: Menu },
|
||||
]
|
||||
|
||||
/** "Diğer" altında toplananlar (mobil) */
|
||||
export const moreNav: NavItem[] = [review, reports, settings]
|
||||
|
||||
/** Masaüstü sol menü: hepsi doğrudan */
|
||||
export const sideNav: NavItem[] = [dashboard, ledger, planning, review, savings, reports, settings]
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Navigate, Outlet, useLocation, useSearchParams } from 'react-router'
|
||||
import { Logo } from '@/components/logo'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import { safeReturnPath } from '@/lib/return-path'
|
||||
import { t } from '@/lib/strings'
|
||||
import { paths } from './nav-items'
|
||||
|
||||
/** Oturum kontrol edilirken gösterilen tam ekran açılış. */
|
||||
export function Splash() {
|
||||
return (
|
||||
<div className="grid min-h-dvh place-items-center" role="status" aria-label={t.common.loading}>
|
||||
<Logo size={56} className="animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Oturum gerektiren sayfalar. Oturum yoksa → /giris?donus=<şimdiki adres> */
|
||||
export function RequireAuth() {
|
||||
const me = useMe()
|
||||
const location = useLocation()
|
||||
|
||||
if (me.isPending) return <Splash />
|
||||
if (!me.data) {
|
||||
const back = location.pathname + location.search
|
||||
const to = back === '/' ? paths.login : `${paths.login}?donus=${encodeURIComponent(back)}`
|
||||
return <Navigate to={to} replace />
|
||||
}
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
/**
|
||||
* Yalnızca oturumsuzken: giriş/kayıt.
|
||||
* Oturum açılınca (giriş/kayıt başarılı olduğunda da) `?donus=` adresine, yoksa panele gönderir.
|
||||
* Yönlendirme yalnızca burada yapılır — formlar ayrıca `navigate` çağırmaz (yarış durumu olmasın).
|
||||
*/
|
||||
export function RequireGuest() {
|
||||
const me = useMe()
|
||||
const [params] = useSearchParams()
|
||||
if (me.isPending) return <Splash />
|
||||
if (me.data) return <Navigate to={safeReturnPath(params.get('donus'), paths.dashboard)} replace />
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { Markdown } from '@/components/markdown'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Markdown alanı için "Yaz / Önizle" sekmeleri. `children` = yazma paneli (genelde `Textarea`).
|
||||
* Kimlikler `useId` ile üretilir → aynı sayfada birden çok editör olabilir.
|
||||
*/
|
||||
export function MarkdownTabs({
|
||||
label,
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
/** Sekme listesinin erişilebilir adı */
|
||||
label: string
|
||||
/** Önizlenecek metin */
|
||||
value: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const id = useId()
|
||||
const [tab, setTab] = useState<'write' | 'preview'>('write')
|
||||
|
||||
const tabButton = (v: typeof tab, text: string) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${id}-tab-${v}`}
|
||||
aria-selected={tab === v}
|
||||
aria-controls={`${id}-panel-${v}`}
|
||||
onClick={() => setTab(v)}
|
||||
className={cn(
|
||||
'h-11 rounded-md px-4 text-sm font-medium transition-colors',
|
||||
tab === v ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div role="tablist" aria-label={label} className="inline-flex rounded-lg bg-muted p-1">
|
||||
{tabButton('write', t.notes.write)}
|
||||
{tabButton('preview', t.notes.preview)}
|
||||
</div>
|
||||
{tab === 'write' ? (
|
||||
<div id={`${id}-panel-write`} role="tabpanel" aria-labelledby={`${id}-tab-write`}>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
id={`${id}-panel-preview`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${id}-tab-preview`}
|
||||
className="min-h-40 rounded-lg border p-3"
|
||||
>
|
||||
{value.trim() ? (
|
||||
<Markdown>{value}</Markdown>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t.notes.previewEmpty}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Not metnini Markdown olarak gösterir (GFM: tablo, ~~üstü çizili~~, `- [ ]` yapılacaklar).
|
||||
* Güvenlik: ham HTML işlenmez (react-markdown varsayılanı); `javascript:` gibi
|
||||
* tehlikeli bağlantılar kütüphane tarafından temizlenir. Bağlantılar yeni sekmede açılır.
|
||||
*/
|
||||
const components: Components = {
|
||||
a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
||||
}
|
||||
|
||||
export function Markdown({ children, className }: { children: string; className?: string }) {
|
||||
return (
|
||||
<div className={cn('markdown', className)}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { formatNumber, parseMoney } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Input>, 'value' | 'onChange' | 'type'> & {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Tutar alanı: telefonda sayısal klavye açar, sağda `₺` gösterir.
|
||||
* Alandan çıkınca yazılanı Türkçe biçime getirir: `1250,5` → `1.250,50`.
|
||||
* Değer metin olarak tutulur; kuruşa çevirme `toKurus` ile gönderirken yapılır.
|
||||
*
|
||||
* react-hook-form ile `Controller` üzerinden kullanılır (kontrollü bileşen).
|
||||
*/
|
||||
export function MoneyInput({ value, onChange, onBlur, className, ...props }: Props) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...props}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const k = parseMoney(value)
|
||||
if (k !== null && value.trim() !== '') onChange(formatNumber(k / 100))
|
||||
onBlur?.(e)
|
||||
}}
|
||||
className={cn('money pr-9 text-right', className)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-muted-foreground"
|
||||
>
|
||||
₺
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Construction, Loader2, RotateCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { t } from '@/lib/strings'
|
||||
import { useDocumentTitle } from '@/lib/use-document-title'
|
||||
|
||||
/** Sayfa başlığı + açıklama; sekme başlığını da ayarlar. */
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
useDocumentTitle(title)
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">{title}</h1>
|
||||
{description && <p className="mt-1 text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Sayfa içi yükleniyor göstergesi */
|
||||
export function PageLoading() {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center justify-center gap-2 p-10 text-muted-foreground"
|
||||
>
|
||||
<Loader2 className="size-5 animate-spin" aria-hidden />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Veri yüklenemedi: Türkçe mesaj + tekrar dene */
|
||||
export function PageError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center gap-3 rounded-xl border border-expense/30 bg-expense/5 p-8 text-center"
|
||||
>
|
||||
<p className="text-expense">{message}</p>
|
||||
<Button variant="outline" onClick={onRetry}>
|
||||
<RotateCw aria-hidden />
|
||||
{t.common.retry}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Henüz geliştirilmemiş ekranlar için yer tutucu. `when`: "Faz 4'te" gibi (Türkçe ek uyumu için tam ifade). */
|
||||
export function ComingSoon({ when }: { when: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed p-10 text-center text-muted-foreground">
|
||||
<Construction className="size-8" aria-hidden />
|
||||
<p>{t.comingSoon(when)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Tek seçimlik düğme grubu (ör. Haftalık / Aylık). Her düğme ≥ 44 px yüksek. */
|
||||
export function Segmented<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
/** Ekran okuyucu için grup adı */
|
||||
label: string
|
||||
value: T
|
||||
options: readonly { value: T; label: string }[]
|
||||
onChange: (value: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="flex rounded-lg border p-0.5">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
aria-pressed={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'min-h-11 rounded-md px-3 text-sm font-medium sm:px-4',
|
||||
value === o.value ? 'bg-primary text-primary-foreground' : 'hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Yükleniyor yer tutucusu: içeriğin şeklinde gri, nabız gibi atan kutu */
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div aria-hidden className={cn('animate-pulse rounded-xl bg-muted', className)} />
|
||||
}
|
||||
@@ -8,8 +8,17 @@ const OPTIONS: { value: Theme; label: string; icon: typeof Sun }[] = [
|
||||
{ value: 'system', label: 'Sistem', icon: Monitor },
|
||||
]
|
||||
|
||||
/** Aydınlık / Karanlık / Sistem seçici (radyo grubu gibi davranır, klavyeyle gezilebilir). */
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
/**
|
||||
* Aydınlık / Karanlık / Sistem seçici (radyo grubu gibi davranır, klavyeyle gezilebilir).
|
||||
* `compact`: yalnızca ikonlar (dar alanlar için; etiketler ekran okuyucuda kalır).
|
||||
*/
|
||||
export function ThemeToggle({
|
||||
className,
|
||||
compact = false,
|
||||
}: {
|
||||
className?: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
return (
|
||||
<div
|
||||
@@ -23,14 +32,16 @@ export function ThemeToggle({ className }: { className?: string }) {
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={theme === value}
|
||||
title={label}
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
'inline-flex min-h-11 min-w-11 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors',
|
||||
compact && 'flex-1 px-0',
|
||||
theme === value && 'bg-background text-foreground shadow-sm',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
<span>{label}</span>
|
||||
<span className={compact ? 'sr-only' : undefined}>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Toaster as Sonner } from 'sonner'
|
||||
import { useTheme } from '@/lib/theme'
|
||||
|
||||
/**
|
||||
* Bildirimler (toast). Kullanım: `import { toast } from 'sonner'` → `toast.success('Kaydedildi')`
|
||||
* Mobilde alt menüyle çakışmasın diye üstte gösterilir.
|
||||
*/
|
||||
export function Toaster() {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme}
|
||||
position="top-center"
|
||||
richColors
|
||||
closeButton
|
||||
toastOptions={{ className: 'font-sans' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-5 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-4"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
// Kakeibo: h-11 (44 px dokunma alanı); mobilde text-base (16 px) → iOS odaklanınca yakınlaştırmaz
|
||||
className={cn(
|
||||
"h-11 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
// shadcn textarea (Input ile aynı görünüm); Kakeibo: mobilde text-base → iOS yakınlaştırmaz
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-40 w-full rounded-lg border border-input bg-transparent px-3 py-2.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user