tiller_sync/model/
category.rs

1use crate::error::Res;
2use crate::model::items::{Item, Items};
3use crate::utils;
4use anyhow::bail;
5use clap::Parser;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10/// Represents the category data from a Categories sheet, including the header mapping.
11pub type Categories = Items<Category>;
12
13/// Represents a single row from the Category sheet.
14#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
15#[serde(rename_all = "snake_case")]
16pub struct Category {
17    /// The name of the category. This is the primary key and must be unique.
18    pub(crate) category: String,
19
20    /// The group this category belongs to. Groups organize related categories together for
21    /// reporting purposes (e.g., "Food", "Transportation", "Housing"). All categories should have
22    /// a Group assigned.
23    pub(crate) category_group: String,
24
25    /// The type classification for this category. Common types include "Expense", "Income", and
26    /// "Transfer". All categories should have a Type assigned.
27    #[serde(rename = "type")]
28    pub(crate) r#type: String,
29
30    /// Controls visibility in reports. Set to "Hide" to exclude this category from reports.
31    /// This is useful for categories like credit card payments or internal transfers that you
32    /// don't want appearing in spending reports.
33    pub(crate) hide_from_reports: String,
34
35    /// Custom columns not part of the standard Tiller schema.
36    pub(crate) other_fields: BTreeMap<String, String>,
37
38    /// Row position from last sync down (0-indexed); None for locally-added rows.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub(crate) original_order: Option<u64>,
41}
42
43impl Category {
44    /// Set any of the fields on `self` that are set in `update`.
45    pub fn merge_updates(&mut self, update: CategoryUpdates) {
46        if let Some(x) = update.category {
47            self.category = x;
48        }
49        if let Some(x) = update.group {
50            self.category_group = x;
51        }
52        if let Some(x) = update.r#type {
53            self.r#type = x;
54        }
55        if let Some(x) = update.hide_from_reports {
56            self.hide_from_reports = x;
57        }
58
59        for (key, val) in update.other_fields {
60            self.other_fields.insert(key, val);
61        }
62    }
63}
64
65impl Item for Category {
66    fn set_with_header<S1, S2>(&mut self, header: S1, value: S2) -> Res<()>
67    where
68        S1: AsRef<str>,
69        S2: Into<String>,
70    {
71        let header = header.as_ref();
72        let value = value.into();
73
74        match CategoryColumn::from_header(header) {
75            Ok(col) => match col {
76                CategoryColumn::Category => self.category = value,
77                CategoryColumn::Group => self.category_group = value,
78                CategoryColumn::Type => self.r#type = value,
79                CategoryColumn::HideFromReports => self.hide_from_reports = value,
80            },
81            Err(_) => {
82                let _ = self.other_fields.insert(header.to_string(), value);
83            }
84        }
85
86        Ok(())
87    }
88
89    fn get_by_header(&self, header: &str) -> String {
90        match CategoryColumn::from_header(header) {
91            Ok(col) => match col {
92                CategoryColumn::Category => self.category.clone(),
93                CategoryColumn::Group => self.category_group.clone(),
94                CategoryColumn::Type => self.r#type.clone(),
95                CategoryColumn::HideFromReports => self.hide_from_reports.clone(),
96            },
97            Err(_) => self.other_fields.get(header).cloned().unwrap_or_default(),
98        }
99    }
100
101    fn set_original_order(&mut self, original_order: u64) {
102        self.original_order = Some(original_order)
103    }
104
105    fn get_original_order(&self) -> Option<u64> {
106        self.original_order
107    }
108}
109
110/// Represents the known columns that should be found in the categories sheet.
111#[derive(Default, Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum CategoryColumn {
114    /// The name of the category. This is the primary key and must be unique.
115    #[default]
116    Category,
117    /// The group this category belongs to. Groups organize related categories together for
118    /// reporting purposes (e.g., "Food", "Transportation", "Housing").
119    Group,
120    /// The type classification for this category. Common types include "Expense", "Income", and
121    /// "Transfer".
122    #[serde(rename = "type")]
123    Type,
124    /// Controls visibility in reports. Set to "Hide" to exclude this category from reports.
125    HideFromReports,
126}
127
128serde_plain::derive_display_from_serialize!(CategoryColumn);
129serde_plain::derive_fromstr_from_deserialize!(CategoryColumn);
130
131impl CategoryColumn {
132    pub fn from_header(header: impl AsRef<str>) -> Res<CategoryColumn> {
133        let header_str = header.as_ref();
134        match header_str {
135            CATEGORY_STR => Ok(CategoryColumn::Category),
136            GROUP_STR => Ok(CategoryColumn::Group),
137            TYPE_STR => Ok(CategoryColumn::Type),
138            HIDE_FROM_REPORTS_STR => Ok(CategoryColumn::HideFromReports),
139            bad => bail!("Invalid category column name '{bad}'"),
140        }
141    }
142}
143
144pub(super) const CATEGORY_STR: &str = "Category";
145pub(super) const GROUP_STR: &str = "Group";
146pub(super) const TYPE_STR: &str = "Type";
147pub(super) const HIDE_FROM_REPORTS_STR: &str = "Hide From Reports";
148
149/// The fields to update in a category row. Only set values will be changed, unset values will
150/// not be changed.
151///
152/// See tiller documentation for more information about the Categories sheet:
153/// <https://help.tiller.com/en/articles/3250769-customizing-categories>
154#[derive(Debug, Default, Clone, Parser, Serialize, Deserialize, JsonSchema)]
155pub struct CategoryUpdates {
156    /// The new name for the category. Use this to rename a category. Due to `ON UPDATE CASCADE`
157    /// foreign key constraints, renaming a category automatically updates all references in
158    /// transactions and autocat rules.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    #[arg(long)]
161    pub category: Option<String>,
162
163    /// The group this category belongs to. Groups organize related categories together for
164    /// reporting purposes (e.g., "Food", "Transportation", "Housing"). All categories should have
165    /// a Group assigned.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    #[arg(long)]
168    pub group: Option<String>,
169
170    /// The type classification for this category. Common types include "Expense", "Income", and
171    /// "Transfer". All categories should have a Type assigned.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    #[arg(long, name = "type")]
174    pub r#type: Option<String>,
175
176    /// Controls visibility in reports. Set to "Hide" to exclude this category from reports.
177    /// This is useful for categories like credit card payments or internal transfers that you
178    /// don't want appearing in spending reports.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    #[arg(long)]
181    pub hide_from_reports: Option<String>,
182
183    /// Custom columns not part of the standard Tiller schema.
184    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
185    #[arg(long = "other-field", value_parser = utils::parse_key_val)]
186    pub other_fields: BTreeMap<String, String>,
187}