envelope_cli/models/
transaction.rs

1//! Transaction model
2//!
3//! Represents financial transactions with support for splits, transfers,
4//! and various statuses (pending, cleared, reconciled).
5
6use chrono::{DateTime, NaiveDate, Utc};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10use super::ids::{AccountId, CategoryId, PayeeId, TransactionId};
11use super::money::Money;
12
13/// Status of a transaction
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "lowercase")]
16pub enum TransactionStatus {
17    /// Transaction has not yet cleared the bank
18    #[default]
19    Pending,
20    /// Transaction has cleared the bank
21    Cleared,
22    /// Transaction has been reconciled and is locked
23    Reconciled,
24}
25
26impl TransactionStatus {
27    /// Check if this transaction is locked (cannot be edited without unlocking)
28    pub fn is_locked(&self) -> bool {
29        matches!(self, Self::Reconciled)
30    }
31}
32
33impl fmt::Display for TransactionStatus {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            Self::Pending => write!(f, "Pending"),
37            Self::Cleared => write!(f, "Cleared"),
38            Self::Reconciled => write!(f, "Reconciled"),
39        }
40    }
41}
42
43/// A split portion of a transaction assigned to a specific category
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Split {
46    /// The category for this split portion
47    pub category_id: CategoryId,
48
49    /// The amount for this split (same sign as parent transaction)
50    pub amount: Money,
51
52    /// Optional memo for this split
53    #[serde(default)]
54    pub memo: String,
55}
56
57impl Split {
58    /// Create a new split
59    pub fn new(category_id: CategoryId, amount: Money) -> Self {
60        Self {
61            category_id,
62            amount,
63            memo: String::new(),
64        }
65    }
66
67    /// Create a new split with a memo
68    pub fn with_memo(category_id: CategoryId, amount: Money, memo: impl Into<String>) -> Self {
69        Self {
70            category_id,
71            amount,
72            memo: memo.into(),
73        }
74    }
75}
76
77/// A financial transaction
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct Transaction {
80    /// Unique identifier
81    pub id: TransactionId,
82
83    /// The account this transaction belongs to
84    pub account_id: AccountId,
85
86    /// Transaction date
87    pub date: NaiveDate,
88
89    /// Amount (positive for inflow, negative for outflow)
90    pub amount: Money,
91
92    /// Payee ID (optional)
93    pub payee_id: Option<PayeeId>,
94
95    /// Payee name (stored for display, even if payee_id is set)
96    #[serde(default)]
97    pub payee_name: String,
98
99    /// Category ID (None if this is a split transaction or transfer)
100    pub category_id: Option<CategoryId>,
101
102    /// Split transactions - if non-empty, category_id should be None
103    #[serde(default)]
104    pub splits: Vec<Split>,
105
106    /// Memo/notes
107    #[serde(default)]
108    pub memo: String,
109
110    /// Transaction status
111    #[serde(default)]
112    pub status: TransactionStatus,
113
114    /// If this is a transfer, the ID of the linked transaction in the other account
115    pub transfer_transaction_id: Option<TransactionId>,
116
117    /// Import ID for duplicate detection during CSV import
118    pub import_id: Option<String>,
119
120    /// When the transaction was created
121    pub created_at: DateTime<Utc>,
122
123    /// When the transaction was last modified
124    pub updated_at: DateTime<Utc>,
125}
126
127impl Transaction {
128    /// Create a new transaction
129    pub fn new(account_id: AccountId, date: NaiveDate, amount: Money) -> Self {
130        let now = Utc::now();
131        Self {
132            id: TransactionId::new(),
133            account_id,
134            date,
135            amount,
136            payee_id: None,
137            payee_name: String::new(),
138            category_id: None,
139            splits: Vec::new(),
140            memo: String::new(),
141            status: TransactionStatus::Pending,
142            transfer_transaction_id: None,
143            import_id: None,
144            created_at: now,
145            updated_at: now,
146        }
147    }
148
149    /// Create a transaction with all common fields
150    pub fn with_details(
151        account_id: AccountId,
152        date: NaiveDate,
153        amount: Money,
154        payee_name: impl Into<String>,
155        category_id: Option<CategoryId>,
156        memo: impl Into<String>,
157    ) -> Self {
158        let mut txn = Self::new(account_id, date, amount);
159        txn.payee_name = payee_name.into();
160        txn.category_id = category_id;
161        txn.memo = memo.into();
162        txn
163    }
164
165    /// Check if this is a split transaction
166    pub fn is_split(&self) -> bool {
167        !self.splits.is_empty()
168    }
169
170    /// Check if this is a transfer
171    pub fn is_transfer(&self) -> bool {
172        self.transfer_transaction_id.is_some()
173    }
174
175    /// Check if this is an inflow (positive amount)
176    pub fn is_inflow(&self) -> bool {
177        self.amount.is_positive()
178    }
179
180    /// Check if this is an outflow (negative amount)
181    pub fn is_outflow(&self) -> bool {
182        self.amount.is_negative()
183    }
184
185    /// Check if this transaction is locked
186    pub fn is_locked(&self) -> bool {
187        self.status.is_locked()
188    }
189
190    /// Set the status
191    pub fn set_status(&mut self, status: TransactionStatus) {
192        self.status = status;
193        self.updated_at = Utc::now();
194    }
195
196    /// Clear the transaction (mark as cleared)
197    pub fn clear(&mut self) {
198        self.set_status(TransactionStatus::Cleared);
199    }
200
201    /// Mark as reconciled
202    pub fn reconcile(&mut self) {
203        self.set_status(TransactionStatus::Reconciled);
204    }
205
206    /// Add a split
207    pub fn add_split(&mut self, split: Split) {
208        self.splits.push(split);
209        // When splits are added, category_id should be cleared
210        self.category_id = None;
211        self.updated_at = Utc::now();
212    }
213
214    /// Clear all splits and set a single category
215    pub fn set_category(&mut self, category_id: CategoryId) {
216        self.splits.clear();
217        self.category_id = Some(category_id);
218        self.updated_at = Utc::now();
219    }
220
221    /// Get the total of all splits (should equal transaction amount)
222    pub fn splits_total(&self) -> Money {
223        self.splits.iter().map(|s| s.amount).sum()
224    }
225
226    /// Validate the transaction
227    pub fn validate(&self) -> Result<(), TransactionValidationError> {
228        // If split, splits total must equal transaction amount
229        if self.is_split() {
230            let splits_total = self.splits_total();
231            if splits_total != self.amount {
232                return Err(TransactionValidationError::SplitsMismatch {
233                    transaction_amount: self.amount,
234                    splits_total,
235                });
236            }
237        }
238
239        // Can't have both category_id and splits
240        if self.category_id.is_some() && !self.splits.is_empty() {
241            return Err(TransactionValidationError::CategoryAndSplits);
242        }
243
244        // Transfers shouldn't have categories
245        if self.is_transfer() && (self.category_id.is_some() || !self.splits.is_empty()) {
246            return Err(TransactionValidationError::TransferWithCategory);
247        }
248
249        Ok(())
250    }
251
252    /// Generate an import ID for duplicate detection
253    pub fn generate_import_id(&self) -> String {
254        use std::hash::{Hash, Hasher};
255        let mut hasher = std::collections::hash_map::DefaultHasher::new();
256        self.date.hash(&mut hasher);
257        self.amount.cents().hash(&mut hasher);
258        self.payee_name.hash(&mut hasher);
259        format!("imp-{:016x}", hasher.finish())
260    }
261}
262
263impl fmt::Display for Transaction {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        write!(
266            f,
267            "{} {} {}",
268            self.date.format("%Y-%m-%d"),
269            self.payee_name,
270            self.amount
271        )
272    }
273}
274
275/// Validation errors for transactions
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub enum TransactionValidationError {
278    SplitsMismatch {
279        transaction_amount: Money,
280        splits_total: Money,
281    },
282    CategoryAndSplits,
283    TransferWithCategory,
284}
285
286impl fmt::Display for TransactionValidationError {
287    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288        match self {
289            Self::SplitsMismatch {
290                transaction_amount,
291                splits_total,
292            } => write!(
293                f,
294                "Split totals ({}) do not match transaction amount ({})",
295                splits_total, transaction_amount
296            ),
297            Self::CategoryAndSplits => {
298                write!(f, "Transaction cannot have both a category and splits")
299            }
300            Self::TransferWithCategory => {
301                write!(f, "Transfer transactions should not have a category")
302            }
303        }
304    }
305}
306
307impl std::error::Error for TransactionValidationError {}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn test_account_id() -> AccountId {
314        AccountId::new()
315    }
316
317    fn test_category_id() -> CategoryId {
318        CategoryId::new()
319    }
320
321    #[test]
322    fn test_new_transaction() {
323        let account_id = test_account_id();
324        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
325        let amount = Money::from_cents(-5000);
326
327        let txn = Transaction::new(account_id, date, amount);
328        assert_eq!(txn.account_id, account_id);
329        assert_eq!(txn.date, date);
330        assert_eq!(txn.amount, amount);
331        assert_eq!(txn.status, TransactionStatus::Pending);
332    }
333
334    #[test]
335    fn test_inflow_outflow() {
336        let account_id = test_account_id();
337        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
338
339        let inflow = Transaction::new(account_id, date, Money::from_cents(1000));
340        assert!(inflow.is_inflow());
341        assert!(!inflow.is_outflow());
342
343        let outflow = Transaction::new(account_id, date, Money::from_cents(-1000));
344        assert!(!outflow.is_inflow());
345        assert!(outflow.is_outflow());
346    }
347
348    #[test]
349    fn test_status_transitions() {
350        let account_id = test_account_id();
351        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
352        let mut txn = Transaction::new(account_id, date, Money::from_cents(-1000));
353
354        assert!(!txn.is_locked());
355
356        txn.clear();
357        assert_eq!(txn.status, TransactionStatus::Cleared);
358        assert!(!txn.is_locked());
359
360        txn.reconcile();
361        assert_eq!(txn.status, TransactionStatus::Reconciled);
362        assert!(txn.is_locked());
363    }
364
365    #[test]
366    fn test_split_transaction() {
367        let account_id = test_account_id();
368        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
369        let mut txn = Transaction::new(account_id, date, Money::from_cents(-10000));
370
371        let cat1 = test_category_id();
372        let cat2 = test_category_id();
373
374        txn.add_split(Split::new(cat1, Money::from_cents(-6000)));
375        txn.add_split(Split::new(cat2, Money::from_cents(-4000)));
376
377        assert!(txn.is_split());
378        assert_eq!(txn.splits_total(), Money::from_cents(-10000));
379        assert!(txn.validate().is_ok());
380    }
381
382    #[test]
383    fn test_split_validation_mismatch() {
384        let account_id = test_account_id();
385        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
386        let mut txn = Transaction::new(account_id, date, Money::from_cents(-10000));
387
388        let cat1 = test_category_id();
389        txn.add_split(Split::new(cat1, Money::from_cents(-5000)));
390
391        assert!(matches!(
392            txn.validate(),
393            Err(TransactionValidationError::SplitsMismatch { .. })
394        ));
395    }
396
397    #[test]
398    fn test_category_and_splits_validation() {
399        let account_id = test_account_id();
400        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
401        let mut txn = Transaction::new(account_id, date, Money::from_cents(-10000));
402
403        let cat1 = test_category_id();
404        let cat2 = test_category_id();
405
406        txn.category_id = Some(cat1);
407        txn.splits.push(Split::new(cat2, Money::from_cents(-10000)));
408
409        assert_eq!(
410            txn.validate(),
411            Err(TransactionValidationError::CategoryAndSplits)
412        );
413    }
414
415    #[test]
416    fn test_import_id_generation() {
417        let account_id = test_account_id();
418        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
419        let mut txn = Transaction::new(account_id, date, Money::from_cents(-5000));
420        txn.payee_name = "Test Store".to_string();
421
422        let import_id = txn.generate_import_id();
423        assert!(import_id.starts_with("imp-"));
424
425        // Same transaction should generate same import ID
426        let import_id2 = txn.generate_import_id();
427        assert_eq!(import_id, import_id2);
428    }
429
430    #[test]
431    fn test_serialization() {
432        let account_id = test_account_id();
433        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
434        let txn = Transaction::with_details(
435            account_id,
436            date,
437            Money::from_cents(-5000),
438            "Test Store",
439            Some(test_category_id()),
440            "Test memo",
441        );
442
443        let json = serde_json::to_string(&txn).unwrap();
444        let deserialized: Transaction = serde_json::from_str(&json).unwrap();
445        assert_eq!(txn.id, deserialized.id);
446        assert_eq!(txn.amount, deserialized.amount);
447        assert_eq!(txn.payee_name, deserialized.payee_name);
448    }
449
450    #[test]
451    fn test_display() {
452        let account_id = test_account_id();
453        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
454        let mut txn = Transaction::new(account_id, date, Money::from_cents(-5000));
455        txn.payee_name = "Test Store".to_string();
456
457        assert_eq!(format!("{}", txn), "2025-01-15 Test Store -$50.00");
458    }
459}