1use crate::error::Res;
2use crate::model::items::{Item, Items};
3use crate::model::{Amount, Date, DateCanBeEmptyStr, DateFromOptStr};
4use crate::utils;
5use anyhow::bail;
6use clap::Parser;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::str::FromStr;
11
12pub type Transactions = Items<Transaction>;
16
17#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
21#[serde(rename_all = "snake_case")]
22pub struct Transaction {
23 pub(crate) transaction_id: String,
26
27 pub(crate) date: Date,
30
31 pub(crate) description: String,
33
34 pub(crate) amount: Amount,
36
37 pub(crate) account: String,
40
41 pub(crate) account_number: String,
43
44 pub(crate) institution: String,
46
47 pub(crate) month: Option<Date>,
49
50 pub(crate) week: Option<Date>,
52
53 pub(crate) full_description: String,
55
56 pub(crate) account_id: String,
59
60 pub(crate) check_number: String,
62
63 pub(crate) date_added: Option<Date>,
65
66 pub(crate) merchant_name: String,
69
70 pub(crate) category_hint: String,
73
74 pub(crate) category: String,
77
78 pub(crate) note: String,
80
81 pub(crate) tags: String,
83
84 pub(crate) categorized_date: Option<Date>,
87
88 pub(crate) statement: String,
90
91 pub(crate) metadata: String,
93
94 pub(crate) no_name: String,
96
97 pub(crate) other_fields: BTreeMap<String, String>,
99
100 #[serde(skip_serializing_if = "Option::is_none")]
103 pub(crate) original_order: Option<u64>,
104}
105
106impl Item for Transaction {
107 fn set_with_header<S1, S2>(&mut self, header: S1, value: S2) -> Res<()>
108 where
109 S1: AsRef<str>,
110 S2: Into<String>,
111 {
112 let header = header.as_ref();
113 let value = value.into();
114
115 match TransactionColumn::from_header(header) {
116 Ok(col) => match col {
117 TransactionColumn::TransactionId => self.transaction_id = value,
118 TransactionColumn::Date => self.date = Date::parse(value)?,
119 TransactionColumn::Description => self.description = value,
120 TransactionColumn::Amount => self.amount = Amount::from_str(&value)?,
121 TransactionColumn::Account => self.account = value,
122 TransactionColumn::AccountNumber => self.account_number = value,
123 TransactionColumn::Institution => self.institution = value,
124 TransactionColumn::Month => self.month = value.date_from_opt_s()?,
125 TransactionColumn::Week => self.week = value.date_from_opt_s()?,
126 TransactionColumn::FullDescription => self.full_description = value,
127 TransactionColumn::AccountId => self.account_id = value,
128 TransactionColumn::CheckNumber => self.check_number = value,
129 TransactionColumn::DateAdded => self.date_added = value.date_from_opt_s()?,
130 TransactionColumn::MerchantName => self.merchant_name = value,
131 TransactionColumn::CategoryHint => self.category_hint = value,
132 TransactionColumn::Category => self.category = value,
133 TransactionColumn::Note => self.note = value,
134 TransactionColumn::Tags => self.tags = value,
135 TransactionColumn::CategorizedDate => {
136 self.categorized_date = value.date_from_opt_s()?
137 }
138 TransactionColumn::Statement => self.statement = value,
139 TransactionColumn::Metadata => self.metadata = value,
140 TransactionColumn::NoName => self.no_name = value,
141 },
142 Err(_) => {
143 let _ = self.other_fields.insert(header.to_string(), value);
144 }
145 }
146
147 Ok(())
148 }
149
150 fn get_by_header(&self, header: &str) -> String {
152 match TransactionColumn::from_header(header) {
153 Ok(col) => match col {
154 TransactionColumn::TransactionId => self.transaction_id.clone(),
155 TransactionColumn::Date => self.date.to_string(),
156 TransactionColumn::Description => self.description.clone(),
157 TransactionColumn::Amount => self.amount.to_string(),
158 TransactionColumn::Account => self.account.clone(),
159 TransactionColumn::AccountNumber => self.account_number.clone(),
160 TransactionColumn::Institution => self.institution.clone(),
161 TransactionColumn::Month => self.month.date_to_s(),
162 TransactionColumn::Week => self.week.date_to_s(),
163 TransactionColumn::FullDescription => self.full_description.clone(),
164 TransactionColumn::AccountId => self.account_id.clone(),
165 TransactionColumn::CheckNumber => self.check_number.clone(),
166 TransactionColumn::DateAdded => self.date_added.date_to_s(),
167 TransactionColumn::MerchantName => self.merchant_name.clone(),
168 TransactionColumn::CategoryHint => self.category_hint.clone(),
169 TransactionColumn::Category => self.category.clone(),
170 TransactionColumn::Note => self.note.clone(),
171 TransactionColumn::Tags => self.tags.clone(),
172 TransactionColumn::CategorizedDate => self.categorized_date.date_to_s(),
173 TransactionColumn::Statement => self.statement.clone(),
174 TransactionColumn::Metadata => self.metadata.clone(),
175 TransactionColumn::NoName => self.no_name.clone(),
176 },
177 Err(_) => self.other_fields.get(header).cloned().unwrap_or_default(),
178 }
179 }
180
181 fn set_original_order(&mut self, original_order: u64) {
182 self.original_order = Some(original_order);
183 }
184
185 fn get_original_order(&self) -> Option<u64> {
186 self.original_order
187 }
188}
189
190impl Transaction {
191 pub fn merge_updates(&mut self, update: TransactionUpdates) {
193 if let Some(x) = update.date {
194 self.date = x;
195 }
196 if let Some(x) = update.description {
197 self.description = x;
198 }
199 if let Some(x) = update.amount {
200 self.amount = x;
201 }
202 if let Some(x) = update.account {
203 self.account = x;
204 }
205 if let Some(x) = update.account_number {
206 self.account_number = x;
207 }
208 if let Some(x) = update.institution {
209 self.institution = x;
210 }
211 if let Some(x) = update.month {
212 self.month = Some(x);
213 }
214 if let Some(x) = update.week {
215 self.week = Some(x);
216 }
217 if let Some(x) = update.full_description {
218 self.full_description = x;
219 }
220 if let Some(x) = update.account_id {
221 self.account_id = x;
222 }
223 if let Some(x) = update.check_number {
224 self.check_number = x;
225 }
226 if let Some(x) = update.date_added {
227 self.date_added = Some(x);
228 }
229 if let Some(x) = update.merchant_name {
230 self.merchant_name = x;
231 }
232 if let Some(x) = update.category_hint {
233 self.category_hint = x;
234 }
235 if let Some(x) = update.category {
236 self.category = x;
237 }
238 if let Some(x) = update.note {
239 self.note = x;
240 }
241 if let Some(x) = update.tags {
242 self.tags = x;
243 }
244 if let Some(x) = update.categorized_date {
245 self.categorized_date = Some(x);
246 }
247 if let Some(x) = update.statement {
248 self.statement = x;
249 }
250 if let Some(x) = update.metadata {
251 self.metadata = x;
252 }
253 if let Some(x) = update.no_name {
254 self.no_name = x;
255 }
256
257 for (key, val) in update.other_fields {
258 self.other_fields.insert(key, val);
259 }
260 }
261}
262
263#[derive(
267 Default,
268 Debug,
269 Clone,
270 Eq,
271 PartialEq,
272 Ord,
273 PartialOrd,
274 Hash,
275 Serialize,
276 Deserialize,
277 schemars::JsonSchema,
278)]
279#[serde(rename_all = "snake_case")]
280pub enum TransactionColumn {
281 #[default]
284 TransactionId,
285 Date,
288 Description,
290 Amount,
292 Account,
295 AccountNumber,
297 Institution,
299 Month,
301 Week,
303 FullDescription,
305 AccountId,
308 CheckNumber,
310 DateAdded,
312 MerchantName,
315 CategoryHint,
318 Category,
321 Note,
323 Tags,
325 CategorizedDate,
328 Statement,
330 Metadata,
332 NoName,
334}
335
336serde_plain::derive_display_from_serialize!(TransactionColumn);
337serde_plain::derive_fromstr_from_deserialize!(TransactionColumn);
338
339impl TransactionColumn {
340 pub fn from_header(header: impl AsRef<str>) -> Res<TransactionColumn> {
341 let header_str = header.as_ref();
342 match header_str {
343 TRANSACTION_ID_STR => Ok(TransactionColumn::TransactionId),
344 DATE_STR => Ok(TransactionColumn::Date),
345 DESCRIPTION_STR => Ok(TransactionColumn::Description),
346 AMOUNT_STR => Ok(TransactionColumn::Amount),
347 ACCOUNT_STR => Ok(TransactionColumn::Account),
348 ACCOUNT_NUMBER_STR => Ok(TransactionColumn::AccountNumber),
349 INSTITUTION_STR => Ok(TransactionColumn::Institution),
350 MONTH_STR => Ok(TransactionColumn::Month),
351 WEEK_STR => Ok(TransactionColumn::Week),
352 FULL_DESCRIPTION_STR => Ok(TransactionColumn::FullDescription),
353 ACCOUNT_ID_STR => Ok(TransactionColumn::AccountId),
354 CHECK_NUMBER_STR => Ok(TransactionColumn::CheckNumber),
355 DATE_ADDED_STR => Ok(TransactionColumn::DateAdded),
356 MERCHANT_NAME_STR => Ok(TransactionColumn::MerchantName),
357 CATEGORY_HINT_STR => Ok(TransactionColumn::CategoryHint),
358 CATEGORY_STR => Ok(TransactionColumn::Category),
359 NOTE_STR => Ok(TransactionColumn::Note),
360 TAGS_STR => Ok(TransactionColumn::Tags),
361 CATEGORIZED_DATE_STR => Ok(TransactionColumn::CategorizedDate),
362 STATEMENT_STR => Ok(TransactionColumn::Statement),
363 METADATA_STR => Ok(TransactionColumn::Metadata),
364 NO_NAME_STR => Ok(TransactionColumn::NoName),
365 bad => bail!("Invalid transaction column name '{bad}'"),
366 }
367 }
368
369 pub fn to_header(&self) -> &'static str {
371 match self {
372 TransactionColumn::TransactionId => TRANSACTION_ID_STR,
373 TransactionColumn::Date => DATE_STR,
374 TransactionColumn::Description => DESCRIPTION_STR,
375 TransactionColumn::Amount => AMOUNT_STR,
376 TransactionColumn::Account => ACCOUNT_STR,
377 TransactionColumn::AccountNumber => ACCOUNT_NUMBER_STR,
378 TransactionColumn::Institution => INSTITUTION_STR,
379 TransactionColumn::Month => MONTH_STR,
380 TransactionColumn::Week => WEEK_STR,
381 TransactionColumn::FullDescription => FULL_DESCRIPTION_STR,
382 TransactionColumn::AccountId => ACCOUNT_ID_STR,
383 TransactionColumn::CheckNumber => CHECK_NUMBER_STR,
384 TransactionColumn::DateAdded => DATE_ADDED_STR,
385 TransactionColumn::MerchantName => MERCHANT_NAME_STR,
386 TransactionColumn::CategoryHint => CATEGORY_HINT_STR,
387 TransactionColumn::Category => CATEGORY_STR,
388 TransactionColumn::Note => NOTE_STR,
389 TransactionColumn::Tags => TAGS_STR,
390 TransactionColumn::CategorizedDate => CATEGORIZED_DATE_STR,
391 TransactionColumn::Statement => STATEMENT_STR,
392 TransactionColumn::Metadata => METADATA_STR,
393 TransactionColumn::NoName => NO_NAME_STR,
394 }
395 }
396}
397
398impl AsRef<str> for TransactionColumn {
399 fn as_ref(&self) -> &str {
400 self.to_header()
401 }
402}
403
404#[derive(Debug, Default, Clone, Parser, Serialize, Deserialize, JsonSchema)]
410pub struct TransactionUpdates {
411 #[serde(skip_serializing_if = "Option::is_none")]
414 #[arg(long)]
415 pub date: Option<Date>,
416
417 #[serde(skip_serializing_if = "Option::is_none")]
419 #[arg(long)]
420 pub description: Option<String>,
421
422 #[serde(skip_serializing_if = "Option::is_none")]
424 #[arg(long)]
425 pub amount: Option<Amount>,
426
427 #[serde(skip_serializing_if = "Option::is_none")]
430 #[arg(long)]
431 pub account: Option<String>,
432
433 #[serde(skip_serializing_if = "Option::is_none")]
435 #[arg(long)]
436 pub account_number: Option<String>,
437
438 #[serde(skip_serializing_if = "Option::is_none")]
440 #[arg(long)]
441 pub institution: Option<String>,
442
443 #[serde(skip_serializing_if = "Option::is_none")]
445 #[arg(long)]
446 pub month: Option<Date>,
447
448 #[serde(skip_serializing_if = "Option::is_none")]
450 #[arg(long)]
451 pub week: Option<Date>,
452
453 #[serde(skip_serializing_if = "Option::is_none")]
455 #[arg(long)]
456 pub full_description: Option<String>,
457
458 #[serde(skip_serializing_if = "Option::is_none")]
461 #[arg(long)]
462 pub account_id: Option<String>,
463
464 #[serde(skip_serializing_if = "Option::is_none")]
466 #[arg(long)]
467 pub check_number: Option<String>,
468
469 #[serde(skip_serializing_if = "Option::is_none")]
471 #[arg(long)]
472 pub date_added: Option<Date>,
473
474 #[serde(skip_serializing_if = "Option::is_none")]
477 #[arg(long)]
478 pub merchant_name: Option<String>,
479
480 #[serde(skip_serializing_if = "Option::is_none")]
483 #[arg(long)]
484 pub category_hint: Option<String>,
485
486 #[serde(skip_serializing_if = "Option::is_none")]
489 #[arg(long)]
490 pub category: Option<String>,
491
492 #[serde(skip_serializing_if = "Option::is_none")]
494 #[arg(long)]
495 pub note: Option<String>,
496
497 #[serde(skip_serializing_if = "Option::is_none")]
499 #[arg(long)]
500 pub tags: Option<String>,
501
502 #[serde(skip_serializing_if = "Option::is_none")]
505 #[arg(long)]
506 pub categorized_date: Option<Date>,
507
508 #[serde(skip_serializing_if = "Option::is_none")]
510 #[arg(long)]
511 pub statement: Option<String>,
512
513 #[serde(skip_serializing_if = "Option::is_none")]
515 #[arg(long)]
516 pub metadata: Option<String>,
517
518 #[serde(skip_serializing_if = "Option::is_none")]
520 #[arg(long)]
521 pub no_name: Option<String>,
522
523 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
525 #[arg(long = "other-field", value_parser = utils::parse_key_val)]
526 pub other_fields: BTreeMap<String, String>,
527}
528
529pub(super) const TRANSACTION_ID_STR: &str = "Transaction ID";
530pub(super) const DATE_STR: &str = "Date";
531pub(super) const DESCRIPTION_STR: &str = "Description";
532pub(super) const AMOUNT_STR: &str = "Amount";
533pub(super) const ACCOUNT_STR: &str = "Account";
534pub(super) const ACCOUNT_NUMBER_STR: &str = "Account #";
535pub(super) const INSTITUTION_STR: &str = "Institution";
536pub(super) const MONTH_STR: &str = "Month";
537pub(super) const WEEK_STR: &str = "Week";
538pub(super) const FULL_DESCRIPTION_STR: &str = "Full Description";
539pub(super) const ACCOUNT_ID_STR: &str = "Account ID";
540pub(super) const CHECK_NUMBER_STR: &str = "Check Number";
541pub(super) const DATE_ADDED_STR: &str = "Date Added";
542pub(super) const MERCHANT_NAME_STR: &str = "Merchant Name";
543pub(super) const CATEGORY_HINT_STR: &str = "Category Hint";
544pub(super) const CATEGORY_STR: &str = "Category";
545pub(super) const NOTE_STR: &str = "Note";
546pub(super) const TAGS_STR: &str = "Tags";
547pub(super) const CATEGORIZED_DATE_STR: &str = "Categorized Date";
548pub(super) const STATEMENT_STR: &str = "Statement";
549pub(super) const METADATA_STR: &str = "Metadata";
550pub(super) const NO_NAME_STR: &str = "";