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;
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
70#[cfg(test)]
71mod tests {
72    use super::super::utils::materialize_ops;
73    use super::*;
74    use crate::types::*;
75
76    fn create_test_transaction(tags: Vec<&str>, metadata: Vec<(&str, &str)>) -> DirectiveWrapper {
77        DirectiveWrapper {
78            directive_type: "transaction".to_string(),
79            date: "2024-01-15".to_string(),
80            filename: None,
81            lineno: None,
82            data: DirectiveData::Transaction(TransactionData {
83                flag: "*".to_string(),
84                payee: Some("Test".to_string()),
85                narration: "Test transaction".to_string(),
86                tags: tags.into_iter().map(String::from).collect(),
87                links: vec![],
88                metadata: metadata
89                    .into_iter()
90                    .map(|(k, v)| (k.to_string(), MetaValueData::String(v.to_string())))
91                    .collect(),
92                postings: vec![
93                    PostingData {
94                        account: "Assets:Cash".to_string(),
95                        units: Some(AmountData {
96                            number: "-100.00".to_string(),
97                            currency: "USD".to_string(),
98                        }),
99                        cost: None,
100                        price: None,
101                        flag: None,
102                        metadata: vec![],
103                    },
104                    PostingData {
105                        account: "Expenses:Test".to_string(),
106                        units: Some(AmountData {
107                            number: "100.00".to_string(),
108                            currency: "USD".to_string(),
109                        }),
110                        cost: None,
111                        price: None,
112                        flag: None,
113                        metadata: vec![],
114                    },
115                ],
116            }),
117        }
118    }
119
120    #[test]
121    fn test_rx_txn_adds_default_metadata() {
122        let plugin = RxTxnPlugin;
123
124        let input = PluginInput {
125            directives: vec![create_test_transaction(vec!["rx_txn"], vec![])],
126            options: PluginOptions {
127                operating_currencies: vec!["USD".to_string()],
128                title: None,
129            },
130            config: None,
131        };
132
133        let input_dirs = input.directives.clone();
134        let output = plugin.process(input);
135        assert_eq!(output.errors.len(), 0);
136        let directives = materialize_ops(&input_dirs, &output);
137        assert_eq!(directives.len(), 1);
138
139        if let DirectiveData::Transaction(txn) = &directives[0].data {
140            let has_final = txn.metadata.iter().any(|(k, _)| k == "final");
141            let has_roll = txn.metadata.iter().any(|(k, _)| k == "roll");
142            assert!(has_final, "Should have 'final' metadata");
143            assert!(has_roll, "Should have 'roll' metadata");
144        } else {
145            panic!("Expected transaction");
146        }
147    }
148
149    #[test]
150    fn test_rx_txn_preserves_existing_metadata() {
151        let plugin = RxTxnPlugin;
152
153        let input = PluginInput {
154            directives: vec![create_test_transaction(
155                vec!["rx_txn"],
156                vec![("final", "2024-12-31"), ("roll", "False")],
157            )],
158            options: PluginOptions {
159                operating_currencies: vec!["USD".to_string()],
160                title: None,
161            },
162            config: None,
163        };
164
165        let input_dirs = input.directives.clone();
166        let output = plugin.process(input);
167        assert_eq!(output.errors.len(), 0);
168        let directives = materialize_ops(&input_dirs, &output);
169
170        if let DirectiveData::Transaction(txn) = &directives[0].data {
171            // Should only have 2 metadata items (the original ones)
172            assert_eq!(txn.metadata.len(), 2);
173            let final_meta = txn.metadata.iter().find(|(k, _)| k == "final").unwrap();
174            if let MetaValueData::String(v) = &final_meta.1 {
175                assert_eq!(v, "2024-12-31");
176            } else {
177                panic!("Expected string metadata value");
178            }
179        } else {
180            panic!("Expected transaction");
181        }
182    }
183
184    #[test]
185    fn test_rx_txn_ignores_untagged_transactions() {
186        let plugin = RxTxnPlugin;
187
188        let input = PluginInput {
189            directives: vec![create_test_transaction(vec![], vec![])],
190            options: PluginOptions {
191                operating_currencies: vec!["USD".to_string()],
192                title: None,
193            },
194            config: None,
195        };
196
197        let input_dirs = input.directives.clone();
198        let output = plugin.process(input);
199        assert_eq!(output.errors.len(), 0);
200        let directives = materialize_ops(&input_dirs, &output);
201
202        if let DirectiveData::Transaction(txn) = &directives[0].data {
203            // Should have no metadata added
204            assert!(txn.metadata.is_empty());
205        } else {
206            panic!("Expected transaction");
207        }
208    }
209
210    #[test]
211    fn test_rx_txn_ignores_other_tags() {
212        let plugin = RxTxnPlugin;
213
214        let input = PluginInput {
215            directives: vec![create_test_transaction(vec!["other_tag"], vec![])],
216            options: PluginOptions {
217                operating_currencies: vec!["USD".to_string()],
218                title: None,
219            },
220            config: None,
221        };
222
223        let input_dirs = input.directives.clone();
224        let output = plugin.process(input);
225        assert_eq!(output.errors.len(), 0);
226        let directives = materialize_ops(&input_dirs, &output);
227
228        if let DirectiveData::Transaction(txn) = &directives[0].data {
229            // Should have no metadata added
230            assert!(txn.metadata.is_empty());
231        } else {
232            panic!("Expected transaction");
233        }
234    }
235}