d1a24afc3c
- 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>
377 lines
12 KiB
Rust
377 lines
12 KiB
Rust
//! 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;
|
||
}
|