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:
@@ -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