95231cd4d6
- /api/periods/{id}/summary: gelir − sabit gider − birikim − devreden borç + artı = harcanabilir
- /api/periods/{id}/budget: 4 grup aylık; haftalar gün sayısına göre, toplam kuruşu kuruşuna eşit
- Elle hafta ayarı (kalan diğer haftalara dağılır), otomatiğe dönüş, aşım uyarısı
- Öneri: varsayılan yüzdeler / önceki dönem / son 3 dönem ortalaması
- Testler: orantılı bölme (3), bütçe entegrasyon (6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
404 lines
12 KiB
Rust
404 lines
12 KiB
Rust
//! 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
|
||
})
|
||
);
|
||
|
||
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;
|
||
}
|