Files
kakeibo/backend/tests/debt.rs
T

314 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
}