diff --git a/CLAUDE.md b/CLAUDE.md index ffca8ae..26d1ebd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +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. - 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/api.http b/backend/api.http index 8977b8b..63047bb 100644 --- a/backend/api.http +++ b/backend/api.http @@ -448,3 +448,24 @@ Content-Type: application/json ### Üyeyi çıkar (sahip) DELETE {{baseUrl}}/household/members/{{memberId}} + +# ─────────────────────────── Borç takibi ─────────────────────────── + +### Özet (son ölçüm, değişim, ilerleme, taksitler, hedefler, 6 aylık yük) +GET {{baseUrl}}/debt + +### Yeni ölçüm (kuruş) +POST {{baseUrl}}/debt/snapshots +Content-Type: application/json + +{ "date": "2026-11-30", "kmh": 0, "positive_balance": 0, "card_balance": 0, "remaining_installments": 0, "note": "maaş sonrası" } + +### İçe aktarma ön izleme (commit=true → yazar); gövde: docs/BORC_TAKIBI.md biçimi + asOf +POST {{baseUrl}}/debt/import?commit=false +Content-Type: application/json + +{ "asOf": "2026-11", "debtSnapshots": [], "installmentPlans": [], "roadmap": [] } + +### Dönemin borcu +GET {{baseUrl}}/periods/{{periodId}}/debt + diff --git a/backend/src/db/indexes.rs b/backend/src/db/indexes.rs index 9c92235..89c3644 100644 --- a/backend/src/db/indexes.rs +++ b/backend/src/db/indexes.rs @@ -89,6 +89,18 @@ fn definitions() -> Vec<(&'static str, Vec)> { col::IMPORT_RULES, vec![idx(doc! { "household_id": 1, "pattern": 1 })], ), + ( + col::DEBT_SNAPSHOTS, + vec![idx(doc! { "household_id": 1, "date": -1 })], + ), + ( + col::INSTALLMENT_PLANS, + vec![idx(doc! { "household_id": 1, "first_month": 1 })], + ), + ( + col::DEBT_TARGETS, + vec![idx(doc! { "household_id": 1, "month": 1 })], + ), ( col::RESTORE_POINTS, vec![idx(doc! { "household_id": 1, "created_at": -1 })], diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs index 4d0feed..d3e2e0d 100644 --- a/backend/src/db/migrations.rs +++ b/backend/src/db/migrations.rs @@ -40,6 +40,11 @@ const MIGRATIONS: &[Migration] = &[ name: "restore_points", run: |db| Box::pin(m003_restore_points(db)), }, + Migration { + version: 4, + name: "debt_tracking", + run: |db| Box::pin(m004_debt_tracking(db)), + }, ]; /// En son şema sürümü (JSON yedeğine yazılır; daha yeni yedek reddedilir). @@ -126,6 +131,21 @@ async fn m003_restore_points(db: &Database) -> Result<()> { Ok(()) } +/// v4: borç takibi koleksiyonları (transaction içinde ilk yazımdan önce var olmalı). +async fn m004_debt_tracking(db: &Database) -> Result<()> { + let existing = db.list_collection_names().await?; + for name in [ + col::DEBT_SNAPSHOTS, + col::INSTALLMENT_PLANS, + col::DEBT_TARGETS, + ] { + if !existing.iter().any(|n| n == name) { + db.create_collection(name).await?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 1a3f52d..b2cbf1b 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -75,6 +75,12 @@ pub mod col { pub const SCHEMA_MIGRATIONS: &str = "schema_migrations"; /// JSON geri yüklemeden önce alınan, sıkıştırılmış hane kopyaları ("geri al") pub const RESTORE_POINTS: &str = "restore_points"; + /// Banka borcu ölçümleri (KMH, kart, kalan taksit, artı bakiye) + pub const DEBT_SNAPSHOTS: &str = "debt_snapshots"; + /// Taksit planları (ilk taksit ayı + adet → aylık yük kendiliğinden) + pub const INSTALLMENT_PLANS: &str = "installment_plans"; + /// Borç yol haritası: ay sonu toplam borç hedefleri + pub const DEBT_TARGETS: &str = "debt_targets"; /// Hane verisi taşıyan koleksiyonlar (`household_id` alanı var). pub const HOUSEHOLD_SCOPED: &[&str] = &[ @@ -92,5 +98,8 @@ pub mod col { ASSETS, ASSET_TRANSACTIONS, IMPORT_RULES, + DEBT_SNAPSHOTS, + INSTALLMENT_PLANS, + DEBT_TARGETS, ]; } diff --git a/backend/src/models/period.rs b/backend/src/models/period.rs index 94ff70b..f4259b9 100644 --- a/backend/src/models/period.rs +++ b/backend/src/models/period.rs @@ -58,5 +58,8 @@ pub struct Period { pub to_savings: Option, #[serde(default)] pub closed_at: Option, + /// Elle girilen ay başı toplam banka borcu (kuruş); yoksa son ölçümden (borç takibi) + #[serde(default)] + pub opening_debt: Option, pub version: i32, } diff --git a/backend/src/routes/debt.rs b/backend/src/routes/debt.rs new file mode 100644 index 0000000..f1550f6 --- /dev/null +++ b/backend/src/routes/debt.rs @@ -0,0 +1,486 @@ +//! Borç takibi (`services::debt`, docs/BORC_TAKIBI.md). Hanenin tüm üyeleri. +//! +//! | Yöntem | Yol | İş | +//! |---|---|---| +//! | GET | `/api/debt` | özet: son ölçüm, değişim, ilerleme, tahmini bitiş, ölçümler, taksitler, hedefler, 6 aylık yük | +//! | POST | `/api/debt/snapshots` · PATCH/DELETE `/api/debt/snapshots/{id}` | ölçüm | +//! | POST | `/api/debt/installments` · PATCH/DELETE `/api/debt/installments/{id}` | taksit planı | +//! | PUT | `/api/debt/targets` | yol haritası (liste olduğu gibi kaydedilir) | +//! | POST | `/api/debt/import?commit=false\|true` | kaynak belgedeki JSON → ön izleme / aktarma | +//! | GET | `/api/periods/{id}/debt` | dönemin ay başı borcu, devreden, taksit yükü, hedef | +//! | PUT | `/api/periods/{id}/opening-debt` | elle ay başı toplam borç (`null` → son ölçümden) | + +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use mongodb::bson::Uuid; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::{ + auth::AuthUser, + db::col, + error::{AppError, AppResult}, + services::{ + calendar::{format_date, parse_date, today_tr}, + debt::{self, InstallmentPlan, PlanInput, Snapshot, SnapshotInput}, + period, + }, + state::AppState, +}; + +fn parse_id(id: &str) -> AppResult { + Uuid::parse_str(id).map_err(|_| AppError::NotFound) +} + +// ---------- DTO ---------- + +#[derive(Serialize, TS, Clone)] +#[ts(export, rename = "DebtSnapshot")] +pub struct SnapshotDto { + id: String, + date: String, + #[ts(type = "number")] + kmh: i64, + #[ts(type = "number")] + positive_balance: i64, + #[ts(type = "number")] + card_balance: i64, + #[ts(type = "number")] + remaining_installments: i64, + /// Hesaplanır: KMH + kart + kalan taksit − artı bakiye + #[ts(type = "number")] + total: i64, + /// Bir önceki ölçüme göre + #[ts(type = "number | null")] + change: Option, + note: Option, + source: String, + version: i32, +} + +fn snap_dto(s: &Snapshot, prev: Option<&Snapshot>) -> SnapshotDto { + SnapshotDto { + id: s.id.to_string(), + date: s.date.clone(), + kmh: s.kmh, + positive_balance: s.positive_balance, + card_balance: s.card_balance, + remaining_installments: s.remaining_installments, + total: s.total(), + change: prev.map(|p| s.total() - p.total()), + note: s.note.clone(), + source: s.source.clone(), + version: s.version, + } +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "InstallmentPlan")] +pub struct PlanDto { + id: String, + merchant: String, + #[ts(type = "number")] + total_amount: i64, + #[ts(type = "number")] + monthly_amount: i64, + total_count: i32, + /// `"YYYY-MM"` + first_month: String, + last_month: String, + /// Bu ay dahil ekstreye gelen + paid_count: i32, + remaining_count: i32, + /// Kalan taksitlerin toplamı (bu aydan sonra) + #[ts(type = "number")] + remaining_amount: i64, + version: i32, +} + +fn plan_dto(p: &InstallmentPlan, month: i32) -> PlanDto { + PlanDto { + id: p.id.to_string(), + merchant: p.merchant.clone(), + total_amount: p.total_amount, + monthly_amount: p.monthly_amount, + total_count: p.total_count, + first_month: p.first_month.clone(), + last_month: p.last_month().unwrap_or_default(), + paid_count: p.paid_count(month), + remaining_count: p.remaining_count(month), + remaining_amount: i64::from(p.remaining_count(month)) * p.monthly_amount, + version: p.version, + } +} + +#[derive(Serialize, Deserialize, TS)] +#[ts(export, rename = "DebtTarget")] +pub struct TargetDto { + /// `"YYYY-MM"` + month: String, + /// Ay sonu toplam borç hedefi (kuruş) + #[ts(type = "number")] + closing_target: i64, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "MonthLoad")] +pub struct MonthLoadDto { + month: String, + #[ts(type = "number")] + amount: i64, +} + +#[derive(Serialize, TS)] +#[ts(export)] +pub struct DebtSummary { + /// En yeni önce + snapshots: Vec, + latest: Option, + /// Son ölçüm − önceki ayın son ölçümü (eksi → azaldı) + #[ts(type = "number | null")] + change_vs_last_month: Option, + /// İlk ölçüm (yolculuğun başı) + start: Option, + /// 0..1 + progress: Option, + /// `"YYYY-MM"` + estimated_end: Option, + installments: Vec, + targets: Vec, + /// Bu ay dahil 6 ay + schedule: Vec, + /// Bu ay (`"YYYY-MM"`) + this_month: String, +} + +// ---------- Özet ---------- + +pub async fn summary(State(s): State, auth: AuthUser) -> AppResult> { + let today = format_date(today_tr()); + let x = debt::summary(&s.db, auth.household_id, &today).await?; + let list: Vec = x + .snapshots + .iter() + .enumerate() + .map(|(i, sn)| snap_dto(sn, i.checked_sub(1).map(|j| &x.snapshots[j]))) + .rev() + .collect(); + Ok(Json(DebtSummary { + latest: list.first().cloned(), + start: x.start.as_ref().map(|s0| snap_dto(s0, None)), + snapshots: list, + change_vs_last_month: x.change_vs_last_month, + progress: x.progress, + estimated_end: x.estimated_end, + installments: x.plans.iter().map(|p| plan_dto(p, x.this_month)).collect(), + targets: x + .targets + .into_iter() + .map(|t| TargetDto { + month: t.month, + closing_target: t.closing_target, + }) + .collect(), + schedule: x + .schedule + .into_iter() + .map(|(month, amount)| MonthLoadDto { month, amount }) + .collect(), + this_month: debt::month_name(x.this_month), + })) +} + +// ---------- Ölçümler ---------- + +#[derive(Deserialize, TS)] +#[ts(export, rename = "DebtSnapshotRequest", optional_fields)] +pub struct SnapshotRequest { + /// `"YYYY-MM-DD"` + date: Option, + #[ts(type = "number")] + kmh: Option, + #[ts(type = "number")] + positive_balance: Option, + #[ts(type = "number")] + card_balance: Option, + #[ts(type = "number")] + remaining_installments: Option, + /// Boş metin → notu kaldır + note: Option, + version: Option, +} + +impl From for SnapshotInput { + fn from(r: SnapshotRequest) -> Self { + SnapshotInput { + date: r.date, + kmh: r.kmh, + positive_balance: r.positive_balance, + card_balance: r.card_balance, + remaining_installments: r.remaining_installments, + note: r.note.map(Some), + version: r.version, + } + } +} + +pub async fn create_snapshot( + State(s): State, + auth: AuthUser, + Json(req): Json, +) -> AppResult<(StatusCode, Json)> { + let sn = debt::create_snapshot(&s.db, auth.household_id, req.into(), "manual").await?; + Ok((StatusCode::CREATED, Json(snap_dto(&sn, None)))) +} + +pub async fn update_snapshot( + State(s): State, + auth: AuthUser, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + let sn = debt::update_snapshot(&s.db, auth.household_id, parse_id(&id)?, req.into()).await?; + Ok(Json(snap_dto(&sn, None))) +} + +pub async fn delete_snapshot( + State(s): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult { + debt::delete( + &s.db, + col::DEBT_SNAPSHOTS, + auth.household_id, + parse_id(&id)?, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +// ---------- Taksitler ---------- + +#[derive(Deserialize, TS)] +#[ts(export, rename = "InstallmentRequest", optional_fields)] +pub struct PlanRequest { + merchant: Option, + #[ts(type = "number")] + total_amount: Option, + #[ts(type = "number")] + monthly_amount: Option, + total_count: Option, + /// `"YYYY-MM"` + first_month: Option, + version: Option, +} + +impl From for PlanInput { + fn from(r: PlanRequest) -> Self { + PlanInput { + merchant: r.merchant, + total_amount: r.total_amount, + monthly_amount: r.monthly_amount, + total_count: r.total_count, + first_month: r.first_month, + version: r.version, + } + } +} + +fn this_month() -> i32 { + debt::month_index(debt::month_of(&format_date(today_tr()))).unwrap_or(0) +} + +pub async fn create_plan( + State(s): State, + auth: AuthUser, + Json(req): Json, +) -> AppResult<(StatusCode, Json)> { + let p = debt::create_plan(&s.db, auth.household_id, req.into()).await?; + Ok((StatusCode::CREATED, Json(plan_dto(&p, this_month())))) +} + +pub async fn update_plan( + State(s): State, + auth: AuthUser, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + let p = debt::update_plan(&s.db, auth.household_id, parse_id(&id)?, req.into()).await?; + Ok(Json(plan_dto(&p, this_month()))) +} + +pub async fn delete_plan( + State(s): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult { + debt::delete( + &s.db, + col::INSTALLMENT_PLANS, + auth.household_id, + parse_id(&id)?, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +// ---------- Hedefler ---------- + +#[derive(Deserialize, TS)] +#[ts(export, rename = "DebtTargetsRequest")] +pub struct TargetsRequest { + targets: Vec, +} + +pub async fn set_targets( + State(s): State, + auth: AuthUser, + Json(req): Json, +) -> AppResult { + debt::set_targets( + &s.db, + auth.household_id, + req.targets + .into_iter() + .map(|t| (t.month, t.closing_target)) + .collect(), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +// ---------- İçe aktarma ---------- + +#[derive(Deserialize)] +pub struct ImportQuery { + #[serde(default)] + commit: bool, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "DebtImportRow")] +pub struct ImportRowDto { + /// `snapshot` | `installment` | `target` + kind: String, + label: String, + #[ts(type = "number")] + amount: i64, + /// `new` | `exists` | `update` | `error` + status: String, + message: Option, +} + +#[derive(Serialize, TS)] +#[ts(export)] +pub struct DebtImportReport { + committed: bool, + rows: Vec, + errors: Vec, +} + +pub async fn import( + State(s): State, + auth: AuthUser, + Query(q): Query, + Json(body): Json, +) -> AppResult> { + let (rows, errors) = debt::import(&s.db, auth.household_id, &body, q.commit).await?; + Ok(Json(DebtImportReport { + committed: q.commit, + rows: rows + .into_iter() + .map(|r| ImportRowDto { + kind: r.kind.into(), + label: r.label, + amount: r.amount, + status: r.status.into(), + message: r.message, + }) + .collect(), + errors, + })) +} + +// ---------- Dönem ---------- + +#[derive(Serialize, TS)] +#[ts(export)] +pub struct PeriodDebt { + /// Ay başı toplam borç + #[ts(type = "number | null")] + opening_total: Option, + /// `manual` | `snapshot` | `none` + opening_source: String, + opening_date: Option, + /// Devreden borç = ay başı toplam − kalan taksitler + #[ts(type = "number | null")] + carried: Option, + #[ts(type = "number")] + installment_load: i64, + /// Borç ödemesi (dönemin birikim hedefi) + #[ts(type = "number")] + payment: i64, + /// Ay başı − borç ödemesi − taksit yükü + #[ts(type = "number | null")] + closing_target: Option, + #[ts(type = "number | null")] + roadmap_target: Option, + latest_date: Option, + #[ts(type = "number | null")] + latest_total: Option, +} + +pub async fn period_debt( + State(s): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult> { + let hid = auth.household_id; + let p = period::get(&s.db, hid, parse_id(&id)?).await?; + let last = parse_date(&p.end_date) + .map(|d| format_date(d - time::Duration::days(1))) + .unwrap_or_else(|| p.end_date.clone()); + let d = debt::period_debt( + &p, + &last, + &debt::snapshots(&s.db, hid).await?, + &debt::plans(&s.db, hid).await?, + &debt::targets(&s.db, hid).await?, + ); + Ok(Json(PeriodDebt { + opening_total: d.opening_total, + opening_source: d.opening_source.into(), + opening_date: d.opening_date, + carried: d.carried, + installment_load: d.installment_load, + payment: d.payment, + closing_target: d.closing_target, + roadmap_target: d.roadmap_target, + latest_date: d.latest_in_period.as_ref().map(|x| x.0.clone()), + latest_total: d.latest_in_period.map(|x| x.1), + })) +} + +#[derive(Deserialize, TS)] +#[ts(export, rename = "OpeningDebtRequest")] +pub struct OpeningRequest { + /// Kuruş; `null` → son ölçümden + #[ts(type = "number | null")] + amount: Option, +} + +pub async fn set_opening( + State(s): State, + auth: AuthUser, + Path(id): Path, + Json(req): Json, +) -> AppResult { + let pid = parse_id(&id)?; + period::get_open(&s.db, auth.household_id, pid).await?; // kapalı dönem değişmez + debt::set_opening(&s.db, auth.household_id, pid, req.amount).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 1c86b2e..eae3558 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -6,6 +6,7 @@ mod backup; mod bank_import; mod budget; mod catalog; +mod debt; mod entry; mod health; mod household; @@ -94,6 +95,21 @@ pub fn router(state: AppState) -> Router { .route("/periods/{id}/notes", get(note::list).post(note::create)) .route("/periods/{id}/summary", get(budget::summary)) .route("/periods/{id}/review", get(review::get)) + .route("/periods/{id}/debt", get(debt::period_debt)) + .route("/periods/{id}/opening-debt", put(debt::set_opening)) + .route("/debt", get(debt::summary)) + .route("/debt/snapshots", post(debt::create_snapshot)) + .route( + "/debt/snapshots/{id}", + patch(debt::update_snapshot).delete(debt::delete_snapshot), + ) + .route("/debt/installments", post(debt::create_plan)) + .route( + "/debt/installments/{id}", + patch(debt::update_plan).delete(debt::delete_plan), + ) + .route("/debt/targets", put(debt::set_targets)) + .route("/debt/import", post(debt::import)) .route("/periods/{id}/report.md", get(monthly_report::get)) .route("/periods/{id}/daily", get(stats::daily)) .route("/stats/periods", get(stats::periods)) diff --git a/backend/src/services/backup.rs b/backend/src/services/backup.rs index 1086175..3f320c4 100644 --- a/backend/src/services/backup.rs +++ b/backend/src/services/backup.rs @@ -57,6 +57,9 @@ pub const KINDS: &[(&str, &str)] = &[ (col::ASSET_TRANSACTIONS, "Varlık işlemi"), (col::EXCHANGE_RATES, "Elle girilen kur"), (col::IMPORT_RULES, "Ekstre kuralı"), + (col::DEBT_SNAPSHOTS, "Borç ölçümü"), + (col::INSTALLMENT_PLANS, "Taksit planı"), + (col::DEBT_TARGETS, "Borç hedefi"), ]; /// Uygulamanın `i32` yazdığı alanlar (geri kalan tam sayılar `i64`: tutarlar, `_e6`). diff --git a/backend/src/services/debt.rs b/backend/src/services/debt.rs new file mode 100644 index 0000000..960971c --- /dev/null +++ b/backend/src/services/debt.rs @@ -0,0 +1,1082 @@ +//! Borç takibi (docs/BORC_TAKIBI.md): banka borcu ölçümleri, taksit planları, yol haritası. +//! +//! Kakeibo'nun "devreden borç"u (bütçe aşımı, `carryovers`) ile ilgisi yoktur; banka borcu +//! harcanabilir tutarı düşürmez. Tutarlar kuruş (`i64`). Toplam borç saklanmaz, hesaplanır: +//! `KMH + kart + kalan taksit − artı bakiye`. + +use std::collections::BTreeMap; + +use mongodb::{ + Database, + bson::{Bson, Document, Uuid, doc}, +}; +use serde::Deserialize; + +use crate::{ + db::col, + error::{AppError, AppResult}, + models::common::{new_meta, now, scope}, + services::period, +}; + +// ---------- Ay hesabı ("YYYY-MM") ---------- + +/// `"2026-11"` → ay sırası (yıl×12 + ay−1) +pub fn month_index(m: &str) -> Option { + let (y, mo) = m.split_once('-')?; + if y.len() != 4 || mo.len() != 2 { + return None; + } + let (y, mo): (i32, i32) = (y.parse().ok()?, mo.parse().ok()?); + (1..=12).contains(&mo).then_some(y * 12 + mo - 1) +} + +pub fn month_name(i: i32) -> String { + format!("{:04}-{:02}", i.div_euclid(12), i.rem_euclid(12) + 1) +} + +/// `"2026-11-13"` → `"2026-11"` +pub fn month_of(date: &str) -> &str { + date.get(0..7).unwrap_or(date) +} + +// ---------- Kayıtlar ---------- + +#[derive(Debug, Clone, Deserialize)] +pub struct Snapshot { + #[serde(rename = "_id")] + pub id: Uuid, + /// `"YYYY-MM-DD"` + pub date: String, + pub kmh: i64, + pub positive_balance: i64, + pub card_balance: i64, + pub remaining_installments: i64, + #[serde(default)] + pub note: Option, + #[serde(default = "manual")] + pub source: String, + pub version: i32, +} + +fn manual() -> String { + "manual".into() +} + +impl Snapshot { + pub fn total(&self) -> i64 { + total( + self.kmh, + self.card_balance, + self.remaining_installments, + self.positive_balance, + ) + } +} + +pub fn total(kmh: i64, card: i64, remaining: i64, positive: i64) -> i64 { + kmh + card + remaining - positive +} + +#[derive(Debug, Clone, Deserialize)] +pub struct InstallmentPlan { + #[serde(rename = "_id")] + pub id: Uuid, + pub merchant: String, + pub total_amount: i64, + pub monthly_amount: i64, + pub total_count: i32, + /// İlk taksitin ekstreye geldiği ay `"YYYY-MM"` + pub first_month: String, + pub version: i32, +} + +impl InstallmentPlan { + fn range(&self) -> Option<(i32, i32)> { + let first = month_index(&self.first_month)?; + Some((first, first + self.total_count - 1)) + } + /// `month` dahil ekstreye gelmiş taksit sayısı + pub fn paid_count(&self, month: i32) -> i32 { + self.range().map_or(0, |(first, _)| { + (month - first + 1).clamp(0, self.total_count) + }) + } + pub fn remaining_count(&self, month: i32) -> i32 { + self.total_count - self.paid_count(month) + } + pub fn last_month(&self) -> Option { + self.range().map(|(_, last)| month_name(last)) + } +} + +/// Ayın taksit yükü: o ay ekstreye gelen taksitlerin toplamı +pub fn installment_load(plans: &[InstallmentPlan], month: i32) -> i64 { + plans + .iter() + .filter(|p| p.range().is_some_and(|(f, l)| f <= month && month <= l)) + .map(|p| p.monthly_amount) + .sum() +} + +/// `month`'tan **sonra** ekstreye gelecek taksitlerin toplamı (ölçümdeki "kalan taksit") +pub fn remaining_after(plans: &[InstallmentPlan], month: i32) -> i64 { + plans + .iter() + .map(|p| i64::from(p.remaining_count(month)) * p.monthly_amount) + .sum() +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Target { + pub month: String, + pub closing_target: i64, +} + +// ---------- Doğrulama ---------- + +fn bad(m: impl Into) -> AppError { + AppError::BadRequest(m.into()) +} + +/// Kart / IBAN / müşteri numarası yazılmasın: 12+ haneli sayı dizisi (boşluk/tire yok sayılır) +pub fn looks_sensitive(s: &str) -> bool { + let mut run = 0; + for c in s.chars() { + if c.is_ascii_digit() { + run += 1; + if run >= 12 { + return true; + } + } else if c != ' ' && c != '-' { + run = 0; + } + } + false +} + +fn clean_text(v: Option, what: &str, max: usize) -> AppResult> { + let Some(v) = v.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) else { + return Ok(None); + }; + if v.chars().count() > max { + return Err(bad(format!("{what} en fazla {max} karakter olabilir"))); + } + if looks_sensitive(&v) { + return Err(bad( + "Güvenlik için kart, IBAN ya da müşteri numarası yazmayın", + )); + } + Ok(Some(v)) +} + +fn amount(v: i64, what: &str) -> AppResult { + period::validate_amount(v).map_err(|_| bad(format!("{what} geçersiz"))) +} + +fn valid_date(d: &str) -> AppResult<()> { + crate::services::calendar::parse_date(d) + .map(|_| ()) + .ok_or_else(|| bad("Tarih geçersiz")) +} + +fn valid_month(m: &str) -> AppResult { + month_index(m).ok_or_else(|| bad("Ay geçersiz (YYYY-AA)")) +} + +// ---------- Okuma ---------- + +async fn find( + db: &Database, + coll: &str, + household_id: Uuid, + sort: Document, +) -> AppResult> { + let mut c = db + .collection::(coll) + .find(scope(household_id)) + .sort(sort) + .await?; + let mut out = Vec::new(); + while c.advance().await? { + out.push(c.deserialize_current()?); + } + Ok(out) +} + +/// Eskiden yeniye +pub async fn snapshots(db: &Database, hid: Uuid) -> AppResult> { + find(db, col::DEBT_SNAPSHOTS, hid, doc! { "date": 1, "_id": 1 }).await +} + +/// İlk taksit ayına göre +pub async fn plans(db: &Database, hid: Uuid) -> AppResult> { + find( + db, + col::INSTALLMENT_PLANS, + hid, + doc! { "first_month": 1, "_id": 1 }, + ) + .await +} + +pub async fn targets(db: &Database, hid: Uuid) -> AppResult> { + find(db, col::DEBT_TARGETS, hid, doc! { "month": 1 }).await +} + +// ---------- Ölçümler ---------- + +#[derive(Debug, Default)] +pub struct SnapshotInput { + pub date: Option, + pub kmh: Option, + pub positive_balance: Option, + pub card_balance: Option, + pub remaining_installments: Option, + pub note: Option>, + pub version: Option, +} + +pub async fn create_snapshot( + db: &Database, + hid: Uuid, + i: SnapshotInput, + source: &str, +) -> AppResult { + let date = i.date.ok_or_else(|| bad("Tarih gerekli"))?; + valid_date(&date)?; + let mut d = new_meta(hid); + let id = d.get("_id").cloned().unwrap_or(Bson::Null); + d.insert("date", date); + d.insert("kmh", amount(i.kmh.unwrap_or(0), "KMH")?); + d.insert( + "positive_balance", + amount(i.positive_balance.unwrap_or(0), "Artı bakiye")?, + ); + d.insert( + "card_balance", + amount(i.card_balance.unwrap_or(0), "Kart borcu")?, + ); + d.insert( + "remaining_installments", + amount(i.remaining_installments.unwrap_or(0), "Kalan taksit")?, + ); + d.insert("note", clean_text(i.note.flatten(), "Not", 200)?); + d.insert("source", source); + db.collection::(col::DEBT_SNAPSHOTS) + .insert_one(d) + .await?; + db.collection::(col::DEBT_SNAPSHOTS) + .find_one(doc! { "_id": id }) + .await? + .ok_or_else(|| AppError::Internal("ölçüm okunamadı".into())) +} + +pub async fn update_snapshot( + db: &Database, + hid: Uuid, + id: Uuid, + i: SnapshotInput, +) -> AppResult { + let mut set = doc! { "updated_at": now() }; + if let Some(d) = i.date { + valid_date(&d)?; + set.insert("date", d); + } + for (k, v, w) in [ + ("kmh", i.kmh, "KMH"), + ("positive_balance", i.positive_balance, "Artı bakiye"), + ("card_balance", i.card_balance, "Kart borcu"), + ( + "remaining_installments", + i.remaining_installments, + "Kalan taksit", + ), + ] { + if let Some(v) = v { + set.insert(k, amount(v, w)?); + } + } + if let Some(n) = i.note { + set.insert("note", clean_text(n, "Not", 200)?); + } + update(db, col::DEBT_SNAPSHOTS, hid, id, i.version, set).await +} + +async fn update( + db: &Database, + coll: &str, + hid: Uuid, + id: Uuid, + version: Option, + set: Document, +) -> AppResult { + let mut f = scope(hid); + f.insert("_id", id); + let current = db + .collection::(coll) + .find_one(f.clone()) + .await? + .ok_or(AppError::NotFound)?; + if let Some(v) = version { + if current.get_i32("version").ok() != Some(v) { + return Err(period::stale()); + } + f.insert("version", v); + } + db.collection::(coll) + .find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } }) + .return_document(mongodb::options::ReturnDocument::After) + .await? + .ok_or_else(period::stale) +} + +pub async fn delete(db: &Database, coll: &str, hid: Uuid, id: Uuid) -> AppResult<()> { + let mut f = scope(hid); + f.insert("_id", id); + let ts = now(); + let r = db + .collection::(coll) + .update_one( + f, + doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } }, + ) + .await?; + if r.matched_count == 0 { + return Err(AppError::NotFound); + } + Ok(()) +} + +// ---------- Taksit planları ---------- + +#[derive(Debug, Default)] +pub struct PlanInput { + pub merchant: Option, + pub total_amount: Option, + pub monthly_amount: Option, + pub total_count: Option, + pub first_month: Option, + pub version: Option, +} + +fn check_plan(monthly: i64, total: i64, count: i32) -> AppResult<()> { + if !(1..=120).contains(&count) { + return Err(bad("Taksit sayısı 1–120 olmalı")); + } + if monthly <= 0 || total <= 0 { + return Err(bad("Tutarlar sıfırdan büyük olmalı")); + } + if monthly > total { + return Err(bad("Aylık taksit toplam tutardan büyük olamaz")); + } + Ok(()) +} + +pub async fn create_plan(db: &Database, hid: Uuid, i: PlanInput) -> AppResult { + let merchant = clean_text(i.merchant, "İşyeri", 60)?.ok_or_else(|| bad("İşyeri gerekli"))?; + let total = amount(i.total_amount.unwrap_or(0), "Toplam tutar")?; + let monthly = amount(i.monthly_amount.unwrap_or(0), "Aylık taksit")?; + let count = i.total_count.unwrap_or(0); + check_plan(monthly, total, count)?; + let first = i.first_month.ok_or_else(|| bad("İlk taksit ayı gerekli"))?; + valid_month(&first)?; + let mut d = new_meta(hid); + let id = d.get("_id").cloned().unwrap_or(Bson::Null); + d.insert("merchant", merchant); + d.insert("total_amount", total); + d.insert("monthly_amount", monthly); + d.insert("total_count", count); + d.insert("first_month", first); + db.collection::(col::INSTALLMENT_PLANS) + .insert_one(d) + .await?; + db.collection::(col::INSTALLMENT_PLANS) + .find_one(doc! { "_id": id }) + .await? + .ok_or_else(|| AppError::Internal("taksit okunamadı".into())) +} + +pub async fn update_plan( + db: &Database, + hid: Uuid, + id: Uuid, + i: PlanInput, +) -> AppResult { + let mut f = scope(hid); + f.insert("_id", id); + let cur = db + .collection::(col::INSTALLMENT_PLANS) + .find_one(f) + .await? + .ok_or(AppError::NotFound)?; + let mut set = doc! { "updated_at": now() }; + if let Some(m) = i.merchant { + set.insert( + "merchant", + clean_text(Some(m), "İşyeri", 60)?.ok_or_else(|| bad("İşyeri gerekli"))?, + ); + } + let total = i + .total_amount + .map_or(Ok(cur.total_amount), |v| amount(v, "Toplam tutar"))?; + let monthly = i + .monthly_amount + .map_or(Ok(cur.monthly_amount), |v| amount(v, "Aylık taksit"))?; + let count = i.total_count.unwrap_or(cur.total_count); + check_plan(monthly, total, count)?; + set.insert("total_amount", total); + set.insert("monthly_amount", monthly); + set.insert("total_count", count); + if let Some(m) = i.first_month { + valid_month(&m)?; + set.insert("first_month", m); + } + update(db, col::INSTALLMENT_PLANS, hid, id, i.version, set).await +} + +// ---------- Yol haritası ---------- + +/// Hedef listesini olduğu gibi kaydeder (listede olmayan aylar silinir) +pub async fn set_targets(db: &Database, hid: Uuid, list: Vec<(String, i64)>) -> AppResult<()> { + let mut seen = std::collections::HashSet::new(); + for (m, v) in &list { + valid_month(m)?; + amount(*v, "Hedef")?; + if !seen.insert(m.clone()) { + return Err(bad(format!("{m} ayı iki kez yazılmış"))); + } + } + let coll = db.collection::(col::DEBT_TARGETS); + let ts = now(); + let mut gone = scope(hid); + gone.insert( + "month", + doc! { "$nin": list.iter().map(|(m, _)| m.clone()).collect::>() }, + ); + coll.update_many( + gone, + doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } }, + ) + .await?; + for (m, v) in list { + upsert_target(db, hid, &m, v).await?; + } + Ok(()) +} + +async fn upsert_target(db: &Database, hid: Uuid, month: &str, v: i64) -> AppResult<()> { + let coll = db.collection::(col::DEBT_TARGETS); + let mut f = scope(hid); + f.insert("month", month); + let r = coll + .update_one( + f, + doc! { "$set": { "closing_target": v, "updated_at": now() }, "$inc": { "version": 1 } }, + ) + .await?; + if r.matched_count == 0 { + let mut d = new_meta(hid); + d.insert("month", month); + d.insert("closing_target", v); + coll.insert_one(d).await?; + } + Ok(()) +} + +// ---------- Özet (Panel kartı + /borc) ---------- + +#[derive(Debug)] +pub struct Summary { + pub snapshots: Vec, + pub plans: Vec, + pub targets: Vec, + pub latest: Option, + /// Son ölçüm − önceki ayın son ölçümü + pub change_vs_last_month: Option, + pub start: Option, + /// 0..1 (başlangıçtan bu yana ödenen oran) + pub progress: Option, + /// Tahmini sıfırlanma ayı: hedef listesinde 0 olan ilk ay; yoksa ortalama aylık düşüşle + pub estimated_end: Option, + pub this_month: i32, + /// Önümüzdeki 6 ay (bu ay dahil) taksit yükü + pub schedule: Vec<(String, i64)>, +} + +pub fn summarize( + snapshots: Vec, + plans: Vec, + targets: Vec, + this_month: i32, +) -> Summary { + let latest = snapshots.last().cloned(); + let start = snapshots.first().cloned(); + let change_vs_last_month = latest.as_ref().and_then(|l| { + let lm = month_index(month_of(&l.date))?; + snapshots + .iter() + .rev() + .find(|s| month_index(month_of(&s.date)).is_some_and(|m| m < lm)) + .map(|prev| l.total() - prev.total()) + }); + let progress = match (&start, &latest) { + (Some(s), Some(l)) if s.total() > 0 => { + Some(((s.total() - l.total()) as f64 / s.total() as f64).clamp(0.0, 1.0)) + } + _ => None, + }; + let estimated_end = targets + .iter() + .find(|t| t.closing_target <= 0) + .map(|t| t.month.clone()) + .or_else(|| { + // Ortalama aylık düşüşle (başlangıç → son ölçüm) + let (s, l) = (start.as_ref()?, latest.as_ref()?); + let months = month_index(month_of(&l.date))? - month_index(month_of(&s.date))?; + let paid = s.total() - l.total(); + if months <= 0 || paid <= 0 || l.total() <= 0 { + return None; + } + let per_month = paid as f64 / f64::from(months); + let left = (l.total() as f64 / per_month).ceil() as i32; + Some(month_name(month_index(month_of(&l.date))? + left)) + }); + let schedule = (0..6) + .map(|k| { + ( + month_name(this_month + k), + installment_load(&plans, this_month + k), + ) + }) + .collect(); + Summary { + snapshots, + plans, + targets, + latest, + change_vs_last_month, + start, + progress, + estimated_end, + this_month, + schedule, + } +} + +pub async fn summary(db: &Database, hid: Uuid, today: &str) -> AppResult { + Ok(summarize( + snapshots(db, hid).await?, + plans(db, hid).await?, + targets(db, hid).await?, + month_index(month_of(today)).unwrap_or(0), + )) +} + +// ---------- Dönem (aylık plan) ---------- + +#[derive(Debug)] +pub struct PeriodDebt { + /// Ay başı toplam borç + pub opening_total: Option, + /// `manual` | `snapshot` | `none` + pub opening_source: &'static str, + pub opening_date: Option, + /// Devreden borç = ay başı toplam − kalan taksitler (planda gösterilen) + pub carried: Option, + pub installment_load: i64, + /// Borç ödemesi = dönemin birikim hedefi (C) + pub payment: i64, + /// Ay sonu hedefi = ay başı − C − taksit yükü + pub closing_target: Option, + /// Yol haritasındaki hedef (varsa) + pub roadmap_target: Option, + /// Dönem içindeki son ölçüm + pub latest_in_period: Option<(String, i64)>, +} + +pub fn period_debt( + p: &crate::models::period::Period, + last_date: &str, + snapshots: &[Snapshot], + plans: &[InstallmentPlan], + targets: &[Target], +) -> PeriodDebt { + let month = month_index(month_of(&p.start_date)).unwrap_or(0); + let before = snapshots + .iter() + .rev() + .find(|s| s.date.as_str() <= p.start_date.as_str()); + let (opening_total, opening_source, opening_date, remaining) = match (p.opening_debt, before) { + (Some(v), _) => (Some(v), "manual", None, remaining_after(plans, month - 1)), + (None, Some(s)) => ( + Some(s.total()), + "snapshot", + Some(s.date.clone()), + s.remaining_installments, + ), + (None, None) => (None, "none", None, 0), + }; + let load = installment_load(plans, month); + PeriodDebt { + opening_total, + opening_source, + opening_date, + carried: opening_total.map(|t| t - remaining), + installment_load: load, + payment: p.savings_goal, + closing_target: opening_total.map(|t| t - p.savings_goal - load), + roadmap_target: targets + .iter() + .find(|t| t.month == month_of(&p.start_date)) + .map(|t| t.closing_target), + latest_in_period: snapshots + .iter() + .rev() + .find(|s| s.date.as_str() > p.start_date.as_str() && s.date.as_str() <= last_date) + .map(|s| (s.date.clone(), s.total())), + } +} + +pub async fn set_opening( + db: &Database, + hid: Uuid, + period_id: Uuid, + amount_kurus: Option, +) -> AppResult<()> { + if let Some(v) = amount_kurus { + amount(v, "Ay başı borç")?; + } + let mut f = scope(hid); + f.insert("_id", period_id); + let r = db + .collection::(col::PERIODS) + .update_one( + f, + doc! { "$set": { "opening_debt": amount_kurus, "updated_at": now() }, "$inc": { "version": 1 } }, + ) + .await?; + if r.matched_count == 0 { + return Err(AppError::NotFound); + } + Ok(()) +} + +// ---------- İçe aktarma (docs/BORC_TAKIBI.md biçimi) ---------- + +/// JSON sayısı (TL, en fazla 2 ondalık) → kuruş; kayan nokta kullanılmaz +pub fn tl_to_kurus(v: &serde_json::Value) -> Option { + let s = match v { + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::String(s) => s.trim().replace(',', "."), + _ => return None, + }; + let (int, frac) = s.split_once('.').unwrap_or((&s, "")); + if int.is_empty() || !int.bytes().all(|b| b.is_ascii_digit()) || frac.len() > 2 { + return None; + } + if !frac.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let frac = format!("{frac:0<2}"); + Some(int.parse::().ok()? * 100 + frac.parse::().ok()?) +} + +#[derive(Debug, Default)] +pub struct ImportPlan { + pub snapshots: Vec<(SnapshotInput, Option)>, + pub plans: Vec, + pub targets: Vec<(String, i64)>, + pub errors: Vec, +} + +/// Kaynak belgedeki JSON: `{ asOf, debtSnapshots[], installmentPlans[], roadmap[] }` +pub fn parse_import(v: &serde_json::Value) -> ImportPlan { + let mut out = ImportPlan::default(); + let as_of = v.get("asOf").and_then(|x| x.as_str()).and_then(month_index); + let get = |o: &serde_json::Value, k: &str| o.get(k).and_then(tl_to_kurus); + for (i, s) in v + .get("debtSnapshots") + .and_then(|x| x.as_array()) + .into_iter() + .flatten() + .enumerate() + { + let date = s.get("date").and_then(|d| d.as_str()).map(str::to_string); + let fields = [ + get(s, "kmh"), + get(s, "positiveBalance"), + get(s, "cardBalance"), + get(s, "remainingInstallments"), + ]; + if date.is_none() || fields.iter().any(Option::is_none) { + out.errors.push(format!( + "{}. ölçüm: tarih ya da tutar eksik/geçersiz", + i + 1 + )); + continue; + } + out.snapshots.push(( + SnapshotInput { + date, + kmh: fields[0], + positive_balance: fields[1], + card_balance: fields[2], + remaining_installments: fields[3], + note: Some(s.get("note").and_then(|n| n.as_str()).map(str::to_string)), + version: None, + }, + get(s, "total"), + )); + } + for (i, p) in v + .get("installmentPlans") + .and_then(|x| x.as_array()) + .into_iter() + .flatten() + .enumerate() + { + let count = p + .get("totalCount") + .and_then(|c| c.as_i64()) + .map(|c| c as i32); + // İlk taksit ayı: açıkça verilmişse o; yoksa asOf − paidCount + 1 + let first = p + .get("firstMonth") + .and_then(|f| f.as_str()) + .and_then(month_index) + .or_else(|| { + let paid = p.get("paidCount").and_then(|c| c.as_i64())? as i32; + Some(as_of? - paid + 1) + }); + match (count, first) { + (Some(c), Some(f)) => out.plans.push(PlanInput { + merchant: p + .get("merchant") + .and_then(|m| m.as_str()) + .map(str::to_string), + total_amount: get(p, "totalAmount"), + monthly_amount: get(p, "monthlyAmount"), + total_count: Some(c), + first_month: Some(month_name(f)), + version: None, + }), + _ => out.errors.push(format!( + "{}. taksit: adet ya da ilk ay (firstMonth / asOf + paidCount) eksik", + i + 1 + )), + } + } + for (i, t) in v + .get("roadmap") + .and_then(|x| x.as_array()) + .into_iter() + .flatten() + .enumerate() + { + match ( + t.get("month").and_then(|m| m.as_str()), + get(t, "closingDebtTarget"), + ) { + (Some(m), Some(val)) if month_index(m).is_some() => out.targets.push((m.into(), val)), + _ => out + .errors + .push(format!("{}. hedef: ay ya da tutar geçersiz", i + 1)), + } + } + out +} + +/// Ön izleme satırı +#[derive(Debug)] +pub struct ImportRow { + pub kind: &'static str, + pub label: String, + pub amount: i64, + /// `new` | `exists` | `update` | `error` + pub status: &'static str, + pub message: Option, +} + +pub async fn import( + db: &Database, + hid: Uuid, + v: &serde_json::Value, + commit: bool, +) -> AppResult<(Vec, Vec)> { + let plan = parse_import(v); + let existing_snaps = snapshots(db, hid).await?; + let existing_plans = plans(db, hid).await?; + let existing_targets: BTreeMap = targets(db, hid) + .await? + .into_iter() + .map(|t| (t.month, t.closing_target)) + .collect(); + let mut rows = Vec::new(); + let mut errors = plan.errors.clone(); + + for (s, declared) in plan.snapshots { + let date = s.date.clone().unwrap_or_default(); + let t = total( + s.kmh.unwrap_or(0), + s.card_balance.unwrap_or(0), + s.remaining_installments.unwrap_or(0), + s.positive_balance.unwrap_or(0), + ); + let (status, message) = if let Some(d) = declared.filter(|d| *d != t) { + ( + "error", + Some(format!("dosyadaki toplam ({d}) hesapla tutmuyor")), + ) + } else if existing_snaps.iter().any(|e| e.date == date) { + ("exists", None) + } else { + ("new", None) + }; + if commit && status == "new" { + create_snapshot(db, hid, s, "import").await?; + } + rows.push(ImportRow { + kind: "snapshot", + label: date, + amount: t, + status, + message, + }); + } + for p in plan.plans { + let label = format!( + "{} · {} taksit · ilk {}", + p.merchant.clone().unwrap_or_default(), + p.total_count.unwrap_or(0), + p.first_month.clone().unwrap_or_default() + ); + let monthly = p.monthly_amount.unwrap_or(0); + let dup = existing_plans.iter().any(|e| { + Some(&e.merchant) == p.merchant.as_ref() + && Some(&e.first_month) == p.first_month.as_ref() + && e.monthly_amount == monthly + && Some(e.total_count) == p.total_count + }); + let check = check_plan( + monthly, + p.total_amount.unwrap_or(0), + p.total_count.unwrap_or(0), + ); + let (status, message) = match (&check, dup) { + (Err(e), _) => ("error", Some(e.to_string())), + (Ok(()), true) => ("exists", None), + _ => ("new", None), + }; + if commit && status == "new" { + create_plan(db, hid, p).await?; + } + rows.push(ImportRow { + kind: "installment", + label, + amount: monthly, + status, + message, + }); + } + for (m, val) in plan.targets { + let status = match existing_targets.get(&m) { + Some(v) if *v == val => "exists", + Some(_) => "update", + None => "new", + }; + if commit && status != "exists" { + upsert_target(db, hid, &m, val).await?; + } + rows.push(ImportRow { + kind: "target", + label: m, + amount: val, + status, + message: None, + }); + } + if rows.is_empty() && errors.is_empty() { + errors.push("Dosyada ölçüm, taksit ya da hedef bulunamadı".into()); + } + Ok((rows, errors)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snap(date: &str, kmh: i64, pos: i64, card: i64, rem: i64) -> Snapshot { + Snapshot { + id: crate::models::common::new_id(), + date: date.into(), + kmh, + positive_balance: pos, + card_balance: card, + remaining_installments: rem, + note: None, + source: "manual".into(), + version: 1, + } + } + + /// Kaynak belgedeki 8 taksit (paidCount, Kasım 2026 itibarıyla) + fn doc_plans() -> Vec { + let nov = month_index("2026-11").unwrap(); + [ + (1_902_329, 271_761, 7, 5), + (299_915, 33_324, 9, 5), + (83_614, 11_945, 7, 5), + (89_019, 12_717, 7, 4), + (119_470, 17_067, 7, 3), + (51_994, 7_428, 7, 3), + (127_413, 18_202, 7, 2), + (176_215, 58_738, 3, 2), + ] + .into_iter() + .map(|(total, monthly, count, paid)| InstallmentPlan { + id: crate::models::common::new_id(), + merchant: "x".into(), + total_amount: total, + monthly_amount: monthly, + total_count: count, + first_month: month_name(nov - paid + 1), + version: 1, + }) + .collect() + } + + #[test] + fn months() { + assert_eq!(month_index("2026-11"), Some(2026 * 12 + 10)); + assert_eq!(month_name(month_index("2026-12").unwrap() + 1), "2027-01"); + assert_eq!(month_index("2026-13"), None); + assert_eq!(month_index("26-11"), None); + } + + #[test] + fn totals_match_the_source_document() { + // 31.10.2026 ve 13.11.2026 ölçümleri + assert_eq!( + snap("2026-10-31", 0, 236_311, 6_671_590, 1_417_769).total(), + 7_853_048 + ); + assert_eq!( + snap("2026-11-13", 3_926_444, 0, 2_994_854, 986_587).total(), + 7_907_885 + ); + } + + #[test] + fn installment_loads_match_the_source_document() { + let plans = doc_plans(); + let m = |s: &str| month_index(s).unwrap(); + let loads: Vec = [ + "2026-11", "2026-12", "2027-01", "2027-02", "2027-03", "2027-04", "2027-05", + ] + .iter() + .map(|x| installment_load(&plans, m(x))) + .collect(); + assert_eq!( + loads, + [431_182, 431_182, 372_444, 88_738, 76_021, 18_202, 0] + ); + // Ölçümlerdeki "kalan taksit": Ekim sonu → Kasım ve sonrası; Kasım → Aralık ve sonrası + assert_eq!(remaining_after(&plans, m("2026-10")), 1_417_769); + assert_eq!(remaining_after(&plans, m("2026-11")), 986_587); + assert_eq!(plans[0].paid_count(m("2026-11")), 5); + assert_eq!(plans[0].remaining_count(m("2026-11")), 2); + assert_eq!( + plans[0].last_month().as_deref(), + Some("2027-01"), + "Temmuz + 6 ay" + ); + } + + #[test] + fn november_plan_carried_debt() { + let p = crate::models::period::Period { + id: crate::models::common::new_id(), + name: "Kasım 2026".into(), + start_date: "2026-11-01".into(), + end_date: "2026-12-01".into(), + status: crate::models::period::PeriodStatus::Open, + savings_goal: 500_000, + reflection_md: None, + to_savings: None, + closed_at: None, + opening_debt: None, + version: 1, + }; + let snaps = vec![ + snap("2026-10-31", 0, 236_311, 6_671_590, 1_417_769), + snap("2026-11-13", 3_926_444, 0, 2_994_854, 986_587), + ]; + let plans = doc_plans(); + let targets = vec![Target { + month: "2026-11".into(), + closing_target: 6_699_500, + }]; + let d = period_debt(&p, "2026-11-30", &snaps, &plans, &targets); + assert_eq!(d.opening_total, Some(7_853_048)); + assert_eq!(d.opening_source, "snapshot"); + assert_eq!(d.carried, Some(6_435_279), "Kasım devreden borç 64.352,79"); + assert_eq!(d.installment_load, 431_182); + assert_eq!(d.closing_target, Some(7_853_048 - 500_000 - 431_182)); + assert_eq!(d.roadmap_target, Some(6_699_500)); + assert_eq!(d.latest_in_period, Some(("2026-11-13".into(), 7_907_885))); + + // Elle ay başı: devreden = elle − Ekim sonrası kalan taksit + let manual = crate::models::period::Period { + opening_debt: Some(8_000_000), + ..p + }; + let d = period_debt(&manual, "2026-11-30", &snaps, &plans, &targets); + assert_eq!( + (d.opening_source, d.carried), + ("manual", Some(8_000_000 - 1_417_769)) + ); + } + + #[test] + fn summary_change_progress_and_end() { + let snaps = vec![ + snap("2026-10-31", 0, 236_311, 6_671_590, 1_417_769), + snap("2026-11-13", 3_926_444, 0, 2_994_854, 986_587), + ]; + let targets = vec![ + Target { + month: "2027-02".into(), + closing_target: 1_568_100, + }, + Target { + month: "2027-03".into(), + closing_target: 0, + }, + ]; + let s = summarize(snaps, doc_plans(), targets, month_index("2026-11").unwrap()); + assert_eq!( + s.change_vs_last_month, + Some(7_907_885 - 7_853_048), + "+548,37 arttı" + ); + assert_eq!(s.progress, Some(0.0), "borç arttıysa ilerleme 0"); + assert_eq!(s.estimated_end.as_deref(), Some("2027-03")); + assert_eq!(s.schedule[0], ("2026-11".to_string(), 431_182)); + assert_eq!(s.schedule[5], ("2027-04".to_string(), 18_202)); + } + + #[test] + fn parsing_and_safety() { + assert_eq!(tl_to_kurus(&serde_json::json!(78530.48)), Some(7_853_048)); + assert_eq!(tl_to_kurus(&serde_json::json!(2363.11)), Some(236_311)); + assert_eq!(tl_to_kurus(&serde_json::json!(0)), Some(0)); + assert_eq!(tl_to_kurus(&serde_json::json!(66995)), Some(6_699_500)); + assert_eq!(tl_to_kurus(&serde_json::json!("1.5")), Some(150)); + assert_eq!(tl_to_kurus(&serde_json::json!(-5)), None); + assert_eq!(tl_to_kurus(&serde_json::json!(1.234)), None); + assert!(looks_sensitive("TR12 0006 4000 0011 2345 6789 01")); + assert!(looks_sensitive("kart 4543-1234-5678-9010")); + assert!(!looks_sensitive("Koçtaş/Chippin 7 taksit 2026")); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 6f88448..bed1728 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -9,6 +9,7 @@ pub mod calendar; pub mod catalog; pub mod closing; pub mod csv_io; +pub mod debt; pub mod entry; pub mod holding; pub mod invite; diff --git a/backend/tests/debt.rs b/backend/tests/debt.rs new file mode 100644 index 0000000..73fb6e3 --- /dev/null +++ b/backend/tests/debt.rs @@ -0,0 +1,313 @@ +//! Borç takibi (docs/BORC_TAKIBI.md): kaynak belgedeki JSON'un içe aktarılması (kabul rakamları), +//! dönemin ay başı / devreden borcu, elle düzeltme, ölçüm ve taksit işlemleri, güvenlik, yalıtım. + +mod common; + +use axum::http::StatusCode; +use common::{Session, TestApp}; +use serde_json::{Value, json}; + +/// Kaynak belgedeki başlangıç verisi (+ `asOf`: paidCount'ların ait olduğu ay) +fn source_json() -> Value { + json!({ + "asOf": "2026-11", + "debtSnapshots": [ + { "date": "2026-10-31", "kmh": 0, "positiveBalance": 2363.11, "cardBalance": 66715.90, "remainingInstallments": 14177.69, "total": 78530.48, "note": "Kasım başı" }, + { "date": "2026-11-13", "kmh": 39264.44, "positiveBalance": 0, "cardBalance": 29948.54, "remainingInstallments": 9865.87, "total": 79078.85, "note": "ara ölçüm" } + ], + "installmentPlans": [ + { "merchant": "Koçtaş/Chippin", "totalAmount": 19023.29, "monthlyAmount": 2717.61, "totalCount": 7, "paidCount": 5 }, + { "merchant": "Hepsiburada", "totalAmount": 2999.15, "monthlyAmount": 333.24, "totalCount": 9, "paidCount": 5 }, + { "merchant": "Koçtaş/Chippin", "totalAmount": 836.14, "monthlyAmount": 119.45, "totalCount": 7, "paidCount": 5 }, + { "merchant": "Koçtaş/Chippin", "totalAmount": 890.19, "monthlyAmount": 127.17, "totalCount": 7, "paidCount": 4 }, + { "merchant": "Koçtaş Fix", "totalAmount": 1194.70, "monthlyAmount": 170.67, "totalCount": 7, "paidCount": 3 }, + { "merchant": "Koçtaş/Chippin", "totalAmount": 519.94, "monthlyAmount": 74.28, "totalCount": 7, "paidCount": 3 }, + { "merchant": "Koçtaş/Chippin", "totalAmount": 1274.13, "monthlyAmount": 182.02, "totalCount": 7, "paidCount": 2 }, + { "merchant": "Trendyol", "totalAmount": 1762.15, "monthlyAmount": 587.38, "totalCount": 3, "paidCount": 2 } + ], + "roadmap": [ + { "month": "2026-11", "closingDebtTarget": 66995 }, + { "month": "2026-12", "closingDebtTarget": 49890 }, + { "month": "2027-01", "closingDebtTarget": 33286 }, + { "month": "2027-02", "closingDebtTarget": 15681 }, + { "month": "2027-03", "closingDebtTarget": 0 } + ] + }) +} + +fn statuses(r: &Value) -> Vec { + r["rows"] + .as_array() + .unwrap() + .iter() + .map(|x| x["status"].as_str().unwrap().to_string()) + .collect() +} + +async fn import(app: &TestApp, s: &Session, commit: bool) -> Value { + let (st, r) = app + .post(&format!("/debt/import?commit={commit}"), s, source_json()) + .await; + assert_eq!(st, StatusCode::OK, "{r}"); + r +} + +#[tokio::test] +async fn import_source_document_and_acceptance_numbers() { + let app = TestApp::new().await; + let s = app.register("Sahip", None).await; + + // Ön izleme: hiçbir şey yazılmaz, toplamlar belgedekiyle aynı + let r = import(&app, &s, false).await; + assert_eq!(r["errors"], json!([])); + assert_eq!(statuses(&r), vec!["new"; 15]); + let rows = r["rows"].as_array().unwrap(); + assert_eq!( + (rows[0]["amount"].as_i64(), rows[1]["amount"].as_i64()), + (Some(7_853_048), Some(7_907_885)) + ); + assert_eq!(rows[2]["label"], "Koçtaş/Chippin · 7 taksit · ilk 2026-07"); + let (_, d) = app.get("/debt", &s).await; + assert_eq!(d["snapshots"], json!([]), "ön izleme yazmaz"); + + // Aktar + let r = import(&app, &s, true).await; + assert_eq!(r["committed"], true); + let (_, d) = app.get("/debt", &s).await; + assert_eq!(d["latest"]["total"], 7_907_885); + assert_eq!(d["latest"]["change"], 7_907_885 - 7_853_048); + assert_eq!(d["change_vs_last_month"], 54_837); + assert_eq!(d["start"]["total"], 7_853_048); + assert_eq!(d["estimated_end"], "2027-03"); + assert_eq!(d["installments"].as_array().unwrap().len(), 8); + assert_eq!(d["targets"].as_array().unwrap().len(), 5); + assert_eq!(d["schedule"].as_array().unwrap().len(), 6); + assert_eq!(d["snapshots"][0]["date"], "2026-11-13", "en yeni önce"); + + // Tekrar aktarınca çift kayıt yok + let r = import(&app, &s, true).await; + assert!(statuses(&r).iter().all(|x| x == "exists"), "{r}"); + let (_, d) = app.get("/debt", &s).await; + assert_eq!(d["snapshots"].as_array().unwrap().len(), 2); + + // Kasım planı: devreden borç 64.352,79; taksit yükü 4.311,82 + let (_, p) = app + .post( + "/periods", + &s, + json!({ "start_date": "2026-11-01", "end_date": "2026-12-01", "savings_goal": 700_000 }), + ) + .await; + let pid = p["id"].as_str().unwrap(); + let (st, pd) = app.get(&format!("/periods/{pid}/debt"), &s).await; + assert_eq!(st, StatusCode::OK, "{pd}"); + assert_eq!(pd["opening_total"], 7_853_048); + assert_eq!(pd["opening_source"], "snapshot"); + assert_eq!(pd["carried"], 6_435_279); + assert_eq!(pd["installment_load"], 431_182); + assert_eq!(pd["payment"], 700_000); + assert_eq!(pd["closing_target"], 7_853_048 - 700_000 - 431_182); + assert_eq!(pd["roadmap_target"], 6_699_500); + assert_eq!( + (pd["latest_date"].as_str(), pd["latest_total"].as_i64()), + (Some("2026-11-13"), Some(7_907_885)) + ); + + // Elle ay başı → manual; null → yine ölçümden + let (st, _) = app + .put( + &format!("/periods/{pid}/opening-debt"), + &s, + json!({ "amount": 8_000_000 }), + ) + .await; + assert_eq!(st, StatusCode::NO_CONTENT); + let (_, pd) = app.get(&format!("/periods/{pid}/debt"), &s).await; + assert_eq!( + (pd["opening_source"].as_str(), pd["carried"].as_i64()), + (Some("manual"), Some(8_000_000 - 1_417_769)) + ); + app.put( + &format!("/periods/{pid}/opening-debt"), + &s, + json!({ "amount": null }), + ) + .await; + let (_, pd) = app.get(&format!("/periods/{pid}/debt"), &s).await; + assert_eq!(pd["opening_source"], "snapshot"); + + // JSON yedeği borç verisini de taşır + let (_, _, file) = app.get_text("/backup/download", &s).await; + let v: Value = serde_json::from_str(&file).unwrap(); + assert_eq!( + ( + v["counts"]["debt_snapshots"].as_i64(), + v["counts"]["installment_plans"].as_i64(), + v["counts"]["debt_targets"].as_i64() + ), + (Some(2), Some(8), Some(5)) + ); + + app.cleanup().await; +} + +#[tokio::test] +async fn import_rejects_inconsistent_file() { + let app = TestApp::new().await; + let s = app.register("Sahip", None).await; + let mut bad = source_json(); + bad["debtSnapshots"][0]["total"] = json!(78530.49); + bad["installmentPlans"][0]["monthlyAmount"] = json!(99999); + bad["installmentPlans"][1] + .as_object_mut() + .unwrap() + .remove("paidCount"); + bad["roadmap"][0]["closingDebtTarget"] = json!("çok"); + let (_, r) = app.post("/debt/import?commit=true", &s, bad).await; + let rows = r["rows"].as_array().unwrap(); + assert_eq!(rows[0]["status"], "error", "toplam tutmuyor"); + assert!(rows[0]["message"].as_str().unwrap().contains("tutmuyor")); + assert_eq!(rows[2]["status"], "error", "aylık > toplam"); + assert_eq!(r["errors"].as_array().unwrap().len(), 2, "{r}"); + let (_, d) = app.get("/debt", &s).await; + assert_eq!( + d["snapshots"].as_array().unwrap().len(), + 1, + "yalnız geçerli ölçüm yazıldı" + ); + let (_, r) = app.post("/debt/import", &s, json!({ "baska": 1 })).await; + assert_eq!( + r["errors"][0], + "Dosyada ölçüm, taksit ya da hedef bulunamadı" + ); + app.cleanup().await; +} + +#[tokio::test] +async fn snapshots_installments_targets_and_safety() { + let app = TestApp::new().await; + let s = app.register("Sahip", None).await; + + let (st, sn) = app + .post( + "/debt/snapshots", + &s, + json!({ "date": "2026-11-30", "kmh": 1_000_000, "positive_balance": 0, "card_balance": 2_500_000, "remaining_installments": 986_587, "note": "maaş sonrası" }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{sn}"); + assert_eq!(sn["total"], 1_000_000 + 2_500_000 + 986_587); + let id = sn["id"].as_str().unwrap(); + let (st, u) = app + .patch( + &format!("/debt/snapshots/{id}"), + &s, + json!({ "kmh": 0, "version": 1 }), + ) + .await; + assert_eq!((st, u["total"].as_i64()), (StatusCode::OK, Some(3_486_587))); + let (st, _) = app + .patch( + &format!("/debt/snapshots/{id}"), + &s, + json!({ "kmh": 5, "version": 1 }), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT, "eski sürüm"); + + // Kart / IBAN numarası yazılamaz + for note in ["kart 4543 1234 5678 9010", "TR120006400000112345678901"] { + let (st, b) = app + .post( + "/debt/snapshots", + &s, + json!({ "date": "2026-12-01", "note": note }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + assert!( + b["error"]["message"].as_str().unwrap().contains("IBAN"), + "{b}" + ); + } + let (st, _) = app + .post("/debt/snapshots", &s, json!({ "date": "2026-02-30" })) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + + // Taksit + let (st, p) = app + .post( + "/debt/installments", + &s, + json!({ "merchant": "Mağaza", "total_amount": 120_000, "monthly_amount": 20_000, "total_count": 6, "first_month": "2026-10" }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{p}"); + assert_eq!(p["last_month"], "2027-03"); + for bad in [ + json!({ "merchant": "X", "total_amount": 100, "monthly_amount": 200, "total_count": 2, "first_month": "2026-10" }), + json!({ "merchant": "X", "total_amount": 100, "monthly_amount": 50, "total_count": 0, "first_month": "2026-10" }), + json!({ "merchant": "X", "total_amount": 100, "monthly_amount": 50, "total_count": 2, "first_month": "2026-13" }), + ] { + let (st, _) = app.post("/debt/installments", &s, bad).await; + assert_eq!(st, StatusCode::BAD_REQUEST); + } + + // Hedefler: liste olduğu gibi + let (st, _) = app + .put("/debt/targets", &s, json!({ "targets": [{ "month": "2026-12", "closing_target": 100 }, { "month": "2027-01", "closing_target": 0 }] })) + .await; + assert_eq!(st, StatusCode::NO_CONTENT); + app.put( + "/debt/targets", + &s, + json!({ "targets": [{ "month": "2027-01", "closing_target": 0 }] }), + ) + .await; + let (_, d) = app.get("/debt", &s).await; + assert_eq!( + d["targets"], + json!([{ "month": "2027-01", "closing_target": 0 }]) + ); + let (st, _) = app + .put("/debt/targets", &s, json!({ "targets": [{ "month": "2027-01", "closing_target": 0 }, { "month": "2027-01", "closing_target": 5 }] })) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "aynı ay iki kez"); + + // Başka hane göremez / silemez; silinen listeden çıkar + let other = app.register("Diger", None).await; + let (_, od) = app.get("/debt", &other).await; + assert_eq!(od["snapshots"], json!([])); + let (st, _) = app.delete(&format!("/debt/snapshots/{id}"), &other).await; + assert_eq!(st, StatusCode::NOT_FOUND); + let (st, _) = app.delete(&format!("/debt/snapshots/{id}"), &s).await; + assert_eq!(st, StatusCode::NO_CONTENT); + let (_, d) = app.get("/debt", &s).await; + assert_eq!(d["snapshots"], json!([])); + assert!(d["latest"].is_null()); + + // Kapalı dönemde ay başı borç değişmez + let (_, p) = app + .post( + "/periods", + &s, + json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }), + ) + .await; + let pid = p["id"].as_str().unwrap(); + app.post(&format!("/periods/{pid}/close"), &s, json!({})) + .await; + let (st, _) = app + .put( + &format!("/periods/{pid}/opening-debt"), + &s, + json!({ "amount": 1 }), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT); + let (_, pd) = app.get(&format!("/periods/{pid}/debt"), &s).await; + assert_eq!(pd["opening_source"], "none"); + + app.cleanup().await; +} diff --git a/docs/BORC_TAKIBI.md b/docs/BORC_TAKIBI.md new file mode 100644 index 0000000..241f1fb --- /dev/null +++ b/docs/BORC_TAKIBI.md @@ -0,0 +1,60 @@ +# Borç takibi (Kakeibo uyarlaması) + +Kaynak: `butcem/docs/BORC_TAKIBI.md` (kullanıcının özellik tanımı). Kişisel rakamlar (seed) bu repoya +**girmez**; canlıya "İçe aktar (JSON)" ile yüklenir (dosya repoda tutulmaz). + +## Amaç +Gerçek banka borcu (KMH + kredi kartı + ekstreye gelmemiş taksitler) Panel'in en üstünde görünsün ve +her ay küçülmesi izlensin. Hedef: toplam borcu yol haritasındaki son aya kadar sıfırlamak. + +## Kararlar (kullanıcı, 2026-11-14) +- **Ayrı "Borç" modülü.** Kakeibo'daki "devreden borç" (bütçe aşımının devri, `carryovers`) ile + karıştırılmaz; banka borcu **harcanabilir tutarı düşürmez** (aylara yayılarak birikimle ödenir). + Borç sıfırlanana kadar dönemin birikim hedefi (C) "Borç ödemesi" olarak etiketlenir. +- Başlangıç verisi canlıya **içe aktarma dosyasıyla** (önizleme → onay). + +## Formüller (tutarlar kuruş, `i64`) +``` +toplam borç = KMH eksi bakiyesi + kart borcu (provizyon dahil) + kalan taksitler − maaş hesabı artı bakiyesi +devreden borç = toplam borç − kalan taksitler (aylık planda gösterilen) +taksit yükü(ay) = Σ aylık tutar (ilk taksit ayı ≤ ay ≤ son taksit ayı) +kalan taksit(ay) = Σ aylık tutar × (ay'dan SONRAKİ taksit sayısı) (ölçümün ayına göre önerilir) +ay sonu hedefi = ay başı toplam borç − birikim (C) − o ayın taksit yükü +aylık başarı = ay başı toplam − ay sonu toplam ≥ C + o ayın taksit yükü +``` +- Toplam **saklanmaz**, her okumada hesaplanır. +- Taksit planı "ödenen taksit sayısı" yerine **ilk taksit ayı** (`first_month`) ile saklanır; ödenen / + kalan sayılar her ay kendiliğinden hesaplanır. İçe aktarmada `asOf` ayı + `paidCount` → + `first_month = asOf − paidCount + 1`. +- Ay başı borç: dönemin başlangıcından önceki (≤ başlangıç günü) son ölçüm; dönemde elle düzeltilebilir. + +## Veri modeli (MongoDB, ortak alanlar: `_id` UUID v7, `household_id`, zaman damgaları, `deleted_at`, `version`) +- `debt_snapshots`: `date`, `kmh`, `positive_balance`, `card_balance`, `remaining_installments`, + `note`, `source` (`manual` | `import`) +- `installment_plans`: `merchant`, `total_amount`, `monthly_amount`, `total_count`, `first_month` (`YYYY-MM`) +- `debt_targets`: `month` (`YYYY-MM`), `closing_target` (yol haritası, toplam borç cinsinden) +- `periods.opening_debt` (isteğe bağlı elle ay başı toplam borç) + +**Güvenlik:** IBAN, kart ve müşteri numarası saklanmaz; açıklama/işyeri alanlarında 12+ haneli sayı +dizisi reddedilir. + +## Ekranlar +1. Panel en üstü: güncel toplam borç, geçen aya göre değişim, başlangıç → 0 ilerleme, tahmini bitiş, + KMH · Kart · Kalan taksit dökümü. +2. `/borc`: gerçek (ölçümler) + hedef (yol haritası, kesikli) çizgi grafik, ölçüm tablosu, "Yeni ölçüm" + (toplam canlı hesaplanır, kalan taksit önerilir), hedefleri düzenleme, içe aktarma. +3. Aylık plan: ay başı borç (otomatik, düzenlenebilir), devreden borç, "Borç ödemesi" (C), taksit yükü, + hedef ay sonu borç (salt okunur); taksit yükü Sabit giderlere "Taksitler" olarak önerilir. +4. `/taksitler`: açık planlar, önümüzdeki 6 ayın taksit yükü (çubuk); yeni taksitte uyarı + "Borç sıfırlanana kadar yeni taksit planı yok." + +## Kabul ölçütleri (kaynak belgeyle aynı) +- Ölçüm toplamları kaynak belgedeki iki ölçümle birebir (78.530,48 ve 79.078,85). +- Kasım planında devreden borç 64.352,79. +- Taksit yükü: Kasım ve Aralık 4.311,82 · Ocak 3.724,44 · Şubat 887,38 · Mart 760,21 · Nisan 182,02. +- Gerçek ve hedef aynı grafikte; hiçbir yerde IBAN / kart numarası yok. + +## Adımlar +- B1 backend (modeller, hesaplar, uç noktalar, içe aktarma, testler) +- B2 arayüz: Panel kartı, `/borc`, `/taksitler` +- B3 aylık plan entegrasyonu + içe aktarma dosyasının hazırlanması (kullanıcıya, repo dışı) diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index b98742a..ffc14b6 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -230,6 +230,17 @@ Renkler ve ikonlar frontend tasarım token'larında. - Geri yükleme = **tam değiştirme** (birleştirme yok), tek transaction. Aynı hane → kimlikler korunur; başka hane → tüm `_id`'ler yeniden üretilir, `*_id` referansları çevrilir, `household_id` hedef hane, `*_by` hanede yoksa geri yükleyen kişi. Hane adı yalnız aynı hanede geri gelir; ayarlar her zaman. - Sürüm: `format_version`/`schema_version` daha yeniyse reddedilir; eskiyse hane düzeyi yükseltmeler uygulanır (v<2 → varsayılan varlıklar). +### 3.14c Borç takibi (docs/BORC_TAKIBI.md) +```js +debt_snapshots { _id, household_id, date: "YYYY-MM-DD", kmh, positive_balance, card_balance, + remaining_installments, note, source: "manual" | "import", … } // toplam saklanmaz +installment_plans { _id, household_id, merchant, total_amount, monthly_amount, total_count, + first_month: "YYYY-MM", … } // ödenen/kalan her ay hesaplanır +debt_targets { _id, household_id, month: "YYYY-MM", closing_target, … } // yol haritası +periods.opening_debt // isteğe bağlı elle ay başı toplam borç +``` +- Kakeibo "devreden borç"u (`carryovers`, bütçe aşımı) ile ilgisizdir; harcanabilir tutarı etkilemez. + ### 3.15 `schema_migrations` ```js { _id: 1 /* sürüm numarası */, name: "initial", applied_at } diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 57a7f53..0bccdfe 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,10 +1,11 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 10 ✅ tamamlandı.** Sıradaki: kullanıcının **geniş kapsamlı testi** (canlıda, telefonda). Kullanıcının bulduğu hatalar/istekler önce. Faz 11 (Tauri: Windows, Linux, Android) **yalnızca kullanıcı istediğinde** başlar. Kullanıcıya hatırlat: canlı sunucu yedeği (DEPLOY §6) gerçek veriden önce; ilk iş JSON yedeği indirmek. +**Borç takibi – B2 (arayüz):** Panel en üstüne borç kartı (toplam, geçen aya göre ± renkli, başlangıç→0 ilerleme + tahmini bitiş, KMH·Kart·Kalan taksit), `/borc` (Recharts: ölçümler düz çizgi + `debt_targets` kesikli; tablo tarih/KMH/kart/taksit/toplam/değişim; "Yeni ölçüm" formu: toplam canlı, kalan taksit önerisi = taksit planlarından `remaining_after(ay)`; hedefleri düzenle; "İçe aktar (JSON)" ön izleme→onay), `/taksitler` (açık planlar, 6 ay çubuk; "Yeni taksit"te uyarı "Borç sıfırlanana kadar yeni taksit planı yok."). Tipler hazır: `DebtSummary`, `DebtSnapshot`, `InstallmentPlan`, `DebtTarget`, `MonthLoad`, `DebtImportReport`, `PeriodDebt`. Sonra B3. Kullanıcı canlıyı (kakeibo.ta1sph.website) kullanıyor → içe aktarma dosyası B3'te scratchpad'de hazırlanıp verilecek. **Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅. ## 📌 Alınan Kararlar +- **Borç takibi (kullanıcı, 2026-11-14):** `butcem/docs/BORC_TAKIBI.md` Kakeibo'ya uyarlanıyor (`docs/BORC_TAKIBI.md`). Ayrı Borç modülü (harcanabilir tutarı düşürmez; C = "Borç ödemesi"); başlangıç verisi canlıya içe aktarma dosyasıyla (repo dışı). Kullanıcı asıl olarak canlıyı kullanıyor. - **Giriş güvenliği (kullanıcı, 2026-11-14):** reCAPTCHA istendi → seçeneklerden **Cloudflare Turnstile** seçildi (KVKK dostu); kilit **IP'ye göre** (e-postaya göre değil: sahibi kilitleme saldırısı olmasın). Üst üste 3 hata → giriş + kayıt 15 dk. - **Tauri / mobil + masaüstü uygulamalar (kullanıcı, 2026-11-13):** Faz 11 (Windows, Linux, Android) **kullanıcının geniş kapsamlı testinden sonra, kullanıcı prompt verdiğinde** yapılır; kendiliğinden başlanmaz. Şimdi Faz 10: web uygulamasının eksikleri + arayüz cilası (PWA web kurulumu Faz 10'da kalır). - **Aylık rapor tasarımı (kullanıcı onayı, 2026-11-13):** 7 bölüm — Bir bakışta (4 soru + önceki dönem) · Ne yaptım (gruplar ✅≤%90/⚠️≤%100/❌, ilk 5 kategori + ortalamaya göre, haftalar, ödeme türü, birikim) · İyi gitti · Zorladı · Ne yapılabilirdi ("olsaydı" hesapları) · Kendi notların (plan notları + yansıtma, yorumsuz) · Gelecek dönem (önerilen bütçe = son ≤4 dönem ortalaması 50 ₺ yukarı, ≤3 somut adım, devir, boş hedef kutuları) + Ek: tüm kategoriler. Eşik: kategori %20 **ve** 250 ₺. Karşılaştırma: önceki ≤3 kapalı/arşiv dönem. "Sen" dili. Açık dönem → ara rapor (hedef yargısı yok, bitmemiş hafta yok). @@ -155,3 +156,4 @@ - **Faz 10 – Adım 4 (2026-11-13):** Önbellek başlıkları zaten doğruydu (Faz 5: `/assets/*` 1 yıl immutable, HTML no-cache, sıkıştırma). Ana JS 950 KB (gz 284) tek paket → sayfa bazlı `lazy` route: ana 402 KB (gz 128), sayfalar 2–23 KB; Markdown (49 KB gz) ve zod/form (40 KB gz) yalnız kullanan sayfada. İlk sürümde Defter yavaşlamıştı (lazy route → `/auth/me` sıralı) → `prefetchMe` ile paralel; `HydrateFallback: Splash`. Boşta alt menü sayfalarını önden indirme + `useNavigation` ilerleme çizgisi. Ölçüm (scratchpad `e2e/perf.mjs`: yavaş 4G 1,6 Mbps/150 ms + CPU 4×, soğuk; eski sürüm `git worktree` ile ayrı derlenip aynı koşulda, 2 tekrar ort.): Giriş 2,97→2,23 sn, Panel 2,42→2,03, Raporlar 2,36→2,11, Defter 2,33→2,37 (aynı); hazırda JS 295→171–221 KB. Regresyon: katalog (25) + rapor (21) e2e geçti. Not: e2e rapor başlığı artık `.md-h1`. - **Faz 10 – Adım 5 (2026-11-13) → Faz 10 ✅:** `vite-plugin-pwa` 2.0 + `workbox-window` (dev): manifest (tr, standalone, ikonlar + maskable, kısayollar Harcama ekle/Panel), Workbox precache 98–101 dosya (xlsx ve vietnamca/yunanca/kiril font alt kümeleri hariç → runtime CacheFirst `/assets/`), `navigateFallback` index.html (`/api` hariç). `registerType: 'prompt'` → `PwaUpdater` toast + saatlik `reg.update()`. `OfflineBanner` (useSyncExternalStore). Backend `spa.rs` `cache_policy` (sw.js, manifest, registerSW → no-cache) + birim testi. **Bulunan hata:** çevrimdışı açılışta `/auth/me` ağ hatası → RequireAuth kullanıcıyı giriş sayfasına atıyordu; ayrıca TanStack onlineManager açılış durumunu bilmediği için birkaç sn boş açılış → `SessionUnknown` ("Oturum bilgisi alınamadı… Tekrar dene") + `onlineManager.setOnline(navigator.onLine)`; bağlantı gelince kendiliğinden toparlanır. E2E 14 kontrol (CDP `Page.getInstallabilityErrors` boş, önbellekte /api 0, çevrimdışı, güncelleme: dist kopyası + sw.js index revizyonu değiştirilip sunucu yeniden başlatıldı → bildirim → Yenile → yeni sürüm, oturum/sayfa korunur). npm audit: yeni `glob` (orta) yalnız derleme zamanı; diğer uyarılar mevcut `shadcn` CLI'dan. Docker web aşaması geçti. DEPLOY §7 kurulum. - **Giriş güvenliği (2026-11-14):** Backend `auth/guard.rs` (AttemptGuard, ardışık sayaç, başarıda sıfır, süre bitince yeni tur, 1 sa dokunulmayan kayıt silinir), `auth/captcha.rs` (siteverify, 8 sn zaman aşımı, ağ hatası → `Unavailable` sayılmaz ama reddedilir, `internal-error` de öyle), `auth/client_ip.rs` (en sağ XFF, yoksa ConnectInfo; `main.rs` `into_make_service_with_connect_info`). Login/register `precheck` (kilit → captcha) + sayılan hatalar (yanlış şifre `InvalidCredentialsLeft(n)`, RegistrationClosed / BadRequest davet / Conflict e-posta). E-postaya göre giriş limiter'ı kaldırıldı (şifre değiştirmede kullanıcı başına kaldı). Config `TURNSTILE_SITE_KEY`/`SECRET_KEY` (ikisi birden), `TRUST_PROXY` (varsayılan true: Traefik arkasında herkes aynı IP olmasın). Testler `tests/security.rs` (4) + guard/XFF birim; 2 bilinçli bozma yakalandı; backend 214. Frontend `useAuthSecurity` (jeton, her hatada widget reset, kilit → `useAuthGuard` refetch, `dataUpdatedAt`+saniye geri sayım), Turnstile explicit render (tr, tema, flexible), betik yüklenemezse açıklama + düğme kapalı. E2E: Cloudflare test anahtarlarıyla gerçek widget + siteverify; kalan hak 2/1, kilit 14:57 sayaç, yenileme/kayıt/API kilitli; Cloudflare engelli → düğme kapalı, jetonsuz API 400 captcha. **Kullanıcıda:** Cloudflare'den anahtar alıp Dokploy'a girmek (DEPLOY §8). +- **Borç takibi B1 (2026-11-14):** Migration v4 (3 koleksiyon) + indeksler + `backup::KINDS`. Taksit planı `first_month` ile (içe aktarmada `asOf − paidCount + 1`); kaynak belgedeki tüm kabul rakamları birim + entegrasyon testinde birebir (78.530,48 / 79.078,85 / devreden 64.352,79 / yükler 4.311,82…182,02 / kalan 14.177,69 ve 9.865,87). Ay sonu hedefi toplam borç cinsinden (= ay başı − C − taksit; yol haritası da toplam). Kart/IBAN: 12+ haneli sayı reddedilir. Testler `tests/debt.rs` (3) + 6 birim; 2 bilinçli bozma yakalandı. diff --git a/docs/PHASES.md b/docs/PHASES.md index 3420422..fdbdf09 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -92,3 +92,8 @@ - ✅ 3. Arayüz cilası ve erişilebilirlik: tüm sayfaların telefon/masaüstü/koyu tema taraması, boş durumlar, skeleton, %80 bütçe uyarısı, odak/klavye, kontrast, onay diyalogları - ✅ 4. Performans: sayfa bazlı kod bölme (lazy route), statik dosyalarda uzun önbellek, paket boyutu - ✅ 5. PWA: manifest + service worker (uygulama kabuğu; `/api` asla önbelleğe alınmaz), kurulum, güncelleme bildirimi + +## Borç Takibi Adımları (docs/BORC_TAKIBI.md, kullanıcı isteği 2026-11-14) +- ✅ B1. Backend: ölçümler, taksit planları (ilk ay + adet → aylık yük/kalan kendiliğinden), yol haritası, özet, dönem borcu (ay başı, devreden, hedef), elle ay başı, kaynak belge JSON'unu içe aktarma (ön izleme/onay), JSON yedeğine dahil +- ⏳ B2. Arayüz: Panel borç kartı, `/borc` (gerçek + hedef grafik, tablo, yeni ölçüm, hedefler, içe aktar), `/taksitler` +- ⏳ B3. Aylık plan entegrasyonu ("Borç ödemesi", ay başı/devreden/hedef, taksit önerisi) + içe aktarma dosyası (repo dışı) diff --git a/frontend/src/types/DebtImportReport.ts b/frontend/src/types/DebtImportReport.ts new file mode 100644 index 0000000..5e80af0 --- /dev/null +++ b/frontend/src/types/DebtImportReport.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DebtImportRow } from "./DebtImportRow"; + +export type DebtImportReport = { committed: boolean, rows: Array, errors: Array, }; diff --git a/frontend/src/types/DebtImportRow.ts b/frontend/src/types/DebtImportRow.ts new file mode 100644 index 0000000..528e448 --- /dev/null +++ b/frontend/src/types/DebtImportRow.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DebtImportRow = { +/** + * `snapshot` | `installment` | `target` + */ +kind: string, label: string, amount: number, +/** + * `new` | `exists` | `update` | `error` + */ +status: string, message: string | null, }; diff --git a/frontend/src/types/DebtSnapshot.ts b/frontend/src/types/DebtSnapshot.ts new file mode 100644 index 0000000..2b28c7d --- /dev/null +++ b/frontend/src/types/DebtSnapshot.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DebtSnapshot = { id: string, date: string, kmh: number, positive_balance: number, card_balance: number, remaining_installments: number, +/** + * Hesaplanır: KMH + kart + kalan taksit − artı bakiye + */ +total: number, +/** + * Bir önceki ölçüme göre + */ +change: number | null, note: string | null, source: string, version: number, }; diff --git a/frontend/src/types/DebtSnapshotRequest.ts b/frontend/src/types/DebtSnapshotRequest.ts new file mode 100644 index 0000000..f95310f --- /dev/null +++ b/frontend/src/types/DebtSnapshotRequest.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DebtSnapshotRequest = { +/** + * `"YYYY-MM-DD"` + */ +date?: string, kmh: number, positive_balance: number, card_balance: number, remaining_installments: number, +/** + * Boş metin → notu kaldır + */ +note?: string, version?: number, }; diff --git a/frontend/src/types/DebtSummary.ts b/frontend/src/types/DebtSummary.ts new file mode 100644 index 0000000..4d536f4 --- /dev/null +++ b/frontend/src/types/DebtSummary.ts @@ -0,0 +1,35 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DebtSnapshot } from "./DebtSnapshot"; +import type { DebtTarget } from "./DebtTarget"; +import type { InstallmentPlan } from "./InstallmentPlan"; +import type { MonthLoad } from "./MonthLoad"; + +export type DebtSummary = { +/** + * En yeni önce + */ +snapshots: Array, latest: DebtSnapshot | null, +/** + * Son ölçüm − önceki ayın son ölçümü (eksi → azaldı) + */ +change_vs_last_month: number | null, +/** + * İlk ölçüm (yolculuğun başı) + */ +start: DebtSnapshot | null, +/** + * 0..1 + */ +progress: number | null, +/** + * `"YYYY-MM"` + */ +estimated_end: string | null, installments: Array, targets: Array, +/** + * Bu ay dahil 6 ay + */ +schedule: Array, +/** + * Bu ay (`"YYYY-MM"`) + */ +this_month: string, }; diff --git a/frontend/src/types/DebtTarget.ts b/frontend/src/types/DebtTarget.ts new file mode 100644 index 0000000..77aaa7c --- /dev/null +++ b/frontend/src/types/DebtTarget.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DebtTarget = { +/** + * `"YYYY-MM"` + */ +month: string, +/** + * Ay sonu toplam borç hedefi (kuruş) + */ +closing_target: number, }; diff --git a/frontend/src/types/DebtTargetsRequest.ts b/frontend/src/types/DebtTargetsRequest.ts new file mode 100644 index 0000000..2a0128e --- /dev/null +++ b/frontend/src/types/DebtTargetsRequest.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DebtTarget } from "./DebtTarget"; + +export type DebtTargetsRequest = { targets: Array, }; diff --git a/frontend/src/types/InstallmentPlan.ts b/frontend/src/types/InstallmentPlan.ts new file mode 100644 index 0000000..8eb7d49 --- /dev/null +++ b/frontend/src/types/InstallmentPlan.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type InstallmentPlan = { id: string, merchant: string, total_amount: number, monthly_amount: number, total_count: number, +/** + * `"YYYY-MM"` + */ +first_month: string, last_month: string, +/** + * Bu ay dahil ekstreye gelen + */ +paid_count: number, remaining_count: number, +/** + * Kalan taksitlerin toplamı (bu aydan sonra) + */ +remaining_amount: number, version: number, }; diff --git a/frontend/src/types/InstallmentRequest.ts b/frontend/src/types/InstallmentRequest.ts new file mode 100644 index 0000000..4c1fbd1 --- /dev/null +++ b/frontend/src/types/InstallmentRequest.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type InstallmentRequest = { merchant?: string, total_amount: number, monthly_amount: number, total_count?: number, +/** + * `"YYYY-MM"` + */ +first_month?: string, version?: number, }; diff --git a/frontend/src/types/MonthLoad.ts b/frontend/src/types/MonthLoad.ts new file mode 100644 index 0000000..a146858 --- /dev/null +++ b/frontend/src/types/MonthLoad.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MonthLoad = { month: string, amount: number, }; diff --git a/frontend/src/types/OpeningDebtRequest.ts b/frontend/src/types/OpeningDebtRequest.ts new file mode 100644 index 0000000..dfa8178 --- /dev/null +++ b/frontend/src/types/OpeningDebtRequest.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type OpeningDebtRequest = { +/** + * Kuruş; `null` → son ölçümden + */ +amount: number | null, }; diff --git a/frontend/src/types/PeriodDebt.ts b/frontend/src/types/PeriodDebt.ts new file mode 100644 index 0000000..8947745 --- /dev/null +++ b/frontend/src/types/PeriodDebt.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PeriodDebt = { +/** + * Ay başı toplam borç + */ +opening_total: number | null, +/** + * `manual` | `snapshot` | `none` + */ +opening_source: string, opening_date: string | null, +/** + * Devreden borç = ay başı toplam − kalan taksitler + */ +carried: number | null, installment_load: number, +/** + * Borç ödemesi (dönemin birikim hedefi) + */ +payment: number, +/** + * Ay başı − borç ödemesi − taksit yükü + */ +closing_target: number | null, roadmap_target: number | null, latest_date: string | null, latest_total: number | null, };