use crate::types::{
AmountData, CostData, DirectiveData, DirectiveWrapper, PluginError, PluginInput, PluginOp,
PluginOutput, PriceAnnotationData, PriceAnnotationView, PriceData,
};
use rust_decimal::Decimal;
use rustledger_core::extract_per_unit_price;
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use super::super::{NativePlugin, RegularPlugin};
type LotKey = (String, String, Option<String>);
fn cost_fingerprint(cost: &CostData, units_number: Decimal) -> Option<String> {
let currency = cost.currency.as_deref()?;
let cn = cost.number.as_ref()?;
let per_unit_decimal: Decimal = if let Some(per_str) = cn.per_unit() {
Decimal::from_str(per_str).ok()?
} else {
let total_str = cn.total()?;
let total = Decimal::from_str(total_str).ok()?;
if units_number.is_zero() {
return None;
}
total / units_number.abs()
};
let per_unit = per_unit_decimal.normalize().to_string();
let date = cost.date.as_deref().unwrap_or("");
let label = cost.label.as_deref().unwrap_or("");
Some(format!("{per_unit}|{currency}|{date}|{label}"))
}
pub struct ImplicitPricesPlugin;
impl NativePlugin for ImplicitPricesPlugin {
fn name(&self) -> &'static str {
"implicit_prices"
}
fn description(&self) -> &'static str {
"Generate price entries from transaction costs/prices"
}
fn process(&self, input: PluginInput) -> PluginOutput {
let mut generated_prices = Vec::new();
let mut errors: Vec<PluginError> = Vec::new();
let mut inventory: HashMap<LotKey, Decimal> = HashMap::new();
let mut emitted_keys: HashSet<(String, String, String, String)> = HashSet::new();
for wrapper in &input.directives {
if wrapper.directive_type != "transaction" {
continue;
}
let DirectiveData::Transaction(ref txn) = wrapper.data else {
continue;
};
for posting in &txn.postings {
let Some(ref units) = posting.units else {
continue;
};
let Ok(units_number) = Decimal::from_str(&units.number) else {
continue;
};
let annotation = posting
.price
.as_ref()
.map(PriceAnnotationData::view)
.and_then(|view| {
let (is_total, amount) = match view {
PriceAnnotationView::Unit(a) => (false, a),
PriceAnnotationView::Total(a) => (true, a),
PriceAnnotationView::UnitIncomplete { .. }
| PriceAnnotationView::TotalIncomplete { .. } => return None,
};
let number = Decimal::from_str(&amount.number).ok()?;
Some((is_total, number, amount.currency.clone()))
});
let cost_result = posting.cost.as_ref().and_then(|c| {
let currency = c.currency.clone()?;
let number = match &c.number {
Some(rustledger_plugin_types::CostNumberData::PerUnit { value: n }) => {
match Decimal::from_str(n) {
Ok(d) => Some(rustledger_core::CostNumber::PerUnit { value: d }),
Err(_) => {
return Some(Err(format!(
"implicit_prices: posting on account {:?} has cost \
per_unit {n:?} that doesn't parse as a decimal",
posting.account
)));
}
}
}
Some(rustledger_plugin_types::CostNumberData::Total { value: n }) => {
match Decimal::from_str(n) {
Ok(d) => Some(rustledger_core::CostNumber::Total { value: d }),
Err(_) => {
return Some(Err(format!(
"implicit_prices: posting on account {:?} has cost \
total {n:?} that doesn't parse as a decimal",
posting.account
)));
}
}
}
Some(rustledger_plugin_types::CostNumberData::PerUnitFromTotal {
per_unit,
total,
}) => {
let per_unit_d = match Decimal::from_str(per_unit) {
Ok(d) => d,
Err(_) => {
return Some(Err(format!(
"implicit_prices: posting on account {:?} has \
PerUnitFromTotal per_unit {per_unit:?} that doesn't \
parse as a decimal",
posting.account
)));
}
};
let total_d = match Decimal::from_str(total) {
Ok(d) => d,
Err(_) => {
return Some(Err(format!(
"implicit_prices: posting on account {:?} has \
PerUnitFromTotal total {total:?} that doesn't parse \
as a decimal",
posting.account
)));
}
};
match rustledger_core::BookedCost::try_new(
per_unit_d,
total_d,
units_number,
) {
Ok(b) => Some(rustledger_core::CostNumber::PerUnitFromTotal(b)),
Err(e) => {
return Some(Err(format!(
"implicit_prices: posting on account {:?}: {e}",
posting.account
)));
}
}
}
None => return None,
};
Some(Ok((number, currency)))
});
let cost = match cost_result {
Some(Ok(c)) => Some(c),
Some(Err(msg)) => {
errors.push(PluginError::warning(msg));
None
}
None => None,
};
let reduced = if let Some(c) = posting.cost.as_ref()
&& let Some(fp) = cost_fingerprint(c, units_number)
{
let key = (posting.account.clone(), units.currency.clone(), Some(fp));
let prior = inventory.get(&key).copied().unwrap_or(Decimal::ZERO);
let was_reduction = !prior.is_zero()
&& prior.is_sign_negative() != units_number.is_sign_negative();
inventory.insert(key, prior + units_number);
was_reduction
} else {
false
};
let from_annotation = extract_per_unit_price(
units_number,
annotation,
None::<(Option<rustledger_core::CostNumber>, String)>,
);
let chosen = match (from_annotation, reduced) {
(Some(p), _) => Some(p),
(None, false) => extract_per_unit_price(units_number, None, cost),
(None, true) => None,
};
let Some((per_unit, quote_currency)) = chosen else {
continue;
};
let per_unit_str = per_unit.to_string();
let dedup_key = (
wrapper.date.clone(),
units.currency.clone(),
per_unit.normalize().to_string(),
quote_currency.clone(),
);
if !emitted_keys.insert(dedup_key) {
continue;
}
generated_prices.push(DirectiveWrapper {
directive_type: "price".to_string(),
date: wrapper.date.clone(),
filename: None, lineno: None,
data: DirectiveData::Price(PriceData {
currency: units.currency.clone(),
amount: AmountData {
number: per_unit_str,
currency: quote_currency,
},
metadata: vec![],
}),
});
}
}
let mut ops: Vec<PluginOp> = (0..input.directives.len()).map(PluginOp::Keep).collect();
for w in generated_prices {
ops.push(PluginOp::Insert(w));
}
PluginOutput { ops, errors }
}
}
impl RegularPlugin for ImplicitPricesPlugin {}