Files
suphicakir 7143613ab2 feat(defter): özet ve bütçeye gerçekleşen harcama eklendi (backend)
- Dönem özeti: spent_total ve remaining (harcanabilir − harcanan)
- Bütçe görünümü: grup ve hafta bazında spent
- Değerler saklanmaz, kayıtlardan anlık toplanır; silinenler sayılmaz

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

406 lines
13 KiB
Rust
Raw Permalink 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.
//! 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<i64> {
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::<Document>("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::<i64>(),
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::<i64>(), 100_000);
let manual: Vec<bool> = 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::<i64>(), 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::<i64>(), 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::<Document>("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::<Document>("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::<Document>("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;
}