//! Banka / kredi kartı ekstresi içe aktarma (kullanıcı isteği; Faz 9 Adım 4). //! //! Dosya tarayıcıda okunur (Excel/CSV) ve sütunları eşleştirilir; buraya yalnızca //! **tarih, açıklama, tutar, yön** gelir. Her satır bir türe ayrılır: //! - `expense` → harcama (kategori gerekir) //! - `refund` → **iade**: aynı kategoride eksi tutarlı kayıt (harcamadan düşer) //! - `income` → dönemin planına **gelir** kalemi //! - `ignore` → aktarılmaz (kart borcu ödemesi, kendi hesaplar arası virman…) //! //! Dönem: satırın tarihini içeren yazılabilir dönem; yoksa takvim ayı kadar **arşiv** dönemi //! açılır (var olan dönemlerle çakışmayacak şekilde kırpılır). Kapalı dönem ve gelecek tarih → hata. //! Tekrar: `import_key` (tarih|yön|tutar|sadeleştirilmiş açıklama) — aynı ekstre ya da örtüşen //! iki ekstre yüklense de çift kayıt olmaz. Öneri: hanenin öğrenilmiş kuralları (açıklamanın ilk //! iki anlamlı kelimesi), yoksa hazır ipuçları ("kredi kartı ödemesi" → yoksay), yoksa yön. use std::collections::HashMap; use mongodb::{ Database, bson::{Bson, Document, Uuid, doc}, }; use serde::Deserialize; use time::Date; use crate::{ db::col, error::{AppError, AppResult}, models::{ common::{new_meta, now, scope}, period::Period, plan_item::PlanKind, }, services::{ calendar::{format_date, parse_date, today_tr}, catalog::{self, Catalog}, entry::MAX_DESCRIPTION, period, plan::{self, NewItem}, }, }; pub const MAX_ROWS: usize = 5_000; #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Source { /// Kredi kartı: çıkış = harcama, giriş = iade ya da ödeme Card, /// Banka hesabı: çıkış = harcama, giriş = gelir Account, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Direction { In, Out, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Kind { Expense, Refund, Income, Ignore, } impl Kind { pub fn as_str(self) -> &'static str { match self { Kind::Expense => "expense", Kind::Refund => "refund", Kind::Income => "income", Kind::Ignore => "ignore", } } } #[derive(Debug, Clone)] pub struct Row { pub date: String, pub description: String, /// Kuruş, > 0 pub amount: i64, pub direction: Direction, /// Commit'te kullanıcının seçimi pub kind: Option, pub category_id: Option, } // ---------- Metin sadeleştirme ---------- /// Türkçe harfleri ASCII'ye indir, küçült (bankalar çoğu zaman "ODEME", "SUT" yazar) pub fn fold(s: &str) -> String { s.chars() // "İ".to_lowercase() = "i" + birleşik nokta (U+0307) → önce doğrudan "i" .map(|c| if c == 'İ' { 'i' } else { c }) .flat_map(|c| c.to_lowercase()) .filter(|c| *c != '\u{307}') .map(|c| match c { 'ı' | 'i' | 'î' => 'i', 'ş' => 's', 'ğ' => 'g', 'ü' | 'û' => 'u', 'ö' => 'o', 'ç' => 'c', 'â' => 'a', c => c, }) .collect() } /// Anlamlı kelimeler: harf/rakam dışı ayırıcı; yalnızca rakam ve tek harf atılır fn words(desc: &str) -> Vec { fold(desc) .split(|c: char| !c.is_alphanumeric()) .filter(|w| w.len() >= 2 && !w.chars().all(|c| c.is_ascii_digit())) .map(str::to_string) .collect() } /// Kural anahtarı: ilk iki anlamlı kelime (`MIGROS 1234 ISTANBUL TR` → `migros istanbul`) pub fn rule_key(desc: &str) -> String { words(desc) .into_iter() .take(2) .collect::>() .join(" ") } /// İlk kelime eşleşmesinde kullanılmayacak genel sözcükler (satıcı adı değil) const GENERIC: &[&str] = &[ "iade", "odeme", "pos", "isyeri", "eft", "havale", "fast", "virman", "kredi", "kart", "hesap", "para", "borc", "harcama", "alisveris", "nakit", "atm", "taksit", "ucret", ]; /// Öneri: tam kural (ilk 2 kelime); yoksa ilk kelimesi aynı kurallar hepsi aynı şeyi /// söylüyorsa o (ör. "migros istanbul" öğrenildi → "MIGROS KADIKOY" de Market) fn suggest<'a>(rules: &'a HashMap, desc: &str) -> Option<&'a Rule> { if let Some(r) = rules.get(&rule_key(desc)) { return Some(r); } let first = words(desc).into_iter().next()?; if first.len() < 3 || GENERIC.contains(&first.as_str()) { return None; } let mut same = rules .values() .filter(|r| r.pattern.split(' ').next() == Some(first.as_str())); let r = same.next()?; same.all(|x| x.kind == r.kind && x.category_id == r.category_id) .then_some(r) } /// Tekrar anahtarı (tüm açıklama, sadeleştirilmiş) pub fn import_key(date: &str, dir: Direction, amount: i64, desc: &str) -> String { let d = match dir { Direction::In => "in", Direction::Out => "out", }; format!("{date}|{d}|{amount}|{}", words(desc).join(" ")) } /// Hazır öneri (kural yoksa): ödeme/virman → yoksay; kartta giriş → iade; hesapta giriş → gelir pub fn default_kind(source: Source, dir: Direction, desc: &str) -> Kind { let f = words(desc).join(" "); let transfer = [ "kredi karti", "kart odeme", "kk odeme", "virman", "hesaplar arasi", "kendi hesab", "hesabima", "borc odeme", "odemeniz icin", "tahsilat", ] .iter() .any(|h| f.contains(h)); match (source, dir) { _ if transfer => Kind::Ignore, (_, Direction::Out) => Kind::Expense, (Source::Card, Direction::In) => { if f.contains("odeme") { Kind::Ignore } else { Kind::Refund } } (Source::Account, Direction::In) => Kind::Income, } } // ---------- Dönem ---------- fn first_of_month(d: Date) -> Date { d.replace_day(1).expect("1. gün") } fn next_month(d: Date) -> Date { let (y, m) = if d.month() == time::Month::December { (d.year() + 1, time::Month::January) } else { (d.year(), d.month().next()) }; Date::from_calendar_date(y, m, 1).expect("geçerli ay") } /// Satırın dönemi #[derive(Debug, Clone, PartialEq)] pub enum Target { /// Var olan yazılabilir dönem (açık, düzeltmede ya da arşiv) Existing { id: Uuid, name: String, }, /// Açılacak arşiv dönemi [start, end) Archive { start: String, end: String, }, Blocked(String), } /// Tarih → dönem: içeren dönem; yoksa takvim ayı, var olan dönemlerin dışında kalan kısmı pub fn target(periods: &[Period], date: &str, today: &str) -> Target { if date > today { return Target::Blocked("Gelecek tarihli satır".into()); } if let Some(p) = periods .iter() .find(|p| p.start_date.as_str() <= date && date < p.end_date.as_str()) { return if p.status.is_writable() { Target::Existing { id: p.id, name: p.name.clone(), } } else { Target::Blocked(format!( "Dönem kapalı ({}); düzeltme için yeniden açın", p.name )) }; } let d = parse_date(date).expect("doğrulanmış tarih"); let (ms, me) = (format_date(first_of_month(d)), format_date(next_month(d))); // Ayın içinde, tarihten önce biten en geç dönem / tarihten sonra başlayan en erken dönem let start = periods .iter() .map(|p| p.end_date.as_str()) .filter(|e| *e <= date && *e > ms.as_str()) .max() .map_or(ms.clone(), str::to_string); let end = periods .iter() .map(|p| p.start_date.as_str()) .filter(|s| *s > date && *s < me.as_str()) .min() .map_or(me.clone(), str::to_string); Target::Archive { start, end } } // ---------- Kurallar ---------- #[derive(Debug, Clone, Deserialize)] struct Rule { pattern: String, kind: Kind, #[serde(default)] category_id: Option, } async fn rules(db: &Database, household_id: Uuid) -> AppResult> { let mut c = db .collection::(col::IMPORT_RULES) .find(scope(household_id)) .await?; let mut out = HashMap::new(); while c.advance().await? { let r = c.deserialize_current()?; out.insert(r.pattern.clone(), r); } Ok(out) } /// Var olan içe aktarma anahtarları (harcama + plan kalemi) → adet async fn existing_keys(db: &Database, household_id: Uuid) -> AppResult> { let mut out = HashMap::new(); for coll in [col::ENTRIES, col::PLAN_ITEMS] { let mut f = scope(household_id); f.insert("import_key", doc! { "$type": "string" }); let mut c = db .collection::(coll) .find(f) .projection(doc! { "import_key": 1 }) .await?; while c.advance().await? { if let Ok(k) = c.current().get_str("import_key") { *out.entry(k.to_string()).or_insert(0) += 1; } } } Ok(out) } fn take(m: &mut HashMap, k: &str) -> bool { match m.get_mut(k) { Some(n) if *n > 0 => { *n -= 1; true } _ => false, } } fn validate(r: &Row) -> Result<(), String> { parse_date(&r.date).ok_or_else(|| format!("Tarih geçersiz: {}", r.date))?; if r.amount <= 0 { return Err("Tutar sıfırdan büyük olmalı".into()); } period::validate_amount(r.amount).map_err(|e| e.to_string())?; Ok(()) } // ---------- Önizleme ---------- pub struct PreviewRow { pub kind: Kind, pub category_id: Option, /// Öneri hanenin öğrenilmiş kuralından mı? pub from_rule: bool, pub duplicate: bool, pub target: Target, } pub struct Preview { pub rows: Vec, /// Açılacak arşiv dönemleri [start, end) pub archives: Vec<(String, String)>, } fn check_rows(rows: &[Row]) -> AppResult<()> { if rows.is_empty() { return Err(AppError::BadRequest("Aktarılacak satır yok".into())); } if rows.len() > MAX_ROWS { return Err(AppError::BadRequest(format!( "Bir seferde en fazla {MAX_ROWS} satır aktarılabilir" ))); } Ok(()) } pub async fn preview( db: &Database, household_id: Uuid, source: Source, rows: &[Row], ) -> AppResult { check_rows(rows)?; let periods = period::list(db, household_id).await?; let rules = rules(db, household_id).await?; let mut keys = existing_keys(db, household_id).await?; let today = format_date(today_tr()); let mut archives: Vec<(String, String)> = Vec::new(); let out = rows .iter() .map(|r| { let target = match validate(r) { Err(e) => Target::Blocked(e), Ok(()) => target(&periods, &r.date, &today), }; if let Target::Archive { start, end } = &target && !archives.iter().any(|(s, _)| s == start) { archives.push((start.clone(), end.clone())); } let duplicate = matches!(target, Target::Existing { .. } | Target::Archive { .. }) && take( &mut keys, &import_key(&r.date, r.direction, r.amount, &r.description), ); let rule = suggest(&rules, &r.description); PreviewRow { kind: rule.map_or_else( || default_kind(source, r.direction, &r.description), |x| x.kind, ), category_id: rule.and_then(|x| x.category_id), from_rule: rule.is_some(), duplicate, target, } }) .collect(); archives.sort(); Ok(Preview { rows: out, archives, }) } // ---------- Aktarma ---------- #[derive(Debug, Default)] pub struct Report { pub entries: usize, pub refunds: usize, pub incomes: usize, pub ignored: usize, pub duplicates: usize, /// Açılan arşiv dönemlerinin adları pub archives: Vec, /// (satır sırası 0'dan, neden) pub errors: Vec<(usize, String)>, } pub async fn commit( db: &Database, household_id: Uuid, user_id: Uuid, payment_method_id: Option, rows: &[Row], ) -> AppResult { check_rows(rows)?; if let Some(pm) = payment_method_id { catalog::find(db, household_id, Catalog::PaymentMethods, pm) .await? .ok_or_else(|| AppError::BadRequest("Ödeme türü bulunamadı".into()))?; } let cats = catalog::list(db, Catalog::Categories, household_id, true).await?; let mut periods = period::list(db, household_id).await?; let mut keys = existing_keys(db, household_id).await?; let today = format_date(today_tr()); let mut report = Report::default(); let mut entries: Vec = Vec::new(); let mut incomes: Vec<(usize, Uuid, NewItem, String)> = Vec::new(); let mut learned: HashMap)> = HashMap::new(); for (i, r) in rows.iter().enumerate() { let kind = r.kind.unwrap_or(Kind::Ignore); let pattern = rule_key(&r.description); if kind == Kind::Ignore { report.ignored += 1; if !pattern.is_empty() { learned.insert(pattern, (kind, None)); } continue; } if let Err(e) = validate(r) { report.errors.push((i, e)); continue; } let key = import_key(&r.date, r.direction, r.amount, &r.description); let pid = match target(&periods, &r.date, &today) { Target::Blocked(e) => { report.errors.push((i, e)); continue; } Target::Existing { id, .. } => id, Target::Archive { start, end } => { let p = period::create_archive( db, household_id, parse_date(&start).expect("tarih"), parse_date(&end).expect("tarih"), ) .await?; report.archives.push(p.name.clone()); let id = p.id; periods.push(p); id } }; if take(&mut keys, &key) { report.duplicates += 1; continue; } let desc: String = r.description.trim().chars().take(MAX_DESCRIPTION).collect(); match kind { Kind::Expense | Kind::Refund => { let Some(cat) = r.category_id.and_then(|c| cats.iter().find(|x| x.id == c)) else { report.errors.push((i, "Kategori seçin".into())); continue; }; let Some(group) = cat.group else { report.errors.push((i, "Kategorinin grubu yok".into())); continue; }; let amount = if kind == Kind::Refund { -r.amount } else { r.amount }; let mut d = new_meta(household_id); d.insert("period_id", pid); d.insert("date", &r.date); d.insert("amount", amount); d.insert("group", group.as_str()); d.insert("category_id", cat.id); d.insert( "payment_method_id", payment_method_id.map_or(Bson::Null, Bson::from), ); d.insert( "description", if desc.is_empty() { Bson::Null } else { Bson::String(desc) }, ); d.insert("created_by", user_id); d.insert("import_key", &key); entries.push(d); if kind == Kind::Refund { report.refunds += 1; } else { report.entries += 1; } if !pattern.is_empty() { learned.insert(pattern, (kind, Some(cat.id))); } } Kind::Income => { // Plan kalemi adı en fazla 60 karakter let name: String = if desc.is_empty() { "Gelir".into() } else { desc.chars().take(60).collect() }; incomes.push(( i, pid, NewItem { kind: PlanKind::Income, name, amount: r.amount, date: Some(r.date.clone()), }, key, )); if !pattern.is_empty() { learned.insert(pattern, (kind, None)); } } Kind::Ignore => unreachable!(), } } if !entries.is_empty() { db.collection::(col::ENTRIES) .insert_many(entries) .await?; } for (i, pid, item, key) in incomes { match plan::create(db, household_id, pid, item).await { Ok(p) => { db.collection::(col::PLAN_ITEMS) .update_one(doc! { "_id": p.id }, doc! { "$set": { "import_key": key } }) .await?; report.incomes += 1; } Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => report.errors.push((i, m)), Err(e) => return Err(e), } } // Kurallar: bu aktarımdaki seçimler sonraki ekstreler için öneri olur let ts = now(); for (pattern, (kind, category_id)) in learned { let mut f = scope(household_id); f.insert("pattern", &pattern); db.collection::(col::IMPORT_RULES) .update_one( f, doc! { "$set": { "kind": kind.as_str(), "category_id": category_id.map_or(Bson::Null, Bson::from), "updated_at": ts, }, "$inc": { "uses": 1, "version": 1 }, "$setOnInsert": { "_id": crate::models::common::new_id(), "created_at": ts }, }, ) .upsert(true) .await?; } report.errors.sort_by_key(|(i, _)| *i); Ok(report) } #[cfg(test)] mod tests { use super::*; #[test] fn folding_and_keys() { assert_eq!(fold("ÖDEME Şükrü İĞNE"), "odeme sukru igne"); assert_eq!(rule_key("MIGROS 1234 ISTANBUL TR"), "migros istanbul"); assert_eq!(rule_key("A101 MAGAZACILIK*456"), "a101 magazacilik"); assert_eq!(rule_key("12/11 *"), ""); assert_eq!( import_key("2026-11-13", Direction::Out, 4590, " Migros ŞUBE 12 "), "2026-11-13|out|4590|migros sube" ); } #[test] fn suggestion_falls_back_to_first_word() { let market = Uuid::new(); let ulasim = Uuid::new(); let rule = |p: &str, c: Uuid| { ( p.to_string(), Rule { pattern: p.into(), kind: Kind::Expense, category_id: Some(c), }, ) }; let mut rules: HashMap = [rule("migros istanbul", market), rule("iade migros", market)].into(); assert_eq!( suggest(&rules, "MIGROS 99 ISTANBUL").unwrap().category_id, Some(market), "tam" ); assert_eq!( suggest(&rules, "MİGROS KADIKÖY").unwrap().category_id, Some(market), "ilk kelime" ); assert!(suggest(&rules, "IADE SHELL").is_none(), "genel ilk kelime"); assert!(suggest(&rules, "A101 SUBE").is_none()); // İlk kelimesi aynı ama farklı kategoriler → öneri yok rules.extend([rule("migros jet", ulasim)]); assert!(suggest(&rules, "MIGROS ANKARA").is_none()); } #[test] fn default_kinds() { use Direction::*; use Source::*; assert_eq!(default_kind(Card, Out, "MIGROS ISTANBUL"), Kind::Expense); assert_eq!(default_kind(Card, In, "IADE MIGROS"), Kind::Refund); assert_eq!( default_kind(Card, In, "ÖDEMENİZ İÇİN TEŞEKKÜRLER"), Kind::Ignore ); assert_eq!(default_kind(Account, In, "MAAŞ ÖDEMESİ ACME"), Kind::Income); assert_eq!( default_kind(Account, Out, "KREDİ KARTI ÖDEMESİ"), Kind::Ignore ); assert_eq!( default_kind(Account, Out, "Hesaplar arası virman"), Kind::Ignore ); assert_eq!( default_kind(Account, Out, "ELEKTRIK FATURASI"), Kind::Expense ); } }