1use crate::error::Res;
2use crate::model::date::Y;
3use crate::model::items::{Item, Items};
4use crate::model::{Amount, Date, DateFromOptStr, DateToSheetStr};
5use crate::utils;
6use anyhow::bail;
7use clap::Parser;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::str::FromStr;
12
13pub type Transactions = Items<Transaction>;
17
18#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
22#[serde(rename_all = "snake_case")]
23pub struct Transaction {
24 pub(crate) transaction_id: String,
27
28 pub(crate) date: Date,
31
32 pub(crate) description: String,
34
35 pub(crate) amount: Amount,
37
38 pub(crate) account: String,
41
42 pub(crate) account_number: String,
44
45 pub(crate) institution: String,
47
48 pub(crate) month: Option<Date>,
50
51 pub(crate) week: Option<Date>,
53
54 pub(crate) full_description: String,
56
57 pub(crate) account_id: String,
60
61 pub(crate) check_number: String,
63
64 pub(crate) date_added: Option<Date>,
66
67 pub(crate) merchant_name: String,
70
71 pub(crate) category_hint: String,
74
75 pub(crate) category: String,
78
79 pub(crate) note: String,
81
82 pub(crate) tags: String,
84
85 pub(crate) categorized_date: Option<Date>,
88
89 pub(crate) statement: String,
91
92 pub(crate) metadata: String,
94
95 pub(crate) no_name: String,
97
98 pub(crate) other_fields: BTreeMap<String, String>,
100
101 #[serde(skip_serializing_if = "Option::is_none")]
104 pub(crate) original_order: Option<u64>,
105}
106
107impl Item for Transaction {
108 fn set_with_header<S1, S2>(&mut self, header: S1, value: S2) -> Res<()>
109 where
110 S1: AsRef<str>,
111 S2: Into<String>,
112 {
113 let header = header.as_ref();
114 let value = value.into();
115
116 match TransactionColumn::from_header(header) {
117 Ok(col) => match col {
118 TransactionColumn::TransactionId => self.transaction_id = value,
119 TransactionColumn::Date => self.date = Date::parse(value)?,
120 TransactionColumn::Description => self.description = value,
121 TransactionColumn::Amount => self.amount = Amount::from_str(&value)?,
122 TransactionColumn::Account => self.account = value,
123 TransactionColumn::AccountNumber => self.account_number = value,
124 TransactionColumn::Institution => self.institution = value,
125 TransactionColumn::Month => self.month = value.date_from_opt_s()?,
126 TransactionColumn::Week => self.week = value.date_from_opt_s()?,
127 TransactionColumn::FullDescription => self.full_description = value,
128 TransactionColumn::AccountId => self.account_id = value,
129 TransactionColumn::CheckNumber => self.check_number = value,
130 TransactionColumn::DateAdded => self.date_added = value.date_from_opt_s()?,
131 TransactionColumn::MerchantName => self.merchant_name = value,
132 TransactionColumn::CategoryHint => self.category_hint = value,
133 TransactionColumn::Category => self.category = value,
134 TransactionColumn::Note => self.note = value,
135 TransactionColumn::Tags => self.tags = value,
136 TransactionColumn::CategorizedDate => {
137 self.categorized_date = value.date_from_opt_s()?
138 }
139 TransactionColumn::Statement => self.statement = value,
140 TransactionColumn::Metadata => self.metadata = value,
141 TransactionColumn::NoName => self.no_name = value,
142 },
143 Err(_) => {
144 let _ = self.other_fields.insert(header.to_string(), value);
145 }
146 }
147
148 Ok(())
149 }
150
151 fn get_by_header(&self, header: &str) -> String {
153 match TransactionColumn::from_header(header) {
154 Ok(col) => match col {
155 TransactionColumn::TransactionId => self.transaction_id.clone(),
156 TransactionColumn::Date => self.date.d_to_s(Y::Y4),
157 TransactionColumn::Description => self.description.clone(),
158 TransactionColumn::Amount => self.amount.to_string(),
159 TransactionColumn::Account => self.account.clone(),
160 TransactionColumn::AccountNumber => self.account_number.clone(),
161 TransactionColumn::Institution => self.institution.clone(),
162 TransactionColumn::Month => self.month.d_to_s(Y::Y2),
163 TransactionColumn::Week => self.week.d_to_s(Y::Y2),
164 TransactionColumn::FullDescription => self.full_description.clone(),
165 TransactionColumn::AccountId => self.account_id.clone(),
166 TransactionColumn::CheckNumber => self.check_number.clone(),
167 TransactionColumn::DateAdded => self.date_added.d_to_s(Y::Y2),
168 TransactionColumn::MerchantName => self.merchant_name.clone(),
169 TransactionColumn::CategoryHint => self.category_hint.clone(),
170 TransactionColumn::Category => self.category.clone(),
171 TransactionColumn::Note => self.note.clone(),
172 TransactionColumn::Tags => self.tags.clone(),
173 TransactionColumn::CategorizedDate => self.categorized_date.d_to_s(Y::Y4),
174 TransactionColumn::Statement => self.statement.clone(),
175 TransactionColumn::Metadata => self.metadata.clone(),
176 TransactionColumn::NoName => self.no_name.clone(),
177 },
178 Err(_) => self.other_fields.get(header).cloned().unwrap_or_default(),
179 }
180 }
181
182 fn set_original_order(&mut self, original_order: u64) {
183 self.original_order = Some(original_order);
184 }
185
186 fn get_original_order(&self) -> Option<u64> {
187 self.original_order
188 }
189}
190
191impl Transaction {
192 pub fn merge_updates(&mut self, update: TransactionUpdates) {
194 if let Some(x) = update.date {
195 self.date = x;
196 }
197 if let Some(x) = update.description {
198 self.description = x;
199 }
200 if let Some(x) = update.amount {
201 self.amount = x;
202 }
203 if let Some(x) = update.account {
204 self.account = x;
205 }
206 if let Some(x) = update.account_number {
207 self.account_number = x;
208 }
209 if let Some(x) = update.institution {
210 self.institution = x;
211 }
212 if let Some(x) = update.month {
213 self.month = Some(x);
214 }
215 if let Some(x) = update.week {
216 self.week = Some(x);
217 }
218 if let Some(x) = update.full_description {
219 self.full_description = x;
220 }
221 if let Some(x) = update.account_id {
222 self.account_id = x;
223 }
224 if let Some(x) = update.check_number {
225 self.check_number = x;
226 }
227 if let Some(x) = update.date_added {
228 self.date_added = Some(x);
229 }
230 if let Some(x) = update.merchant_name {
231 self.merchant_name = x;
232 }
233 if let Some(x) = update.category_hint {
234 self.category_hint = x;
235 }
236 if let Some(x) = update.category {
237 self.category = x;
238 }
239 if let Some(x) = update.note {
240 self.note = x;
241 }
242 if let Some(x) = update.tags {
243 self.tags = x;
244 }
245 if let Some(x) = update.categorized_date {
246 self.categorized_date = Some(x);
247 }
248 if let Some(x) = update.statement {
249 self.statement = x;
250 }
251 if let Some(x) = update.metadata {
252 self.metadata = x;
253 }
254 if let Some(x) = update.no_name {
255 self.no_name = x;
256 }
257
258 for (key, val) in update.other_fields {
259 self.other_fields.insert(key, val);
260 }
261 }
262}
263
264#[derive(
268 Default,
269 Debug,
270 Clone,
271 Eq,
272 PartialEq,
273 Ord,
274 PartialOrd,
275 Hash,
276 Serialize,
277 Deserialize,
278 schemars::JsonSchema,
279)]
280#[serde(rename_all = "snake_case")]
281pub enum TransactionColumn {
282 #[default]
285 TransactionId,
286 Date,
289 Description,
291 Amount,
293 Account,
296 AccountNumber,
298 Institution,
300 Month,
302 Week,
304 FullDescription,
306 AccountId,
309 CheckNumber,
311 DateAdded,
313 MerchantName,
316 CategoryHint,
319 Category,
322 Note,
324 Tags,
326 CategorizedDate,
329 Statement,
331 Metadata,
333 NoName,
335}
336
337serde_plain::derive_display_from_serialize!(TransactionColumn);
338serde_plain::derive_fromstr_from_deserialize!(TransactionColumn);
339
340impl TransactionColumn {
341 pub fn from_header(header: impl AsRef<str>) -> Res<TransactionColumn> {
342 let header_str = header.as_ref();
343 match header_str {
344 TRANSACTION_ID_STR => Ok(TransactionColumn::TransactionId),
345 DATE_STR => Ok(TransactionColumn::Date),
346 DESCRIPTION_STR => Ok(TransactionColumn::Description),
347 AMOUNT_STR => Ok(TransactionColumn::Amount),
348 ACCOUNT_STR => Ok(TransactionColumn::Account),
349 ACCOUNT_NUMBER_STR => Ok(TransactionColumn::AccountNumber),
350 INSTITUTION_STR => Ok(TransactionColumn::Institution),
351 MONTH_STR => Ok(TransactionColumn::Month),
352 WEEK_STR => Ok(TransactionColumn::Week),
353 FULL_DESCRIPTION_STR => Ok(TransactionColumn::FullDescription),
354 ACCOUNT_ID_STR => Ok(TransactionColumn::AccountId),
355 CHECK_NUMBER_STR => Ok(TransactionColumn::CheckNumber),
356 DATE_ADDED_STR => Ok(TransactionColumn::DateAdded),
357 MERCHANT_NAME_STR => Ok(TransactionColumn::MerchantName),
358 CATEGORY_HINT_STR => Ok(TransactionColumn::CategoryHint),
359 CATEGORY_STR => Ok(TransactionColumn::Category),
360 NOTE_STR => Ok(TransactionColumn::Note),
361 TAGS_STR => Ok(TransactionColumn::Tags),
362 CATEGORIZED_DATE_STR => Ok(TransactionColumn::CategorizedDate),
363 STATEMENT_STR => Ok(TransactionColumn::Statement),
364 METADATA_STR => Ok(TransactionColumn::Metadata),
365 NO_NAME_STR => Ok(TransactionColumn::NoName),
366 bad => bail!("Invalid transaction column name '{bad}'"),
367 }
368 }
369
370 pub fn to_header(&self) -> &'static str {
372 match self {
373 TransactionColumn::TransactionId => TRANSACTION_ID_STR,
374 TransactionColumn::Date => DATE_STR,
375 TransactionColumn::Description => DESCRIPTION_STR,
376 TransactionColumn::Amount => AMOUNT_STR,
377 TransactionColumn::Account => ACCOUNT_STR,
378 TransactionColumn::AccountNumber => ACCOUNT_NUMBER_STR,
379 TransactionColumn::Institution => INSTITUTION_STR,
380 TransactionColumn::Month => MONTH_STR,
381 TransactionColumn::Week => WEEK_STR,
382 TransactionColumn::FullDescription => FULL_DESCRIPTION_STR,
383 TransactionColumn::AccountId => ACCOUNT_ID_STR,
384 TransactionColumn::CheckNumber => CHECK_NUMBER_STR,
385 TransactionColumn::DateAdded => DATE_ADDED_STR,
386 TransactionColumn::MerchantName => MERCHANT_NAME_STR,
387 TransactionColumn::CategoryHint => CATEGORY_HINT_STR,
388 TransactionColumn::Category => CATEGORY_STR,
389 TransactionColumn::Note => NOTE_STR,
390 TransactionColumn::Tags => TAGS_STR,
391 TransactionColumn::CategorizedDate => CATEGORIZED_DATE_STR,
392 TransactionColumn::Statement => STATEMENT_STR,
393 TransactionColumn::Metadata => METADATA_STR,
394 TransactionColumn::NoName => NO_NAME_STR,
395 }
396 }
397}
398
399impl AsRef<str> for TransactionColumn {
400 fn as_ref(&self) -> &str {
401 self.to_header()
402 }
403}
404
405#[derive(Debug, Default, Clone, Parser, Serialize, Deserialize, JsonSchema)]
411pub struct TransactionUpdates {
412 #[serde(skip_serializing_if = "Option::is_none")]
415 #[arg(long)]
416 pub date: Option<Date>,
417
418 #[serde(skip_serializing_if = "Option::is_none")]
420 #[arg(long)]
421 pub description: Option<String>,
422
423 #[serde(skip_serializing_if = "Option::is_none")]
425 #[arg(long)]
426 pub amount: Option<Amount>,
427
428 #[serde(skip_serializing_if = "Option::is_none")]
431 #[arg(long)]
432 pub account: Option<String>,
433
434 #[serde(skip_serializing_if = "Option::is_none")]
436 #[arg(long)]
437 pub account_number: Option<String>,
438
439 #[serde(skip_serializing_if = "Option::is_none")]
441 #[arg(long)]
442 pub institution: Option<String>,
443
444 #[serde(skip_serializing_if = "Option::is_none")]
446 #[arg(long)]
447 pub month: Option<Date>,
448
449 #[serde(skip_serializing_if = "Option::is_none")]
451 #[arg(long)]
452 pub week: Option<Date>,
453
454 #[serde(skip_serializing_if = "Option::is_none")]
456 #[arg(long)]
457 pub full_description: Option<String>,
458
459 #[serde(skip_serializing_if = "Option::is_none")]
462 #[arg(long)]
463 pub account_id: Option<String>,
464
465 #[serde(skip_serializing_if = "Option::is_none")]
467 #[arg(long)]
468 pub check_number: Option<String>,
469
470 #[serde(skip_serializing_if = "Option::is_none")]
472 #[arg(long)]
473 pub date_added: Option<Date>,
474
475 #[serde(skip_serializing_if = "Option::is_none")]
478 #[arg(long)]
479 pub merchant_name: Option<String>,
480
481 #[serde(skip_serializing_if = "Option::is_none")]
484 #[arg(long)]
485 pub category_hint: Option<String>,
486
487 #[serde(skip_serializing_if = "Option::is_none")]
490 #[arg(long)]
491 pub category: Option<String>,
492
493 #[serde(skip_serializing_if = "Option::is_none")]
495 #[arg(long)]
496 pub note: Option<String>,
497
498 #[serde(skip_serializing_if = "Option::is_none")]
500 #[arg(long)]
501 pub tags: Option<String>,
502
503 #[serde(skip_serializing_if = "Option::is_none")]
506 #[arg(long)]
507 pub categorized_date: Option<Date>,
508
509 #[serde(skip_serializing_if = "Option::is_none")]
511 #[arg(long)]
512 pub statement: Option<String>,
513
514 #[serde(skip_serializing_if = "Option::is_none")]
516 #[arg(long)]
517 pub metadata: Option<String>,
518
519 #[serde(skip_serializing_if = "Option::is_none")]
521 #[arg(long)]
522 pub no_name: Option<String>,
523
524 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
526 #[arg(long = "other-field", value_parser = utils::parse_key_val)]
527 pub other_fields: BTreeMap<String, String>,
528}
529
530pub(super) const TRANSACTION_ID_STR: &str = "Transaction ID";
531pub(super) const DATE_STR: &str = "Date";
532pub(super) const DESCRIPTION_STR: &str = "Description";
533pub(super) const AMOUNT_STR: &str = "Amount";
534pub(super) const ACCOUNT_STR: &str = "Account";
535pub(super) const ACCOUNT_NUMBER_STR: &str = "Account #";
536pub(super) const INSTITUTION_STR: &str = "Institution";
537pub(super) const MONTH_STR: &str = "Month";
538pub(super) const WEEK_STR: &str = "Week";
539pub(super) const FULL_DESCRIPTION_STR: &str = "Full Description";
540pub(super) const ACCOUNT_ID_STR: &str = "Account ID";
541pub(super) const CHECK_NUMBER_STR: &str = "Check Number";
542pub(super) const DATE_ADDED_STR: &str = "Date Added";
543pub(super) const MERCHANT_NAME_STR: &str = "Merchant Name";
544pub(super) const CATEGORY_HINT_STR: &str = "Category Hint";
545pub(super) const CATEGORY_STR: &str = "Category";
546pub(super) const NOTE_STR: &str = "Note";
547pub(super) const TAGS_STR: &str = "Tags";
548pub(super) const CATEGORIZED_DATE_STR: &str = "Categorized Date";
549pub(super) const STATEMENT_STR: &str = "Statement";
550pub(super) const METADATA_STR: &str = "Metadata";
551pub(super) const NO_NAME_STR: &str = "";