From 62e92f552a5fd855ca856bd3882be7c6d115f420 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 12 Nov 2026 12:20:09 +0300 Subject: [PATCH] =?UTF-8?q?feat(d=C3=B6nem):=20d=C3=B6nem=20kapatma,=20dev?= =?UTF-8?q?ir=20ve=20d=C3=BCzeltme=20i=C3=A7in=20yeniden=20a=C3=A7ma=20(ba?= =?UTF-8?q?ckend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /periods/{id}/close: tek transaction; açık → borç, artı → birikim + devir - Devir sonraki dönem açılınca ona bağlanır - POST /periods/{id}/reopen (sahip): düzeltme modu, yeni dönem açık kalır Co-Authored-By: Claude Opus 5.5 --- backend/api.http | 11 + backend/src/models/period.rs | 19 +- backend/src/routes/mod.rs | 2 + backend/src/routes/period.rs | 85 ++++++- backend/src/services/closing.rs | 270 +++++++++++++++++++++++ backend/src/services/mod.rs | 1 + backend/src/services/period.rs | 53 +++-- backend/tests/closing.rs | 245 ++++++++++++++++++++ docs/DATA_MODEL.md | 12 +- docs/MEMORY.md | 5 +- docs/PHASES.md | 2 +- frontend/src/lib/plan-draft.test.ts | 2 + frontend/src/types/Carryover.ts | 11 + frontend/src/types/Period.ts | 10 +- frontend/src/types/PeriodCloseRequest.ts | 7 + frontend/src/types/PeriodCloseResult.ts | 13 ++ frontend/src/types/PeriodStatus.ts | 2 +- 17 files changed, 724 insertions(+), 26 deletions(-) create mode 100644 backend/src/services/closing.rs create mode 100644 backend/tests/closing.rs create mode 100644 frontend/src/types/Carryover.ts create mode 100644 frontend/src/types/PeriodCloseRequest.ts create mode 100644 frontend/src/types/PeriodCloseResult.ts diff --git a/backend/api.http b/backend/api.http index 0a29282..14a3768 100644 --- a/backend/api.http +++ b/backend/api.http @@ -313,3 +313,14 @@ 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 diff --git a/backend/src/models/period.rs b/backend/src/models/period.rs index b2994bf..12a6db4 100644 --- a/backend/src/models/period.rs +++ b/backend/src/models/period.rs @@ -1,6 +1,6 @@ //! `periods` koleksiyonu (DATA_MODEL.md §3.4). -use mongodb::bson::Uuid; +use mongodb::bson::{DateTime, Uuid}; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -10,8 +10,12 @@ use ts_rs::TS; pub enum PeriodStatus { /// Girişler yapılabilir. Hanede en fazla bir tane. Open, - /// Salt okunur (Faz 6: kapanış sihirbazı, sahip yeniden açabilir). + /// 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 { @@ -19,8 +23,14 @@ impl PeriodStatus { 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)] @@ -38,5 +48,10 @@ pub struct Period { /// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası) #[serde(default)] pub reflection_md: Option, + /// Kapanışta birikime aktarılan tutar (kuruş). Faz 8'de varlık hareketine dönüşecek. + #[serde(default)] + pub to_savings: Option, + #[serde(default)] + pub closed_at: Option, pub version: i32, } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index b66256c..f08f6ae 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -68,6 +68,8 @@ pub fn router(state: AppState) -> Router { .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), diff --git a/backend/src/routes/period.rs b/backend/src/routes/period.rs index 11cd605..c271262 100644 --- a/backend/src/routes/period.rs +++ b/backend/src/routes/period.rs @@ -6,7 +6,9 @@ //! | 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 | +//! | 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`) | use axum::{ Json, @@ -25,6 +27,7 @@ use crate::{ models::period::{Period, PeriodStatus}, services::{ calendar::{days_between, format_date, parse_date, weeks}, + closing, period::{self, NewPeriod, PeriodChanges}, }, state::AppState, @@ -77,6 +80,11 @@ pub struct PeriodDto { savings_goal: i64, /// "Nasıl daha iyi yapabilirim?" (Markdown) reflection_md: Option, + /// Kapanışta birikime aktarılan (kuruş); kapanmamışsa `null` + #[ts(type = "number | null")] + to_savings: Option, + /// Kapanış zamanı (ISO 8601) + closed_at: Option, version: i32, weeks: Vec, } @@ -107,6 +115,8 @@ impl From for PeriodDto { 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() @@ -190,3 +200,76 @@ pub async fn update( .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, + version: Option, +} + +#[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, +} + +#[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, +} + +pub async fn close( + State(s): State, + auth: AuthUser, + Path(id): Path, + ValidJson(req): ValidJson, +) -> AppResult> { + 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, + 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, + auth: AuthUser, + Path(id): Path, +) -> AppResult> { + auth.require_owner()?; + let p = closing::reopen(&s.db, auth.household_id, auth.user_id, parse_id(&id)?).await?; + Ok(Json(p.into())) +} diff --git a/backend/src/services/closing.rs b/backend/src/services/closing.rs new file mode 100644 index 0000000..a01302d --- /dev/null +++ b/backend/src/services/closing.rs @@ -0,0 +1,270 @@ +//! 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::{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, +} + +pub struct Closing { + pub period: Period, + pub realized_savings: i64, + pub to_savings: i64, + pub carryover: Option, +} + +/// Gerçekleşen birikimi birikim / devir olarak böler. `to_savings` verilmezse tamamı birikime. +pub fn settle( + realized: i64, + to_savings: Option, +) -> 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, + version: Option, +) -> AppResult { + 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?; + 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::(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::(col::CARRYOVERS) + .update_many( + old, + doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } }, + ) + .session(&mut session) + .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::(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 { + 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 { + 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::(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::(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> { + let mut f = scope(household_id); + f.insert("start_date", doc! { "$gte": &p.end_date }); + Ok(db + .collection::(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()); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index dae8eff..2b9df51 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 closing; pub mod entry; pub mod invite; pub mod money; diff --git a/backend/src/services/period.rs b/backend/src/services/period.rs index 6f00813..0500299 100644 --- a/backend/src/services/period.rs +++ b/backend/src/services/period.rs @@ -82,7 +82,7 @@ pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult AppResult { let p = get(db, household_id, id).await?; - if p.status == PeriodStatus::Closed { + if !p.status.is_writable() { return Err(closed()); } Ok(p) @@ -122,24 +122,51 @@ pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResul 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("reflection_md", Bson::Null); + d.insert("to_savings", Bson::Null); d.insert( "previous_period_id", - previous.map_or(Bson::Null, |p| p.id.into()), + 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); - 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(), - )); + // 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::(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::(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()); } - Err(e) => return Err(e.into()), } coll(db) @@ -155,7 +182,7 @@ pub async fn update( ch: PeriodChanges, ) -> AppResult { let current = get(db, household_id, id).await?; - if current.status == PeriodStatus::Closed { + if !current.status.is_writable() { return Err(closed()); } if ch.version.is_some_and(|v| v != current.version) { @@ -297,7 +324,7 @@ async fn ensure_entries_inside( Ok(()) } -fn stale() -> AppError { +pub fn stale() -> AppError { AppError::Conflict("Dönem başka biri tarafından değiştirildi, sayfayı yenileyin".into()) } diff --git a/backend/tests/closing.rs b/backend/tests/closing.rs new file mode 100644 index 0000000..88a89d6 --- /dev/null +++ b/backend/tests/closing.rs @@ -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; +} diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 901ece4..35c28f0 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -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 @@ -223,7 +225,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. diff --git a/docs/MEMORY.md b/docs/MEMORY.md index c6d87cf..2453034 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 6 – Adım 2:** Backend dönem kapatma (`POST /periods/{id}/close`, tek transaction): `status=closed`, `closed_at/by`; gerçekleşen birikim < 0 → `carryovers{kind: debt}`, > 0 → kullanıcının `to_savings` kadarı birikime (Faz 8'e kadar dönemde saklanır), kalanı `surplus`; devrin `to_period_id`'si sonraki dönem açılınca bağlanır. Yeniden açma (sahip): devirler soft delete. **Kullanıcıya sorulacak:** başka açık dönem varken eski dönem nasıl yeniden açılır? +**Faz 6 – Adım 3:** Frontend Değerlendirme sayfası (`/degerlendirme`, açık dönem; ileride `?donem=`): 4 soru kartı (`PeriodReview`), grup planlanan/gerçekleşen çubukları, en çok harcanan kategoriler (`useCategories(true)` ile ad), haftalık dağılım, "Nasıl daha iyi yapabilirim?" (Markdown editör → `PATCH reflection_md`), notlar `NotesSection context="review"` (yalnızca başlık listesi, tıklayınca açılır), "🔒 Dönemi Kapat" düğmesi (sihirbaz Adım 4). **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 @@ -51,7 +51,7 @@ - **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. @@ -108,3 +108,4 @@ ### 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"). diff --git a/docs/PHASES.md b/docs/PHASES.md index 2e845d0..eb8dc15 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -62,7 +62,7 @@ ## 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) +- ✅ 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) diff --git a/frontend/src/lib/plan-draft.test.ts b/frontend/src/lib/plan-draft.test.ts index f8497ad..00d4b33 100644 --- a/frontend/src/lib/plan-draft.test.ts +++ b/frontend/src/lib/plan-draft.test.ts @@ -14,6 +14,8 @@ const period: Period = { status: 'open', savings_goal: 500_000, reflection_md: null, + to_savings: null, + closed_at: null, version: 3, weeks: [ { index: 1, start_date: '2026-12-01', last_date: '2026-12-06', days: 6 }, diff --git a/frontend/src/types/Carryover.ts b/frontend/src/types/Carryover.ts new file mode 100644 index 0000000..87f25e1 --- /dev/null +++ b/frontend/src/types/Carryover.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 Carryover = { +/** + * `debt` (borç) · `surplus` (artı) + */ +kind: "debt" | "surplus", amount: number, +/** + * Sonraki dönem henüz açılmadıysa `null` (açılınca bağlanır) + */ +to_period_id: string | null, }; diff --git a/frontend/src/types/Period.ts b/frontend/src/types/Period.ts index 84efdfa..e9a63c5 100644 --- a/frontend/src/types/Period.ts +++ b/frontend/src/types/Period.ts @@ -22,4 +22,12 @@ savings_goal: number, /** * "Nasıl daha iyi yapabilirim?" (Markdown) */ -reflection_md: string | null, version: number, weeks: Array, }; +reflection_md: string | null, +/** + * Kapanışta birikime aktarılan (kuruş); kapanmamışsa `null` + */ +to_savings: number | null, +/** + * Kapanış zamanı (ISO 8601) + */ +closed_at: string | null, version: number, weeks: Array, }; diff --git a/frontend/src/types/PeriodCloseRequest.ts b/frontend/src/types/PeriodCloseRequest.ts new file mode 100644 index 0000000..28780e3 --- /dev/null +++ b/frontend/src/types/PeriodCloseRequest.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PeriodCloseRequest = { +/** + * Birikime aktarılacak (kuruş); verilmezse gerçekleşen birikimin tamamı. Kalanı artı olarak devreder. + */ +to_savings?: number, version?: number, }; diff --git a/frontend/src/types/PeriodCloseResult.ts b/frontend/src/types/PeriodCloseResult.ts new file mode 100644 index 0000000..deb806d --- /dev/null +++ b/frontend/src/types/PeriodCloseResult.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Carryover } from "./Carryover"; +import type { Period } from "./Period"; + +export type PeriodCloseResult = { period: Period, +/** + * Değerlendirmenin 4. sorusu (negatifse açık) + */ +realized_savings: number, to_savings: number, +/** + * Sonraki döneme devreden; yoksa `null` + */ +carryover: Carryover | null, }; diff --git a/frontend/src/types/PeriodStatus.ts b/frontend/src/types/PeriodStatus.ts index 5a9ee91..c4e4b09 100644 --- a/frontend/src/types/PeriodStatus.ts +++ b/frontend/src/types/PeriodStatus.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PeriodStatus = "open" | "closed"; +export type PeriodStatus = "open" | "closed" | "correcting";