use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::date::Date;
use crate::decimal::Decimal;
use crate::indexer::Index;
use crate::parser::located::Located;
use crate::parser::posting::{Amount, Posting};
use crate::parser::transaction::{State, Transaction};
pub fn translate(
txs: &mut Vec<Located<Transaction>>,
target: &str,
db: &Index,
fixed_date: Option<&str>,
cta_gain: &str,
cta_loss: &str,
precision: usize,
) {
let transit = identify_transit_groups(txs);
if transit.is_empty() {
return;
}
let adjustments = collect_adjustments(
txs,
&transit,
target,
db,
fixed_date,
precision,
);
for adj in adjustments {
let cta = if adj.drift.is_negative() { cta_gain } else { cta_loss };
txs.push(build_release_tx(&adj, target, cta, precision));
}
txs.sort_by(|a, b| a.value.date.cmp(&b.value.date));
}
fn identify_transit_groups(
txs: &[Located<Transaction>],
) -> HashSet<(String, String)> {
let mut sums: HashMap<(String, String), Decimal> = HashMap::new();
let mut tainted: HashSet<(String, String)> = HashSet::new();
for lt in txs {
let multi = !is_single_commodity(<.value);
for lp in <.value.postings {
if let Some(a) = &lp.value.amount {
let key = (lp.value.account.clone(), a.commodity.clone());
if multi {
tainted.insert(key.clone());
}
let v = sums.entry(key).or_insert(Decimal::zero());
*v = *v + a.value;
}
}
}
sums.into_iter()
.filter(|(k, v)| v.is_zero() && !tainted.contains(k))
.map(|(k, _)| k)
.collect()
}
fn is_single_commodity(tx: &Transaction) -> bool {
let mut seen: Option<String> = None;
for lp in &tx.postings {
if lp.value.is_virtual && !lp.value.balanced {
continue;
}
let Some(a) = &lp.value.amount else { continue };
match &seen {
None => seen = Some(a.commodity.clone()),
Some(c) if *c == a.commodity => {}
_ => return false,
}
}
true
}
struct Adjustment {
date: Date,
file: Arc<str>,
line: usize,
account: String,
drift: Decimal,
}
fn collect_adjustments(
txs: &[Located<Transaction>],
transit: &HashSet<(String, String)>,
target: &str,
db: &Index,
fixed_date: Option<&str>,
precision: usize,
) -> Vec<Adjustment> {
let mut running: HashMap<(String, String), (Decimal, Decimal, bool)> =
HashMap::new();
let mut out = Vec::new();
for lt in txs.iter() {
let lookup_date: String = fixed_date
.map(str::to_string)
.unwrap_or_else(|| lt.value.date.to_string());
for lp in <.value.postings {
let Some(a) = &lp.value.amount else { continue };
let key = (lp.value.account.clone(), a.commodity.clone());
if !transit.contains(&key) {
continue;
}
let target_val = if a.commodity == target {
Some(a.value)
} else {
db.find(&a.commodity, target, &lookup_date)
.map(|r| a.value.mul_rounded(r))
};
let entry = running
.entry(key.clone())
.or_insert((Decimal::zero(), Decimal::zero(), false));
entry.0 = entry.0 + a.value;
match target_val {
Some(v) => entry.1 = entry.1 + v,
None => entry.2 = true,
}
if !entry.2
&& entry.0.is_zero()
&& !entry.1.is_display_zero(precision)
{
out.push(Adjustment {
date: lt.value.date,
file: lt.file.clone(),
line: lt.line,
account: key.0.clone(),
drift: entry.1,
});
entry.1 = Decimal::zero();
}
}
}
out
}
fn build_release_tx(
adj: &Adjustment,
target: &str,
cta_account: &str,
precision: usize,
) -> Located<Transaction> {
let description = "translation adjustment".to_string();
let debit = Posting {
account: adj.account.clone(),
amount: Some(Amount {
commodity: target.to_string(),
value: -adj.drift,
decimals: precision,
}),
costs: None,
lot_cost: None,
balance_assertion: None,
is_virtual: true,
balanced: true,
comments: Vec::new(),
};
let credit = Posting {
account: cta_account.to_string(),
amount: Some(Amount {
commodity: target.to_string(),
value: adj.drift,
decimals: precision,
}),
costs: None,
lot_cost: None,
balance_assertion: None,
is_virtual: true,
balanced: true,
comments: Vec::new(),
};
Located {
file: adj.file.clone(),
line: adj.line,
value: Transaction {
date: adj.date,
state: State::Cleared,
code: None,
description,
postings: vec![
Located {
file: adj.file.clone(),
line: adj.line,
value: debit,
},
Located {
file: adj.file.clone(),
line: adj.line,
value: credit,
},
],
comments: Vec::new(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser;
use crate::resolver;
fn setup(src: &str) -> (Vec<Located<Transaction>>, Index) {
let entries = parser::parse(src).unwrap();
let resolved = resolver::resolve(entries).unwrap();
let prices = crate::indexer::index(resolved.prices);
let txs = crate::booker::book(resolved.transactions).unwrap();
(txs, prices)
}
#[test]
fn transit_drift_emits_cta_release_tx() {
let src = "\
P 2024-01-15 EUR USD 1.10\n\
P 2024-06-15 EUR USD 1.05\n\
2024-01-15 * receive\n\
\tassets:checking 10 EUR\n\
\tincome:salary -10 EUR\n\
2024-06-15 * spend\n\
\texpenses:food 10 EUR\n\
\tassets:checking -10 EUR\n";
let (mut txs, db) = setup(src);
translate(&mut txs, "USD", &db, None, "in:cta", "ex:cta", 2);
assert_eq!(txs.len(), 3);
let release = txs
.iter()
.find(|lt| lt.value.description == "translation adjustment")
.expect("release tx missing");
assert_eq!(release.value.postings.len(), 2);
let debit = &release.value.postings[0].value;
assert_eq!(debit.account, "assets:checking");
assert_eq!(
debit.amount.as_ref().unwrap().value,
Decimal::parse("-0.50").unwrap()
);
let credit = &release.value.postings[1].value;
assert_eq!(credit.account, "ex:cta");
assert_eq!(
credit.amount.as_ref().unwrap().value,
Decimal::parse("0.50").unwrap()
);
}
#[test]
fn non_transit_account_untouched() {
let src = "\
P 2024-01-15 EUR USD 1.10\n\
2024-01-15 * receive only\n\
\tassets:checking 10 EUR\n\
\tincome:salary -10 EUR\n";
let (mut txs, db) = setup(src);
let original = txs.len();
translate(&mut txs, "USD", &db, None, "in:cta", "ex:cta", 2);
assert_eq!(txs.len(), original);
}
#[test]
fn no_drift_no_release() {
let src = "\
P 2024-01-15 EUR USD 1.10\n\
P 2024-06-15 EUR USD 1.10\n\
2024-01-15 * receive\n\
\tassets:checking 10 EUR\n\
\tincome:salary -10 EUR\n\
2024-06-15 * spend\n\
\texpenses:food 10 EUR\n\
\tassets:checking -10 EUR\n";
let (mut txs, db) = setup(src);
let original = txs.len();
translate(&mut txs, "USD", &db, None, "in:cta", "ex:cta", 2);
assert_eq!(txs.len(), original);
}
#[test]
fn missing_rate_skips_group() {
let src = "\
2024-01-15 * receive\n\
\tassets:checking 10 EUR\n\
\tincome:salary -10 EUR\n\
2024-06-15 * spend\n\
\texpenses:food 10 EUR\n\
\tassets:checking -10 EUR\n";
let (mut txs, db) = setup(src);
let original = txs.len();
translate(&mut txs, "USD", &db, None, "in:cta", "ex:cta", 2);
assert_eq!(txs.len(), original);
}
#[test]
fn multi_commodity_tx_skipped_to_avoid_double_booking_with_realizer() {
let src = "\
P 2024-06-15 EUR USD 1.05\n\
2024-06-15 * fx trade\n\
\tassets:usd -100 USD\n\
\tassets:eur 95 EUR\n";
let (mut txs, db) = setup(src);
let original = txs.len();
translate(&mut txs, "USD", &db, None, "in:cta", "ex:cta", 2);
assert_eq!(
txs.len(),
original,
"CTA must not fire on multi-commodity tx"
);
}
#[test]
fn market_snapshot_single_rate_produces_no_drift() {
let src = "\
P 2024-01-15 EUR USD 1.10\n\
P 2024-06-15 EUR USD 1.05\n\
2024-01-15 * receive\n\
\tassets:checking 10 EUR\n\
\tincome:salary -10 EUR\n\
2024-06-15 * spend\n\
\texpenses:food 10 EUR\n\
\tassets:checking -10 EUR\n";
let (mut txs, db) = setup(src);
let original = txs.len();
translate(&mut txs, "USD", &db, Some("2024-06-15"), "in:cta", "ex:cta", 2);
assert_eq!(txs.len(), original);
}
}