envelope_cli/display/
transaction.rs

1//! Transaction display formatting
2//!
3//! Provides utilities for formatting transactions for terminal display,
4//! including register views and status indicators.
5
6use crate::models::{Transaction, TransactionStatus};
7
8/// Format a single transaction for display (register row)
9pub fn format_transaction_row(txn: &Transaction) -> String {
10    let status_icon = match txn.status {
11        TransactionStatus::Pending => " ",
12        TransactionStatus::Cleared => "✓",
13        TransactionStatus::Reconciled => "🔒",
14    };
15
16    let transfer_indicator = if txn.is_transfer() { "⇄ " } else { "" };
17    let split_indicator = if txn.is_split() {
18        format!(" [{}]", txn.splits.len())
19    } else {
20        String::new()
21    };
22
23    let payee_display = if txn.payee_name.is_empty() {
24        "(no payee)".to_string()
25    } else {
26        format!("{}{}", transfer_indicator, txn.payee_name)
27    };
28
29    format!(
30        "{} {} {:20} {:>12}{}",
31        status_icon,
32        txn.date.format("%Y-%m-%d"),
33        truncate(&payee_display, 20),
34        txn.amount,
35        split_indicator
36    )
37}
38
39/// Format a list of transactions as a register
40pub fn format_transaction_register(transactions: &[Transaction]) -> String {
41    if transactions.is_empty() {
42        return "No transactions found.\n".to_string();
43    }
44
45    let mut output = String::new();
46    output.push_str(&format!(
47        "{:3} {:10} {:20} {:>12}\n",
48        "St", "Date", "Payee", "Amount"
49    ));
50    output.push_str(&"-".repeat(50));
51    output.push('\n');
52
53    for txn in transactions {
54        output.push_str(&format_transaction_row(txn));
55        output.push('\n');
56    }
57
58    output
59}
60
61/// Format transaction details for display
62pub fn format_transaction_details(txn: &Transaction, category_name: Option<&str>) -> String {
63    let mut output = String::new();
64
65    output.push_str(&format!("Transaction: {}\n", txn.id));
66    output.push_str(&format!("Date:        {}\n", txn.date.format("%Y-%m-%d")));
67    output.push_str(&format!("Amount:      {}\n", txn.amount));
68
69    if !txn.payee_name.is_empty() {
70        output.push_str(&format!("Payee:       {}\n", txn.payee_name));
71    }
72
73    if let Some(cat_name) = category_name {
74        output.push_str(&format!("Category:    {}\n", cat_name));
75    } else if txn.is_split() {
76        output.push_str(&format!(
77            "Category:    Split ({} categories)\n",
78            txn.splits.len()
79        ));
80    } else {
81        output.push_str("Category:    (uncategorized)\n");
82    }
83
84    if !txn.memo.is_empty() {
85        output.push_str(&format!("Memo:        {}\n", txn.memo));
86    }
87
88    output.push_str(&format!("Status:      {}\n", txn.status));
89
90    if txn.is_transfer() {
91        output.push_str("Type:        Transfer\n");
92    }
93
94    if txn.is_split() {
95        output.push_str("\nSplits:\n");
96        for (i, split) in txn.splits.iter().enumerate() {
97            let memo_part = if split.memo.is_empty() {
98                String::new()
99            } else {
100                format!(" - {}", split.memo)
101            };
102            output.push_str(&format!(
103                "  {}. {} to {}{}\n",
104                i + 1,
105                split.amount,
106                split.category_id,
107                memo_part
108            ));
109        }
110    }
111
112    output
113}
114
115/// Format a transaction list with account grouping
116pub fn format_transaction_list_by_account(
117    transactions: &[Transaction],
118    account_name: &str,
119) -> String {
120    let mut output = String::new();
121
122    output.push_str(&format!("Account: {}\n", account_name));
123    output.push_str(&format!("Transactions: {}\n\n", transactions.len()));
124
125    output.push_str(&format!(
126        "{:3} {:10} {:20} {:>12} {:>12}\n",
127        "St", "Date", "Payee", "Outflow", "Inflow"
128    ));
129    output.push_str(&"-".repeat(62));
130    output.push('\n');
131
132    let mut running_balance = crate::models::Money::zero();
133
134    for txn in transactions {
135        let status_icon = match txn.status {
136            TransactionStatus::Pending => " ",
137            TransactionStatus::Cleared => "✓",
138            TransactionStatus::Reconciled => "🔒",
139        };
140
141        let payee_display = if txn.payee_name.is_empty() {
142            "(no payee)".to_string()
143        } else {
144            txn.payee_name.clone()
145        };
146
147        let (outflow, inflow) = if txn.amount.is_negative() {
148            (format!("{}", -txn.amount), String::new())
149        } else {
150            (String::new(), format!("{}", txn.amount))
151        };
152
153        running_balance += txn.amount;
154
155        output.push_str(&format!(
156            "{:3} {} {:20} {:>12} {:>12}\n",
157            status_icon,
158            txn.date.format("%Y-%m-%d"),
159            truncate(&payee_display, 20),
160            outflow,
161            inflow
162        ));
163    }
164
165    output.push_str(&"-".repeat(62));
166    output.push('\n');
167    output.push_str(&format!("{:>50} {:>12}\n", "Balance:", running_balance));
168
169    output
170}
171
172/// Format a short transaction summary (one line)
173pub fn format_transaction_short(txn: &Transaction) -> String {
174    let status_icon = match txn.status {
175        TransactionStatus::Pending => " ",
176        TransactionStatus::Cleared => "✓",
177        TransactionStatus::Reconciled => "🔒",
178    };
179
180    let payee_display = if txn.payee_name.is_empty() {
181        "(no payee)"
182    } else {
183        &txn.payee_name
184    };
185
186    format!(
187        "{} {} {} {}",
188        status_icon,
189        txn.date.format("%Y-%m-%d"),
190        truncate(payee_display, 20),
191        txn.amount
192    )
193}
194
195/// Truncate a string to a maximum length
196fn truncate(s: &str, max_len: usize) -> String {
197    if s.len() <= max_len {
198        format!("{:width$}", s, width = max_len)
199    } else {
200        format!("{}...", &s[..max_len - 3])
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::models::{AccountId, Money};
208    use chrono::NaiveDate;
209
210    #[test]
211    fn test_format_transaction_row() {
212        let txn = Transaction::with_details(
213            AccountId::new(),
214            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
215            Money::from_cents(-5000),
216            "Test Store",
217            None,
218            "",
219        );
220
221        let formatted = format_transaction_row(&txn);
222        assert!(formatted.contains("2025-01-15"));
223        assert!(formatted.contains("Test Store"));
224        assert!(formatted.contains("-$50.00"));
225    }
226
227    #[test]
228    fn test_format_empty_register() {
229        let formatted = format_transaction_register(&[]);
230        assert!(formatted.contains("No transactions found"));
231    }
232
233    #[test]
234    fn test_format_transaction_details() {
235        let txn = Transaction::with_details(
236            AccountId::new(),
237            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
238            Money::from_cents(-5000),
239            "Test Store",
240            None,
241            "Test memo",
242        );
243
244        let formatted = format_transaction_details(&txn, Some("Groceries"));
245        assert!(formatted.contains("Test Store"));
246        assert!(formatted.contains("Groceries"));
247        assert!(formatted.contains("Test memo"));
248    }
249
250    #[test]
251    fn test_truncate() {
252        assert_eq!(truncate("Short", 10).trim(), "Short");
253        // Note: truncate pads short strings, so we test the truncation behavior
254        let result = truncate("A very long string", 10);
255        assert!(result.len() <= 10);
256        assert!(result.ends_with("..."));
257    }
258}