envelope_cli/models/
account.rs

1//! Account model
2//!
3//! Represents financial accounts (checking, savings, credit cards, etc.)
4
5use chrono::{DateTime, NaiveDate, Utc};
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9use super::ids::AccountId;
10use super::money::Money;
11
12/// Type of financial account
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "lowercase")]
15pub enum AccountType {
16    /// Checking account
17    #[default]
18    Checking,
19    /// Savings account
20    Savings,
21    /// Credit card
22    Credit,
23    /// Cash/wallet
24    Cash,
25    /// Investment account
26    Investment,
27    /// Line of credit
28    LineOfCredit,
29    /// Other account type
30    Other,
31}
32
33impl AccountType {
34    /// Returns true if this account type typically has a negative balance as normal
35    /// (e.g., credit cards show debt as positive spending)
36    pub fn is_liability(&self) -> bool {
37        matches!(self, Self::Credit | Self::LineOfCredit)
38    }
39
40    /// Parse account type from string
41    pub fn parse(s: &str) -> Option<Self> {
42        match s.to_lowercase().as_str() {
43            "checking" => Some(Self::Checking),
44            "savings" => Some(Self::Savings),
45            "credit" | "credit_card" | "creditcard" => Some(Self::Credit),
46            "cash" => Some(Self::Cash),
47            "investment" => Some(Self::Investment),
48            "line_of_credit" | "lineofcredit" | "loc" => Some(Self::LineOfCredit),
49            "other" => Some(Self::Other),
50            _ => None,
51        }
52    }
53}
54
55impl fmt::Display for AccountType {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            Self::Checking => write!(f, "Checking"),
59            Self::Savings => write!(f, "Savings"),
60            Self::Credit => write!(f, "Credit Card"),
61            Self::Cash => write!(f, "Cash"),
62            Self::Investment => write!(f, "Investment"),
63            Self::LineOfCredit => write!(f, "Line of Credit"),
64            Self::Other => write!(f, "Other"),
65        }
66    }
67}
68
69/// A financial account
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct Account {
72    /// Unique identifier
73    pub id: AccountId,
74
75    /// Account name (e.g., "Chase Checking")
76    pub name: String,
77
78    /// Type of account
79    #[serde(rename = "type")]
80    pub account_type: AccountType,
81
82    /// Whether this account is included in the budget
83    /// Off-budget accounts (like investments) don't affect Available to Budget
84    pub on_budget: bool,
85
86    /// Whether this account is archived (soft-deleted)
87    pub archived: bool,
88
89    /// Opening balance when the account was created
90    pub starting_balance: Money,
91
92    /// Notes about this account
93    #[serde(default)]
94    pub notes: String,
95
96    /// Date of last reconciliation
97    pub last_reconciled_date: Option<NaiveDate>,
98
99    /// Balance at last reconciliation
100    pub last_reconciled_balance: Option<Money>,
101
102    /// When the account was created
103    pub created_at: DateTime<Utc>,
104
105    /// When the account was last modified
106    pub updated_at: DateTime<Utc>,
107
108    /// Sort order for display
109    #[serde(default)]
110    pub sort_order: i32,
111}
112
113impl Account {
114    /// Create a new account with default values
115    pub fn new(name: impl Into<String>, account_type: AccountType) -> Self {
116        let now = Utc::now();
117        Self {
118            id: AccountId::new(),
119            name: name.into(),
120            account_type,
121            on_budget: true,
122            archived: false,
123            starting_balance: Money::zero(),
124            notes: String::new(),
125            last_reconciled_date: None,
126            last_reconciled_balance: None,
127            created_at: now,
128            updated_at: now,
129            sort_order: 0,
130        }
131    }
132
133    /// Create a new account with a starting balance
134    pub fn with_starting_balance(
135        name: impl Into<String>,
136        account_type: AccountType,
137        starting_balance: Money,
138    ) -> Self {
139        let mut account = Self::new(name, account_type);
140        account.starting_balance = starting_balance;
141        account
142    }
143
144    /// Mark this account as archived
145    pub fn archive(&mut self) {
146        self.archived = true;
147        self.updated_at = Utc::now();
148    }
149
150    /// Unarchive this account
151    pub fn unarchive(&mut self) {
152        self.archived = false;
153        self.updated_at = Utc::now();
154    }
155
156    /// Set whether this account is on-budget
157    pub fn set_on_budget(&mut self, on_budget: bool) {
158        self.on_budget = on_budget;
159        self.updated_at = Utc::now();
160    }
161
162    /// Record a reconciliation
163    pub fn reconcile(&mut self, date: NaiveDate, balance: Money) {
164        self.last_reconciled_date = Some(date);
165        self.last_reconciled_balance = Some(balance);
166        self.updated_at = Utc::now();
167    }
168
169    /// Validate the account
170    pub fn validate(&self) -> Result<(), AccountValidationError> {
171        if self.name.trim().is_empty() {
172            return Err(AccountValidationError::EmptyName);
173        }
174
175        if self.name.len() > 100 {
176            return Err(AccountValidationError::NameTooLong(self.name.len()));
177        }
178
179        Ok(())
180    }
181}
182
183impl fmt::Display for Account {
184    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185        write!(f, "{} ({})", self.name, self.account_type)
186    }
187}
188
189/// Validation errors for accounts
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub enum AccountValidationError {
192    EmptyName,
193    NameTooLong(usize),
194}
195
196impl fmt::Display for AccountValidationError {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        match self {
199            Self::EmptyName => write!(f, "Account name cannot be empty"),
200            Self::NameTooLong(len) => {
201                write!(f, "Account name too long ({} chars, max 100)", len)
202            }
203        }
204    }
205}
206
207impl std::error::Error for AccountValidationError {}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_new_account() {
215        let account = Account::new("Checking", AccountType::Checking);
216        assert_eq!(account.name, "Checking");
217        assert_eq!(account.account_type, AccountType::Checking);
218        assert!(account.on_budget);
219        assert!(!account.archived);
220        assert_eq!(account.starting_balance, Money::zero());
221    }
222
223    #[test]
224    fn test_with_starting_balance() {
225        let account = Account::with_starting_balance(
226            "Savings",
227            AccountType::Savings,
228            Money::from_cents(100000),
229        );
230        assert_eq!(account.starting_balance.cents(), 100000);
231    }
232
233    #[test]
234    fn test_archive() {
235        let mut account = Account::new("Test", AccountType::Checking);
236        assert!(!account.archived);
237
238        account.archive();
239        assert!(account.archived);
240
241        account.unarchive();
242        assert!(!account.archived);
243    }
244
245    #[test]
246    fn test_validation() {
247        let mut account = Account::new("Valid Name", AccountType::Checking);
248        assert!(account.validate().is_ok());
249
250        account.name = String::new();
251        assert_eq!(account.validate(), Err(AccountValidationError::EmptyName));
252
253        account.name = "a".repeat(101);
254        assert!(matches!(
255            account.validate(),
256            Err(AccountValidationError::NameTooLong(_))
257        ));
258    }
259
260    #[test]
261    fn test_account_type_parsing() {
262        assert_eq!(AccountType::parse("checking"), Some(AccountType::Checking));
263        assert_eq!(AccountType::parse("SAVINGS"), Some(AccountType::Savings));
264        assert_eq!(AccountType::parse("credit_card"), Some(AccountType::Credit));
265        assert_eq!(AccountType::parse("invalid"), None);
266    }
267
268    #[test]
269    fn test_is_liability() {
270        assert!(AccountType::Credit.is_liability());
271        assert!(AccountType::LineOfCredit.is_liability());
272        assert!(!AccountType::Checking.is_liability());
273        assert!(!AccountType::Savings.is_liability());
274    }
275
276    #[test]
277    fn test_serialization() {
278        let account = Account::new("Test", AccountType::Checking);
279        let json = serde_json::to_string(&account).unwrap();
280        let deserialized: Account = serde_json::from_str(&json).unwrap();
281        assert_eq!(account.id, deserialized.id);
282        assert_eq!(account.name, deserialized.name);
283    }
284
285    #[test]
286    fn test_display() {
287        let account = Account::new("My Checking", AccountType::Checking);
288        assert_eq!(format!("{}", account), "My Checking (Checking)");
289    }
290}