feat(plan): devreden borç planın parçası, borçlu ayda %45 kuralı
- Devreden borç: elle girilen ya da dönem başından önceki son ay sonu ölçümü - Borçlu ay: harcanabilir = gelir × oran (vars. %45), kalan borca, artan birikime - Birikim hedefi ve Kakeibo devri borçlu ayda uygulanmaz; kapanış devir üretmez - Plan düzenleme ve sihirbazda devreden borç alanı + oran; canlı önizleme - Plan'daki eski borç bölümü ve taksit önerisi kaldırıldı (veri yerinde) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,17 @@ async fn summary_computes_spendable_with_carryovers() {
|
||||
"carryover_surplus": 20_000,
|
||||
// 4.800.000 − 1.545.000 − 500.000 − 100.000 + 20.000
|
||||
"spendable": 2_675_000,
|
||||
// Banka borcu yok → normal Kakeibo (docs/KAKEIBO_PANEL_ISTEKLERI.md A)
|
||||
"carried_debt": 0,
|
||||
"carried_debt_source": null,
|
||||
"carried_debt_date": null,
|
||||
"carried_debt_auto": null,
|
||||
"debt_mode": false,
|
||||
"spendable_rate_bp": 4500,
|
||||
"debt_payment": 0,
|
||||
"debt_to_savings": 0,
|
||||
"debt_fixed_too_high": false,
|
||||
"closing_debt_target": null,
|
||||
"budget_total": 0,
|
||||
"unallocated": 2_675_000,
|
||||
"spent_total": 0,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Devreden borç planın parçası (docs/KAKEIBO_PANEL_ISTEKLERI.md A): %45 kuralı, kaynak
|
||||
//! (ölçüm / elle), borç bitince normal düzen, borçlu ayda Kakeibo devri yok.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn december(app: &TestApp, s: &Session) -> String {
|
||||
let (_, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": 500_000 }),
|
||||
)
|
||||
.await;
|
||||
let id = p["id"].as_str().unwrap().to_string();
|
||||
// A5 kontrol: gelir 79.509,64 · sabit 23.006,90
|
||||
for (kind, name, amount) in [
|
||||
("income", "Maaş", 7_950_964),
|
||||
("expense", "Kira", 2_000_000),
|
||||
("expense", "Taksitler", 300_690),
|
||||
] {
|
||||
app.post(
|
||||
&format!("/periods/{id}/plan-items"),
|
||||
s,
|
||||
json!({ "kind": kind, "name": name, "amount": amount }),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
async fn summary(app: &TestApp, s: &Session, pid: &str) -> Value {
|
||||
let (st, b) = app.get(&format!("/periods/{pid}/summary"), s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
b
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn debt_month_uses_45_percent_rule() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Sahip", None).await;
|
||||
let pid = december(&app, &s).await;
|
||||
|
||||
// Borç yokken normal Kakeibo: gelir − sabit − birikim
|
||||
let x = summary(&app, &s, &pid).await;
|
||||
assert_eq!(x["debt_mode"], false);
|
||||
assert_eq!(x["carried_debt_source"], Value::Null);
|
||||
assert_eq!(x["spendable"], 7_950_964 - 2_300_690 - 500_000);
|
||||
|
||||
// Kasım sonu ölçümü (toplam 57.100) → otomatik devreden borç
|
||||
app.post(
|
||||
"/debt/snapshots",
|
||||
&s,
|
||||
json!({ "date": "2026-11-30", "kmh": 3_000_000, "card_balance": 2_710_000, "note": "maaş sonrası" }),
|
||||
)
|
||||
.await;
|
||||
let x = summary(&app, &s, &pid).await;
|
||||
assert_eq!(x["debt_mode"], true);
|
||||
assert_eq!(
|
||||
(
|
||||
x["carried_debt"].as_i64(),
|
||||
x["carried_debt_source"].as_str()
|
||||
),
|
||||
(Some(5_710_000), Some("auto"))
|
||||
);
|
||||
assert_eq!(x["carried_debt_date"], "2026-11-30");
|
||||
assert_eq!(x["spendable_rate_bp"], 4500);
|
||||
assert_eq!(
|
||||
x["spendable"], 3_577_934,
|
||||
"35.779,34 — birikim hedefi girmez"
|
||||
);
|
||||
assert_eq!(x["debt_payment"], 2_072_340, "20.723,40");
|
||||
assert_eq!(x["closing_debt_target"], 3_637_660, "36.376,60");
|
||||
assert_eq!(x["debt_fixed_too_high"], false);
|
||||
assert_eq!(
|
||||
x["unallocated"], 3_577_934,
|
||||
"bütçe dağıtımı yeni harcanabilire göre"
|
||||
);
|
||||
|
||||
// Elle düzeltme → manual; 10.000 → borca 10.000, birikime 10.723,40
|
||||
let (st, _) = app
|
||||
.put(
|
||||
&format!("/periods/{pid}/opening-debt"),
|
||||
&s,
|
||||
json!({ "amount": 1_000_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
let x = summary(&app, &s, &pid).await;
|
||||
assert_eq!(x["carried_debt_source"], "manual");
|
||||
assert_eq!(
|
||||
x["carried_debt_auto"], 5_710_000,
|
||||
"ölçüm değeri elle düzeltmede de görünür"
|
||||
);
|
||||
assert_eq!(x["carried_debt_date"], "2026-11-30");
|
||||
assert_eq!(
|
||||
(x["debt_payment"].as_i64(), x["debt_to_savings"].as_i64()),
|
||||
(Some(1_000_000), Some(1_072_340))
|
||||
);
|
||||
|
||||
// Elle 0 → borç bitti, normal düzen (A6)
|
||||
app.put(
|
||||
&format!("/periods/{pid}/opening-debt"),
|
||||
&s,
|
||||
json!({ "amount": 0 }),
|
||||
)
|
||||
.await;
|
||||
let x = summary(&app, &s, &pid).await;
|
||||
assert_eq!(x["debt_mode"], false);
|
||||
assert_eq!(x["spendable"], 7_950_964 - 2_300_690 - 500_000);
|
||||
|
||||
// Oran ayarlanabilir (%10–%90)
|
||||
app.put(
|
||||
&format!("/periods/{pid}/opening-debt"),
|
||||
&s,
|
||||
json!({ "amount": null }),
|
||||
)
|
||||
.await;
|
||||
let (_, p) = app.get(&format!("/periods/{pid}"), &s).await;
|
||||
let (st, p2) = app
|
||||
.patch(
|
||||
&format!("/periods/{pid}"),
|
||||
&s,
|
||||
json!({ "spendable_rate_bp": 5000, "version": p["version"] }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{p2}");
|
||||
assert_eq!(p2["spendable_rate_bp"], 5000);
|
||||
assert_eq!(summary(&app, &s, &pid).await["spendable"], 3_975_482);
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/periods/{pid}"),
|
||||
&s,
|
||||
json!({ "spendable_rate_bp": 9500 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn net_positive_measurement_means_no_debt_and_fixed_too_high_warns() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Sahip", None).await;
|
||||
let pid = december(&app, &s).await;
|
||||
// Artı bakiye borçtan büyük → borç 0 → normal düzen
|
||||
app.post(
|
||||
"/debt/snapshots",
|
||||
&s,
|
||||
json!({ "date": "2026-11-30", "card_balance": 100_000, "positive_balance": 300_000 }),
|
||||
)
|
||||
.await;
|
||||
let x = summary(&app, &s, &pid).await;
|
||||
assert_eq!(
|
||||
(x["debt_mode"].as_bool(), x["carried_debt"].as_i64()),
|
||||
(Some(false), Some(0))
|
||||
);
|
||||
|
||||
// Sabitler çok yüksek: harcanabilir = gelir − sabit, borca 0, uyarı
|
||||
app.put(
|
||||
&format!("/periods/{pid}/opening-debt"),
|
||||
&s,
|
||||
json!({ "amount": 5_710_000 }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Büyük ödeme", "amount": 3_000_000 }),
|
||||
)
|
||||
.await;
|
||||
let x = summary(&app, &s, &pid).await;
|
||||
assert_eq!(x["debt_fixed_too_high"], true);
|
||||
assert_eq!(x["debt_payment"], 0);
|
||||
assert_eq!(x["spendable"], 7_950_964 - 5_300_690);
|
||||
assert_eq!(x["closing_debt_target"], 5_710_000);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closing_a_debt_month_creates_no_kakeibo_carryover() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Sahip", None).await;
|
||||
let (_, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01", "savings_goal": 0 }),
|
||||
)
|
||||
.await;
|
||||
let nov = p["id"].as_str().unwrap().to_string();
|
||||
app.post(
|
||||
&format!("/periods/{nov}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
|
||||
)
|
||||
.await;
|
||||
app.put(
|
||||
&format!("/periods/{nov}/opening-debt"),
|
||||
&s,
|
||||
json!({ "amount": 5_000_000 }),
|
||||
)
|
||||
.await;
|
||||
let (st, c) = app
|
||||
.post(
|
||||
&format!("/periods/{nov}/close"),
|
||||
&s,
|
||||
json!({ "to_savings": 200_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{c}");
|
||||
assert_eq!(c["to_savings"], 0, "borçlu ayda birikim aktarılmaz");
|
||||
assert!(c["carryover"].is_null(), "Kakeibo devri yok: {c}");
|
||||
let (_, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01" }),
|
||||
)
|
||||
.await;
|
||||
let x = summary(&app, &s, p["id"].as_str().unwrap()).await;
|
||||
assert_eq!(
|
||||
(
|
||||
x["carryover_debt"].as_i64(),
|
||||
x["carryover_surplus"].as_i64()
|
||||
),
|
||||
(Some(0), Some(0))
|
||||
);
|
||||
app.cleanup().await;
|
||||
}
|
||||
Reference in New Issue
Block a user