Files
kakeibo/backend/src/services/period.rs
T
suphicakir 7f3fde074c feat(plan): sabit gelir/gider kalemleri ve dönem notları (backend)
- /api/periods/{id}/plan-items: listele, ekle; /api/plan-items/{id}: düzenle, sil
- Önceki dönemden kopyala: göreli gün korunur, dönem sonuna kırpılır, aynı kalem tekrar eklenmez
- /api/periods/{id}/notes (planning | review), /api/notes/{id}: Markdown notlar
- Kapalı dönemde değişiklik yok; silme geri alınabilir şekilde işaretlenir
- Entegrasyon testleri (5): kurallar, kopyalama, salt okunur dönem, hane izolasyonu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:34:35 +03:00

310 lines
9.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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>,
pub version: Option<i32>,
}
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 == 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<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", "");
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::<Document>(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<Period> {
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(&current.start_date);
let end_s = ch.end_date.as_deref().unwrap_or(&current.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<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(())
}
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());
}
}