From f0d285a9babcec31ce659239f8a4c8226e95bb8e Mon Sep 17 00:00:00 2001 From: suphicakir Date: Sat, 14 Nov 2026 06:27:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(plan):=20devreden=20bor=C3=A7=20plan=C4=B1?= =?UTF-8?q?n=20par=C3=A7as=C4=B1,=20bor=C3=A7lu=20ayda=20%45=20kural=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 16 + CLAUDE.md | 2 +- backend/src/models/period.rs | 3 + backend/src/routes/budget.rs | 39 ++- backend/src/routes/period.rs | 6 + backend/src/services/budget.rs | 82 ++++- backend/src/services/closing.rs | 13 +- backend/src/services/debt.rs | 43 +++ backend/src/services/period.rs | 10 + backend/tests/budget.rs | 11 + backend/tests/debt_plan.rs | 234 +++++++++++++++ docs/BORC_TAKIBI.md | 2 + docs/KAKEIBO_PANEL_ISTEKLERI.md | 194 ++++++++++++ docs/MEMORY.md | 1 + frontend/src/features/debt/period-debt.tsx | 283 ------------------ .../src/features/period-wizard/schemas.ts | 5 +- .../features/period-wizard/step-budget.tsx | 4 +- .../features/period-wizard/step-savings.tsx | 119 ++++++-- .../features/planning/carried-debt-field.tsx | 72 +++++ frontend/src/features/planning/page.tsx | 7 +- frontend/src/features/planning/plan-edit.tsx | 92 ++++-- frontend/src/features/planning/schema.ts | 7 + .../src/features/planning/summary-tiles.tsx | 127 +++++--- frontend/src/lib/debt-plan.test.ts | 116 +++++++ frontend/src/lib/debt-plan.ts | 130 ++++++++ frontend/src/lib/debt.test.ts | 14 - frontend/src/lib/debt.ts | 18 -- frontend/src/lib/periods.ts | 6 +- frontend/src/lib/plan-draft.test.ts | 23 ++ frontend/src/lib/plan-draft.ts | 49 ++- frontend/src/lib/strings.ts | 64 ++-- frontend/src/types/Period.ts | 4 + frontend/src/types/PeriodSummary.ts | 38 ++- frontend/src/types/PeriodUpdateRequest.ts | 4 + 34 files changed, 1376 insertions(+), 462 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 backend/tests/debt_plan.rs create mode 100644 docs/KAKEIBO_PANEL_ISTEKLERI.md delete mode 100644 frontend/src/features/debt/period-debt.tsx create mode 100644 frontend/src/features/planning/carried-debt-field.tsx create mode 100644 frontend/src/lib/debt-plan.test.ts create mode 100644 frontend/src/lib/debt-plan.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..77d40a2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Değişiklik günlüğü + +Biçim: [Keep a Changelog](https://keepachangelog.com/tr/1.1.0/). Tarihler GG.AA.YYYY. + +## Yayınlanmamış + +### Eklendi +- Aylık planda **Devreden borç (KMH + kart)**: son ay sonu ölçümünden otomatik gelir, planda ve yeni dönem sihirbazında elle düzeltilebilir ("elle düzeltildi", "Ölçüme dön"). Ölçüm yoksa elle yazılır. +- Borçlu ay düzeni: harcanabilir = gelirin %45'i (dönem başına %10–%90 ayarlanabilir); kalan önce borca gider, borcu aşan kısım birikime. Özet: Gelir − Sabit giderler − Bu ay borca giden = Harcanabilir; altında "Devreden borç · ay sonu hedef". Sabitler çok yüksekse kırmızı uyarı. + +### Değişti +- Borç takibi aylık planın parçası oldu (docs/KAKEIBO_PANEL_ISTEKLERI.md). Borçlu ayda birikim hedefi ve Kakeibo'nun kendi devri (önceki ay aşımı/artısı) uygulanmaz; dönem kapanışında devir ve birikim aktarımı üretilmez. Devreden borç 0 olunca normal Kakeibo düzeni döner. +- Kakeibo'nun bütçe aşımı devri artık "Önceki ay aşımı" adıyla gösterilir (banka borcuyla karışmasın). + +### Kaldırıldı +- Plan'daki "Bu ayın borcu" bölümü ve "Taksitler" sabit gideri önerisi (taksitler sabit giderlere elle girilir). Veriler yerinde. diff --git a/CLAUDE.md b/CLAUDE.md index 7508cbe..739dcfa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ npm run db:restore -- [--force] - Erişilebilirlik kuralları: `--muted-foreground` açık temada 0.52 (gri zeminde AA); kart bileşenleri (`Section`, rapor bölümü) `min-w-0` — ızgara öğesi içeriğine uzayıp telefonda taşmasın; `Button` `sm`/`icon-sm` dokunmatikte (`pointer-coarse`) 44 px; tutarlar `formatMoney` ile **bölünmez boşluklu** (`1.250,00\u00a0₺`; testlerde `\u00a0`); bütçe çubuğu `budgetLevel` (≥ %80 turuncu + "Bütçe kullanımı %N", aşım kırmızı); `Markdown shiftHeadings` (sayfada tek h1). Cümlelerde sayıdan sonra ek yok. - PWA: `vite.config.ts` `VitePWA` (registerType `prompt`, `/api` asla önbelleğe alınmaz, `navigateFallbackDenylist /api`, xlsx + yabancı font alt kümeleri ön önbellekte yok); `lib/pwa.tsx` (`PwaUpdater` "Yeni sürüm hazır → Yenile", saatlik yoklama; `OfflineBanner`), `lib/online.ts`. `main.tsx` `onlineManager.setOnline(navigator.onLine)`. Oturum sorgusu ağ hatası/duraklama → `RequireAuth` giriş sayfasına **atmaz**, "Tekrar dene" gösterir. Geliştirmede SW yok. DEPLOY §7. - Giriş güvenliği: `auth/guard.rs` (IP başına üst üste 3 hata → giriş+kayıt 15 dk kilit, bellek içi; e-postaya göre kilit YOK), `auth/captcha.rs` (Cloudflare Turnstile; anahtar yoksa kapalı; testte `Captcha::Fixed`), `auth/client_ip.rs` (`TRUST_PROXY` → en sağdaki X-Forwarded-For). Hata kodları `locked` (429 + Retry-After), `captcha` (400), `invalid_credentials` (kalan hak mesajda). `GET /auth/guard`. Arayüz `features/auth/guard.tsx` (`useAuthSecurity`), `lock-notice.tsx`, `components/turnstile.tsx`. Testler `TestApp::with_captcha`, `send_with` (X-Forwarded-For). DEPLOY §8. -- Borç takibi (docs/BORC_TAKIBI.md): `services/debt.rs` (saf: `total`, `installment_load`, `remaining_after`, `summarize`, `period_debt`; içe aktarma `parse_import` kaynak belge JSON + `asOf`, TL → kuruş `tl_to_kurus` kayan noktasız), `routes/debt.rs` (`/debt*`, `/periods/{id}/debt`, `/periods/{id}/opening-debt`). Koleksiyonlar `debt_snapshots`, `installment_plans` (`first_month`), `debt_targets`; toplam saklanmaz. Kakeibo `carryovers` ile karıştırma. Kişisel seed verisi repoya girmez. Arayüz `features/debt/` (`debt-card.tsx` Panel en üstü + /borc, `debt-chart.tsx` lazy Recharts, `snapshot-form.tsx` kalan taksit önerisi `remainingAfter`, `history.tsx`, `targets-editor.tsx`, `import-section.tsx`, `installments-page.tsx`), `lib/debt.ts` (saf hesaplar backend ile aynı). Adresler `/borc`, `/taksitler`; menüde "Borç" (masaüstü + Diğer). Plan: `features/debt/period-debt.tsx` (`PeriodDebtSection`: ay başı [elle düzelt], devreden banka borcu, borç ödemesi, taksit, hedef ay sonu + yol haritası, dönem içi ilerleme, "Taksitler" sabit gideri önerisi `findInstallmentItem`); borç > 0 iken `SummaryTiles debtPayment` → "Borç ödemesi" (Plan, düzenleme, sihirbaz). Kakeibo'nun "Devreden borç"u ile karışmasın diye banka borcu "Devreden banka borcu". +- Borç takibi (docs/BORC_TAKIBI.md): `services/debt.rs` (saf: `total`, `installment_load`, `remaining_after`, `summarize`, `period_debt`; içe aktarma `parse_import` kaynak belge JSON + `asOf`, TL → kuruş `tl_to_kurus` kayan noktasız), `routes/debt.rs` (`/debt*`, `/periods/{id}/debt`, `/periods/{id}/opening-debt`). Koleksiyonlar `debt_snapshots`, `installment_plans` (`first_month`), `debt_targets`; toplam saklanmaz. Kakeibo `carryovers` ile karıştırma. Kişisel seed verisi repoya girmez. Arayüz `features/debt/` (`debt-card.tsx` Panel en üstü + /borc, `debt-chart.tsx` lazy Recharts, `snapshot-form.tsx` kalan taksit önerisi `remainingAfter`, `history.tsx`, `targets-editor.tsx`, `import-section.tsx`, `installments-page.tsx`), `lib/debt.ts` (saf hesaplar backend ile aynı). Adresler `/borc`, `/taksitler`; menüde "Borç" (masaüstü + Diğer). Plan (docs/KAKEIBO_PANEL_ISTEKLERI.md A): devreden borç = `periods.opening_debt` (elle) yoksa dönem başından önceki son ölçüm (`debt::carried_for`); > 0 → borçlu ay: `budget::debt_plan` (harcanabilir = gelir × `spendable_rate_bp` [vars. 4500], borca giden = min(kalan, borç), artan → birikim; birikim hedefi ve Kakeibo devirleri yok sayılır, kapanış devir/birikim üretmez). Frontend aynası `lib/debt-plan.ts` (`debtPlan`, `previewSummary`), alan `features/planning/carried-debt-field.tsx` (plan düzenleme + sihirbaz 4. adım), `SummaryTiles` `summary.debt_mode`. Kakeibo devri "Önceki ay aşımı". - Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404. ## Ortam diff --git a/backend/src/models/period.rs b/backend/src/models/period.rs index f4259b9..e9f9551 100644 --- a/backend/src/models/period.rs +++ b/backend/src/models/period.rs @@ -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, + /// Borçlu ayda harcanabilir oranı (baz puan, 4500 = %45); boşsa varsayılan + #[serde(default)] + pub spendable_rate_bp: Option, pub version: i32, } diff --git a/backend/src/routes/budget.rs b/backend/src/routes/budget.rs index d396501..89af5b2 100644 --- a/backend/src/routes/budget.rs +++ b/backend/src/routes/budget.rs @@ -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, + /// Son ay sonu ölçümünün tarihi (elle girilmiş olsa da); ölçüm yoksa `null` + carried_debt_date: Option, + /// 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, + 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, /// 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, diff --git a/backend/src/routes/period.rs b/backend/src/routes/period.rs index 5d98c7a..4271bf2 100644 --- a/backend/src/routes/period.rs +++ b/backend/src/routes/period.rs @@ -58,6 +58,8 @@ pub struct UpdateRequest { end_date: Option, #[ts(type = "number", optional)] savings_goal: Option, + /// Borçlu ayda harcanabilir oranı (baz puan; 4500 = %45) + spendable_rate_bp: Option, /// "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır reflection_md: Option, version: Option, @@ -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, /// "Nasıl daha iyi yapabilirim?" (Markdown) reflection_md: Option, /// Kapanışta birikime aktarılan (kuruş); kapanmamışsa `null` @@ -115,6 +119,7 @@ impl From 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, }, diff --git a/backend/src/services/budget.rs b/backend/src/services/budget.rs index 55dbc3b..a5acc72 100644 --- a/backend/src/services/budget.rs +++ b/backend/src/services/budget.rs @@ -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, + /// 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); + } +} diff --git a/backend/src/services/closing.rs b/backend/src/services/closing.rs index 9eb301e..1e569f1 100644 --- a/backend/src/services/closing.rs +++ b/backend/src/services/closing.rs @@ -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 = diff --git a/backend/src/services/debt.rs b/backend/src/services/debt.rs index 960971c..f52260b 100644 --- a/backend/src/services/debt.rs +++ b/backend/src/services/debt.rs @@ -573,6 +573,48 @@ pub async fn summary(db: &Database, hid: Uuid, today: &str) -> AppResult, +} + +/// 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> { + let mut f = scope(hid); + f.insert("date", doc! { "$lte": &p.start_date }); + let s = db + .collection::(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![ diff --git a/backend/src/services/period.rs b/backend/src/services/period.rs index 2039990..3f0779d 100644 --- a/backend/src/services/period.rs +++ b/backend/src/services/period.rs @@ -41,6 +41,8 @@ pub struct PeriodChanges { pub start_date: Option, pub end_date: Option, pub savings_goal: Option, + /// Borçlu ayda harcanabilir oranı (baz puan, 1000–9000) + pub spendable_rate_bp: Option, /// `Some("")` → metni kaldır pub reflection_md: Option, pub version: Option, @@ -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)?); } diff --git a/backend/tests/budget.rs b/backend/tests/budget.rs index de585f6..de8fcdd 100644 --- a/backend/tests/budget.rs +++ b/backend/tests/budget.rs @@ -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, diff --git a/backend/tests/debt_plan.rs b/backend/tests/debt_plan.rs new file mode 100644 index 0000000..74bd722 --- /dev/null +++ b/backend/tests/debt_plan.rs @@ -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; +} diff --git a/docs/BORC_TAKIBI.md b/docs/BORC_TAKIBI.md index b7fce6d..57c4459 100644 --- a/docs/BORC_TAKIBI.md +++ b/docs/BORC_TAKIBI.md @@ -1,3 +1,5 @@ +> **Bu yaklaşım `docs/KAKEIBO_PANEL_ISTEKLERI.md` ile değiştirildi (14 Kasım 2026):** borç ayrı sayfada değil, aylık planın ve panelin içinde "Devreden borç" olarak izlenir. Koleksiyonlar ve veri yerinde kalır. + # Borç takibi (Kakeibo uyarlaması) Kaynak: `butcem/docs/BORC_TAKIBI.md` (kullanıcının özellik tanımı). Kişisel rakamlar (seed) bu repoya diff --git a/docs/KAKEIBO_PANEL_ISTEKLERI.md b/docs/KAKEIBO_PANEL_ISTEKLERI.md new file mode 100644 index 0000000..4d04bb5 --- /dev/null +++ b/docs/KAKEIBO_PANEL_ISTEKLERI.md @@ -0,0 +1,194 @@ +# Kakeibo panel istekleri (güncel: 14 Kasım 2026, 2. tur) + +## VS Code'daki Claude'a verilecek prompt + +Aşağıdaki metni olduğu gibi kopyalayıp yapıştırın: + +```text +docs/KAKEIBO_PANEL_ISTEKLERI.md dosyasını baştan sona oku. Bu dosya, docs/BORC_TAKIBI.md'deki +"Borç sayfası" yaklaşımının yerine geçiyor; borç artık ayrı sayfada değil, aylık planın ve panelin +içinde "Devreden borç" olarak takip edilecek. + +Kurallar: +1. Kod yazmadan önce: ilgili dosyaları bul (Planlama, Defter, Ay sonu değerlendirmesi, Panel, + Borç sayfası, nav menüsü) ve bana dosya listesiyle birlikte adım adım bir plan göster. + Onayımı bekle. +2. Mevcut verilere dokunma: veritabanı yedeği al, yalnızca alan ekle, eski kayıtları toplu + değiştirme, koleksiyon silme. Borç sayfasını kaldırırken verisini silme. +3. Bölüm sırasıyla ilerle (A → H). Her bölümden sonra test et, kısa özet ver. +4. Hesapları dosyadaki "Kontrol" rakamlarıyla birim testine dönüştür. +5. Türkçe büyük harf için toLocaleUpperCase("tr-TR") kullan; CSS text-transform kullanma. +6. Bitince npm run lint, typecheck ve testler geçsin; CHANGELOG "Yayınlanmamış" altına yaz. +``` + +> **Not (Claude, sohbetten):** Bu bilgisayardaki çalışma kopyasında (dal `faz/17-ice-aktarma`, sürüm 1.2.0, 9 Kasım) Planlama, Defter, Ay sonu ve Borç ekranları yok. Bu ekranların bulunduğu güncel sürüm üzerinde çalışılmalı. Dosyalar bulunamazsa Claude tahminle yeni ekran yazmasın, sorsun. + +--- + +## A · Devreden borç: planın parçası olsun + +### A1 · Planlama sayfasında yeni kutu +- "Birikim hedefi" kutusunun **üzerine** aynı görünümde **"Devreden borç"** kutusu eklenir. +- Aylık plana alanlar eklenir (eski alanlar değişmez, varsayılan `null`): + - `carriedDebtMinor`: devreden borç (pozitif sayı) + - `carriedDebtSource`: `"auto"` ya da `"manual"` + - `debtPaymentMinor`: bu ay borca giden tutar (hesaplanır) + - `spendableRateBp`: borçlu ayda harcanabilir oranı (varsayılan **4500** = %45) + +### A2 · İlk dönem: elle giriş +- Önceki tanımlı dönem yoksa "Devreden borç" boş ve düzenlenebilir gelir. Kullanıcı elle girer (`source = "manual"`). +- Yardım metni: "Maaş hesabının eksi bakiyesi + kartın tüm borcu (ekstre + dönem içi harcama) + kalan taksitler − hesaptaki artı bakiye." + +### A3 · Sonraki dönemler: otomatik +- Yeni dönem planı oluşturulurken devreden borç, **önceki dönemin ay sonu borcundan** otomatik dolar (`source = "auto"`). Ay sonu borcunun tanımı için bkz. **C**. +- Kullanıcı isterse elle düzeltebilir. Düzeltince `source = "manual"` olur ve kutuda "elle düzeltildi" rozeti görünür. + +### A4 · Borç varsa birikim yok, borç odaklı plan +- `carriedDebtMinor > 0` iken **birikim hedefi plana dahil edilmez** (0 sayılır). +- Planlama sayfasında "Birikim hedefi" kutusu yerine **"Devreden borç"** ve altında **"Bu ay borca giden"** gösterilir. Birikim kutusu gizlenir ya da soluk "Borç bitince açılır" yazısıyla durur. +- Panelde de aynı kutu "Devreden borç" yazar (bkz. D). + +### A5 · Harcanabilir hesabı (borçlu ay) +Devreden borcun tamamını tek ayda düşmek harcanabiliri eksiye sokar. Bu yüzden: + +``` +Harcanabilir = Toplam gelir × %45 (spendableRateBp ile ayarlanabilir) +Bu ay borca giden = Toplam gelir − Sabit giderler − Harcanabilir +``` + +Kenar durumları: +- `Bu ay borca giden` **devreden borçtan büyükse**: borca yalnızca devreden borç kadar gider, kalan tutar birikime yazılır (borç bu ay biter). +- `Gelir − Sabit < Gelir × %45` ise (sabitler çok yüksek): Harcanabilir = Gelir − Sabit, borca giden 0, kırmızı uyarı: "Sabit giderler çok yüksek, bu ay borç azalmıyor." +- Ekranda formül açıkça yazılsın: `Gelir − Sabit − Bu ay borca giden = Harcanabilir`. Altında küçük satır: "Devreden borç 57.100 · ay sonu hedef 36.376,60." + +**Kontrol (Aralık 2026 rakamlarıyla):** +- Gelir 79.509,64 · Sabit 23.006,90 · Devreden borç 57.100 +- Harcanabilir = 79.509,64 × 0,45 = **35.779,34** +- Bu ay borca giden = 79.509,64 − 23.006,90 − 35.779,34 = **20.723,40** +- Ay sonu hedef borç = 57.100 − 20.723,40 = **36.376,60** +- Kenar durumu testi: devreden borç 10.000 → borca giden 10.000, birikime 10.723,40. + +### A6 · Borç bitince +- Devreden borç 0 olduğunda (otomatik ya da elle) plan normal Kakeibo düzenine döner: `Gelir − Sabit − Birikim hedefi = Harcanabilir`. Birikim kutusu tekrar görünür, %45 kuralı devre dışı kalır. + +--- + +## B · Borçlar sayfası kaldırılsın +- Borç sayfası rotası (`/borc` ya da benzeri), menü bağlantısı ve sayfaya özel bileşenler kaldırılır. +- **Veri silinmez:** `debtSnapshots` / `installmentPlans` koleksiyonları ve modelleri yerinde kalır. Kullanılmayan servis kodu silinebilir, migration ile koleksiyon düşürülmez. +- Taksit yükü bilgisi gerekiyorsa Planlama → Sabit giderler → "Taksitler" satırında öneri olarak kalabilir. +- `docs/BORC_TAKIBI.md` dosyasının en üstüne "Bu yaklaşım KAKEIBO_PANEL_ISTEKLERI.md ile değiştirildi" notu eklenir. + +--- + +## C · Ay sonu: "Ne kadar biriktirdim?" → "Ne kadar borç devretti?" + +### Tanım (tek doğru kaynak) +``` +Ay sonu borcu = banka hesaplarının eksi bakiyeleri (KMH) + + kredi kartı tüm borcu (ekstre + dönem içi + provizyon) + + kalan taksitler + − hesaplardaki artı bakiyeler (nakit, banka, yemek kartı, toplu taşıma) +(sonuç eksiyse borç 0, aradaki fark birikimdir) +``` +Hesap, dönem bitiş tarihindeki hesap bakiyelerinden yapılır. Transferler (kart ödemesi, ATM, kendi hesapları arası havale) borcu değiştirmez. + +### Ekran +- **Borç varken** başlık: **"4 · Ne kadar borç devretti?"** + - Ana rakam: ay sonu borcu (bir sonraki dönemin devreden borcu olur). + - Altında: "Ay başı 78.530,48 → ay sonu X" ve fark. Azaldıysa yeşil "borç Y TL azaldı", arttıysa kırmızı "borç Y TL arttı". + - Plan karşılaştırması: "Hedef: Z TL borca gidecekti, gerçekleşen: Y TL". +- **Borç yokken** başlık: **"4 · Ne kadar biriktirdim?"**. Değer = ay sonu net artı − ay başı net artı. Eksiyse birikim yazılmaz, "Bu ay birikim yok" denir. +- **Plan hedefi asla gerçekleşen olarak gösterilmez.** + +### Mevcut hatanın kontrolü +Kullanıcı hiç birikim girmedi, hesap sürekli ekside, ama ekranda birikim çıkıyor. Şu dört olasılığı kontrol et: +1. Plan hedefi (C) gerçekleşen yerine yazılıyor olabilir. +2. Hesap yalnızca `gelir − gider` olarak yapılıp sabit giderler, kart borcu ya da KMH faizi düşülmüyor olabilir. +3. Kart ödemesi veya hesaplar arası havale birikim sayılıyor olabilir. +4. Eksi sonuç `Math.max(0, …)` ile sıfıra çekiliyor ya da işareti kayboluyor olabilir. + +**Kontrol (bankayla doğrulanmış):** 31 Ekim ay sonu borcu **78.530,48**, 13 Kasım **79.078,85** → "borç **548,37 TL arttı**". Birikim gösterilmez. + +--- + +## D · Panel (dashboard) + +### D1 · Plan satırı +``` +[ Toplam gelir ] − [ Sabit giderler ] − [ Devreden borç ] − [ Toplam birikim ] = [ Harcanabilir ] +``` +- "Devreden borç" kutusunun ana rakamı **bu ay borca giden** tutardır. Küçük satırda devreden borcun tamamı yazar: "toplam 57.100". Formül böylece eksiye düşmez. +- Borç varken birikim kutusu 0 ve soluk ("borç bitince"). Borç yokken devreden borç kutusu soluk/gizli. + +### D2 · Ödeme türleri satırı (anlık bakiye) +`[ Nakit ] [ Kredi kartı ] [ Banka kartı + Havale/EFT ] [ Yemek kartı ] [ Toplu taşıma kartı ]` + +| Kutu | Kaynak (hesap türü) | Ana rakam | Alt satır | +|---|---|---|---| +| Nakit | `cash` | bakiye | bu ay nakit harcama (minibüs, taksi dahil) | +| Kredi kartı | `credit_card` | **dönem içi borç** (ör. −29.713,54) | toplam borç (taksit dahil) · son ödeme · kalan limit | +| Banka kartı + Havale/EFT | `bank` | bakiye (ör. **−39.264,44**, kırmızı) | KMH kullanılan / limit · bu ay çıkış | +| Yemek kartı | `meal_card` | bakiye | bu ay harcanan | +| Toplu taşıma kartı | **yeni** `transit_card` | bakiye | bu ay yükleme / harcama | + +Kurallar: +- **Ödeme türü = işlemin hesabının türü.** Defter kodları: N `cash`, KK `credit_card`, BK `bank`, YK `meal_card`, TT `transit_card`. +- **Havale/EFT** banka hesabından çıktığı için "Banka kartı" kutusunda gider sayılır. Kendi hesapları arası havale (maaş → Enpara, kızın ek hesabı) transferdir. +- **Kart ödemesi ve ATM çekimi transferdir**, hiçbir kutuda gider değildir. +- **İstanbulkart:** yükleme, banka/nakit hesabından `transit_card` hesabına transfer. Binişler ulaşım gideri (ya da ay sonunda tek düzeltme kaydı). İçe aktarmada "İSTANBULKART YUKLEME" yeni aktarmalarda bu hesaba transfer olarak eşlensin; eski kayıtlar değişmesin. +- `transit_card` için: `shared/accounts.ts` → `ACCOUNT_TYPES`, hesap formu, `tr.ts` ("Toplu taşıma kartı" / "Toplu taşıma kartları"), ikon. Enum genişlediği için migration gerekmez. +- Her kutuda **son içe aktarma / mutabakat tarihi** küçük yazılsın. Bankayla farklıysa mevcut mutabakat akışına bağlantı. +- **Kontrol (13 Kasım):** Banka kartı **−39.264,44**, Kredi kartı **−29.713,54**. 235 TL provizyon "onay bekleyen" olarak ayrı. + +### D3 · Yerleşim +- Orta alan çok dar. Ana kapsayıcının dar `max-w-*` sınırı kaldırılsın ya da genişletilsin (ör. `max-w-screen-2xl`, yanlarda `px-4 lg:px-8`). +- Kutu satırları ekran genişliğine yayılsın: + - Plan satırı: mobilde 2, tablette 3, masaüstünde 5 sütun. + - Ödeme türleri: mobilde 2, masaüstünde 5 sütun. + - Altındaki kartlar iki sütun (`lg:grid-cols-2`). +- Kutular arasındaki "−" ve "=" işaretleri masaüstünde görünsün, mobilde gizlensin. + +--- + +## E · Defter: Açıklama varsayılan büyük harf +- İşlem formu (`transaction-form.tsx`, `name="description"`) ve defter satırlarındaki açıklama alanları. +- `toLocaleUpperCase("tr-TR")`: hem yazarken (onChange) hem sunucuda (zod `.transform`). +- Mevcut kayıtlar toplu değiştirilmez. + +## F · Planlama: Sabit gelirler / Sabit giderler kutuları büyük harf +- Bu iki bölümdeki metin kutuları (kalem adı) aynı kuralla. +- Ortak yardımcı: `shared/format.ts` → `upperTr(s)`. Birim testi: `"elma market"` → `"ELMA MARKET"`, `"istanbul"` → `"İSTANBUL"`, `"ılık"` → `"ILIK"`. + +## G · Metin değişiklikleri (`tr.ts`) +- "Birikim hedefi" → borçlu ayda "Devreden borç" / "Bu ay borca giden". +- Ay sonu: "Ne kadar biriktirdim?" → borçlu ayda "Ne kadar borç devretti?". +- Panel kutusu: "Devreden borç". + +## H · Kabul kriterleri +- [ ] Planlamada "Devreden borç" kutusu birikim hedefinin üstünde. İlk dönemde elle, sonraki dönemlerde otomatik dolar, elle düzeltilebilir. +- [ ] Borç varken birikim hedefi plana girmez. Harcanabilir = gelir × %45. A5 kontrol rakamları testte birebir. +- [ ] Borç 0 olunca plan birikim düzenine döner. +- [ ] Borçlar sayfası ve menü bağlantısı yok. Verisi duruyor. +- [ ] Ay sonu "Ne kadar borç devretti?" gösteriyor. Test: 78.530,48 → 79.078,85 = "borç 548,37 TL arttı". +- [ ] Panelde plan satırı `Gelir − Sabit − Devreden borç − Birikim = Harcanabilir` ve ödeme türleri satırı var. 13 Kasım: −39.264,44 ve −29.713,54. +- [ ] Kart ödemesi, ATM ve kendi hesaplar arası havale hiçbir kutuda gider değil. +- [ ] Panel orta alanı geniş ekranda yayılıyor, mobilde taşma yok. +- [ ] Açıklama ve sabit gelir/gider adları Türkçe büyük harfle kaydediliyor. Eski kayıtlar değişmedi. +- [ ] Lint, typecheck ve testler geçiyor. CHANGELOG güncel. + +--- + +## Kakeibo uyarlaması ve kararlar (kullanıcı, 14 Kasım 2026) + +Belge butcem için yazıldı; Kakeibo karşılıkları ve kullanıcı kararları: + +1. **Panel bakiyeleri (D2):** son ay sonu ölçümünden (`debt_snapshots`) gelir; canlı banka eşleşmesi yok. + Önceki aydan devreden yoksa planlamada elle yazılır. +2. **Borçlu aylarda Kakeibo'nun kendi aşım devri (`carryovers`) üretilmez** (çifte sayım olmasın). +3. **Taksit planları için ayrı ekran yok.** Taksitler sabit giderlere elle eklenir; taksit önerileri kaldırılır, + `installment_plans` verisi yerinde kalır. +4. **%45 kuralı varsayılan:** açık dönem dahil, borç > 0 olan her dönemde. +5. Mevcut veriye dokunulmaz: yalnızca alan eklenir (`periods.spendable_rate_bp`, `payment_methods.type`), + eski kayıtlar toplu değiştirilmez; `periods.opening_debt` (B1'den) devreden borcun elle değeridir. + diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 3793d52..895c9fd 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -161,3 +161,4 @@ - **Borç takibi B3 (2026-11-14) → B1–B3 ✅:** Plan'da "Bu ayın borcu" (`PeriodDebtSection`, yalnız borç verisi varsa): ay başı (ölçümden / elle, `useSetOpeningDebt`, "Son ölçüme dön"), devreden banka borcu, borç ödemesi (C), bu ayın taksiti, hedef ay sonu + yol haritası farkı (üstünde kırmızı / altında yeşil), dönem içi son ölçüme göre azalma vs C+taksit çubuğu, "Taksitler" sabit gideri tek tık (adı `findInstallmentItem`: taksit/taksitler, tr küçük harf; tutar farklıysa uyarı). `SummaryTiles debtPayment`. E2E 18 kontrol (hedef 67.218,66 = 78.530,48 − 7.000 − 4.311,82; harcanabilir 53.000 → 48.688,18). İçe aktarma dosyası `samples/` (gitignore) — 12+ haneli sayı yok. - **Dev sunucusu düzeltmesi (2026-11-14):** Kullanıcı /borc'ta "Kayıt bulunamadı" gördü: 8080'de 00:40'tan kalma eski backend (borç kodu yok) çalışıyordu; yeniden başlatılan `npm run dev`'in cargo watch'ı portu alamıyordu (ikinci kez). Kök neden: `concurrently -k` cargo watch'ı kapatır, `cargo run`'ın çocuğu kakeibo bazen yetim kalır. Çözüm: `scripts/stop-stale-api.sh` (`dev:api` başında; 8080'i tutan süreç adı `kakeibo` ise kapatır, değilse uyarır). 8091'de denendi. +- **Panel istekleri A (2026-11-14):** docs/KAKEIBO_PANEL_ISTEKLERI.md + kararlar. `periods.spendable_rate_bp` (yalnız alan eklendi; eski kayıt `None` → %45). `debt::carried_for` (elle > son ölçüm ≤ dönem başı; elle olsa da `auto` değeri döner → "Ölçüme dön"). `budget::debt_plan` A5 rakamları birim + `tests/debt_plan.rs` (3: %45 kuralı/elle/0 → normal/oran 50 & 95 red; net artı ölçüm → borç yok; sabit yüksek uyarı; kapanışta devir ve birikim yok). Frontend `lib/debt-plan.ts` (+12 test), `CarriedDebtField`, plan taslağına `carried`/`rate` (`diffPlan` → PUT opening-debt, PATCH oran). `PeriodDebtSection` + taksit önerisi silindi (veri duruyor). E2E 16 kontrol (8091, DB silindi); bulunan: telefonda "Devreden borç · hedef" satırı `money` (nowrap) yüzünden 393 px taşıyordu → iki parça. Backend tümü, frontend 155 test. diff --git a/frontend/src/features/debt/period-debt.tsx b/frontend/src/features/debt/period-debt.tsx deleted file mode 100644 index a0bd777..0000000 --- a/frontend/src/features/debt/period-debt.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import { useState } from 'react' -import { CircleCheck, Landmark, Loader2, Pencil, Plus } from 'lucide-react' -import { Link } from 'react-router' -import { toast } from 'sonner' -import { FormError } from '@/components/form-field' -import { paths } from '@/components/layout/nav-items' -import { MoneyInput } from '@/components/money-input' -import { Button } from '@/components/ui/button' -import { findInstallmentItem, usePeriodDebt, useSetOpeningDebt } from '@/lib/debt' -import { formatDate, formatMoney, formatNumber, parseMoney } from '@/lib/format' -import { useCreatePlanItem } from '@/lib/periods' -import { t } from '@/lib/strings' -import { cn } from '@/lib/utils' -import type { Period } from '@/types/Period' -import type { PeriodDebt } from '@/types/PeriodDebt' -import type { PlanItem } from '@/types/PlanItem' - -const s = t.debt.period - -/** - * Aylık plan: bu ayın banka borcu (docs/BORC_TAKIBI.md §3). Ay başı borç son ölçümden - * (elle düzeltilebilir), devreden, borç ödemesi (= birikim hedefi), taksit yükü, hedef ay sonu. - * Borç takibinde hiç veri yoksa görünmez. - */ -export function PeriodDebtSection({ - period, - items, - writable, -}: { - period: Period - items: PlanItem[] - writable: boolean -}) { - const q = usePeriodDebt(period.id) - const d = q.data - if (!d || (d.opening_total === null && d.installment_load === 0)) return null - - return ( -
-
-

- - {s.title} -

- - {s.toDebt} - -
-

{s.hint}

- -
- - - - - -
- - - {writable && ( - - )} -
- ) -} - -function Tile({ - label, - amount, - sign, - hint, -}: { - label: string - amount: number | null - sign?: string - hint?: string -}) { - return ( -
-
- {sign && {sign} } - {label} -
-
{amount === null ? '—' : formatMoney(amount)}
- {hint &&

{hint}

} -
- ) -} - -/** Ay başı borç: kaynağı yazılı; düzeltilebilir (dönem açıkken) */ -function Opening({ period, d, writable }: { period: Period; d: PeriodDebt; writable: boolean }) { - const set = useSetOpeningDebt(period.id) - const [editing, setEditing] = useState(false) - const [value, setValue] = useState('') - const [error, setError] = useState() - const source = - d.opening_source === 'manual' - ? s.manual - : d.opening_date - ? s.fromSnapshot(formatDate(d.opening_date)) - : s.noOpening - - async function save(amount: number | null) { - setError(undefined) - try { - await set.mutateAsync(amount) - setEditing(false) - toast.success(s.openingSaved) - } catch (e) { - setError(e instanceof Error ? e.message : String(e)) - } - } - - return ( -
-
- {s.opening} - {writable && !editing && ( - - )} -
- {editing ? ( -
- - -
- - {d.opening_source === 'manual' && ( - - )} - -
-
- ) : ( - <> -
- {d.opening_total === null ? '—' : formatMoney(d.opening_total)} -
-

{source}

- - )} -
- ) -} - -/** Hedef ay sonu borç (+ yol haritasıyla karşılaştırma) */ -function Target({ d }: { d: PeriodDebt }) { - const diff = - d.closing_target !== null && d.roadmap_target !== null - ? d.closing_target - d.roadmap_target - : null - return ( -
-
- = - {s.target} -
-
- {d.closing_target === null ? '—' : formatMoney(d.closing_target)} -
- {d.roadmap_target !== null && ( -

- {s.roadmap(formatMoney(d.roadmap_target))} - {diff !== null && diff !== 0 && ( - 0 ? 'text-expense' : 'text-income'}> - {' · '} - {diff > 0 - ? s.vsRoadmapAbove(formatMoney(diff)) - : s.vsRoadmapBelow(formatMoney(-diff))} - - )} -

- )} -
- ) -} - -/** Aylık başarı: ay başı − son ölçüm ≥ borç ödemesi + taksit */ -function Progress({ d }: { d: PeriodDebt }) { - if (d.opening_total === null || d.latest_total === null || !d.latest_date) return null - const down = d.opening_total - d.latest_total - const needed = d.payment + d.installment_load - const ok = down >= needed && needed > 0 - const ratio = needed > 0 ? Math.min(Math.max(down / needed, 0), 1) : 0 - return ( -
-

{s.progressTitle(formatDate(d.latest_date))}

-

- {ok && } - {down >= 0 ? s.paidDown(formatMoney(down)) : s.grew(formatMoney(-down))} - - {' '} - · {s.needed(formatMoney(needed))} - -

-
-
-
-
- ) -} - -/** Taksit yükü planda "Taksitler" sabit gideri olarak yoksa: tek tıkla ekle */ -function InstallmentsSuggestion({ - period, - items, - load, -}: { - period: Period - items: PlanItem[] - load: number -}) { - const create = useCreatePlanItem(period.id) - if (load <= 0) return null - const existing = findInstallmentItem(items) - if (existing) { - if (existing.amount === load) return null - return ( -

- {s.installmentsMismatch(formatMoney(existing.amount), formatMoney(load))} -

- ) - } - return ( - - ) -} diff --git a/frontend/src/features/period-wizard/schemas.ts b/frontend/src/features/period-wizard/schemas.ts index 6681f5c..c81608f 100644 --- a/frontend/src/features/period-wizard/schemas.ts +++ b/frontend/src/features/period-wizard/schemas.ts @@ -42,7 +42,10 @@ export function itemSchema(start: string, last: string) { export type ItemForm = z.infer> /** 4. adım */ -export const savingsSchema = z.object({ goal: moneyText('optional') }) +export const savingsSchema = z.object({ + goal: moneyText('optional'), + carried: moneyText('optional'), +}) export type SavingsForm = z.infer /** 5. adım: 4 grubun aylık bütçesi */ diff --git a/frontend/src/features/period-wizard/step-budget.tsx b/frontend/src/features/period-wizard/step-budget.tsx index 9998585..aa708c4 100644 --- a/frontend/src/features/period-wizard/step-budget.tsx +++ b/frontend/src/features/period-wizard/step-budget.tsx @@ -13,7 +13,6 @@ import { useHousehold } from '@/lib/household' import { toKurus } from '@/lib/money-schema' import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods' import { moneyToText } from '@/lib/plan-draft' -import { useDebt } from '@/lib/debt' import { t } from '@/lib/strings' import { cn } from '@/lib/utils' import type { GroupAmounts } from '@/types/GroupAmounts' @@ -88,7 +87,6 @@ function BudgetStepForm({ onBack: () => void onDone: () => void }) { - const debt = useDebt() const setBudget = useSetBudget(period.id) const { control, @@ -124,7 +122,7 @@ function BudgetStepForm({ return (
- 0} /> +
reset(toForm(a))} /> diff --git a/frontend/src/features/period-wizard/step-savings.tsx b/frontend/src/features/period-wizard/step-savings.tsx index 80ec690..2ba0c93 100644 --- a/frontend/src/features/period-wizard/step-savings.tsx +++ b/frontend/src/features/period-wizard/step-savings.tsx @@ -3,19 +3,33 @@ import { Controller, useForm, useWatch } from 'react-hook-form' import { FormError, FormField } from '@/components/form-field' import { MoneyInput } from '@/components/money-input' import { PageError, PageLoading } from '@/components/page' +import { CarriedDebtField } from '@/features/planning/carried-debt-field' import { ApiError } from '@/lib/api' +import { + carriedText, + DEFAULT_SPENDABLE_RATE_BP, + effectiveCarried, + formatRate, + parseCarried, + previewSummary, +} from '@/lib/debt-plan' +import { useSetOpeningDebt } from '@/lib/debt' import { formatMoney, formatNumber, parseMoney } from '@/lib/format' import { toKurus } from '@/lib/money-schema' import { useSummary, useUpdatePeriod } from '@/lib/periods' import { t } from '@/lib/strings' import { cn } from '@/lib/utils' import type { Period } from '@/types/Period' +import type { PeriodSummary } from '@/types/PeriodSummary' import { savingsSchema, type SavingsForm } from './schemas' import { StepActions, StepIntro } from './wizard-ui' const s = t.wizard.savings -/** 4. adım: birikim hedefi (harcamadan önce ayrılır). */ +/** + * 4. adım: devreden borç + birikim hedefi (harcamadan önce ayrılır). + * Devreden borç > 0 → borçlu ay: harcanabilir gelirin %45'i, kalan borca; birikim kapalı. + */ export function StepSavings({ period, onBack, @@ -26,7 +40,25 @@ export function StepSavings({ onNext: () => void }) { const summary = useSummary(period.id) + if (summary.isPending) return + if (summary.isError) + return + return +} + +function SavingsForm({ + period, + summary, + onBack, + onNext, +}: { + period: Period + summary: PeriodSummary + onBack: () => void + onNext: () => void +}) { const update = useUpdatePeriod() + const setCarried = useSetOpeningDebt(period.id) const { control, @@ -36,23 +68,32 @@ export function StepSavings({ } = useForm({ resolver: zodResolver(savingsSchema), mode: 'onTouched', - defaultValues: { goal: period.savings_goal ? formatNumber(period.savings_goal / 100) : '' }, + defaultValues: { + goal: period.savings_goal ? formatNumber(period.savings_goal / 100) : '', + carried: carriedText(summary), + }, }) const goalText = useWatch({ control, name: 'goal' }) + const carriedValue = useWatch({ control, name: 'carried' }) - if (summary.isPending) return - if (summary.isError) - return - - // Birikimden önceki kalan = harcanabilir + (kayıtlı) birikim hedefi → formül yalnızca backend'de - const available = summary.data.spendable + summary.data.savings_goal - const goal = parseMoney(goalText) ?? 0 - const after = available - Math.max(goal, 0) + const preview = previewSummary(summary, { + income: summary.income_total, + fixed: summary.fixed_expense_total, + savings: Math.max(parseMoney(goalText) ?? 0, 0), + carried: effectiveCarried(carriedValue, summary), + rateBp: period.spendable_rate_bp ?? DEFAULT_SPENDABLE_RATE_BP, + budget: summary.budget_total, + }) + const available = summary.income_total - summary.fixed_expense_total + const after = preview.spendable const onSubmit = handleSubmit(async (v) => { const kurus = toKurus(v.goal) + const carried = parseCarried(v.carried) + const manual = summary.carried_debt_source === 'manual' ? summary.carried_debt : null try { - if (kurus !== period.savings_goal) { + if (carried !== manual) await setCarried.mutateAsync(carried) + if (!preview.debt_mode && kurus !== period.savings_goal) { await update.mutateAsync({ id: period.id, version: period.version, savings_goal: kurus }) } onNext() @@ -67,18 +108,54 @@ export function StepSavings({
- - {(f) => ( - ( - - )} + ( + )} - - + /> + {preview.debt_mode ? ( + <> + +

+ {s.goal}: {t.carried.savingsLocked} +

+ + ) : ( + + {(f) => ( + ( + + )} + /> + )} + + )} + + {preview.debt_fixed_too_high && ( +

+ {t.summary.fixedTooHigh} +

+ )} {after < 0 && (

{s.negative} diff --git a/frontend/src/features/planning/carried-debt-field.tsx b/frontend/src/features/planning/carried-debt-field.tsx new file mode 100644 index 0000000..56722a6 --- /dev/null +++ b/frontend/src/features/planning/carried-debt-field.tsx @@ -0,0 +1,72 @@ +import type { FieldError } from 'react-hook-form' +import { FormField } from '@/components/form-field' +import { MoneyInput } from '@/components/money-input' +import { formatDate, formatMoney, formatNumber } from '@/lib/format' +import { parseCarried } from '@/lib/debt-plan' +import { t } from '@/lib/strings' +import type { PeriodSummary } from '@/types/PeriodSummary' + +const s = t.carried + +/** + * Devreden banka borcu (KMH + kart). Boş → son ay sonu ölçümünden otomatik; yazılırsa elle + * düzeltme ("elle düzeltildi"), `0` → borç bitti. Kaydetme çağıranın formunda. + */ +export function CarriedDebtField({ + summary, + value, + onChange, + onBlur, + name, + error, +}: { + summary: PeriodSummary + value: string + onChange: (v: string) => void + onBlur?: () => void + name?: string + error?: FieldError +}) { + const manual = parseCarried(value) + const auto = summary.carried_debt_auto + const info = + manual === 0 + ? s.zero + : manual === null + ? auto !== null && summary.carried_debt_date + ? s.fromSnapshot(formatDate(summary.carried_debt_date), formatMoney(auto)) + : s.noSnapshot + : null + + return ( +

+ + {(f) => ( + + )} + + {manual !== null && ( + + {s.manual} + + )} + {info &&

{info}

} + {manual !== null && auto !== null && ( + + )} +
+ ) +} diff --git a/frontend/src/features/planning/page.tsx b/frontend/src/features/planning/page.tsx index adaa42a..6d8de97 100644 --- a/frontend/src/features/planning/page.tsx +++ b/frontend/src/features/planning/page.tsx @@ -4,9 +4,7 @@ import { PageError, PageHeader, PageLoading } from '@/components/page' import { Button } from '@/components/ui/button' import { NotesSection } from '@/features/notes/notes-section' import { CopyPrevious } from '@/features/period-wizard/step-items' -import { PeriodDebtSection } from '@/features/debt/period-debt' import { PeriodPicker } from '@/features/periods/period-picker' -import { useDebt } from '@/lib/debt' import { SelectedPeriod } from '@/features/periods/selected-period' import { isWritable } from '@/lib/period-select' import { useBudget, usePlanItems, useSummary } from '@/lib/periods' @@ -34,8 +32,6 @@ function PlanView({ period }: { period: Period }) { const items = usePlanItems(period.id) const summary = useSummary(period.id) const budget = useBudget(period.id) - const debt = useDebt() - const inDebt = (debt.data?.latest?.total ?? 0) > 0 const [editing, setEditing] = useState(false) const failed = [items, summary, budget].find((q) => q.isError) @@ -71,8 +67,7 @@ function PlanView({ period }: { period: Period }) { ) } /> - - +
i.kind === 'income')} /> i.kind === 'expense')} /> diff --git a/frontend/src/features/planning/plan-edit.tsx b/frontend/src/features/planning/plan-edit.tsx index 368a42f..1b09b60 100644 --- a/frontend/src/features/planning/plan-edit.tsx +++ b/frontend/src/features/planning/plan-edit.tsx @@ -23,7 +23,12 @@ import { formatDayMonth, formatMoney, formatNumber, parseMoney } from '@/lib/for import { group, GROUPS } from '@/lib/groups' import { useSavePlan } from '@/lib/periods' import { diffPlan, GROUP_CODES, hasChanges, moneyToText, toDraft } from '@/lib/plan-draft' -import { useDebt } from '@/lib/debt' +import { + DEFAULT_SPENDABLE_RATE_BP, + effectiveCarried, + parseRate, + previewSummary, +} from '@/lib/debt-plan' import { t } from '@/lib/strings' import { cn } from '@/lib/utils' import type { GroupBudget } from '@/types/GroupBudget' @@ -35,6 +40,7 @@ import { DistributionBar } from './distribution-bar' import { PeriodHeading, Section } from './plan-read' import { planSchema, type PlanForm } from './schema' import { SuggestionPicker } from './suggestion-picker' +import { CarriedDebtField } from './carried-debt-field' import { SummaryTiles } from './summary-tiles' const s = t.plan @@ -60,7 +66,6 @@ export function PlanEdit({ summary: PeriodSummary onClose: () => void }) { - const debt = useDebt() const save = useSavePlan(period) const { control, @@ -71,29 +76,27 @@ export function PlanEdit({ } = useForm({ resolver: zodResolver(planSchema(period.start_date, period.last_date)), mode: 'onTouched', - defaultValues: toDraft(period, items, budget), + defaultValues: toDraft(period, items, budget, summary), }) const v = useWatch({ control }) // ---- Canlı önizleme: kaydedilmiş özet + taslaktaki farklar (formül backend'de kalır) const incomeTotal = (v.incomes ?? []).reduce((a, r) => a + k(r?.amount), 0) const fixedTotal = (v.expenses ?? []).reduce((a, r) => a + k(r?.amount), 0) - const savings = k(v.savings) - const spendable = - summary.spendable + - (incomeTotal - summary.income_total) - - (fixedTotal - summary.fixed_expense_total) - - (savings - summary.savings_goal) const budgetTotal = GROUP_CODES.reduce((a, g) => a + k(v.budget?.[g]), 0) - const preview: PeriodSummary = { - ...summary, - income_total: incomeTotal, - fixed_expense_total: fixedTotal, - savings_goal: savings, - spendable, - budget_total: budgetTotal, - unallocated: spendable - budgetTotal, - } + const rateBp = parseRate(v.rate) + const preview = previewSummary(summary, { + income: incomeTotal, + fixed: fixedTotal, + savings: k(v.savings), + carried: effectiveCarried(v.carried, summary), + rateBp: + rateBp !== null && rateBp >= 1000 && rateBp <= 9000 + ? rateBp + : (period.spendable_rate_bp ?? DEFAULT_SPENDABLE_RATE_BP), + budget: budgetTotal, + }) + const spendable = preview.spendable // ---- Kaydedilmemiş değişiklik koruması const blocker = useBlocker( @@ -113,7 +116,7 @@ export function PlanEdit({ }, [isDirty]) const onSubmit = handleSubmit(async (draft) => { - const ops = diffPlan(period, items, budget, draft) + const ops = diffPlan(period, items, budget, draft, summary) if (!hasChanges(ops)) { toast(s.noChanges) return onClose() @@ -148,7 +151,7 @@ export function PlanEdit({
- 0} /> +
} > - - {(f) => ( +
+
+

{t.carried.title}

+

{t.carried.hint}

} + name="carried" + render={({ field }) => ( + + )} /> + {preview.debt_mode && ( + + {(f) => ( + + )} + + )} +
+ {preview.debt_mode ? ( +
+

{s.savings}

+

{t.carried.savingsLocked}

+
+ ) : ( + + {(f) => ( + } + /> + )} + )} - +
{ + const bp = parseRate(v) + return bp !== null && bp >= 1000 && bp <= 9000 + }, t.carried.rateError), budget: z.object({ needs: money, wants: money, culture: money, unexpected: money }), weeks: z.object({ needs: z.array(money), diff --git a/frontend/src/features/planning/summary-tiles.tsx b/frontend/src/features/planning/summary-tiles.tsx index 6c6cd3c..061eb45 100644 --- a/frontend/src/features/planning/summary-tiles.tsx +++ b/frontend/src/features/planning/summary-tiles.tsx @@ -1,3 +1,5 @@ +import { TriangleAlert } from 'lucide-react' +import { formatRate } from '@/lib/debt-plan' import { formatMoney } from '@/lib/format' import { t } from '@/lib/strings' import { cn } from '@/lib/utils' @@ -6,61 +8,96 @@ import type { PeriodSummary } from '@/types/PeriodSummary' const s = t.summary /** - * Plan özeti: Gelir − Sabit giderler − Birikim − Devreden borç (+ Devreden artı) = Harcanabilir. - * Mobilde 2 sütun, geniş ekranda tek satır. Devir kutucukları yalnızca tutar varsa görünür. + * Plan özeti. + * - Normal ay: Gelir − Sabit giderler − Birikim − Önceki ay aşımı (+ Devreden artı) = Harcanabilir. + * - Borçlu ay (devreden banka borcu > 0, docs/KAKEIBO_PANEL_ISTEKLERI.md A): + * Gelir − Sabit giderler − Bu ay borca giden = Harcanabilir (gelirin %45'i); birikim kapalı. + * Mobilde 2 sütun, geniş ekranda tek satır. */ -export function SummaryTiles({ - summary, - debtPayment = false, -}: { - summary: PeriodSummary - /** Borç takibinde borç varken birikim hedefi "Borç ödemesi" adıyla (docs/BORC_TAKIBI.md) */ - debtPayment?: boolean -}) { +export function SummaryTiles({ summary }: { summary: PeriodSummary }) { + const debt = summary.debt_mode const tiles: { label: string; amount: number; sign?: '−' | '+'; tone?: string }[] = [ { label: s.income, amount: summary.income_total, tone: 'text-income' }, { label: s.fixed, amount: summary.fixed_expense_total, sign: '−' }, - { label: debtPayment ? s.debtPayment : s.savings, amount: summary.savings_goal, sign: '−' }, ] - if (summary.carryover_debt > 0) { - tiles.push({ label: s.debt, amount: summary.carryover_debt, sign: '−', tone: 'text-expense' }) - } - if (summary.carryover_surplus > 0) { - tiles.push({ label: s.surplus, amount: summary.carryover_surplus, sign: '+' }) + if (debt) { + tiles.push({ label: s.debtPayment, amount: summary.debt_payment, sign: '−' }) + } else { + tiles.push({ label: s.savings, amount: summary.savings_goal, sign: '−' }) + if (summary.carryover_debt > 0) { + tiles.push({ label: s.debt, amount: summary.carryover_debt, sign: '−', tone: 'text-expense' }) + } + if (summary.carryover_surplus > 0) { + tiles.push({ label: s.surplus, amount: summary.carryover_surplus, sign: '+' }) + } } return ( -
- {tiles.map((tile) => ( -
-
- {tile.sign && {tile.sign} } - {tile.label} -
-
{formatMoney(tile.amount)}
-
- ))} -
-
- = - {s.spendable} -
-
+
+ {tiles.map((tile) => ( +
+
+ {tile.sign && {tile.sign} } + {tile.label} +
+
+ {formatMoney(tile.amount)} +
+
+ ))} +
- {formatMoney(summary.spendable)} -
-
-
+
+ = + {s.spendable} +
+
+ {formatMoney(summary.spendable)} +
+ {debt && ( +

+ {s.rateLabel(formatRate(summary.spendable_rate_bp))} +

+ )} +
+ + {debt && ( +
+

+ {s.carriedPart(formatMoney(summary.carried_debt))} + · + + {s.targetPart(formatMoney(summary.closing_debt_target ?? summary.carried_debt))} + +

+ {summary.debt_to_savings > 0 && ( +

+ {s.toSavingsLine(formatMoney(summary.debt_to_savings))} +

+ )} + {summary.debt_to_savings === 0 && ( +

{s.savingsLocked}

+ )} + {summary.debt_fixed_too_high && ( +

+ + {s.fixedTooHigh} +

+ )} +
+ )} +
) } diff --git a/frontend/src/lib/debt-plan.test.ts b/frontend/src/lib/debt-plan.test.ts new file mode 100644 index 0000000..0aa109a --- /dev/null +++ b/frontend/src/lib/debt-plan.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { + carriedText, + debtPlan, + effectiveCarried, + parseCarried, + parseRate, + previewSummary, +} from './debt-plan' + +// docs/KAKEIBO_PANEL_ISTEKLERI.md A5 "Kontrol" rakamları (backend testiyle aynı) +describe('debtPlan', () => { + it('A5: gelir 79.509,64 · sabit 23.006,90 · devreden 57.100', () => { + const p = debtPlan(7_950_964, 2_300_690, 5_710_000, 4500) + expect(p.spendable).toBe(3_577_934) + expect(p.payment).toBe(2_072_340) + expect(p.toSavings).toBe(0) + expect(p.closingTarget).toBe(3_637_660) + expect(p.fixedTooHigh).toBe(false) + }) + + it('borç küçükse artan birikime gider, hedef 0', () => { + const p = debtPlan(7_950_964, 2_300_690, 1_000_000, 4500) + expect([p.payment, p.toSavings, p.closingTarget]).toEqual([1_000_000, 1_072_340, 0]) + }) + + it('sabitler çok yüksek: harcanabilir = gelir − sabit, borca 0', () => { + const p = debtPlan(7_950_964, 5_000_000, 5_710_000, 4500) + expect([p.spendable, p.payment, p.fixedTooHigh]).toEqual([2_950_964, 0, true]) + expect(p.closingTarget).toBe(5_710_000) + }) + + it('oran değişebilir; yarım kuruş yukarı yuvarlanır', () => { + expect(debtPlan(1_000_000, 0, 1, 5000).spendable).toBe(500_000) + expect(debtPlan(3, 0, 1, 5000).spendable).toBe(2) + }) +}) + +describe('devreden borç alanı ve önizleme', () => { + const auto = { + carried_debt_source: 'auto' as const, + carried_debt: 5_710_000, + carried_debt_auto: 5_710_000, + } + const manual0 = { + carried_debt_source: 'manual' as const, + carried_debt: 0, + carried_debt_auto: 5_710_000, + } + + it('form metni: otomatik → boş, elle 0 → "0"', () => { + expect(carriedText(auto)).toBe('') + expect(carriedText(manual0)).toBe('0') + expect(parseCarried('')).toBeNull() + expect(parseCarried('0')).toBe(0) + expect(parseCarried('10.000')).toBe(1_000_000) + }) + + it('boş alan son ölçüme döner; elle değer önce gelir', () => { + expect(effectiveCarried('', manual0)).toBe(5_710_000) + expect(effectiveCarried('0', auto)).toBe(0) + expect( + effectiveCarried('', { ...auto, carried_debt_source: null, carried_debt_auto: null }), + ).toBe(0) + }) + + it('oran metni', () => { + expect(parseRate('45')).toBe(4500) + expect(parseRate('45,5')).toBe(4550) + expect(parseRate('')).toBeNull() + expect(parseRate('abc')).toBeNull() + }) + + it('önizleme: borçlu ay %45, borç bitince normal Kakeibo', () => { + const saved = { + income_total: 0, + fixed_expense_total: 0, + savings_goal: 500_000, + carryover_debt: 100_000, + carryover_surplus: 0, + spendable: 0, + 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: 0, + spent_total: 1_000, + remaining: 0, + } + const d = { + income: 7_950_964, + fixed: 2_300_690, + savings: 500_000, + rateBp: 4500, + budget: 3_000_000, + } + const debt = previewSummary(saved, { ...d, carried: 5_710_000 }) + expect(debt.debt_mode).toBe(true) + expect([debt.spendable, debt.debt_payment, debt.closing_debt_target]).toEqual([ + 3_577_934, 2_072_340, 3_637_660, + ]) + expect(debt.unallocated).toBe(577_934) + expect(debt.remaining).toBe(3_576_934) + const normal = previewSummary(saved, { ...d, carried: 0 }) + expect(normal.debt_mode).toBe(false) + // gelir − sabit − birikim − önceki ay aşımı + expect(normal.spendable).toBe(7_950_964 - 2_300_690 - 500_000 - 100_000) + }) +}) diff --git a/frontend/src/lib/debt-plan.ts b/frontend/src/lib/debt-plan.ts new file mode 100644 index 0000000..d0d9650 --- /dev/null +++ b/frontend/src/lib/debt-plan.ts @@ -0,0 +1,130 @@ +/** + * Borçlu ayın planı (docs/KAKEIBO_PANEL_ISTEKLERI.md A). Backend `services/budget.rs` + * `debt_plan` ile birebir aynı; asıl değer backend'den gelir, bu yalnızca düzenleme önizlemesi. + * + * harcanabilir = gelir × oran (varsayılan %45, yarım yukarı yuvarlanır) + * borca giden = min(gelir − sabit − harcanabilir, devreden borç); artan → birikim + * gelir − sabit < gelir × oran → harcanabilir = gelir − sabit, borca 0, uyarı + */ +import type { PeriodSummary } from '@/types/PeriodSummary' +import { formatNumber } from './format' +import { toKurus } from './money-schema' + +export const DEFAULT_SPENDABLE_RATE_BP = 4500 + +export type DebtPlan = { + carried: number + rateBp: number + spendable: number + payment: number + toSavings: number + fixedTooHigh: boolean + closingTarget: number +} + +export function debtPlan(income: number, fixed: number, carried: number, rateBp: number): DebtPlan { + const byRate = Number((BigInt(Math.max(income, 0)) * BigInt(rateBp) + 5_000n) / 10_000n) + const afterFixed = income - fixed + const fixedTooHigh = afterFixed < byRate + const spendable = fixedTooHigh ? afterFixed : byRate + const raw = fixedTooHigh ? 0 : afterFixed - byRate + const payment = Math.min(raw, carried) + return { + carried, + rateBp, + spendable, + payment, + toSavings: raw - payment, + fixedTooHigh, + closingTarget: carried - payment, + } +} + +/** Baz puan → `45` / `45,5` */ +export function formatRate(bp: number): string { + return formatNumber(bp / 100, bp % 100 === 0 ? 0 : 2) +} + +// ---------- Plan düzenleme: devreden borç alanı + canlı önizleme ---------- + +type CarriedSource = Pick< + PeriodSummary, + 'carried_debt_source' | 'carried_debt' | 'carried_debt_auto' +> + +/** Form metni: elle girilmişse tutar (`0` dahil — borç bitti), otomatikse boş */ +export function carriedText(s: CarriedSource): string { + if (s.carried_debt_source !== 'manual') return '' + return s.carried_debt ? formatNumber(s.carried_debt / 100) : '0' +} + +/** Form metni → istek: boş → `null` (son ölçümden), aksi halde kuruş */ +export function parseCarried(text: string | undefined): number | null { + return (text ?? '').trim() === '' ? null : Math.max(toKurus(text ?? ''), 0) +} + +/** Formdaki metne göre geçerli devreden borç */ +export function effectiveCarried(text: string | undefined, s: CarriedSource): number { + return parseCarried(text) ?? s.carried_debt_auto ?? 0 +} + +/** Oran metni (`45`, `45,5`) → baz puan; geçersizse `null` */ +export function parseRate(text: string | undefined): number | null { + const v = Number((text ?? '').trim().replace(',', '.')) + if (!(text ?? '').trim() || !Number.isFinite(v)) return null + return Math.round(v * 100) +} + +/** + * Düzenleme taslağından özet önizlemesi (backend `budget::summary` ile aynı kural). + * Borçlu ay: harcanabilir = `debtPlan`; değilse gelir − sabit − birikim − devir borç + devir artı. + */ +export function previewSummary( + saved: PeriodSummary, + d: { + income: number + fixed: number + savings: number + carried: number + rateBp: number + budget: number + }, +): PeriodSummary { + const base = { + ...saved, + income_total: d.income, + fixed_expense_total: d.fixed, + savings_goal: d.savings, + } + let next: PeriodSummary + if (d.carried > 0) { + const p = debtPlan(d.income, d.fixed, d.carried, d.rateBp) + next = { + ...base, + spendable: p.spendable, + carried_debt: d.carried, + debt_mode: true, + spendable_rate_bp: d.rateBp, + debt_payment: p.payment, + debt_to_savings: p.toSavings, + debt_fixed_too_high: p.fixedTooHigh, + closing_debt_target: p.closingTarget, + } + } else { + next = { + ...base, + spendable: d.income - d.fixed - d.savings - saved.carryover_debt + saved.carryover_surplus, + carried_debt: 0, + debt_mode: false, + spendable_rate_bp: d.rateBp, + debt_payment: 0, + debt_to_savings: 0, + debt_fixed_too_high: false, + closing_debt_target: null, + } + } + next.budget_total = d.budget + next.unallocated = next.spendable - d.budget + next.remaining = next.spendable - saved.spent_total + return next +} diff --git a/frontend/src/lib/debt.test.ts b/frontend/src/lib/debt.test.ts index 78e4c25..cc74671 100644 --- a/frontend/src/lib/debt.test.ts +++ b/frontend/src/lib/debt.test.ts @@ -79,17 +79,3 @@ describe('debt (backend ile aynı, kabul rakamları)', () => { ]) }) }) - -describe('plan integration', () => { - it('finds the installments expense item by name', async () => { - const { findInstallmentItem } = await import('./debt') - const items = [ - { kind: 'income', name: 'Taksitler' }, - { kind: 'expense', name: 'Kira' }, - { kind: 'expense', name: ' TAKSİTLER ' }, - ] - expect(findInstallmentItem(items)?.name).toBe(' TAKSİTLER ') - expect(findInstallmentItem([{ kind: 'expense', name: 'Taksit' }])).toBeTruthy() - expect(findInstallmentItem([{ kind: 'expense', name: 'Taksitli alışveriş' }])).toBeUndefined() - }) -}) diff --git a/frontend/src/lib/debt.ts b/frontend/src/lib/debt.ts index f2e452b..15e81b4 100644 --- a/frontend/src/lib/debt.ts +++ b/frontend/src/lib/debt.ts @@ -10,7 +10,6 @@ import type { DebtSummary } from '@/types/DebtSummary' import type { DebtTarget } from '@/types/DebtTarget' import type { InstallmentPlan } from '@/types/InstallmentPlan' import type { InstallmentRequest } from '@/types/InstallmentRequest' -import type { PeriodDebt } from '@/types/PeriodDebt' import { api } from './api' import { queryKeys } from './query' @@ -96,13 +95,6 @@ export function useDebt() { return useQuery({ queryKey: queryKeys.debt, queryFn: () => api.get('/debt') }) } -export function usePeriodDebt(periodId: string) { - return useQuery({ - queryKey: queryKeys.periodDebt(periodId), - queryFn: () => api.get(`/periods/${periodId}/debt`), - }) -} - /** Borç verisi değişince özet ve dönem borçları yenilenir */ function useInvalidateDebt() { const qc = useQueryClient() @@ -168,16 +160,6 @@ export function useSetOpeningDebt(periodId: string) { }) } -/** Plan kalemlerinde "Taksit(ler)" adlı sabit gider (büyük/küçük harf ve Türkçe harf duyarsız) */ -export function findInstallmentItem( - items: readonly T[], -): T | undefined { - return items.find( - (i) => - i.kind === 'expense' && /^taks[iı]t(ler|lar)?$/.test(i.name.trim().toLocaleLowerCase('tr')), - ) -} - export function useDebtImport() { const invalidate = useInvalidateDebt() return useMutation({ diff --git a/frontend/src/lib/periods.ts b/frontend/src/lib/periods.ts index 3f65027..63dee5c 100644 --- a/frontend/src/lib/periods.ts +++ b/frontend/src/lib/periods.ts @@ -214,12 +214,14 @@ export function useSavePlan(period: Period) { return useMutation({ mutationFn: async (ops: PlanOps) => { const pid = period.id - if (ops.savings !== null) { + if (ops.savings !== null || ops.rateBp !== null) { await api.patch(`/periods/${pid}`, { - savings_goal: ops.savings, + savings_goal: ops.savings ?? undefined, + spendable_rate_bp: ops.rateBp ?? undefined, version: period.version, }) } + if (ops.carried) await api.put(`/periods/${pid}/opening-debt`, ops.carried) for (const id of ops.deletes) await api.delete(`/plan-items/${id}`) for (const { id, ...body } of ops.updates) await api.patch(`/plan-items/${id}`, body) for (const body of ops.creates) await api.post(`/periods/${pid}/plan-items`, body) diff --git a/frontend/src/lib/plan-draft.test.ts b/frontend/src/lib/plan-draft.test.ts index 00d4b33..3e1e390 100644 --- a/frontend/src/lib/plan-draft.test.ts +++ b/frontend/src/lib/plan-draft.test.ts @@ -13,6 +13,7 @@ const period: Period = { days: 31, status: 'open', savings_goal: 500_000, + spendable_rate_bp: null, reflection_md: null, to_savings: null, closed_at: null, @@ -118,4 +119,26 @@ describe('plan draft', () => { const ops = diffPlan(period, items, budget, draft) expect(ops.weeks).toEqual([{ group: 'wants', week_start: '2026-12-01', amount: 0 }]) }) + + it('devreden borç ve oran yalnızca değişince gönderilir', () => { + const auto = { + carried_debt_source: 'auto' as const, + carried_debt: 5_710_000, + carried_debt_auto: 5_710_000, + } + const draft = toDraft(period, items, budget, auto) + expect([draft.carried, draft.rate]).toEqual(['', '45']) + expect(hasChanges(diffPlan(period, items, budget, draft, auto))).toBe(false) + draft.carried = '0' // borç bitti + draft.rate = '50' + const ops = diffPlan(period, items, budget, draft, auto) + expect(ops.carried).toEqual({ amount: 0 }) + expect(ops.rateBp).toBe(5000) + // Elle → boş: otomatiğe dön + const manual = { ...auto, carried_debt_source: 'manual' as const, carried_debt: 1_000_000 } + const d2 = toDraft(period, items, budget, manual) + expect(d2.carried).toBe('10.000,00') + d2.carried = '' + expect(diffPlan(period, items, budget, d2, manual).carried).toEqual({ amount: null }) + }) }) diff --git a/frontend/src/lib/plan-draft.ts b/frontend/src/lib/plan-draft.ts index c51d5c9..ace917e 100644 --- a/frontend/src/lib/plan-draft.ts +++ b/frontend/src/lib/plan-draft.ts @@ -13,6 +13,14 @@ import type { Period } from '@/types/Period' import type { PlanItem } from '@/types/PlanItem' import type { PlanItemCreateRequest } from '@/types/PlanItemCreateRequest' import type { PlanItemUpdateRequest } from '@/types/PlanItemUpdateRequest' +import type { PeriodSummary } from '@/types/PeriodSummary' +import { + carriedText, + DEFAULT_SPENDABLE_RATE_BP, + formatRate, + parseCarried, + parseRate, +} from './debt-plan' import { formatNumber } from './format' import { toKurus } from './money-schema' @@ -32,13 +40,32 @@ export type PlanDraft = { incomes: ItemDraft[] expenses: ItemDraft[] savings: string + /** Devreden borç: `""` → son ay sonu ölçümünden, aksi halde elle (`0` → borç bitti) */ + carried: string + /** Borçlu ayda harcanabilir oranı, yüzde (`45`) */ + rate: string budget: Record /** Her grup için dönemin haftaları sırasıyla; `""` → otomatik */ weeks: Record } +/** Dönemin kayıtlı devreden borç durumu (özetten) */ +export type CarriedState = Pick< + PeriodSummary, + 'carried_debt_source' | 'carried_debt' | 'carried_debt_auto' +> +const NO_CARRIED: CarriedState = { + carried_debt_source: null, + carried_debt: 0, + carried_debt_auto: null, +} + export type PlanOps = { savings: number | null + /** Değişmediyse `null`; `{ amount: null }` → otomatiğe dön */ + carried: { amount: number | null } | null + /** Değişmediyse `null` */ + rateBp: number | null deletes: string[] updates: (PlanItemUpdateRequest & { id: string })[] creates: PlanItemCreateRequest[] @@ -65,13 +92,20 @@ function budgetFor(budget: GroupBudget[], g: Group): GroupBudget | undefined { return budget.find((b) => b.group === g) } -export function toDraft(period: Period, items: PlanItem[], budget: GroupBudget[]): PlanDraft { +export function toDraft( + period: Period, + items: PlanItem[], + budget: GroupBudget[], + carried: CarriedState = NO_CARRIED, +): PlanDraft { const rec = (f: (g: Group) => T) => Object.fromEntries(GROUP_CODES.map((g) => [g, f(g)])) as Record return { incomes: items.filter((i) => i.kind === 'income').map(itemToDraft), expenses: items.filter((i) => i.kind === 'expense').map(itemToDraft), savings: moneyToText(period.savings_goal), + carried: carriedText(carried), + rate: formatRate(period.spendable_rate_bp ?? DEFAULT_SPENDABLE_RATE_BP), budget: rec((g) => moneyToText(budgetFor(budget, g)?.monthly ?? 0)), weeks: rec((g) => period.weeks.map((w) => { @@ -88,9 +122,12 @@ export function diffPlan( items: PlanItem[], budget: GroupBudget[], draft: PlanDraft, + carried: CarriedState = NO_CARRIED, ): PlanOps { const ops: PlanOps = { savings: null, + carried: null, + rateBp: null, deletes: [], updates: [], creates: [], @@ -101,6 +138,14 @@ export function diffPlan( const savings = toKurus(draft.savings) if (savings !== period.savings_goal) ops.savings = savings + const manual = carried.carried_debt_source === 'manual' ? carried.carried_debt : null + const nextCarried = parseCarried(draft.carried) + if (nextCarried !== manual) ops.carried = { amount: nextCarried } + const rate = parseRate(draft.rate) + if (rate !== null && rate !== (period.spendable_rate_bp ?? DEFAULT_SPENDABLE_RATE_BP)) { + ops.rateBp = rate + } + const byId = new Map(items.map((i) => [i.id, i])) const kept = new Set() const rows = [ @@ -146,6 +191,8 @@ export function diffPlan( export function hasChanges(ops: PlanOps): boolean { return ( ops.savings !== null || + ops.carried !== null || + ops.rateBp !== null || ops.deletes.length + ops.updates.length + ops.creates.length + ops.weeks.length > 0 || ops.monthly !== null ) diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index 955771d..464f2e8 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -253,13 +253,40 @@ export const t = { income: 'Gelir', fixed: 'Sabit giderler', savings: 'Birikim', - /** Borç takibinde borç > 0 iken birikim hedefinin adı */ - debtPayment: 'Borç ödemesi', - debt: 'Devreden borç', + /** Borçlu ay (devreden borç > 0): gelir − sabit − bu ay borca giden = harcanabilir */ + debtPayment: 'Bu ay borca giden', + carriedPart: (amount: string) => `Devreden borç ${amount}`, + targetPart: (amount: string) => `ay sonu hedef ${amount}`, + rateShort: (pct: string) => `gelirin %${pct}`, + toSavingsLine: (amount: string) => `Borç bu ay kapanıyor; artan ${amount} birikime`, + savingsLocked: 'Birikim: borç bitince açılır', + fixedTooHigh: + 'Sabit giderler çok yüksek: harcanabilir oranın altında kaldı, bu ay borca para ayrılamıyor.', + rateLabel: (pct: string) => `Harcanabilir: gelirin %${pct}`, + /** Kakeibo'nun kendi devri (önceki ayın bütçe aşımı); banka borcuyla karışmasın */ + debt: 'Önceki ay aşımı', surplus: 'Devreden artı', spendable: 'Harcanabilir', }, + /** Planda devreden banka borcu alanı (docs/KAKEIBO_PANEL_ISTEKLERI.md A) */ + carried: { + title: 'Devreden borç', + label: 'Devreden borç (KMH + kart)', + hint: 'Borç varken harcanabilir gelirin belli bir oranıdır; kalan önce borca gider, birikim borç bitince açılır.', + fromSnapshot: (date: string, amount: string) => + `Ay sonu ölçümünden (${date}) otomatik: ${amount}`, + noSnapshot: + 'Ay sonu ölçümü yok. Önceki aydan devreden banka borcu (KMH + kart) varsa yaz; yoksa boş bırak.', + manual: 'elle düzeltildi', + backToAuto: 'Ölçüme dön', + zero: '0 → borç bitti; normal Kakeibo düzeni (birikim hedefi) geçerli.', + rate: 'Harcanabilir oranı (%)', + rateHint: 'Borçlu ayda harcanabilir = gelir × bu oran. Varsayılan %45.', + rateError: '%10 ile %90 arasında bir sayı yazın', + savingsLocked: 'Borç bitince açılır. Bu ay gelirin kalanı önce borca gider.', + }, + ledger: { refund: 'İade', refundHint: 'İade kaydı: bu tutar kategorinin harcamasından düşülür.', @@ -527,37 +554,6 @@ export const t = { removeInstallmentConfirm: (m: string) => `${m} taksit planı silinsin mi?`, noInstallments: 'Kayıtlı taksit planı yok.', toInstallments: 'Taksitler', - period: { - title: 'Bu ayın borcu', - hint: 'Banka borcu (KMH, kart, taksitler). Harcanabilir tutarı düşürmez; birikim hedefin borç bitene kadar borç ödemesi sayılır.', - opening: 'Ay başı borç', - fromSnapshot: (date: string) => `${date} ölçümünden`, - manual: 'Elle girildi', - noOpening: 'Ölçüm yok', - editOpening: 'Ay başı borcu düzelt', - resetOpening: 'Son ölçüme dön', - openingSaved: 'Ay başı borç kaydedildi', - carried: 'Devreden banka borcu', - carriedHint: 'Ay başı borç − ekstreye gelmemiş taksitler', - payment: 'Borç ödemesi', - load: 'Bu ayın taksiti', - target: 'Hedef ay sonu borç', - targetHint: 'Ay başı − borç ödemesi − bu ayın taksiti', - roadmap: (amount: string) => `Yol haritası: ${amount}`, - vsRoadmapAbove: (amount: string) => `yol haritasının ${amount} üstünde`, - vsRoadmapBelow: (amount: string) => `yol haritasının ${amount} altında`, - progressTitle: (date: string) => `${date} ölçümüne göre`, - paidDown: (amount: string) => `Borç ${amount} azaldı`, - grew: (amount: string) => `Borç ${amount} arttı`, - needed: (amount: string) => `ayın hedefi: ${amount} azalma (borç ödemesi + taksit)`, - onTrack: 'Hedefe ulaşıldı', - addInstallments: (amount: string) => `"Taksitler" sabit giderini ekle (${amount})`, - installmentsAdded: 'Taksitler sabit giderlere eklendi', - installmentsMismatch: (plan: string, load: string) => - `Plandaki "Taksitler" ${plan}; bu ayın taksit yükü ${load}. Düzenle'den güncelleyebilirsin.`, - installmentsName: 'Taksitler', - toDebt: 'Borç takibi', - }, }, monthlyReport: { title: 'Aylık Rapor', diff --git a/frontend/src/types/Period.ts b/frontend/src/types/Period.ts index e9a63c5..4b3cc7c 100644 --- a/frontend/src/types/Period.ts +++ b/frontend/src/types/Period.ts @@ -19,6 +19,10 @@ last_date: string, days: number, status: PeriodStatus, * Kuruş */ savings_goal: number, +/** + * Borçlu ayda harcanabilir oranı (baz puan); `null` → varsayılan %45 + */ +spendable_rate_bp: number | null, /** * "Nasıl daha iyi yapabilirim?" (Markdown) */ diff --git a/frontend/src/types/PeriodSummary.ts b/frontend/src/types/PeriodSummary.ts index f10ac10..1fa4de7 100644 --- a/frontend/src/types/PeriodSummary.ts +++ b/frontend/src/types/PeriodSummary.ts @@ -10,9 +10,45 @@ carryover_debt: number, */ carryover_surplus: number, /** - * gelir − sabit gider − birikim − borç + artı + * Normal ay: gelir − sabit gider − birikim − borç + artı · Borçlu ay: gelir × oran */ spendable: number, +/** + * Devreden (banka) borcu; yoksa 0. > 0 → borçlu ay (birikim hedefi plana girmez) + */ +carried_debt: number, +/** + * `auto` (ay sonu ölçümü) | `manual` (planda elle) | `null` (hiç girilmedi) + */ +carried_debt_source: "auto" | "manual" | null, +/** + * Son ay sonu ölçümünün tarihi (elle girilmiş olsa da); ölçüm yoksa `null` + */ +carried_debt_date: string | null, +/** + * Son ay sonu ölçümünden gelen tutar (elle düzeltme kaldırılınca kullanılacak); yoksa `null` + */ +carried_debt_auto: number | null, debt_mode: boolean, +/** + * Etkin harcanabilir oranı (baz puan) + */ +spendable_rate_bp: number, +/** + * Bu ay borca giden + */ +debt_payment: number, +/** + * Borca giden devreden borcu aşarsa birikime kalan + */ +debt_to_savings: number, +/** + * Sabit giderler çok yüksek: bu ay borç azalmıyor + */ +debt_fixed_too_high: boolean, +/** + * Ay sonu hedef borç (borçlu ayda) + */ +closing_debt_target: number | null, /** * 4 grubun aylık bütçe toplamı */ diff --git a/frontend/src/types/PeriodUpdateRequest.ts b/frontend/src/types/PeriodUpdateRequest.ts index 7b35d16..67517fa 100644 --- a/frontend/src/types/PeriodUpdateRequest.ts +++ b/frontend/src/types/PeriodUpdateRequest.ts @@ -1,6 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type PeriodUpdateRequest = { name?: string, start_date?: string, end_date?: string, savings_goal?: number, +/** + * Borçlu ayda harcanabilir oranı (baz puan; 4500 = %45) + */ +spendable_rate_bp?: number, /** * "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır */