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}