1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "lowercase")]
16pub enum TransactionStatus {
17 #[default]
19 Pending,
20 Cleared,
22 Reconciled,
24}
25
26impl TransactionStatus {
27 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#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Split {
46 pub category_id: CategoryId,
48
49 pub amount: Money,
51
52 #[serde(default)]
54 pub memo: String,
55}
56
57impl Split {
58 pub fn new(category_id: CategoryId, amount: Money) -> Self {
60 Self {
61 category_id,
62 amount,
63 memo: String::new(),
64 }
65 }
66
67 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#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct Transaction {
80 pub id: TransactionId,
82
83 pub account_id: AccountId,
85
86 pub date: NaiveDate,
88
89 pub amount: Money,
91
92 pub payee_id: Option<PayeeId>,
94
95 #[serde(default)]
97 pub payee_name: String,
98
99 pub category_id: Option<CategoryId>,
101
102 #[serde(default)]
104 pub splits: Vec<Split>,
105
106 #[serde(default)]
108 pub memo: String,
109
110 #[serde(default)]
112 pub status: TransactionStatus,
113
114 pub transfer_transaction_id: Option<TransactionId>,
116
117 pub import_id: Option<String>,
119
120 pub created_at: DateTime<Utc>,
122
123 pub updated_at: DateTime<Utc>,
125}
126
127impl Transaction {
128 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 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 pub fn is_split(&self) -> bool {
167 !self.splits.is_empty()
168 }
169
170 pub fn is_transfer(&self) -> bool {
172 self.transfer_transaction_id.is_some()
173 }
174
175 pub fn is_inflow(&self) -> bool {
177 self.amount.is_positive()
178 }
179
180 pub fn is_outflow(&self) -> bool {
182 self.amount.is_negative()
183 }
184
185 pub fn is_locked(&self) -> bool {
187 self.status.is_locked()
188 }
189
190 pub fn set_status(&mut self, status: TransactionStatus) {
192 self.status = status;
193 self.updated_at = Utc::now();
194 }
195
196 pub fn clear(&mut self) {
198 self.set_status(TransactionStatus::Cleared);
199 }
200
201 pub fn reconcile(&mut self) {
203 self.set_status(TransactionStatus::Reconciled);
204 }
205
206 pub fn add_split(&mut self, split: Split) {
208 self.splits.push(split);
209 self.category_id = None;
211 self.updated_at = Utc::now();
212 }
213
214 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 pub fn splits_total(&self) -> Money {
223 self.splits.iter().map(|s| s.amount).sum()
224 }
225
226 pub fn validate(&self) -> Result<(), TransactionValidationError> {
228 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 if self.category_id.is_some() && !self.splits.is_empty() {
241 return Err(TransactionValidationError::CategoryAndSplits);
242 }
243
244 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 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#[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 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}