1use crate::models::{Transaction, TransactionStatus};
7
8pub 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
39pub 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
61pub 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
115pub 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
172pub 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
195fn 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 let result = truncate("A very long string", 10);
255 assert!(result.len() <= 10);
256 assert!(result.ends_with("..."));
257 }
258}