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
+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;