//! Bütçe ve dönem özeti. mod common; use axum::http::StatusCode; use common::{Session, TestApp}; use mongodb::bson::{Document, Uuid, doc}; use serde_json::{Value, json}; async fn open_december(app: &TestApp, s: &Session, savings_goal: i64) -> String { let (st, p) = app .post( "/periods", s, json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": savings_goal }), ) .await; assert_eq!(st, StatusCode::CREATED, "{p}"); p["id"].as_str().unwrap().to_string() } async fn add_item(app: &TestApp, s: &Session, pid: &str, kind: &str, amount: i64) { let (st, b) = app .post( &format!("/periods/{pid}/plan-items"), s, json!({ "kind": kind, "name": format!("{kind}-{amount}"), "amount": amount }), ) .await; assert_eq!(st, StatusCode::CREATED, "{b}"); } fn group<'a>(budget: &'a Value, code: &str) -> &'a Value { budget .as_array() .unwrap() .iter() .find(|g| g["group"] == code) .unwrap() } fn week_amounts(g: &Value) -> Vec { g["weeks"] .as_array() .unwrap() .iter() .map(|w| w["amount"].as_i64().unwrap()) .collect() } #[tokio::test] async fn summary_computes_spendable_with_carryovers() { let app = TestApp::new().await; let s = app.register("Test", None).await; let pid = open_december(&app, &s, 500_000).await; // Boş dönem: gelir yok → harcanabilir = -birikim let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await; assert_eq!(sum["spendable"], -500_000); add_item(&app, &s, &pid, "income", 4_500_000).await; add_item(&app, &s, &pid, "income", 300_000).await; add_item(&app, &s, &pid, "expense", 1_500_000).await; add_item(&app, &s, &pid, "expense", 45_000).await; // Önceki dönemden devir (Faz 6'da kapanış sihirbazı yazacak) let hid = Uuid::parse_str(&s.household_id).unwrap(); let to = Uuid::parse_str(&pid).unwrap(); app.db .collection::("carryovers") .insert_many([ doc! { "household_id": hid, "to_period_id": to, "kind": "debt", "amount": 100_000_i64, "deleted_at": null }, doc! { "household_id": hid, "to_period_id": to, "kind": "surplus", "amount": 20_000_i64, "deleted_at": null }, ]) .await .unwrap(); let (st, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await; assert_eq!(st, StatusCode::OK); assert_eq!( sum, json!({ "income_total": 4_800_000, "fixed_expense_total": 1_545_000, "savings_goal": 500_000, "carryover_debt": 100_000, "carryover_surplus": 20_000, // 4.800.000 − 1.545.000 − 500.000 − 100.000 + 20.000 "spendable": 2_675_000, "budget_total": 0, "unallocated": 2_675_000, "spent_total": 0, "remaining": 2_675_000 }) ); app.cleanup().await; } #[tokio::test] async fn default_suggestion_splits_spendable() { let app = TestApp::new().await; let s = app.register("Test", None).await; let pid = open_december(&app, &s, 0).await; let url = format!("/periods/{pid}/budget/suggestion?method=default"); // Gelir yokken öneri yok let (_, sug) = app.get(&url, &s).await; assert_eq!(sug["available"], false); assert!(sug["message"].as_str().unwrap().contains("Harcanabilir")); add_item(&app, &s, &pid, "income", 1_234_567).await; let (_, sug) = app.get(&url, &s).await; assert_eq!(sug["available"], true); assert_eq!( sug["amounts"], json!({ "needs": 617_283, "wants": 370_370, "culture": 123_457, "unexpected": 123_457 }) ); // Önceki dönem yokken previous/avg3 → veri yok for m in ["previous", "avg3"] { let (_, sug) = app .get(&format!("/periods/{pid}/budget/suggestion?method={m}"), &s) .await; assert_eq!(sug["available"], false, "{m}"); } app.cleanup().await; } #[tokio::test] async fn monthly_budget_is_split_into_weeks_by_days() { let app = TestApp::new().await; let s = app.register("Test", None).await; let pid = open_december(&app, &s, 0).await; add_item(&app, &s, &pid, "income", 300_000).await; let url = format!("/periods/{pid}/budget"); // Başta hepsi 0 let (_, b) = app.get(&url, &s).await; assert_eq!(b.as_array().unwrap().len(), 4); assert_eq!(week_amounts(group(&b, "needs")), vec![0; 5]); let (st, b) = app .put( &url, &s, json!({ "needs": 100_000, "wants": 50_000, "culture": 31, "unexpected": 0 }), ) .await; assert_eq!(st, StatusCode::OK, "{b}"); let needs = group(&b, "needs"); assert_eq!(needs["monthly"], 100_000); // Aralık: 6,7,7,7,4 gün → toplam tam 100.000 assert_eq!( week_amounts(needs), vec![19_355, 22_581, 22_581, 22_580, 12_903] ); assert_eq!(week_amounts(group(&b, "culture")), vec![6, 7, 7, 7, 4]); for g in b.as_array().unwrap() { assert_eq!( week_amounts(g).iter().sum::(), g["monthly"].as_i64().unwrap() ); } // Özet: 150.031 dağıtıldı, harcanabilir 300.000 → kalan 149.969 let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await; assert_eq!( (sum["budget_total"].as_i64(), sum["unallocated"].as_i64()), (Some(150_031), Some(149_969)) ); // Fazla dağıtım → kalan negatif (arayüz uyarı gösterir) app.put( &url, &s, json!({ "needs": 400_000, "wants": 0, "culture": 0, "unexpected": 0 }), ) .await; let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await; assert_eq!(sum["unallocated"], -100_000); // Negatif tutar → 400 let (st, _) = app .put( &url, &s, json!({ "needs": -1, "wants": 0, "culture": 0, "unexpected": 0 }), ) .await; assert_eq!(st, StatusCode::BAD_REQUEST); app.cleanup().await; } #[tokio::test] async fn manual_week_override_redistributes_the_rest() { let app = TestApp::new().await; let s = app.register("Test", None).await; let pid = open_december(&app, &s, 0).await; app.put( &format!("/periods/{pid}/budget"), &s, json!({ "needs": 100_000, "wants": 0, "culture": 0, "unexpected": 0 }), ) .await; let weeks_url = format!("/periods/{pid}/budget/weeks"); // 2. hafta (7–13 Ara) elle 50.000 → kalan 50.000 diğer haftalara (6+7+7+4 = 24 gün) let (st, b) = app .put( &weeks_url, &s, json!({ "group": "needs", "week_start": "2026-12-07", "amount": 50_000 }), ) .await; assert_eq!(st, StatusCode::OK, "{b}"); let needs = group(&b, "needs"); assert_eq!( week_amounts(needs), vec![12_500, 50_000, 14_584, 14_583, 8_333] ); assert_eq!(week_amounts(needs).iter().sum::(), 100_000); let manual: Vec = needs["weeks"] .as_array() .unwrap() .iter() .map(|w| w["is_manual"].as_bool().unwrap()) .collect(); assert_eq!(manual, vec![false, true, false, false, false]); assert_eq!(needs["weekly_over"], false); // Aylık tutar değişince elle girilen hafta korunur let (_, b) = app .put( &format!("/periods/{pid}/budget"), &s, json!({ "needs": 74_000, "wants": 0, "culture": 0, "unexpected": 0 }), ) .await; assert_eq!(week_amounts(group(&b, "needs"))[1], 50_000); assert_eq!(week_amounts(group(&b, "needs")).iter().sum::(), 74_000); // Elle girilenler aylığı aşarsa uyarı; diğer haftalar 0 let (_, b) = app .put( &weeks_url, &s, json!({ "group": "needs", "week_start": "2026-12-14", "amount": 30_000 }), ) .await; let needs = group(&b, "needs"); assert_eq!(needs["weekly_over"], true); assert_eq!(week_amounts(needs), vec![0, 50_000, 30_000, 0, 0]); // null → otomatiğe dön app.put( &weeks_url, &s, json!({ "group": "needs", "week_start": "2026-12-14", "amount": null }), ) .await; let (_, b) = app .put( &weeks_url, &s, json!({ "group": "needs", "week_start": "2026-12-07", "amount": null }), ) .await; assert_eq!(week_amounts(group(&b, "needs")).iter().sum::(), 74_000); assert!( group(&b, "needs")["weeks"] .as_array() .unwrap() .iter() .all(|w| w["is_manual"] == false) ); // Haftanın ilk günü olmayan tarih → 400 let (st, _) = app .put( &weeks_url, &s, json!({ "group": "needs", "week_start": "2026-12-08", "amount": 1 }), ) .await; assert_eq!(st, StatusCode::BAD_REQUEST); app.cleanup().await; } #[tokio::test] async fn previous_and_avg3_suggestions_use_actual_spending() { let app = TestApp::new().await; let s = app.register("Test", None).await; let hid = Uuid::parse_str(&s.household_id).unwrap(); // Ekim ve Kasım kapalı, harcamaları var (Faz 5'te defterden gelecek) let mut closed = Vec::new(); for (start, end) in [("2026-10-01", "2026-11-01"), ("2026-11-01", "2026-12-01")] { let (_, p) = app .post( "/periods", &s, json!({ "start_date": start, "end_date": end }), ) .await; let id = Uuid::parse_str(p["id"].as_str().unwrap()).unwrap(); app.db .collection::("periods") .update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } }) .await .unwrap(); closed.push(id); } let entry = |pid: Uuid, group: &str, amount: i64| { doc! { "household_id": hid, "period_id": pid, "group": group, "amount": amount, "date": "2026-10-10", "deleted_at": null } }; app.db .collection::("entries") .insert_many([ entry(closed[0], "needs", 100_000), entry(closed[0], "wants", 41_000), entry(closed[1], "needs", 200_000), entry(closed[1], "needs", 50_000), entry(closed[1], "culture", 10_000), ]) .await .unwrap(); let pid = open_december(&app, &s, 0).await; let (_, prev) = app .get( &format!("/periods/{pid}/budget/suggestion?method=previous"), &s, ) .await; assert_eq!(prev["available"], true, "{prev}"); assert_eq!( prev["amounts"], json!({ "needs": 250_000, "wants": 0, "culture": 10_000, "unexpected": 0 }) ); // avg3: 2 kapalı dönem var → ikiye bölünür (yuvarlayarak) let (_, avg) = app .get(&format!("/periods/{pid}/budget/suggestion?method=avg3"), &s) .await; assert_eq!( avg["amounts"], json!({ "needs": 175_000, "wants": 20_500, "culture": 5_000, "unexpected": 0 }) ); app.cleanup().await; } #[tokio::test] async fn budget_closed_period_and_isolation() { let app = TestApp::new().await; let a = app.register("A", None).await; let b = app.register("B", None).await; let pid = open_december(&app, &a, 0).await; let body = json!({ "needs": 1, "wants": 0, "culture": 0, "unexpected": 0 }); // B erişemez let checks = [ app.get(&format!("/periods/{pid}/summary"), &b).await.0, app.get(&format!("/periods/{pid}/budget"), &b).await.0, app.put(&format!("/periods/{pid}/budget"), &b, body.clone()) .await .0, app.put( &format!("/periods/{pid}/budget/weeks"), &b, json!({ "group": "needs", "week_start": "2026-12-07", "amount": 1 }), ) .await .0, app.get(&format!("/periods/{pid}/budget/suggestion"), &b) .await .0, ]; assert!( checks.iter().all(|s| *s == StatusCode::NOT_FOUND), "{checks:?}" ); // Kapalı dönem: okuma serbest, yazma 409 let id = Uuid::parse_str(&pid).unwrap(); app.db .collection::("periods") .update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } }) .await .unwrap(); assert_eq!( app.get(&format!("/periods/{pid}/budget"), &a).await.0, StatusCode::OK ); assert_eq!( app.put(&format!("/periods/{pid}/budget"), &a, body).await.0, StatusCode::CONFLICT ); app.cleanup().await; }