tiller_sync/model/
transaction.rs

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
12/// Represents a collection of transactions from a Transactions sheet, including the header mapping.
13/// See tiller documentation for more information about the semantic meanings of transaction
14/// columns: https://help.tiller.com/en/articles/432681-transactions-sheet-columns
15pub type Transactions = Items<Transaction>;
16
17/// Represents a single row from the Transactions sheet.
18/// See tiller documentation for more information about the semantic meanings of transaction
19/// columns: https://help.tiller.com/en/articles/432681-transactions-sheet-columns
20#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
21#[serde(rename_all = "snake_case")]
22pub struct Transaction {
23    /// A unique ID assigned to the transaction by Tiller's systems. Critical for support
24    /// troubleshooting and must not be deleted.
25    pub(crate) transaction_id: String,
26
27    /// The posted date (when the transaction cleared) or transaction date (when the transaction
28    /// occurred). Posted date takes priority except for investment accounts.
29    pub(crate) date: Date,
30
31    /// Cleaned-up merchant information from your bank.
32    pub(crate) description: String,
33
34    /// Transaction value where income and credits are positive; expenses and debits are negative.
35    pub(crate) amount: Amount,
36
37    /// The account name as it appears on your bank's website or your custom nickname from Tiller
38    /// Console.
39    pub(crate) account: String,
40
41    /// Last four digits of the bank account number (e.g., "xxxx1102").
42    pub(crate) account_number: String,
43
44    /// Financial institution name (e.g., "Bank of America").
45    pub(crate) institution: String,
46
47    /// First day of the transaction's month, useful for pivot tables and reporting.
48    pub(crate) month: Option<Date>,
49
50    /// Sunday date of the transaction's week for weekly breakdowns.
51    pub(crate) week: Option<Date>,
52
53    /// Unmodified merchant details directly from your bank, including codes and numbers.
54    pub(crate) full_description: String,
55
56    /// A unique ID assigned to your accounts by Tiller's systems. Important for troubleshooting;
57    /// do not delete.
58    pub(crate) account_id: String,
59
60    /// Check number when available for checks you write.
61    pub(crate) check_number: String,
62
63    /// When the transaction was added to the spreadsheet.
64    pub(crate) date_added: Option<Date>,
65
66    /// Normalized merchant name standardizing variants (e.g., "Amazon" for multiple Amazon
67    /// formats). Optional automated column.
68    pub(crate) merchant_name: String,
69
70    /// Data provider's category suggestion based on merchant knowledge. Optional automated column;
71    /// not included in core templates.
72    pub(crate) category_hint: String,
73
74    /// User-assigned category. Non-automated by default to promote spending awareness; AutoCat
75    /// available for automation.
76    pub(crate) category: String,
77
78    /// Custom notes about specific transactions. Leveraged by Category Rollup reports.
79    pub(crate) note: String,
80
81    /// User-defined tags for additional transaction categorization.
82    pub(crate) tags: String,
83
84    /// Date when AutoCat automatically categorized or updated a transaction. Google Sheets Add-on
85    /// column.
86    pub(crate) categorized_date: Option<Date>,
87
88    /// For reconciling transactions to bank statements. Google Sheets Add-on column.
89    pub(crate) statement: String,
90
91    /// Supports workflows including CSV imports. Google Sheets Add-on column.
92    pub(crate) metadata: String,
93
94    /// Empty column that may appear at Column A.
95    pub(crate) no_name: String,
96
97    /// Custom columns not part of the standard Tiller schema.
98    pub(crate) other_fields: BTreeMap<String, String>,
99
100    /// Row position from last sync down (0-indexed); None for locally-added rows.
101    /// Used for formula preservation during sync up.
102    #[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    /// Get a field value by its header name.
151    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    /// Set any of the fields on `self` that are set in `update`.
192    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/// Represents the known columns that should be found in the transactions sheet.
264/// See tiller documentation for more information about the semantic meanings of transaction
265/// columns: https://help.tiller.com/en/articles/432681-transactions-sheet-columns
266#[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    /// A unique ID assigned to the transaction by Tiller's systems. Critical for support
282    /// troubleshooting and must not be deleted.
283    #[default]
284    TransactionId,
285    /// The posted date (when the transaction cleared) or transaction date (when the transaction
286    /// occurred). Posted date takes priority except for investment accounts.
287    Date,
288    /// Cleaned-up merchant information from your bank.
289    Description,
290    /// Transaction value where income and credits are positive; expenses and debits are negative.
291    Amount,
292    /// The account name as it appears on your bank's website or your custom nickname from Tiller
293    /// Console.
294    Account,
295    /// Last four digits of the bank account number (e.g., "xxxx1102").
296    AccountNumber,
297    /// Financial institution name (e.g., "Bank of America").
298    Institution,
299    /// First day of the transaction's month, useful for pivot tables and reporting.
300    Month,
301    /// Sunday date of the transaction's week for weekly breakdowns.
302    Week,
303    /// Unmodified merchant details directly from your bank, including codes and numbers.
304    FullDescription,
305    /// A unique ID assigned to your accounts by Tiller's systems. Important for troubleshooting;
306    /// do not delete.
307    AccountId,
308    /// Check number when available for checks you write.
309    CheckNumber,
310    /// When the transaction was added to the spreadsheet.
311    DateAdded,
312    /// Normalized merchant name standardizing variants (e.g., "Amazon" for multiple Amazon
313    /// formats). Optional automated column.
314    MerchantName,
315    /// Data provider's category suggestion based on merchant knowledge. Optional automated column;
316    /// not included in core templates.
317    CategoryHint,
318    /// User-assigned category. Non-automated by default to promote spending awareness; AutoCat
319    /// available for automation.
320    Category,
321    /// Custom notes about specific transactions. Leveraged by Category Rollup reports.
322    Note,
323    /// User-defined tags for additional transaction categorization.
324    Tags,
325    /// Date when AutoCat automatically categorized or updated a transaction. Google Sheets Add-on
326    /// column.
327    CategorizedDate,
328    /// For reconciling transactions to bank statements. Google Sheets Add-on column.
329    Statement,
330    /// Supports workflows including CSV imports. Google Sheets Add-on column.
331    Metadata,
332    /// My sheet has an empty column at Column A which I did not add.
333    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    /// Returns the header string for this column (e.g., "Note", "Category").
370    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/// The fields to update in a transaction row. Only set values will be changed, unset values will
405/// not be changed.
406///
407/// See tiller documentation for more information about the semantic meanings of transaction
408/// columns: https://help.tiller.com/en/articles/432681-transactions-sheet-columns
409#[derive(Debug, Default, Clone, Parser, Serialize, Deserialize, JsonSchema)]
410pub struct TransactionUpdates {
411    /// The posted date (when the transaction cleared) or transaction date (when the transaction
412    /// occurred). Posted date takes priority except for investment accounts.
413    #[serde(skip_serializing_if = "Option::is_none")]
414    #[arg(long)]
415    pub date: Option<Date>,
416
417    /// Cleaned-up merchant information from your bank.
418    #[serde(skip_serializing_if = "Option::is_none")]
419    #[arg(long)]
420    pub description: Option<String>,
421
422    /// Transaction value where income and credits are positive; expenses and debits are negative.
423    #[serde(skip_serializing_if = "Option::is_none")]
424    #[arg(long)]
425    pub amount: Option<Amount>,
426
427    /// The account name as it appears on your bank's website or your custom nickname from Tiller
428    /// Console.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    #[arg(long)]
431    pub account: Option<String>,
432
433    /// Last four digits of the bank account number (e.g., "xxxx1102").
434    #[serde(skip_serializing_if = "Option::is_none")]
435    #[arg(long)]
436    pub account_number: Option<String>,
437
438    /// Financial institution name (e.g., "Bank of America").
439    #[serde(skip_serializing_if = "Option::is_none")]
440    #[arg(long)]
441    pub institution: Option<String>,
442
443    /// First day of the transaction's month, useful for pivot tables and reporting.
444    #[serde(skip_serializing_if = "Option::is_none")]
445    #[arg(long)]
446    pub month: Option<Date>,
447
448    /// Sunday date of the transaction's week for weekly breakdowns.
449    #[serde(skip_serializing_if = "Option::is_none")]
450    #[arg(long)]
451    pub week: Option<Date>,
452
453    /// Unmodified merchant details directly from your bank, including codes and numbers.
454    #[serde(skip_serializing_if = "Option::is_none")]
455    #[arg(long)]
456    pub full_description: Option<String>,
457
458    /// A unique ID assigned to your accounts by Tiller's systems. Important for troubleshooting;
459    /// do not delete.
460    #[serde(skip_serializing_if = "Option::is_none")]
461    #[arg(long)]
462    pub account_id: Option<String>,
463
464    /// Check number when available for checks you write.
465    #[serde(skip_serializing_if = "Option::is_none")]
466    #[arg(long)]
467    pub check_number: Option<String>,
468
469    /// When the transaction was added to the spreadsheet.
470    #[serde(skip_serializing_if = "Option::is_none")]
471    #[arg(long)]
472    pub date_added: Option<Date>,
473
474    /// Normalized merchant name standardizing variants (e.g., "Amazon" for multiple Amazon
475    /// formats). Optional automated column.
476    #[serde(skip_serializing_if = "Option::is_none")]
477    #[arg(long)]
478    pub merchant_name: Option<String>,
479
480    /// Data provider's category suggestion based on merchant knowledge. Optional automated column;
481    /// not included in core templates.
482    #[serde(skip_serializing_if = "Option::is_none")]
483    #[arg(long)]
484    pub category_hint: Option<String>,
485
486    /// User-assigned category. Non-automated by default to promote spending awareness; AutoCat
487    /// available for automation.
488    #[serde(skip_serializing_if = "Option::is_none")]
489    #[arg(long)]
490    pub category: Option<String>,
491
492    /// Custom notes about specific transactions. Leveraged by Category Rollup reports.
493    #[serde(skip_serializing_if = "Option::is_none")]
494    #[arg(long)]
495    pub note: Option<String>,
496
497    /// User-defined tags for additional transaction categorization.
498    #[serde(skip_serializing_if = "Option::is_none")]
499    #[arg(long)]
500    pub tags: Option<String>,
501
502    /// Date when AutoCat automatically categorized or updated a transaction. Google Sheets Add-on
503    /// column.
504    #[serde(skip_serializing_if = "Option::is_none")]
505    #[arg(long)]
506    pub categorized_date: Option<Date>,
507
508    /// For reconciling transactions to bank statements. Google Sheets Add-on column.
509    #[serde(skip_serializing_if = "Option::is_none")]
510    #[arg(long)]
511    pub statement: Option<String>,
512
513    /// Supports workflows including CSV imports. Google Sheets Add-on column.
514    #[serde(skip_serializing_if = "Option::is_none")]
515    #[arg(long)]
516    pub metadata: Option<String>,
517
518    /// Empty column that may appear at Column A.
519    #[serde(skip_serializing_if = "Option::is_none")]
520    #[arg(long)]
521    pub no_name: Option<String>,
522
523    /// Custom columns not part of the standard Tiller schema.
524    #[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 = "";