Skip to main content

rustledger_plugin/native/plugins/
check_closing.rs

1//! Zero balance assertion on account closing.
2
3use crate::types::{
4    AmountData, BalanceData, DirectiveData, DirectiveWrapper, MetaValueData, PluginInput, PluginOp,
5    PluginOutput,
6};
7
8use super::super::NativePlugin;
9use super::utils::increment_date;
10
11/// Plugin that inserts zero balance assertion when posting has `closing: TRUE` metadata.
12///
13/// When a posting has metadata `closing: TRUE`, this plugin adds a balance assertion
14/// for that account with zero balance on the next day.
15pub struct CheckClosingPlugin;
16
17impl NativePlugin for CheckClosingPlugin {
18    fn name(&self) -> &'static str {
19        "check_closing"
20    }
21
22    fn description(&self) -> &'static str {
23        "Zero balance assertion on account closing"
24    }
25
26    fn process(&self, input: PluginInput) -> PluginOutput {
27        let mut ops: Vec<PluginOp> = Vec::new();
28
29        // Default currency for auto-balanced (units=None) closing postings:
30        // prefer the user's first operating currency, falling back to "USD"
31        // when none is configured. Closes #1039.
32        let default_currency = input
33            .options
34            .operating_currencies
35            .first()
36            .cloned()
37            .unwrap_or_else(|| "USD".to_string());
38
39        for (i, wrapper) in input.directives.iter().enumerate() {
40            ops.push(PluginOp::Keep(i));
41
42            if let DirectiveData::Transaction(txn) = &wrapper.data {
43                for posting in &txn.postings {
44                    // Check for closing: TRUE metadata
45                    let has_closing = posting.metadata.iter().any(|(key, val)| {
46                        key == "closing" && matches!(val, MetaValueData::Bool(true))
47                    });
48
49                    if has_closing {
50                        // Parse the date and add one day
51                        if let Some(next_date) = increment_date(&wrapper.date) {
52                            // Use the posting's units currency if present,
53                            // otherwise the resolved default (operating
54                            // currency or "USD" fallback).
55                            let currency = posting
56                                .units
57                                .as_ref()
58                                .map_or_else(|| default_currency.clone(), |u| u.currency.clone());
59
60                            // Add zero balance assertion
61                            ops.push(PluginOp::Insert(DirectiveWrapper {
62                                directive_type: "balance".to_string(),
63                                date: next_date,
64                                filename: None, // Plugin-generated
65                                lineno: None,
66                                data: DirectiveData::Balance(BalanceData {
67                                    account: posting.account.clone(),
68                                    amount: AmountData {
69                                        number: "0".to_string(),
70                                        currency,
71                                    },
72                                    tolerance: None,
73                                    metadata: vec![],
74                                }),
75                            }));
76                        }
77                    }
78                }
79            }
80        }
81
82        // Final ordering is the loader's responsibility — it re-sorts
83        // directives after the plugin pass.
84        PluginOutput {
85            ops,
86            errors: Vec::new(),
87        }
88    }
89}