//! Ekstre içe aktarma: öneri (tür/kategori/kural), arşiv dönemi, iade, gelir, yoksay, //! tekrar tespiti, kapalı/gelecek dönem engeli, arşiv kuralları. mod common; use axum::http::StatusCode; use common::{Session, TestApp}; use serde_json::{Value, json}; async fn id_by(app: &TestApp, s: &Session, path: &str, name: &str) -> String { let (_, list) = app.get(path, s).await; list.as_array() .unwrap() .iter() .find(|c| c["name"] == name) .unwrap()["id"] .as_str() .unwrap() .to_string() } fn row(date: &str, desc: &str, amount: i64, dir: &str) -> Value { json!({ "date": date, "description": desc, "amount": amount, "direction": dir }) } async fn preview(app: &TestApp, s: &Session, source: &str, rows: &[Value]) -> Value { let (st, b) = app .post( "/bank-import/preview", s, json!({ "source": source, "rows": rows }), ) .await; assert_eq!(st, StatusCode::OK, "{b}"); b } async fn commit(app: &TestApp, s: &Session, pm: Option<&str>, rows: Vec) -> Value { let (st, b) = app .post( "/bank-import/commit", s, json!({ "payment_method_id": pm, "rows": rows }), ) .await; assert_eq!(st, StatusCode::OK, "{b}"); b } fn with(mut r: Value, kind: &str, cat: Option<&str>) -> Value { r["kind"] = json!(kind); if let Some(c) = cat { r["category_id"] = json!(c); } r } #[tokio::test] async fn card_statement_with_archive_refund_and_rules() { let app = TestApp::new().await; let s = app.register("Test", None).await; let market = id_by(&app, &s, "/categories", "Market").await; let kart = id_by(&app, &s, "/payment-methods", "Kredi Kartı").await; // Ekim kapalı, Kasım açık let (_, oct) = app .post( "/periods", &s, json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }), ) .await; let oct = oct["id"].as_str().unwrap().to_string(); app.post(&format!("/periods/{oct}/close"), &s, json!({})) .await; let (_, nov) = app .post( "/periods", &s, json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }), ) .await; let nov = nov["id"].as_str().unwrap().to_string(); let rows = vec![ row("2026-09-10", "MİGROS 1234 İSTANBUL TR", 45_000, "out"), row("2026-09-12", "IADE MIGROS ISTANBUL", 10_000, "in"), row("2026-09-30", "ÖDEMENİZ İÇİN TEŞEKKÜRLER", 500_000, "in"), row("2026-11-05", "A101 MAGAZACILIK", 20_000, "out"), row("2099-12-20", "GELECEK", 1_000, "out"), row("2026-10-05", "EKIM", 1_000, "out"), ]; let p = preview(&app, &s, "card", &rows).await; let kinds: Vec<&str> = p["rows"] .as_array() .unwrap() .iter() .map(|r| r["kind"].as_str().unwrap()) .collect(); assert_eq!( kinds, [ "expense", "refund", "ignore", "expense", "expense", "expense" ] ); assert_eq!(p["rows"][0]["period"], "archive"); assert_eq!(p["rows"][0]["period_name"], "Eylül 2026"); assert_eq!(p["rows"][3]["period"], "existing"); assert_eq!(p["rows"][3]["period_name"], "Kasım 2026"); assert_eq!(p["rows"][4]["message"], "Gelecek tarihli satır"); assert_eq!( p["rows"][5]["message"], "Dönem kapalı (Ekim 2026); düzeltme için yeniden açın" ); assert_eq!( p["archives"], json!([{ "name": "Eylül 2026", "start_date": "2026-09-01", "last_date": "2026-09-30" }]) ); assert_eq!(p["rows"][0]["from_rule"], false); // Aktar: kullanıcı seçimleri let chosen = vec![ with(rows[0].clone(), "expense", Some(&market)), with(rows[1].clone(), "refund", Some(&market)), with(rows[2].clone(), "ignore", None), with(rows[3].clone(), "expense", Some(&market)), with(rows[4].clone(), "expense", Some(&market)), with(rows[5].clone(), "expense", Some(&market)), ]; let r = commit(&app, &s, Some(&kart), chosen.clone()).await; assert_eq!( ( r["entries"].as_u64(), r["refunds"].as_u64(), r["ignored"].as_u64() ), (Some(2), Some(1), Some(1)) ); assert_eq!(r["archives"], json!(["Eylül 2026"])); assert_eq!( r["errors"] .as_array() .unwrap() .iter() .map(|e| e["index"].as_u64().unwrap()) .collect::>(), [4, 5] ); // Arşiv dönemi: Eylül, net harcama 450 − 100 = 350 let (_, periods) = app.get("/periods", &s).await; let sep = periods .as_array() .unwrap() .iter() .find(|p| p["name"] == "Eylül 2026") .unwrap() .clone(); assert_eq!(sep["status"], "archived"); let sep_id = sep["id"].as_str().unwrap(); let (_, sum) = app.get(&format!("/periods/{sep_id}/summary"), &s).await; assert_eq!(sum["spent_total"], 35_000, "iade harcamadan düşer"); let (_, entries) = app.get(&format!("/periods/{sep_id}/entries"), &s).await; let refund = entries .as_array() .unwrap() .iter() .find(|e| e["amount"] == -10_000) .unwrap() .clone(); assert_eq!(refund["payment_method_id"], kart.as_str()); assert_eq!(refund["description"], "IADE MIGROS ISTANBUL"); let (_, nov_entries) = app.get(&format!("/periods/{nov}/entries"), &s).await; assert_eq!(nov_entries.as_array().unwrap().len(), 1); // Aynı ekstre tekrar: öğrenilen kural + tekrar işareti; aktarma çift kayıt yapmaz let p = preview(&app, &s, "card", &rows).await; assert_eq!(p["rows"][0]["from_rule"], true); assert_eq!(p["rows"][0]["category_id"], market.as_str()); assert_eq!(p["rows"][1]["kind"], "refund"); assert_eq!(p["rows"][2]["kind"], "ignore", "yoksay kuralı da öğrenildi"); assert_eq!( p["rows"] .as_array() .unwrap() .iter() .map(|r| r["duplicate"].as_bool().unwrap()) .collect::>(), [true, true, false, true, false, false] ); assert_eq!(p["archives"], json!([]), "Eylül artık var"); let r = commit(&app, &s, Some(&kart), chosen).await; assert_eq!( (r["entries"].as_u64(), r["duplicates"].as_u64()), (Some(0), Some(3)) ); // İade düzenlemede eksi kalır let rid = refund["id"].as_str().unwrap(); let (st, _) = app .patch(&format!("/entries/{rid}"), &s, json!({ "amount": 5_000 })) .await; assert_eq!(st, StatusCode::BAD_REQUEST); let (st, e) = app .patch(&format!("/entries/{rid}"), &s, json!({ "amount": -5_000 })) .await; assert_eq!(st, StatusCode::OK, "{e}"); // Arşiv kapatılmaz; öneriler arşivi kullanır (Ekim 0 + Eylül 400 net) / 2 dönem let (st, b) = app .post(&format!("/periods/{sep_id}/close"), &s, json!({})) .await; assert_eq!(st, StatusCode::CONFLICT, "{b}"); let (_, sug) = app .get(&format!("/periods/{nov}/budget/suggestion?method=avg3"), &s) .await; assert_eq!(sug["available"], true, "{sug}"); assert_eq!(sug["amounts"]["needs"], 20_000, "(450 − 50) / 2"); // Başka hane kuralları görmez let other = app.register("Diger", None).await; let p = preview(&app, &other, "card", &rows[..1]).await; assert_eq!(p["rows"][0]["from_rule"], false); assert_eq!(p["rows"][0]["duplicate"], false); app.cleanup().await; } #[tokio::test] async fn account_statement_income_and_transfers() { let app = TestApp::new().await; let s = app.register("Test", None).await; let fatura = id_by(&app, &s, "/categories", "Faturalar").await; let rows = vec![ row("2026-08-01", "MAAŞ ÖDEMESİ ACME A.Ş.", 4_500_000, "in"), row("2026-08-05", "KREDİ KARTI ÖDEMESİ", 1_200_000, "out"), row("2026-08-07", "ELEKTRIK FATURASI", 85_000, "out"), row("2026-08-31", "HESAPLAR ARASI VİRMAN", 100_000, "out"), ]; let p = preview(&app, &s, "account", &rows).await; let kinds: Vec<&str> = p["rows"] .as_array() .unwrap() .iter() .map(|r| r["kind"].as_str().unwrap()) .collect(); assert_eq!(kinds, ["income", "ignore", "expense", "ignore"]); let r = commit( &app, &s, None, vec![ with(rows[0].clone(), "income", None), with(rows[1].clone(), "ignore", None), with(rows[2].clone(), "expense", Some(&fatura)), with(rows[3].clone(), "ignore", None), ], ) .await; assert_eq!( ( r["incomes"].as_u64(), r["entries"].as_u64(), r["ignored"].as_u64() ), (Some(1), Some(1), Some(2)) ); let (_, periods) = app.get("/periods", &s).await; let aug = periods .as_array() .unwrap() .iter() .find(|p| p["name"] == "Ağustos 2026") .unwrap()["id"] .as_str() .unwrap() .to_string(); let (_, sum) = app.get(&format!("/periods/{aug}/summary"), &s).await; assert_eq!(sum["income_total"], 4_500_000); assert_eq!(sum["spent_total"], 85_000); let (_, items) = app.get(&format!("/periods/{aug}/plan-items"), &s).await; assert_eq!(items[0]["name"], "MAAŞ ÖDEMESİ ACME A.Ş."); assert_eq!(items[0]["date"], "2026-08-01"); // Gelir de tekrar etmez let p = preview(&app, &s, "account", &rows[..1]).await; assert_eq!(p["rows"][0]["duplicate"], true); // Kategori seçilmemiş harcama → satır hatası; boş istek → 400 let r = commit( &app, &s, None, vec![with(row("2026-08-09", "SU", 1_000, "out"), "expense", None)], ) .await; assert_eq!(r["errors"][0]["message"], "Kategori seçin"); let (st, _) = app .post( "/bank-import/preview", &s, json!({ "source": "card", "rows": [] }), ) .await; assert_eq!(st, StatusCode::BAD_REQUEST); app.cleanup().await; } #[tokio::test] async fn archives_are_skipped_in_the_carryover_chain() { let app = TestApp::new().await; let s = app.register("Test", None).await; let market = id_by(&app, &s, "/categories", "Market").await; // Ekim: gelir 10.000, 5.000 birikime → 5.000 artı devreder (bağlanacak dönem yok) let (_, oct) = app .post( "/periods", &s, json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }), ) .await; let oct = oct["id"].as_str().unwrap().to_string(); app.post( &format!("/periods/{oct}/plan-items"), &s, json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }), ) .await; app.post( &format!("/periods/{oct}/close"), &s, json!({ "to_savings": 500_000 }), ) .await; // Kasım ekstresi → Kasım arşiv dönemi let r = commit( &app, &s, None, vec![with( row("2026-11-03", "MIGROS", 10_000, "out"), "expense", Some(&market), )], ) .await; assert_eq!(r["archives"], json!(["Kasım 2026"])); // Aralık açılır: önceki (devir) dönemi arşiv değil Ekim → artı Aralık'a bağlanır let (st, dec) = app .post( "/periods", &s, json!({ "start_date": "2026-12-01", "end_date": "2027-01-01" }), ) .await; assert_eq!(st, StatusCode::CREATED, "{dec}"); let dec = dec["id"].as_str().unwrap().to_string(); let (_, sum) = app.get(&format!("/periods/{dec}/summary"), &s).await; assert_eq!(sum["carryover_surplus"], 500_000); // Ekim yeniden açılıp kapatılırsa yeni devir yine Aralık'a (arşiv Kasım değil) app.post(&format!("/periods/{oct}/reopen"), &s, json!({})) .await; let (st, b) = app .post( &format!("/periods/{oct}/close"), &s, json!({ "to_savings": 400_000 }), ) .await; assert_eq!(st, StatusCode::OK, "{b}"); assert_eq!(b["carryover"]["to_period_id"], dec.as_str()); let (_, sum) = app.get(&format!("/periods/{dec}/summary"), &s).await; assert_eq!(sum["carryover_surplus"], 600_000); app.cleanup().await; }