use crate::types::{
AmountData, CostData, DirectiveData, DirectiveWrapper, 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;
type LotKey = (String, String, Option<String>);
fn cost_fingerprint(cost: &CostData, units_number: Decimal) -> Option<String> {
let currency = cost.currency.as_deref()?;
let per_unit_decimal: Decimal = if let Some(per_str) = &cost.number_per {
Decimal::from_str(per_str).ok()?
} else if let Some(total_str) = &cost.number_total {
let total = Decimal::from_str(total_str).ok()?;
if units_number.is_zero() {
return None;
}
total / units_number.abs()
} else {
return None;
};
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 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 = posting.cost.as_ref().and_then(|c| {
let currency = c.currency.clone()?;
let per = c
.number_per
.as_ref()
.and_then(|n| Decimal::from_str(n).ok());
let total = c
.number_total
.as_ref()
.and_then(|n| Decimal::from_str(n).ok());
if per.is_none() && total.is_none() {
return None;
}
Some((per, total, currency))
});
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::<(_, _, 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: Vec::new(),
}
}
}