//! 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; }