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:
2026-03-03 21:18:37 +02:00
parent 773dae4684
commit 10a76e3003
10102 changed files with 108019 additions and 1335 deletions

View 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);
}
}