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:
@@ -182,6 +182,7 @@ mod tests {
|
||||
note: Some("Test transaction".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
|
||||
|
||||
33
outlay-core/src/bin/seed_demo.rs
Normal file
33
outlay-core/src/bin/seed_demo.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let data_dir: PathBuf = dirs_next().join("outlay");
|
||||
let db_path = data_dir.join("outlay.db");
|
||||
|
||||
println!("Database path: {}", db_path.display());
|
||||
|
||||
if db_path.exists() {
|
||||
println!("Removing existing database for a clean seed...");
|
||||
std::fs::remove_file(&db_path).expect("Failed to remove existing database");
|
||||
}
|
||||
|
||||
let db = outlay_core::db::Database::open(&db_path)
|
||||
.expect("Failed to open database");
|
||||
|
||||
println!("Seeding demo data (2 years of realistic usage)...");
|
||||
|
||||
outlay_core::seed::seed_demo_data(&db)
|
||||
.expect("Failed to seed demo data");
|
||||
|
||||
println!("Done! Restart Outlay to see the demo data.");
|
||||
}
|
||||
|
||||
fn dirs_next() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("XDG_DATA_HOME") {
|
||||
PathBuf::from(dir)
|
||||
} else if let Ok(home) = std::env::var("HOME") {
|
||||
PathBuf::from(home).join(".local").join("share")
|
||||
} else {
|
||||
PathBuf::from(".")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,7 @@ pub fn export_transactions_csv<W: Write>(
|
||||
let transactions = db.list_all_transactions(from, to)?;
|
||||
let mut wtr = Writer::from_writer(writer);
|
||||
|
||||
wtr.write_record(["Date", "Type", "Category", "Amount", "Currency", "Exchange Rate", "Note"])?;
|
||||
wtr.write_record(["Date", "Type", "Category", "Amount", "Currency", "Exchange Rate", "Note", "Payee"])?;
|
||||
|
||||
for txn in &transactions {
|
||||
let cat_name = db
|
||||
@@ -55,6 +55,7 @@ pub fn export_transactions_csv<W: Write>(
|
||||
txn.currency.clone(),
|
||||
format!("{:.4}", txn.exchange_rate),
|
||||
txn.note.clone().unwrap_or_default(),
|
||||
txn.payee.clone().unwrap_or_default(),
|
||||
])?;
|
||||
}
|
||||
|
||||
@@ -86,6 +87,7 @@ mod tests {
|
||||
note: Some("Lunch".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
|
||||
@@ -96,7 +98,7 @@ mod tests {
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
let lines: Vec<&str> = output.trim().lines().collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(lines[0], "Date,Type,Category,Amount,Currency,Exchange Rate,Note");
|
||||
assert_eq!(lines[0], "Date,Type,Category,Amount,Currency,Exchange Rate,Note,Payee");
|
||||
assert!(lines[1].contains("2026-03-01"));
|
||||
assert!(lines[1].contains("expense"));
|
||||
assert!(lines[1].contains("42.50"));
|
||||
@@ -131,6 +133,7 @@ mod tests {
|
||||
note: None,
|
||||
date: NaiveDate::from_ymd_opt(2026, 1, day).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
}
|
||||
@@ -162,6 +165,7 @@ mod tests {
|
||||
note: None,
|
||||
date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
let txn2 = NewTransaction {
|
||||
amount: 1000.0,
|
||||
@@ -172,6 +176,7 @@ mod tests {
|
||||
note: Some("Salary".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn1).unwrap();
|
||||
db.insert_transaction(&txn2).unwrap();
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::models::{Budget, Category, RecurringTransaction, Transaction};
|
||||
use serde::Serialize;
|
||||
use std::io::Write;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, serde::Deserialize)]
|
||||
pub struct ExportData {
|
||||
pub transactions: Vec<Transaction>,
|
||||
pub categories: Vec<Category>,
|
||||
@@ -87,6 +87,7 @@ mod tests {
|
||||
note: Some("Test".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
|
||||
@@ -136,6 +137,7 @@ mod tests {
|
||||
note: Some("Freelance".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
|
||||
|
||||
351
outlay-core/src/export_ofx.rs
Normal file
351
outlay-core/src/export_ofx.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
use crate::db::Database;
|
||||
use crate::models::TransactionType;
|
||||
use chrono::{Local, NaiveDate};
|
||||
use std::io::Write;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ExportError {
|
||||
Db(rusqlite::Error),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for ExportError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
ExportError::Db(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ExportError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
ExportError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExportError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ExportError::Db(e) => write!(f, "Database error: {}", e),
|
||||
ExportError::Io(e) => write!(f, "IO error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape special characters for OFX SGML content.
|
||||
fn ofx_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// Export transactions to OFX 1.6 SGML format.
|
||||
///
|
||||
/// This produces a bank statement download file compatible with
|
||||
/// Quicken, GnuCash, and other personal finance applications.
|
||||
pub fn export_ofx<W: Write>(
|
||||
db: &Database,
|
||||
writer: &mut W,
|
||||
from: Option<NaiveDate>,
|
||||
to: Option<NaiveDate>,
|
||||
) -> Result<usize, ExportError> {
|
||||
let transactions = db.list_all_transactions(from, to)?;
|
||||
let now = Local::now();
|
||||
let dtserver = now.format("%Y%m%d%H%M%S").to_string();
|
||||
|
||||
// Determine date range for the statement
|
||||
let start_date = transactions
|
||||
.first()
|
||||
.map(|t| t.date)
|
||||
.unwrap_or_else(|| now.date_naive());
|
||||
let end_date = transactions
|
||||
.last()
|
||||
.map(|t| t.date)
|
||||
.unwrap_or_else(|| now.date_naive());
|
||||
|
||||
let base_currency = db
|
||||
.get_setting("base_currency")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
// OFX SGML headers
|
||||
writeln!(writer, "OFXHEADER:100")?;
|
||||
writeln!(writer, "DATA:OFXSGML")?;
|
||||
writeln!(writer, "VERSION:160")?;
|
||||
writeln!(writer, "SECURITY:NONE")?;
|
||||
writeln!(writer, "ENCODING:USASCII")?;
|
||||
writeln!(writer, "CHARSET:1252")?;
|
||||
writeln!(writer, "COMPRESSION:NONE")?;
|
||||
writeln!(writer, "OLDFILEUID:NONE")?;
|
||||
writeln!(writer, "NEWFILEUID:NONE")?;
|
||||
writeln!(writer)?;
|
||||
|
||||
// OFX body
|
||||
writeln!(writer, "<OFX>")?;
|
||||
writeln!(writer, "<SIGNONMSGSRSV1>")?;
|
||||
writeln!(writer, "<SONRS>")?;
|
||||
writeln!(writer, "<STATUS>")?;
|
||||
writeln!(writer, "<CODE>0")?;
|
||||
writeln!(writer, "<SEVERITY>INFO")?;
|
||||
writeln!(writer, "</STATUS>")?;
|
||||
writeln!(writer, "<DTSERVER>{}", dtserver)?;
|
||||
writeln!(writer, "<LANGUAGE>ENG")?;
|
||||
writeln!(writer, "</SONRS>")?;
|
||||
writeln!(writer, "</SIGNONMSGSRSV1>")?;
|
||||
|
||||
writeln!(writer, "<BANKMSGSRSV1>")?;
|
||||
writeln!(writer, "<STMTTRNRS>")?;
|
||||
writeln!(writer, "<TRNUID>0")?;
|
||||
writeln!(writer, "<STATUS>")?;
|
||||
writeln!(writer, "<CODE>0")?;
|
||||
writeln!(writer, "<SEVERITY>INFO")?;
|
||||
writeln!(writer, "</STATUS>")?;
|
||||
writeln!(writer, "<STMTRS>")?;
|
||||
writeln!(writer, "<CURDEF>{}", base_currency)?;
|
||||
writeln!(writer, "<BANKACCTFROM>")?;
|
||||
writeln!(writer, "<BANKID>0")?;
|
||||
writeln!(writer, "<ACCTID>OUTLAY")?;
|
||||
writeln!(writer, "<ACCTTYPE>CHECKING")?;
|
||||
writeln!(writer, "</BANKACCTFROM>")?;
|
||||
|
||||
writeln!(writer, "<BANKTRANLIST>")?;
|
||||
writeln!(
|
||||
writer,
|
||||
"<DTSTART>{}",
|
||||
start_date.format("%Y%m%d")
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"<DTEND>{}",
|
||||
end_date.format("%Y%m%d")
|
||||
)?;
|
||||
|
||||
for txn in &transactions {
|
||||
let trntype = match txn.transaction_type {
|
||||
TransactionType::Expense => "DEBIT",
|
||||
TransactionType::Income => "CREDIT",
|
||||
};
|
||||
let amount = match txn.transaction_type {
|
||||
TransactionType::Expense => -txn.amount,
|
||||
TransactionType::Income => txn.amount,
|
||||
};
|
||||
let cat_name = db
|
||||
.get_category(txn.category_id)
|
||||
.map(|c| c.name)
|
||||
.unwrap_or_else(|_| "Unknown".to_string());
|
||||
|
||||
let name = if let Some(ref payee) = txn.payee {
|
||||
if !payee.is_empty() {
|
||||
ofx_escape(payee)
|
||||
} else {
|
||||
ofx_escape(&cat_name)
|
||||
}
|
||||
} else {
|
||||
ofx_escape(&cat_name)
|
||||
};
|
||||
|
||||
writeln!(writer, "<STMTTRN>")?;
|
||||
writeln!(writer, "<TRNTYPE>{}", trntype)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"<DTPOSTED>{}",
|
||||
txn.date.format("%Y%m%d")
|
||||
)?;
|
||||
writeln!(writer, "<TRNAMT>{:.2}", amount)?;
|
||||
writeln!(writer, "<FITID>{}", txn.id)?;
|
||||
writeln!(writer, "<NAME>{}", name)?;
|
||||
|
||||
if let Some(ref note) = txn.note {
|
||||
if !note.is_empty() {
|
||||
writeln!(writer, "<MEMO>{}", ofx_escape(note))?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(writer, "</STMTTRN>")?;
|
||||
}
|
||||
|
||||
writeln!(writer, "</BANKTRANLIST>")?;
|
||||
|
||||
// Ledger balance (sum of all exported transactions)
|
||||
let balance: f64 = transactions.iter().map(|t| match t.transaction_type {
|
||||
TransactionType::Expense => -t.amount,
|
||||
TransactionType::Income => t.amount,
|
||||
}).sum();
|
||||
|
||||
writeln!(writer, "<LEDGERBAL>")?;
|
||||
writeln!(writer, "<BALAMT>{:.2}", balance)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"<DTASOF>{}",
|
||||
end_date.format("%Y%m%d")
|
||||
)?;
|
||||
writeln!(writer, "</LEDGERBAL>")?;
|
||||
|
||||
writeln!(writer, "</STMTRS>")?;
|
||||
writeln!(writer, "</STMTTRNRS>")?;
|
||||
writeln!(writer, "</BANKMSGSRSV1>")?;
|
||||
writeln!(writer, "</OFX>")?;
|
||||
|
||||
Ok(transactions.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::NewTransaction;
|
||||
|
||||
fn setup_db() -> Database {
|
||||
Database::open_in_memory().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ofx_header() {
|
||||
let db = setup_db();
|
||||
let mut buf = Vec::new();
|
||||
export_ofx(&db, &mut buf, None, None).unwrap();
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
assert!(output.starts_with("OFXHEADER:100"));
|
||||
assert!(output.contains("VERSION:160"));
|
||||
assert!(output.contains("<OFX>"));
|
||||
assert!(output.contains("</OFX>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ofx_expense_debit() {
|
||||
let db = setup_db();
|
||||
let cats = db
|
||||
.list_categories(Some(TransactionType::Expense))
|
||||
.unwrap();
|
||||
|
||||
let txn = NewTransaction {
|
||||
amount: 25.99,
|
||||
transaction_type: TransactionType::Expense,
|
||||
category_id: cats[0].id,
|
||||
currency: "USD".to_string(),
|
||||
exchange_rate: 1.0,
|
||||
note: Some("Books".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: Some("Amazon".to_string()),
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let count = export_ofx(&db, &mut buf, None, None).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
assert!(output.contains("<TRNTYPE>DEBIT"));
|
||||
assert!(output.contains("<TRNAMT>-25.99"));
|
||||
assert!(output.contains("<NAME>Amazon"));
|
||||
assert!(output.contains("<MEMO>Books"));
|
||||
assert!(output.contains("<DTPOSTED>20260301"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ofx_income_credit() {
|
||||
let db = setup_db();
|
||||
let cats = db
|
||||
.list_categories(Some(TransactionType::Income))
|
||||
.unwrap();
|
||||
|
||||
let txn = NewTransaction {
|
||||
amount: 5000.0,
|
||||
transaction_type: TransactionType::Income,
|
||||
category_id: cats[0].id,
|
||||
currency: "USD".to_string(),
|
||||
exchange_rate: 1.0,
|
||||
note: None,
|
||||
date: NaiveDate::from_ymd_opt(2026, 2, 28).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
export_ofx(&db, &mut buf, None, None).unwrap();
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
assert!(output.contains("<TRNTYPE>CREDIT"));
|
||||
assert!(output.contains("<TRNAMT>5000.00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ofx_escapes_special_chars() {
|
||||
let db = setup_db();
|
||||
let cats = db
|
||||
.list_categories(Some(TransactionType::Expense))
|
||||
.unwrap();
|
||||
|
||||
let txn = NewTransaction {
|
||||
amount: 10.0,
|
||||
transaction_type: TransactionType::Expense,
|
||||
category_id: cats[0].id,
|
||||
currency: "USD".to_string(),
|
||||
exchange_rate: 1.0,
|
||||
note: Some("Tom & Jerry's <shop>".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: Some("A&B Store".to_string()),
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
export_ofx(&db, &mut buf, None, None).unwrap();
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
assert!(output.contains("<NAME>A&B Store"));
|
||||
assert!(output.contains("Tom & Jerry's <shop>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ofx_empty_export() {
|
||||
let db = setup_db();
|
||||
let mut buf = Vec::new();
|
||||
let count = export_ofx(&db, &mut buf, None, None).unwrap();
|
||||
assert_eq!(count, 0);
|
||||
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
assert!(output.contains("<BANKTRANLIST>"));
|
||||
assert!(output.contains("</BANKTRANLIST>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ofx_ledger_balance() {
|
||||
let db = setup_db();
|
||||
let expense_cats = db
|
||||
.list_categories(Some(TransactionType::Expense))
|
||||
.unwrap();
|
||||
let income_cats = db
|
||||
.list_categories(Some(TransactionType::Income))
|
||||
.unwrap();
|
||||
|
||||
let txn1 = NewTransaction {
|
||||
amount: 100.0,
|
||||
transaction_type: TransactionType::Income,
|
||||
category_id: income_cats[0].id,
|
||||
currency: "USD".to_string(),
|
||||
exchange_rate: 1.0,
|
||||
note: None,
|
||||
date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
let txn2 = NewTransaction {
|
||||
amount: 30.0,
|
||||
transaction_type: TransactionType::Expense,
|
||||
category_id: expense_cats[0].id,
|
||||
currency: "USD".to_string(),
|
||||
exchange_rate: 1.0,
|
||||
note: None,
|
||||
date: NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn1).unwrap();
|
||||
db.insert_transaction(&txn2).unwrap();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
export_ofx(&db, &mut buf, None, None).unwrap();
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
// Balance should be 100 - 30 = 70
|
||||
assert!(output.contains("<BALAMT>70.00"));
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,7 @@ mod tests {
|
||||
note: Some("Groceries".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
},
|
||||
NewTransaction {
|
||||
amount: 12.50,
|
||||
@@ -322,6 +323,7 @@ mod tests {
|
||||
note: Some("Coffee".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 5).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
},
|
||||
NewTransaction {
|
||||
amount: 3000.0,
|
||||
@@ -332,6 +334,7 @@ mod tests {
|
||||
note: Some("Salary".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -340,7 +343,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// Set a budget
|
||||
db.set_budget(cats[0].id, "2026-03", 200.0).unwrap();
|
||||
db.set_budget(cats[0].id, "2026-03", 200.0, false).unwrap();
|
||||
|
||||
let tmp = std::env::temp_dir().join("outlay_test_report.pdf");
|
||||
generate_monthly_report(&db, 2026, 3, "USD", &tmp).unwrap();
|
||||
|
||||
264
outlay-core/src/export_qif.rs
Normal file
264
outlay-core/src/export_qif.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
use crate::db::Database;
|
||||
use crate::models::TransactionType;
|
||||
use chrono::NaiveDate;
|
||||
use std::io::Write;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ExportError {
|
||||
Db(rusqlite::Error),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for ExportError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
ExportError::Db(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ExportError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
ExportError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExportError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ExportError::Db(e) => write!(f, "Database error: {}", e),
|
||||
ExportError::Io(e) => write!(f, "IO error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a QIF-style category path like "Food:Groceries" for subcategories.
|
||||
fn category_path(db: &Database, category_id: i64) -> String {
|
||||
let cat = match db.get_category(category_id) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return "Unknown".to_string(),
|
||||
};
|
||||
if let Some(parent_id) = cat.parent_id {
|
||||
if let Ok(parent) = db.get_category(parent_id) {
|
||||
return format!("{}:{}", parent.name, cat.name);
|
||||
}
|
||||
}
|
||||
cat.name
|
||||
}
|
||||
|
||||
/// Export transactions to QIF (Quicken Interchange Format).
|
||||
///
|
||||
/// Produces `!Type:Bank` records with support for splits.
|
||||
/// Dates use MM/DD/YYYY as per the QIF specification.
|
||||
pub fn export_qif<W: Write>(
|
||||
db: &Database,
|
||||
writer: &mut W,
|
||||
from: Option<NaiveDate>,
|
||||
to: Option<NaiveDate>,
|
||||
) -> Result<usize, ExportError> {
|
||||
let transactions = db.list_all_transactions(from, to)?;
|
||||
|
||||
writeln!(writer, "!Type:Bank")?;
|
||||
|
||||
for txn in &transactions {
|
||||
// D - date in MM/DD/YYYY
|
||||
let date_str = txn.date.format("%m/%d/%Y").to_string();
|
||||
writeln!(writer, "D{}", date_str)?;
|
||||
|
||||
// T - amount (negative for expenses)
|
||||
let amount = match txn.transaction_type {
|
||||
TransactionType::Expense => -txn.amount,
|
||||
TransactionType::Income => txn.amount,
|
||||
};
|
||||
writeln!(writer, "T{:.2}", amount)?;
|
||||
|
||||
// P - payee
|
||||
if let Some(ref payee) = txn.payee {
|
||||
if !payee.is_empty() {
|
||||
writeln!(writer, "P{}", payee)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for splits
|
||||
let splits = db.get_splits(txn.id).unwrap_or_default();
|
||||
|
||||
if splits.is_empty() {
|
||||
// L - category
|
||||
let cat_path = category_path(db, txn.category_id);
|
||||
writeln!(writer, "L{}", cat_path)?;
|
||||
} else {
|
||||
// Split lines: S for category, $ for amount, E for memo
|
||||
for split in &splits {
|
||||
let split_cat = category_path(db, split.category_id);
|
||||
writeln!(writer, "S{}", split_cat)?;
|
||||
|
||||
let split_amount = match txn.transaction_type {
|
||||
TransactionType::Expense => -split.amount,
|
||||
TransactionType::Income => split.amount,
|
||||
};
|
||||
writeln!(writer, "${:.2}", split_amount)?;
|
||||
|
||||
if let Some(ref note) = split.note {
|
||||
if !note.is_empty() {
|
||||
writeln!(writer, "E{}", note)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// M - memo/note
|
||||
if let Some(ref note) = txn.note {
|
||||
if !note.is_empty() {
|
||||
writeln!(writer, "M{}", note)?;
|
||||
}
|
||||
}
|
||||
|
||||
// ^ - end of record
|
||||
writeln!(writer, "^")?;
|
||||
}
|
||||
|
||||
Ok(transactions.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::NewTransaction;
|
||||
|
||||
fn setup_db() -> Database {
|
||||
Database::open_in_memory().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qif_header() {
|
||||
let db = setup_db();
|
||||
let mut buf = Vec::new();
|
||||
export_qif(&db, &mut buf, None, None).unwrap();
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
assert!(output.starts_with("!Type:Bank"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qif_expense_negative_amount() {
|
||||
let db = setup_db();
|
||||
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
|
||||
|
||||
let txn = NewTransaction {
|
||||
amount: 42.50,
|
||||
transaction_type: TransactionType::Expense,
|
||||
category_id: cats[0].id,
|
||||
currency: "USD".to_string(),
|
||||
exchange_rate: 1.0,
|
||||
note: Some("Lunch".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: Some("Cafe".to_string()),
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let count = export_qif(&db, &mut buf, None, None).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
assert!(output.contains("D03/01/2026"));
|
||||
assert!(output.contains("T-42.50"));
|
||||
assert!(output.contains("PCafe"));
|
||||
assert!(output.contains("MLunch"));
|
||||
assert!(output.contains("^"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qif_income_positive_amount() {
|
||||
let db = setup_db();
|
||||
let cats = db.list_categories(Some(TransactionType::Income)).unwrap();
|
||||
|
||||
let txn = NewTransaction {
|
||||
amount: 1000.0,
|
||||
transaction_type: TransactionType::Income,
|
||||
category_id: cats[0].id,
|
||||
currency: "USD".to_string(),
|
||||
exchange_rate: 1.0,
|
||||
note: Some("Salary".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
export_qif(&db, &mut buf, None, None).unwrap();
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
assert!(output.contains("T1000.00"));
|
||||
assert!(output.contains("MSalary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qif_record_separator() {
|
||||
let db = setup_db();
|
||||
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
|
||||
|
||||
for day in 1..=3 {
|
||||
let txn = NewTransaction {
|
||||
amount: 10.0,
|
||||
transaction_type: TransactionType::Expense,
|
||||
category_id: cats[0].id,
|
||||
currency: "USD".to_string(),
|
||||
exchange_rate: 1.0,
|
||||
note: None,
|
||||
date: NaiveDate::from_ymd_opt(2026, 1, day).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let count = export_qif(&db, &mut buf, None, None).unwrap();
|
||||
assert_eq!(count, 3);
|
||||
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
let separators = output.lines().filter(|l| *l == "^").count();
|
||||
assert_eq!(separators, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qif_date_range_filter() {
|
||||
let db = setup_db();
|
||||
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
|
||||
|
||||
for day in 1..=5 {
|
||||
let txn = NewTransaction {
|
||||
amount: 10.0,
|
||||
transaction_type: TransactionType::Expense,
|
||||
category_id: cats[0].id,
|
||||
currency: "USD".to_string(),
|
||||
exchange_rate: 1.0,
|
||||
note: None,
|
||||
date: NaiveDate::from_ymd_opt(2026, 1, day).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let count = export_qif(
|
||||
&db,
|
||||
&mut buf,
|
||||
Some(NaiveDate::from_ymd_opt(2026, 1, 2).unwrap()),
|
||||
Some(NaiveDate::from_ymd_opt(2026, 1, 4).unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qif_empty_export() {
|
||||
let db = setup_db();
|
||||
let mut buf = Vec::new();
|
||||
let count = export_qif(&db, &mut buf, None, None).unwrap();
|
||||
assert_eq!(count, 0);
|
||||
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
assert_eq!(output.trim(), "!Type:Bank");
|
||||
}
|
||||
}
|
||||
168
outlay-core/src/expr.rs
Normal file
168
outlay-core/src/expr.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
/// Evaluate a simple arithmetic expression containing +, -, *, /.
|
||||
/// Supports decimal numbers. Returns None if the input is not a valid expression.
|
||||
pub fn eval_expr(input: &str) -> Option<f64> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// If it's just a plain number, parse directly
|
||||
if let Ok(v) = input.parse::<f64>() {
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
// Tokenize
|
||||
let mut tokens = Vec::new();
|
||||
let mut num_buf = String::new();
|
||||
|
||||
for ch in input.chars() {
|
||||
if ch.is_ascii_digit() || ch == '.' {
|
||||
num_buf.push(ch);
|
||||
} else if ch == '+' || ch == '-' || ch == '*' || ch == '/' {
|
||||
if num_buf.is_empty() {
|
||||
return None;
|
||||
}
|
||||
tokens.push(Token::Num(num_buf.parse::<f64>().ok()?));
|
||||
num_buf.clear();
|
||||
tokens.push(Token::Op(ch));
|
||||
} else if ch.is_whitespace() {
|
||||
continue;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if !num_buf.is_empty() {
|
||||
tokens.push(Token::Num(num_buf.parse::<f64>().ok()?));
|
||||
}
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Evaluate: * and / first (left to right), then + and -
|
||||
let mut simplified: Vec<Token> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < tokens.len() {
|
||||
if !simplified.is_empty() {
|
||||
match simplified.last() {
|
||||
Some(Token::Op('*')) | Some(Token::Op('/')) => {
|
||||
if let Token::Num(b) = &tokens[i] {
|
||||
let op = if let Some(Token::Op(op)) = simplified.pop() { op } else { return None; };
|
||||
if let Some(Token::Num(a)) = simplified.pop() {
|
||||
let result = if op == '*' { a * b } else {
|
||||
if *b == 0.0 { return None; }
|
||||
a / b
|
||||
};
|
||||
simplified.push(Token::Num(result));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
simplified.push(tokens[i].clone());
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Second pass: handle + and -
|
||||
let mut result = match simplified.first()? {
|
||||
Token::Num(n) => *n,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let mut j = 1;
|
||||
while j + 1 < simplified.len() {
|
||||
let num = match &simplified[j + 1] {
|
||||
Token::Num(n) => *n,
|
||||
_ => return None,
|
||||
};
|
||||
match &simplified[j] {
|
||||
Token::Op('+') => result += num,
|
||||
Token::Op('-') => result -= num,
|
||||
_ => return None,
|
||||
}
|
||||
j += 2;
|
||||
}
|
||||
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Token {
|
||||
Num(f64),
|
||||
Op(char),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_plain_number() {
|
||||
assert_eq!(eval_expr("12.50"), Some(12.50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_addition() {
|
||||
assert_eq!(eval_expr("12.50+8.75"), Some(21.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subtraction() {
|
||||
assert_eq!(eval_expr("100-25.50"), Some(74.50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiplication() {
|
||||
assert_eq!(eval_expr("3*4.5"), Some(13.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mul_before_add() {
|
||||
assert_eq!(eval_expr("10+5*2"), Some(20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spaces() {
|
||||
assert_eq!(eval_expr("10 + 5"), Some(15.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty() {
|
||||
assert_eq!(eval_expr(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid() {
|
||||
assert_eq!(eval_expr("abc"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chain() {
|
||||
assert_eq!(eval_expr("1+2+3"), Some(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_ops() {
|
||||
// 5 + 3*2 - 1 = 5 + 6 - 1 = 10
|
||||
assert_eq!(eval_expr("5+3*2-1"), Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_division() {
|
||||
assert_eq!(eval_expr("10/4"), Some(2.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_division_by_zero() {
|
||||
assert_eq!(eval_expr("10/0"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_div_before_add() {
|
||||
// 10 + 6/2 = 10 + 3 = 13
|
||||
assert_eq!(eval_expr("10+6/2"), Some(13.0));
|
||||
}
|
||||
}
|
||||
75
outlay-core/src/import_csv.rs
Normal file
75
outlay-core/src/import_csv.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use crate::db::Database;
|
||||
use crate::models::{NewTransaction, TransactionType};
|
||||
use std::path::Path;
|
||||
|
||||
pub fn import_csv(db: &Database, path: &Path, merge: bool) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
if !merge {
|
||||
db.reset_all_data()?;
|
||||
}
|
||||
|
||||
let mut reader = csv::Reader::from_path(path)?;
|
||||
let mut count = 0;
|
||||
|
||||
for result in reader.records() {
|
||||
let record = result?;
|
||||
if record.len() < 6 {
|
||||
continue;
|
||||
}
|
||||
let date_str = &record[0];
|
||||
let type_str = &record[1];
|
||||
let category_name = &record[2];
|
||||
let amount: f64 = match record[3].parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let currency = &record[4];
|
||||
let exchange_rate: f64 = match record[5].parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) => 1.0,
|
||||
};
|
||||
let note = if record.len() > 6 && !record[6].is_empty() {
|
||||
Some(record[6].to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let payee = if record.len() > 7 && !record[7].is_empty() {
|
||||
Some(record[7].to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let txn_type = match type_str.to_lowercase().as_str() {
|
||||
"expense" => TransactionType::Expense,
|
||||
"income" => TransactionType::Income,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let categories = db.list_categories(Some(txn_type))?;
|
||||
let category_id = match categories.iter().find(|c| c.name == category_name) {
|
||||
Some(c) => c.id,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")?;
|
||||
|
||||
if merge && db.find_duplicate_transaction(amount, txn_type, category_id, date)? {
|
||||
continue;
|
||||
}
|
||||
|
||||
let new_txn = NewTransaction {
|
||||
amount,
|
||||
transaction_type: txn_type,
|
||||
category_id,
|
||||
currency: currency.to_string(),
|
||||
exchange_rate,
|
||||
note,
|
||||
date,
|
||||
recurring_id: None,
|
||||
payee,
|
||||
};
|
||||
db.insert_transaction(&new_txn)?;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
59
outlay-core/src/import_json.rs
Normal file
59
outlay-core/src/import_json.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use crate::db::Database;
|
||||
use crate::export_json::ExportData;
|
||||
use crate::models::{NewCategory, NewTransaction};
|
||||
use std::path::Path;
|
||||
|
||||
pub fn import_json(db: &Database, path: &Path, merge: bool) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let data: ExportData = serde_json::from_str(&content)?;
|
||||
|
||||
if !merge {
|
||||
db.reset_all_data()?;
|
||||
}
|
||||
|
||||
for cat in &data.categories {
|
||||
let existing = db.list_categories(Some(cat.transaction_type))?;
|
||||
if !existing.iter().any(|c| c.name == cat.name) {
|
||||
let new_cat = NewCategory {
|
||||
name: cat.name.clone(),
|
||||
icon: cat.icon.clone(),
|
||||
color: cat.color.clone(),
|
||||
transaction_type: cat.transaction_type,
|
||||
sort_order: cat.sort_order,
|
||||
parent_id: None,
|
||||
};
|
||||
db.insert_category(&new_cat)?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut count = 0;
|
||||
for txn in &data.transactions {
|
||||
let categories = db.list_categories(Some(txn.transaction_type))?;
|
||||
let original_cat = data.categories.iter().find(|c| c.id == txn.category_id);
|
||||
let category_id = match original_cat {
|
||||
Some(oc) => categories.iter().find(|c| c.name == oc.name).map(|c| c.id),
|
||||
None => None,
|
||||
};
|
||||
let Some(category_id) = category_id else { continue };
|
||||
|
||||
if merge && db.find_duplicate_transaction(txn.amount, txn.transaction_type, category_id, txn.date)? {
|
||||
continue;
|
||||
}
|
||||
|
||||
let new_txn = NewTransaction {
|
||||
amount: txn.amount,
|
||||
transaction_type: txn.transaction_type,
|
||||
category_id,
|
||||
currency: txn.currency.clone(),
|
||||
exchange_rate: txn.exchange_rate,
|
||||
note: txn.note.clone(),
|
||||
date: txn.date,
|
||||
recurring_id: None,
|
||||
payee: txn.payee.clone(),
|
||||
};
|
||||
db.insert_transaction(&new_txn)?;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
298
outlay-core/src/import_ofx.rs
Normal file
298
outlay-core/src/import_ofx.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
use crate::db::Database;
|
||||
use crate::models::{NewTransaction, TransactionType};
|
||||
use chrono::NaiveDate;
|
||||
use std::path::Path;
|
||||
|
||||
/// Import transactions from an OFX 1.6 SGML file.
|
||||
///
|
||||
/// Parses STMTTRN records looking for:
|
||||
/// - TRNTYPE (DEBIT/CREDIT)
|
||||
/// - DTPOSTED (YYYYMMDD date)
|
||||
/// - TRNAMT (signed amount)
|
||||
/// - NAME (payee/description)
|
||||
/// - MEMO (note)
|
||||
///
|
||||
/// Since OFX does not carry category information, all imported
|
||||
/// transactions are assigned to the first available category
|
||||
/// of the matching type (expense for DEBIT, income for CREDIT).
|
||||
pub fn import_ofx(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 base_currency = db
|
||||
.get_setting("base_currency")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let mut count = 0;
|
||||
|
||||
// Parse STMTTRN blocks
|
||||
let mut pos = 0;
|
||||
let upper = content.to_uppercase();
|
||||
while let Some(start) = upper[pos..].find("<STMTTRN>") {
|
||||
let block_start = pos + start;
|
||||
let block_end = if let Some(end) = upper[block_start..].find("</STMTTRN>") {
|
||||
block_start + end + "</STMTTRN>".len()
|
||||
} else {
|
||||
// No closing tag - take until next STMTTRN or end
|
||||
if let Some(next) = upper[block_start + 9..].find("<STMTTRN>") {
|
||||
block_start + 9 + next
|
||||
} else {
|
||||
content.len()
|
||||
}
|
||||
};
|
||||
|
||||
let block = &content[block_start..block_end];
|
||||
|
||||
let trntype = extract_tag_value(block, "TRNTYPE");
|
||||
let dtposted = extract_tag_value(block, "DTPOSTED");
|
||||
let trnamt = extract_tag_value(block, "TRNAMT");
|
||||
let name = extract_tag_value(block, "NAME");
|
||||
let memo = extract_tag_value(block, "MEMO");
|
||||
|
||||
if let Some(amt_str) = &trnamt {
|
||||
if let Ok(amt) = amt_str.replace(',', "").parse::<f64>() {
|
||||
let txn_type = if let Some(ref tt) = trntype {
|
||||
match tt.to_uppercase().as_str() {
|
||||
"CREDIT" => TransactionType::Income,
|
||||
_ => TransactionType::Expense,
|
||||
}
|
||||
} else if amt < 0.0 {
|
||||
TransactionType::Expense
|
||||
} else {
|
||||
TransactionType::Income
|
||||
};
|
||||
|
||||
let abs_amount = amt.abs();
|
||||
let date = dtposted
|
||||
.as_ref()
|
||||
.and_then(|d| parse_ofx_date(d))
|
||||
.unwrap_or_else(|| chrono::Local::now().date_naive());
|
||||
|
||||
let category_id = match txn_type {
|
||||
TransactionType::Expense => default_expense_id,
|
||||
TransactionType::Income => default_income_id,
|
||||
};
|
||||
|
||||
let payee = name.as_ref().map(|n| ofx_unescape(n));
|
||||
let note = memo.as_ref().map(|m| ofx_unescape(m));
|
||||
|
||||
if merge && db.find_duplicate_transaction(abs_amount, txn_type, category_id, date)? {
|
||||
// Skip duplicate
|
||||
} else {
|
||||
let new_txn = NewTransaction {
|
||||
amount: abs_amount,
|
||||
transaction_type: txn_type,
|
||||
category_id,
|
||||
currency: base_currency.clone(),
|
||||
exchange_rate: 1.0,
|
||||
note,
|
||||
date,
|
||||
recurring_id: None,
|
||||
payee,
|
||||
};
|
||||
db.insert_transaction(&new_txn)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos = block_end;
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Extract the value of an OFX SGML tag from a block of text.
|
||||
/// OFX 1.6 SGML tags look like: <TAGNAME>value
|
||||
/// The value runs until the next < or newline.
|
||||
fn extract_tag_value(block: &str, tag: &str) -> Option<String> {
|
||||
let upper_block = block.to_uppercase();
|
||||
let search = format!("<{}>", tag.to_uppercase());
|
||||
let start = upper_block.find(&search)?;
|
||||
let value_start = start + search.len();
|
||||
let remaining = &block[value_start..];
|
||||
|
||||
// Value ends at next '<' or newline
|
||||
let end = remaining
|
||||
.find(|c: char| c == '<' || c == '\n' || c == '\r')
|
||||
.unwrap_or(remaining.len());
|
||||
|
||||
let value = remaining[..end].trim().to_string();
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an OFX date string (YYYYMMDD or YYYYMMDDHHMMSS).
|
||||
fn parse_ofx_date(s: &str) -> Option<NaiveDate> {
|
||||
let s = s.trim();
|
||||
// Take just the first 8 chars (YYYYMMDD)
|
||||
if s.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let date_part = &s[..8];
|
||||
NaiveDate::parse_from_str(date_part, "%Y%m%d").ok()
|
||||
}
|
||||
|
||||
/// Unescape OFX SGML entities.
|
||||
fn ofx_unescape(s: &str) -> String {
|
||||
s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
#[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_ofx(content: &str) -> std::path::PathBuf {
|
||||
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
let path = std::env::temp_dir().join(format!("outlay_test_ofx_{}.ofx", n));
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
f.write_all(content.as_bytes()).unwrap();
|
||||
f.flush().unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn minimal_ofx(transactions: &str) -> String {
|
||||
format!(
|
||||
"OFXHEADER:100\nDATA:OFXSGML\nVERSION:160\n\n\
|
||||
<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS>\n\
|
||||
<BANKTRANLIST>\n{}\n</BANKTRANLIST>\n\
|
||||
</STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>",
|
||||
transactions
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_ofx_expense() {
|
||||
let db = setup_db();
|
||||
let path = write_temp_ofx(&minimal_ofx(
|
||||
"<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>20260301\n<TRNAMT>-42.50\n<NAME>Cafe\n<MEMO>Lunch\n</STMTTRN>",
|
||||
));
|
||||
let count = import_ofx(&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_ofx_income() {
|
||||
let db = setup_db();
|
||||
let path = write_temp_ofx(&minimal_ofx(
|
||||
"<STMTTRN>\n<TRNTYPE>CREDIT\n<DTPOSTED>20260215\n<TRNAMT>5000.00\n<NAME>Employer\n</STMTTRN>",
|
||||
));
|
||||
let count = import_ofx(&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, 5000.0);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_ofx_multiple() {
|
||||
let db = setup_db();
|
||||
let path = write_temp_ofx(&minimal_ofx(
|
||||
"<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>20260101\n<TRNAMT>-10.00\n</STMTTRN>\n\
|
||||
<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>20260102\n<TRNAMT>-20.00\n</STMTTRN>\n\
|
||||
<STMTTRN>\n<TRNTYPE>CREDIT\n<DTPOSTED>20260103\n<TRNAMT>50.00\n</STMTTRN>",
|
||||
));
|
||||
let count = import_ofx(&db, &path, true).unwrap();
|
||||
assert_eq!(count, 3);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_ofx_merge_deduplication() {
|
||||
let db = setup_db();
|
||||
let path = write_temp_ofx(&minimal_ofx(
|
||||
"<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>20260301\n<TRNAMT>-42.50\n</STMTTRN>",
|
||||
));
|
||||
let count1 = import_ofx(&db, &path, true).unwrap();
|
||||
assert_eq!(count1, 1);
|
||||
|
||||
let count2 = import_ofx(&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_ofx_unescapes_entities() {
|
||||
let db = setup_db();
|
||||
let path = write_temp_ofx(&minimal_ofx(
|
||||
"<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>20260101\n<TRNAMT>-10.00\n<NAME>A&B Store\n<MEMO>Tom & Jerry's\n</STMTTRN>",
|
||||
));
|
||||
let count = import_ofx(&db, &path, true).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let txns = db.list_all_transactions(None, None).unwrap();
|
||||
assert_eq!(txns[0].payee.as_deref(), Some("A&B Store"));
|
||||
assert_eq!(txns[0].note.as_deref(), Some("Tom & Jerry's"));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_ofx_empty() {
|
||||
let db = setup_db();
|
||||
let path = write_temp_ofx(&minimal_ofx(""));
|
||||
let count = import_ofx(&db, &path, true).unwrap();
|
||||
assert_eq!(count, 0);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tag_value() {
|
||||
let block = "<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>20260301\n<TRNAMT>-42.50\n</STMTTRN>";
|
||||
assert_eq!(extract_tag_value(block, "TRNTYPE"), Some("DEBIT".to_string()));
|
||||
assert_eq!(extract_tag_value(block, "DTPOSTED"), Some("20260301".to_string()));
|
||||
assert_eq!(extract_tag_value(block, "TRNAMT"), Some("-42.50".to_string()));
|
||||
assert_eq!(extract_tag_value(block, "FITID"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ofx_date() {
|
||||
assert_eq!(
|
||||
parse_ofx_date("20260301"),
|
||||
Some(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_ofx_date("20260301120000"),
|
||||
Some(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap())
|
||||
);
|
||||
assert_eq!(parse_ofx_date("2026"), None);
|
||||
}
|
||||
}
|
||||
187
outlay-core/src/import_pdf.rs
Normal file
187
outlay-core/src/import_pdf.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use crate::models::PdfParsedRow;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
/// Extract transactions from a PDF bank statement.
|
||||
/// Tries text extraction first, falls back to OCR if no text found.
|
||||
pub fn extract_transactions_from_pdf(bytes: &[u8]) -> Result<Vec<PdfParsedRow>, String> {
|
||||
// Try text-based extraction first
|
||||
match extract_text_based(bytes) {
|
||||
Ok(rows) if !rows.is_empty() => return Ok(rows),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Fall back to OCR
|
||||
if crate::ocr::is_available() {
|
||||
return extract_ocr_based(bytes);
|
||||
}
|
||||
|
||||
Err("No text found in PDF and OCR is not available".to_string())
|
||||
}
|
||||
|
||||
fn extract_text_based(bytes: &[u8]) -> Result<Vec<PdfParsedRow>, String> {
|
||||
let text = pdf_extract::extract_text_from_mem(bytes)
|
||||
.map_err(|e| format!("PDF text extraction failed: {}", e))?;
|
||||
|
||||
if text.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut rows = Vec::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(row) = parse_statement_line(line) {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn extract_ocr_based(bytes: &[u8]) -> Result<Vec<PdfParsedRow>, String> {
|
||||
let amounts: Vec<(f64, String)> = crate::ocr::extract_amounts_from_image(bytes)
|
||||
.ok_or_else(|| "OCR extraction returned no results".to_string())?;
|
||||
|
||||
let rows: Vec<PdfParsedRow> = amounts
|
||||
.into_iter()
|
||||
.map(|(amount, source_line)| PdfParsedRow {
|
||||
date: None,
|
||||
description: source_line,
|
||||
amount: amount.abs(),
|
||||
is_credit: amount > 0.0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Try to parse a single line from a bank statement.
|
||||
/// Common formats:
|
||||
/// "01/15/2026 GROCERY STORE -45.67"
|
||||
/// "2026-01-15 SALARY +2500.00"
|
||||
/// "15 Jan Coffee Shop 12.50"
|
||||
fn parse_statement_line(line: &str) -> Option<PdfParsedRow> {
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try to find a date at the start
|
||||
let (date, desc_start) = try_parse_date_prefix(&tokens);
|
||||
|
||||
// Try to find an amount at the end
|
||||
let (amount, is_credit, desc_end) = try_parse_amount_suffix(&tokens)?;
|
||||
|
||||
// Everything between date and amount is description
|
||||
if desc_start >= desc_end {
|
||||
return None;
|
||||
}
|
||||
let description = tokens[desc_start..desc_end].join(" ");
|
||||
if description.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(PdfParsedRow {
|
||||
date,
|
||||
description,
|
||||
amount,
|
||||
is_credit,
|
||||
})
|
||||
}
|
||||
|
||||
fn try_parse_date_prefix(tokens: &[&str]) -> (Option<NaiveDate>, usize) {
|
||||
if tokens.is_empty() {
|
||||
return (None, 0);
|
||||
}
|
||||
|
||||
// Try single token: "2026-01-15", "01/15/2026", "15/01/2026"
|
||||
if let Some(d) = parse_date_flexible(tokens[0]) {
|
||||
return (Some(d), 1);
|
||||
}
|
||||
|
||||
// Try two tokens: "15 Jan", "Jan 15"
|
||||
if tokens.len() >= 2 {
|
||||
let combined = format!("{} {}", tokens[0], tokens[1]);
|
||||
if let Some(d) = parse_date_flexible(&combined) {
|
||||
return (Some(d), 2);
|
||||
}
|
||||
// Try three tokens: "15 Jan 2026"
|
||||
if tokens.len() >= 3 {
|
||||
let combined3 = format!("{} {} {}", tokens[0], tokens[1], tokens[2]);
|
||||
if let Some(d) = parse_date_flexible(&combined3) {
|
||||
return (Some(d), 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(None, 0)
|
||||
}
|
||||
|
||||
fn try_parse_amount_suffix(tokens: &[&str]) -> Option<(f64, bool, usize)> {
|
||||
for i in (0..tokens.len()).rev() {
|
||||
let tok = tokens[i];
|
||||
let cleaned = tok.replace(',', "").replace('$', "");
|
||||
if let Ok(val) = cleaned.parse::<f64>() {
|
||||
let is_credit = val > 0.0 || tok.starts_with('+');
|
||||
return Some((val.abs(), is_credit, i));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_date_flexible(s: &str) -> Option<NaiveDate> {
|
||||
let formats = [
|
||||
"%Y-%m-%d",
|
||||
"%m/%d/%Y",
|
||||
"%d/%m/%Y",
|
||||
"%m-%d-%Y",
|
||||
"%d %b %Y",
|
||||
"%b %d %Y",
|
||||
"%d %b",
|
||||
"%b %d",
|
||||
];
|
||||
for fmt in &formats {
|
||||
if let Ok(d) = NaiveDate::parse_from_str(s, fmt) {
|
||||
return Some(d);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_line_with_date_and_amount() {
|
||||
let row = parse_statement_line("2026-01-15 GROCERY STORE -45.67").unwrap();
|
||||
assert_eq!(
|
||||
row.date,
|
||||
Some(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap())
|
||||
);
|
||||
assert_eq!(row.description, "GROCERY STORE");
|
||||
assert!((row.amount - 45.67).abs() < 0.01);
|
||||
assert!(!row.is_credit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_line_credit() {
|
||||
let row = parse_statement_line("2026-01-15 SALARY +2500.00").unwrap();
|
||||
assert!((row.amount - 2500.0).abs() < 0.01);
|
||||
assert!(row.is_credit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_line_no_date() {
|
||||
let row = parse_statement_line("COFFEE SHOP 12.50").unwrap();
|
||||
assert!(row.date.is_none());
|
||||
assert_eq!(row.description, "COFFEE SHOP");
|
||||
assert!((row.amount - 12.50).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_line_too_short() {
|
||||
assert!(parse_statement_line("hello").is_none());
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,19 @@ pub mod db;
|
||||
pub mod exchange;
|
||||
pub mod export_csv;
|
||||
pub mod export_json;
|
||||
pub mod export_ofx;
|
||||
pub mod export_pdf;
|
||||
pub mod export_qif;
|
||||
pub mod import_csv;
|
||||
pub mod import_json;
|
||||
pub mod import_ofx;
|
||||
pub mod import_qif;
|
||||
pub mod backup;
|
||||
pub mod recurring;
|
||||
pub mod expr;
|
||||
pub mod ocr;
|
||||
pub mod notifications;
|
||||
pub mod nlp;
|
||||
pub mod sankey;
|
||||
pub mod import_pdf;
|
||||
pub mod seed;
|
||||
|
||||
@@ -78,6 +78,7 @@ pub struct Category {
|
||||
pub transaction_type: TransactionType,
|
||||
pub is_default: bool,
|
||||
pub sort_order: i32,
|
||||
pub parent_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -87,6 +88,7 @@ pub struct NewCategory {
|
||||
pub color: Option<String>,
|
||||
pub transaction_type: TransactionType,
|
||||
pub sort_order: i32,
|
||||
pub parent_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -101,6 +103,7 @@ pub struct Transaction {
|
||||
pub date: NaiveDate,
|
||||
pub created_at: String,
|
||||
pub recurring_id: Option<i64>,
|
||||
pub payee: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -113,6 +116,7 @@ pub struct NewTransaction {
|
||||
pub note: Option<String>,
|
||||
pub date: NaiveDate,
|
||||
pub recurring_id: Option<i64>,
|
||||
pub payee: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -121,6 +125,7 @@ pub struct Budget {
|
||||
pub category_id: i64,
|
||||
pub amount: f64,
|
||||
pub month: String,
|
||||
pub rollover: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -136,6 +141,10 @@ pub struct RecurringTransaction {
|
||||
pub end_date: Option<NaiveDate>,
|
||||
pub last_generated: Option<NaiveDate>,
|
||||
pub active: bool,
|
||||
pub resume_date: Option<NaiveDate>,
|
||||
pub is_bill: bool,
|
||||
pub reminder_days: i32,
|
||||
pub subscription_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -148,6 +157,9 @@ pub struct NewRecurringTransaction {
|
||||
pub frequency: Frequency,
|
||||
pub start_date: NaiveDate,
|
||||
pub end_date: Option<NaiveDate>,
|
||||
pub is_bill: bool,
|
||||
pub reminder_days: i32,
|
||||
pub subscription_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -158,6 +170,240 @@ pub struct ExchangeRate {
|
||||
pub fetched_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tag {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Split {
|
||||
pub id: i64,
|
||||
pub transaction_id: i64,
|
||||
pub category_id: i64,
|
||||
pub amount: f64,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransactionTemplate {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub amount: Option<f64>,
|
||||
pub transaction_type: TransactionType,
|
||||
pub category_id: i64,
|
||||
pub currency: String,
|
||||
pub payee: Option<String>,
|
||||
pub note: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CategorizeRule {
|
||||
pub id: i64,
|
||||
pub field: String,
|
||||
pub pattern: String,
|
||||
pub category_id: i64,
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavingsGoal {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub target: f64,
|
||||
pub saved: f64,
|
||||
pub currency: String,
|
||||
pub deadline: Option<NaiveDate>,
|
||||
pub color: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WishlistItem {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub amount: f64,
|
||||
pub category_id: Option<i64>,
|
||||
pub url: Option<String>,
|
||||
pub note: Option<String>,
|
||||
pub priority: i32,
|
||||
pub purchased: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SubscriptionCategory {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub icon: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Subscription {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub amount: f64,
|
||||
pub currency: String,
|
||||
pub frequency: Frequency,
|
||||
pub category_id: i64,
|
||||
pub start_date: NaiveDate,
|
||||
pub next_due: NaiveDate,
|
||||
pub active: bool,
|
||||
pub note: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub recurring_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewSubscription {
|
||||
pub name: String,
|
||||
pub amount: f64,
|
||||
pub currency: String,
|
||||
pub frequency: Frequency,
|
||||
pub category_id: i64,
|
||||
pub start_date: NaiveDate,
|
||||
pub note: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub recurring_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreditCard {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub credit_limit: Option<f64>,
|
||||
pub statement_close_day: i32,
|
||||
pub due_day: i32,
|
||||
pub min_payment_pct: f64,
|
||||
pub current_balance: f64,
|
||||
pub currency: String,
|
||||
pub color: Option<String>,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewCreditCard {
|
||||
pub name: String,
|
||||
pub credit_limit: Option<f64>,
|
||||
pub statement_close_day: i32,
|
||||
pub due_day: i32,
|
||||
pub min_payment_pct: f64,
|
||||
pub currency: String,
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Achievement {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub earned_at: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedTransaction {
|
||||
pub amount: f64,
|
||||
pub category_name: Option<String>,
|
||||
pub category_id: Option<i64>,
|
||||
pub note: Option<String>,
|
||||
pub payee: Option<String>,
|
||||
pub transaction_type: TransactionType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SankeyNode {
|
||||
pub label: String,
|
||||
pub value: f64,
|
||||
pub color: (f64, f64, f64),
|
||||
pub y: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SankeyFlow {
|
||||
pub from_idx: usize,
|
||||
pub to_idx: usize,
|
||||
pub value: f64,
|
||||
pub from_y: f64,
|
||||
pub to_y: f64,
|
||||
pub width: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SankeyLayout {
|
||||
pub left_nodes: Vec<SankeyNode>,
|
||||
pub right_nodes: Vec<SankeyNode>,
|
||||
pub center_y: f64,
|
||||
pub center_height: f64,
|
||||
pub flows_in: Vec<SankeyFlow>,
|
||||
pub flows_out: Vec<SankeyFlow>,
|
||||
pub net: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecapCategory {
|
||||
pub category_name: String,
|
||||
pub category_icon: Option<String>,
|
||||
pub category_color: Option<String>,
|
||||
pub amount: f64,
|
||||
pub percentage: f64,
|
||||
pub change_pct: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MonthlyRecap {
|
||||
pub total_income: f64,
|
||||
pub total_expenses: f64,
|
||||
pub net: f64,
|
||||
pub transaction_count: i64,
|
||||
pub categories: Vec<RecapCategory>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BudgetCycleMode {
|
||||
Calendar,
|
||||
Payday,
|
||||
Rolling,
|
||||
}
|
||||
|
||||
impl BudgetCycleMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
BudgetCycleMode::Calendar => "calendar",
|
||||
BudgetCycleMode::Payday => "payday",
|
||||
BudgetCycleMode::Rolling => "rolling",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"payday" => BudgetCycleMode::Payday,
|
||||
"rolling" => BudgetCycleMode::Rolling,
|
||||
_ => BudgetCycleMode::Calendar,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for BudgetCycleMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PdfParsedRow {
|
||||
pub date: Option<NaiveDate>,
|
||||
pub description: String,
|
||||
pub amount: f64,
|
||||
pub is_credit: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
248
outlay-core/src/nlp.rs
Normal file
248
outlay-core/src/nlp.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
use crate::models::{Category, ParsedTransaction, TransactionType};
|
||||
|
||||
/// Parse a free-form text string into a transaction.
|
||||
///
|
||||
/// Supported patterns:
|
||||
/// "Coffee 4.50" -> amount=4.50, category=fuzzy("Coffee")
|
||||
/// "4.50 groceries milk" -> amount=4.50, category=fuzzy("groceries"), note="milk"
|
||||
/// "Lunch 12.50 at Subway" -> amount=12.50, category=fuzzy("Lunch"), payee="Subway"
|
||||
/// "$25 gas" -> amount=25, category=fuzzy("gas")
|
||||
pub fn parse_transaction(input: &str, categories: &[Category]) -> Option<ParsedTransaction> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tokens: Vec<&str> = input.split_whitespace().collect();
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the amount token (first token that parses as a number, with optional $ prefix)
|
||||
let mut amount: Option<f64> = None;
|
||||
let mut amount_idx: Option<usize> = None;
|
||||
for (i, tok) in tokens.iter().enumerate() {
|
||||
let cleaned = tok.trim_start_matches('$').replace(',', "");
|
||||
if let Ok(val) = cleaned.parse::<f64>() {
|
||||
if val > 0.0 {
|
||||
amount = Some(val);
|
||||
amount_idx = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let amount = amount?;
|
||||
let amount_idx = amount_idx.unwrap();
|
||||
|
||||
// Collect non-amount tokens
|
||||
let word_tokens: Vec<&str> = tokens
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != amount_idx)
|
||||
.map(|(_, t)| *t)
|
||||
.collect();
|
||||
|
||||
// Find payee marker ("at", "from", "to")
|
||||
let mut payee: Option<String> = None;
|
||||
let mut pre_marker_words: Vec<&str> = Vec::new();
|
||||
let mut found_marker = false;
|
||||
let mut post_marker_words: Vec<&str> = Vec::new();
|
||||
|
||||
for word in &word_tokens {
|
||||
let lower = word.to_lowercase();
|
||||
if !found_marker && (lower == "at" || lower == "from" || lower == "to") {
|
||||
found_marker = true;
|
||||
continue;
|
||||
}
|
||||
if found_marker {
|
||||
post_marker_words.push(word);
|
||||
} else {
|
||||
pre_marker_words.push(word);
|
||||
}
|
||||
}
|
||||
|
||||
if !post_marker_words.is_empty() {
|
||||
payee = Some(post_marker_words.join(" "));
|
||||
}
|
||||
|
||||
// Try to match first pre-marker word(s) to a category
|
||||
let mut matched_category: Option<(String, i64)> = None;
|
||||
let mut note_words: Vec<&str> = Vec::new();
|
||||
|
||||
if !pre_marker_words.is_empty() {
|
||||
// Try matching progressively fewer words from the start
|
||||
for len in (1..=pre_marker_words.len()).rev() {
|
||||
let candidate = pre_marker_words[..len].join(" ");
|
||||
if let Some(cat) = fuzzy_match_category(&candidate, categories) {
|
||||
matched_category = Some((cat.name.clone(), cat.id));
|
||||
note_words = pre_marker_words[len..].to_vec();
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If no match, treat all words as note
|
||||
if matched_category.is_none() {
|
||||
note_words = pre_marker_words;
|
||||
}
|
||||
}
|
||||
|
||||
let note = if note_words.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(note_words.join(" "))
|
||||
};
|
||||
|
||||
Some(ParsedTransaction {
|
||||
amount,
|
||||
category_name: matched_category.as_ref().map(|(n, _)| n.clone()),
|
||||
category_id: matched_category.map(|(_, id)| id),
|
||||
note,
|
||||
payee,
|
||||
transaction_type: TransactionType::Expense,
|
||||
})
|
||||
}
|
||||
|
||||
fn fuzzy_match_category<'a>(query: &str, categories: &'a [Category]) -> Option<&'a Category> {
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
// Exact match
|
||||
if let Some(cat) = categories
|
||||
.iter()
|
||||
.find(|c| c.name.to_lowercase() == query_lower)
|
||||
{
|
||||
return Some(cat);
|
||||
}
|
||||
|
||||
// Prefix match
|
||||
if let Some(cat) = categories
|
||||
.iter()
|
||||
.find(|c| c.name.to_lowercase().starts_with(&query_lower))
|
||||
{
|
||||
return Some(cat);
|
||||
}
|
||||
|
||||
// Contains match
|
||||
if let Some(cat) = categories
|
||||
.iter()
|
||||
.find(|c| c.name.to_lowercase().contains(&query_lower))
|
||||
{
|
||||
return Some(cat);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_categories() -> Vec<Category> {
|
||||
vec![
|
||||
Category {
|
||||
id: 1,
|
||||
name: "Food and Dining".into(),
|
||||
icon: None,
|
||||
color: None,
|
||||
transaction_type: TransactionType::Expense,
|
||||
is_default: false,
|
||||
sort_order: 0,
|
||||
parent_id: None,
|
||||
},
|
||||
Category {
|
||||
id: 2,
|
||||
name: "Transport".into(),
|
||||
icon: None,
|
||||
color: None,
|
||||
transaction_type: TransactionType::Expense,
|
||||
is_default: false,
|
||||
sort_order: 0,
|
||||
parent_id: None,
|
||||
},
|
||||
Category {
|
||||
id: 3,
|
||||
name: "Groceries".into(),
|
||||
icon: None,
|
||||
color: None,
|
||||
transaction_type: TransactionType::Expense,
|
||||
is_default: false,
|
||||
sort_order: 0,
|
||||
parent_id: None,
|
||||
},
|
||||
Category {
|
||||
id: 4,
|
||||
name: "Gas".into(),
|
||||
icon: None,
|
||||
color: None,
|
||||
transaction_type: TransactionType::Expense,
|
||||
is_default: false,
|
||||
sort_order: 0,
|
||||
parent_id: None,
|
||||
},
|
||||
Category {
|
||||
id: 5,
|
||||
name: "Coffee".into(),
|
||||
icon: None,
|
||||
color: None,
|
||||
transaction_type: TransactionType::Expense,
|
||||
is_default: false,
|
||||
sort_order: 0,
|
||||
parent_id: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_category_amount() {
|
||||
let cats = test_categories();
|
||||
let result = parse_transaction("Coffee 4.50", &cats).unwrap();
|
||||
assert!((result.amount - 4.50).abs() < 0.001);
|
||||
assert_eq!(result.category_id, Some(5));
|
||||
assert!(result.payee.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amount_first() {
|
||||
let cats = test_categories();
|
||||
let result = parse_transaction("4.50 groceries milk", &cats).unwrap();
|
||||
assert!((result.amount - 4.50).abs() < 0.001);
|
||||
assert_eq!(result.category_id, Some(3));
|
||||
assert_eq!(result.note.as_deref(), Some("milk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_payee() {
|
||||
let cats = test_categories();
|
||||
let result = parse_transaction("Coffee 12.50 at Starbucks", &cats).unwrap();
|
||||
assert!((result.amount - 12.50).abs() < 0.001);
|
||||
assert_eq!(result.category_id, Some(5));
|
||||
assert_eq!(result.payee.as_deref(), Some("Starbucks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dollar_sign() {
|
||||
let cats = test_categories();
|
||||
let result = parse_transaction("$25 gas", &cats).unwrap();
|
||||
assert!((result.amount - 25.0).abs() < 0.001);
|
||||
assert_eq!(result.category_id, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_category_match() {
|
||||
let cats = test_categories();
|
||||
let result = parse_transaction("15.00 mystery", &cats).unwrap();
|
||||
assert!((result.amount - 15.0).abs() < 0.001);
|
||||
assert!(result.category_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_input() {
|
||||
let cats = test_categories();
|
||||
assert!(parse_transaction("", &cats).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_amount() {
|
||||
let cats = test_categories();
|
||||
assert!(parse_transaction("just some words", &cats).is_none());
|
||||
}
|
||||
}
|
||||
76
outlay-core/src/notifications.rs
Normal file
76
outlay-core/src/notifications.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use crate::db::Database;
|
||||
use std::process::Command;
|
||||
|
||||
/// Send a desktop notification via notify-send (Linux).
|
||||
/// Returns silently if notify-send is not available.
|
||||
pub fn send_notification(title: &str, body: &str, urgency: &str) {
|
||||
let _ = Command::new("notify-send")
|
||||
.arg("--urgency")
|
||||
.arg(urgency)
|
||||
.arg("--app-name=Outlay")
|
||||
.arg(title)
|
||||
.arg(body)
|
||||
.spawn();
|
||||
}
|
||||
|
||||
/// Check all budgets for the given month and send notifications
|
||||
/// for any thresholds crossed that haven't been notified yet.
|
||||
/// Only sends if budget_notifications setting is enabled.
|
||||
pub fn check_and_send_budget_notifications(db: &Database, month: &str) {
|
||||
let enabled = db.get_setting("budget_notifications")
|
||||
.ok().flatten().map(|s| s == "1").unwrap_or(false);
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let budgets = match db.list_budgets_for_month(month) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for budget in &budgets {
|
||||
let cat_name = db.get_category(budget.category_id)
|
||||
.map(|c| c.name)
|
||||
.unwrap_or_else(|_| "Unknown".to_string());
|
||||
|
||||
let thresholds = match db.check_budget_thresholds(budget.category_id, month) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for threshold in &thresholds {
|
||||
let (title, urgency) = match threshold {
|
||||
100 => (
|
||||
format!("Budget exceeded: {}", cat_name),
|
||||
"critical",
|
||||
),
|
||||
_ => (
|
||||
format!("Budget {}% used: {}", threshold, cat_name),
|
||||
"normal",
|
||||
),
|
||||
};
|
||||
|
||||
let progress = db.get_budget_progress(budget.category_id, month)
|
||||
.ok().flatten();
|
||||
let body = if let Some((budget_amt, spent, pct)) = progress {
|
||||
format!("{:.2} of {:.2} spent ({:.0}%)", spent, budget_amt, pct)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
send_notification(&title, &body, urgency);
|
||||
let _ = db.record_notification(budget.category_id, month, *threshold);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_send_notification_does_not_panic() {
|
||||
// Should not panic even if notify-send is not installed
|
||||
send_notification("Test", "Body", "normal");
|
||||
}
|
||||
}
|
||||
206
outlay-core/src/ocr.rs
Normal file
206
outlay-core/src/ocr.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// Extract all monetary amounts from a receipt image using tesseract OCR.
|
||||
/// Returns each amount paired with the line of text it was found on (trimmed).
|
||||
/// Results are sorted: lines containing "total" first, then by amount descending.
|
||||
/// Returns None if tesseract is unavailable or no amounts are found.
|
||||
pub fn extract_amounts_from_image(image_bytes: &[u8]) -> Option<Vec<(f64, String)>> {
|
||||
let tesseract = find_tesseract()?;
|
||||
|
||||
// Write image to a temp file
|
||||
let tmp_dir = std::env::temp_dir();
|
||||
let tmp_path = tmp_dir.join("outlay_ocr_tmp.png");
|
||||
let mut file = std::fs::File::create(&tmp_path).ok()?;
|
||||
file.write_all(image_bytes).ok()?;
|
||||
drop(file);
|
||||
|
||||
let mut cmd = Command::new(&tesseract);
|
||||
cmd.arg(&tmp_path).arg("stdout");
|
||||
|
||||
// If using bundled tesseract, point TESSDATA_PREFIX to bundled tessdata
|
||||
if let Some(parent) = tesseract.parent() {
|
||||
let tessdata = parent.join("tessdata");
|
||||
if tessdata.is_dir() {
|
||||
cmd.env("TESSDATA_PREFIX", parent);
|
||||
}
|
||||
}
|
||||
|
||||
let output = cmd.output().ok()?;
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let results = parse_all_amounts(&text);
|
||||
if results.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(results)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if tesseract is available (bundled or system).
|
||||
pub fn is_available() -> bool {
|
||||
find_tesseract().is_some()
|
||||
}
|
||||
|
||||
fn find_tesseract() -> Option<PathBuf> {
|
||||
// Check for bundled tesseract next to our binary (AppImage layout)
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(bin_dir) = exe.parent() {
|
||||
let bundled = bin_dir.join("tesseract");
|
||||
if bundled.is_file() {
|
||||
return Some(bundled);
|
||||
}
|
||||
// Also check ../lib/tesseract (AppImage usr/lib layout)
|
||||
let lib_bundled = bin_dir.join("../lib/tesseract").canonicalize().ok();
|
||||
if let Some(p) = lib_bundled {
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to system PATH
|
||||
Command::new("tesseract")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|_| PathBuf::from("tesseract"))
|
||||
}
|
||||
|
||||
fn parse_all_amounts(text: &str) -> Vec<(f64, String)> {
|
||||
let mut results: Vec<(f64, String, bool)> = Vec::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let line_amounts = extract_amounts_from_line(trimmed);
|
||||
let is_total = trimmed.to_lowercase().contains("total");
|
||||
for amt in line_amounts {
|
||||
// Deduplicate: skip if we already have this exact amount
|
||||
if !results.iter().any(|(a, _, _)| (*a - amt).abs() < 0.001) {
|
||||
results.push((amt, trimmed.to_string(), is_total));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: "total" lines first, then by amount descending
|
||||
results.sort_by(|a, b| {
|
||||
b.2.cmp(&a.2).then(b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal))
|
||||
});
|
||||
|
||||
results.into_iter().map(|(amt, line, _)| (amt, line)).collect()
|
||||
}
|
||||
|
||||
fn extract_amounts_from_line(line: &str) -> Vec<f64> {
|
||||
let mut results = Vec::new();
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
// Look for digit sequences followed by separator and exactly 2 digits
|
||||
if chars[i].is_ascii_digit() {
|
||||
let start = i;
|
||||
// Consume integer part
|
||||
while i < len && chars[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
// Check for decimal separator followed by exactly 2 digits
|
||||
if i < len && (chars[i] == '.' || chars[i] == ',') {
|
||||
let sep = i;
|
||||
i += 1;
|
||||
let decimal_start = i;
|
||||
while i < len && chars[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
if i - decimal_start == 2 {
|
||||
let int_part: String = chars[start..sep].iter().collect();
|
||||
let dec_part: String = chars[decimal_start..i].iter().collect();
|
||||
if let Ok(val) = format!("{}.{}", int_part, dec_part).parse::<f64>() {
|
||||
if val > 0.0 {
|
||||
results.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_all_returns_sorted() {
|
||||
let text = "Item 1 5.99\nItem 2 3.50\nTotal 9.49\n";
|
||||
let results = parse_all_amounts(text);
|
||||
// "Total" line should come first
|
||||
assert_eq!(results[0].0, 9.49);
|
||||
assert!(results[0].1.contains("Total"));
|
||||
assert_eq!(results.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_comma_separator() {
|
||||
let text = "Total: 12,99\n";
|
||||
let results = parse_all_amounts(text);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0, 12.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_total_sorts_by_amount() {
|
||||
let text = "Coffee 4.50\nSandwich 8.99\n";
|
||||
let results = parse_all_amounts(text);
|
||||
assert_eq!(results[0].0, 8.99);
|
||||
assert_eq!(results[1].0, 4.50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_amounts() {
|
||||
let text = "Hello world\nNo numbers here\n";
|
||||
let results = parse_all_amounts(text);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_total_case_insensitive() {
|
||||
let text = "Sub 5.00\nTOTAL 15.00\nChange 5.00\n";
|
||||
let results = parse_all_amounts(text);
|
||||
// TOTAL line first
|
||||
assert_eq!(results[0].0, 15.00);
|
||||
assert!(results[0].1.contains("TOTAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deduplicates_amounts() {
|
||||
let text = "Subtotal 10.00\nTotal 10.00\n";
|
||||
let results = parse_all_amounts(text);
|
||||
// Same amount on two lines - should deduplicate
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0, 10.00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_large_amount() {
|
||||
let text = "Grand Total 1250.00\n";
|
||||
let results = parse_all_amounts(text);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0, 1250.00);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,33 @@
|
||||
use crate::db::Database;
|
||||
use crate::exchange::ExchangeRateService;
|
||||
use crate::models::{Frequency, NewTransaction};
|
||||
use chrono::{Datelike, Days, NaiveDate};
|
||||
|
||||
/// Details about a generated recurring transaction.
|
||||
pub struct GeneratedInfo {
|
||||
pub description: String,
|
||||
pub amount: f64,
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
pub fn generate_missed_transactions(
|
||||
db: &Database,
|
||||
today: NaiveDate,
|
||||
base_currency: &str,
|
||||
) -> Result<usize, rusqlite::Error> {
|
||||
let (count, _details) = generate_missed_transactions_detailed(db, today, base_currency)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn generate_missed_transactions_detailed(
|
||||
db: &Database,
|
||||
today: NaiveDate,
|
||||
base_currency: &str,
|
||||
) -> Result<(usize, Vec<GeneratedInfo>), rusqlite::Error> {
|
||||
let recurring = db.list_recurring(true)?;
|
||||
let mut count = 0;
|
||||
let mut details = Vec::new();
|
||||
let rate_service = ExchangeRateService::new(db);
|
||||
|
||||
for rec in &recurring {
|
||||
let from = match rec.last_generated {
|
||||
@@ -22,19 +42,43 @@ pub fn generate_missed_transactions(
|
||||
|
||||
let dates = generate_dates(from, until, rec.frequency);
|
||||
|
||||
// Fetch exchange rate once per recurring (same currency for all dates)
|
||||
let exchange_rate = if rec.currency.eq_ignore_ascii_case(base_currency) {
|
||||
1.0
|
||||
} else {
|
||||
fetch_rate_sync(&rate_service, &rec.currency, base_currency).unwrap_or(1.0)
|
||||
};
|
||||
|
||||
let desc = rec
|
||||
.note
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
db.get_category(rec.category_id)
|
||||
.map(|c| c.name)
|
||||
.unwrap_or_else(|_| "Recurring".to_string())
|
||||
});
|
||||
|
||||
for date in &dates {
|
||||
let txn = NewTransaction {
|
||||
amount: rec.amount,
|
||||
transaction_type: rec.transaction_type,
|
||||
category_id: rec.category_id,
|
||||
currency: rec.currency.clone(),
|
||||
exchange_rate: 1.0,
|
||||
exchange_rate,
|
||||
note: rec.note.clone(),
|
||||
date: *date,
|
||||
recurring_id: Some(rec.id),
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn)?;
|
||||
count += 1;
|
||||
details.push(GeneratedInfo {
|
||||
description: desc.clone(),
|
||||
amount: rec.amount,
|
||||
currency: rec.currency.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(&last) = dates.last() {
|
||||
@@ -42,7 +86,15 @@ pub fn generate_missed_transactions(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
Ok((count, details))
|
||||
}
|
||||
|
||||
fn fetch_rate_sync(service: &ExchangeRateService<'_>, from: &str, to: &str) -> Option<f64> {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.ok()?;
|
||||
rt.block_on(service.get_rate(from, to)).ok()
|
||||
}
|
||||
|
||||
fn next_date(date: NaiveDate, freq: Frequency) -> NaiveDate {
|
||||
@@ -55,6 +107,32 @@ fn next_date(date: NaiveDate, freq: Frequency) -> NaiveDate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the next occurrence date for a recurring transaction from today.
|
||||
pub fn next_occurrence(rec: &crate::models::RecurringTransaction, from: NaiveDate) -> Option<NaiveDate> {
|
||||
if !rec.active {
|
||||
return None;
|
||||
}
|
||||
// Start from last_generated + 1 period, or start_date
|
||||
let mut date = match rec.last_generated {
|
||||
Some(last) => next_date(last, rec.frequency),
|
||||
None => rec.start_date,
|
||||
};
|
||||
|
||||
// Advance until we reach today or beyond
|
||||
while date < from {
|
||||
date = next_date(date, rec.frequency);
|
||||
}
|
||||
|
||||
// Check end_date
|
||||
if let Some(end) = rec.end_date {
|
||||
if date > end {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(date)
|
||||
}
|
||||
|
||||
fn generate_dates(from: NaiveDate, until: NaiveDate, freq: Frequency) -> Vec<NaiveDate> {
|
||||
let mut dates = Vec::new();
|
||||
let mut current = from;
|
||||
@@ -65,7 +143,7 @@ fn generate_dates(from: NaiveDate, until: NaiveDate, freq: Frequency) -> Vec<Nai
|
||||
dates
|
||||
}
|
||||
|
||||
fn add_months(date: NaiveDate, months: u32) -> NaiveDate {
|
||||
pub fn add_months(date: NaiveDate, months: u32) -> NaiveDate {
|
||||
let total_months = date.month0() + months;
|
||||
let new_year = date.year() + (total_months / 12) as i32;
|
||||
let new_month = (total_months % 12) + 1;
|
||||
@@ -113,13 +191,16 @@ mod tests {
|
||||
frequency: Frequency::Daily,
|
||||
start_date: NaiveDate::from_ymd_opt(2026, 2, 24).unwrap(),
|
||||
end_date: None,
|
||||
is_bill: false,
|
||||
reminder_days: 3,
|
||||
subscription_id: None,
|
||||
};
|
||||
let rec_id = db.insert_recurring(&rec).unwrap();
|
||||
|
||||
let today = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
|
||||
db.update_recurring_last_generated(rec_id, NaiveDate::from_ymd_opt(2026, 2, 26).unwrap()).unwrap();
|
||||
|
||||
let count = generate_missed_transactions(&db, today).unwrap();
|
||||
let count = generate_missed_transactions(&db, today, "USD").unwrap();
|
||||
// Should generate Feb 27, Feb 28, Mar 1 = 3 transactions
|
||||
assert_eq!(count, 3);
|
||||
}
|
||||
@@ -139,13 +220,16 @@ mod tests {
|
||||
frequency: Frequency::Monthly,
|
||||
start_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
|
||||
end_date: None,
|
||||
is_bill: false,
|
||||
reminder_days: 3,
|
||||
subscription_id: None,
|
||||
};
|
||||
let rec_id = db.insert_recurring(&rec).unwrap();
|
||||
|
||||
db.update_recurring_last_generated(rec_id, NaiveDate::from_ymd_opt(2026, 1, 15).unwrap()).unwrap();
|
||||
|
||||
let today = NaiveDate::from_ymd_opt(2026, 3, 20).unwrap();
|
||||
let count = generate_missed_transactions(&db, today).unwrap();
|
||||
let count = generate_missed_transactions(&db, today, "USD").unwrap();
|
||||
// Should generate Feb 15 and Mar 15
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
@@ -165,11 +249,14 @@ mod tests {
|
||||
frequency: Frequency::Daily,
|
||||
start_date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
|
||||
end_date: Some(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap()),
|
||||
is_bill: false,
|
||||
reminder_days: 3,
|
||||
subscription_id: None,
|
||||
};
|
||||
db.insert_recurring(&rec).unwrap();
|
||||
|
||||
let today = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
|
||||
let count = generate_missed_transactions(&db, today).unwrap();
|
||||
let count = generate_missed_transactions(&db, today, "USD").unwrap();
|
||||
// end_date is Jan 5, generates Jan 1-5 = 5 transactions
|
||||
assert_eq!(count, 5);
|
||||
}
|
||||
@@ -189,11 +276,14 @@ mod tests {
|
||||
frequency: Frequency::Weekly,
|
||||
start_date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
|
||||
end_date: None,
|
||||
is_bill: false,
|
||||
reminder_days: 3,
|
||||
subscription_id: None,
|
||||
};
|
||||
db.insert_recurring(&rec).unwrap();
|
||||
|
||||
let today = NaiveDate::from_ymd_opt(2026, 2, 22).unwrap();
|
||||
let count = generate_missed_transactions(&db, today).unwrap();
|
||||
let count = generate_missed_transactions(&db, today, "USD").unwrap();
|
||||
// From Feb 1 weekly: Feb 1, 8, 15, 22 = 4
|
||||
assert_eq!(count, 4);
|
||||
}
|
||||
|
||||
147
outlay-core/src/sankey.rs
Normal file
147
outlay-core/src/sankey.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use crate::models::{SankeyFlow, SankeyLayout, SankeyNode};
|
||||
|
||||
/// Compute a Sankey diagram layout.
|
||||
///
|
||||
/// `income_sources`: (label, amount, (r, g, b)) for each income category
|
||||
/// `expense_categories`: (label, amount, (r, g, b)) for each expense category
|
||||
/// `total_height`: pixel height of the diagram area
|
||||
pub fn compute_sankey_layout(
|
||||
income_sources: &[(String, f64, (f64, f64, f64))],
|
||||
expense_categories: &[(String, f64, (f64, f64, f64))],
|
||||
total_height: f64,
|
||||
) -> SankeyLayout {
|
||||
let total_income: f64 = income_sources.iter().map(|(_, v, _)| v).sum();
|
||||
let total_expense: f64 = expense_categories.iter().map(|(_, v, _)| v).sum();
|
||||
let net = total_income - total_expense;
|
||||
let max_side = total_income.max(total_expense).max(1.0);
|
||||
|
||||
let padding = 4.0;
|
||||
|
||||
// Layout left (income) nodes
|
||||
let mut left_nodes = Vec::new();
|
||||
let mut y = 0.0;
|
||||
let income_count = income_sources.len().max(1);
|
||||
let total_padding_left = padding * (income_count.saturating_sub(1)) as f64;
|
||||
let available_left = total_height - total_padding_left;
|
||||
for (label, value, color) in income_sources {
|
||||
let h = (value / max_side) * available_left;
|
||||
left_nodes.push(SankeyNode {
|
||||
label: label.clone(),
|
||||
value: *value,
|
||||
color: *color,
|
||||
y,
|
||||
height: h,
|
||||
});
|
||||
y += h + padding;
|
||||
}
|
||||
|
||||
// Layout right (expense) nodes
|
||||
let mut right_nodes = Vec::new();
|
||||
y = 0.0;
|
||||
let expense_count = expense_categories.len().max(1);
|
||||
let total_padding_right = padding * (expense_count.saturating_sub(1)) as f64;
|
||||
let available_right = total_height - total_padding_right;
|
||||
for (label, value, color) in expense_categories {
|
||||
let h = (value / max_side) * available_right;
|
||||
right_nodes.push(SankeyNode {
|
||||
label: label.clone(),
|
||||
value: *value,
|
||||
color: *color,
|
||||
y,
|
||||
height: h,
|
||||
});
|
||||
y += h + padding;
|
||||
}
|
||||
|
||||
// Center node (net/available)
|
||||
let center_height = (total_income / max_side) * available_left;
|
||||
let center_y = 0.0;
|
||||
|
||||
// Flows from income -> center
|
||||
let mut flows_in = Vec::new();
|
||||
let mut from_y_cursor = 0.0;
|
||||
let mut to_y_cursor = 0.0;
|
||||
for (i, node) in left_nodes.iter().enumerate() {
|
||||
let w = (node.value / max_side) * available_left;
|
||||
flows_in.push(SankeyFlow {
|
||||
from_idx: i,
|
||||
to_idx: 0,
|
||||
value: node.value,
|
||||
from_y: from_y_cursor,
|
||||
to_y: to_y_cursor,
|
||||
width: w,
|
||||
});
|
||||
from_y_cursor += w + padding;
|
||||
to_y_cursor += w;
|
||||
}
|
||||
|
||||
// Flows from center -> expenses
|
||||
let mut flows_out = Vec::new();
|
||||
let mut from_y_cursor = 0.0;
|
||||
let mut to_y_cursor = 0.0;
|
||||
for (i, node) in right_nodes.iter().enumerate() {
|
||||
let w = (node.value / max_side) * available_right;
|
||||
flows_out.push(SankeyFlow {
|
||||
from_idx: 0,
|
||||
to_idx: i,
|
||||
value: node.value,
|
||||
from_y: from_y_cursor,
|
||||
to_y: to_y_cursor,
|
||||
width: w,
|
||||
});
|
||||
from_y_cursor += w;
|
||||
to_y_cursor += w + padding;
|
||||
}
|
||||
|
||||
SankeyLayout {
|
||||
left_nodes,
|
||||
right_nodes,
|
||||
center_y,
|
||||
center_height,
|
||||
flows_in,
|
||||
flows_out,
|
||||
net,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_basic_layout() {
|
||||
let income = vec![("Salary".into(), 5000.0, (0.0, 0.8, 0.0))];
|
||||
let expenses = vec![
|
||||
("Rent".into(), 2000.0, (0.8, 0.0, 0.0)),
|
||||
("Food".into(), 1000.0, (0.8, 0.4, 0.0)),
|
||||
];
|
||||
let layout = compute_sankey_layout(&income, &expenses, 400.0);
|
||||
assert_eq!(layout.left_nodes.len(), 1);
|
||||
assert_eq!(layout.right_nodes.len(), 2);
|
||||
assert!((layout.net - 2000.0).abs() < 0.01);
|
||||
assert_eq!(layout.flows_in.len(), 1);
|
||||
assert_eq!(layout.flows_out.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_inputs() {
|
||||
let layout = compute_sankey_layout(&[], &[], 400.0);
|
||||
assert!(layout.left_nodes.is_empty());
|
||||
assert!(layout.right_nodes.is_empty());
|
||||
assert!((layout.net - 0.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_proportional_heights() {
|
||||
let income = vec![
|
||||
("Salary".into(), 3000.0, (0.0, 0.8, 0.0)),
|
||||
("Freelance".into(), 1000.0, (0.0, 0.6, 0.0)),
|
||||
];
|
||||
let expenses = vec![("Rent".into(), 2000.0, (0.8, 0.0, 0.0))];
|
||||
let layout = compute_sankey_layout(&income, &expenses, 400.0);
|
||||
// Salary should be 3x the height of Freelance
|
||||
let salary_h = layout.left_nodes[0].height;
|
||||
let freelance_h = layout.left_nodes[1].height;
|
||||
assert!((salary_h / freelance_h - 3.0).abs() < 0.1);
|
||||
}
|
||||
}
|
||||
539
outlay-core/src/seed.rs
Normal file
539
outlay-core/src/seed.rs
Normal file
@@ -0,0 +1,539 @@
|
||||
use chrono::{Datelike, Local, NaiveDate};
|
||||
use rand::Rng;
|
||||
use rusqlite::params;
|
||||
|
||||
use crate::db::Database;
|
||||
|
||||
/// Populate the database with realistic demo data spanning ~2 years.
|
||||
/// Assumes the database already has default categories seeded.
|
||||
pub fn seed_demo_data(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut rng = rand::thread_rng();
|
||||
let today = Local::now().date_naive();
|
||||
let start = NaiveDate::from_ymd_opt(today.year() - 2, today.month(), 1).unwrap();
|
||||
|
||||
// -- Settings --
|
||||
db.set_setting("base_currency", "USD")?;
|
||||
db.set_setting("theme", "system")?;
|
||||
|
||||
// -- Look up category IDs --
|
||||
let cats: Vec<(i64, String, String)> = db.conn.prepare(
|
||||
"SELECT id, name, type FROM categories ORDER BY id"
|
||||
)?.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})?.filter_map(|r| r.ok()).collect();
|
||||
|
||||
let cat_id = |name: &str| -> i64 {
|
||||
cats.iter().find(|(_, n, _)| n == name).map(|(id, _, _)| *id).unwrap_or(1)
|
||||
};
|
||||
|
||||
// Expense category IDs
|
||||
let food_id = cat_id("Food and Dining");
|
||||
let groceries_id = cat_id("Groceries");
|
||||
let transport_id = cat_id("Transport");
|
||||
let housing_id = cat_id("Housing/Rent");
|
||||
let utilities_id = cat_id("Utilities");
|
||||
let entertainment_id = cat_id("Entertainment");
|
||||
let shopping_id = cat_id("Shopping");
|
||||
let health_id = cat_id("Health");
|
||||
let education_id = cat_id("Education");
|
||||
let subscriptions_id = cat_id("Subscriptions");
|
||||
let personal_id = cat_id("Personal Care");
|
||||
let gifts_id = cat_id("Gifts");
|
||||
let travel_id = cat_id("Travel");
|
||||
|
||||
// Income category IDs
|
||||
let salary_id = cat_id("Salary");
|
||||
let freelance_id = cat_id("Freelance");
|
||||
let investment_id = cat_id("Investment");
|
||||
let gift_income_id = cat_id("Gift");
|
||||
let refund_id = cat_id("Refund");
|
||||
|
||||
// Realistic payees and notes per category
|
||||
let food_payees = ["Chipotle", "Starbucks", "Panda Express", "Subway", "Pizza Hut",
|
||||
"Local Diner", "Thai Kitchen", "Burger Joint", "Sushi Bar", "Taco Bell"];
|
||||
let grocery_payees = ["Whole Foods", "Trader Joe's", "Kroger", "Costco", "Aldi",
|
||||
"Safeway", "Target", "Walmart"];
|
||||
let transport_notes = ["Gas station", "Bus pass", "Uber ride", "Lyft", "Parking",
|
||||
"Car wash", "Oil change", "Tire rotation"];
|
||||
let entertainment_notes = ["Movie tickets", "Netflix", "Concert", "Board game",
|
||||
"Bowling", "Escape room", "Museum", "Book"];
|
||||
let shopping_payees = ["Amazon", "Target", "Best Buy", "IKEA", "Home Depot",
|
||||
"Etsy", "Thrift store"];
|
||||
let health_notes = ["Pharmacy", "Doctor copay", "Gym membership", "Vitamins",
|
||||
"Dentist", "Eye exam"];
|
||||
let personal_notes = ["Haircut", "Toiletries", "Dry cleaning", "Laundry"];
|
||||
|
||||
// Helper: random float in range
|
||||
let rand_amount = |rng: &mut rand::rngs::ThreadRng, low: f64, high: f64| -> f64 {
|
||||
let val = rng.gen_range(low..high);
|
||||
(val * 100.0).round() / 100.0
|
||||
};
|
||||
|
||||
let rand_pick = |rng: &mut rand::rngs::ThreadRng, items: &[&str]| -> String {
|
||||
items[rng.gen_range(0..items.len())].to_string()
|
||||
};
|
||||
|
||||
let insert_txn = |date: NaiveDate, amount: f64, txn_type: &str, cat: i64,
|
||||
note: Option<&str>, payee: Option<&str>| -> Result<(), Box<dyn std::error::Error>> {
|
||||
let date_str = date.format("%Y-%m-%d").to_string();
|
||||
let created = format!("{} 12:00:00", date_str);
|
||||
db.conn.execute(
|
||||
"INSERT INTO transactions (amount, type, category_id, currency, exchange_rate, note, date, created_at, payee)
|
||||
VALUES (?1, ?2, ?3, 'USD', 1.0, ?4, ?5, ?6, ?7)",
|
||||
params![amount, txn_type, cat, note, date_str, created, payee],
|
||||
)?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// -- Generate transactions month by month --
|
||||
let mut current = start;
|
||||
while current <= today {
|
||||
let year = current.year();
|
||||
let month = current.month();
|
||||
let days_in_month = if month == 12 {
|
||||
NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||
} else {
|
||||
NaiveDate::from_ymd_opt(year, month + 1, 1)
|
||||
}.and_then(|d| d.pred_opt()).map(|d| d.day()).unwrap_or(30);
|
||||
|
||||
let month_str = format!("{}-{:02}", year, month);
|
||||
|
||||
// Monthly income: salary on the 1st and 15th (biweekly)
|
||||
let base_salary = 2850.0 + (year - start.year()) as f64 * 150.0;
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 1) {
|
||||
if d <= today {
|
||||
insert_txn(d, base_salary, "income", salary_id, Some("Biweekly paycheck"), Some("Acme Corp"))?;
|
||||
}
|
||||
}
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 15) {
|
||||
if d <= today {
|
||||
insert_txn(d, base_salary, "income", salary_id, Some("Biweekly paycheck"), Some("Acme Corp"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Occasional freelance income (30% of months)
|
||||
if rng.gen_bool(0.3) {
|
||||
let day = rng.gen_range(5..=25).min(days_in_month);
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 200.0, 1200.0);
|
||||
insert_txn(d, amt, "income", freelance_id, Some("Web dev project"), Some("Freelance client"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Investment dividends quarterly (March, June, Sept, Dec)
|
||||
if matches!(month, 3 | 6 | 9 | 12) {
|
||||
let day = rng.gen_range(10..=20).min(days_in_month);
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 50.0, 180.0);
|
||||
insert_txn(d, amt, "income", investment_id, Some("Dividend payment"), Some("Vanguard"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Occasional refunds
|
||||
if rng.gen_bool(0.15) {
|
||||
let day = rng.gen_range(1..=days_in_month);
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 10.0, 80.0);
|
||||
insert_txn(d, amt, "income", refund_id, Some("Return item"), Some("Amazon"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Birthday/holiday gift income (December, month of user)
|
||||
if month == 12 {
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 25) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 50.0, 200.0);
|
||||
insert_txn(d, amt, "income", gift_income_id, Some("Holiday gift"), None)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- EXPENSES --
|
||||
|
||||
// Rent: 1st of every month
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 1) {
|
||||
if d <= today {
|
||||
insert_txn(d, 1350.00, "expense", housing_id, Some("Monthly rent"), Some("Pinewood Apartments"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Utilities: ~10th of month
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 10.min(days_in_month)) {
|
||||
if d <= today {
|
||||
let electric = rand_amount(&mut rng, 60.0, 140.0);
|
||||
insert_txn(d, electric, "expense", utilities_id, Some("Electric bill"), Some("City Power Co"))?;
|
||||
let internet = 65.00;
|
||||
insert_txn(d, internet, "expense", utilities_id, Some("Internet"), Some("Comcast"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Phone bill: 5th
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 5.min(days_in_month)) {
|
||||
if d <= today {
|
||||
insert_txn(d, 45.00, "expense", subscriptions_id, Some("Phone plan"), Some("Mint Mobile"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming subscriptions: 1st
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 1) {
|
||||
if d <= today {
|
||||
insert_txn(d, 15.99, "expense", subscriptions_id, Some("Streaming service"), Some("Netflix"))?;
|
||||
insert_txn(d, 10.99, "expense", subscriptions_id, Some("Music streaming"), Some("Spotify"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Groceries: 2-4 trips per month
|
||||
let grocery_trips = rng.gen_range(2..=4);
|
||||
for _ in 0..grocery_trips {
|
||||
let day = rng.gen_range(1..=days_in_month);
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 45.0, 160.0);
|
||||
let payee = rand_pick(&mut rng, &grocery_payees);
|
||||
insert_txn(d, amt, "expense", groceries_id, Some("Weekly groceries"), Some(&payee))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Food and dining: 4-8 meals out per month
|
||||
let meals_out = rng.gen_range(4..=8);
|
||||
for _ in 0..meals_out {
|
||||
let day = rng.gen_range(1..=days_in_month);
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 8.0, 55.0);
|
||||
let payee = rand_pick(&mut rng, &food_payees);
|
||||
insert_txn(d, amt, "expense", food_id, None, Some(&payee))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transport: 2-5 per month
|
||||
let transport_count = rng.gen_range(2..=5);
|
||||
for _ in 0..transport_count {
|
||||
let day = rng.gen_range(1..=days_in_month);
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 5.0, 65.0);
|
||||
let note = rand_pick(&mut rng, &transport_notes);
|
||||
insert_txn(d, amt, "expense", transport_id, Some(¬e), None)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entertainment: 1-3 per month
|
||||
let ent_count = rng.gen_range(1..=3);
|
||||
for _ in 0..ent_count {
|
||||
let day = rng.gen_range(1..=days_in_month);
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 10.0, 70.0);
|
||||
let note = rand_pick(&mut rng, &entertainment_notes);
|
||||
insert_txn(d, amt, "expense", entertainment_id, Some(¬e), None)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shopping: 1-3 per month
|
||||
let shop_count = rng.gen_range(1..=3);
|
||||
for _ in 0..shop_count {
|
||||
let day = rng.gen_range(1..=days_in_month);
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 15.0, 120.0);
|
||||
let payee = rand_pick(&mut rng, &shopping_payees);
|
||||
insert_txn(d, amt, "expense", shopping_id, None, Some(&payee))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Health: 0-2 per month
|
||||
let health_count = rng.gen_range(0..=2);
|
||||
for _ in 0..health_count {
|
||||
let day = rng.gen_range(1..=days_in_month);
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 15.0, 120.0);
|
||||
let note = rand_pick(&mut rng, &health_notes);
|
||||
insert_txn(d, amt, "expense", health_id, Some(¬e), None)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Personal care: 0-2 per month
|
||||
let personal_count = rng.gen_range(0..=2);
|
||||
for _ in 0..personal_count {
|
||||
let day = rng.gen_range(1..=days_in_month);
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 12.0, 60.0);
|
||||
let note = rand_pick(&mut rng, &personal_notes);
|
||||
insert_txn(d, amt, "expense", personal_id, Some(¬e), None)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Education: occasional (20% of months)
|
||||
if rng.gen_bool(0.2) {
|
||||
let day = rng.gen_range(1..=days_in_month);
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 15.0, 80.0);
|
||||
insert_txn(d, amt, "expense", education_id, Some("Online course"), Some("Udemy"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gifts: mainly November/December, occasionally otherwise
|
||||
let gift_chance = if matches!(month, 11 | 12) { 0.8 } else { 0.1 };
|
||||
if rng.gen_bool(gift_chance) {
|
||||
let day = rng.gen_range(1..=days_in_month);
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 20.0, 150.0);
|
||||
insert_txn(d, amt, "expense", gifts_id, Some("Birthday/holiday gift"), None)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Travel: 1-2 trips per year (spread across a few months)
|
||||
if rng.gen_bool(0.08) {
|
||||
for _ in 0..rng.gen_range(2..=4) {
|
||||
let day = rng.gen_range(1..=days_in_month);
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if d <= today {
|
||||
let amt = rand_amount(&mut rng, 50.0, 400.0);
|
||||
let notes = ["Hotel stay", "Flight", "Restaurant abroad", "Sightseeing"];
|
||||
let note = rand_pick(&mut rng, ¬es);
|
||||
insert_txn(d, amt, "expense", travel_id, Some(¬e), None)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Budgets for this month --
|
||||
let budget_items: Vec<(i64, f64)> = vec![
|
||||
(groceries_id, 500.0),
|
||||
(food_id, 350.0),
|
||||
(transport_id, 200.0),
|
||||
(entertainment_id, 150.0),
|
||||
(shopping_id, 200.0),
|
||||
(utilities_id, 250.0),
|
||||
(subscriptions_id, 80.0),
|
||||
(health_id, 100.0),
|
||||
(personal_id, 75.0),
|
||||
];
|
||||
for (cat, amt) in &budget_items {
|
||||
db.conn.execute(
|
||||
"INSERT OR IGNORE INTO budgets (category_id, amount, month) VALUES (?1, ?2, ?3)",
|
||||
params![cat, amt, month_str],
|
||||
)?;
|
||||
}
|
||||
|
||||
// Advance to next month
|
||||
current = if month == 12 {
|
||||
NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
|
||||
} else {
|
||||
NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
|
||||
};
|
||||
}
|
||||
|
||||
// -- Recurring transactions (plain, non-subscription) --
|
||||
let two_years_ago = format!("{}-{:02}-01", today.year() - 2, today.month());
|
||||
let recurring_items: Vec<(f64, &str, i64, &str, &str)> = vec![
|
||||
(1350.00, "expense", housing_id, "monthly", "Monthly rent"),
|
||||
(65.00, "expense", utilities_id, "monthly", "Internet"),
|
||||
];
|
||||
for (amount, txn_type, cat, freq, note) in &recurring_items {
|
||||
db.conn.execute(
|
||||
"INSERT INTO recurring_transactions (amount, type, category_id, currency, note, frequency, start_date, active)
|
||||
VALUES (?1, ?2, ?3, 'USD', ?4, ?5, ?6, 1)",
|
||||
params![amount, txn_type, cat, note, freq, two_years_ago],
|
||||
)?;
|
||||
}
|
||||
|
||||
// -- Linked subscriptions + recurring --
|
||||
use crate::models::{Frequency, NewRecurringTransaction, TransactionType};
|
||||
|
||||
let sub_services: Vec<(&str, f64, &str, &str)> = vec![
|
||||
("Netflix", 15.99, "tabler-brand-netflix", "#E50914"),
|
||||
("Spotify", 10.99, "tabler-brand-spotify", "#1DB954"),
|
||||
("iCloud", 2.99, "tabler-cloud", "#3693F3"),
|
||||
("GitHub", 4.00, "tabler-brand-github", "#333333"),
|
||||
("Xbox Game Pass", 16.99, "tabler-brand-xbox", "#107C10"),
|
||||
];
|
||||
|
||||
for (name, amount, _icon, _color) in &sub_services {
|
||||
// Find the subscription category by name
|
||||
let sub_cat_id: i64 = db.conn.query_row(
|
||||
"SELECT id FROM subscription_categories WHERE name = ?1",
|
||||
params![name],
|
||||
|row| row.get(0),
|
||||
).unwrap_or_else(|_| {
|
||||
// Fallback to "Other" category
|
||||
db.conn.query_row(
|
||||
"SELECT id FROM subscription_categories WHERE name = 'Other'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap_or(1)
|
||||
});
|
||||
|
||||
let start = chrono::NaiveDate::parse_from_str(&two_years_ago, "%Y-%m-%d")
|
||||
.unwrap_or(today);
|
||||
|
||||
let new_rec = NewRecurringTransaction {
|
||||
amount: *amount,
|
||||
transaction_type: TransactionType::Expense,
|
||||
category_id: subscriptions_id,
|
||||
currency: "USD".to_string(),
|
||||
note: Some(name.to_string()),
|
||||
frequency: Frequency::Monthly,
|
||||
start_date: start,
|
||||
end_date: None,
|
||||
is_bill: true,
|
||||
reminder_days: 3,
|
||||
subscription_id: None,
|
||||
};
|
||||
db.insert_linked_recurring_and_subscription(&new_rec, sub_cat_id, name)?;
|
||||
}
|
||||
|
||||
// -- Savings goals --
|
||||
db.conn.execute(
|
||||
"INSERT INTO savings_goals (name, target, saved, currency, deadline, color, icon)
|
||||
VALUES ('Emergency Fund', 10000.0, 6450.0, 'USD', ?1, '#27ae60', 'tabler-shield')",
|
||||
params![format!("{}-12-31", today.year())],
|
||||
)?;
|
||||
db.conn.execute(
|
||||
"INSERT INTO savings_goals (name, target, saved, currency, deadline, color, icon)
|
||||
VALUES ('Vacation Fund', 3000.0, 1820.0, 'USD', ?1, '#3498db', 'tabler-plane')",
|
||||
params![format!("{}-06-30", today.year() + 1)],
|
||||
)?;
|
||||
db.conn.execute(
|
||||
"INSERT INTO savings_goals (name, target, saved, currency, deadline, color, icon)
|
||||
VALUES ('New Laptop', 1500.0, 950.0, 'USD', ?1, '#9b59b6', 'tabler-device-laptop')",
|
||||
params![format!("{}-09-01", today.year())],
|
||||
)?;
|
||||
|
||||
// -- Wishlist items --
|
||||
db.conn.execute(
|
||||
"INSERT INTO wishlist_items (name, amount, category_id, note, priority)
|
||||
VALUES ('Noise Cancelling Headphones', 299.99, ?1, 'Sony WH-1000XM5', 1)",
|
||||
params![shopping_id],
|
||||
)?;
|
||||
db.conn.execute(
|
||||
"INSERT INTO wishlist_items (name, amount, category_id, note, priority)
|
||||
VALUES ('Ergonomic Keyboard', 179.00, ?1, 'Kinesis Advantage 360', 2)",
|
||||
params![shopping_id],
|
||||
)?;
|
||||
db.conn.execute(
|
||||
"INSERT INTO wishlist_items (name, amount, category_id, note, priority)
|
||||
VALUES ('Camping Gear Set', 450.00, ?1, 'Tent + sleeping bag + mat', 3)",
|
||||
params![travel_id],
|
||||
)?;
|
||||
|
||||
// -- Credit Cards --
|
||||
db.conn.execute(
|
||||
"INSERT INTO credit_cards (name, credit_limit, statement_close_day, due_day, min_payment_pct, current_balance, currency, color)
|
||||
VALUES ('Chase Sapphire', 8000.0, 25, 15, 2.0, 2340.0, 'USD', '#003087')",
|
||||
[],
|
||||
)?;
|
||||
db.conn.execute(
|
||||
"INSERT INTO credit_cards (name, credit_limit, statement_close_day, due_day, min_payment_pct, current_balance, currency, color)
|
||||
VALUES ('Amex Gold', 12000.0, 20, 10, 2.0, 890.0, 'USD', '#C4A000')",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// -- Purchased wishlist items --
|
||||
db.conn.execute(
|
||||
"INSERT INTO wishlist_items (name, amount, category_id, note, priority, purchased)
|
||||
VALUES ('Mechanical Keyboard', 149.99, ?1, 'Cherry MX Brown switches', 2, 1)",
|
||||
params![shopping_id],
|
||||
)?;
|
||||
db.conn.execute(
|
||||
"INSERT INTO wishlist_items (name, amount, category_id, note, priority, purchased)
|
||||
VALUES ('Running Shoes', 89.99, ?1, 'Nike Pegasus', 1, 1)",
|
||||
params![shopping_id],
|
||||
)?;
|
||||
|
||||
// -- Achievements --
|
||||
let two_years_ago_dt = format!(
|
||||
"{}-{:02}-15 12:00:00",
|
||||
today.year() - 2,
|
||||
today.month()
|
||||
);
|
||||
let one_year_ago_dt = format!(
|
||||
"{}-{:02}-15 12:00:00",
|
||||
today.year() - 1,
|
||||
today.month()
|
||||
);
|
||||
let six_months_ago_dt = {
|
||||
let m = if today.month() > 6 { today.month() - 6 } else { today.month() + 6 };
|
||||
let y = if today.month() > 6 { today.year() } else { today.year() - 1 };
|
||||
format!("{}-{:02}-15 12:00:00", y, m)
|
||||
};
|
||||
db.conn.execute(
|
||||
"UPDATE achievements SET earned_at = ?1 WHERE name = 'First Transaction'",
|
||||
params![two_years_ago_dt],
|
||||
)?;
|
||||
db.conn.execute(
|
||||
"UPDATE achievements SET earned_at = ?1 WHERE name = '100 Transactions'",
|
||||
params![one_year_ago_dt],
|
||||
)?;
|
||||
db.conn.execute(
|
||||
"UPDATE achievements SET earned_at = ?1 WHERE name = 'Month Under Budget'",
|
||||
params![six_months_ago_dt],
|
||||
)?;
|
||||
|
||||
// -- Transaction Templates --
|
||||
db.insert_template(
|
||||
"Morning Coffee",
|
||||
Some(5.50),
|
||||
TransactionType::Expense,
|
||||
food_id,
|
||||
"USD",
|
||||
Some("Starbucks"),
|
||||
Some("Daily coffee"),
|
||||
None,
|
||||
)?;
|
||||
db.insert_template(
|
||||
"Weekly Groceries",
|
||||
Some(85.00),
|
||||
TransactionType::Expense,
|
||||
groceries_id,
|
||||
"USD",
|
||||
Some("Trader Joe's"),
|
||||
Some("Weekly grocery run"),
|
||||
None,
|
||||
)?;
|
||||
|
||||
// -- Tags --
|
||||
db.conn.execute("INSERT OR IGNORE INTO tags (name) VALUES ('essential')", [])?;
|
||||
db.conn.execute("INSERT OR IGNORE INTO tags (name) VALUES ('splurge')", [])?;
|
||||
db.conn.execute("INSERT OR IGNORE INTO tags (name) VALUES ('recurring')", [])?;
|
||||
db.conn.execute("INSERT OR IGNORE INTO tags (name) VALUES ('work-related')", [])?;
|
||||
|
||||
// -- Categorization rules --
|
||||
db.conn.execute(
|
||||
"INSERT INTO categorization_rules (field, pattern, category_id, priority)
|
||||
VALUES ('payee', 'Starbucks', ?1, 1)",
|
||||
params![food_id],
|
||||
)?;
|
||||
db.conn.execute(
|
||||
"INSERT INTO categorization_rules (field, pattern, category_id, priority)
|
||||
VALUES ('payee', 'Whole Foods', ?1, 1)",
|
||||
params![groceries_id],
|
||||
)?;
|
||||
db.conn.execute(
|
||||
"INSERT INTO categorization_rules (field, pattern, category_id, priority)
|
||||
VALUES ('payee', 'Amazon', ?1, 1)",
|
||||
params![shopping_id],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user