feat(bütçe): aylık/haftalık bütçe, bütçe önerisi ve dönem özeti (backend)
- /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>
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
//! 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;
|
||||
}
|
||||
Reference in New Issue
Block a user