Add feature batch 2, subscription/recurring sync, smooth charts, and app icon
- Implement subscriptions view with bidirectional recurring transaction sync - Add cascade delete/pause/resume between subscriptions and recurring - Fix foreign key constraints when deleting recurring transactions - Add cross-view instant refresh via callback pattern - Replace Bezier chart smoothing with Fritsch-Carlson monotone Hermite interpolation - Smooth budget sparklines using shared monotone_subdivide function - Add vertical spacing to budget rows - Add app icon (receipt on GNOME blue) in all sizes for desktop, web, and AppImage - Add calendar, credit cards, forecast, goals, insights, and wishlist views - Add date picker, numpad, quick-add, category combo, and edit dialog components - Add import/export for CSV, JSON, OFX, QIF formats - Add NLP transaction parsing, OCR receipt scanning, expression evaluator - Add notification support, Sankey chart, tray icon - Add demo data seeder with full DB wipe - Expand database schema with subscriptions, goals, credit cards, and more
This commit is contained in:
333
outlay-core/src/import_qif.rs
Normal file
333
outlay-core/src/import_qif.rs
Normal file
@@ -0,0 +1,333 @@
|
||||
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<usize, Box<dyn std::error::Error>> {
|
||||
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<NaiveDate> = None;
|
||||
let mut amount: Option<f64> = None;
|
||||
let mut payee: Option<String> = None;
|
||||
let mut category: Option<String> = None;
|
||||
let mut memo: Option<String> = 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::<f64>().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<NaiveDate> {
|
||||
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<String>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user