//! 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, pub savings_goal: Option, } #[derive(Default)] pub struct PeriodChanges { pub name: Option, pub start_date: Option, pub end_date: Option, pub savings_goal: Option, pub version: Option, } fn coll(db: &Database) -> mongodb::Collection { db.collection(col::PERIODS) } pub async fn current(db: &Database, household_id: Uuid) -> AppResult> { 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> { 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 { 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 { let p = get(db, household_id, id).await?; if p.status == PeriodStatus::Closed { 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 { 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", ""); d.insert( "previous_period_id", previous.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); match db.collection::(col::PERIODS).insert_one(d).await { Ok(_) => {} // Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller Err(e) if is_duplicate_key(&e) => { return Err(AppError::Conflict( "Zaten açık bir dönem var. Sayfayı yenileyin.".into(), )); } Err(e) => 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 { let current = get(db, household_id, id).await?; if current.status == PeriodStatus::Closed { 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)?); } 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 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 { 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 { 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, ) -> 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::(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(()) } 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()); } }