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::types::*;
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(
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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(input);
assert!(output.errors.is_empty(), "expected no errors");
}
#[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 = plugin.process(input);
assert!(
!output.errors.is_empty(),
"expected warning for undeclared USD"
);
assert!(
output.errors.iter().any(|e| e.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 = plugin.process(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_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 = plugin.process(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 = plugin.process(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 = plugin.process(input);
assert!(output.errors.is_empty(), "expected no errors");
}
#[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 = plugin.process(input);
let price_count = output
.directives
.iter()
.filter(|d| d.directive_type == "price")
.count();
assert!(
price_count >= 1,
"should generate at least 1 price directive"
);
let price = output
.directives
.iter()
.find(|d| d.directive_type == "price");
assert!(price.is_some(), "should have a price directive");
if let Some(p) = price
&& let DirectiveData::Price(price_data) = &p.data
{
assert_eq!(price_data.currency, "HOOL");
assert_eq!(price_data.amount.currency, "USD");
}
}
#[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 = 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 = plugin.process(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 = 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 output = plugin.process(input);
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 = plugin.process(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 = plugin.process(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"
);
}
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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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 = plugin.process(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![],
}),
}
}
#[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 = plugin.process(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!(
!data.tags.is_empty(),
"auto_tag should add a tag for Expenses:Food 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 = plugin.process(input);
assert!(!output.errors.is_empty(), "should warn about Assets:Unused");
assert!(
output.errors.iter().any(|e| e.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 = plugin.process(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 = plugin.process(input);
assert!(
!output.errors.is_empty(),
"pedantic should catch 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 = plugin.process(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 {
let has_final = data.metadata.iter().any(|(k, _)| k == "final");
let has_roll = data.metadata.iter().any(|(k, _)| k == "roll");
assert!(
has_final || has_roll,
"rx_txn should add 'final' and/or 'roll' metadata to tagged transaction"
);
}
}
#[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 = plugin.process(input);
assert!(output.errors.is_empty());
}
#[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 = plugin.process(input);
assert!(
!output.errors.is_empty(),
"should warn about missing gains 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 = plugin.process(input);
assert!(output.errors.is_empty());
let balance_count = output
.directives
.iter()
.filter(|d| d.directive_type == "balance")
.count();
assert!(
balance_count > 0,
"should insert balance assertions after close"
);
}
#[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 = plugin.process(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 = plugin.process(input);
assert!(
!output.errors.is_empty(),
"should error when required 'name' attribute is missing"
);
}
#[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 = plugin.process(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"
);
}
}
#[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 = plugin.process(input);
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 3);
}
#[test]
fn test_effective_date_splits_transaction() {
let plugin = EffectiveDatePlugin;
let mut txn = make_transaction(
"2024-01-15",
"Deferred expense",
vec![
("Expenses:Food", "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("2024-02-15".to_string()),
));
}
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Food"),
txn,
]);
let output = plugin.process(input);
assert!(output.errors.is_empty());
assert!(
output.directives.len() > 3,
"effective_date should split into multiple directives (got {})",
output.directives.len()
);
}
#[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 = plugin.process(input);
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 3);
}
#[test]
fn test_forecast_expands_recurring_transaction() {
let plugin = ForecastPlugin;
let forecast_txn = 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: "Rent [MONTHLY REPEAT 3 TIMES]".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![],
},
],
}),
};
let input = make_input(vec![
make_open("2024-01-01", "Assets:Cash"),
make_open("2024-01-01", "Expenses:Rent"),
forecast_txn,
]);
let output = plugin.process(input);
assert!(output.errors.is_empty());
let txn_count = output
.directives
.iter()
.filter(|d| d.directive_type == "transaction")
.count();
assert!(
txn_count >= 3,
"forecast should expand to at least 3 transactions (got {txn_count})"
);
}
#[test]
fn test_generate_base_ccy_prices_creates_derived_price() {
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 = plugin.process(input);
assert!(output.errors.is_empty());
let price_count = output
.directives
.iter()
.filter(|d| d.directive_type == "price")
.count();
assert!(
price_count > 2,
"should generate derived price entries (got {price_count})"
);
}
#[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 = plugin.process(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_divides_by_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 = plugin.process(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 {
let expense_postings: Vec<_> = data
.postings
.iter()
.filter(|p| p.account.starts_with("Expenses:Food"))
.collect();
assert!(
expense_postings.len() >= 2,
"should split expense into at least 2 member postings (got {})",
expense_postings.len()
);
}
}
#[test]
fn test_unrealized_reports_unrealized_gains() {
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 = plugin.process(input);
assert!(
output.errors.is_empty()
|| output
.errors
.iter()
.all(|e| e.severity == PluginErrorSeverity::Warning)
);
}
#[test]
fn test_check_average_cost_no_error_on_correct_sale() {
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",
),
]);
let output = plugin.process(input);
assert!(output.errors.is_empty());
}
#[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 = plugin.process(input);
assert!(!output.errors.is_empty(), "should error without config");
assert!(output.errors[0].message.contains("requires configuration"));
}
#[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 = plugin.process(input);
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 3);
}
#[test]
fn test_box_accrual_with_metadata_splits_losses() {
let plugin = BoxAccrualPlugin;
let input = make_input(vec![
make_open("2024-01-01", "Income:Capital-Losses"),
make_open("2024-01-01", "Assets:Cash"),
make_transaction_with_metadata(
"2024-07-01",
"Loss with expiry",
vec![(
"synthetic_loan_expiry",
MetaValueData::Date("2026-06-30".to_string()),
)],
vec![
("Income:Capital-Losses", "-1000", "USD"),
("Assets:Cash", "1000", "USD"),
],
),
]);
let output = plugin.process(input);
assert!(output.errors.is_empty());
}
#[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 = plugin.process(input);
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 2);
}
#[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 = plugin.process(input);
assert!(output.errors.is_empty());
assert_eq!(output.directives.len(), 2);
}