Skip to main content

rustledger_plugin/native/plugins/
rx_txn.rs

1//! Plugin for Regular Expected Transactions (beanahead).
2//!
3//! Sets default metadata values for transactions tagged with `#rx_txn`.
4//! This is used by the beanahead tool for managing recurring transactions.
5
6use crate::types::{DirectiveData, MetaValueData, PluginInput, PluginOp, PluginOutput};
7
8use super::super::{NativePlugin, RegularPlugin};
9
10/// Tag used to identify Regular Expected Transactions.
11const TAG_RX: &str = "rx_txn";
12
13/// Plugin for Regular Expected Transactions.
14///
15/// For transactions tagged with `#rx_txn`, this plugin sets default
16/// metadata values:
17/// - `final`: None (null)
18/// - `roll`: True
19pub struct RxTxnPlugin;
20
21impl NativePlugin for RxTxnPlugin {
22    fn name(&self) -> &'static str {
23        "rx_txn_plugin"
24    }
25
26    fn description(&self) -> &'static str {
27        "Set default metadata for Regular Expected Transactions (beanahead)"
28    }
29
30    fn process(&self, input: PluginInput) -> PluginOutput {
31        let mut ops: Vec<PluginOp> = Vec::with_capacity(input.directives.len());
32        for (i, mut wrapper) in input.directives.into_iter().enumerate() {
33            let needs_change = matches!(&wrapper.data, DirectiveData::Transaction(t)
34                if t.tags.contains(&TAG_RX.to_string())
35                    && (!t.metadata.iter().any(|(k, _)| k == "final")
36                        || !t.metadata.iter().any(|(k, _)| k == "roll")));
37
38            if !needs_change {
39                ops.push(PluginOp::Keep(i));
40                continue;
41            }
42
43            if let DirectiveData::Transaction(ref mut txn) = wrapper.data {
44                let has_final = txn.metadata.iter().any(|(k, _)| k == "final");
45                let has_roll = txn.metadata.iter().any(|(k, _)| k == "roll");
46
47                if !has_final {
48                    txn.metadata.push((
49                        "final".to_string(),
50                        MetaValueData::String("None".to_string()),
51                    ));
52                }
53                if !has_roll {
54                    txn.metadata.push((
55                        "roll".to_string(),
56                        MetaValueData::String("True".to_string()),
57                    ));
58                }
59            }
60            ops.push(PluginOp::Modify(i, wrapper));
61        }
62
63        PluginOutput {
64            ops,
65            errors: Vec::new(),
66        }
67    }
68}
69
70impl RegularPlugin for RxTxnPlugin {}
71
72#[cfg(test)]
73mod tests {
74    use super::super::utils::materialize_ops;
75    use super::*;
76    use crate::types::*;
77
78    fn create_test_transaction(tags: Vec<&str>, metadata: Vec<(&str, &str)>) -> DirectiveWrapper {
79        DirectiveWrapper {
80            directive_type: "transaction".to_string(),
81            date: "2024-01-15".to_string(),
82            filename: None,
83            lineno: None,
84            data: DirectiveData::Transaction(TransactionData {
85                flag: "*".to_string(),
86                payee: Some("Test".to_string()),
87                narration: "Test transaction".to_string(),
88                tags: tags.into_iter().map(String::from).collect(),
89                links: vec![],
90                metadata: metadata
91                    .into_iter()
92                    .map(|(k, v)| (k.to_string(), MetaValueData::String(v.to_string())))
93                    .collect(),
94                postings: vec![
95                    PostingData {
96                        account: "Assets:Cash".to_string(),
97                        units: Some(AmountData {
98                            number: "-100.00".to_string(),
99                            currency: "USD".to_string(),
100                        }),
101                        cost: None,
102                        price: None,
103                        flag: None,
104                        metadata: vec![],
105                        span: None,
106                    },
107                    PostingData {
108                        account: "Expenses:Test".to_string(),
109                        units: Some(AmountData {
110                            number: "100.00".to_string(),
111                            currency: "USD".to_string(),
112                        }),
113                        cost: None,
114                        price: None,
115                        flag: None,
116                        metadata: vec![],
117                        span: None,
118                    },
119                ],
120            }),
121        }
122    }
123
124    #[test]
125    fn test_rx_txn_adds_default_metadata() {
126        let plugin = RxTxnPlugin;
127
128        let input = PluginInput {
129            directives: vec![create_test_transaction(vec!["rx_txn"], vec![])],
130            options: PluginOptions {
131                operating_currencies: vec!["USD".to_string()],
132                title: None,
133            },
134            config: None,
135        };
136
137        let input_dirs = input.directives.clone();
138        let output = plugin.process(input);
139        assert_eq!(output.errors.len(), 0);
140        let directives = materialize_ops(&input_dirs, &output);
141        assert_eq!(directives.len(), 1);
142
143        if let DirectiveData::Transaction(txn) = &directives[0].data {
144            let has_final = txn.metadata.iter().any(|(k, _)| k == "final");
145            let has_roll = txn.metadata.iter().any(|(k, _)| k == "roll");
146            assert!(has_final, "Should have 'final' metadata");
147            assert!(has_roll, "Should have 'roll' metadata");
148        } else {
149            panic!("Expected transaction");
150        }
151    }
152
153    #[test]
154    fn test_rx_txn_preserves_existing_metadata() {
155        let plugin = RxTxnPlugin;
156
157        let input = PluginInput {
158            directives: vec![create_test_transaction(
159                vec!["rx_txn"],
160                vec![("final", "2024-12-31"), ("roll", "False")],
161            )],
162            options: PluginOptions {
163                operating_currencies: vec!["USD".to_string()],
164                title: None,
165            },
166            config: None,
167        };
168
169        let input_dirs = input.directives.clone();
170        let output = plugin.process(input);
171        assert_eq!(output.errors.len(), 0);
172        let directives = materialize_ops(&input_dirs, &output);
173
174        if let DirectiveData::Transaction(txn) = &directives[0].data {
175            // Should only have 2 metadata items (the original ones)
176            assert_eq!(txn.metadata.len(), 2);
177            let final_meta = txn.metadata.iter().find(|(k, _)| k == "final").unwrap();
178            if let MetaValueData::String(v) = &final_meta.1 {
179                assert_eq!(v, "2024-12-31");
180            } else {
181                panic!("Expected string metadata value");
182            }
183        } else {
184            panic!("Expected transaction");
185        }
186    }
187
188    #[test]
189    fn test_rx_txn_ignores_untagged_transactions() {
190        let plugin = RxTxnPlugin;
191
192        let input = PluginInput {
193            directives: vec![create_test_transaction(vec![], vec![])],
194            options: PluginOptions {
195                operating_currencies: vec!["USD".to_string()],
196                title: None,
197            },
198            config: None,
199        };
200
201        let input_dirs = input.directives.clone();
202        let output = plugin.process(input);
203        assert_eq!(output.errors.len(), 0);
204        let directives = materialize_ops(&input_dirs, &output);
205
206        if let DirectiveData::Transaction(txn) = &directives[0].data {
207            // Should have no metadata added
208            assert!(txn.metadata.is_empty());
209        } else {
210            panic!("Expected transaction");
211        }
212    }
213
214    #[test]
215    fn test_rx_txn_ignores_other_tags() {
216        let plugin = RxTxnPlugin;
217
218        let input = PluginInput {
219            directives: vec![create_test_transaction(vec!["other_tag"], vec![])],
220            options: PluginOptions {
221                operating_currencies: vec!["USD".to_string()],
222                title: None,
223            },
224            config: None,
225        };
226
227        let input_dirs = input.directives.clone();
228        let output = plugin.process(input);
229        assert_eq!(output.errors.len(), 0);
230        let directives = materialize_ops(&input_dirs, &output);
231
232        if let DirectiveData::Transaction(txn) = &directives[0].data {
233            // Should have no metadata added
234            assert!(txn.metadata.is_empty());
235        } else {
236            panic!("Expected transaction");
237        }
238    }
239}