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