- 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
457 lines
11 KiB
Rust
457 lines
11 KiB
Rust
use chrono::NaiveDate;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fmt;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum TransactionType {
|
|
Expense,
|
|
Income,
|
|
}
|
|
|
|
impl TransactionType {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
TransactionType::Expense => "expense",
|
|
TransactionType::Income => "income",
|
|
}
|
|
}
|
|
|
|
pub fn from_str(s: &str) -> Option<Self> {
|
|
match s {
|
|
"expense" => Some(TransactionType::Expense),
|
|
"income" => Some(TransactionType::Income),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for TransactionType {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(self.as_str())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Frequency {
|
|
Daily,
|
|
Weekly,
|
|
Biweekly,
|
|
Monthly,
|
|
Yearly,
|
|
}
|
|
|
|
impl Frequency {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Frequency::Daily => "daily",
|
|
Frequency::Weekly => "weekly",
|
|
Frequency::Biweekly => "biweekly",
|
|
Frequency::Monthly => "monthly",
|
|
Frequency::Yearly => "yearly",
|
|
}
|
|
}
|
|
|
|
pub fn from_str(s: &str) -> Option<Self> {
|
|
match s {
|
|
"daily" => Some(Frequency::Daily),
|
|
"weekly" => Some(Frequency::Weekly),
|
|
"biweekly" => Some(Frequency::Biweekly),
|
|
"monthly" => Some(Frequency::Monthly),
|
|
"yearly" => Some(Frequency::Yearly),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Frequency {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(self.as_str())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Category {
|
|
pub id: i64,
|
|
pub name: String,
|
|
pub icon: Option<String>,
|
|
pub color: Option<String>,
|
|
pub transaction_type: TransactionType,
|
|
pub is_default: bool,
|
|
pub sort_order: i32,
|
|
pub parent_id: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct NewCategory {
|
|
pub name: String,
|
|
pub icon: Option<String>,
|
|
pub color: Option<String>,
|
|
pub transaction_type: TransactionType,
|
|
pub sort_order: i32,
|
|
pub parent_id: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Transaction {
|
|
pub id: i64,
|
|
pub amount: f64,
|
|
pub transaction_type: TransactionType,
|
|
pub category_id: i64,
|
|
pub currency: String,
|
|
pub exchange_rate: f64,
|
|
pub note: Option<String>,
|
|
pub date: NaiveDate,
|
|
pub created_at: String,
|
|
pub recurring_id: Option<i64>,
|
|
pub payee: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct NewTransaction {
|
|
pub amount: f64,
|
|
pub transaction_type: TransactionType,
|
|
pub category_id: i64,
|
|
pub currency: String,
|
|
pub exchange_rate: f64,
|
|
pub note: Option<String>,
|
|
pub date: NaiveDate,
|
|
pub recurring_id: Option<i64>,
|
|
pub payee: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Budget {
|
|
pub id: i64,
|
|
pub category_id: i64,
|
|
pub amount: f64,
|
|
pub month: String,
|
|
pub rollover: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RecurringTransaction {
|
|
pub id: i64,
|
|
pub amount: f64,
|
|
pub transaction_type: TransactionType,
|
|
pub category_id: i64,
|
|
pub currency: String,
|
|
pub note: Option<String>,
|
|
pub frequency: Frequency,
|
|
pub start_date: NaiveDate,
|
|
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)]
|
|
pub struct NewRecurringTransaction {
|
|
pub amount: f64,
|
|
pub transaction_type: TransactionType,
|
|
pub category_id: i64,
|
|
pub currency: String,
|
|
pub note: Option<String>,
|
|
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)]
|
|
pub struct ExchangeRate {
|
|
pub base: String,
|
|
pub target: String,
|
|
pub rate: f64,
|
|
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::*;
|
|
|
|
#[test]
|
|
fn test_transaction_type_round_trip() {
|
|
for tt in [TransactionType::Expense, TransactionType::Income] {
|
|
let s = tt.as_str();
|
|
let parsed = TransactionType::from_str(s).unwrap();
|
|
assert_eq!(tt, parsed);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_transaction_type_invalid() {
|
|
assert_eq!(TransactionType::from_str("bogus"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_frequency_round_trip() {
|
|
for f in [
|
|
Frequency::Daily,
|
|
Frequency::Weekly,
|
|
Frequency::Biweekly,
|
|
Frequency::Monthly,
|
|
Frequency::Yearly,
|
|
] {
|
|
let s = f.as_str();
|
|
let parsed = Frequency::from_str(s).unwrap();
|
|
assert_eq!(f, parsed);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_frequency_invalid() {
|
|
assert_eq!(Frequency::from_str("quarterly"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_transaction_type_display() {
|
|
assert_eq!(format!("{}", TransactionType::Expense), "expense");
|
|
assert_eq!(format!("{}", TransactionType::Income), "income");
|
|
}
|
|
|
|
#[test]
|
|
fn test_frequency_display() {
|
|
assert_eq!(format!("{}", Frequency::Monthly), "monthly");
|
|
assert_eq!(format!("{}", Frequency::Biweekly), "biweekly");
|
|
}
|
|
}
|