diff --git a/backend/api.http b/backend/api.http index 9b04987..ab37ae0 100644 --- a/backend/api.http +++ b/backend/api.http @@ -263,3 +263,39 @@ Content-Type: application/json ### 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 diff --git a/backend/src/models/entry.rs b/backend/src/models/entry.rs new file mode 100644 index 0000000..6b284b8 --- /dev/null +++ b/backend/src/models/entry.rs @@ -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, + #[serde(default)] + pub description: Option, + pub created_by: Uuid, + pub version: i32, +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 2598768..0de6cf3 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -2,6 +2,7 @@ //! Şema: `docs/DATA_MODEL.md` pub mod common; +pub mod entry; pub mod group; pub mod household; pub mod invite; diff --git a/backend/src/routes/entry.rs b/backend/src/routes/entry.rs new file mode 100644 index 0000000..a4afa4c --- /dev/null +++ b/backend/src/routes/entry.rs @@ -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, + description: Option, +} + +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "EntryUpdateRequest", optional_fields)] +pub struct UpdateRequest { + date: Option, + #[ts(type = "number", optional)] + amount: Option, + category_id: Option, + /// Boş metin → ödeme türünü kaldır + payment_method_id: Option, + /// Boş metin → açıklamayı kaldır + description: Option, + version: Option, +} + +#[derive(Deserialize)] +pub struct ListQuery { + from: Option, + to: Option, +} + +#[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, + description: Option, + created_by: String, + version: i32, +} + +impl From 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::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::parse_str(id.trim()).map_err(|_| AppError::BadRequest(format!("{what} geçersiz"))) +} + +pub async fn list( + State(s): State, + auth: AuthUser, + Path(period_id): Path, + Query(q): Query, +) -> AppResult>> { + 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, + auth: AuthUser, + Path(period_id): Path, + ValidJson(req): ValidJson, +) -> AppResult<(StatusCode, Json)> { + 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, + auth: AuthUser, + Path(id): Path, + ValidJson(req): ValidJson, +) -> AppResult> { + 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, + auth: AuthUser, + Path(id): Path, +) -> AppResult { + entry::delete(&s.db, auth.household_id, path_id(&id)?).await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn restore( + State(s): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult> { + let e = entry::restore(&s.db, auth.household_id, path_id(&id)?).await?; + Ok(Json(e.into())) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 587f2d8..a8871e4 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -3,6 +3,7 @@ mod auth; mod budget; mod catalog; +mod entry; mod health; mod household; mod note; @@ -84,6 +85,12 @@ pub fn router(state: AppState) -> Router { .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)) // Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin) .fallback(|| async { AppError::NotFound }); diff --git a/backend/src/services/catalog.rs b/backend/src/services/catalog.rs index 3884f99..ceeb68f 100644 --- a/backend/src/services/catalog.rs +++ b/backend/src/services/catalog.rs @@ -73,6 +73,18 @@ fn coll(db: &Database, c: Catalog) -> mongodb::Collection { 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> { + 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, diff --git a/backend/src/services/entry.rs b/backend/src/services/entry.rs new file mode 100644 index 0000000..2b17317 --- /dev/null +++ b/backend/src/services/entry.rs @@ -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, + pub description: Option, +} + +#[derive(Default)] +pub struct EntryChanges { + pub date: Option, + pub amount: Option, + pub category_id: Option, + /// `Some(None)` → ödeme türünü kaldır (istekte boş metin) + pub payment_method_id: Option>, + /// `Some("")` → açıklamayı kaldır + pub description: Option, + pub version: Option, +} + +/// Liste filtresi: tarih aralığı (ikisi de dahil, isteğe bağlı) +#[derive(Default)] +pub struct EntryFilter { + pub from: Option, + pub to: Option, +} + +fn coll(db: &Database) -> mongodb::Collection { + 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> { + 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 { + 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::(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 { + 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 { + 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 { + 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, +) -> AppResult { + 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, +) -> 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 { + 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 { + 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 { + 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> { + 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()) +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 44a7084..c12d07b 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -4,6 +4,7 @@ pub mod account; pub mod budget; pub mod calendar; pub mod catalog; +pub mod entry; pub mod invite; pub mod money; pub mod note; diff --git a/backend/tests/entries.rs b/backend/tests/entries.rs new file mode 100644 index 0000000..2ef0b4d --- /dev/null +++ b/backend/tests/entries.rs @@ -0,0 +1,453 @@ +//! 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::("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; +} diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 7e36d5c..653e5d9 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 5 – Adım 1:** Backend harcama kayıtları (`entries`): model (DATA_MODEL'e bak), `services/entry.rs` (create/update/delete soft + restore, list: dönem/hafta/tarih aralığı), doğrulama (tutar > 0, tarih açık dönem içinde, kategori hanenin ve aktif, grup = kategorinin grubu, ödeme türü hanenin), `routes/entry.rs`, TS tipleri, `tests/entries.rs` (CRUD, kapalı dönem, izolasyon, geri al). Ardından `budget::suggest` previous/avg3 testleri gerçek kayıtla. +**Faz 5 – Adım 2:** Backend gerçekleşen harcamalar: dönem özetine `spent_total` + `remaining` (harcanabilir − harcanan), bütçe görünümüne grup×hafta `spent` (aggregation, saklanmaz), haftalık özet uç noktası ya da `budget` yanıtına ekleme (karar: mevcut `GET /budget` DTO'suna `spent` alanı). Testler: haftalara doğru dağılım, silinen kayıt sayılmaz, izolasyon. ## 📌 Alınan Kararlar - **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`). @@ -97,3 +97,6 @@ - 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. diff --git a/docs/PHASES.md b/docs/PHASES.md index 1dcac60..746a4dc 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -54,8 +54,8 @@ - ✅ 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 +- ✅ 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 (mongodump günlük, 14 gün) + geri yükleme denemesi diff --git a/frontend/src/types/Entry.ts b/frontend/src/types/Entry.ts new file mode 100644 index 0000000..c0142b3 --- /dev/null +++ b/frontend/src/types/Entry.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Group } from "./Group"; + +export type Entry = { id: string, period_id: string, date: string, +/** + * Kuruş + */ +amount: number, group: Group, category_id: string, payment_method_id: string | null, description: string | null, created_by: string, version: number, }; diff --git a/frontend/src/types/EntryCreateRequest.ts b/frontend/src/types/EntryCreateRequest.ts new file mode 100644 index 0000000..9f7afe4 --- /dev/null +++ b/frontend/src/types/EntryCreateRequest.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type EntryCreateRequest = { +/** + * `"YYYY-MM-DD"`, dönem içinde + */ +date: string, +/** + * Kuruş, > 0 + */ +amount: number, +/** + * Grup kategoriden alınır + */ +category_id: string, payment_method_id?: string, description?: string, }; diff --git a/frontend/src/types/EntryUpdateRequest.ts b/frontend/src/types/EntryUpdateRequest.ts new file mode 100644 index 0000000..dbdfa3a --- /dev/null +++ b/frontend/src/types/EntryUpdateRequest.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type EntryUpdateRequest = { date?: string, amount?: number, category_id?: string, +/** + * Boş metin → ödeme türünü kaldır + */ +payment_method_id?: string, +/** + * Boş metin → açıklamayı kaldır + */ +description?: string, version?: number, };