use crate::db::Database; use crate::models::{NewTransaction, TransactionType}; use chrono::NaiveDate; use std::path::Path; /// Import transactions from a QIF (Quicken Interchange Format) file. /// /// QIF records use single-character line prefixes: /// - D = date (MM/DD/YYYY or MM/DD'YY) /// - T = amount (negative = expense, positive = income) /// - P = payee /// - L = category /// - M = memo/note /// - S/$/E = split lines (category/amount/memo) /// - ^ = end of record /// /// Categories are matched by name. If a category is not found, /// the transaction is assigned to the first matching-type category. pub fn import_qif(db: &Database, path: &Path, merge: bool) -> Result> { let content = std::fs::read_to_string(path)?; if !merge { db.reset_all_data()?; } let expense_cats = db.list_categories(Some(TransactionType::Expense))?; let income_cats = db.list_categories(Some(TransactionType::Income))?; let default_expense_id = expense_cats.first().map(|c| c.id).unwrap_or(1); let default_income_id = income_cats.first().map(|c| c.id).unwrap_or(1); let mut count = 0; let mut date: Option = None; let mut amount: Option = None; let mut payee: Option = None; let mut category: Option = None; let mut memo: Option = None; for line in content.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('!') { continue; } let prefix = &line[..1]; let value = &line[1..]; match prefix { "D" => { date = parse_qif_date(value); } "T" => { amount = value.replace(',', "").parse::().ok(); } "P" => { if !value.is_empty() { payee = Some(value.to_string()); } } "L" => { if !value.is_empty() { category = Some(value.to_string()); } } "M" => { if !value.is_empty() { memo = Some(value.to_string()); } } "^" => { // End of record - save transaction if let (Some(d), Some(amt)) = (date, amount) { let txn_type = if amt < 0.0 { TransactionType::Expense } else { TransactionType::Income }; let abs_amount = amt.abs(); let category_id = resolve_category( &category, txn_type, &expense_cats, &income_cats, default_expense_id, default_income_id, ); if merge && db.find_duplicate_transaction(abs_amount, txn_type, category_id, d)? { // Skip duplicate } else { let new_txn = NewTransaction { amount: abs_amount, transaction_type: txn_type, category_id, currency: base_currency(db), exchange_rate: 1.0, note: memo.clone(), date: d, recurring_id: None, payee: payee.clone(), }; db.insert_transaction(&new_txn)?; count += 1; } } // Reset for next record date = None; amount = None; payee = None; category = None; memo = None; } // Skip split lines (S, $, E) and other unknown prefixes _ => {} } } Ok(count) } fn base_currency(db: &Database) -> String { db.get_setting("base_currency") .ok() .flatten() .unwrap_or_else(|| "USD".to_string()) } /// Parse a QIF date string. Supports: /// - MM/DD/YYYY (e.g., 03/01/2026) /// - MM/DD'YY (e.g., 3/ 1'26) /// - M/D/YYYY /// - MM-DD-YYYY fn parse_qif_date(s: &str) -> Option { let s = s.trim().replace(' ', ""); // Try MM/DD/YYYY or M/D/YYYY if let Ok(d) = NaiveDate::parse_from_str(&s, "%m/%d/%Y") { return Some(d); } // Try MM-DD-YYYY if let Ok(d) = NaiveDate::parse_from_str(&s, "%m-%d-%Y") { return Some(d); } // Try the apostrophe format: M/D'YY if let Some(apos_idx) = s.find('\'') { let date_part = &s[..apos_idx]; let year_part = &s[apos_idx + 1..]; if let Some((month_str, day_str)) = date_part.split_once('/') { let month: u32 = month_str.parse().ok()?; let day: u32 = day_str.parse().ok()?; let year_short: i32 = year_part.parse().ok()?; let year = if year_short < 100 { 2000 + year_short } else { year_short }; return NaiveDate::from_ymd_opt(year, month, day); } } None } /// Resolve a QIF category name to a database category ID. /// QIF uses "Parent:Sub" for subcategories. fn resolve_category( cat_name: &Option, txn_type: TransactionType, expense_cats: &[crate::models::Category], income_cats: &[crate::models::Category], default_expense_id: i64, default_income_id: i64, ) -> i64 { let cats = match txn_type { TransactionType::Expense => expense_cats, TransactionType::Income => income_cats, }; let default_id = match txn_type { TransactionType::Expense => default_expense_id, TransactionType::Income => default_income_id, }; let Some(name) = cat_name else { return default_id; }; // Try exact match first if let Some(c) = cats.iter().find(|c| c.name == *name) { return c.id; } // For "Parent:Sub" format, try matching just the sub-category name if let Some((_parent, sub)) = name.split_once(':') { if let Some(c) = cats.iter().find(|c| c.name == sub) { return c.id; } } // Case-insensitive match let lower = name.to_lowercase(); if let Some(c) = cats.iter().find(|c| c.name.to_lowercase() == lower) { return c.id; } default_id } #[cfg(test)] mod tests { use super::*; use std::io::Write; use std::sync::atomic::{AtomicUsize, Ordering}; static COUNTER: AtomicUsize = AtomicUsize::new(0); fn setup_db() -> Database { Database::open_in_memory().unwrap() } fn write_temp_qif(content: &str) -> std::path::PathBuf { let n = COUNTER.fetch_add(1, Ordering::SeqCst); let path = std::env::temp_dir().join(format!("outlay_test_qif_{}.qif", n)); let mut f = std::fs::File::create(&path).unwrap(); f.write_all(content.as_bytes()).unwrap(); f.flush().unwrap(); path } #[test] fn test_import_qif_expense() { let db = setup_db(); let path = write_temp_qif( "!Type:Bank\nD03/01/2026\nT-42.50\nPCafe\nMLunch\n^\n", ); let count = import_qif(&db, &path, true).unwrap(); assert_eq!(count, 1); let txns = db.list_all_transactions(None, None).unwrap(); assert_eq!(txns.len(), 1); assert_eq!(txns[0].amount, 42.50); assert_eq!(txns[0].transaction_type, TransactionType::Expense); assert_eq!(txns[0].payee.as_deref(), Some("Cafe")); assert_eq!(txns[0].note.as_deref(), Some("Lunch")); let _ = std::fs::remove_file(&path); } #[test] fn test_import_qif_income() { let db = setup_db(); let path = write_temp_qif( "!Type:Bank\nD02/15/2026\nT1000.00\nMSalary\n^\n", ); let count = import_qif(&db, &path, true).unwrap(); assert_eq!(count, 1); let txns = db.list_all_transactions(None, None).unwrap(); assert_eq!(txns[0].transaction_type, TransactionType::Income); assert_eq!(txns[0].amount, 1000.0); let _ = std::fs::remove_file(&path); } #[test] fn test_import_qif_multiple_records() { let db = setup_db(); let path = write_temp_qif( "!Type:Bank\nD01/01/2026\nT-10.00\n^\nD01/02/2026\nT-20.00\n^\nD01/03/2026\nT50.00\n^\n", ); let count = import_qif(&db, &path, true).unwrap(); assert_eq!(count, 3); let _ = std::fs::remove_file(&path); } #[test] fn test_import_qif_merge_deduplication() { let db = setup_db(); let path = write_temp_qif( "!Type:Bank\nD03/01/2026\nT-42.50\n^\n", ); let count1 = import_qif(&db, &path, true).unwrap(); assert_eq!(count1, 1); let count2 = import_qif(&db, &path, true).unwrap(); assert_eq!(count2, 0); let txns = db.list_all_transactions(None, None).unwrap(); assert_eq!(txns.len(), 1); let _ = std::fs::remove_file(&path); } #[test] fn test_import_qif_category_matching() { let db = setup_db(); let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); let cat_name = &cats[0].name; let path = write_temp_qif(&format!( "!Type:Bank\nD01/01/2026\nT-25.00\nL{}\n^\n", cat_name )); let count = import_qif(&db, &path, true).unwrap(); assert_eq!(count, 1); let txns = db.list_all_transactions(None, None).unwrap(); assert_eq!(txns[0].category_id, cats[0].id); let _ = std::fs::remove_file(&path); } #[test] fn test_parse_qif_date_formats() { assert_eq!( parse_qif_date("03/01/2026"), Some(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap()) ); assert_eq!( parse_qif_date("3/1/2026"), Some(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap()) ); assert_eq!( parse_qif_date("03-01-2026"), Some(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap()) ); assert_eq!( parse_qif_date("3/ 1'26"), Some(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap()) ); } #[test] fn test_import_qif_empty_file() { let db = setup_db(); let path = write_temp_qif("!Type:Bank\n"); let count = import_qif(&db, &path, true).unwrap(); assert_eq!(count, 0); let _ = std::fs::remove_file(&path); } }