Skip to main content

tiller_sync/model/
transaction.rs

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