use rust_decimal::Decimal;
use rustledger_core::{
Amount, Directive, InternedStr, Inventory, NaiveDate, Pad, Position, Posting, Transaction,
};
use std::collections::HashMap;
use std::ops::Neg;
#[derive(Debug, Clone)]
pub struct PadResult {
pub directives: Vec<Directive>,
pub padding_transactions: Vec<Transaction>,
pub errors: Vec<PadError>,
}
#[derive(Debug, Clone)]
pub struct PadError {
pub date: NaiveDate,
pub message: String,
pub account: Option<InternedStr>,
}
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<InternedStr>) -> Self {
self.account = Some(account.into());
self
}
}
#[derive(Debug, Clone)]
struct PendingPad {
pad: Pad,
used: bool,
padded_currencies: std::collections::HashSet<InternedStr>,
}
pub fn process_pads(directives: &[Directive]) -> PadResult {
let num_directives = directives.len();
let mut inventories: HashMap<InternedStr, Inventory> =
HashMap::with_capacity(num_directives.min(16));
let mut pending_pads: HashMap<InternedStr, 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 {
directives: directives.to_vec(),
padding_transactions,
errors,
}
}
fn create_padding_transaction(
date: NaiveDate,
target_account: &str,
source_account: &str,
difference: Amount,
balance: &Amount,
) -> Transaction {
let narration = format!(
"(Padding inserted for Balance of {} {} for difference {} {})",
balance.number, balance.currency, difference.number, difference.currency
);
Transaction::new(date, &narration)
.with_flag('P')
.with_posting(Posting::new(target_account, difference.clone()))
.with_posting(Posting::new(source_account, difference.neg()))
}
pub fn expand_pads(directives: &[Directive]) -> Vec<Directive> {
let result = process_pads(directives);
let mut expanded: Vec<Directive> = Vec::new();
let mut sorted_originals: Vec<&Directive> = directives.iter().collect();
sorted_originals.sort_by_key(|d| d.date());
let mut pad_txns_by_date: HashMap<NaiveDate, Vec<&Transaction>> = HashMap::new();
for txn in &result.padding_transactions {
pad_txns_by_date.entry(txn.date).or_default().push(txn);
}
for directive in sorted_originals {
match directive {
Directive::Pad(pad) => {
if let Some(txns) = pad_txns_by_date.get(&pad.date) {
for txn in txns {
if txn.postings.iter().any(|p| p.account == pad.account) {
expanded.push(Directive::Transaction((*txn).clone()));
}
}
}
}
other => {
expanded.push(other.clone());
}
}
}
expanded
}
pub fn merge_with_padding(directives: &[Directive]) -> Vec<Directive> {
let result = process_pads(directives);
let mut merged: Vec<Directive> = directives.to_vec();
for txn in result.padding_transactions {
merged.push(Directive::Transaction(txn));
}
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_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(500.00), "USD"),
))
.with_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_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(2000.00), "USD"),
))
.with_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_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
))
.with_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_expand_pads() {
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 expanded = expand_pads(&directives);
assert_eq!(expanded.len(), 4);
let has_pad = expanded.iter().any(|d| matches!(d, Directive::Pad(_)));
assert!(!has_pad, "Pad should be replaced");
let txn_count = expanded
.iter()
.filter(|d| matches!(d, Directive::Transaction(_)))
.count();
assert_eq!(txn_count, 1);
}
#[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_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_posting(Posting::new("Assets:Bank", Amount::new(dec!(-100), "USD")))
.with_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))
);
}
}