use rust_decimal::Decimal;
use rustledger_core::{
Amount, Currency, Directive, Inventory, NaiveDate, Pad, Position, Posting, Transaction,
};
use std::collections::HashMap;
use std::ops::Neg;
pub const SYNTH_PAD_NARRATION_PREFIX: &str = "(Padding inserted for Balance of ";
#[must_use]
pub fn is_synthesized_pad(txn: &Transaction) -> bool {
txn.flag == 'P'
&& txn
.narration
.as_str()
.starts_with(SYNTH_PAD_NARRATION_PREFIX)
}
#[derive(Debug, Clone)]
pub struct PadResult {
pub padding_transactions: Vec<Transaction>,
pub errors: Vec<PadError>,
}
#[derive(Debug, Clone)]
pub struct PadError {
pub date: NaiveDate,
pub message: String,
pub account: Option<rustledger_core::Account>,
}
impl PadError {
pub fn new(date: NaiveDate, message: impl Into<String>) -> Self {
Self {
date,
message: message.into(),
account: None,
}
}
pub fn with_account(mut self, account: impl Into<rustledger_core::Account>) -> Self {
self.account = Some(account.into());
self
}
}
#[derive(Debug, Clone)]
struct PendingPad {
pad: Pad,
used: bool,
padded_currencies: std::collections::HashSet<Currency>,
}
pub fn process_pads(directives: &[Directive]) -> PadResult {
let num_directives = directives.len();
let mut inventories: HashMap<rustledger_core::Account, Inventory> =
HashMap::with_capacity(num_directives.min(16));
let mut pending_pads: HashMap<rustledger_core::Account, PendingPad> = HashMap::with_capacity(4);
let mut padding_transactions = Vec::with_capacity(num_directives.min(16));
let mut errors = Vec::with_capacity(4);
let mut sorted: Vec<&Directive> = directives.iter().collect();
sorted.sort_by_key(|d| d.date());
for directive in sorted {
match directive {
Directive::Open(open) => {
inventories.insert(open.account.clone(), Inventory::new());
}
Directive::Transaction(txn) => {
for posting in &txn.postings {
if let Some(units) = posting.amount()
&& let Some(inv) = inventories.get_mut(&posting.account)
{
let position = if let Some(cost_spec) = &posting.cost {
if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
Position::with_cost(units.clone(), cost)
} else {
Position::simple(units.clone())
}
} else {
Position::simple(units.clone())
};
inv.add(position);
}
}
}
Directive::Pad(pad) => {
pending_pads.insert(
pad.account.clone(),
PendingPad {
pad: pad.clone(),
used: false,
padded_currencies: std::collections::HashSet::new(),
},
);
}
Directive::Balance(bal) => {
if let Some(pending) = pending_pads.get_mut(&bal.account) {
if pending.padded_currencies.contains(&bal.amount.currency) {
continue;
}
let current = inventories
.get(&bal.account)
.map_or(Decimal::ZERO, |inv| inv.units(&bal.amount.currency));
let difference = bal.amount.number - current;
if difference != Decimal::ZERO {
let pad_txn = create_padding_transaction(
pending.pad.date,
&pending.pad.account,
&pending.pad.source_account,
Amount::new(difference, &bal.amount.currency),
&bal.amount, );
if let Some(inv) = inventories.get_mut(&pending.pad.account) {
inv.add(Position::simple(Amount::new(
difference,
&bal.amount.currency,
)));
}
if let Some(inv) = inventories.get_mut(&pending.pad.source_account) {
inv.add(Position::simple(Amount::new(
-difference,
&bal.amount.currency,
)));
}
padding_transactions.push(pad_txn);
}
pending.used = true;
pending
.padded_currencies
.insert(bal.amount.currency.clone());
}
}
_ => {}
}
}
for (account, pending) in pending_pads {
if !pending.used {
errors.push(
PadError::new(
pending.pad.date,
format!(
"Pad directive for account {account} has no corresponding balance assertion"
),
)
.with_account(account),
);
}
}
PadResult {
padding_transactions,
errors,
}
}
fn create_padding_transaction(
date: NaiveDate,
target_account: &str,
source_account: &str,
difference: Amount,
balance: &Amount,
) -> Transaction {
let narration = format!(
"{prefix}{bal_num} {bal_cur} for difference {diff_num} {diff_cur})",
prefix = SYNTH_PAD_NARRATION_PREFIX,
bal_num = balance.number,
bal_cur = balance.currency,
diff_num = difference.number,
diff_cur = difference.currency,
);
Transaction::new(date, &narration)
.with_flag('P')
.with_synthesized_posting(Posting::new(target_account, difference.clone()))
.with_synthesized_posting(Posting::new(source_account, difference.neg()))
}
pub fn merge_with_padding(directives: &[Directive]) -> Vec<Directive> {
debug_assert!(
!directives
.iter()
.any(|d| matches!(d, Directive::Transaction(t) if is_synthesized_pad(t))),
"merge_with_padding called on input that already contains synth pad transactions; \
re-running would double-count pad effects",
);
let result = process_pads(directives);
let mut merged: Vec<Directive> =
Vec::with_capacity(directives.len() + result.padding_transactions.len());
for txn in result.padding_transactions {
merged.push(Directive::Transaction(txn));
}
merged.extend(directives.iter().cloned());
merged.sort_by_key(rustledger_core::Directive::date);
merged
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
use rustledger_core::{Balance, Open};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
rustledger_core::naive_date(year, month, day).unwrap()
}
#[test]
fn test_process_pads_basic() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 2),
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
)),
];
let result = process_pads(&directives);
assert!(result.errors.is_empty());
assert_eq!(result.padding_transactions.len(), 1);
let txn = &result.padding_transactions[0];
assert_eq!(txn.date, date(2024, 1, 1));
assert_eq!(txn.postings.len(), 2);
assert_eq!(txn.postings[0].account, "Assets:Bank");
assert_eq!(
txn.postings[0].amount(),
Some(&Amount::new(dec!(1000.00), "USD"))
);
assert_eq!(txn.postings[1].account, "Equity:Opening");
assert_eq!(
txn.postings[1].amount(),
Some(&Amount::new(dec!(-1000.00), "USD"))
);
}
#[test]
fn test_process_pads_with_existing_balance() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Transaction(
Transaction::new(date(2024, 1, 5), "Deposit")
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(500.00), "USD"),
))
.with_synthesized_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-500.00), "USD"),
)),
),
Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 15),
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
)),
];
let result = process_pads(&directives);
assert!(result.errors.is_empty());
assert_eq!(result.padding_transactions.len(), 1);
let txn = &result.padding_transactions[0];
assert_eq!(
txn.postings[0].amount(),
Some(&Amount::new(dec!(500.00), "USD"))
);
}
#[test]
fn test_process_pads_negative_adjustment() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Transaction(
Transaction::new(date(2024, 1, 5), "Big deposit")
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(2000.00), "USD"),
))
.with_synthesized_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-2000.00), "USD"),
)),
),
Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 15),
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
)),
];
let result = process_pads(&directives);
assert!(result.errors.is_empty());
assert_eq!(result.padding_transactions.len(), 1);
let txn = &result.padding_transactions[0];
assert_eq!(
txn.postings[0].amount(),
Some(&Amount::new(dec!(-1000.00), "USD"))
);
}
#[test]
fn test_process_pads_no_difference() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Transaction(
Transaction::new(date(2024, 1, 5), "Exact deposit")
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
))
.with_synthesized_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-1000.00), "USD"),
)),
),
Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 15),
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
)),
];
let result = process_pads(&directives);
assert!(result.errors.is_empty());
assert!(result.padding_transactions.is_empty());
}
#[test]
fn test_process_pads_unused_pad() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
];
let result = process_pads(&directives);
assert_eq!(result.errors.len(), 1);
assert!(
result.errors[0]
.message
.contains("no corresponding balance")
);
}
#[test]
fn test_merge_with_padding() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 2),
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
)),
];
let merged = merge_with_padding(&directives);
assert_eq!(merged.len(), 5);
let has_pad = merged.iter().any(|d| matches!(d, Directive::Pad(_)));
assert!(has_pad, "Pad should be preserved");
let txn_count = merged
.iter()
.filter(|d| matches!(d, Directive::Transaction(_)))
.count();
assert_eq!(txn_count, 1);
}
#[test]
fn test_is_synthesized_pad_recognizes_synth() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 2),
"Assets:Bank",
Amount::new(dec!(1000), "USD"),
)),
];
let result = process_pads(&directives);
let synth = result.padding_transactions.into_iter().next().unwrap();
assert!(
is_synthesized_pad(&synth),
"synth pad transaction must be detected by is_synthesized_pad",
);
}
#[test]
fn test_is_synthesized_pad_rejects_user_p_flag() {
let user_p = Transaction::new(date(2024, 1, 1), "user-authored P-flag txn")
.with_flag('P')
.with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")));
assert!(
!is_synthesized_pad(&user_p),
"user-written P-flag transaction must not be classified as synth",
);
}
#[test]
fn test_merge_with_padding_same_date_pad_balance_synth_comes_first() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 2),
"Assets:Bank",
Amount::new(dec!(1000), "USD"),
)),
];
let merged = merge_with_padding(&directives);
let synth_idx = merged
.iter()
.position(|d| matches!(d, Directive::Transaction(t) if is_synthesized_pad(t)))
.expect("synth present");
let balance_idx = merged
.iter()
.position(|d| matches!(d, Directive::Balance(_)))
.expect("balance present");
assert!(
synth_idx < balance_idx,
"synth pad (idx {synth_idx}) must appear before Balance (idx {balance_idx}) on same date",
);
}
#[test]
#[should_panic(expected = "merge_with_padding called on input that already contains synth")]
fn test_merge_with_padding_double_apply_debug_asserts() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 2),
"Assets:Bank",
Amount::new(dec!(1000), "USD"),
)),
];
let merged_once = merge_with_padding(&directives);
let _merged_twice = merge_with_padding(&merged_once); }
#[test]
fn test_padding_transaction_has_p_flag() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 2),
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
)),
];
let result = process_pads(&directives);
assert_eq!(result.padding_transactions.len(), 1);
assert_eq!(result.padding_transactions[0].flag, 'P');
}
#[test]
fn test_process_pads_multiple_currencies() {
let directives = vec![
Directive::Open(Open::new(date(2007, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2007, 1, 1), "Equity:Opening")),
Directive::Pad(Pad::new(
date(2007, 12, 30),
"Assets:Cash",
"Equity:Opening",
)),
Directive::Balance(Balance::new(
date(2007, 12, 31),
"Assets:Cash",
Amount::new(dec!(200), "CAD"),
)),
Directive::Balance(Balance::new(
date(2007, 12, 31),
"Assets:Cash",
Amount::new(dec!(300), "USD"),
)),
];
let result = process_pads(&directives);
assert!(result.errors.is_empty(), "Should have no errors");
assert_eq!(
result.padding_transactions.len(),
2,
"Should generate TWO padding transactions (one per currency)"
);
let currencies: Vec<_> = result
.padding_transactions
.iter()
.filter_map(|txn| txn.postings.first())
.filter_map(|p| p.amount())
.map(|a| a.currency.as_str())
.collect();
assert!(currencies.contains(&"CAD"), "Should pad CAD");
assert!(currencies.contains(&"USD"), "Should pad USD");
}
#[test]
fn test_process_pads_transaction_after_balance_ends_pad() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 2),
"Assets:Bank",
Amount::new(dec!(1000), "USD"),
)),
Directive::Transaction(
Transaction::new(date(2024, 1, 3), "Spending")
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-100), "USD"),
))
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(100), "USD"),
)),
),
Directive::Balance(Balance::new(
date(2024, 1, 5),
"Assets:Bank",
Amount::new(dec!(900), "USD"),
)),
];
let result = process_pads(&directives);
assert_eq!(result.padding_transactions.len(), 1);
assert_eq!(
result.padding_transactions[0]
.postings
.first()
.and_then(|p| p.amount())
.map(|a| a.number),
Some(dec!(1000))
);
}
}