Files
kakeibo/backend/tests/bank_import.rs
T
suphicakir d1a24afc3c feat(ekstre): banka/kart ekstresi içe aktarma altyapısı (arşiv dönemleri, iade, öğrenen kurallar)
- Arşiv dönemi durumu: geçmiş aylar için; kapatılmaz, devir zincirinde atlanır, bütçe önerilerini besler
- POST /bank-import/preview ve /commit: harcama / iade (eksi) / gelir (plan) / yoksay
- Tekrar tespiti (import_key), açıklamadan öğrenen kategori kuralları, ödeme/virman ipuçları
- Kapalı dönem ve gelecek tarih satır hatası; iade düzenlemede eksi kalır

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:33:07 +03:00

377 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.
//! 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>) -> 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::<Vec<_>>(),
[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::<Vec<_>>(),
[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;
}