Add feature batch 2, subscription/recurring sync, smooth charts, and app icon

- Implement subscriptions view with bidirectional recurring transaction sync
- Add cascade delete/pause/resume between subscriptions and recurring
- Fix foreign key constraints when deleting recurring transactions
- Add cross-view instant refresh via callback pattern
- Replace Bezier chart smoothing with Fritsch-Carlson monotone Hermite interpolation
- Smooth budget sparklines using shared monotone_subdivide function
- Add vertical spacing to budget rows
- Add app icon (receipt on GNOME blue) in all sizes for desktop, web, and AppImage
- Add calendar, credit cards, forecast, goals, insights, and wishlist views
- Add date picker, numpad, quick-add, category combo, and edit dialog components
- Add import/export for CSV, JSON, OFX, QIF formats
- Add NLP transaction parsing, OCR receipt scanning, expression evaluator
- Add notification support, Sankey chart, tray icon
- Add demo data seeder with full DB wipe
- Expand database schema with subscriptions, goals, credit cards, and more
This commit is contained in:
2026-03-03 21:18:37 +02:00
parent 773dae4684
commit 10a76e3003
10102 changed files with 108019 additions and 1335 deletions

View File

@@ -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();

View 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

View File

@@ -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();

View File

@@ -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();

View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// 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&amp;B Store"));
assert!(output.contains("Tom &amp; Jerry's &lt;shop&gt;"));
}
#[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"));
}
}

View File

@@ -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();

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

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

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

View 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("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
}
#[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&amp;B Store\n<MEMO>Tom &amp; Jerry&apos;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);
}
}

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

View File

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

View File

@@ -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;

View File

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

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

View File

@@ -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
View 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
View 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(&note), 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(&note), 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(&note), 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(&note), 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, &notes);
insert_txn(d, amt, "expense", travel_id, Some(&note), 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(())
}