use rustledger_plugin::native::{
AutoTagPlugin, BoxAccrualPlugin, CapitalGainsGainLossPlugin, CapitalGainsLongShortPlugin,
CheckAverageCostPlugin, CheckCommodityPlugin, CheckDrainedPlugin, CommodityAttrPlugin,
CurrencyAccountsPlugin, EffectiveDatePlugin, ForecastPlugin, GenerateBaseCcyPricesPlugin,
ImplicitPricesPlugin, LeafOnlyPlugin, NativePlugin, NativePluginRegistry, NoDuplicatesPlugin,
NoUnusedPlugin, OneCommodityPlugin, PedanticPlugin, RenameAccountsPlugin, RxTxnPlugin,
SellGainsPlugin, SplitExpensesPlugin, UniquePricesPlugin, UnrealizedPlugin, ZerosumPlugin,
};
use rustledger_plugin::test_helpers::materialize_ops;
use rustledger_plugin::types::*;
struct ProcessedOutput {
directives: Vec<DirectiveWrapper>,
errors: Vec<PluginError>,
}
#[allow(dead_code)]
fn process_and_materialize<P: NativePlugin + ?Sized>(
plugin: &P,
input: PluginInput,
) -> ProcessedOutput {
let input_dirs = input.directives.clone();
let out = plugin.process(input);
let directives = materialize_ops(&input_dirs, &out);
ProcessedOutput {
directives,
errors: out.errors,
}
}
fn make_input(directives: Vec<DirectiveWrapper>) -> PluginInput {
PluginInput {
directives,
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: None,
}
}
fn make_open(date: &str, account: &str) -> DirectiveWrapper {
DirectiveWrapper {
directive_type: "open".to_string(),
date: date.to_string(),
filename: None,
lineno: None,
data: DirectiveData::Open(OpenData {
account: account.to_string(),
currencies: vec![],
booking: None,
metadata: vec![],
}),
}
}
fn make_transaction(
date: &str,
narration: &str,
postings: Vec<(&str, &str, &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: narration.to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: postings
.into_iter()
.map(|(account, number, currency)| PostingData {
account: account.to_string(),
units: Some(AmountData {
number: number.to_string(),
currency: currency.to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
})
.collect(),
}),
}
}
fn make_transaction_with_cost(
date: &str,
narration: &str,
account: &str,
units: (&str, &str),
cost: (&str, &str),
other_account: &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: narration.to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: account.to_string(),
units: Some(AmountData {
number: units.0.to_string(),
currency: units.1.to_string(),
}),
cost: Some(CostData {
number_per: Some(cost.0.to_string()),
number_total: None,
currency: Some(cost.1.to_string()),
date: None,
label: None,
merge: false,
}),
price: None,
flag: None,
metadata: vec![],
},
PostingData {
account: other_account.to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
}
}
fn make_price(date: &str, currency: &str, amount: &str, quote_currency: &str) -> DirectiveWrapper {
DirectiveWrapper {
directive_type: "price".to_string(),
date: date.to_string(),
filename: None,
lineno: None,
data: DirectiveData::Price(PriceData {
currency: currency.to_string(),
amount: AmountData {
number: amount.to_string(),
currency: quote_currency.to_string(),
},
metadata: vec![],
}),
}
}
fn make_transaction_with_cost_and_price_total(
date: &str,
narration: &str,
account: &str,
units: (&str, &str),
cost: (&str, &str),
price_total: (&str, &str),
other_account: &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: narration.to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: account.to_string(),
units: Some(AmountData {
number: units.0.to_string(),
currency: units.1.to_string(),
}),
cost: Some(CostData {
number_per: Some(cost.0.to_string()),
number_total: None,
currency: Some(cost.1.to_string()),
date: None,
label: None,
merge: false,
}),
price: Some(PriceAnnotationData {
is_total: true, amount: Some(AmountData {
number: price_total.0.to_string(),
currency: price_total.1.to_string(),
}),
number: None,
currency: None,
}),
flag: None,
metadata: vec![],
},
PostingData {
account: other_account.to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
}
}
fn make_transaction_with_cost_and_price(
date: &str,
narration: &str,
account: &str,
units: (&str, &str),
cost: (&str, &str),
price: (&str, &str),
other_account: &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: narration.to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: account.to_string(),
units: Some(AmountData {
number: units.0.to_string(),
currency: units.1.to_string(),
}),
cost: Some(CostData {
number_per: Some(cost.0.to_string()),
number_total: None,
currency: Some(cost.1.to_string()),
date: None,
label: None,
merge: false,
}),
price: Some(PriceAnnotationData {
is_total: false,
amount: Some(AmountData {
number: price.0.to_string(),
currency: price.1.to_string(),
}),
number: None,
currency: None,
}),
flag: None,
metadata: vec![],
},
PostingData {
account: other_account.to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
}
}
fn make_commodity(date: &str, currency: &str) -> DirectiveWrapper {
DirectiveWrapper {
directive_type: "commodity".to_string(),
date: date.to_string(),
filename: None,
lineno: None,
data: DirectiveData::Commodity(CommodityData {
currency: currency.to_string(),
metadata: vec![],
}),
}
}
#[test]
fn test_leafonly_error_on_parent_account() {
let plugin = LeafOnlyPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Expenses:Food"),
make_open("2024-01-01", "Expenses:Food:Restaurant"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction(
"2024-01-15",
"Good lunch",
vec![
("Expenses:Food:Restaurant", "25.00", "USD"),
("Assets:Cash", "-25.00", "USD"),
],
),
make_transaction(
"2024-01-16",
"Bad posting to parent",
vec![
("Expenses:Food", "30.00", "USD"),
("Assets:Cash", "-30.00", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"expected 1 error for parent posting"
);
assert!(
output.errors[0].message.contains("Expenses:Food"),
"error should mention the parent account"
);
}
#[test]
fn test_leafonly_ok_on_leaf_accounts() {
let plugin = LeafOnlyPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Expenses:Food"),
make_open("2024-01-01", "Expenses:Food:Restaurant"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction(
"2024-01-15",
"Lunch",
vec![
("Expenses:Food:Restaurant", "25.00", "USD"),
("Assets:Cash", "-25.00", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty(), "expected no errors");
}
#[test]
fn test_noduplicates_transaction() {
let plugin = NoDuplicatesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"Grocery Store",
vec![
("Expenses:Food", "50.00", "USD"),
("Assets:Bank", "-50.00", "USD"),
],
),
make_transaction(
"2024-01-15",
"Grocery Store",
vec![
("Expenses:Food", "50.00", "USD"),
("Assets:Bank", "-50.00", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 1, "expected 1 duplicate error");
assert!(
output.errors[0].message.contains("Duplicate"),
"error should mention duplicate"
);
}
#[test]
fn test_noduplicates_ok_different_amounts() {
let plugin = NoDuplicatesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"Grocery Store",
vec![
("Expenses:Food", "50.00", "USD"),
("Assets:Bank", "-50.00", "USD"),
],
),
make_transaction(
"2024-01-15",
"Grocery Store",
vec![
("Expenses:Food", "75.00", "USD"),
("Assets:Bank", "-75.00", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty(), "expected no errors");
}
#[test]
fn test_noduplicates_distinct_links_are_not_duplicates() {
let plugin = NoDuplicatesPlugin;
let mut txn_a = make_transaction(
"2024-06-11",
"ATM Withdrawal",
vec![
("Assets:Checking:Test", "-100.00", "USD"),
("Expenses:ATM", "100.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_a.data {
t.links = vec!["stmt-2024-06-seq1".to_string()];
}
let mut txn_b = make_transaction(
"2024-06-11",
"ATM Withdrawal",
vec![
("Assets:Checking:Test", "-100.00", "USD"),
("Expenses:ATM", "100.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_b.data {
t.links = vec!["stmt-2024-06-seq2".to_string()];
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Checking:Test"),
make_open("2024-01-01", "Expenses:ATM"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"distinct ^link values should disambiguate otherwise-identical transactions, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_distinct_tags_are_not_duplicates() {
let plugin = NoDuplicatesPlugin;
let mut txn_a = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "5.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_a.data {
t.tags = vec!["morning".to_string()];
}
let mut txn_b = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "5.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_b.data {
t.tags = vec!["afternoon".to_string()];
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"distinct tags should disambiguate otherwise-identical transactions, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_duplicate_tags_collapse_to_set() {
let plugin = NoDuplicatesPlugin;
let mut txn_a = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "5.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_a.data {
t.tags = vec!["morning".to_string(), "morning".to_string()];
}
let mut txn_b = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "5.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_b.data {
t.tags = vec!["morning".to_string()];
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"a tag repeated in the Vec must collapse to a set member and hash \
equal to a single occurrence, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_tag_link_boundary_no_collision() {
let plugin = NoDuplicatesPlugin;
let mut txn_a = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "5.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_a.data {
t.tags = vec!["a".to_string(), "b".to_string()];
t.links = vec![];
}
let mut txn_b = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "5.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_b.data {
t.tags = vec!["a".to_string()];
t.links = vec!["b".to_string()];
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"tags=[a,b] with no links must NOT collide with tags=[a] links=[b], \
got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_tag_order_independent() {
let plugin = NoDuplicatesPlugin;
let mut txn_a = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "5.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_a.data {
t.tags = vec!["morning".to_string(), "caffeine".to_string()];
}
let mut txn_b = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "5.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_b.data {
t.tags = vec!["caffeine".to_string(), "morning".to_string()];
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"reordered but identical tag sets should hash equal and be flagged as duplicate, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_distinct_costs_are_not_duplicates() {
let plugin = NoDuplicatesPlugin;
let txn_a = make_transaction_with_cost(
"2024-01-15",
"Buy stock",
"Assets:Stock",
("10", "AAPL"),
("150.00", "USD"),
"Assets:Cash",
);
let txn_b = make_transaction_with_cost(
"2024-01-15",
"Buy stock",
"Assets:Stock",
("10", "AAPL"),
("160.00", "USD"), "Assets:Cash",
);
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"distinct cost specs should disambiguate otherwise-identical transactions, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_distinct_prices_are_not_duplicates() {
let plugin = NoDuplicatesPlugin;
let txn_a = make_transaction_with_price(
"2024-01-15",
"Sell stock",
"Assets:Stock",
("-5", "AAPL"),
("200.00", "USD"),
"Assets:Cash",
);
let txn_b = make_transaction_with_price(
"2024-01-15",
"Sell stock",
"Assets:Stock",
("-5", "AAPL"),
("210.00", "USD"), "Assets:Cash",
);
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"distinct prices should disambiguate otherwise-identical transactions, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_metadata_differences_are_still_duplicates() {
use rustledger_plugin_types::MetaValueData;
let plugin = NoDuplicatesPlugin;
let mut txn_a = make_transaction(
"2024-01-15",
"Grocery Store",
vec![
("Expenses:Food", "50.00", "USD"),
("Assets:Bank", "-50.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_a.data {
t.metadata = vec![(
"reference".to_string(),
MetaValueData::String("A".to_string()),
)];
}
let mut txn_b = make_transaction(
"2024-01-15",
"Grocery Store",
vec![
("Expenses:Food", "50.00", "USD"),
("Assets:Bank", "-50.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_b.data {
t.metadata = vec![(
"reference".to_string(),
MetaValueData::String("B".to_string()),
)];
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"metadata-only differences must not disambiguate (matches beancount \
exclude_meta=True), got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_distinct_flags_are_not_duplicates() {
let plugin = NoDuplicatesPlugin;
let mut txn_a = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "5.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_a.data {
t.flag = "*".to_string();
}
let mut txn_b = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "5.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_b.data {
t.flag = "!".to_string();
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"distinct flags should disambiguate otherwise-identical transactions, got: {:?}",
output.errors
);
}
fn make_txn_with<F: FnOnce(&mut TransactionData)>(
date: &str,
narration: &str,
postings: Vec<(&str, &str, &str)>,
mutate: F,
) -> DirectiveWrapper {
let mut wrapper = make_transaction(date, narration, postings);
if let DirectiveData::Transaction(t) = &mut wrapper.data {
mutate(t);
}
wrapper
}
#[test]
fn test_noduplicates_distinct_dates_are_not_duplicates() {
let plugin = NoDuplicatesPlugin;
let postings = vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
];
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction("2024-01-15", "Coffee", postings.clone()),
make_transaction("2024-01-16", "Coffee", postings),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"different dates must not collide, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_distinct_narration_are_not_duplicates() {
let plugin = NoDuplicatesPlugin;
let postings = vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
];
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction("2024-01-15", "Coffee", postings.clone()),
make_transaction("2024-01-15", "Lunch", postings),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"different narration must not collide, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_distinct_payees_are_not_duplicates() {
let plugin = NoDuplicatesPlugin;
let postings = vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
];
let txn_a = make_txn_with("2024-01-15", "Coffee", postings.clone(), |t| {
t.payee = Some("Starbucks".to_string());
});
let txn_b = make_txn_with("2024-01-15", "Coffee", postings, |t| {
t.payee = Some("Blue Bottle".to_string());
});
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"different payees must not collide, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_none_vs_empty_payee_differ() {
let plugin = NoDuplicatesPlugin;
let postings = vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
];
let txn_a = make_txn_with("2024-01-15", "Coffee", postings.clone(), |t| {
t.payee = None;
});
let txn_b = make_txn_with("2024-01-15", "Coffee", postings, |t| {
t.payee = Some(String::new());
});
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"None payee must not collide with Some(\"\"), got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_link_order_independent() {
let plugin = NoDuplicatesPlugin;
let postings = vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
];
let txn_a = make_txn_with("2024-01-15", "Coffee", postings.clone(), |t| {
t.links = vec!["stmt-a".to_string(), "stmt-b".to_string()];
});
let txn_b = make_txn_with("2024-01-15", "Coffee", postings, |t| {
t.links = vec!["stmt-b".to_string(), "stmt-a".to_string()];
});
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"reordered link sets should hash equal, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_empty_vs_absent_tags_are_duplicates() {
let plugin = NoDuplicatesPlugin;
let postings = vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
];
let txn_a = make_transaction("2024-01-15", "Coffee", postings.clone());
let txn_b = make_txn_with("2024-01-15", "Coffee", postings, |t| {
t.tags = vec![];
t.links = vec![];
});
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"empty tags/links must hash equal to absent tags/links, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_distinct_accounts_are_not_duplicates() {
let plugin = NoDuplicatesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "5.00", "USD"),
],
),
make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Cash", "-5.00", "USD"), ("Expenses:Food", "5.00", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"different account must not collide, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_distinct_posting_count_are_not_duplicates() {
let plugin = NoDuplicatesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_open("2024-01-01", "Expenses:Fee"),
make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "5.00", "USD"),
],
),
make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "4.50", "USD"),
("Expenses:Fee", "0.50", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"different posting counts must not collide, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_reordered_postings_are_not_duplicates() {
let plugin = NoDuplicatesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"Coffee",
vec![
("Assets:Bank", "-5.00", "USD"),
("Expenses:Food", "5.00", "USD"),
],
),
make_transaction(
"2024-01-15",
"Coffee",
vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"reordered postings must not collide (postings are an ordered list in \
beancount), got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_none_vs_some_units_differ() {
let plugin = NoDuplicatesPlugin;
let txn_a = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
],
);
let mut txn_b = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_b.data {
t.postings[1].units = None;
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"None units must not collide with Some units, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_cost_with_date_differs() {
let plugin = NoDuplicatesPlugin;
let mut txn_a = make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("150.00", "USD"),
"Assets:Cash",
);
let mut txn_b = make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("150.00", "USD"),
"Assets:Cash",
);
if let DirectiveData::Transaction(t) = &mut txn_b.data
&& let Some(cost) = &mut t.postings[0].cost
{
cost.date = Some("2024-01-10".to_string());
}
let _ = &mut txn_a;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"cost with date must not collide with cost without date, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_cost_with_label_differs() {
let plugin = NoDuplicatesPlugin;
let txn_a = make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("150.00", "USD"),
"Assets:Cash",
);
let mut txn_b = make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("150.00", "USD"),
"Assets:Cash",
);
if let DirectiveData::Transaction(t) = &mut txn_b.data
&& let Some(cost) = &mut t.postings[0].cost
{
cost.label = Some("lot-42".to_string());
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"cost with label must not collide with cost without label, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_total_vs_per_unit_cost_differ() {
let plugin = NoDuplicatesPlugin;
let txn_a = make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("150.00", "USD"), "Assets:Cash",
);
let mut txn_b = make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("150.00", "USD"),
"Assets:Cash",
);
if let DirectiveData::Transaction(t) = &mut txn_b.data
&& let Some(cost) = &mut t.postings[0].cost
{
cost.number_per = None;
cost.number_total = Some("1500.00".to_string());
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"per-unit cost must not collide with total cost, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_unit_vs_total_price_differ() {
let plugin = NoDuplicatesPlugin;
let mut txn_a = make_transaction(
"2024-01-15",
"Sell",
vec![
("Assets:Stock", "-5", "AAPL"),
("Assets:Cash", "875.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_a.data {
t.postings[0].price = Some(PriceAnnotationData {
is_total: false,
amount: Some(AmountData {
number: "175.00".to_string(),
currency: "USD".to_string(),
}),
number: None,
currency: None,
});
}
let mut txn_b = make_transaction(
"2024-01-15",
"Sell",
vec![
("Assets:Stock", "-5", "AAPL"),
("Assets:Cash", "875.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_b.data {
t.postings[0].price = Some(PriceAnnotationData {
is_total: true, amount: Some(AmountData {
number: "875.00".to_string(),
currency: "USD".to_string(),
}),
number: None,
currency: None,
});
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"`@` and `@@` prices must not collide, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_incomplete_vs_complete_price_differ() {
let plugin = NoDuplicatesPlugin;
let mut txn_a = make_transaction(
"2024-01-15",
"Sell",
vec![
("Assets:Stock", "-5", "AAPL"),
("Assets:Cash", "0.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_a.data {
t.postings[0].price = Some(PriceAnnotationData {
is_total: false,
amount: None,
number: None,
currency: Some("USD".to_string()),
});
}
let mut txn_b = make_transaction(
"2024-01-15",
"Sell",
vec![
("Assets:Stock", "-5", "AAPL"),
("Assets:Cash", "0.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_b.data {
t.postings[0].price = Some(PriceAnnotationData {
is_total: false,
amount: Some(AmountData {
number: "175.00".to_string(),
currency: "USD".to_string(),
}),
number: None,
currency: None,
});
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"incomplete and complete prices must not collide, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_distinct_posting_flags_differ() {
let plugin = NoDuplicatesPlugin;
let txn_a = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
],
);
let mut txn_b = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_b.data {
t.postings[0].flag = Some("!".to_string());
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"distinct posting flags must not collide, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_posting_metadata_does_not_disambiguate() {
use rustledger_plugin_types::MetaValueData;
let plugin = NoDuplicatesPlugin;
let txn_a = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
],
);
let mut txn_b = make_transaction(
"2024-01-15",
"Coffee",
vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
],
);
if let DirectiveData::Transaction(t) = &mut txn_b.data {
t.postings[0].metadata =
vec![("ref".to_string(), MetaValueData::String("abc".to_string()))];
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"posting-level metadata must not disambiguate (exclude_meta=True), \
got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_three_identical_reports_two_duplicates() {
let plugin = NoDuplicatesPlugin;
let postings = vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
];
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction("2024-01-15", "Coffee", postings.clone()),
make_transaction("2024-01-15", "Coffee", postings.clone()),
make_transaction("2024-01-15", "Coffee", postings),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
2,
"three identical transactions should produce two duplicate errors, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_ignores_non_transaction_directives() {
let plugin = NoDuplicatesPlugin;
let postings = vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
];
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction("2024-01-15", "Coffee", postings.clone()),
make_open("2024-02-01", "Assets:Savings"),
make_transaction("2024-01-15", "Coffee", postings),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"only the two real transaction duplicates should be flagged \
(non-transaction directives ignored), got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_empty_postings_edge_case() {
let plugin = NoDuplicatesPlugin;
let txn_a = make_txn_with("2024-01-15", "placeholder", vec![], |_| {});
let txn_b = make_txn_with("2024-01-15", "placeholder", vec![], |_| {});
let input = make_input(vec![txn_a, txn_b]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"two empty-posting transactions should hash equal and be flagged, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_detects_duplicates_across_distance() {
let plugin = NoDuplicatesPlugin;
let target_postings = vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
];
let mut directives = vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction("2024-01-15", "Coffee", target_postings.clone()),
];
for day in 16..=65 {
directives.push(make_transaction(
&format!("2024-01-{day:02}"),
"Distinct",
vec![
("Expenses:Food", &format!("{day}.00"), "USD"),
("Assets:Bank", &format!("-{day}.00"), "USD"),
],
));
}
directives.push(make_transaction("2024-01-15", "Coffee", target_postings));
let input = make_input(directives);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"duplicates should be detected regardless of distance in the \
directive stream, got: {:?}",
output.errors
);
}
#[test]
fn test_noduplicates_source_location_not_part_of_identity() {
let plugin = NoDuplicatesPlugin;
let postings = vec![
("Expenses:Food", "5.00", "USD"),
("Assets:Bank", "-5.00", "USD"),
];
let mut txn_a = make_transaction("2024-01-15", "Coffee", postings.clone());
txn_a.filename = Some("a.beancount".to_string());
txn_a.lineno = Some(10);
let mut txn_b = make_transaction("2024-01-15", "Coffee", postings);
txn_b.filename = Some("b.beancount".to_string());
txn_b.lineno = Some(42);
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
txn_a,
txn_b,
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"source filename/lineno must not influence the hash, got: {:?}",
output.errors
);
}
#[test]
fn test_onecommodity_error_multiple_currencies() {
let plugin = OneCommodityPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Expenses:Restaurant"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction(
"2024-01-15",
"Lunch",
vec![
("Expenses:Restaurant", "25.00", "USD"),
("Assets:Cash", "-25.00", "USD"),
],
),
make_transaction(
"2024-01-16",
"Dinner",
vec![
("Expenses:Restaurant", "30.00", "CAD"),
("Assets:Cash", "-30.00", "CAD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
2,
"expected 2 errors for mixed currencies (one per account)"
);
let error_text: String = output.errors.iter().map(|e| e.message.clone()).collect();
assert!(
error_text.contains("USD") && error_text.contains("CAD"),
"errors should mention both currencies"
);
}
#[test]
fn test_onecommodity_ok_single_currency() {
let plugin = OneCommodityPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Expenses:Restaurant"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction(
"2024-01-15",
"Lunch",
vec![
("Expenses:Restaurant", "25.00", "USD"),
("Assets:Cash", "-25.00", "USD"),
],
),
make_transaction(
"2024-01-16",
"Dinner",
vec![
("Expenses:Restaurant", "30.00", "USD"),
("Assets:Cash", "-30.00", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty(), "expected no errors");
}
#[test]
fn test_onecommodity_empty_input() {
let plugin = OneCommodityPlugin;
let output = process_and_materialize(&plugin, make_input(vec![]));
assert_eq!(output.errors.len(), 0);
}
#[test]
fn test_onecommodity_skips_auto_balanced_posting() {
let plugin = OneCommodityPlugin;
let txn_with_none_posting = 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: "Auto-balanced".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Cash".to_string(),
units: Some(AmountData {
number: "-10.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
PostingData {
account: "Expenses:Misc".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
};
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Misc"),
txn_with_none_posting,
make_transaction(
"2024-01-16",
"EUR follow-up",
vec![("Expenses:Misc", "20.00", "EUR")],
),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
0,
"None posting should be skipped (no currency recorded for that account); got: {:?}",
output.errors
);
}
#[test]
fn test_onecommodity_independent_accounts_ok() {
let plugin = OneCommodityPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:USD"),
make_open("2024-01-01", "Assets:EUR"),
make_open("2024-01-01", "Equity:Open"),
make_transaction(
"2024-01-15",
"USD deposit",
vec![
("Assets:USD", "100.00", "USD"),
("Equity:Open", "-100.00", "USD"),
],
),
make_transaction(
"2024-01-15",
"EUR deposit",
vec![
("Assets:EUR", "50.00", "EUR"),
("Equity:Open", "-50.00", "EUR"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 1);
assert!(output.errors[0].message.contains("Equity:Open"));
}
#[test]
fn test_onecommodity_three_currencies_cascade() {
let plugin = OneCommodityPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Mixed"),
make_transaction("2024-01-15", "USD", vec![("Assets:Mixed", "100.00", "USD")]),
make_transaction("2024-01-16", "EUR", vec![("Assets:Mixed", "50.00", "EUR")]),
make_transaction("2024-01-17", "GBP", vec![("Assets:Mixed", "30.00", "GBP")]),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 2, "got: {:?}", output.errors);
let messages: Vec<_> = output.errors.iter().map(|e| e.message.as_str()).collect();
assert!(
messages.iter().any(|m| m.contains("USD and EUR")),
"expected literal `USD and EUR` pairing in: {messages:?}"
);
assert!(
messages.iter().any(|m| m.contains("USD and GBP")),
"expected literal `USD and GBP` pairing in: {messages:?}"
);
for m in &messages {
assert!(
m.contains("Assets:Mixed"),
"every error should name Assets:Mixed: {m}"
);
}
}
#[test]
fn test_onecommodity_ignores_non_transaction_directives() {
let plugin = OneCommodityPlugin;
let input = make_input(vec![
make_commodity("2024-01-01", "USD"),
make_commodity("2024-01-01", "EUR"),
make_open("2024-01-01", "Assets:Cash"),
make_price("2024-01-15", "USD", "0.85", "EUR"),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 0);
}
#[test]
fn test_check_commodity_undeclared() {
let plugin = CheckCommodityPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"Groceries",
vec![
("Expenses:Food", "50.00", "USD"),
("Assets:Bank", "-50.00", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"exactly one warning for the single undeclared currency"
);
assert!(
output.errors[0].message.contains("USD"),
"warning should mention USD"
);
}
#[test]
fn test_check_commodity_declared_ok() {
let plugin = CheckCommodityPlugin;
let input = make_input(vec![
make_commodity("2024-01-01", "USD"),
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"Groceries",
vec![
("Expenses:Food", "50.00", "USD"),
("Assets:Bank", "-50.00", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
let has_usd_warning = output.errors.iter().any(|e| e.message.contains("USD"));
assert!(!has_usd_warning, "should not warn about declared USD");
}
#[test]
fn test_check_commodity_empty_input() {
let plugin = CheckCommodityPlugin;
let output = process_and_materialize(&plugin, make_input(vec![]));
assert_eq!(output.errors.len(), 0);
assert_eq!(output.directives.len(), 0);
}
#[test]
fn test_check_commodity_severity_is_warning() {
let plugin = CheckCommodityPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"Lunch",
vec![
("Expenses:Food", "10.00", "USD"),
("Assets:Bank", "-10.00", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 1);
assert_eq!(
output.errors[0].severity,
PluginErrorSeverity::Warning,
"check_commodity diagnostics must be warnings"
);
}
#[test]
fn test_check_commodity_passthrough_unchanged() {
let plugin = CheckCommodityPlugin;
let input_directives = vec![
make_commodity("2024-01-01", "USD"),
make_open("2024-01-01", "Assets:Bank"),
make_transaction("2024-01-15", "Test", vec![("Assets:Bank", "10.00", "USD")]),
];
let input = make_input(input_directives.clone());
let output = process_and_materialize(&plugin, input);
assert_eq!(output.directives.len(), input_directives.len());
for (a, b) in output.directives.iter().zip(input_directives.iter()) {
assert_eq!(a.directive_type, b.directive_type);
assert_eq!(a.date, b.date);
}
}
#[test]
fn test_check_commodity_undeclared_cost_currency() {
let plugin = CheckCommodityPlugin;
let input = make_input(vec![
make_commodity("2024-01-01", "HOOL"),
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Equity:Open"),
DirectiveWrapper {
directive_type: "transaction".to_string(),
date: "2024-02-01".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: "Buy with cost".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Brokerage".to_string(),
units: Some(AmountData {
number: "5".to_string(),
currency: "HOOL".to_string(),
}),
cost: Some(CostData {
number_per: Some("100.00".to_string()),
number_total: None,
currency: Some("USD".to_string()),
date: None,
label: None,
merge: false,
}),
price: None,
flag: None,
metadata: vec![],
},
PostingData {
account: "Equity:Open".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
},
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 1, "got: {:?}", output.errors);
assert!(output.errors[0].message.contains("USD"));
}
#[test]
fn test_check_commodity_cost_with_none_currency_skipped() {
let plugin = CheckCommodityPlugin;
let input = make_input(vec![
make_commodity("2024-01-01", "HOOL"),
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Equity:Open"),
DirectiveWrapper {
directive_type: "transaction".to_string(),
date: "2024-02-01".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: "Cost with no currency".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Brokerage".to_string(),
units: Some(AmountData {
number: "5".to_string(),
currency: "HOOL".to_string(),
}),
cost: Some(CostData {
number_per: Some("100.00".to_string()),
number_total: None,
currency: None,
date: None,
label: None,
merge: false,
}),
price: None,
flag: None,
metadata: vec![],
},
PostingData {
account: "Equity:Open".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
},
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 0, "got: {:?}", output.errors);
}
#[test]
fn test_check_commodity_undeclared_in_balance_directive() {
let plugin = CheckCommodityPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
DirectiveWrapper {
directive_type: "balance".to_string(),
date: "2024-01-15".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Balance(BalanceData {
account: "Assets:Bank".to_string(),
amount: AmountData {
number: "100.00".to_string(),
currency: "GBP".to_string(),
},
tolerance: None,
metadata: vec![],
}),
},
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 1);
assert!(output.errors[0].message.contains("GBP"));
}
#[test]
fn test_check_commodity_undeclared_in_price_directive() {
let plugin = CheckCommodityPlugin;
let input = make_input(vec![make_price("2024-01-15", "HOOL", "520.00", "USD")]);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 2, "got: {:?}", output.errors);
let messages: Vec<_> = output.errors.iter().map(|e| e.message.clone()).collect();
assert!(messages.iter().any(|m| m.contains("HOOL")));
assert!(messages.iter().any(|m| m.contains("USD")));
}
#[test]
fn test_check_commodity_dedupes_repeated_undeclared() {
let plugin = CheckCommodityPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"T1",
vec![
("Expenses:Food", "10.00", "USD"),
("Assets:Bank", "-10.00", "USD"),
],
),
make_transaction(
"2024-01-16",
"T2",
vec![
("Expenses:Food", "20.00", "USD"),
("Assets:Bank", "-20.00", "USD"),
],
),
make_transaction(
"2024-01-17",
"T3",
vec![
("Expenses:Food", "30.00", "USD"),
("Assets:Bank", "-30.00", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 1, "deduped to one warning");
assert!(output.errors[0].message.contains("USD"));
}
#[test]
fn test_check_commodity_mixed_declared_and_undeclared() {
let plugin = CheckCommodityPlugin;
let input = make_input(vec![
make_commodity("2024-01-01", "USD"),
make_open("2024-01-01", "Assets:USD"),
make_open("2024-01-01", "Assets:EUR"),
make_open("2024-01-01", "Assets:GBP"),
make_transaction("2024-01-15", "USD", vec![("Assets:USD", "10.00", "USD")]),
make_transaction("2024-01-16", "EUR", vec![("Assets:EUR", "20.00", "EUR")]),
make_transaction("2024-01-17", "GBP", vec![("Assets:GBP", "30.00", "GBP")]),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 2, "EUR and GBP undeclared, USD ok");
let messages: Vec<_> = output.errors.iter().map(|e| e.message.clone()).collect();
assert!(messages.iter().any(|m| m.contains("EUR")));
assert!(messages.iter().any(|m| m.contains("GBP")));
assert!(!messages.iter().any(|m| m.contains("USD")));
}
#[test]
fn test_unique_prices_duplicate_error() {
let plugin = UniquePricesPlugin;
let input = make_input(vec![
make_price("2024-01-15", "HOOL", "520.00", "USD"),
make_price("2024-01-15", "HOOL", "525.00", "USD"), ]);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 1, "expected 1 duplicate price error");
assert!(
output.errors[0].message.contains("Duplicate price"),
"error should mention duplicate"
);
}
#[test]
fn test_unique_prices_different_days_ok() {
let plugin = UniquePricesPlugin;
let input = make_input(vec![
make_price("2024-01-15", "HOOL", "520.00", "USD"),
make_price("2024-01-16", "HOOL", "525.00", "USD"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty(), "expected no errors");
}
#[test]
fn test_unique_prices_different_pairs_ok() {
let plugin = UniquePricesPlugin;
let input = make_input(vec![
make_price("2024-01-15", "HOOL", "520.00", "USD"),
make_price("2024-01-15", "GOOG", "150.00", "USD"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty(), "expected no errors");
}
fn implicit_prices_emitted(
input: &PluginInput,
output: &ProcessedOutput,
) -> Vec<(String, String, String)> {
fn extract(directives: &[DirectiveWrapper]) -> Vec<(String, String, String)> {
directives
.iter()
.filter(|d| d.directive_type == "price")
.filter_map(|d| match &d.data {
DirectiveData::Price(p) => Some((
p.currency.clone(),
p.amount.number.clone(),
p.amount.currency.clone(),
)),
_ => None,
})
.collect()
}
let input_prices = extract(&input.directives);
let mut output_prices = extract(&output.directives);
for ip in &input_prices {
if let Some(pos) = output_prices.iter().position(|p| p == ip) {
output_prices.remove(pos);
}
}
output_prices
}
fn make_txn_with_price_annotation(
date: &str,
narration: &str,
units: (&str, &str),
price: (&str, &str),
is_total: bool,
) -> 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: narration.to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Brokerage".to_string(),
units: Some(AmountData {
number: units.0.to_string(),
currency: units.1.to_string(),
}),
cost: None,
price: Some(PriceAnnotationData {
amount: Some(AmountData {
number: price.0.to_string(),
currency: price.1.to_string(),
}),
is_total,
number: None,
currency: None,
}),
flag: None,
metadata: vec![],
},
PostingData {
account: "Assets:Cash".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
}
}
#[test]
fn test_implicit_prices_from_cost() {
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost(
"2024-01-15",
"Buy stock",
"Assets:Brokerage",
("10", "HOOL"),
("520.00", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input.clone());
assert_eq!(
implicit_prices_emitted(&input, &output),
vec![("HOOL".into(), "520.00".into(), "USD".into())]
);
}
#[test]
fn test_implicit_prices_from_cost_total() {
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Assets:Cash"),
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: "Buy with total cost".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Brokerage".to_string(),
units: Some(AmountData {
number: "10".to_string(),
currency: "ABC".to_string(),
}),
cost: Some(CostData {
number_per: None,
number_total: Some("500".to_string()),
currency: Some("USD".to_string()),
date: None,
label: None,
merge: false,
}),
price: None,
flag: None,
metadata: vec![],
},
PostingData {
account: "Assets:Cash".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
},
]);
let output = process_and_materialize(&plugin, input.clone());
assert_eq!(
implicit_prices_emitted(&input, &output),
vec![("ABC".into(), "50".into(), "USD".into())],
"{{TOTAL CURRENCY}} cost spec must divide by units.abs()"
);
}
#[test]
fn test_implicit_prices_from_unit_annotation() {
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Assets:Cash"),
make_txn_with_price_annotation(
"2024-01-15",
"Sell at unit price",
("-5", "HOOL"),
("530", "USD"),
false, ),
]);
let output = process_and_materialize(&plugin, input.clone());
assert_eq!(
implicit_prices_emitted(&input, &output),
vec![("HOOL".into(), "530".into(), "USD".into())]
);
}
#[test]
fn test_implicit_prices_from_total_annotation_issue_992() {
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2020-01-01", "Assets:Insurance"),
make_txn_with_price_annotation(
"2025-01-23",
"insurance matured",
("-27204.53", "BAM"),
("15152.07", "EUR"),
true, ),
]);
let output = process_and_materialize(&plugin, input.clone());
let prices = implicit_prices_emitted(&input, &output);
assert_eq!(prices.len(), 1, "exactly one price per posting");
let (base, num_str, quote) = &prices[0];
assert_eq!(base, "BAM");
assert_eq!(quote, "EUR");
let parsed: rust_decimal::Decimal = num_str.parse().expect("price parses");
assert!(
parsed > rust_decimal_macros::dec!(0.55) && parsed < rust_decimal_macros::dec!(0.56),
"@@ total must be divided by units.abs(); got {num_str}"
);
}
#[test]
fn test_implicit_prices_annotation_and_cost_emits_one_not_two() {
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost_and_price(
"2024-01-15",
"Sell with both cost and price",
"Assets:Brokerage",
("-5", "ABC"),
("1.25", "EUR"), ("1.40", "EUR"), "Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input.clone());
let prices = implicit_prices_emitted(&input, &output);
assert_eq!(prices.len(), 1, "exactly one price (annotation wins)");
assert_eq!(
prices[0],
("ABC".into(), "1.40".into(), "EUR".into()),
"annotation amount wins over cost"
);
}
#[test]
fn test_implicit_prices_zero_unit_total_falls_through_to_cost_currency() {
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost_and_price_total(
"2024-01-15",
"Closing position with @@",
"Assets:Brokerage",
("0", "ABC"), ("50", "USD"),
("100", "EUR"), "Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input.clone());
let prices = implicit_prices_emitted(&input, &output);
assert_eq!(prices.len(), 1, "exactly one price");
assert_eq!(
prices[0],
("ABC".into(), "50".into(), "USD".into()),
"currency must come from the same source as the per-unit value (cost = USD), \
NOT the annotation (EUR). Pre-fix this returned (50, EUR)."
);
}
#[test]
fn test_implicit_prices_skips_reducing_sell_with_cost() {
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost(
"2024-02-01",
"Buy 100 X",
"Assets:Brokerage",
("100", "X"),
("1", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-03-01",
"Sell 50 X",
"Assets:Brokerage",
("-50", "X"),
("1", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input.clone());
let prices = implicit_prices_emitted(&input, &output);
assert_eq!(
prices,
vec![("X".into(), "1".into(), "USD".into())],
"exactly one implicit price (from the buy); the sell must not \
re-emit the same lot's cost as a price"
);
}
#[test]
fn test_implicit_prices_reducing_sell_with_annotation_emits_from_price() {
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost(
"2024-02-01",
"Buy",
"Assets:Brokerage",
("10", "X"),
("100", "USD"),
"Assets:Cash",
),
make_transaction_with_cost_and_price(
"2024-03-01",
"Sell with explicit price",
"Assets:Brokerage",
("-5", "X"),
("100", "USD"), ("110", "USD"), "Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input.clone());
let prices = implicit_prices_emitted(&input, &output);
assert_eq!(prices.len(), 2, "exactly two emits: buy-cost + sell-@");
assert!(prices.contains(&("X".into(), "100".into(), "USD".into())));
assert!(prices.contains(&("X".into(), "110".into(), "USD".into())));
}
#[test]
fn test_implicit_prices_dedup_same_day_same_price() {
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:RetirementA"),
make_open("2024-01-01", "Assets:RetirementB"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost(
"2024-02-01",
"Buy in account A",
"Assets:RetirementA",
("10", "FUND"),
("100", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-02-01",
"Buy in account B",
"Assets:RetirementB",
("20", "FUND"),
("100", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input.clone());
let prices = implicit_prices_emitted(&input, &output);
assert_eq!(
prices,
vec![("FUND".into(), "100".into(), "USD".into())],
"two same-day same-price buys should dedup to one emit"
);
}
#[test]
fn test_implicit_prices_reduced_gate_and_dedup_are_scale_insensitive() {
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost(
"2024-02-01",
"Buy",
"Assets:Brokerage",
("10", "X"),
("100.00", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-02-01",
"Buy more",
"Assets:Brokerage",
("5", "X"),
("100", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-03-01",
"Sell",
"Assets:Brokerage",
("-5", "X"),
("100", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input.clone());
let prices = implicit_prices_emitted(&input, &output);
assert_eq!(
prices.len(),
1,
"exactly one emit: the first buy. Second buy dedups, sell is \
REDUCED. Got: {prices:?}"
);
assert_eq!(prices[0].0, "X", "base currency carries through");
assert_eq!(prices[0].2, "USD", "quote currency carries through");
assert_eq!(
prices[0].1, "100.00",
"emitted price preserves the first-emit cost's intrinsic scale"
);
}
#[test]
fn test_implicit_prices_oversell_crossing_zero_emits_for_residual_leg() {
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost(
"2024-02-01",
"Buy",
"Assets:Brokerage",
("100", "X"),
("1", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-03-01",
"Reducing leg",
"Assets:Brokerage",
("-100", "X"),
("1", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-03-01",
"New-short leg",
"Assets:Brokerage",
("-50", "X"),
("2", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input.clone());
let prices = implicit_prices_emitted(&input, &output);
assert_eq!(
prices.len(),
2,
"buy + residual short leg emit; reducing leg suppressed. Got: {prices:?}"
);
assert!(prices.contains(&("X".into(), "1".into(), "USD".into())));
assert!(prices.contains(&("X".into(), "2".into(), "USD".into())));
}
#[test]
fn test_implicit_prices_emits_nothing_for_plain_transfer() {
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:A"),
make_open("2024-01-01", "Assets:B"),
make_transaction(
"2024-01-15",
"Plain transfer",
vec![("Assets:A", "100", "USD"), ("Assets:B", "-100", "USD")],
),
]);
let output = process_and_materialize(&plugin, input.clone());
assert!(implicit_prices_emitted(&input, &output).is_empty());
}
#[test]
fn test_implicit_prices_emitted_excludes_input_price_directives() {
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Assets:Cash"),
make_price("2024-01-10", "HOOL", "500.00", "USD"),
make_transaction_with_cost(
"2024-01-15",
"Buy stock",
"Assets:Brokerage",
("10", "HOOL"),
("520.00", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input.clone());
assert_eq!(
implicit_prices_emitted(&input, &output),
vec![("HOOL".into(), "520.00".into(), "USD".into())]
);
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(64))]
#[test]
fn prop_implicit_prices_emits_per_unit_for_both_annotation_forms(
units in 1u32..1000,
per_unit_cents in 1u32..1_000_000,
is_total in proptest::bool::ANY,
) {
use rust_decimal::Decimal;
use std::str::FromStr;
let units_d = Decimal::from(units);
let per_unit = Decimal::new(i64::from(per_unit_cents), 2);
let annotation_amount = if is_total { per_unit * units_d } else { per_unit };
let plugin = ImplicitPricesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Assets:Cash"),
make_txn_with_price_annotation(
"2024-01-15",
"Buy",
(&units.to_string(), "HOOL"),
(&annotation_amount.to_string(), "USD"),
is_total,
),
]);
let output = process_and_materialize(&plugin, input.clone());
let emitted = implicit_prices_emitted(&input, &output);
proptest::prop_assert_eq!(
emitted.len(), 1,
"expected 1 emitted price for units={} annotation={} is_total={}",
units, annotation_amount, is_total
);
let (currency, number_str, quote) = &emitted[0];
proptest::prop_assert_eq!(currency, "HOOL");
proptest::prop_assert_eq!(quote, "USD");
let emitted_d = Decimal::from_str(number_str)
.expect("emitted number must be a valid Decimal");
proptest::prop_assert_eq!(
emitted_d, per_unit,
"emitted {} must equal per-unit {} (is_total={})",
emitted_d, per_unit, is_total
);
}
}
#[test]
fn test_registry_finds_all_plugins() {
let registry = NativePluginRegistry::new();
let plugin_names = [
"implicit_prices",
"check_commodity",
"auto_accounts",
"leafonly",
"noduplicates",
"onecommodity",
"unique_prices",
"check_closing",
"close_tree",
"coherent_cost",
"sellgains",
"pedantic",
"unrealized",
];
for name in &plugin_names {
assert!(registry.find(name).is_some(), "should find plugin: {name}");
}
}
#[test]
fn test_registry_finds_with_beancount_prefix() {
let registry = NativePluginRegistry::new();
assert!(registry.find("beancount.plugins.leafonly").is_some());
assert!(registry.find("beancount.plugins.noduplicates").is_some());
}
#[test]
fn test_registry_list_all() {
let registry = NativePluginRegistry::new();
let plugins = registry.list();
assert!(plugins.len() >= 13, "should have at least 13 plugins");
}
#[test]
fn test_auto_accounts_generates_opens() {
use rustledger_plugin::types::*;
use rustledger_plugin::*;
let registry = NativePluginRegistry::new();
let plugin: &dyn NativePlugin = registry.find("auto_accounts").unwrap();
let input = PluginInput {
directives: vec![DirectiveWrapper {
directive_type: "transaction".to_string(),
date: "2020-01-01".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: "Test".to_string(),
tags: vec![],
links: vec![],
postings: vec![
PostingData {
account: "Expenses:Food".to_string(),
units: Some(AmountData {
number: "100".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
metadata: vec![],
flag: None,
},
PostingData {
account: "Assets:Cash".to_string(),
units: Some(AmountData {
number: "-100".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
metadata: vec![],
flag: None,
},
],
metadata: vec![],
}),
}],
options: PluginOptions::default(),
config: None,
};
let output = process_and_materialize(plugin, input);
eprintln!("Output directives: {}", output.directives.len());
for d in &output.directives {
eprintln!(" {}: {}", d.directive_type, d.date);
}
assert_eq!(
output.directives.len(),
3,
"expected 2 opens + 1 transaction"
);
let open_count = output
.directives
.iter()
.filter(|d| d.directive_type == "open")
.count();
assert_eq!(open_count, 2, "expected 2 open directives");
let directives = wrappers_to_directives(&output.directives).unwrap();
eprintln!("Converted directives: {}", directives.len());
for d in &directives {
match d {
rustledger_core::Directive::Open(o) => {
eprintln!(" Open: {}", o.account);
}
rustledger_core::Directive::Transaction(t) => {
eprintln!(" Transaction: {}", t.narration);
}
_ => eprintln!(" Other"),
}
}
let open_count = directives
.iter()
.filter(|d| matches!(d, rustledger_core::Directive::Open(_)))
.count();
assert_eq!(open_count, 2, "expected 2 Open directives after conversion");
}
#[test]
fn test_auto_accounts_same_date_ordering() {
use rustledger_plugin::types::*;
use rustledger_plugin::*;
let registry = NativePluginRegistry::new();
let plugin: &dyn NativePlugin = registry.find("auto_accounts").unwrap();
let input = PluginInput {
directives: vec![
DirectiveWrapper {
directive_type: "open".to_string(),
date: "1900-01-01".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Open(OpenData {
account: "Liabilities:Credit-Card".to_string(),
currencies: vec![],
booking: None,
metadata: vec![],
}),
},
DirectiveWrapper {
directive_type: "transaction".to_string(),
date: "2016-08-30".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: Some("Amazon".to_string()),
narration: "Order".to_string(),
tags: vec![],
links: vec![],
postings: vec![
PostingData {
account: "Expenses:FIXME:A".to_string(),
units: Some(AmountData {
number: "14.99".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
metadata: vec![],
flag: None,
},
PostingData {
account: "Liabilities:Credit-Card".to_string(),
units: Some(AmountData {
number: "-14.99".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
metadata: vec![],
flag: None,
},
],
metadata: vec![],
}),
},
],
options: PluginOptions::default(),
config: None,
};
let mut output = process_and_materialize(plugin, input);
sort_directives(&mut output.directives);
eprintln!("\n=== Output directives (ordered) ===");
for (i, d) in output.directives.iter().enumerate() {
eprintln!(" [{}] {}: {}", i, d.directive_type, d.date);
if let DirectiveData::Open(open) = &d.data {
eprintln!(" account: {}", open.account);
}
}
assert_eq!(output.directives.len(), 3);
let idx_open_fixme = output
.directives
.iter()
.position(|d| {
d.directive_type == "open"
&& matches!(&d.data, DirectiveData::Open(o) if o.account == "Expenses:FIXME:A")
})
.expect("should have Open for Expenses:FIXME:A");
let idx_txn = output
.directives
.iter()
.position(|d| d.directive_type == "transaction" && d.date == "2016-08-30")
.expect("should have Transaction on 2016-08-30");
eprintln!("\nOpen Expenses:FIXME:A at index {idx_open_fixme}, Transaction at index {idx_txn}");
assert!(
idx_open_fixme < idx_txn,
"Open for Expenses:FIXME:A should come before Transaction on same date"
);
let directives = wrappers_to_directives(&output.directives).unwrap();
eprintln!("\n=== Converted directives ===");
for (i, d) in directives.iter().enumerate() {
match d {
rustledger_core::Directive::Open(o) => {
eprintln!(" [{}] Open: {} on {}", i, o.account, o.date);
}
rustledger_core::Directive::Transaction(t) => {
eprintln!(" [{}] Transaction on {}", i, t.date);
}
_ => {}
}
}
let converted_idx_open = directives
.iter()
.position(|d| {
matches!(d, rustledger_core::Directive::Open(o) if o.account.as_str() == "Expenses:FIXME:A")
})
.expect("should have Open after conversion");
let converted_idx_txn = directives
.iter()
.position(|d| matches!(d, rustledger_core::Directive::Transaction(_)))
.expect("should have Transaction after conversion");
eprintln!(
"\nAfter conversion: Open at {converted_idx_open}, Transaction at {converted_idx_txn}"
);
assert!(
converted_idx_open < converted_idx_txn,
"Open should still come before Transaction after conversion"
);
}
use rustledger_plugin::native::CheckClosingPlugin;
fn make_transaction_with_closing_metadata(
date: &str,
narration: &str,
account: &str,
units: (&str, &str),
other_account: &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: narration.to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: account.to_string(),
units: Some(AmountData {
number: units.0.to_string(),
currency: units.1.to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![("closing".to_string(), MetaValueData::Bool(true))],
},
PostingData {
account: other_account.to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
}
}
#[test]
fn test_check_closing_adds_balance_assertion() {
let plugin = CheckClosingPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Final"),
make_transaction_with_closing_metadata(
"2024-01-15",
"Close out account",
"Assets:Bank",
("-500.00", "USD"),
"Expenses:Final",
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty(), "expected no errors");
let balance = output
.directives
.iter()
.find(|d| d.directive_type == "balance");
assert!(balance.is_some(), "expected balance assertion to be added");
let balance = balance.unwrap();
assert_eq!(balance.date, "2024-01-16", "balance should be on next day");
if let DirectiveData::Balance(b) = &balance.data {
assert_eq!(b.account, "Assets:Bank");
assert_eq!(b.amount.number, "0");
} else {
panic!("expected balance directive");
}
}
#[test]
fn test_check_closing_no_metadata() {
let plugin = CheckClosingPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"Normal transaction",
vec![
("Expenses:Food", "50.00", "USD"),
("Assets:Bank", "-50.00", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty(), "expected no errors");
let balance_count = output
.directives
.iter()
.filter(|d| d.directive_type == "balance")
.count();
assert_eq!(
balance_count, 0,
"should not add balance without closing metadata"
);
}
#[test]
fn test_check_closing_empty_input() {
let plugin = CheckClosingPlugin;
let output = process_and_materialize(&plugin, make_input(vec![]));
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 0);
}
#[test]
fn test_check_closing_false_value_no_emission() {
let plugin = CheckClosingPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Final"),
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: "Not really closing".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Bank".to_string(),
units: Some(AmountData {
number: "-100.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![("closing".to_string(), MetaValueData::Bool(false))],
},
PostingData {
account: "Expenses:Final".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
},
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let balance_count = output
.directives
.iter()
.filter(|d| d.directive_type == "balance")
.count();
assert_eq!(balance_count, 0);
}
#[test]
fn test_check_closing_non_bool_metadata_no_emission() {
let plugin = CheckClosingPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Final"),
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: "String closing".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Bank".to_string(),
units: Some(AmountData {
number: "-100.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![(
"closing".to_string(),
MetaValueData::String("yes".to_string()),
)],
},
PostingData {
account: "Expenses:Final".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
},
]);
let output = process_and_materialize(&plugin, input);
let balance_count = output
.directives
.iter()
.filter(|d| d.directive_type == "balance")
.count();
assert_eq!(balance_count, 0);
}
#[test]
fn test_check_closing_units_none_uses_operating_currency_usd() {
let plugin = CheckClosingPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Final"),
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: "Auto-balanced close".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Bank".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![("closing".to_string(), MetaValueData::Bool(true))],
},
PostingData {
account: "Expenses:Final".to_string(),
units: Some(AmountData {
number: "100.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
},
]);
let output = process_and_materialize(&plugin, input);
let balance = output
.directives
.iter()
.find(|d| d.directive_type == "balance")
.expect("should have balance assertion");
assert_eq!(balance.date, "2024-01-16");
if let DirectiveData::Balance(b) = &balance.data {
assert_eq!(b.account, "Assets:Bank");
assert_eq!(b.amount.number, "0");
assert_eq!(b.amount.currency, "USD");
} else {
panic!("expected Balance directive");
}
}
#[test]
fn test_check_closing_units_none_uses_operating_currency_eur() {
let plugin = CheckClosingPlugin;
let input = PluginInput {
directives: vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Final"),
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: "Auto-balanced close".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Bank".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![("closing".to_string(), MetaValueData::Bool(true))],
},
PostingData {
account: "Expenses:Final".to_string(),
units: Some(AmountData {
number: "100.00".to_string(),
currency: "EUR".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
},
],
options: PluginOptions {
operating_currencies: vec!["EUR".to_string()],
title: None,
},
config: None,
};
let output = process_and_materialize(&plugin, input);
let balance = output
.directives
.iter()
.find(|d| d.directive_type == "balance")
.expect("should have balance assertion");
if let DirectiveData::Balance(b) = &balance.data {
assert_eq!(b.account, "Assets:Bank");
assert_eq!(b.amount.number, "0");
assert_eq!(
b.amount.currency, "EUR",
"operating_currencies[0] (EUR) should win over the USD literal fallback"
);
} else {
panic!("expected Balance directive");
}
}
#[test]
fn test_check_closing_units_none_falls_back_to_usd_when_no_operating_ccy() {
let plugin = CheckClosingPlugin;
let input = PluginInput {
directives: vec![
make_open("2024-01-01", "Assets:Bank"),
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: "Auto-balanced close".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![PostingData {
account: "Assets:Bank".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![("closing".to_string(), MetaValueData::Bool(true))],
}],
}),
},
],
options: PluginOptions {
operating_currencies: vec![],
title: None,
},
config: None,
};
let output = process_and_materialize(&plugin, input);
let balance = output
.directives
.iter()
.find(|d| d.directive_type == "balance")
.expect("should have balance assertion");
if let DirectiveData::Balance(b) = &balance.data {
assert_eq!(b.amount.currency, "USD", "fallback when no operating ccy");
} else {
panic!("expected Balance directive");
}
}
#[test]
fn test_check_closing_multiple_closings_in_one_txn() {
let plugin = CheckClosingPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:USD"),
make_open("2024-01-01", "Assets:EUR"),
make_open("2024-01-01", "Equity:Close"),
DirectiveWrapper {
directive_type: "transaction".to_string(),
date: "2024-06-30".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: "Close both accounts".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:USD".to_string(),
units: Some(AmountData {
number: "-100.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![("closing".to_string(), MetaValueData::Bool(true))],
},
PostingData {
account: "Assets:EUR".to_string(),
units: Some(AmountData {
number: "-50.00".to_string(),
currency: "EUR".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![("closing".to_string(), MetaValueData::Bool(true))],
},
PostingData {
account: "Equity:Close".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
},
]);
let output = process_and_materialize(&plugin, input);
let balances: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "balance")
.collect();
assert_eq!(balances.len(), 2, "one balance per closing posting");
for b in &balances {
assert_eq!(b.date, "2024-07-01");
}
let mut by_account: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
for b in &balances {
if let DirectiveData::Balance(bal) = &b.data {
by_account.insert(bal.account.as_str(), bal.amount.currency.as_str());
}
}
assert_eq!(by_account.get("Assets:USD"), Some(&"USD"));
assert_eq!(by_account.get("Assets:EUR"), Some(&"EUR"));
}
#[test]
fn test_check_closing_ignores_non_transaction_directives() {
let plugin = CheckClosingPlugin;
let input = make_input(vec![
make_commodity("2024-01-01", "USD"),
make_open("2024-01-01", "Assets:Bank"),
make_price("2024-01-15", "USD", "1.10", "EUR"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let balance_count = output
.directives
.iter()
.filter(|d| d.directive_type == "balance")
.count();
assert_eq!(balance_count, 0);
assert_eq!(output.directives.len(), 3);
}
#[test]
fn test_check_closing_invalid_date_skips_emission() {
let plugin = CheckClosingPlugin;
let input = make_input(vec![DirectiveWrapper {
directive_type: "transaction".to_string(),
date: "2024-13-01".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: "Bad date".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![PostingData {
account: "Assets:Bank".to_string(),
units: Some(AmountData {
number: "-100.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![("closing".to_string(), MetaValueData::Bool(true))],
}],
}),
}]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let balance_count = output
.directives
.iter()
.filter(|d| d.directive_type == "balance")
.count();
assert_eq!(
balance_count, 0,
"no balance emitted when date can't be incremented"
);
assert_eq!(
output.directives.len(),
1,
"original transaction should pass through; got: {:?}",
output.directives
);
}
#[test]
fn test_check_closing_unrelated_metadata_doesnt_trigger() {
let plugin = CheckClosingPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Expenses:Final"),
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: "Mixed metadata".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Bank".to_string(),
units: Some(AmountData {
number: "-100.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![("closing".to_string(), MetaValueData::Bool(true))],
},
PostingData {
account: "Expenses:Final".to_string(),
units: Some(AmountData {
number: "100.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![("note".to_string(), MetaValueData::Bool(true))],
},
],
}),
},
]);
let output = process_and_materialize(&plugin, input);
let balances: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "balance")
.collect();
assert_eq!(balances.len(), 1, "only the `closing` key triggers");
if let DirectiveData::Balance(b) = &balances[0].data {
assert_eq!(b.account, "Assets:Bank");
}
}
use rustledger_plugin::native::CloseTreePlugin;
fn make_close(date: &str, account: &str) -> DirectiveWrapper {
DirectiveWrapper {
directive_type: "close".to_string(),
date: date.to_string(),
filename: None,
lineno: None,
data: DirectiveData::Close(CloseData {
account: account.to_string(),
metadata: vec![],
}),
}
}
#[test]
fn test_close_tree_closes_children() {
let plugin = CloseTreePlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Assets:Bank:Checking"),
make_open("2024-01-01", "Assets:Bank:Savings"),
make_transaction(
"2024-01-15",
"Deposit",
vec![
("Assets:Bank:Checking", "100.00", "USD"),
("Assets:Bank:Savings", "-100.00", "USD"),
],
),
make_close("2024-12-31", "Assets:Bank"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty(), "expected no errors");
let close_directives: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "close")
.collect();
assert_eq!(
close_directives.len(),
3,
"expected 3 close directives (parent + 2 children)"
);
let closed_accounts: Vec<String> = close_directives
.iter()
.filter_map(|d| {
if let DirectiveData::Close(c) = &d.data {
Some(c.account.clone())
} else {
None
}
})
.collect();
assert!(closed_accounts.contains(&"Assets:Bank".to_string()));
assert!(closed_accounts.contains(&"Assets:Bank:Checking".to_string()));
assert!(closed_accounts.contains(&"Assets:Bank:Savings".to_string()));
}
#[test]
fn test_close_tree_no_duplicate_close() {
let plugin = CloseTreePlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Bank"),
make_open("2024-01-01", "Assets:Bank:Checking"),
make_close("2024-06-30", "Assets:Bank:Checking"), make_close("2024-12-31", "Assets:Bank"),
]);
let output = process_and_materialize(&plugin, input);
let checking_closes = output
.directives
.iter()
.filter(|d| {
d.directive_type == "close"
&& matches!(&d.data, DirectiveData::Close(c) if c.account == "Assets:Bank:Checking")
})
.count();
assert_eq!(
checking_closes, 1,
"should not duplicate close for already-closed account"
);
}
use rustledger_plugin::native::CoherentCostPlugin;
fn make_transaction_with_price(
date: &str,
narration: &str,
account: &str,
units: (&str, &str),
price: (&str, &str),
other_account: &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: narration.to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: account.to_string(),
units: Some(AmountData {
number: units.0.to_string(),
currency: units.1.to_string(),
}),
cost: None,
price: Some(PriceAnnotationData {
amount: Some(AmountData {
number: price.0.to_string(),
currency: price.1.to_string(),
}),
is_total: false,
number: None,
currency: None,
}),
flag: None,
metadata: vec![],
},
PostingData {
account: other_account.to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
}
}
#[test]
fn test_coherent_cost_mixed_usage_error() {
let plugin = CoherentCostPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost(
"2024-01-15",
"Buy stock",
"Assets:Stock",
("10", "HOOL"),
("100", "USD"),
"Assets:Cash",
),
make_transaction_with_price(
"2024-02-15",
"Convert",
"Assets:Stock",
("5", "HOOL"),
("110", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"expected error for mixed cost/price usage"
);
assert!(
output.errors[0].message.contains("HOOL"),
"error should mention the currency"
);
}
#[test]
fn test_coherent_cost_only_cost_ok() {
let plugin = CoherentCostPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost(
"2024-01-15",
"Buy stock",
"Assets:Stock",
("10", "HOOL"),
("100", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-02-15",
"Buy more",
"Assets:Stock",
("5", "HOOL"),
("110", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"expected no errors when using only cost"
);
}
#[test]
fn test_coherent_cost_only_price_ok() {
let plugin = CoherentCostPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Forex"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_price(
"2024-01-15",
"Exchange",
"Assets:Forex",
("100", "EUR"),
("1.10", "USD"),
"Assets:Cash",
),
make_transaction_with_price(
"2024-02-15",
"Exchange more",
"Assets:Forex",
("50", "EUR"),
("1.12", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"expected no errors when using only price"
);
}
#[test]
fn test_coherent_cost_cost_and_price_ok() {
let plugin = CoherentCostPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Income:CapitalGains"),
make_transaction_with_cost(
"2024-01-15",
"Buy stock",
"Assets:Stock",
("10", "HOOL"),
("100", "USD"),
"Assets:Cash",
),
make_transaction_with_cost_and_price(
"2024-06-15",
"Sell stock",
"Assets:Stock",
("-10", "HOOL"),
("100", "USD"), ("150", "USD"), "Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"expected no errors when using cost+price on same posting (capital gains)"
);
}
fn make_input_with_config(directives: Vec<DirectiveWrapper>, config: &str) -> PluginInput {
PluginInput {
directives,
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: Some(config.to_string()),
}
}
fn make_transaction_with_tag(
date: &str,
narration: &str,
tags: Vec<&str>,
postings: Vec<(&str, &str, &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: narration.to_string(),
tags: tags.into_iter().map(String::from).collect(),
links: vec![],
metadata: vec![],
postings: postings
.into_iter()
.map(|(account, number, currency)| PostingData {
account: account.to_string(),
units: Some(AmountData {
number: number.to_string(),
currency: currency.to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
})
.collect(),
}),
}
}
fn make_transaction_with_metadata(
date: &str,
narration: &str,
metadata: Vec<(&str, MetaValueData)>,
postings: Vec<(&str, &str, &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: narration.to_string(),
tags: vec![],
links: vec![],
metadata: metadata
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect(),
postings: postings
.into_iter()
.map(|(account, number, currency)| PostingData {
account: account.to_string(),
units: Some(AmountData {
number: number.to_string(),
currency: currency.to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
})
.collect(),
}),
}
}
fn make_open_with_currencies(date: &str, account: &str, currencies: Vec<&str>) -> DirectiveWrapper {
DirectiveWrapper {
directive_type: "open".to_string(),
date: date.to_string(),
filename: None,
lineno: None,
data: DirectiveData::Open(OpenData {
account: account.to_string(),
currencies: currencies.into_iter().map(String::from).collect(),
booking: None,
metadata: vec![],
}),
}
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(64))]
#[test]
fn prop_coherent_cost_errors_match_intersection(
postings in proptest::collection::vec(
(0u32..4, 0u32..3),
1..=8,
),
) {
use std::collections::HashSet;
let plugin = CoherentCostPlugin;
let mut directives: Vec<DirectiveWrapper> = vec![
make_open("2020-01-01", "Assets:Bank"),
];
for (i, (cid, kind)) in postings.iter().enumerate() {
let currency = format!("C{cid}");
let has_cost = matches!(kind, 0 | 2);
let has_price = matches!(kind, 1 | 2);
directives.push(DirectiveWrapper {
directive_type: "transaction".to_string(),
date: format!("2024-01-{:02}", (i % 28) + 1),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: "p".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![PostingData {
account: "Assets:Bank".to_string(),
units: Some(AmountData {
number: "1".to_string(),
currency: currency.clone(),
}),
cost: if has_cost {
Some(CostData {
number_per: Some("100".to_string()),
number_total: None,
currency: Some("USD".to_string()),
date: None,
label: None,
merge: false,
})
} else {
None
},
price: if has_price {
Some(PriceAnnotationData {
is_total: false,
amount: Some(AmountData {
number: "100".to_string(),
currency: "USD".to_string(),
}),
number: None,
currency: None,
})
} else {
None
},
flag: None,
metadata: vec![],
}],
}),
});
}
let input = make_input(directives);
let mut with_cost: HashSet<u32> = HashSet::new();
let mut price_only: HashSet<u32> = HashSet::new();
for (cid, kind) in &postings {
match kind {
0 | 2 => { with_cost.insert(*cid); }
1 => { price_only.insert(*cid); }
_ => {}
}
}
let expected_errors = with_cost.intersection(&price_only).count();
let output = process_and_materialize(&plugin, input);
proptest::prop_assert_eq!(
output.errors.len(), expected_errors,
"expected {} error(s) (currencies in both cost and price-only sets); \
postings={:?}",
expected_errors, postings
);
}
}
#[test]
fn test_auto_tag_adds_tag_for_expense() {
let plugin = AutoTagPlugin::new();
let input = make_input(vec![
make_open("2024-01-01", "Expenses:Food:Restaurant"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction(
"2024-01-15",
"Lunch",
vec![
("Expenses:Food:Restaurant", "25", "USD"),
("Assets:Cash", "-25", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.unwrap();
let DirectiveData::Transaction(data) = &txn.data else {
panic!(
"directive_type was 'transaction' but data variant is {:?} — impossible state",
txn.data
);
};
assert_eq!(
data.tags.len(),
1,
"auto_tag should add exactly one tag for the single matching posting"
);
}
#[test]
fn test_no_unused_warns_on_unused_account() {
let plugin = NoUnusedPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Used"),
make_open("2024-01-01", "Assets:Unused"),
make_open("2024-01-01", "Equity:Opening"),
make_transaction(
"2024-01-15",
"Use it",
vec![
("Assets:Used", "100", "USD"),
("Equity:Opening", "-100", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"exactly one error for the single unused account"
);
assert!(
output.errors[0].message.contains("Unused"),
"error should mention the unused account"
);
}
#[test]
fn test_no_unused_ok_when_all_used() {
let plugin = NoUnusedPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"Lunch",
vec![
("Expenses:Food", "25", "USD"),
("Assets:Cash", "-25", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty(), "no unused accounts");
}
#[test]
fn test_pedantic_runs_multiple_validators() {
let plugin = PedanticPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Expenses:Food"),
make_open("2024-01-01", "Expenses:Food:Restaurant"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction(
"2024-01-15",
"Bad",
vec![
("Expenses:Food", "25", "USD"), ("Assets:Cash", "-25", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"exactly one error for the single leaf-only violation"
);
}
#[test]
fn test_rx_txn_adds_metadata_to_tagged_transaction() {
let plugin = RxTxnPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Rent"),
make_transaction_with_tag(
"2024-01-15",
"Monthly rent",
vec!["rx_txn"],
vec![
("Expenses:Rent", "1000", "USD"),
("Assets:Cash", "-1000", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must be present");
let DirectiveData::Transaction(data) = &txn.data else {
panic!(
"non-Transaction data on transaction directive: {:?}",
txn.data
);
};
let final_meta = data.metadata.iter().find(|(k, _)| k == "final");
let roll_meta = data.metadata.iter().find(|(k, _)| k == "roll");
assert!(final_meta.is_some(), "rx_txn must add 'final' metadata");
assert!(roll_meta.is_some(), "rx_txn must add 'roll' metadata");
if let Some((_, MetaValueData::String(v))) = final_meta {
assert_eq!(v, "None", "default 'final' value is 'None'");
} else {
panic!("'final' metadata should be a string 'None'");
}
if let Some((_, MetaValueData::String(v))) = roll_meta {
assert_eq!(v, "True", "default 'roll' value is 'True'");
} else {
panic!("'roll' metadata should be a string 'True'");
}
}
#[test]
fn test_rx_txn_ignores_untagged_transaction() {
let plugin = RxTxnPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"Lunch",
vec![
("Expenses:Food", "25", "USD"),
("Assets:Cash", "-25", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must be present");
let DirectiveData::Transaction(data) = &txn.data else {
panic!(
"non-Transaction data on transaction directive: {:?}",
txn.data
);
};
assert!(
data.metadata.is_empty(),
"untagged transaction should have NO metadata added"
);
}
#[test]
fn test_rx_txn_preserves_existing_metadata() {
let plugin = RxTxnPlugin;
let mut txn = make_transaction_with_tag(
"2024-01-15",
"Recurring with explicit metadata",
vec!["rx_txn"],
vec![
("Expenses:Rent", "1000", "USD"),
("Assets:Cash", "-1000", "USD"),
],
);
if let DirectiveData::Transaction(ref mut data) = txn.data {
data.metadata.push((
"final".to_string(),
MetaValueData::String("2024-12-31".to_string()),
));
data.metadata.push((
"roll".to_string(),
MetaValueData::String("False".to_string()),
));
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Rent"),
txn,
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let DirectiveData::Transaction(data) = &output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must be present")
.data
else {
panic!("non-Transaction data on transaction directive");
};
assert_eq!(
data.metadata.len(),
2,
"existing metadata preserved, no defaults added (got {} entries)",
data.metadata.len()
);
let final_val = data
.metadata
.iter()
.find(|(k, _)| k == "final")
.map(|(_, v)| v);
if let Some(MetaValueData::String(v)) = final_val {
assert_eq!(v, "2024-12-31", "existing 'final' value preserved");
} else {
panic!("'final' metadata should remain as '2024-12-31'");
}
let roll_val = data
.metadata
.iter()
.find(|(k, _)| k == "roll")
.map(|(_, v)| v);
if let Some(MetaValueData::String(v)) = roll_val {
assert_eq!(
v, "False",
"existing 'roll' value preserved (not overwritten)"
);
} else {
panic!("'roll' metadata should remain as 'False'");
}
}
#[test]
fn test_rx_txn_fills_in_missing_metadata_only() {
let plugin = RxTxnPlugin;
let mut txn = make_transaction_with_tag(
"2024-01-15",
"Half-configured rx",
vec!["rx_txn"],
vec![
("Expenses:Rent", "1000", "USD"),
("Assets:Cash", "-1000", "USD"),
],
);
if let DirectiveData::Transaction(ref mut data) = txn.data {
data.metadata.push((
"final".to_string(),
MetaValueData::String("2024-12-31".to_string()),
));
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Rent"),
txn,
]);
let output = process_and_materialize(&plugin, input);
let DirectiveData::Transaction(data) = &output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must be present")
.data
else {
panic!("non-Transaction data on transaction directive");
};
assert_eq!(
data.metadata.len(),
2,
"got existing 'final' + defaulted 'roll'"
);
let final_val = data
.metadata
.iter()
.find(|(k, _)| k == "final")
.map(|(_, v)| v);
if let Some(MetaValueData::String(v)) = final_val {
assert_eq!(v, "2024-12-31", "pre-existing 'final' is untouched");
} else {
panic!("'final' metadata should be a string");
}
let roll_val = data
.metadata
.iter()
.find(|(k, _)| k == "roll")
.map(|(_, v)| v);
if let Some(MetaValueData::String(v)) = roll_val {
assert_eq!(v, "True", "missing 'roll' is filled with default");
} else {
panic!("'roll' metadata should be a string default");
}
}
#[test]
fn test_rx_txn_works_alongside_other_tags() {
let plugin = RxTxnPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Rent"),
make_transaction_with_tag(
"2024-01-15",
"Mixed tags",
vec!["rx_txn", "monthly", "essential"],
vec![
("Expenses:Rent", "1000", "USD"),
("Assets:Cash", "-1000", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
let DirectiveData::Transaction(data) = &output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must be present")
.data
else {
panic!("non-Transaction data on transaction directive");
};
assert!(
data.metadata.iter().any(|(k, _)| k == "final"),
"rx_txn applies even when other tags are present (final missing)"
);
assert!(
data.metadata.iter().any(|(k, _)| k == "roll"),
"rx_txn applies even when other tags are present (roll missing)"
);
assert_eq!(data.tags.len(), 3, "all tags preserved");
}
fn make_sale_with_gain_posting(
date: &str,
asset_account: &str,
units: (&str, &str),
cost: (&str, &str),
price: (&str, &str),
gain_account: &str,
gain_amount: (&str, &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: "Sell with gain posting".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: asset_account.to_string(),
units: Some(AmountData {
number: units.0.to_string(),
currency: units.1.to_string(),
}),
cost: Some(CostData {
number_per: Some(cost.0.to_string()),
number_total: None,
currency: Some(cost.1.to_string()),
date: None,
label: None,
merge: false,
}),
price: Some(PriceAnnotationData {
is_total: false,
amount: Some(AmountData {
number: price.0.to_string(),
currency: price.1.to_string(),
}),
number: None,
currency: None,
}),
flag: None,
metadata: vec![],
},
PostingData {
account: "Assets:Cash".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
PostingData {
account: gain_account.to_string(),
units: Some(AmountData {
number: gain_amount.0.to_string(),
currency: gain_amount.1.to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
}
}
#[test]
fn test_sell_gains_warns_missing_gains_posting() {
let plugin = SellGainsPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost_and_price(
"2024-06-15",
"Sell stock",
"Assets:Stock",
("-10", "AAPL"),
("100", "USD"),
("150", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"exactly one warning for the single sale missing gains posting"
);
assert!(
output.errors[0].message.contains("gain") || output.errors[0].message.contains("Gain"),
"warning should reference the missing gains posting"
);
}
#[test]
fn test_sell_gains_silent_with_income_posting() {
let plugin = SellGainsPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Income:Capital-Gains"),
make_sale_with_gain_posting(
"2024-06-15",
"Assets:Stock",
("-10", "AAPL"),
("100", "USD"),
("150", "USD"),
"Income:Capital-Gains",
("-500", "USD"), ),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"no warning when Income:Capital-Gains posting is present (got {} warnings)",
output.errors.len()
);
}
#[test]
fn test_sell_gains_silent_with_expenses_posting() {
let plugin = SellGainsPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Capital-Losses"),
make_sale_with_gain_posting(
"2024-06-15",
"Assets:Stock",
("-10", "AAPL"),
("100", "USD"),
("80", "USD"), "Expenses:Capital-Losses",
("200", "USD"),
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"no warning when Expenses:* posting is present (got {} warnings)",
output.errors.len()
);
}
#[test]
fn test_sell_gains_silent_for_buy() {
let plugin = SellGainsPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost_and_price(
"2024-01-15",
"Buy stock",
"Assets:Stock",
("10", "AAPL"), ("100", "USD"),
("100", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"buys are not flagged regardless of postings (got {} warnings)",
output.errors.len()
);
}
#[test]
fn test_sell_gains_silent_when_gain_is_zero() {
let plugin = SellGainsPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost_and_price(
"2024-06-15",
"Sell at cost",
"Assets:Stock",
("-10", "AAPL"),
("100", "USD"),
("100", "USD"), "Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"zero gain doesn't warrant a warning (got {} warnings)",
output.errors.len()
);
}
#[test]
fn test_sell_gains_silent_without_cost() {
let plugin = SellGainsPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction(
"2024-06-15",
"Transfer stock",
vec![
("Assets:Stock", "-10", "AAPL"),
("Assets:Cash", "1500", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"sale without cost/price annotation is not flagged (got {} warnings)",
output.errors.len()
);
}
#[test]
fn test_sell_gains_two_sales_share_one_income_posting() {
let plugin = SellGainsPlugin;
let txn = DirectiveWrapper {
directive_type: "transaction".to_string(),
date: "2024-06-15".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: "Sell two lots".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Stock".to_string(),
units: Some(AmountData {
number: "-5".to_string(),
currency: "AAPL".to_string(),
}),
cost: Some(CostData {
number_per: Some("100".to_string()),
number_total: None,
currency: Some("USD".to_string()),
date: None,
label: None,
merge: false,
}),
price: Some(PriceAnnotationData {
is_total: false,
amount: Some(AmountData {
number: "150".to_string(),
currency: "USD".to_string(),
}),
number: None,
currency: None,
}),
flag: None,
metadata: vec![],
},
PostingData {
account: "Assets:Stock".to_string(),
units: Some(AmountData {
number: "-3".to_string(),
currency: "AAPL".to_string(),
}),
cost: Some(CostData {
number_per: Some("200".to_string()),
number_total: None,
currency: Some("USD".to_string()),
date: None,
label: None,
merge: false,
}),
price: Some(PriceAnnotationData {
is_total: false,
amount: Some(AmountData {
number: "180".to_string(),
currency: "USD".to_string(),
}),
number: None,
currency: None,
}),
flag: None,
metadata: vec![],
},
PostingData {
account: "Income:Capital-Gains".to_string(),
units: Some(AmountData {
number: "-190".to_string(), currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
PostingData {
account: "Assets:Cash".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
};
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Income:Capital-Gains"),
txn,
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"single Income posting covers both sales in this transaction \
(per-transaction check, not per-posting); got {} warnings",
output.errors.len()
);
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(64))]
#[test]
fn prop_sell_gains_warns_iff_nonzero_gain_with_no_income_posting(
units in 1u32..1000,
cost_cents in 1u32..1_000_000,
sale_cents in 1u32..1_000_000,
with_income_posting in proptest::bool::ANY,
) {
use rust_decimal::Decimal;
let plugin = SellGainsPlugin;
let cost_d = Decimal::new(i64::from(cost_cents), 2);
let sale_d = Decimal::new(i64::from(sale_cents), 2);
let units_d = Decimal::from(units);
let expected_gain = (sale_d - cost_d) * units_d;
let sale_posting = PostingData {
account: "Assets:Brokerage".to_string(),
units: Some(AmountData {
number: format!("-{units}"),
currency: "AAPL".to_string(),
}),
cost: Some(CostData {
number_per: Some(cost_d.to_string()),
number_total: None,
currency: Some("USD".to_string()),
date: None,
label: None,
merge: false,
}),
price: Some(PriceAnnotationData {
is_total: false,
amount: Some(AmountData {
number: sale_d.to_string(),
currency: "USD".to_string(),
}),
number: None,
currency: None,
}),
flag: None,
metadata: vec![],
};
let cash_posting = PostingData {
account: "Assets:Cash".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
};
let income_posting = PostingData {
account: "Income:Capital-Gains".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
};
let mut postings = vec![sale_posting, cash_posting];
if with_income_posting {
postings.push(income_posting);
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Brokerage"),
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Income:Capital-Gains"),
DirectiveWrapper {
directive_type: "transaction".to_string(),
date: "2024-06-15".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: "Sale".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings,
}),
},
]);
let output = process_and_materialize(&plugin, input);
let should_warn = expected_gain != Decimal::ZERO && !with_income_posting;
let expected_count = usize::from(should_warn);
proptest::prop_assert_eq!(
output.errors.len(), expected_count,
"expected {} warning(s) for cost={} sale={} units={} with_income={}",
expected_count, cost_d, sale_d, units, with_income_posting
);
}
}
#[test]
fn test_check_drained_adds_balance_assertions_on_close() {
let plugin = CheckDrainedPlugin;
let input = make_input(vec![
make_open_with_currencies("2024-01-01", "Assets:Bank", vec!["USD"]),
make_transaction(
"2024-06-15",
"Deposit",
vec![
("Assets:Bank", "100", "USD"),
("Income:Salary", "-100", "USD"),
],
),
make_close("2024-12-31", "Assets:Bank"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
assert_eq!(
output
.directives
.iter()
.filter(|d| d.directive_type == "balance")
.count(),
1,
"exactly one balance assertion for the single closed account+currency"
);
}
#[test]
fn test_commodity_attr_ok_with_no_config() {
let plugin = CommodityAttrPlugin::new();
let input = make_input(vec![make_commodity("2024-01-01", "USD")]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
}
#[test]
fn test_commodity_attr_error_with_missing_required_attr() {
let plugin = CommodityAttrPlugin::new();
let input =
make_input_with_config(vec![make_commodity("2024-01-01", "AAPL")], "{'name': null}");
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"exactly one error for the single commodity missing required 'name'"
);
}
#[test]
fn test_currency_accounts_single_currency_no_change() {
let plugin = CurrencyAccountsPlugin::new();
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"Lunch",
vec![
("Expenses:Food", "25", "USD"),
("Assets:Cash", "-25", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.unwrap();
if let DirectiveData::Transaction(data) = &txn.data {
assert_eq!(
data.postings.len(),
2,
"single-currency transaction should not gain extra postings"
);
}
}
fn make_txn_with_effective_date(
entry_date: &str,
effective_date: &str,
target_account: &str,
) -> DirectiveWrapper {
let mut txn = make_transaction(
entry_date,
"Deferred",
vec![(target_account, "25", "USD"), ("Assets:Cash", "-25", "USD")],
);
if let DirectiveData::Transaction(ref mut data) = txn.data {
data.postings[0].metadata.push((
"effective_date".to_string(),
MetaValueData::Date(effective_date.to_string()),
));
}
txn
}
#[test]
fn test_effective_date_no_metadata_passthrough() {
let plugin = EffectiveDatePlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"No effective date",
vec![
("Expenses:Food", "25", "USD"),
("Assets:Cash", "-25", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 3);
}
#[test]
fn test_effective_date_future_uses_later_holding_account() {
let plugin = EffectiveDatePlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Food"),
make_txn_with_effective_date("2024-01-15", "2024-02-15", "Expenses:Food"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let opens: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "open")
.collect();
let new_open = opens.iter().find(|d| {
if let DirectiveData::Open(o) = &d.data {
o.account == "Assets:Hold:Expenses:Food"
} else {
false
}
});
assert!(
new_open.is_some(),
"future effective_date should generate Open for 'later' holding (Assets:Hold:Expenses:Food); \
got opens: {:?}",
opens
.iter()
.filter_map(|d| {
if let DirectiveData::Open(o) = &d.data {
Some(&o.account)
} else {
None
}
})
.collect::<Vec<_>>()
);
let effective_txns: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "transaction" && d.date == "2024-02-15")
.collect();
assert_eq!(
effective_txns.len(),
1,
"exactly one new transaction at the effective date"
);
let DirectiveData::Transaction(eff_data) = &effective_txns[0].data else {
panic!("effective-date directive has non-Transaction data");
};
let hold_posting = eff_data
.postings
.iter()
.find(|p| p.account == "Assets:Hold:Expenses:Food")
.expect("effective-date txn must have a hold posting");
let hold_units = hold_posting.units.as_ref().expect("hold has units");
assert_eq!(
hold_units.number, "-25",
"hold posting must be sign-flipped from the original (+25 → -25)"
);
assert_eq!(hold_units.currency, "USD");
let original_txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction" && d.date == "2024-01-15")
.expect("original transaction must remain");
let DirectiveData::Transaction(orig_data) = &original_txn.data else {
panic!("original directive has non-Transaction data");
};
assert_eq!(
orig_data.links.len(),
1,
"plugin should attach exactly one link to the original txn"
);
assert_eq!(
eff_data.links.len(),
1,
"plugin should attach exactly one link to the effective-date txn"
);
assert_eq!(
orig_data.links[0], eff_data.links[0],
"the same link should appear on both transactions"
);
assert!(
orig_data.links[0].starts_with("edate-"),
"link should follow the `edate-<date>-<id>` shape; got '{}'",
orig_data.links[0]
);
}
#[test]
fn test_effective_date_past_uses_earlier_holding_account() {
let plugin = EffectiveDatePlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Food"),
make_txn_with_effective_date("2024-02-15", "2024-01-15", "Expenses:Food"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let opens: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "open")
.collect();
let new_open = opens.iter().find(|d| {
if let DirectiveData::Open(o) = &d.data {
o.account == "Liabilities:Hold:Expenses:Food"
} else {
false
}
});
assert!(
new_open.is_some(),
"past effective_date should generate Open for 'earlier' holding (Liabilities:Hold:Expenses:Food)"
);
assert_eq!(
output
.directives
.iter()
.filter(|d| d.directive_type == "transaction" && d.date == "2024-01-15")
.count(),
1
);
}
#[test]
fn test_effective_date_unconfigured_prefix_unchanged() {
let plugin = EffectiveDatePlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Liabilities:CreditCard"),
make_txn_with_effective_date("2024-01-15", "2024-02-15", "Liabilities:CreditCard"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
assert!(
!output.directives.iter().any(|d| {
d.directive_type == "open"
&& matches!(
&d.data,
DirectiveData::Open(o) if o.account.contains(":Hold:")
)
}),
"unconfigured prefix should NOT generate holding-account Opens"
);
assert_eq!(
output
.directives
.iter()
.filter(|d| d.directive_type == "transaction" && d.date == "2024-02-15")
.count(),
0,
"unconfigured prefix should NOT spawn a new effective-date txn"
);
}
#[test]
fn test_effective_date_custom_config_remaps_prefix() {
let plugin = EffectiveDatePlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Liabilities:Pay"),
make_txn_with_effective_date("2024-01-15", "2024-02-15", "Liabilities:Pay"),
],
"{'Liabilities': {'earlier': 'Assets:Hold:Liab', 'later': 'Liabilities:Hold:Liab'}}",
);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
assert!(
output.directives.iter().any(|d| matches!(
&d.data,
DirectiveData::Open(o) if o.account.starts_with("Liabilities:Hold:Liab")
)),
"custom config should map Liabilities: → Liabilities:Hold:Liab"
);
let effective_txns: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "transaction" && d.date == "2024-02-15")
.collect();
assert_eq!(
effective_txns.len(),
1,
"custom config should spawn exactly one effective-date transaction"
);
let DirectiveData::Transaction(eff_data) = &effective_txns[0].data else {
panic!(
"effective-date directive has non-Transaction data: {:?}",
effective_txns[0].data
);
};
assert!(
eff_data
.postings
.iter()
.any(|p| p.account.starts_with("Liabilities:Hold:Liab")),
"effective-date txn should post to the remapped 'later' holding account; got: {:?}",
eff_data
.postings
.iter()
.map(|p| &p.account)
.collect::<Vec<_>>()
);
}
fn make_forecast_txn(date: &str, narration: &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: narration.to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Expenses:Rent".to_string(),
units: Some(AmountData {
number: "1000".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
PostingData {
account: "Assets:Cash".to_string(),
units: Some(AmountData {
number: "-1000".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
}
}
#[test]
fn test_forecast_no_forecast_flag_passthrough() {
let plugin = ForecastPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Rent"),
make_transaction(
"2024-01-15",
"Regular rent",
vec![
("Expenses:Rent", "1000", "USD"),
("Assets:Cash", "-1000", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 3);
}
#[test]
fn test_forecast_monthly_repeat_emits_exactly_n_transactions() {
let plugin = ForecastPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Rent"),
make_forecast_txn("2024-01-15", "Rent [MONTHLY REPEAT 3 TIMES]"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txns: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "transaction")
.collect();
assert_eq!(txns.len(), 3, "MONTHLY REPEAT 3 TIMES emits exactly 3 txns");
let dates: Vec<&str> = txns.iter().map(|t| t.date.as_str()).collect();
assert_eq!(dates, vec!["2024-01-15", "2024-02-15", "2024-03-15"]);
for txn in &txns {
let DirectiveData::Transaction(data) = &txn.data else {
panic!("transaction directive_type with non-Transaction data");
};
assert_eq!(
data.narration, "Rent",
"bracketed pattern should be stripped from narration"
);
assert_eq!(data.flag, "#", "forecast flag preserved across replication");
assert_eq!(
data.postings.len(),
2,
"original posting count preserved (Expenses:Rent + Assets:Cash)"
);
assert_eq!(data.postings[0].account, "Expenses:Rent");
let units_0 = data.postings[0]
.units
.as_ref()
.expect("first posting has units");
assert_eq!(units_0.number, "1000");
assert_eq!(units_0.currency, "USD");
assert_eq!(data.postings[1].account, "Assets:Cash");
let units_1 = data.postings[1]
.units
.as_ref()
.expect("second posting has units");
assert_eq!(units_1.number, "-1000");
assert_eq!(units_1.currency, "USD");
}
}
#[test]
fn test_forecast_weekly_repeat() {
let plugin = ForecastPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Rent"),
make_forecast_txn("2024-01-01", "Groceries [WEEKLY REPEAT 4 TIMES]"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txns: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "transaction")
.collect();
assert_eq!(txns.len(), 4);
let dates: Vec<&str> = txns.iter().map(|t| t.date.as_str()).collect();
assert_eq!(
dates,
vec!["2024-01-01", "2024-01-08", "2024-01-15", "2024-01-22"]
);
}
#[test]
fn test_forecast_daily_repeat() {
let plugin = ForecastPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Rent"),
make_forecast_txn("2024-01-15", "Coffee [DAILY REPEAT 5 TIMES]"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txns: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "transaction")
.collect();
assert_eq!(txns.len(), 5);
let dates: Vec<&str> = txns.iter().map(|t| t.date.as_str()).collect();
assert_eq!(
dates,
vec![
"2024-01-15",
"2024-01-16",
"2024-01-17",
"2024-01-18",
"2024-01-19",
]
);
}
#[test]
fn test_forecast_monthly_until_inclusive() {
let plugin = ForecastPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Rent"),
make_forecast_txn("2024-01-15", "Rent [MONTHLY UNTIL 2024-04-15]"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txns: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "transaction")
.collect();
assert_eq!(txns.len(), 4, "until is inclusive: Jan, Feb, Mar, Apr");
let dates: Vec<&str> = txns.iter().map(|t| t.date.as_str()).collect();
assert_eq!(
dates,
vec!["2024-01-15", "2024-02-15", "2024-03-15", "2024-04-15"]
);
}
#[test]
fn test_forecast_skip_increases_stride() {
let plugin = ForecastPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Rent"),
make_forecast_txn(
"2024-01-01",
"Quarterly insurance [MONTHLY SKIP 1 TIME REPEAT 3 TIMES]",
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txns: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "transaction")
.collect();
assert_eq!(txns.len(), 3);
let dates: Vec<&str> = txns.iter().map(|t| t.date.as_str()).collect();
assert_eq!(
dates,
vec!["2024-01-01", "2024-03-01", "2024-05-01"],
"SKIP 1 TIME = bi-monthly stride"
);
}
#[test]
fn test_forecast_no_pattern_in_narration_kept_unchanged() {
let plugin = ForecastPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Rent"),
make_forecast_txn("2024-01-15", "Forecast with no recurrence pattern"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txns: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "transaction")
.collect();
assert_eq!(
txns.len(),
1,
"no recurrence pattern in narration → no expansion"
);
assert_eq!(txns[0].date, "2024-01-15");
}
#[test]
fn test_generate_base_ccy_prices_no_config_passthrough() {
let plugin = GenerateBaseCcyPricesPlugin;
let input = make_input(vec![
make_price("2024-01-01", "EUR", "1.10", "USD"),
make_price("2024-01-01", "ETH", "2000", "EUR"),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let price_count = output
.directives
.iter()
.filter(|d| d.directive_type == "price")
.count();
assert_eq!(
price_count, 2,
"no config → input passes through with no derived prices"
);
}
#[test]
fn test_generate_base_ccy_prices_emits_derived_chain() {
let plugin = GenerateBaseCcyPricesPlugin;
let input = make_input_with_config(
vec![
make_price("2024-01-01", "EUR", "1.10", "USD"),
make_price("2024-01-01", "ETH", "2000", "EUR"),
],
"USD",
);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let prices: Vec<_> = output
.directives
.iter()
.filter(|d| d.directive_type == "price")
.collect();
assert_eq!(
prices.len(),
3,
"input prices + exactly one derived ETH→USD"
);
let derived = prices
.iter()
.find_map(|d| match &d.data {
DirectiveData::Price(p) if p.currency == "ETH" && p.amount.currency == "USD" => Some(p),
_ => None,
})
.expect("derived ETH→USD price must be emitted");
assert_eq!(
derived.amount.number, "2200",
"derived = 2000 EUR * 1.10 USD/EUR = 2200 USD"
);
}
#[test]
fn test_generate_base_ccy_prices_skips_when_target_already_exists() {
let plugin = GenerateBaseCcyPricesPlugin;
let input = make_input_with_config(
vec![
make_price("2024-01-01", "EUR", "1.10", "USD"),
make_price("2024-01-01", "ETH", "2000", "EUR"),
make_price("2024-01-01", "ETH", "1900", "USD"),
],
"USD",
);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let eth_usd_prices: Vec<_> = output
.directives
.iter()
.filter_map(|d| match &d.data {
DirectiveData::Price(p) if p.currency == "ETH" && p.amount.currency == "USD" => Some(p),
_ => None,
})
.collect();
assert_eq!(
eth_usd_prices.len(),
1,
"pre-existing ETH→USD suppresses derivation (no duplicate)"
);
assert_eq!(
eth_usd_prices[0].amount.number, "1900",
"pre-existing price preserved verbatim, NOT replaced by 2200"
);
}
#[test]
fn test_generate_base_ccy_prices_skips_prices_already_in_base() {
let plugin = GenerateBaseCcyPricesPlugin;
let input = make_input_with_config(
vec![
make_price("2024-01-01", "EUR", "1.10", "USD"),
make_price("2024-01-01", "GBP", "1.30", "USD"),
],
"USD",
);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let price_count = output
.directives
.iter()
.filter(|d| d.directive_type == "price")
.count();
assert_eq!(
price_count, 2,
"no derivation when all prices are already in base currency"
);
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(64))]
#[test]
fn prop_generate_base_ccy_prices_multiplies_chain_exactly(
c_to_x_cents in 1u32..10_000_000,
x_to_base_cents in 1u32..10_000_000,
) {
use rust_decimal::Decimal;
let to_dollars = |cents: u32| Decimal::new(i64::from(cents), 2);
let c_x = to_dollars(c_to_x_cents);
let x_b = to_dollars(x_to_base_cents);
let expected = c_x * x_b;
let plugin = GenerateBaseCcyPricesPlugin;
let input = make_input_with_config(
vec![
make_price("2024-01-01", "X", &x_b.to_string(), "USD"),
make_price("2024-01-01", "C", &c_x.to_string(), "X"),
],
"USD",
);
let output = process_and_materialize(&plugin, input);
proptest::prop_assert!(output.errors.is_empty());
let derived = output
.directives
.iter()
.find_map(|d| match &d.data {
DirectiveData::Price(p)
if p.currency == "C" && p.amount.currency == "USD" =>
{
Some(p)
}
_ => None,
});
proptest::prop_assert!(derived.is_some(), "derived C→USD must be emitted");
let derived = derived.unwrap();
let got: Decimal = derived.amount.number.parse().expect("derived parses");
proptest::prop_assert_eq!(
got, expected,
"derived rate must equal C→X * X→base exactly; got {} expected {}",
got, expected
);
}
}
#[test]
fn test_rename_accounts_renames_in_transaction() {
let plugin = RenameAccountsPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Expenses:OldName"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction(
"2024-01-15",
"Test",
vec![
("Expenses:OldName", "25", "USD"),
("Assets:Cash", "-25", "USD"),
],
),
],
"{'Expenses:OldName': 'Expenses:NewName'}",
);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let has_new_name = output.directives.iter().any(|d| {
if let DirectiveData::Transaction(txn) = &d.data {
txn.postings.iter().any(|p| p.account == "Expenses:NewName")
} else {
false
}
});
assert!(has_new_name, "should rename account to Expenses:NewName");
}
#[test]
fn test_split_expenses_no_config_passthrough() {
let plugin = SplitExpensesPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Expenses:Food"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction(
"2024-01-15",
"Lunch",
vec![
("Expenses:Food", "100", "USD"),
("Assets:Cash", "-100", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 3, "no config → unchanged");
}
#[test]
fn test_split_expenses_divides_amount_evenly_between_two_members() {
let plugin = SplitExpensesPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Expenses:Food"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction(
"2024-01-15",
"Group dinner",
vec![
("Expenses:Food", "100", "USD"),
("Assets:Cash", "-100", "USD"),
],
),
],
"Alice Bob",
);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must remain");
let DirectiveData::Transaction(data) = &txn.data else {
panic!("non-Transaction data");
};
assert_eq!(
data.postings
.iter()
.filter(|p| p.account == "Expenses:Food")
.count(),
0,
"original bare Expenses:Food posting must be replaced"
);
let alice = data
.postings
.iter()
.find(|p| p.account == "Expenses:Food:Alice")
.expect("Alice's split must be present");
let bob = data
.postings
.iter()
.find(|p| p.account == "Expenses:Food:Bob")
.expect("Bob's split must be present");
let alice_units = alice.units.as_ref().expect("Alice has units");
let bob_units = bob.units.as_ref().expect("Bob has units");
assert_eq!(alice_units.number, "50", "100 / 2 members = 50");
assert_eq!(bob_units.number, "50");
assert_eq!(alice_units.currency, "USD");
assert_eq!(bob_units.currency, "USD");
for p in [alice, bob] {
assert!(
p.metadata.iter().any(|(k, v)| k == "__automatic__"
&& matches!(v, MetaValueData::String(s) if s == "True")),
"split posting must carry __automatic__: True metadata"
);
}
let opens: std::collections::BTreeSet<&str> = output
.directives
.iter()
.filter_map(|d| match &d.data {
DirectiveData::Open(o) if o.account.starts_with("Expenses:Food:") => {
Some(o.account.as_str())
}
_ => None,
})
.collect();
assert!(
opens.contains("Expenses:Food:Alice") && opens.contains("Expenses:Food:Bob"),
"Open directives for both sub-accounts must be emitted; got: {opens:?}"
);
}
#[test]
fn test_split_expenses_skips_already_split_account() {
let plugin = SplitExpensesPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Expenses:Food:Alice"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction(
"2024-01-15",
"Alice's lunch",
vec![
("Expenses:Food:Alice", "20", "USD"),
("Assets:Cash", "-20", "USD"),
],
),
],
"Alice Bob",
);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must remain");
let DirectiveData::Transaction(data) = &txn.data else {
panic!("non-Transaction data");
};
assert_eq!(
data.postings.len(),
2,
"already-split posting must NOT be re-split"
);
let alice = data
.postings
.iter()
.find(|p| p.account == "Expenses:Food:Alice")
.expect("Alice posting preserved");
assert_eq!(
alice.units.as_ref().unwrap().number,
"20",
"amount unchanged"
);
assert!(
alice.metadata.iter().all(|(k, _)| k != "__automatic__"),
"untouched posting must not get __automatic__ metadata"
);
}
#[test]
fn test_split_expenses_skips_non_expenses_postings() {
let plugin = SplitExpensesPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Income:Salary"),
make_transaction(
"2024-01-15",
"Paycheck",
vec![
("Income:Salary", "-1000", "USD"),
("Assets:Cash", "1000", "USD"),
],
),
],
"Alice Bob",
);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must remain");
let DirectiveData::Transaction(data) = &txn.data else {
panic!("non-Transaction data");
};
assert_eq!(
data.postings.len(),
2,
"non-Expenses postings are not split"
);
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(64))]
#[test]
fn prop_split_expenses_sum_preserves_total(
amount_cents in 1u32..1_000_000,
member_count in 1usize..=5,
) {
use rust_decimal::Decimal;
use std::str::FromStr;
let amount = Decimal::new(i64::from(amount_cents), 2);
let members: Vec<String> = (0..member_count).map(|i| format!("M{i}")).collect();
let config = members.join(" ");
let plugin = SplitExpensesPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Expenses:Food"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction(
"2024-01-15",
"Group meal",
vec![
("Expenses:Food", &amount.to_string(), "USD"),
("Assets:Cash", &(-amount).to_string(), "USD"),
],
),
],
&config,
);
let output = process_and_materialize(&plugin, input);
proptest::prop_assert!(output.errors.is_empty());
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must remain");
let DirectiveData::Transaction(data) = &txn.data else {
panic!("non-Transaction data");
};
let split_sum: Decimal = data
.postings
.iter()
.filter(|p| p.account.starts_with("Expenses:Food:"))
.filter_map(|p| p.units.as_ref())
.filter_map(|u| Decimal::from_str(&u.number).ok())
.sum();
let expected = (amount / Decimal::from(member_count)) * Decimal::from(member_count);
proptest::prop_assert_eq!(
split_sum, expected,
"sum of {} splits must equal (amount/N)*N for amount={}, N={}",
member_count, amount, member_count
);
proptest::prop_assert_eq!(
data.postings.len(), member_count + 1,
"posting count after split should be N+1 (split×N + cash)"
);
}
}
#[test]
fn test_unrealized_warns_on_unrealized_gain() {
let plugin = UnrealizedPlugin::new();
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_commodity("2024-01-01", "AAPL"),
make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
make_price("2024-06-15", "AAPL", "150", "USD"),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"exactly one warning for the single position with a market price"
);
let msg = &output.errors[0].message;
assert!(
msg.contains("500") && msg.contains("AAPL"),
"warning should report 500 USD gain on AAPL; got: {msg}"
);
assert_eq!(
output.errors[0].severity,
PluginErrorSeverity::Warning,
"unrealized changes are warnings, never errors"
);
}
#[test]
fn test_unrealized_warns_on_unrealized_loss() {
let plugin = UnrealizedPlugin::new();
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_commodity("2024-01-01", "AAPL"),
make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
make_price("2024-06-15", "AAPL", "50", "USD"),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 1, "exactly one warning");
let msg = &output.errors[0].message;
assert!(
msg.contains("-500") && msg.contains("AAPL"),
"warning should report -500 USD (loss) on AAPL; got: {msg}"
);
}
#[test]
fn test_unrealized_silent_when_market_equals_cost() {
let plugin = UnrealizedPlugin::new();
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_commodity("2024-01-01", "AAPL"),
make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
make_price("2024-06-15", "AAPL", "100", "USD"),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"no warning when market price equals cost basis (got {} warnings)",
output.errors.len()
);
}
#[test]
fn test_unrealized_silent_without_price_directive() {
let plugin = UnrealizedPlugin::new();
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_commodity("2024-01-01", "AAPL"),
make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"no warning emitted when there's no current price (got {} warnings)",
output.errors.len()
);
}
#[test]
fn test_unrealized_silent_for_zero_position() {
let plugin = UnrealizedPlugin::new();
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_commodity("2024-01-01", "AAPL"),
make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-03-15",
"Sell",
"Assets:Stock",
("-10", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
make_price("2024-06-15", "AAPL", "150", "USD"),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"no warning when position is fully closed (got {} warnings)",
output.errors.len()
);
}
#[test]
fn test_unrealized_aggregates_multiple_buys_into_position() {
let plugin = UnrealizedPlugin::new();
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_commodity("2024-01-01", "AAPL"),
make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("5", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-02-15",
"Buy",
"Assets:Stock",
("5", "AAPL"),
("200", "USD"),
"Assets:Cash",
),
make_price("2024-06-15", "AAPL", "150", "USD"),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"weighted-average cost basis: 10 units at avg $150 cost = $1500; \
market 10 × $150 = $1500; unrealized = 0 (got {} warnings)",
output.errors.len()
);
}
#[test]
fn test_unrealized_silent_when_quote_currency_is_not_usd() {
let plugin = UnrealizedPlugin::new();
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_commodity("2024-01-01", "ABC"),
make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "ABC"),
("100", "EUR"),
"Assets:Cash",
),
make_price("2024-06-15", "ABC", "150", "EUR"),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"non-USD quote currencies are skipped today (got {} warnings)",
output.errors.len()
);
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(64))]
#[test]
fn prop_unrealized_warning_amount_matches_units_times_delta(
units in 1u32..1000,
cost_cents in 1u32..1_000_000,
market_cents in 1u32..1_000_000,
) {
use rust_decimal::Decimal;
let to_dollars = |cents: u32| -> Decimal {
Decimal::new(i64::from(cents), 2)
};
let cost_d = to_dollars(cost_cents);
let market_d = to_dollars(market_cents);
let plugin = UnrealizedPlugin::new();
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_commodity("2024-01-01", "AAPL"),
make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
(&units.to_string(), "AAPL"),
(&cost_d.to_string(), "USD"),
"Assets:Cash",
),
make_price("2024-06-15", "AAPL", &market_d.to_string(), "USD"),
]);
let output = process_and_materialize(&plugin, input);
let units_d = Decimal::from(units);
let expected_gain = (market_d - cost_d) * units_d;
let above_threshold = expected_gain.abs() > Decimal::new(1, 2);
if above_threshold {
proptest::prop_assert_eq!(
output.errors.len(), 1,
"expected 1 warning for expected_gain={}", expected_gain
);
let msg = &output.errors[0].message;
proptest::prop_assert!(
msg.contains(&expected_gain.to_string()),
"warning '{}' should contain the exact gain {}",
msg, expected_gain
);
} else {
proptest::prop_assert!(
output.errors.is_empty(),
"no warning expected for expected_gain={} (≤ 0.01 threshold)",
expected_gain
);
}
}
#[test]
fn prop_unrealized_aggregates_two_buys_correctly(
units_a in 1u32..500,
units_b in 1u32..500,
cost_a_cents in 1u32..1_000_000,
cost_b_cents in 1u32..1_000_000,
market_cents in 1u32..1_000_000,
) {
use rust_decimal::Decimal;
let to_dollars = |cents: u32| Decimal::new(i64::from(cents), 2);
let cost_a_d = to_dollars(cost_a_cents);
let cost_b_d = to_dollars(cost_b_cents);
let market_d = to_dollars(market_cents);
let units_a_d = Decimal::from(units_a);
let units_b_d = Decimal::from(units_b);
let plugin = UnrealizedPlugin::new();
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_commodity("2024-01-01", "AAPL"),
make_transaction_with_cost(
"2024-01-15",
"Buy A",
"Assets:Stock",
(&units_a.to_string(), "AAPL"),
(&cost_a_d.to_string(), "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-02-15",
"Buy B",
"Assets:Stock",
(&units_b.to_string(), "AAPL"),
(&cost_b_d.to_string(), "USD"),
"Assets:Cash",
),
make_price("2024-06-15", "AAPL", &market_d.to_string(), "USD"),
]);
let output = process_and_materialize(&plugin, input);
let total_units = units_a_d + units_b_d;
let total_cost = cost_a_d * units_a_d + cost_b_d * units_b_d;
let expected_gain = total_units * market_d - total_cost;
let above_threshold = expected_gain.abs() > Decimal::new(1, 2);
if above_threshold {
proptest::prop_assert_eq!(
output.errors.len(), 1,
"expected 1 aggregated warning; expected_gain={}", expected_gain
);
let msg = &output.errors[0].message;
proptest::prop_assert!(
msg.contains(&expected_gain.to_string()),
"warning '{}' should contain aggregate gain {}",
msg, expected_gain
);
} else {
proptest::prop_assert!(
output.errors.is_empty(),
"no warning expected for aggregate gain={} (≤ 0.01)",
expected_gain
);
}
}
}
#[test]
fn test_unrealized_custom_gains_account_currently_unused_in_output() {
let plugin = UnrealizedPlugin::with_account("Income:Custom-Unrealized".to_string());
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_commodity("2024-01-01", "AAPL"),
make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
make_price("2024-06-15", "AAPL", "150", "USD"),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"warning still fires regardless of account customization"
);
assert!(
!output.errors[0]
.message
.contains("Income:Custom-Unrealized"),
"current behavior: gains_account is not surfaced in warnings"
);
}
fn make_open_with_booking(date: &str, account: &str, booking: &str) -> DirectiveWrapper {
DirectiveWrapper {
directive_type: "open".to_string(),
date: date.to_string(),
filename: None,
lineno: None,
data: DirectiveData::Open(OpenData {
account: account.to_string(),
currencies: vec![],
booking: Some(booking.to_string()),
metadata: vec![],
}),
}
}
#[test]
fn test_check_average_cost_silent_on_correct_sale() {
let plugin = CheckAverageCostPlugin::new();
let input = make_input(vec![
make_open_with_booking("2024-01-01", "Assets:Stock", "NONE"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-06-15",
"Sell at avg cost",
"Assets:Stock",
("-5", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"sale at exact average cost is fine; got {} warnings",
output.errors.len()
);
}
#[test]
fn test_check_average_cost_warns_when_sale_cost_diverges_from_average() {
let plugin = CheckAverageCostPlugin::new();
let input = make_input(vec![
make_open_with_booking("2024-01-01", "Assets:Stock", "NONE"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost(
"2024-01-15",
"Buy 1",
"Assets:Stock",
("5", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-02-15",
"Buy 2",
"Assets:Stock",
("5", "AAPL"),
("200", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-06-15",
"Sell below average",
"Assets:Stock",
("-3", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"exactly one warning for the diverging sale"
);
let msg = &output.errors[0].message;
assert!(
msg.contains("Assets:Stock") && msg.contains("AAPL"),
"warning should reference the account and commodity; got: {msg}"
);
}
#[test]
fn test_check_average_cost_skips_strict_booking_account() {
let plugin = CheckAverageCostPlugin::new();
let input = make_input(vec![
make_open_with_booking("2024-01-01", "Assets:Stock", "STRICT"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-06-15",
"Sell at very wrong cost",
"Assets:Stock",
("-5", "AAPL"),
("999", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"STRICT-booking account is skipped; got {} warnings",
output.errors.len()
);
}
#[test]
fn test_check_average_cost_skips_account_without_booking_specified() {
let plugin = CheckAverageCostPlugin::new();
let input = make_input(vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-06-15",
"Sell at wrong cost",
"Assets:Stock",
("-5", "AAPL"),
("500", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"account without explicit NONE booking is not checked"
);
}
#[test]
fn test_check_average_cost_respects_custom_tolerance() {
use rust_decimal::Decimal;
let plugin = CheckAverageCostPlugin::with_tolerance(Decimal::new(5, 1)); let input = make_input(vec![
make_open_with_booking("2024-01-01", "Assets:Stock", "NONE"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_cost(
"2024-01-15",
"Buy",
"Assets:Stock",
("10", "AAPL"),
("100", "USD"),
"Assets:Cash",
),
make_transaction_with_cost(
"2024-06-15",
"Sell at -40% (within tolerance)",
"Assets:Stock",
("-3", "AAPL"),
("60", "USD"),
"Assets:Cash",
),
]);
let output = process_and_materialize(&plugin, input);
assert!(
output.errors.is_empty(),
"40% deviation is within 50% custom tolerance; got {} warnings",
output.errors.len()
);
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(64))]
#[test]
fn prop_check_average_cost_sell_at_weighted_mean_produces_no_warning(
buys in proptest::collection::vec(
(1u32..100, 1u32..1_000_000),
1..=5,
),
) {
use rust_decimal::Decimal;
let total_units: Decimal = buys.iter()
.map(|(u, _)| Decimal::from(*u))
.sum();
let total_cost: Decimal = buys.iter()
.map(|(u, p)| Decimal::from(*u) * Decimal::new(i64::from(*p), 2))
.sum();
let avg = total_cost / total_units;
let mut directives: Vec<DirectiveWrapper> = vec![
DirectiveWrapper {
directive_type: "open".to_string(),
date: "2024-01-01".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Open(OpenData {
account: "Assets:Broker".to_string(),
currencies: vec![],
booking: Some("NONE".to_string()),
metadata: vec![],
}),
},
];
for (i, (units, price_cents)) in buys.iter().enumerate() {
let price = Decimal::new(i64::from(*price_cents), 2);
directives.push(make_transaction_with_cost(
&format!("2024-01-{:02}", (i % 28) + 1),
"Buy",
"Assets:Broker",
(&units.to_string(), "AAPL"),
(&price.to_string(), "USD"),
"Assets:Cash",
));
}
directives.push(make_transaction_with_cost(
"2024-12-01",
"Sell at average",
"Assets:Broker",
("-1", "AAPL"),
(&avg.to_string(), "USD"),
"Assets:Cash",
));
let plugin = CheckAverageCostPlugin::new();
let input = make_input(directives);
let output = process_and_materialize(&plugin, input);
proptest::prop_assert_eq!(
output.errors.len(), 0,
"selling at computed weighted mean {} should produce 0 warnings; \
buys={:?}, errors={:?}",
avg, buys, output.errors
);
}
}
const ZEROSUM_CFG: &str =
"{'zerosum_accounts': {'Assets:ZeroSum': ('Assets:ZeroSum-Matched', 30)}}";
#[test]
fn test_zerosum_requires_config() {
let plugin = ZerosumPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_transaction("2024-01-15", "Test", vec![("Assets:Cash", "100", "USD")]),
]);
let output = process_and_materialize(&plugin, input);
assert_eq!(
output.errors.len(),
1,
"exactly one error for missing required config"
);
assert!(output.errors[0].message.contains("requires configuration"));
}
#[test]
fn test_zerosum_matches_pair_within_window() {
let plugin = ZerosumPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Assets:ZeroSum"),
make_transaction(
"2024-01-05",
"Outgoing transfer",
vec![
("Assets:Cash", "-100", "USD"),
("Assets:ZeroSum", "100", "USD"),
],
),
make_transaction(
"2024-01-15",
"Incoming transfer",
vec![
("Assets:Cash", "100", "USD"),
("Assets:ZeroSum", "-100", "USD"),
],
),
],
ZEROSUM_CFG,
);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let zerosum_count: usize = output
.directives
.iter()
.filter_map(|d| match &d.data {
DirectiveData::Transaction(t) => Some(
t.postings
.iter()
.filter(|p| p.account == "Assets:ZeroSum")
.count(),
),
_ => None,
})
.sum();
let matched_count: usize = output
.directives
.iter()
.filter_map(|d| match &d.data {
DirectiveData::Transaction(t) => Some(
t.postings
.iter()
.filter(|p| p.account == "Assets:ZeroSum-Matched")
.count(),
),
_ => None,
})
.sum();
assert_eq!(
zerosum_count, 0,
"matched postings should leave the zerosum account"
);
assert_eq!(
matched_count, 2,
"both halves of the pair land in the matched account"
);
}
#[test]
fn test_zerosum_does_not_match_pair_outside_window() {
let plugin = ZerosumPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Assets:ZeroSum"),
make_transaction(
"2024-01-05",
"Outgoing",
vec![
("Assets:Cash", "-100", "USD"),
("Assets:ZeroSum", "100", "USD"),
],
),
make_transaction(
"2024-03-10",
"Incoming far in the future",
vec![
("Assets:Cash", "100", "USD"),
("Assets:ZeroSum", "-100", "USD"),
],
),
],
ZEROSUM_CFG,
);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let zerosum_count: usize = output
.directives
.iter()
.filter_map(|d| match &d.data {
DirectiveData::Transaction(t) => Some(
t.postings
.iter()
.filter(|p| p.account == "Assets:ZeroSum")
.count(),
),
_ => None,
})
.sum();
let matched_count: usize = output
.directives
.iter()
.filter_map(|d| match &d.data {
DirectiveData::Transaction(t) => Some(
t.postings
.iter()
.filter(|p| p.account == "Assets:ZeroSum-Matched")
.count(),
),
_ => None,
})
.sum();
assert_eq!(
zerosum_count, 2,
"out-of-window pair stays in the zerosum account"
);
assert_eq!(matched_count, 0, "no postings moved to matched account");
}
#[test]
fn test_zerosum_leaves_unmatched_posting_alone() {
let plugin = ZerosumPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Assets:ZeroSum"),
make_transaction(
"2024-01-05",
"Lonely outgoing",
vec![
("Assets:Cash", "-100", "USD"),
("Assets:ZeroSum", "100", "USD"),
],
),
],
ZEROSUM_CFG,
);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let zerosum_count: usize = output
.directives
.iter()
.filter_map(|d| match &d.data {
DirectiveData::Transaction(t) => Some(
t.postings
.iter()
.filter(|p| p.account == "Assets:ZeroSum")
.count(),
),
_ => None,
})
.sum();
assert_eq!(
zerosum_count, 1,
"unmatched posting remains in zerosum account"
);
}
#[test]
fn test_box_accrual_no_metadata_passthrough() {
let plugin = BoxAccrualPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"Normal transaction",
vec![
("Expenses:Food", "25", "USD"),
("Assets:Cash", "-25", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 3);
}
#[test]
fn test_box_accrual_multi_year_splits_preserve_total() {
use rust_decimal::Decimal;
use std::str::FromStr;
let plugin = BoxAccrualPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Income:Capital-Losses"),
make_open("2024-01-01", "Assets:Broker"),
make_transaction_with_metadata(
"2024-07-01",
"Sell synthetic spanning 3 years",
vec![(
"synthetic_loan_expiry",
MetaValueData::Date("2026-06-30".to_string()),
)],
vec![
("Income:Capital-Losses", "-1000.00", "USD"),
("Assets:Broker", "1000.00", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must remain");
let DirectiveData::Transaction(data) = &txn.data else {
panic!("non-Transaction data on transaction directive");
};
let losses_iter = || {
data.postings
.iter()
.filter(|p| p.account.ends_with(":Capital-Losses"))
};
assert_eq!(
losses_iter().count(),
3,
"3-year span yields 3 Capital-Losses postings"
);
for p in losses_iter() {
assert!(
p.metadata.iter().any(|(k, _)| k == "effective_date"),
"every split should carry effective_date metadata"
);
}
let sum: Decimal = losses_iter()
.filter_map(|p| p.units.as_ref())
.filter_map(|u| Decimal::from_str(&u.number).ok())
.sum();
assert_eq!(
sum,
Decimal::from_str("-1000.00").unwrap(),
"split amounts must sum exactly to the original total"
);
}
#[test]
fn test_box_accrual_same_year_no_split() {
let plugin = BoxAccrualPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Income:Capital-Losses"),
make_open("2024-01-01", "Assets:Broker"),
make_transaction_with_metadata(
"2024-03-01",
"Same-year span",
vec![(
"synthetic_loan_expiry",
MetaValueData::Date("2024-12-31".to_string()),
)],
vec![
("Income:Capital-Losses", "-365", "USD"),
("Assets:Broker", "365", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must remain");
let DirectiveData::Transaction(data) = &txn.data else {
panic!("non-Transaction data on transaction directive");
};
let loss_postings: Vec<_> = data
.postings
.iter()
.filter(|p| p.account.ends_with(":Capital-Losses"))
.collect();
assert_eq!(
loss_postings.len(),
1,
"same-year transaction is left with its original single posting"
);
assert!(
!loss_postings[0]
.metadata
.iter()
.any(|(k, _)| k == "effective_date"),
"single-year passthrough should not add effective_date metadata"
);
}
#[test]
fn test_box_accrual_no_capital_losses_posting_unchanged() {
let plugin = BoxAccrualPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction_with_metadata(
"2024-07-01",
"Lunch with stray expiry metadata",
vec![(
"synthetic_loan_expiry",
MetaValueData::Date("2026-06-30".to_string()),
)],
vec![
("Expenses:Food", "25", "USD"),
("Assets:Cash", "-25", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must remain");
let DirectiveData::Transaction(data) = &txn.data else {
panic!("non-Transaction data on transaction directive");
};
assert_eq!(
data.postings.len(),
2,
"no Capital-Losses → original postings preserved exactly"
);
}
#[test]
fn test_box_accrual_two_capital_losses_postings_unchanged() {
let plugin = BoxAccrualPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Income:Capital-Losses"),
make_open("2024-01-01", "Income:Other:Capital-Losses"),
make_open("2024-01-01", "Assets:Broker"),
make_transaction_with_metadata(
"2024-07-01",
"Two loss accounts",
vec![(
"synthetic_loan_expiry",
MetaValueData::Date("2026-06-30".to_string()),
)],
vec![
("Income:Capital-Losses", "-500", "USD"),
("Income:Other:Capital-Losses", "-500", "USD"),
("Assets:Broker", "1000", "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must remain");
let DirectiveData::Transaction(data) = &txn.data else {
panic!("non-Transaction data on transaction directive");
};
assert_eq!(
data.postings
.iter()
.filter(|p| p.account.ends_with(":Capital-Losses"))
.count(),
2,
"ambiguous case → both loss postings kept untouched"
);
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(64))]
#[test]
fn prop_box_accrual_split_amounts_preserve_total(
total_cents in 1u32..10_000_000,
start_month in 1u32..=12,
start_day in 1u32..=28,
extra_years in 1u32..=5,
expiry_month in 1u32..=12,
expiry_day in 1u32..=28,
) {
use rust_decimal::Decimal;
use std::str::FromStr;
let start_date = format!("2024-{start_month:02}-{start_day:02}");
let expiry_year = 2024 + extra_years;
let expiry_date = format!("{expiry_year:04}-{expiry_month:02}-{expiry_day:02}");
let total_loss = -Decimal::new(i64::from(total_cents), 2);
let plugin = BoxAccrualPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Income:Capital-Losses"),
make_open("2024-01-01", "Assets:Broker"),
make_transaction_with_metadata(
&start_date,
"Synthetic with random expiry",
vec![(
"synthetic_loan_expiry",
MetaValueData::Date(expiry_date.clone()),
)],
vec![
("Income:Capital-Losses", &total_loss.to_string(), "USD"),
("Assets:Broker", &(-total_loss).to_string(), "USD"),
],
),
]);
let output = process_and_materialize(&plugin, input);
proptest::prop_assert!(output.errors.is_empty());
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction must remain");
let DirectiveData::Transaction(data) = &txn.data else {
panic!("non-Transaction data");
};
let split_sum: Decimal = data
.postings
.iter()
.filter(|p| p.account.ends_with(":Capital-Losses"))
.filter_map(|p| p.units.as_ref())
.filter_map(|u| Decimal::from_str(&u.number).ok())
.sum();
proptest::prop_assert_eq!(
split_sum, total_loss,
"split sum ({}) must equal original total ({}) for {} -> {}",
split_sum, total_loss, start_date, expiry_date
);
}
}
fn make_long_short_sale(
entry_date: &str,
cost_date: &str,
asset: (&str, &str), cost: (&str, &str), price: (&str, &str), gain_account: &str,
gain_amount: (&str, &str),
) -> DirectiveWrapper {
DirectiveWrapper {
directive_type: "transaction".to_string(),
date: entry_date.to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: "Sell with cost-dated lot".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Stock".to_string(),
units: Some(AmountData {
number: asset.0.to_string(),
currency: asset.1.to_string(),
}),
cost: Some(CostData {
number_per: Some(cost.0.to_string()),
number_total: None,
currency: Some(cost.1.to_string()),
date: Some(cost_date.to_string()),
label: None,
merge: false,
}),
price: Some(PriceAnnotationData {
is_total: false,
amount: Some(AmountData {
number: price.0.to_string(),
currency: price.1.to_string(),
}),
number: None,
currency: None,
}),
flag: None,
metadata: vec![],
},
PostingData {
account: "Assets:Cash".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
PostingData {
account: gain_account.to_string(),
units: Some(AmountData {
number: gain_amount.0.to_string(),
currency: gain_amount.1.to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
}
}
const LONG_SHORT_CFG: &str =
"{'Income:Capital-Gains': [':Capital-Gains', ':Capital-Gains:Short', ':Capital-Gains:Long']}";
#[test]
fn test_capital_gains_long_short_no_config_passthrough() {
let plugin = CapitalGainsLongShortPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_transaction("2024-01-15", "Simple", vec![("Assets:Cash", "100", "USD")]),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 2);
}
#[test]
fn test_capital_gains_long_short_invalid_config_passthrough() {
let plugin = CapitalGainsLongShortPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Cash"),
make_transaction("2024-01-15", "Simple", vec![("Assets:Cash", "100", "USD")]),
],
"this is not valid plugin config",
);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 2);
}
#[test]
fn test_capital_gains_long_short_no_matching_postings_unchanged() {
let plugin = CapitalGainsLongShortPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Food"),
make_transaction(
"2024-01-15",
"Buy lunch",
vec![
("Expenses:Food", "10", "USD"),
("Assets:Cash", "-10", "USD"),
],
),
],
LONG_SHORT_CFG,
);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 0);
assert_eq!(
output.directives.len(),
3,
"no matching posting → no new Open directives, count unchanged"
);
}
#[test]
fn test_capital_gains_long_short_classifies_short_term() {
let plugin = CapitalGainsLongShortPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Income:Capital-Gains"),
make_long_short_sale(
"2024-07-15", "2024-01-15", ("-10", "AAPL"),
("100", "USD"),
("150", "USD"),
"Income:Capital-Gains",
("-500", "USD"),
),
],
LONG_SHORT_CFG,
);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 0);
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("rewritten transaction should still be present");
let DirectiveData::Transaction(data) = &txn.data else {
panic!(
"transaction directive_type with non-Transaction data: {:?}",
txn.data
);
};
let short_postings: Vec<&PostingData> = data
.postings
.iter()
.filter(|p| p.account.contains(":Capital-Gains:Short"))
.collect();
assert_eq!(short_postings.len(), 1, "short_term gain rebooks to :Short");
assert_eq!(
data.postings
.iter()
.filter(|p| p.account.contains(":Capital-Gains:Long"))
.count(),
0,
"no long-term posting expected"
);
let short_units = short_postings[0]
.units
.as_ref()
.expect("short posting must have units");
assert_eq!(
short_units.number, "-500",
"short_term gain amount = (cost - price) * |units| = -500"
);
assert_eq!(short_units.currency, "USD");
assert!(
output.directives.iter().any(|d| {
if let DirectiveData::Open(o) = &d.data {
o.account.contains(":Capital-Gains:Short")
} else {
false
}
}),
"plugin should emit Open for the new short-term account"
);
}
#[test]
fn test_capital_gains_long_short_classifies_long_term() {
let plugin = CapitalGainsLongShortPlugin;
let input = make_input_with_config(
vec![
make_open("2022-01-01", "Assets:Stock"),
make_open("2022-01-01", "Assets:Cash"),
make_open("2022-01-01", "Income:Capital-Gains"),
make_long_short_sale(
"2024-07-15",
"2022-01-15", ("-10", "AAPL"),
("100", "USD"),
("150", "USD"),
"Income:Capital-Gains",
("-500", "USD"),
),
],
LONG_SHORT_CFG,
);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 0);
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("rewritten transaction should still be present");
let DirectiveData::Transaction(data) = &txn.data else {
panic!(
"transaction directive_type with non-Transaction data: {:?}",
txn.data
);
};
let long_postings: Vec<&PostingData> = data
.postings
.iter()
.filter(|p| p.account.contains(":Capital-Gains:Long"))
.collect();
assert_eq!(long_postings.len(), 1, "long_term gain rebooks to :Long");
assert_eq!(
data.postings
.iter()
.filter(|p| p.account.contains(":Capital-Gains:Short"))
.count(),
0,
"no short-term posting expected"
);
let long_units = long_postings[0]
.units
.as_ref()
.expect("long posting must have units");
assert_eq!(
long_units.number, "-500",
"long_term gain amount = (cost - price) * |units| = -500"
);
assert_eq!(long_units.currency, "USD");
}
#[test]
fn test_capital_gains_long_short_no_cost_date_passes_through_unchanged() {
let plugin = CapitalGainsLongShortPlugin;
let txn = DirectiveWrapper {
directive_type: "transaction".to_string(),
date: "2024-07-15".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: "Sell with no-date cost".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Assets:Stock".to_string(),
units: Some(AmountData {
number: "-10".to_string(),
currency: "AAPL".to_string(),
}),
cost: Some(CostData {
number_per: Some("100".to_string()),
number_total: None,
currency: Some("USD".to_string()),
date: None, label: None,
merge: false,
}),
price: Some(PriceAnnotationData {
is_total: false,
amount: Some(AmountData {
number: "150".to_string(),
currency: "USD".to_string(),
}),
number: None,
currency: None,
}),
flag: None,
metadata: vec![],
},
PostingData {
account: "Assets:Cash".to_string(),
units: None,
cost: None,
price: None,
flag: None,
metadata: vec![],
},
PostingData {
account: "Income:Capital-Gains".to_string(),
units: Some(AmountData {
number: "-500".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
};
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Stock"),
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Income:Capital-Gains"),
txn,
],
LONG_SHORT_CFG,
);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 0);
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction still present");
let DirectiveData::Transaction(data) = &txn.data else {
panic!(
"non-Transaction data on transaction directive: {:?}",
txn.data
);
};
assert_eq!(
data.postings
.iter()
.filter(|p| p.account.contains(":Capital-Gains:Short")
|| p.account.contains(":Capital-Gains:Long"))
.count(),
0,
"no Short/Long postings emitted when cost_date is missing"
);
assert_eq!(
data.postings
.iter()
.filter(|p| p.account == "Income:Capital-Gains")
.count(),
1,
"generic Income:Capital-Gains posting must be preserved when \
the plugin falls through (issue #1010 fix)"
);
assert_eq!(
data.postings.len(),
3,
"all three input postings preserved on fall-through"
);
}
const GAIN_LOSS_CFG: &str =
"{'Income:Capital-Gains:Long': [':Long', ':Long:Gains', ':Long:Losses']}";
#[test]
fn test_capital_gains_gain_loss_no_config_passthrough() {
let plugin = CapitalGainsGainLossPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_transaction("2024-01-15", "Simple", vec![("Assets:Cash", "100", "USD")]),
]);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 2);
}
#[test]
fn test_capital_gains_gain_loss_invalid_config_passthrough() {
let plugin = CapitalGainsGainLossPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Cash"),
make_transaction("2024-01-15", "Simple", vec![("Assets:Cash", "100", "USD")]),
],
"{ malformed",
);
let output = process_and_materialize(&plugin, input);
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 2);
}
#[test]
fn test_capital_gains_gain_loss_negative_renames_to_gains() {
let plugin = CapitalGainsGainLossPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Broker"),
make_open("2024-01-01", "Income:Capital-Gains:Long"),
make_transaction(
"2024-01-15",
"Sell with gain",
vec![
("Assets:Broker", "1000", "USD"),
("Income:Capital-Gains:Long", "-100", "USD"),
],
),
],
GAIN_LOSS_CFG,
);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 0);
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction still present");
let DirectiveData::Transaction(data) = &txn.data else {
panic!(
"non-Transaction data on transaction directive: {:?}",
txn.data
);
};
let renamed = data
.postings
.iter()
.find(|p| p.account == "Income:Capital-Gains:Long:Gains")
.unwrap_or_else(|| {
panic!(
"negative posting should rebook to ...:Gains; got: {:?}",
data.postings.iter().map(|p| &p.account).collect::<Vec<_>>()
)
});
let renamed_units = renamed
.units
.as_ref()
.expect("renamed posting must keep its units");
assert_eq!(
renamed_units.number, "-100",
"rename preserves the original units value"
);
assert_eq!(renamed_units.currency, "USD");
assert!(
!data
.postings
.iter()
.any(|p| p.account == "Income:Capital-Gains:Long"),
"original posting should have been renamed away"
);
}
#[test]
fn test_capital_gains_gain_loss_positive_renames_to_losses() {
let plugin = CapitalGainsGainLossPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Broker"),
make_open("2024-01-01", "Income:Capital-Gains:Long"),
make_transaction(
"2024-01-15",
"Sell at loss",
vec![
("Assets:Broker", "-100", "USD"),
("Income:Capital-Gains:Long", "100", "USD"),
],
),
],
GAIN_LOSS_CFG,
);
let output = process_and_materialize(&plugin, input);
assert_eq!(output.errors.len(), 0);
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction still present");
let DirectiveData::Transaction(data) = &txn.data else {
panic!(
"non-Transaction data on transaction directive: {:?}",
txn.data
);
};
let renamed = data
.postings
.iter()
.find(|p| p.account == "Income:Capital-Gains:Long:Losses")
.unwrap_or_else(|| {
panic!(
"positive posting should rebook to ...:Losses; got: {:?}",
data.postings.iter().map(|p| &p.account).collect::<Vec<_>>()
)
});
let renamed_units = renamed
.units
.as_ref()
.expect("renamed posting must keep its units");
assert_eq!(
renamed_units.number, "100",
"rename preserves the original units value"
);
assert_eq!(renamed_units.currency, "USD");
}
#[test]
fn test_capital_gains_gain_loss_pattern_no_match_unchanged() {
let plugin = CapitalGainsGainLossPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Broker"),
make_open("2024-01-01", "Income:Capital-Gains:Short"),
make_transaction(
"2024-01-15",
"Short-term sale",
vec![
("Assets:Broker", "1000", "USD"),
("Income:Capital-Gains:Short", "-100", "USD"),
],
),
],
GAIN_LOSS_CFG,
);
let output = process_and_materialize(&plugin, input);
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction still present");
let DirectiveData::Transaction(data) = &txn.data else {
panic!(
"non-Transaction data on transaction directive: {:?}",
txn.data
);
};
assert!(
data.postings
.iter()
.any(|p| p.account == "Income:Capital-Gains:Short"),
"non-matching account should be left untouched"
);
}
#[test]
fn test_capital_gains_gain_loss_zero_renames_to_losses() {
let plugin = CapitalGainsGainLossPlugin;
let input = make_input_with_config(
vec![
make_open("2024-01-01", "Assets:Broker"),
make_open("2024-01-01", "Income:Capital-Gains:Long"),
make_transaction(
"2024-01-15",
"Zero-amount edge case",
vec![
("Assets:Broker", "0", "USD"),
("Income:Capital-Gains:Long", "0", "USD"),
],
),
],
GAIN_LOSS_CFG,
);
let output = process_and_materialize(&plugin, input);
let txn = output
.directives
.iter()
.find(|d| d.directive_type == "transaction")
.expect("transaction still present");
let DirectiveData::Transaction(data) = &txn.data else {
panic!(
"non-Transaction data on transaction directive: {:?}",
txn.data
);
};
let renamed = data
.postings
.iter()
.find(|p| p.account == "Income:Capital-Gains:Long:Losses")
.unwrap_or_else(|| {
panic!(
"zero posting goes to :Losses (the >= 0 branch); got: {:?}",
data.postings.iter().map(|p| &p.account).collect::<Vec<_>>()
)
});
let renamed_units = renamed
.units
.as_ref()
.expect("renamed posting must keep its units");
assert_eq!(
renamed_units.number, "0",
"zero amount preserved through the rename"
);
}