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