//! 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()) }