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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 })],
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user