//! 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 { 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; }