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:
@@ -61,5 +61,8 @@ pub struct Period {
|
||||
/// Elle girilen ay başı toplam banka borcu (kuruş); yoksa son ölçümden (borç takibi)
|
||||
#[serde(default)]
|
||||
pub opening_debt: Option<i64>,
|
||||
/// Borçlu ayda harcanabilir oranı (baz puan, 4500 = %45); boşsa varsayılan
|
||||
#[serde(default)]
|
||||
pub spendable_rate_bp: Option<i32>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
@@ -72,9 +72,34 @@ pub struct SummaryDto {
|
||||
/// Önceki dönemden devreden artı bakiye
|
||||
#[ts(type = "number")]
|
||||
carryover_surplus: i64,
|
||||
/// gelir − sabit gider − birikim − borç + artı
|
||||
/// Normal ay: gelir − sabit gider − birikim − borç + artı · Borçlu ay: gelir × oran
|
||||
#[ts(type = "number")]
|
||||
spendable: i64,
|
||||
/// Devreden (banka) borcu; yoksa 0. > 0 → borçlu ay (birikim hedefi plana girmez)
|
||||
#[ts(type = "number")]
|
||||
carried_debt: i64,
|
||||
/// `auto` (ay sonu ölçümü) | `manual` (planda elle) | `null` (hiç girilmedi)
|
||||
#[ts(type = "\"auto\" | \"manual\" | null")]
|
||||
carried_debt_source: Option<String>,
|
||||
/// Son ay sonu ölçümünün tarihi (elle girilmiş olsa da); ölçüm yoksa `null`
|
||||
carried_debt_date: Option<String>,
|
||||
/// Son ay sonu ölçümünden gelen tutar (elle düzeltme kaldırılınca kullanılacak); yoksa `null`
|
||||
#[ts(type = "number | null")]
|
||||
carried_debt_auto: Option<i64>,
|
||||
debt_mode: bool,
|
||||
/// Etkin harcanabilir oranı (baz puan)
|
||||
spendable_rate_bp: i32,
|
||||
/// Bu ay borca giden
|
||||
#[ts(type = "number")]
|
||||
debt_payment: i64,
|
||||
/// Borca giden devreden borcu aşarsa birikime kalan
|
||||
#[ts(type = "number")]
|
||||
debt_to_savings: i64,
|
||||
/// Sabit giderler çok yüksek: bu ay borç azalmıyor
|
||||
debt_fixed_too_high: bool,
|
||||
/// Ay sonu hedef borç (borçlu ayda)
|
||||
#[ts(type = "number | null")]
|
||||
closing_debt_target: Option<i64>,
|
||||
/// 4 grubun aylık bütçe toplamı
|
||||
#[ts(type = "number")]
|
||||
budget_total: i64,
|
||||
@@ -194,6 +219,18 @@ pub async fn summary(
|
||||
carryover_debt: x.carryover_debt,
|
||||
carryover_surplus: x.carryover_surplus,
|
||||
spendable: x.spendable,
|
||||
carried_debt: x.debt.map_or(0, |d| d.carried),
|
||||
carried_debt_source: x.carried_source.map(str::to_string),
|
||||
carried_debt_date: x.carried_auto.as_ref().map(|a| a.1.clone()),
|
||||
carried_debt_auto: x.carried_auto.as_ref().map(|a| a.0),
|
||||
debt_mode: x.debt.is_some(),
|
||||
spendable_rate_bp: x
|
||||
.debt
|
||||
.map_or(budget::DEFAULT_SPENDABLE_RATE_BP, |d| d.rate_bp),
|
||||
debt_payment: x.debt.map_or(0, |d| d.payment),
|
||||
debt_to_savings: x.debt.map_or(0, |d| d.to_savings),
|
||||
debt_fixed_too_high: x.debt.is_some_and(|d| d.fixed_too_high),
|
||||
closing_debt_target: x.debt.map(|d| d.closing_target),
|
||||
budget_total: x.budget_total,
|
||||
unallocated: x.spendable - x.budget_total,
|
||||
spent_total: x.spent_total,
|
||||
|
||||
@@ -58,6 +58,8 @@ pub struct UpdateRequest {
|
||||
end_date: Option<String>,
|
||||
#[ts(type = "number", optional)]
|
||||
savings_goal: Option<i64>,
|
||||
/// Borçlu ayda harcanabilir oranı (baz puan; 4500 = %45)
|
||||
spendable_rate_bp: Option<i32>,
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır
|
||||
reflection_md: Option<String>,
|
||||
version: Option<i32>,
|
||||
@@ -79,6 +81,8 @@ pub struct PeriodDto {
|
||||
/// Kuruş
|
||||
#[ts(type = "number")]
|
||||
savings_goal: i64,
|
||||
/// Borçlu ayda harcanabilir oranı (baz puan); `null` → varsayılan %45
|
||||
spendable_rate_bp: Option<i32>,
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown)
|
||||
reflection_md: Option<String>,
|
||||
/// Kapanışta birikime aktarılan (kuruş); kapanmamışsa `null`
|
||||
@@ -115,6 +119,7 @@ impl From<Period> for PeriodDto {
|
||||
end_date: p.end_date,
|
||||
status: p.status,
|
||||
savings_goal: p.savings_goal,
|
||||
spendable_rate_bp: p.spendable_rate_bp,
|
||||
reflection_md: p.reflection_md,
|
||||
to_savings: p.to_savings,
|
||||
closed_at: p.closed_at.and_then(|d| d.try_to_rfc3339_string().ok()),
|
||||
@@ -194,6 +199,7 @@ pub async fn update(
|
||||
start_date: req.start_date,
|
||||
end_date: req.end_date,
|
||||
savings_goal: req.savings_goal,
|
||||
spendable_rate_bp: req.spendable_rate_bp,
|
||||
reflection_md: req.reflection_md,
|
||||
version: req.version,
|
||||
},
|
||||
|
||||
@@ -34,7 +34,58 @@ use crate::{
|
||||
/// Gruplara göre tutarlar (kuruş), `Group::ALL` sırasıyla.
|
||||
pub type GroupAmounts = [i64; 4];
|
||||
|
||||
/// Borçlu ayda varsayılan harcanabilir oranı: %45 (docs/KAKEIBO_PANEL_ISTEKLERI.md A5)
|
||||
pub const DEFAULT_SPENDABLE_RATE_BP: i32 = 4500;
|
||||
|
||||
/// Borçlu ayın planı (devreden borç > 0)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DebtPlan {
|
||||
/// Devreden borç (toplam)
|
||||
pub carried: i64,
|
||||
pub rate_bp: i32,
|
||||
/// Harcanabilir = gelir × oran (sabitler çok yüksekse gelir − sabit)
|
||||
pub spendable: i64,
|
||||
/// Bu ay borca giden = gelir − sabit − harcanabilir (en fazla devreden borç kadar)
|
||||
pub payment: i64,
|
||||
/// Borca giden devreden borçtan büyükse artan kısım birikime
|
||||
pub to_savings: i64,
|
||||
/// Gelir − sabit < gelir × oran → bu ay borç azalmıyor
|
||||
pub fixed_too_high: bool,
|
||||
/// Ay sonu hedef borç = devreden − borca giden
|
||||
pub closing_target: i64,
|
||||
}
|
||||
|
||||
/// A5: Harcanabilir = gelir × oran; borca giden = gelir − sabit − harcanabilir.
|
||||
pub fn debt_plan(income: i64, fixed: i64, carried: i64, rate_bp: i32) -> DebtPlan {
|
||||
// Kuruşa yarım yukarı yuvarlama (gelir ≥ 0)
|
||||
let by_rate = (i128::from(income.max(0)) * i128::from(rate_bp) + 5_000) / 10_000;
|
||||
let by_rate = by_rate as i64;
|
||||
let after_fixed = income - fixed;
|
||||
let (spendable, raw, fixed_too_high) = if after_fixed < by_rate {
|
||||
(after_fixed, 0, true)
|
||||
} else {
|
||||
(by_rate, after_fixed - by_rate, false)
|
||||
};
|
||||
let payment = raw.min(carried);
|
||||
DebtPlan {
|
||||
carried,
|
||||
rate_bp,
|
||||
spendable,
|
||||
payment,
|
||||
to_savings: raw - payment,
|
||||
fixed_too_high,
|
||||
closing_target: carried - payment,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Summary {
|
||||
/// Borçlu ay (devreden borç > 0): harcanabilir %45 kuralıyla; birikim hedefi ve
|
||||
/// Kakeibo devirleri hesaba girmez. `None` → normal Kakeibo düzeni.
|
||||
pub debt: Option<DebtPlan>,
|
||||
/// Devreden borcun kaynağı: `auto` (ay sonu ölçümü) | `manual` (planda elle); yoksa `None`
|
||||
pub carried_source: Option<&'static str>,
|
||||
/// Son ay sonu ölçümü (toplam ≥ 0, tarih) — elle girilmiş olsa da
|
||||
pub carried_auto: Option<(i64, String)>,
|
||||
pub income_total: i64,
|
||||
pub fixed_expense_total: i64,
|
||||
pub savings_goal: i64,
|
||||
@@ -139,8 +190,21 @@ pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppR
|
||||
.await?
|
||||
.iter()
|
||||
.sum();
|
||||
let spendable =
|
||||
income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus;
|
||||
let carried = crate::services::debt::carried_for(db, household_id, &p).await?;
|
||||
let debt = carried.as_ref().filter(|c| c.amount > 0).map(|c| {
|
||||
debt_plan(
|
||||
income_total,
|
||||
fixed_expense_total,
|
||||
c.amount,
|
||||
p.spendable_rate_bp.unwrap_or(DEFAULT_SPENDABLE_RATE_BP),
|
||||
)
|
||||
});
|
||||
let spendable = match &debt {
|
||||
Some(d) => d.spendable,
|
||||
None => {
|
||||
income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus
|
||||
}
|
||||
};
|
||||
let by_group = sum_by(
|
||||
db,
|
||||
col::ENTRIES,
|
||||
@@ -153,6 +217,9 @@ pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppR
|
||||
let spent_total = spent_by_group.iter().sum();
|
||||
|
||||
Ok(Summary {
|
||||
debt,
|
||||
carried_source: carried.as_ref().map(|c| c.source),
|
||||
carried_auto: carried.and_then(|c| c.auto),
|
||||
income_total,
|
||||
fixed_expense_total,
|
||||
savings_goal: p.savings_goal,
|
||||
@@ -579,3 +646,14 @@ async fn previous_closed(
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod debt_plan_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn debt_month_matches_the_request_document() {
|
||||
let d = debt_plan(7_950_964, 2_300_690, 5_710_000, 4500);
|
||||
assert_eq!(d.spendable, 3_577_934);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +101,15 @@ pub async fn close(
|
||||
if version.is_some_and(|v| v != p.version) {
|
||||
return Err(period::stale());
|
||||
}
|
||||
let realized = review::review(db, household_id, period_id)
|
||||
.await?
|
||||
.realized_savings;
|
||||
let (to_savings, carry) = settle(realized, to_savings)?;
|
||||
let r = review::review(db, household_id, period_id).await?;
|
||||
let realized = r.realized_savings;
|
||||
// Borçlu ay: Kakeibo'nun kendi devri üretilmez (açık zaten banka borcunda; çifte sayım
|
||||
// olmasın) ve birikim aktarılmaz — borcun ay sonu hali bir sonraki dönemin devreden borcu olur.
|
||||
let (to_savings, carry) = if r.summary.debt.is_some() {
|
||||
(0, None)
|
||||
} else {
|
||||
settle(realized, to_savings)?
|
||||
};
|
||||
let next = next_period(db, household_id, &p).await?;
|
||||
// Yeniden kapatma: birikime eklenmiş kayıt varsa yeni tutara uyarlanır (önce denetlenir)
|
||||
let savings_sync =
|
||||
|
||||
@@ -573,6 +573,48 @@ pub async fn summary(db: &Database, hid: Uuid, today: &str) -> AppResult<Summary
|
||||
))
|
||||
}
|
||||
|
||||
// ---------- Devreden borç (aylık plan, docs/KAKEIBO_PANEL_ISTEKLERI.md A) ----------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Carried {
|
||||
/// ≥ 0 (ay sonu net artıdaysa borç 0)
|
||||
pub amount: i64,
|
||||
/// `manual` (planda elle) | `auto` (ay sonu ölçümü)
|
||||
pub source: &'static str,
|
||||
/// Son ay sonu ölçümünün tarihi ve toplamı (≥ 0) — elle girilmiş olsa da (arayüz "otomatiğe dön" için)
|
||||
pub auto: Option<(i64, String)>,
|
||||
}
|
||||
|
||||
/// Dönemin devreden borcu: elle girilmişse o (0 dahil → borç bitti), değilse dönem başından
|
||||
/// önceki (≤ başlangıç günü) son ay sonu ölçümünün toplamı. İkisi de yoksa `None`.
|
||||
pub async fn carried_for(
|
||||
db: &Database,
|
||||
hid: Uuid,
|
||||
p: &crate::models::period::Period,
|
||||
) -> AppResult<Option<Carried>> {
|
||||
let mut f = scope(hid);
|
||||
f.insert("date", doc! { "$lte": &p.start_date });
|
||||
let s = db
|
||||
.collection::<Snapshot>(col::DEBT_SNAPSHOTS)
|
||||
.find_one(f)
|
||||
.sort(doc! { "date": -1, "_id": -1 })
|
||||
.await?;
|
||||
let auto = s.map(|s| (s.total().max(0), s.date.clone()));
|
||||
Ok(match (p.opening_debt, auto) {
|
||||
(Some(v), auto) => Some(Carried {
|
||||
amount: v.max(0),
|
||||
source: "manual",
|
||||
auto,
|
||||
}),
|
||||
(None, Some(a)) => Some(Carried {
|
||||
amount: a.0,
|
||||
source: "auto",
|
||||
auto: Some(a),
|
||||
}),
|
||||
(None, None) => None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Dönem (aylık plan) ----------
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1006,6 +1048,7 @@ mod tests {
|
||||
to_savings: None,
|
||||
closed_at: None,
|
||||
opening_debt: None,
|
||||
spendable_rate_bp: None,
|
||||
version: 1,
|
||||
};
|
||||
let snaps = vec![
|
||||
|
||||
@@ -41,6 +41,8 @@ pub struct PeriodChanges {
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
pub savings_goal: Option<i64>,
|
||||
/// Borçlu ayda harcanabilir oranı (baz puan, 1000–9000)
|
||||
pub spendable_rate_bp: Option<i32>,
|
||||
/// `Some("")` → metni kaldır
|
||||
pub reflection_md: Option<String>,
|
||||
pub version: Option<i32>,
|
||||
@@ -208,6 +210,14 @@ pub async fn update(
|
||||
if let Some(name) = ch.name {
|
||||
set.insert("name", clean_name(Some(&name), start, end)?);
|
||||
}
|
||||
if let Some(bp) = ch.spendable_rate_bp {
|
||||
if !(1000..=9000).contains(&bp) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Harcanabilir oranı %10 ile %90 arasında olmalı".into(),
|
||||
));
|
||||
}
|
||||
set.insert("spendable_rate_bp", bp);
|
||||
}
|
||||
if let Some(goal) = ch.savings_goal {
|
||||
set.insert("savings_goal", validate_amount(goal)?);
|
||||
}
|
||||
|
||||
@@ -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