use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
use std::sync::atomic::{AtomicUsize, Ordering};
static HOLDING_ACCOUNT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"'([^']+)'\s*:\s*\{\s*'earlier'\s*:\s*'([^']+)'\s*,\s*'later'\s*:\s*'([^']+)'\s*\}")
.expect("HOLDING_ACCOUNT_RE: invalid regex pattern")
});
use crate::types::{
AmountData, DirectiveData, DirectiveWrapper, MetaValueData, OpenData, PluginInput,
PluginOutput, PostingData, TransactionData,
};
use super::super::NativePlugin;
pub struct EffectiveDatePlugin;
fn default_holding_accounts() -> HashMap<String, (String, String)> {
let mut map = HashMap::new();
map.insert(
"Expenses".to_string(),
(
"Liabilities:Hold:Expenses".to_string(),
"Assets:Hold:Expenses".to_string(),
),
);
map.insert(
"Income".to_string(),
(
"Assets:Hold:Income".to_string(),
"Liabilities:Hold:Income".to_string(),
),
);
map
}
impl NativePlugin for EffectiveDatePlugin {
fn name(&self) -> &'static str {
"effective_date"
}
fn description(&self) -> &'static str {
"Move postings to their effective dates using holding accounts"
}
fn process(&self, input: PluginInput) -> PluginOutput {
let holding_accounts = match &input.config {
Some(config) => parse_config(config).unwrap_or_else(|_| default_holding_accounts()),
None => default_holding_accounts(),
};
let mut new_accounts: HashSet<String> = HashSet::new();
let mut earliest_date: Option<String> = None;
let mut interesting_entries = Vec::new();
let mut filtered_entries = Vec::new();
for directive in input.directives {
if directive.directive_type == "transaction"
&& let DirectiveData::Transaction(ref txn) = directive.data
&& has_effective_date_posting(txn)
{
interesting_entries.push(directive);
continue;
}
if earliest_date.is_none() || directive.date < *earliest_date.as_ref().unwrap() {
earliest_date = Some(directive.date.clone());
}
filtered_entries.push(directive);
}
let mut new_entries = Vec::new();
for mut directive in interesting_entries {
if earliest_date.is_none() || directive.date < *earliest_date.as_ref().unwrap() {
earliest_date = Some(directive.date.clone());
}
let link = generate_link(&directive.date);
if let DirectiveData::Transaction(ref mut txn) = directive.data {
if !txn.links.contains(&link) {
txn.links.push(link.clone());
}
let entry_date = directive.date.clone();
let mut modified_postings = Vec::new();
for posting in &txn.postings {
if let Some(effective_date) = get_effective_date(posting) {
let (hold_account, _is_later) = find_holding_account(
&posting.account,
&effective_date,
&entry_date,
&holding_accounts,
);
if let Some(hold_acct) = hold_account {
let new_account = posting.account.replace(
&find_account_prefix(&posting.account, &holding_accounts),
&hold_acct,
);
new_accounts.insert(new_account.clone());
let mut modified_posting = posting.clone();
modified_posting.account.clone_from(&new_account);
modified_posting
.metadata
.retain(|(k, _)| k != "effective_date");
let hold_posting = create_opposite_posting(&modified_posting);
modified_postings.push(modified_posting);
let mut cleaned_original = posting.clone();
cleaned_original
.metadata
.retain(|(k, _)| k != "effective_date");
let new_txn = TransactionData {
flag: txn.flag.clone(),
payee: txn.payee.clone(),
narration: txn.narration.clone(),
tags: txn.tags.clone(),
links: vec![link.clone()],
metadata: vec![(
"original_date".to_string(),
MetaValueData::Date(entry_date.clone()),
)],
postings: vec![hold_posting, cleaned_original],
};
new_entries.push(DirectiveWrapper {
directive_type: "transaction".to_string(),
date: effective_date,
filename: directive.filename.clone(),
lineno: directive.lineno,
data: DirectiveData::Transaction(new_txn),
});
} else {
modified_postings.push(posting.clone());
}
} else {
modified_postings.push(posting.clone());
}
}
txn.postings = modified_postings;
}
new_entries.push(directive);
}
let mut open_directives: Vec<DirectiveWrapper> = Vec::new();
if let Some(date) = &earliest_date {
for account in &new_accounts {
open_directives.push(DirectiveWrapper {
directive_type: "open".to_string(),
date: date.clone(),
filename: Some("<effective_date>".to_string()),
lineno: Some(0),
data: DirectiveData::Open(OpenData {
account: account.clone(),
currencies: vec![],
booking: None,
metadata: vec![],
}),
});
}
}
new_entries.sort_by(|a, b| a.date.cmp(&b.date));
let mut all_directives = open_directives;
all_directives.extend(new_entries);
all_directives.extend(filtered_entries);
PluginOutput {
directives: all_directives,
errors: Vec::new(),
}
}
}
fn has_effective_date_posting(txn: &TransactionData) -> bool {
txn.postings.iter().any(|p| {
p.metadata
.iter()
.any(|(k, v)| k == "effective_date" && matches!(v, MetaValueData::Date(_)))
})
}
fn get_effective_date(posting: &PostingData) -> Option<String> {
for (key, value) in &posting.metadata {
if key == "effective_date"
&& let MetaValueData::Date(d) = value
{
return Some(d.clone());
}
}
None
}
fn find_holding_account(
account: &str,
effective_date: &str,
entry_date: &str,
holding_accounts: &HashMap<String, (String, String)>,
) -> (Option<String>, bool) {
for (prefix, (earlier, later)) in holding_accounts {
if account.starts_with(prefix) {
let is_later = effective_date > entry_date;
let hold_acct = if is_later { later } else { earlier };
return (Some(hold_acct.clone()), is_later);
}
}
(None, false)
}
fn find_account_prefix(
account: &str,
holding_accounts: &HashMap<String, (String, String)>,
) -> String {
for prefix in holding_accounts.keys() {
if account.starts_with(prefix) {
return prefix.clone();
}
}
String::new()
}
fn create_opposite_posting(posting: &PostingData) -> PostingData {
let mut opposite = posting.clone();
if let Some(ref units) = opposite.units {
let number = if units.number.starts_with('-') {
units.number[1..].to_string()
} else {
format!("-{}", units.number)
};
opposite.units = Some(AmountData {
number,
currency: units.currency.clone(),
});
}
opposite
}
static LINK_COUNTER: AtomicUsize = AtomicUsize::new(0);
fn generate_link(date: &str) -> String {
let date_short = date.replace('-', "");
let date_short = if date_short.len() > 6 {
&date_short[2..]
} else {
&date_short
};
let counter = LINK_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("edate-{}-{:03x}", date_short, counter % 4096)
}
fn parse_config(config: &str) -> Result<HashMap<String, (String, String)>, String> {
let mut result = HashMap::new();
for cap in HOLDING_ACCOUNT_RE.captures_iter(config) {
let prefix = cap[1].to_string();
let earlier = cap[2].to_string();
let later = cap[3].to_string();
result.insert(prefix, (earlier, later));
}
if result.is_empty() {
return Err("No holding accounts found in config".to_string());
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::*;
fn create_test_transaction_with_effective_date(
date: &str,
effective_date: &str,
) -> DirectiveWrapper {
DirectiveWrapper {
directive_type: "transaction".to_string(),
date: date.to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: "Test with effective date".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Cash".to_string(),
units: Some(AmountData {
number: "-100.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
PostingData {
account: "Expenses:Food".to_string(),
units: Some(AmountData {
number: "100.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![(
"effective_date".to_string(),
MetaValueData::Date(effective_date.to_string()),
)],
},
],
}),
}
}
#[test]
fn test_effective_date_later() {
let plugin = EffectiveDatePlugin;
let input = PluginInput {
directives: vec![create_test_transaction_with_effective_date(
"2024-01-15",
"2024-02-01",
)],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: None,
};
let output = plugin.process(input);
assert_eq!(output.errors.len(), 0);
assert!(output.directives.len() >= 2);
let effective_txn_count = output
.directives
.iter()
.filter(|d| d.date == "2024-02-01" && d.directive_type == "transaction")
.count();
assert_eq!(effective_txn_count, 1);
}
#[test]
fn test_effective_date_earlier() {
let plugin = EffectiveDatePlugin;
let input = PluginInput {
directives: vec![create_test_transaction_with_effective_date(
"2024-02-01",
"2024-01-15",
)],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: None,
};
let output = plugin.process(input);
assert_eq!(output.errors.len(), 0);
let effective_txn_count = output
.directives
.iter()
.filter(|d| d.date == "2024-01-15" && d.directive_type == "transaction")
.count();
assert_eq!(effective_txn_count, 1);
}
#[test]
fn test_no_effective_date_unchanged() {
let plugin = EffectiveDatePlugin;
let input = PluginInput {
directives: vec![DirectiveWrapper {
directive_type: "transaction".to_string(),
date: "2024-01-15".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: "Regular transaction".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Cash".to_string(),
units: Some(AmountData {
number: "-100.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
PostingData {
account: "Expenses:Food".to_string(),
units: Some(AmountData {
number: "100.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
}],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: None,
};
let output = plugin.process(input);
assert_eq!(output.errors.len(), 0);
let txn_count = output
.directives
.iter()
.filter(|d| d.directive_type == "transaction")
.count();
assert_eq!(txn_count, 1);
}
}