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,
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, 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_key(|a| a.value.date);
}
fn identify_transit_groups(
txs: &[Located<Transaction>],
) -> HashSet<(String, String)> {
let mut sums: HashMap<(String, String), Decimal> = HashMap::new();
for lt in txs {
for lp in <.value.postings {
if let Some(a) = &lp.value.amount {
let key = (lp.value.account.clone(), a.commodity.clone());
let v = sums.entry(key).or_insert(Decimal::zero());
*v += a.value;
}
}
}
sums.into_iter()
.filter(|(_k, v)| v.is_zero())
.map(|(k, _)| k)
.collect()
}
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,
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 = 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 = crate::rebalancer::target_value(
&lp.value,
target,
db,
&lookup_date,
);
let entry = running
.entry(key.clone())
.or_insert((Decimal::zero(), Decimal::zero(), false));
entry.0 += a.value;
match target_val {
Some(v) => 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 = "currency 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,
lot_date: None,
balance_assertion: None,
is_virtual: false,
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,
lot_date: None,
balance_assertion: None,
is_virtual: false,
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, "in:cta", "ex:cta", 2);
assert_eq!(txs.len(), 3);
let release = txs
.iter()
.find(|lt| lt.value.description == "currency 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, "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, "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, "in:cta", "ex:cta", 2);
assert_eq!(txs.len(), original);
}
#[test]
fn multi_commodity_pass_through_account_gets_cta() {
let src = "\
P 2024-01-15 USD EUR 1.10\n\
P 2024-06-15 USD EUR 1.05\n\
2024-01-15 * in\n\
\tcp:partner -100 USD\n\
\tincome:sales 110 EUR\n\
2024-06-15 * out\n\
\tcp:partner 100 USD\n\
\tassets:bank -105 EUR\n";
let (mut txs, db) = setup(src);
translate(&mut txs, "EUR", &db, "in:cta", "ex:cta", 2);
let release = txs
.iter()
.find(|lt| lt.value.description == "currency translation adjustment")
.expect("CTA must fire on a multi-commodity pass-through account");
let debit = &release.value.postings[0].value;
assert_eq!(debit.account, "cp:partner");
assert_eq!(
debit.amount.as_ref().unwrap().value,
Decimal::parse("5").unwrap()
);
}
}