feat(borç): borç takibi backend (ölçümler, taksit planları, yol haritası, dönem borcu, içe aktarma)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 04:27:48 +03:00
parent a2eff1a984
commit 11402ba660
28 changed files with 2188 additions and 1 deletions
+1
View File
@@ -88,6 +88,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--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
+21
View File
@@ -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
+12
View File
@@ -89,6 +89,18 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
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 })],
+20
View File
@@ -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::*;
+9
View File
@@ -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,
];
}
+3
View File
@@ -58,5 +58,8 @@ pub struct Period {
pub to_savings: Option<i64>,
#[serde(default)]
pub closed_at: Option<DateTime>,
/// Elle girilen ay başı toplam banka borcu (kuruş); yoksa son ölçümden (borç takibi)
#[serde(default)]
pub opening_debt: Option<i64>,
pub version: i32,
}
+486
View File
@@ -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> {
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<i64>,
note: Option<String>,
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<SnapshotDto>,
latest: Option<SnapshotDto>,
/// Son ölçüm − önceki ayın son ölçümü (eksi → azaldı)
#[ts(type = "number | null")]
change_vs_last_month: Option<i64>,
/// İlk ölçüm (yolculuğun başı)
start: Option<SnapshotDto>,
/// 0..1
progress: Option<f64>,
/// `"YYYY-MM"`
estimated_end: Option<String>,
installments: Vec<PlanDto>,
targets: Vec<TargetDto>,
/// Bu ay dahil 6 ay
schedule: Vec<MonthLoadDto>,
/// Bu ay (`"YYYY-MM"`)
this_month: String,
}
// ---------- Özet ----------
pub async fn summary(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<DebtSummary>> {
let today = format_date(today_tr());
let x = debt::summary(&s.db, auth.household_id, &today).await?;
let list: Vec<SnapshotDto> = 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<String>,
#[ts(type = "number")]
kmh: Option<i64>,
#[ts(type = "number")]
positive_balance: Option<i64>,
#[ts(type = "number")]
card_balance: Option<i64>,
#[ts(type = "number")]
remaining_installments: Option<i64>,
/// Boş metin → notu kaldır
note: Option<String>,
version: Option<i32>,
}
impl From<SnapshotRequest> 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<AppState>,
auth: AuthUser,
Json(req): Json<SnapshotRequest>,
) -> AppResult<(StatusCode, Json<SnapshotDto>)> {
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<AppState>,
auth: AuthUser,
Path(id): Path<String>,
Json(req): Json<SnapshotRequest>,
) -> AppResult<Json<SnapshotDto>> {
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<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
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<String>,
#[ts(type = "number")]
total_amount: Option<i64>,
#[ts(type = "number")]
monthly_amount: Option<i64>,
total_count: Option<i32>,
/// `"YYYY-MM"`
first_month: Option<String>,
version: Option<i32>,
}
impl From<PlanRequest> 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<AppState>,
auth: AuthUser,
Json(req): Json<PlanRequest>,
) -> AppResult<(StatusCode, Json<PlanDto>)> {
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<AppState>,
auth: AuthUser,
Path(id): Path<String>,
Json(req): Json<PlanRequest>,
) -> AppResult<Json<PlanDto>> {
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<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
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<TargetDto>,
}
pub async fn set_targets(
State(s): State<AppState>,
auth: AuthUser,
Json(req): Json<TargetsRequest>,
) -> AppResult<StatusCode> {
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<String>,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct DebtImportReport {
committed: bool,
rows: Vec<ImportRowDto>,
errors: Vec<String>,
}
pub async fn import(
State(s): State<AppState>,
auth: AuthUser,
Query(q): Query<ImportQuery>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<DebtImportReport>> {
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<i64>,
/// `manual` | `snapshot` | `none`
opening_source: String,
opening_date: Option<String>,
/// Devreden borç = ay başı toplam − kalan taksitler
#[ts(type = "number | null")]
carried: Option<i64>,
#[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<i64>,
#[ts(type = "number | null")]
roadmap_target: Option<i64>,
latest_date: Option<String>,
#[ts(type = "number | null")]
latest_total: Option<i64>,
}
pub async fn period_debt(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<PeriodDebt>> {
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<i64>,
}
pub async fn set_opening(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
Json(req): Json<OpeningRequest>,
) -> AppResult<StatusCode> {
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)
}
+16
View File
@@ -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))
+3
View File
@@ -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`).
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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;
+313
View File
@@ -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<String> {
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;
}
+60
View File
@@ -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ışı)
+11
View File
@@ -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 }
+3 -1
View File
@@ -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ı.
+5
View File
@@ -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ışı)
+4
View File
@@ -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<DebtImportRow>, errors: Array<string>, };
+11
View File
@@ -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, };
+11
View File
@@ -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, };
+11
View File
@@ -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, };
+35
View File
@@ -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<DebtSnapshot>, 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<InstallmentPlan>, targets: Array<DebtTarget>,
/**
* Bu ay dahil 6 ay
*/
schedule: Array<MonthLoad>,
/**
* Bu ay (`"YYYY-MM"`)
*/
this_month: string, };
+11
View File
@@ -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, };
+4
View File
@@ -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<DebtTarget>, };
+15
View File
@@ -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, };
+7
View File
@@ -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, };
+3
View File
@@ -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, };
+7
View File
@@ -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, };
+23
View File
@@ -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, };