rustledger_booking/interpolate.rs
1//! Transaction interpolation.
2//!
3//! Fills in missing posting amounts to balance transactions.
4
5use rust_decimal::Decimal;
6use rust_decimal::prelude::Signed;
7use rustledger_core::{
8 Amount, BookedCost, CostNumber, CostSpec, Currency, IncompleteAmount, Transaction,
9};
10use std::collections::HashMap;
11use thiserror::Error;
12
13/// Errors that can occur during interpolation.
14#[derive(Debug, Clone, Error)]
15pub enum InterpolationError {
16 /// Multiple unknowns in the same currency group, where an unknown is
17 /// either a posting with a missing amount or a posting with an empty
18 /// cost spec (`{}`) whose cost-basis weight is deferred to booking-
19 /// time lot matching. Bean-check enforces "at most one unknown per
20 /// currency group" — see issue #1026 for the cost-spec extension.
21 ///
22 /// The variant name `MultipleMissing` is kept for API stability;
23 /// "missing amounts" in the error message is a slight overgeneral
24 /// (the count includes cost-unknowns, not just missing amounts), but
25 /// the field semantics are correct.
26 #[error(
27 "multiple postings missing amounts or with unresolved cost specs for currency {currency} ({count} unknowns)"
28 )]
29 MultipleMissing {
30 /// The currency group with too many unknowns.
31 currency: Currency,
32 /// Total count of unknowns: missing-amount postings plus
33 /// empty-cost-spec postings whose weight is deferred to
34 /// booking-time lot matching.
35 count: usize,
36 },
37 /// The residual-solved cost for an empty `{}` augmentation came out
38 /// negative — beancount rejects this ("Cost is negative") rather than
39 /// booking a negative-cost lot (#1705 edge e14).
40 #[error(
41 "inferred cost for {currency} posting is negative ({per_unit} per unit); \
42 a lot cannot be acquired at a negative cost"
43 )]
44 NegativeInferredCost {
45 /// The cost currency the negative value was solved in.
46 currency: Currency,
47 /// The (negative) solved per-unit value.
48 per_unit: Decimal,
49 },
50 /// An empty `{}` cost spec names no currency and the transaction's
51 /// other postings span more than one currency, so the cost currency
52 /// (and therefore the residual to solve from) is ambiguous — beancount
53 /// rejects this ("Failed to categorize posting") (#1705 edge e15).
54 #[error(
55 "cannot infer the cost currency for the {{}} cost spec: candidates \
56 {candidates}; name it explicitly (e.g. {{EUR}})"
57 )]
58 AmbiguousInferredCostCurrency {
59 /// Comma-joined candidate currencies observed.
60 candidates: String,
61 },
62
63 /// Cannot infer currency for a posting.
64 #[error("cannot infer currency for posting to account {account}")]
65 CannotInferCurrency {
66 /// The account of the posting.
67 account: rustledger_core::Account,
68 },
69
70 /// Transaction does not balance after interpolation.
71 #[error("transaction does not balance: residual {residual} {currency}")]
72 DoesNotBalance {
73 /// The unbalanced currency.
74 currency: Currency,
75 /// The residual amount.
76 residual: Decimal,
77 },
78}
79
80/// Result of interpolation.
81#[derive(Debug, Clone)]
82pub struct InterpolationResult {
83 /// The interpolated transaction.
84 pub transaction: Transaction,
85 /// Which posting indices were filled in.
86 pub filled_indices: Vec<usize>,
87 /// Residuals after interpolation (should all be near zero).
88 pub residuals: HashMap<Currency, Decimal>,
89}
90
91/// Round an interpolated amount to match existing scale, but never round
92/// a non-zero residual to zero (that would leave the transaction unbalanced).
93fn round_interpolated(residual: Decimal, existing_scale: Option<u32>) -> Decimal {
94 let interpolated = -residual;
95 if let Some(scale) = existing_scale {
96 let rounded = interpolated.round_dp(scale);
97 // If rounding would make non-zero residual into zero, preserve precision
98 if rounded.is_zero() && !residual.is_zero() {
99 interpolated
100 } else {
101 rounded
102 }
103 } else {
104 interpolated
105 }
106}
107
108/// Interpolate missing amounts in a transaction.
109///
110/// This function:
111/// 1. Identifies postings with missing amounts
112/// 2. For each currency, calculates the residual
113/// 3. Fills in the missing amount to balance
114///
115/// # Rules
116///
117/// - At most one posting per currency can have a missing amount
118/// - If a posting has a cost spec with a currency, that currency is used
119/// - Otherwise, the posting gets the residual that makes the transaction balance
120///
121/// # TLA+ Specification
122///
123/// Implements invariants from `Interpolation.tla` (post-#1030 redesign for
124/// N postings + multi-currency + cost-unknowns):
125/// - `AtMostOneUnknownPerCurrency`: For each currency group, at most one
126/// posting may be "unknown" — either a missing amount (counts toward
127/// the units currency) or an empty cost spec like `{}` (counts toward
128/// the cost currency, since the cost-basis weight is unresolved until
129/// booking-pass lot matching). Returns `MultipleMissing` if violated.
130/// - `CompleteImpliesValidated`: Interpolation only completes the
131/// transaction when the validation rule holds.
132///
133/// The spec models the structural validation rule, not the residual
134/// arithmetic that produces filled amounts — see `Interpolation.tla`'s
135/// header for the scope rationale.
136///
137/// See: `spec/tla/Interpolation.tla`
138///
139/// # Example
140///
141/// ```ignore
142/// let txn = Transaction::new(date, "Test")
143/// .with_synthesized_posting(Posting::new("Expenses:Food", Amount::new(dec!(50.00), "USD")))
144/// .with_synthesized_posting(Posting::auto("Assets:Cash"));
145///
146/// let result = interpolate(&txn)?;
147/// // Assets:Cash now has -50.00 USD
148/// ```
149pub fn interpolate(transaction: &Transaction) -> Result<InterpolationResult, InterpolationError> {
150 // Clone the transaction for modification
151 let mut result = transaction.clone();
152 let mut filled_indices = Vec::new();
153
154 // Lazily compute inferred currency only when needed (most transactions don't need it)
155 let mut inferred_cost_currency: Option<Option<Currency>> = None;
156 let get_inferred_currency = |cache: &mut Option<Option<Currency>>| -> Option<Currency> {
157 cache
158 .get_or_insert_with(|| crate::infer_cost_currency_from_postings(transaction))
159 .clone()
160 };
161
162 // Calculate initial residuals from postings with amounts
163 // Pre-allocate for typical case (1-2 currencies per transaction)
164 let num_postings = transaction.postings.len();
165 let mut residuals: HashMap<Currency, Decimal> = HashMap::with_capacity(num_postings.min(4));
166 let mut missing_by_currency: HashMap<Currency, Vec<usize>> = HashMap::with_capacity(2);
167 let mut unassigned_missing: Vec<usize> = Vec::with_capacity(2);
168
169 // Track maximum scale (decimal places) per currency for rounding interpolated amounts.
170 //
171 // Matches Python beancount's `infer_tolerances` rule: only NON-INTEGER posting
172 // units contribute to the per-currency tolerance/precision. Integer amounts
173 // ("1 CAD" commission, "1 CSU" share count) do NOT contribute — they don't
174 // tell us anything about that currency's display precision.
175 //
176 // Cost spec scales are deliberately NOT included. With Python's default
177 // `infer_tolerance_from_cost = False`, cost annotations don't influence the
178 // residual quantization either. The natural Decimal arithmetic that flows
179 // through `cost_amount = units × per_unit` preserves whatever scale the
180 // operands carry, so a transaction with no non-integer posting units in a
181 // given currency simply doesn't get a quantization step (the residual is
182 // rendered at its natural scale).
183 //
184 // - #333 (`1 CSU {2800.01 CAD}` + `1 CAD` commission + missing CAD):
185 // no non-integer CAD posting units in this transaction; residual
186 // passes through unrounded at its natural scale, which is 2dp from
187 // the explicit cost literal `{2800.01}` flowing through
188 // `cost_amount = units × per_unit`.
189 // - #251 (`70.538 ABC {100 USD}` + missing posting): no non-integer
190 // USD posting units; residual = `70.538 × 100 = 7053.800` (scale 3
191 // from the rust_decimal multiplication), preserved naturally.
192 // - #1107 (`-1.763 STOCK {}` lot-matched against high-precision per_unit):
193 // the cash side `336.73 USD` gives USD scale=2; the residual gets
194 // quantized to 2dp instead of inheriting the lot's derived 26-digit
195 // per_unit precision.
196 let mut max_scale_by_currency: HashMap<Currency, u32> = HashMap::with_capacity(4);
197
198 // Track per-currency count of postings whose weight contribution is unknown
199 // because the cost spec is empty (e.g., `{}`) and resolution is deferred to
200 // the booking pass (lot matching). Each such posting is one unknown for
201 // interpolation accounting and gets added to the per-currency unknowns
202 // total alongside missing-amount postings (issue #1026). Without this,
203 // rledger would silently use a fallback weight (price annotation, if
204 // present) and accept transactions with more unknowns than the
205 // interpolation rule allows.
206 let mut cost_unknowns_by_currency: HashMap<Currency, usize> = HashMap::with_capacity(2);
207
208 // Augmenting `{}` postings whose per-unit cost beancount infers from the
209 // balance residual (issue #1705): `(posting index, cost currency, units)`.
210 // Only empty-`{}`, price-less postings qualify — a reduction's `{}` is
211 // already cost-filled by the booking pass (so it never reaches the empty
212 // branch below), and a priced `{}` defers to booking-time lot matching.
213 // Each is solved post-loop, but only when it is the SOLE unknown in its
214 // cost currency group (else the group already errored on the count rule).
215 let mut inferable_cost: Vec<(usize, Currency, Decimal)> = Vec::new();
216
217 for (i, posting) in transaction.postings.iter().enumerate() {
218 match &posting.units {
219 Some(IncompleteAmount::Complete(amount)) => {
220 // Track scale (decimal places) for rounding interpolated amounts.
221 // Skip integer (scale==0) amounts — matches Python's
222 // `infer_tolerances`, which ignores integer posting.units
223 // since they don't reflect intentional currency precision.
224 let scale = amount.number.scale();
225 if scale > 0 {
226 max_scale_by_currency
227 .entry(amount.currency.clone())
228 .and_modify(|s| *s = (*s).max(scale))
229 .or_insert(scale);
230 }
231
232 // Determine the "weight" of this posting for balance purposes.
233 // The cost weight goes through the shared `cost_weight` engine —
234 // the SAME `CostNumber` ladder `calculate_residual` uses — so
235 // interpolation and the balance residual can no longer disagree
236 // (the rule: cost beats price; else price; else units).
237 let cost_contribution = crate::cost_weight::<Decimal>(posting, amount, || {
238 get_inferred_currency(&mut inferred_cost_currency)
239 });
240
241 if let Some((currency, cost_amount)) = cost_contribution {
242 // Cost-based posting: weight is in the cost currency.
243 // Cost spec scales are intentionally NOT tracked in
244 // `max_scale_by_currency` — see its declaration for the
245 // rationale (Python beancount with default
246 // `infer_tolerance_from_cost = False`).
247 *residuals.entry(currency).or_default() += cost_amount;
248 } else if posting.cost.is_some() {
249 // Cost spec exists but has no determinable cost number (e.g.,
250 // an empty `{}` spec where the lot's cost will be filled by
251 // booking-time lot matching). The WEIGHT of this posting is
252 // the cost basis × units, NOT the price × units — so we must
253 // not fall through to the price branch below and use price
254 // as a substitute (that's what happened pre-#1026 fix and
255 // produced silent acceptance of unsolvable transactions).
256 //
257 // Track this as one unknown for the cost currency. The
258 // post-loop check then enforces the "at most one unknown
259 // per currency group" rule that bean-check enforces.
260 let cost_currency = crate::cost_currency_of(posting, || {
261 get_inferred_currency(&mut inferred_cost_currency)
262 });
263 if let Some(curr) = cost_currency {
264 *cost_unknowns_by_currency.entry(curr.clone()).or_default() += 1;
265 // An empty `{}` reaching here is an augmentation (a
266 // reduction's `{}` is filled by the booking pass
267 // first). Beancount infers its per-unit cost from the
268 // residual — INCLUDING when a price annotation is
269 // present, and even when the two disagree (verified
270 // against bean-check 3.2.3: `{} @ 0.90` with a 0.95
271 // residual books {0.95}; the price feeds implicit
272 // price directives only). #1705 edges e07/e16.
273 //
274 // The cost currency must be unambiguous when the spec
275 // does not name one: with candidates from more than
276 // one non-cost posting, beancount fails to categorize
277 // the posting (edge e15).
278 if posting.cost.as_ref().is_some_and(|c| c.currency.is_none()) {
279 // Candidates are currencies of COMPLETE cost-less
280 // postings, excluding any currency that already
281 // has its own missing-amount posting: that group
282 // owns its unknown, so the {} cannot also belong
283 // to it (the per-group at-most-one rule) — which
284 // is what disambiguates "cost-unknown in USD +
285 // missing amount in EUR" (fine, disjoint groups)
286 // from two complete foreign currencies (e15,
287 // beancount: "Failed to categorize").
288 let mut complete: Vec<String> = Vec::new();
289 let mut incomplete: Vec<String> = Vec::new();
290 for p in &transaction.postings {
291 if p.cost.is_some() {
292 continue;
293 }
294 let curr_of = crate::price_currency_of(p).or_else(|| {
295 p.units.as_ref().and_then(|u| match u {
296 IncompleteAmount::Complete(a) => Some(a.currency.clone()),
297 IncompleteAmount::CurrencyOnly(c) => Some(c.clone()),
298 IncompleteAmount::NumberOnly(_) => None,
299 })
300 });
301 let complete_units =
302 matches!(p.units.as_ref(), Some(IncompleteAmount::Complete(_)));
303 if let Some(c) = curr_of {
304 if complete_units {
305 complete.push(c.as_str().to_owned());
306 } else {
307 incomplete.push(c.as_str().to_owned());
308 }
309 }
310 }
311 // A fully-unassigned missing posting (no currency
312 // context at all) makes the whole transaction
313 // reject via the post-scan unassigned+cost-unknown
314 // check with a more specific diagnosis — defer to
315 // it rather than reporting ambiguity.
316 let has_unassigned = transaction.postings.iter().any(|p| {
317 p.cost.is_none()
318 && crate::price_currency_of(p).is_none()
319 && !matches!(
320 p.units.as_ref(),
321 Some(
322 IncompleteAmount::Complete(_)
323 | IncompleteAmount::CurrencyOnly(_)
324 )
325 )
326 });
327 let mut candidates: Vec<String> = complete
328 .into_iter()
329 .filter(|c| !incomplete.contains(c))
330 .collect();
331 candidates.sort_unstable();
332 candidates.dedup();
333 if !has_unassigned && candidates.len() > 1 {
334 return Err(InterpolationError::AmbiguousInferredCostCurrency {
335 candidates: candidates.join(", "),
336 });
337 }
338 }
339 inferable_cost.push((i, curr, amount.number));
340 }
341 } else if let Some(price) = &posting.price {
342 // Price annotation: converts units to price currency.
343 // Scale tracking: per-unit prices are multipliers, so we
344 // do NOT track their scale. Total prices are explicit
345 // amounts, so we DO track theirs (non-integer scale
346 // only — an integer `@@ 1 USD` shouldn't quantize an
347 // elided same-currency residual to whole units).
348 if let Some(price_amt) =
349 price.amount.as_ref().and_then(IncompleteAmount::as_amount)
350 {
351 let (curr, signed) = match price.kind {
352 rustledger_core::PriceKind::Unit => (
353 price_amt.currency.clone(),
354 amount.number.abs() * price_amt.number * amount.number.signum(),
355 ),
356 rustledger_core::PriceKind::Total => {
357 let scale = price_amt.number.scale();
358 if scale > 0 {
359 max_scale_by_currency
360 .entry(price_amt.currency.clone())
361 .and_modify(|s| *s = (*s).max(scale))
362 .or_insert(scale);
363 }
364 (
365 price_amt.currency.clone(),
366 price_amt.number * amount.number.signum(),
367 )
368 }
369 };
370 *residuals.entry(curr).or_default() += signed;
371 } else {
372 // Incomplete/empty price annotation — fall back to units
373 *residuals.entry(amount.currency.clone()).or_default() += amount.number;
374 }
375 } else {
376 // Simple posting: weight is just the units
377 *residuals.entry(amount.currency.clone()).or_default() += amount.number;
378 }
379 }
380 Some(IncompleteAmount::CurrencyOnly(currency)) => {
381 // Currency known, number to be interpolated
382 missing_by_currency
383 .entry(currency.clone())
384 .or_default()
385 .push(i);
386 }
387 Some(IncompleteAmount::NumberOnly(number)) => {
388 // Number known, currency to be inferred
389 // Try to get currency from cost or price
390 let currency = posting
391 .cost
392 .as_ref()
393 .and_then(|c| c.currency.clone())
394 .or_else(|| {
395 // Pull currency from the price's complete amount,
396 // regardless of kind. Incomplete/empty prices
397 // contribute nothing here.
398 posting
399 .price
400 .as_ref()
401 .and_then(|p| p.amount.as_ref())
402 .and_then(IncompleteAmount::as_amount)
403 .map(|a| a.currency.clone())
404 });
405
406 if let Some(curr) = currency {
407 // We have currency from context, make it complete
408 *residuals.entry(curr.clone()).or_default() += *number;
409 } else {
410 // Can't determine currency yet
411 unassigned_missing.push(i);
412 }
413 }
414 None => {
415 // Missing amount - try to determine currency from cost
416 if let Some(cost_spec) = &posting.cost
417 && let Some(currency) = &cost_spec.currency
418 {
419 missing_by_currency
420 .entry(currency.clone())
421 .or_default()
422 .push(i);
423 continue;
424 }
425 // Can't determine currency yet
426 unassigned_missing.push(i);
427 }
428 }
429 }
430
431 // Check for multiple unknowns in the same currency group. An "unknown"
432 // is either a missing-amount posting or a posting with an empty cost
433 // spec (whose cost-basis weight contribution is unknown until booking
434 // resolves the lot match). Bean-check enforces "at most one unknown
435 // per currency group" — see issue #1026.
436 //
437 // Iterate currencies in sorted order so the error message is
438 // deterministic for the same input. HashMap iteration order is
439 // unspecified, so picking "the first failing currency" without
440 // sorting would produce non-reproducible test output.
441 let mut currencies_with_unknowns: Vec<&Currency> = missing_by_currency
442 .keys()
443 .chain(cost_unknowns_by_currency.keys())
444 .collect();
445 currencies_with_unknowns.sort_by(|a, b| a.as_str().cmp(b.as_str()));
446 currencies_with_unknowns.dedup();
447 for currency in currencies_with_unknowns {
448 let missing_count = missing_by_currency
449 .get(currency)
450 .map_or(0, std::vec::Vec::len);
451 let cost_unknown_count = cost_unknowns_by_currency
452 .get(currency)
453 .copied()
454 .unwrap_or(0);
455 let total = missing_count + cost_unknown_count;
456 if total > 1 {
457 return Err(InterpolationError::MultipleMissing {
458 currency: currency.clone(),
459 count: total,
460 });
461 }
462 }
463
464 // Same rule extended to "would-be" landing currencies for unassigned
465 // missing postings: an unassigned-missing posting absorbs residuals
466 // across all non-zero currencies at fill time, so it could land in
467 // any currency including one with a cost-unknown.
468 //
469 // Empirically verified against bean-check (issue #1026): bean-check
470 // rejects ANY combination of unassigned-missing + cost-unknown, even
471 // when the unassigned would semantically prefer a different currency.
472 // The reason is that an unassigned posting's currency assignment is
473 // determined post-hoc from non-zero residuals, and cost-unknowns
474 // contribute an unknown amount to their currency's residual — so the
475 // landing currency could always be the cost-unknown's currency. To
476 // require the user to make the absorber's currency explicit, reject.
477 //
478 // Pick the lexicographically-smallest cost-unknown currency for the
479 // error so the message is reproducible across runs.
480 if !unassigned_missing.is_empty() {
481 let mut cost_unknown_keys: Vec<&Currency> = cost_unknowns_by_currency.keys().collect();
482 cost_unknown_keys.sort_by(|a, b| a.as_str().cmp(b.as_str()));
483 if let Some(curr) = cost_unknown_keys.first() {
484 let count = cost_unknowns_by_currency.get(*curr).copied().unwrap_or(0);
485 return Err(InterpolationError::MultipleMissing {
486 currency: (*curr).clone(),
487 count: count + unassigned_missing.len(),
488 });
489 }
490 }
491
492 // Infer the per-unit cost of augmenting `{}` postings from the residual
493 // (issue #1705). Beancount books first, then interpolates the single
494 // remaining unknown; an augmenting lot written `1000 USD {}` with one
495 // balancing cash leg has its cost inferred from that leg (`-900 EUR` →
496 // 0.90 EUR/unit). rledger left such a lot with no cost basis, which also
497 // broke later reductions of it (the `{}` reduction could no longer match a
498 // cost-bearing lot, surfacing as a spurious "2 unknowns" error).
499 //
500 // Runs BEFORE missing-amount filling so a filled cost balances its
501 // currency before any elided amount absorbs that currency's residual. Each
502 // entry is guaranteed the sole unknown in its cost currency group: the
503 // count-rule check above rejected any group with >1 unknown, and the
504 // unassigned-missing check rejected any unassigned + cost-unknown combo.
505 for (idx, currency, units_number) in inferable_cost {
506 if units_number.is_zero() {
507 continue; // BookedCost is undefined for zero units.
508 }
509 let residual = residuals.get(¤cy).copied().unwrap_or(Decimal::ZERO);
510 // The posting's cost weight must cancel the residual. For
511 // `PerUnitFromTotal` the weight is `total * signum(units)`, so pick
512 // `total = -residual * signum(units)` and `per_unit = total / |units|`.
513 let signum = units_number.signum();
514 let total = -residual * signum;
515 let per_unit = total / units_number.abs();
516 if per_unit < Decimal::ZERO {
517 // Beancount: "Cost is negative" — a lot cannot be acquired at a
518 // negative cost (#1705 edge e14).
519 return Err(InterpolationError::NegativeInferredCost { currency, per_unit });
520 }
521
522 let existing = result.postings[idx]
523 .cost
524 .take()
525 .unwrap_or_else(CostSpec::empty);
526 result.postings[idx].cost = Some(CostSpec {
527 number: Some(CostNumber::PerUnitFromTotal(BookedCost::new(
528 per_unit,
529 total,
530 units_number,
531 ))),
532 currency: Some(currency.clone()),
533 date: existing.date.or(Some(transaction.date)),
534 label: existing.label,
535 merge: existing.merge,
536 });
537 // Fold the now-known cost weight into the residual so downstream
538 // missing-amount solving sees a balanced cost currency.
539 *residuals.entry(currency).or_default() += total * signum;
540 }
541
542 // Fill in known-currency missing postings
543 for (currency, indices) in missing_by_currency {
544 let idx = indices[0];
545 let residual = residuals.get(¤cy).copied().unwrap_or(Decimal::ZERO);
546
547 let interpolated =
548 round_interpolated(residual, max_scale_by_currency.get(¤cy).copied());
549
550 result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
551 interpolated,
552 ¤cy,
553 )));
554 filled_indices.push(idx);
555
556 // Update residual to reflect actual interpolated amount (may have rounding difference)
557 *residuals.entry(currency).or_default() += interpolated;
558 }
559
560 // Handle unassigned missing postings
561 // Each one absorbs one or more currencies' residuals
562 if !unassigned_missing.is_empty() {
563 // Get currencies with non-zero residuals
564 let non_zero_residuals: Vec<(Currency, Decimal)> = residuals
565 .iter()
566 .filter(|&(_, v)| !v.is_zero())
567 .map(|(k, v)| (k.clone(), *v))
568 .collect();
569
570 // Special case: single missing posting with multiple currencies
571 // This is multi-currency interpolation - split into multiple postings
572 if unassigned_missing.len() == 1 && non_zero_residuals.len() > 1 {
573 let idx = unassigned_missing[0];
574 let original_posting = &transaction.postings[idx];
575
576 // Fill the first currency into the original posting
577 let (first_currency, first_residual) = &non_zero_residuals[0];
578 let interpolated = round_interpolated(
579 *first_residual,
580 max_scale_by_currency.get(first_currency).copied(),
581 );
582 result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
583 interpolated,
584 first_currency,
585 )));
586 filled_indices.push(idx);
587 *residuals.entry(first_currency.clone()).or_default() += interpolated;
588
589 // Add new postings for remaining currencies
590 for (currency, residual) in non_zero_residuals.iter().skip(1) {
591 let mut new_posting = original_posting.clone();
592 let interpolated =
593 round_interpolated(*residual, max_scale_by_currency.get(currency).copied());
594 new_posting.units = Some(IncompleteAmount::Complete(Amount::new(
595 interpolated,
596 currency,
597 )));
598 result.postings.push(new_posting);
599 filled_indices.push(result.postings.len() - 1);
600 *residuals.entry(currency.clone()).or_default() += interpolated;
601 }
602 } else {
603 // Check for ambiguous elision: more unassigned missing postings than
604 // available residual currencies means multiple postings would all be
605 // assigned to the same currency, which is ambiguous and an error.
606 if unassigned_missing.len() > non_zero_residuals.len() && !non_zero_residuals.is_empty()
607 {
608 let (currency, _) = &non_zero_residuals[0];
609 return Err(InterpolationError::MultipleMissing {
610 currency: currency.clone(),
611 count: unassigned_missing.len(),
612 });
613 }
614
615 // Standard case: assign one currency per missing posting
616 for (i, idx) in unassigned_missing.iter().enumerate() {
617 if i < non_zero_residuals.len() {
618 let (currency, residual) = &non_zero_residuals[i];
619 let interpolated =
620 round_interpolated(*residual, max_scale_by_currency.get(currency).copied());
621 result.postings[*idx].units = Some(IncompleteAmount::Complete(Amount::new(
622 interpolated,
623 currency,
624 )));
625 filled_indices.push(*idx);
626 *residuals.entry(currency.clone()).or_default() += interpolated;
627 } else if !non_zero_residuals.is_empty() {
628 // Use the first currency
629 let (currency, _) = &non_zero_residuals[0];
630 result.postings[*idx].units =
631 Some(IncompleteAmount::Complete(Amount::zero(currency)));
632 filled_indices.push(*idx);
633 } else if let Some(currency) = get_inferred_currency(&mut inferred_cost_currency) {
634 // No residuals but we can infer currency from cost basis
635 // This handles balanced cost-basis transactions like:
636 // Assets:Crypto 100 USDC {1.0 USD}
637 // Assets:Cash -100 USD
638 // Income:Trading ; <- infer 0 USD from cost basis
639 result.postings[*idx].units =
640 Some(IncompleteAmount::Complete(Amount::zero(¤cy)));
641 filled_indices.push(*idx);
642 } else {
643 // No residuals and cannot infer currency
644 return Err(InterpolationError::CannotInferCurrency {
645 account: transaction.postings[*idx].account.clone(),
646 });
647 }
648 }
649 }
650 }
651
652 // Prune postings that were filled with zero amounts. Python
653 // beancount drops these from its rendered output too — they
654 // contribute nothing to the transaction balance and would just
655 // clutter BQL / JSON / format output.
656 //
657 // The historical concern (#877) was that pre-validation pruning
658 // hid `E1001 Account X was never opened` errors on elided
659 // postings to unopened accounts. The loader pipeline now runs an
660 // EARLY validation phase before booking (see
661 // `rustledger_validate::Phase::Early` and the "Python Compatibility
662 // Policy" section in CLAUDE.md), so account-presence checks fire
663 // BEFORE we reach this prune step. That's a deliberate divergence
664 // from Python — Python silently accepts these references; rledger
665 // catches them. Tested by `test_zero_interpolated_posting_keeps_e1001_*`
666 // in `rustledger-loader`.
667 //
668 // Iterate in reverse so indices stay valid as we remove.
669 let mut indices_to_remove: Vec<usize> = filled_indices
670 .iter()
671 .filter(|&&idx| {
672 result.postings.get(idx).is_some_and(|p| {
673 p.units
674 .as_ref()
675 .and_then(|u| u.as_amount())
676 .is_some_and(|a| a.number.is_zero())
677 })
678 })
679 .copied()
680 .collect();
681 indices_to_remove.sort_unstable_by(|a, b| b.cmp(a));
682
683 for idx in &indices_to_remove {
684 result.postings.remove(*idx);
685 }
686
687 // Drop the removed indices from filled_indices and shift the
688 // surviving ones down to reflect the new posting positions.
689 let final_filled_indices: Vec<usize> = filled_indices
690 .into_iter()
691 .filter(|idx| !indices_to_remove.contains(idx))
692 .map(|idx| {
693 let adjustment = indices_to_remove.iter().filter(|&&r| r < idx).count();
694 idx - adjustment
695 })
696 .collect();
697
698 // Return the residuals we've been tracking incrementally
699 // (no need to recalculate - we've updated residuals as we filled amounts)
700 Ok(InterpolationResult {
701 transaction: result,
702 filled_indices: final_filled_indices,
703 residuals,
704 })
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710 use rust_decimal_macros::dec;
711 use rustledger_core::{NaiveDate, Posting};
712
713 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
714 rustledger_core::naive_date(year, month, day).unwrap()
715 }
716
717 /// Helper to get the complete amount from a posting.
718 fn get_amount(posting: &rustledger_core::Posting) -> Option<&Amount> {
719 posting.units.as_ref().and_then(|u| u.as_amount())
720 }
721
722 #[test]
723 fn test_interpolate_simple() {
724 let txn = Transaction::new(date(2024, 1, 15), "Test")
725 .with_synthesized_posting(Posting::new(
726 "Expenses:Food",
727 Amount::new(dec!(50.00), "USD"),
728 ))
729 .with_synthesized_posting(Posting::auto("Assets:Cash"));
730
731 let result = interpolate(&txn).unwrap();
732
733 assert_eq!(result.filled_indices, vec![1]);
734
735 let filled = &result.transaction.postings[1];
736 let amount = get_amount(filled).expect("should have amount");
737 assert_eq!(amount.number, dec!(-50.00));
738 assert_eq!(amount.currency, "USD");
739 }
740
741 #[test]
742 fn test_interpolate_multiple_postings() {
743 let txn = Transaction::new(date(2024, 1, 15), "Test")
744 .with_synthesized_posting(Posting::new(
745 "Expenses:Food",
746 Amount::new(dec!(30.00), "USD"),
747 ))
748 .with_synthesized_posting(Posting::new(
749 "Expenses:Drink",
750 Amount::new(dec!(20.00), "USD"),
751 ))
752 .with_synthesized_posting(Posting::auto("Assets:Cash"));
753
754 let result = interpolate(&txn).unwrap();
755
756 let filled = &result.transaction.postings[2];
757 let amount = get_amount(filled).expect("should have amount");
758 assert_eq!(amount.number, dec!(-50.00));
759 }
760
761 #[test]
762 fn test_interpolate_no_missing() {
763 let txn = Transaction::new(date(2024, 1, 15), "Test")
764 .with_synthesized_posting(Posting::new(
765 "Expenses:Food",
766 Amount::new(dec!(50.00), "USD"),
767 ))
768 .with_synthesized_posting(Posting::new(
769 "Assets:Cash",
770 Amount::new(dec!(-50.00), "USD"),
771 ));
772
773 let result = interpolate(&txn).unwrap();
774
775 assert!(result.filled_indices.is_empty());
776 }
777
778 #[test]
779 fn test_interpolate_multiple_currencies() {
780 let txn = Transaction::new(date(2024, 1, 15), "Test")
781 .with_synthesized_posting(Posting::new(
782 "Expenses:Food",
783 Amount::new(dec!(50.00), "USD"),
784 ))
785 .with_synthesized_posting(Posting::new(
786 "Expenses:Travel",
787 Amount::new(dec!(100.00), "EUR"),
788 ))
789 .with_synthesized_posting(Posting::new(
790 "Assets:Cash:USD",
791 Amount::new(dec!(-50.00), "USD"),
792 ))
793 .with_synthesized_posting(Posting::auto("Assets:Cash:EUR"));
794
795 let result = interpolate(&txn).unwrap();
796
797 let filled = &result.transaction.postings[3];
798 let amount = get_amount(filled).expect("should have amount");
799 assert_eq!(amount.number, dec!(-100.00));
800 assert_eq!(amount.currency, "EUR");
801 }
802
803 #[test]
804 fn test_interpolate_error_multiple_missing_same_currency() {
805 let txn = Transaction::new(date(2024, 1, 15), "Test")
806 .with_synthesized_posting(Posting::new(
807 "Expenses:Food",
808 Amount::new(dec!(50.00), "USD"),
809 ))
810 .with_synthesized_posting(Posting::auto("Assets:Cash"))
811 .with_synthesized_posting(Posting::auto("Assets:Bank"));
812
813 // Multiple unassigned missing postings with a single residual currency
814 // is ambiguous and should return MultipleMissing error.
815 let result = interpolate(&txn);
816 assert!(
817 matches!(result, Err(InterpolationError::MultipleMissing { .. })),
818 "expected MultipleMissing error, got: {result:?}"
819 );
820 }
821
822 #[test]
823 fn test_interpolate_multiple_missing_different_currencies_ok() {
824 // Two elided postings but two residual currencies - each gets one
825 let txn = Transaction::new(date(2024, 1, 15), "Multi-currency")
826 .with_synthesized_posting(Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")))
827 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")))
828 .with_synthesized_posting(Posting::auto("Liabilities:CreditCard"))
829 .with_synthesized_posting(Posting::auto("Equity:Exchange"));
830
831 // Two unassigned missing, two non-zero residuals - this is unambiguous
832 let result = interpolate(&txn);
833 assert!(
834 result.is_ok(),
835 "expected success for different-currency elision, got: {result:?}"
836 );
837 }
838
839 #[test]
840 fn test_interpolate_with_per_unit_cost() {
841 // 2015-10-02 *
842 // Assets:Stock 10 HOOL {100.00 USD}
843 // Assets:Cash
844 //
845 // Expected: Assets:Cash should be interpolated to -1000.00 USD
846 let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
847 .with_synthesized_posting(
848 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
849 rustledger_core::CostSpec::empty()
850 .with_number(rustledger_core::CostNumber::PerUnit {
851 value: dec!(100.00),
852 })
853 .with_currency("USD"),
854 ),
855 )
856 .with_synthesized_posting(Posting::auto("Assets:Cash"));
857
858 let result = interpolate(&txn).expect("interpolation should succeed");
859
860 // Check that the cash posting was filled
861 assert_eq!(result.filled_indices, vec![1]);
862
863 // Check the interpolated amount
864 let filled = &result.transaction.postings[1];
865 let amount = get_amount(filled).expect("should have amount");
866 assert_eq!(
867 amount.currency, "USD",
868 "should be USD (cost currency), not HOOL"
869 );
870 assert_eq!(
871 amount.number,
872 dec!(-1000.00),
873 "should be -1000 USD (10 * 100)"
874 );
875
876 // Verify the transaction balances
877 let residual = result
878 .residuals
879 .get("USD")
880 .copied()
881 .unwrap_or(Decimal::ZERO);
882 assert!(
883 residual.abs() < dec!(0.01),
884 "USD residual should be ~0, got {residual}"
885 );
886 // There should be NO HOOL residual
887 assert!(
888 !result.residuals.contains_key("HOOL"),
889 "should not have HOOL residual"
890 );
891 }
892
893 /// Agreement fitness function: interpolation and balance-checking now share
894 /// the `cost_weight` engine, so after interpolation the *independent* public
895 /// [`crate::calculate_residual`] must see the result as balanced — across
896 /// cost AND price weights. If interpolation computed a posting using a
897 /// different weight than the residual does, this would surface a non-zero
898 /// residual.
899 #[test]
900 fn test_interpolated_weights_agree_with_calculate_residual() {
901 // Total-cost stock + unit-priced FX leg, both weighing in USD; the auto
902 // cash posting must absorb the USD residual exactly.
903 let txn = Transaction::new(date(2015, 10, 2), "Mixed cost and price")
904 .with_synthesized_posting(
905 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
906 rustledger_core::CostSpec::empty()
907 .with_number(rustledger_core::CostNumber::Total {
908 value: dec!(1500.00),
909 })
910 .with_currency("USD"),
911 ),
912 )
913 .with_synthesized_posting(
914 Posting::new("Assets:EUR", Amount::new(dec!(-200.00), "EUR")).with_price(
915 rustledger_core::PriceAnnotation::unit(Amount::new(dec!(1.10), "USD")),
916 ),
917 )
918 .with_synthesized_posting(Posting::auto("Assets:Cash"));
919
920 let result = interpolate(&txn).expect("interpolation should succeed");
921
922 // The independent residual engine (sharing cost_weight) sees balance.
923 for (currency, value) in crate::calculate_residual(&result.transaction) {
924 assert!(
925 value.abs() < dec!(0.0001),
926 "interpolated result not balanced per calculate_residual: {value} {currency}"
927 );
928 }
929 }
930
931 #[test]
932 fn test_interpolate_with_total_cost() {
933 // 2015-10-02 *
934 // Assets:Stock 10 HOOL {{1000.00 USD}}
935 // Assets:Cash
936 //
937 // Expected: Assets:Cash should be interpolated to -1000.00 USD
938 let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
939 .with_synthesized_posting(
940 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
941 rustledger_core::CostSpec::empty()
942 .with_number(rustledger_core::CostNumber::Total {
943 value: dec!(1000.00),
944 })
945 .with_currency("USD"),
946 ),
947 )
948 .with_synthesized_posting(Posting::auto("Assets:Cash"));
949
950 let result = interpolate(&txn).expect("interpolation should succeed");
951
952 let filled = &result.transaction.postings[1];
953 let amount = get_amount(filled).expect("should have amount");
954 assert_eq!(amount.currency, "USD");
955 assert_eq!(amount.number, dec!(-1000.00));
956 }
957
958 #[test]
959 fn test_interpolate_stock_purchase_with_commission() {
960 // From beancount starter.beancount:
961 // 2013-02-03 * "Bought some stock"
962 // Assets:Stock 8 HOOL {701.20 USD}
963 // Expenses:Commission 7.95 USD
964 // Assets:Cash
965 //
966 // Expected: Cash = -(8 * 701.20 + 7.95) = -5617.55 USD
967 let txn = Transaction::new(date(2013, 2, 3), "Bought some stock")
968 .with_synthesized_posting(
969 Posting::new("Assets:Stock", Amount::new(dec!(8), "HOOL")).with_cost(
970 rustledger_core::CostSpec::empty()
971 .with_number(rustledger_core::CostNumber::PerUnit {
972 value: dec!(701.20),
973 })
974 .with_currency("USD"),
975 ),
976 )
977 .with_synthesized_posting(Posting::new(
978 "Expenses:Commission",
979 Amount::new(dec!(7.95), "USD"),
980 ))
981 .with_synthesized_posting(Posting::auto("Assets:Cash"));
982
983 let result = interpolate(&txn).expect("interpolation should succeed");
984
985 let filled = &result.transaction.postings[2];
986 let amount = get_amount(filled).expect("should have amount");
987 assert_eq!(amount.currency, "USD");
988 // 8 * 701.20 = 5609.60, plus 7.95 commission = 5617.55
989 assert_eq!(amount.number, dec!(-5617.55));
990 }
991
992 #[test]
993 fn test_interpolate_stock_sale_with_cost_and_price() {
994 // Selling stock at a different price than cost basis
995 // 2015-10-02 *
996 // Assets:Stock -10 HOOL {100.00 USD} @ 120.00 USD
997 // Assets:Cash
998 // Income:Gains
999 //
1000 // The sale is at cost (for booking), but price is 120 USD
1001 // Weight: -10 * 100 = -1000 USD (at cost)
1002 // Cash should receive: 10 * 120 = 1200 USD (at price)
1003 // Gains: -200 USD
1004 let txn = Transaction::new(date(2015, 10, 2), "Sell stock")
1005 .with_synthesized_posting(
1006 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1007 .with_cost(
1008 rustledger_core::CostSpec::empty()
1009 .with_number(rustledger_core::CostNumber::PerUnit {
1010 value: dec!(100.00),
1011 })
1012 .with_currency("USD"),
1013 )
1014 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1015 dec!(120.00),
1016 "USD",
1017 ))),
1018 )
1019 .with_synthesized_posting(Posting::new(
1020 "Assets:Cash",
1021 Amount::new(dec!(1200.00), "USD"),
1022 ))
1023 .with_synthesized_posting(Posting::auto("Income:Gains"));
1024
1025 let result = interpolate(&txn).expect("interpolation should succeed");
1026
1027 let filled = &result.transaction.postings[2];
1028 let amount = get_amount(filled).expect("should have amount");
1029 assert_eq!(amount.currency, "USD");
1030 // Gains = cost - proceeds = 1000 - 1200 = -200 (income is negative)
1031 assert_eq!(amount.number, dec!(-200.00));
1032 }
1033
1034 #[test]
1035 fn test_interpolate_balanced_with_cost_no_interpolation_needed() {
1036 // When all amounts are provided, no interpolation needed
1037 // 2015-10-02 *
1038 // Assets:Stock 10 HOOL {100.00 USD}
1039 // Assets:Cash -1000.00 USD
1040 let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
1041 .with_synthesized_posting(
1042 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
1043 rustledger_core::CostSpec::empty()
1044 .with_number(rustledger_core::CostNumber::PerUnit {
1045 value: dec!(100.00),
1046 })
1047 .with_currency("USD"),
1048 ),
1049 )
1050 .with_synthesized_posting(Posting::new(
1051 "Assets:Cash",
1052 Amount::new(dec!(-1000.00), "USD"),
1053 ));
1054
1055 let result = interpolate(&txn).expect("interpolation should succeed");
1056
1057 // No postings should be filled
1058 assert!(result.filled_indices.is_empty());
1059
1060 // Transaction should balance
1061 let residual = result
1062 .residuals
1063 .get("USD")
1064 .copied()
1065 .unwrap_or(Decimal::ZERO);
1066 assert!(residual.abs() < dec!(0.01));
1067 }
1068
1069 #[test]
1070 fn test_interpolate_negative_cost_units_sale() {
1071 // Selling stock (negative units) with cost
1072 // 2015-10-02 *
1073 // Assets:Stock -5 HOOL {100.00 USD}
1074 // Assets:Cash
1075 //
1076 // Expected: Cash = 500.00 USD (proceeds from sale at cost)
1077 let txn = Transaction::new(date(2015, 10, 2), "Sell stock")
1078 .with_synthesized_posting(
1079 Posting::new("Assets:Stock", Amount::new(dec!(-5), "HOOL")).with_cost(
1080 rustledger_core::CostSpec::empty()
1081 .with_number(rustledger_core::CostNumber::PerUnit {
1082 value: dec!(100.00),
1083 })
1084 .with_currency("USD"),
1085 ),
1086 )
1087 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1088
1089 let result = interpolate(&txn).expect("interpolation should succeed");
1090
1091 let filled = &result.transaction.postings[1];
1092 let amount = get_amount(filled).expect("should have amount");
1093 assert_eq!(amount.currency, "USD");
1094 assert_eq!(amount.number, dec!(500.00)); // Positive (receiving cash)
1095 }
1096
1097 // =========================================================================
1098 // Multi-currency interpolation tests
1099 // =========================================================================
1100
1101 #[test]
1102 fn test_interpolate_multi_currency_single_elided() {
1103 // Test case from basic.beancount:
1104 // 2008-04-02 * "Gilbert paid back for iPhone"
1105 // Assets:Cash 440.00 CAD
1106 // Assets:AccountsReceivable -431.92 USD
1107 // Assets:Cash
1108 //
1109 // Expected: The elided Assets:Cash becomes TWO postings:
1110 // Assets:Cash: -440.00 CAD
1111 // Assets:Cash: 431.92 USD
1112 let txn = Transaction::new(date(2008, 4, 2), "Gilbert paid back for iPhone")
1113 .with_synthesized_posting(Posting::new(
1114 "Assets:Cash",
1115 Amount::new(dec!(440.00), "CAD"),
1116 ))
1117 .with_synthesized_posting(Posting::new(
1118 "Assets:AccountsReceivable",
1119 Amount::new(dec!(-431.92), "USD"),
1120 ))
1121 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1122
1123 let result = interpolate(&txn).expect("interpolation should succeed");
1124
1125 // Should now have 4 postings (original 3 + 1 added for second currency)
1126 assert_eq!(
1127 result.transaction.postings.len(),
1128 4,
1129 "should split elided posting into 2"
1130 );
1131
1132 // Check that all residuals are zero
1133 for (currency, residual) in &result.residuals {
1134 assert!(
1135 residual.abs() < dec!(0.01),
1136 "{currency} residual should be ~0, got {residual}"
1137 );
1138 }
1139
1140 // Verify the amounts (order may vary based on HashMap iteration)
1141 let mut found_cad = false;
1142 let mut found_usd = false;
1143 for posting in &result.transaction.postings {
1144 if let Some(amount) = get_amount(posting)
1145 && posting.account.as_str() == "Assets:Cash"
1146 {
1147 if amount.currency == "CAD" && amount.number == dec!(-440.00) {
1148 found_cad = true;
1149 } else if amount.currency == "USD" && amount.number == dec!(431.92) {
1150 found_usd = true;
1151 }
1152 }
1153 }
1154 assert!(found_cad, "should have -440.00 CAD posting");
1155 assert!(found_usd, "should have 431.92 USD posting");
1156 }
1157
1158 #[test]
1159 fn test_interpolate_multi_currency_three_currencies() {
1160 // Three currencies with one elided posting
1161 let txn = Transaction::new(date(2024, 1, 15), "Multi-currency test")
1162 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(100), "USD")))
1163 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(200), "EUR")))
1164 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(300), "GBP")))
1165 .with_synthesized_posting(Posting::auto("Equity:Opening"));
1166
1167 let result = interpolate(&txn).expect("interpolation should succeed");
1168
1169 // Should now have 6 postings (original 4 + 2 added)
1170 assert_eq!(result.transaction.postings.len(), 6);
1171
1172 // All residuals should be zero
1173 for (currency, residual) in &result.residuals {
1174 assert!(
1175 residual.abs() < dec!(0.01),
1176 "{currency} residual should be ~0, got {residual}"
1177 );
1178 }
1179 }
1180
1181 // =========================================================================
1182 // Cost currency inference tests (issue #203)
1183 // =========================================================================
1184
1185 /// Test interpolation with cost currency inferred from other postings.
1186 /// This is the exact case from issue #203.
1187 #[test]
1188 fn test_interpolate_cost_currency_inferred_from_other_posting() {
1189 // 2026-01-01 * "Opening balance"
1190 // Assets:Vanguard:IRA:Trad:VFIFX 10 VFIFX {100}
1191 // Equity:Opening-Balances
1192 //
1193 // The cost currency should be inferred, and the elided posting should
1194 // be filled with -1000 USD.
1195 let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
1196 .with_synthesized_posting(
1197 Posting::new(
1198 "Assets:Vanguard:IRA:Trad:VFIFX",
1199 Amount::new(dec!(10), "VFIFX"),
1200 )
1201 .with_cost(
1202 rustledger_core::CostSpec::empty()
1203 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1204 ),
1205 )
1206 .with_synthesized_posting(Posting::new(
1207 "Equity:Opening-Balances",
1208 Amount::new(dec!(-1000), "USD"),
1209 ));
1210
1211 let result = interpolate(&txn).expect("interpolation should succeed");
1212
1213 // Transaction should balance
1214 let residual = result
1215 .residuals
1216 .get("USD")
1217 .copied()
1218 .unwrap_or(Decimal::ZERO);
1219 assert!(
1220 residual.abs() < dec!(0.01),
1221 "USD residual should be ~0, got {residual}"
1222 );
1223 }
1224
1225 /// Test interpolation where the cash posting is elided.
1226 #[test]
1227 fn test_interpolate_cost_currency_inferred_elided_cash() {
1228 // Like issue #203 but with elided cash posting:
1229 // 2026-01-01 * "Opening balance"
1230 // Assets:Vanguard:IRA:Trad:VFIFX 10 VFIFX {100}
1231 // Equity:Opening-Balances -1000 USD
1232 //
1233 // Both postings are complete, should just balance.
1234 let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
1235 .with_synthesized_posting(
1236 Posting::new(
1237 "Assets:Vanguard:IRA:Trad:VFIFX",
1238 Amount::new(dec!(10), "VFIFX"),
1239 )
1240 .with_cost(
1241 rustledger_core::CostSpec::empty()
1242 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1243 ),
1244 )
1245 .with_synthesized_posting(Posting::new(
1246 "Equity:Opening-Balances",
1247 Amount::new(dec!(-1000), "USD"),
1248 ));
1249
1250 let result = interpolate(&txn).expect("interpolation should succeed");
1251
1252 // No postings filled since both are complete
1253 assert!(result.filled_indices.is_empty());
1254
1255 // Should balance
1256 let residual = result
1257 .residuals
1258 .get("USD")
1259 .copied()
1260 .unwrap_or(Decimal::ZERO);
1261 assert!(
1262 residual.abs() < dec!(0.01),
1263 "USD residual should be ~0, got {residual}"
1264 );
1265 }
1266
1267 // =========================================================================
1268 // Interpolation rounding tests (issue #268)
1269 // =========================================================================
1270
1271 /// Test that interpolated amounts are rounded to match the precision of other amounts.
1272 /// This matches Python beancount's behavior where interpolated amounts use the same
1273 /// quantum (decimal places) as other amounts in the same currency.
1274 ///
1275 /// Issue: <https://github.com/rustledger/rustledger/issues/268>
1276 #[test]
1277 fn test_interpolate_rounds_to_quantum() {
1278 // From issue #268:
1279 // 2026-01-02 * "..."
1280 // Assets:Cash
1281 // Assets:Abc 12.3340 ABC {140.02 USD, 2025-01-01}
1282 // Expenses:Abc -0.01 USD
1283 //
1284 // Cost: 12.3340 * 140.02 = 1727.006680 USD
1285 // Python rounds Cash to -1727.00 (2 decimal places from -0.01 USD)
1286 // Residual: 1727.006680 - 0.01 - 1727.00 = -0.003320 USD (within 0.005 tolerance)
1287 let txn = Transaction::new(date(2026, 1, 2), "Test")
1288 .with_synthesized_posting(Posting::auto("Assets:Cash"))
1289 .with_synthesized_posting(
1290 Posting::new("Assets:Abc", Amount::new(dec!(12.3340), "ABC")).with_cost(
1291 rustledger_core::CostSpec::empty()
1292 .with_number(rustledger_core::CostNumber::PerUnit {
1293 value: dec!(140.02),
1294 })
1295 .with_currency("USD"),
1296 ),
1297 )
1298 .with_synthesized_posting(Posting::new(
1299 "Expenses:Abc",
1300 Amount::new(dec!(-0.01), "USD"),
1301 ));
1302
1303 let result = interpolate(&txn).expect("interpolation should succeed");
1304
1305 // Check that Cash was filled
1306 assert_eq!(result.filled_indices, vec![0]);
1307
1308 // The interpolated amount should be rounded to 2 decimal places
1309 // (matching the -0.01 USD in Expenses:Abc)
1310 let filled = &result.transaction.postings[0];
1311 let amount = get_amount(filled).expect("should have amount");
1312 assert_eq!(amount.currency, "USD");
1313 assert_eq!(
1314 amount.number,
1315 dec!(-1727.00),
1316 "should be -1727.00 USD (rounded to 2 decimal places)"
1317 );
1318
1319 // The residual should be non-zero but small (within tolerance)
1320 let residual = result
1321 .residuals
1322 .get("USD")
1323 .copied()
1324 .unwrap_or(Decimal::ZERO);
1325 assert_eq!(
1326 residual,
1327 dec!(-0.003320),
1328 "residual should be -0.003320 USD"
1329 );
1330 }
1331
1332 /// Test that interpolation uses the maximum scale when multiple amounts have different scales.
1333 #[test]
1334 fn test_interpolate_uses_max_scale() {
1335 // When we have amounts with different scales, use the maximum.
1336 // 0.1 USD (scale 1) and 0.001 USD (scale 3) -> interpolate to scale 3
1337 let txn = Transaction::new(date(2024, 1, 15), "Test")
1338 .with_synthesized_posting(Posting::new("Expenses:A", Amount::new(dec!(0.1), "USD")))
1339 .with_synthesized_posting(Posting::new("Expenses:B", Amount::new(dec!(0.001), "USD")))
1340 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1341
1342 let result = interpolate(&txn).expect("interpolation should succeed");
1343
1344 let filled = &result.transaction.postings[2];
1345 let amount = get_amount(filled).expect("should have amount");
1346
1347 // The amount is exactly -0.101, which fits in 3 decimal places
1348 assert_eq!(amount.number, dec!(-0.101));
1349 // Scale should be 3 (the maximum of 1 and 3)
1350 assert_eq!(amount.number.scale(), 3);
1351 }
1352
1353 /// Test that cost spec scale is used when other postings have lower scale.
1354 ///
1355 /// Issue: <https://github.com/rustledger/rustledger/issues/333>
1356 ///
1357 /// When a transaction has:
1358 /// - A cost spec with decimal places (e.g., {2800.01 CAD})
1359 /// - Other postings with fewer decimal places (e.g., 1 CAD)
1360 ///
1361 /// The interpolated amount should use the cost spec's scale, not the
1362 /// lower scale from other postings.
1363 #[test]
1364 fn test_interpolate_cost_scale_preserved() {
1365 // From issue #333:
1366 // 2026-01-19 * "Buy stock"
1367 // Assets:Stock 1 CSU { 2800.01 CAD }
1368 // Expenses:Commission 1 CAD
1369 // Assets:Cash
1370 //
1371 // Cost: 1 * 2800.01 = 2800.01 CAD (scale 2)
1372 // Commission: 1 CAD (scale 0)
1373 // Without fix: Cash rounds to -2801.00 (scale 0), leaving 0.01 residual
1374 // With fix: Cash is -2801.01 (scale 2), transaction balances
1375 let txn = Transaction::new(date(2026, 1, 19), "Buy stock")
1376 .with_synthesized_posting(
1377 Posting::new("Assets:Stock", Amount::new(dec!(1), "CSU")).with_cost(
1378 rustledger_core::CostSpec::empty()
1379 .with_number(rustledger_core::CostNumber::PerUnit {
1380 value: dec!(2800.01),
1381 })
1382 .with_currency("CAD"),
1383 ),
1384 )
1385 .with_synthesized_posting(Posting::new(
1386 "Expenses:Commission",
1387 Amount::new(dec!(1), "CAD"),
1388 ))
1389 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1390
1391 let result = interpolate(&txn).expect("interpolation should succeed");
1392
1393 // Check that Cash was filled
1394 assert_eq!(result.filled_indices, vec![2]);
1395
1396 // The interpolated amount should be -2801.01 (scale 2 from cost spec)
1397 let filled = &result.transaction.postings[2];
1398 let amount = get_amount(filled).expect("should have amount");
1399 assert_eq!(amount.currency, "CAD");
1400 assert_eq!(
1401 amount.number,
1402 dec!(-2801.01),
1403 "should be -2801.01 CAD (preserving cost spec precision)"
1404 );
1405
1406 // Transaction should balance (no residual)
1407 let residual = result
1408 .residuals
1409 .get("CAD")
1410 .copied()
1411 .unwrap_or(Decimal::ZERO);
1412 assert!(
1413 residual.is_zero(),
1414 "CAD residual should be 0, got {residual}"
1415 );
1416 }
1417
1418 // =========================================================================
1419 // Currency inference from cost basis tests
1420 // =========================================================================
1421
1422 /// Test that zero-amount postings are removed when transaction balances perfectly.
1423 /// Zero-amount interpolated postings are pruned by booking.
1424 ///
1425 /// When a transaction with cost basis balances to zero (cost equals
1426 /// cash), the elided counterpart fills with 0 and gets dropped from
1427 /// the booked output — matches Python beancount's display behavior.
1428 /// The #877 invariant (catching E1001 on the elided posting's
1429 /// account) is preserved by running the loader's early-phase
1430 /// account validator BEFORE booking; see `rustledger-validate`'s
1431 /// `Phase::Early` and `test_zero_interpolated_posting_keeps_e1001_on_unopened_account`
1432 /// in `rustledger-loader/tests/loader_test.rs` for the
1433 /// end-to-end coverage.
1434 ///
1435 /// Example:
1436 /// ```beancount
1437 /// Assets:Crypto 100 USDC {1.0 USD, 2022-04-16}
1438 /// Assets:Cash -100 USD
1439 /// Income:Trading ; <- fills to 0 USD, pruned
1440 /// ```
1441 #[test]
1442 fn test_interpolate_balanced_cost_prunes_zero_posting() {
1443 let txn = Transaction::new(date(2022, 4, 16), "Trade")
1444 .with_synthesized_posting(
1445 Posting::new("Assets:Crypto", Amount::new(dec!(100), "USDC")).with_cost(
1446 rustledger_core::CostSpec::empty()
1447 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.0) })
1448 .with_currency("USD"),
1449 ),
1450 )
1451 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-100), "USD")))
1452 .with_synthesized_posting(Posting::auto("Income:Trading"));
1453
1454 let result = interpolate(&txn).expect("interpolation should succeed");
1455
1456 assert!(
1457 result.filled_indices.is_empty(),
1458 "zero-amount filled posting should have been pruned"
1459 );
1460 assert_eq!(
1461 result.transaction.postings.len(),
1462 2,
1463 "Income:Trading filled to 0 USD should be pruned"
1464 );
1465 assert!(
1466 !result
1467 .transaction
1468 .postings
1469 .iter()
1470 .any(|p| p.account.as_str() == "Income:Trading"),
1471 "Income:Trading should not be in postings after pruning"
1472 );
1473 }
1474
1475 /// Zero-cost basis: empty posting fills to 0 and is pruned.
1476 ///
1477 /// Example:
1478 /// ```beancount
1479 /// Assets:Crypto 100 TOKEN {0 USD}
1480 /// Income:Bonus ; <- fills to 0 USD, pruned
1481 /// ```
1482 #[test]
1483 fn test_interpolate_zero_cost_prunes_zero_posting() {
1484 let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1485 .with_synthesized_posting(
1486 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1487 rustledger_core::CostSpec::empty()
1488 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1489 .with_currency("USD"),
1490 ),
1491 )
1492 .with_synthesized_posting(Posting::auto("Income:Bonus"));
1493
1494 let result = interpolate(&txn).expect("interpolation should succeed");
1495
1496 assert!(
1497 result.filled_indices.is_empty(),
1498 "zero-amount filled posting should have been pruned"
1499 );
1500 assert_eq!(result.transaction.postings.len(), 1);
1501 }
1502
1503 /// Zero total cost: empty posting fills to 0 and is pruned.
1504 ///
1505 /// Example:
1506 /// ```beancount
1507 /// Assets:Crypto 100 TOKEN {{0 USD}}
1508 /// Income:Bonus ; <- fills to 0 USD, pruned
1509 /// ```
1510 #[test]
1511 fn test_interpolate_zero_total_cost_prunes_zero_posting() {
1512 let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1513 .with_synthesized_posting(
1514 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1515 rustledger_core::CostSpec::empty()
1516 .with_number(rustledger_core::CostNumber::Total { value: dec!(0) })
1517 .with_currency("USD"),
1518 ),
1519 )
1520 .with_synthesized_posting(Posting::auto("Income:Bonus"));
1521
1522 let result = interpolate(&txn).expect("interpolation should succeed");
1523
1524 assert!(
1525 result.filled_indices.is_empty(),
1526 "zero-amount filled posting should have been pruned"
1527 );
1528 assert_eq!(result.transaction.postings.len(), 1);
1529 }
1530
1531 // ─── Issue #1026: empty cost spec + missing posting in same group ───
1532 //
1533 // bean-check rejects with "Too many missing numbers for currency
1534 // group 'CCY'" when a transaction has both:
1535 // 1. A posting with empty cost spec `{}` (cost-basis weight unknown
1536 // until booking-pass lot matching).
1537 // 2. Another posting in the same currency group missing its amount.
1538 //
1539 // Pre-fix, rledger silently used the price annotation as the
1540 // posting's weight when cost was unknown, producing a balanced
1541 // residual and accepting the transaction.
1542
1543 /// Minimal repro from #1026's body: position with `{} @ price` plus
1544 /// missing-amount Income:PnL must error.
1545 #[test]
1546 fn test_interpolate_empty_cost_spec_with_missing_amount_errors() {
1547 use rustledger_core::CostSpec;
1548
1549 let txn = Transaction::new(date(2022, 1, 12), "sell what was never bought")
1550 .with_synthesized_posting(
1551 Posting::new(
1552 "Assets:Htsec:Positions",
1553 Amount::new(dec!(-13000.00), "SH513050"),
1554 )
1555 .with_cost(CostSpec::empty()) // empty `{}` — unknown cost
1556 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1557 dec!(1.300),
1558 "CNY",
1559 ))),
1560 )
1561 .with_synthesized_posting(Posting::new(
1562 "Assets:Htsec:Cash",
1563 Amount::new(dec!(16900.00), "CNY"),
1564 ))
1565 .with_synthesized_posting(Posting::new(
1566 "Assets:Htsec:Cash",
1567 Amount::new(dec!(-0.85), "CNY"),
1568 ))
1569 .with_synthesized_posting(Posting::new(
1570 "Expenses:Htsec:Commission",
1571 Amount::new(dec!(0.85), "CNY"),
1572 ))
1573 .with_synthesized_posting(Posting::auto("Income:Htsec:PnL"));
1574
1575 let result = interpolate(&txn);
1576 assert!(
1577 matches!(result, Err(InterpolationError::MultipleMissing { .. })),
1578 "expected MultipleMissing error from empty cost spec + missing posting; got {result:?}"
1579 );
1580 if let Err(InterpolationError::MultipleMissing { currency, count }) = result {
1581 assert_eq!(currency.as_str(), "CNY");
1582 assert!(
1583 count >= 2,
1584 "expected count >= 2 unknowns in CNY group, got {count}"
1585 );
1586 }
1587 }
1588
1589 /// Empty cost spec by itself (no other missing posting) is OK — the
1590 /// booking pass will resolve the lot match. Pre- and post-fix should
1591 /// agree.
1592 #[test]
1593 fn test_interpolate_empty_cost_spec_alone_ok() {
1594 use rustledger_core::CostSpec;
1595
1596 let txn = Transaction::new(date(2022, 1, 12), "Sell HOOL")
1597 .with_synthesized_posting(
1598 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1599 .with_cost(CostSpec::empty())
1600 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1601 dec!(150),
1602 "USD",
1603 ))),
1604 )
1605 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
1606
1607 let result = interpolate(&txn);
1608 assert!(
1609 result.is_ok(),
1610 "single empty cost spec with no missing postings should succeed; got {result:?}"
1611 );
1612 }
1613
1614 /// Two empty cost specs in the same currency group: two cost-unknowns
1615 /// in one group, no missing-amount postings needed → still errors.
1616 #[test]
1617 fn test_interpolate_two_empty_cost_specs_same_currency_errors() {
1618 use rustledger_core::CostSpec;
1619
1620 let txn = Transaction::new(date(2022, 1, 12), "Two unknown-cost sells")
1621 .with_synthesized_posting(
1622 Posting::new("Assets:StockA", Amount::new(dec!(-10), "AAPL"))
1623 .with_cost(CostSpec::empty())
1624 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1625 dec!(150),
1626 "USD",
1627 ))),
1628 )
1629 .with_synthesized_posting(
1630 Posting::new("Assets:StockB", Amount::new(dec!(-5), "GOOG"))
1631 .with_cost(CostSpec::empty())
1632 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1633 dec!(2000),
1634 "USD",
1635 ))),
1636 )
1637 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(11500), "USD")));
1638
1639 let result = interpolate(&txn);
1640 assert!(
1641 matches!(result, Err(InterpolationError::MultipleMissing { .. })),
1642 "two empty cost specs in same currency should error; got {result:?}"
1643 );
1644 }
1645
1646 /// Issue #1705: an augmenting `{}` posting with one balancing leg has
1647 /// its per-unit cost inferred from the residual (like beancount), not
1648 /// left with an empty cost basis. `1000 USD {}` + `-900 EUR` → the lot
1649 /// is booked at 0.90 EUR/unit.
1650 #[test]
1651 fn test_interpolate_augmenting_empty_cost_inferred_from_residual() {
1652 use rustledger_core::{CostNumber, CostSpec};
1653
1654 let txn = Transaction::new(date(2024, 1, 2), "buy USD")
1655 .with_synthesized_posting(
1656 Posting::new("Assets:Broker", Amount::new(dec!(1000), "USD"))
1657 .with_cost(CostSpec::empty()), // augmenting `{}` — no price
1658 )
1659 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-900), "EUR")));
1660
1661 let result = interpolate(&txn).expect("augmenting `{}` should interpolate its cost");
1662 let cost = result.transaction.postings[0]
1663 .cost
1664 .as_ref()
1665 .expect("cost spec should be present");
1666 assert_eq!(cost.currency.as_deref(), Some("EUR"));
1667 match cost.number {
1668 Some(CostNumber::PerUnitFromTotal(b)) => {
1669 assert_eq!(b.per_unit, dec!(0.90), "per-unit cost");
1670 assert_eq!(b.total, dec!(900), "preserved total");
1671 }
1672 other => panic!("expected PerUnitFromTotal, got {other:?}"),
1673 }
1674 // Cost currency now balances.
1675 assert_eq!(
1676 result
1677 .residuals
1678 .get("EUR")
1679 .copied()
1680 .unwrap_or(Decimal::ZERO),
1681 Decimal::ZERO
1682 );
1683 }
1684
1685 /// Cost-unknown in one currency + missing-amount posting in a
1686 /// DIFFERENT currency: should succeed. The two unknowns belong to
1687 /// disjoint currency groups, so the rule is satisfied per-group.
1688 /// Verifies the rule check is per-currency, not global.
1689 #[test]
1690 fn test_interpolate_empty_cost_spec_with_missing_in_different_currency_ok() {
1691 use rustledger_core::CostSpec;
1692
1693 let txn = Transaction::new(date(2022, 1, 12), "Sale + currency-known absorber")
1694 .with_synthesized_posting(
1695 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1696 .with_cost(CostSpec::empty()) // cost-unknown in USD
1697 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1698 dec!(150),
1699 "USD",
1700 ))),
1701 )
1702 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")))
1703 .with_synthesized_posting(Posting::new("Expenses:Fee", Amount::new(dec!(5), "EUR")))
1704 .with_synthesized_posting(Posting {
1705 // Missing amount, currency known via CurrencyOnly: lands in EUR.
1706 units: Some(IncompleteAmount::CurrencyOnly("EUR".into())),
1707 ..Posting::auto("Income:Misc")
1708 });
1709
1710 let result = interpolate(&txn);
1711 assert!(
1712 result.is_ok(),
1713 "cost-unknown in USD + missing-amount in EUR should succeed (disjoint groups); \
1714 got {result:?}"
1715 );
1716 }
1717
1718 /// Issue #1107: an interpolated residual must not inherit the high
1719 /// scale of a derived per-unit cost (which can be 26+ digits from
1720 /// `total / units` division). Python beancount quantizes the
1721 /// residual to currency precision derived from explicit posting
1722 /// units, not cost spec scales.
1723 ///
1724 /// Repro: a sell with explicit high-precision per-unit cost. Pre-fix,
1725 /// the cost scale (5) merged into `max_scale_by_currency[USD]`,
1726 /// rounding the residual to 5dp (`-36.72498`). Post-fix, only the
1727 /// `336.73 USD` cash side contributes to USD precision (scale=2), so
1728 /// the residual is `-36.72` (matches bean-query exactly).
1729 #[test]
1730 fn test_interpolate_residual_ignores_cost_spec_scale() {
1731 use rustledger_core::CostSpec;
1732
1733 let cost_spec = CostSpec {
1734 number: Some(rustledger_core::CostNumber::PerUnit {
1735 value: dec!(170.16734),
1736 }),
1737 currency: Some(Currency::from("USD")),
1738 date: None,
1739 label: None,
1740 merge: false,
1741 };
1742
1743 let txn = Transaction::new(date(2016, 2, 12), "Sell")
1744 .with_synthesized_posting(Posting::new(
1745 "Assets:Cash",
1746 Amount::new(dec!(336.73), "USD"),
1747 ))
1748 .with_synthesized_posting(
1749 Posting::new("Assets:Brokerage", Amount::new(dec!(-1.763), "STOCK"))
1750 .with_cost(cost_spec)
1751 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1752 dec!(191.00),
1753 "USD",
1754 ))),
1755 )
1756 .with_synthesized_posting(Posting::auto("Income:Capital-Gains"));
1757
1758 let result = interpolate(&txn).expect("interpolation should succeed");
1759 let filled = &result.transaction.postings[2];
1760 let amount = get_amount(filled).expect("Income should have amount");
1761
1762 assert_eq!(
1763 amount.currency.as_str(),
1764 "USD",
1765 "residual currency should be USD"
1766 );
1767 assert_eq!(
1768 amount.number.scale(),
1769 2,
1770 "residual scale must be 2 (USD precision from `336.73 USD`), \
1771 not 5 (from cost spec). Pre-fix this was 5. (#1107)"
1772 );
1773 assert_eq!(
1774 amount.number,
1775 dec!(-36.72),
1776 "residual value should match bean-query exactly (#1107). \
1777 Was -36.72498 before fix."
1778 );
1779 }
1780
1781 /// End-to-end #1107 repro through the booking pass — this is the
1782 /// path that actually surfaces in real ledgers, where the booking
1783 /// engine derives a 26+ digit per-unit cost from `{{total}} / units`
1784 /// (or lot-matches a `{}` sell against such a derived cost) and
1785 /// previously propagated that scale into the interpolated residual.
1786 ///
1787 /// Concretely models the healthequity fixture pattern: buy with
1788 /// `{{total}}` total cost, sell with `{}` lot-match. After booking,
1789 /// the sell's filled `CostSpec` carries the high-scale `per_unit` from
1790 /// the division — and interpolation must STILL round the missing
1791 /// Income residual to USD's 2dp (no posting-unit-scale cost-scale
1792 /// contamination).
1793 #[test]
1794 fn test_interpolate_residual_after_booking_total_cost_division() {
1795 use crate::book::BookingEngine;
1796 use rustledger_core::{Cost, CostSpec, IncompleteAmount, PriceAnnotation};
1797
1798 // Buy: 1.763 STOCK {{300.00 USD}} → booking derives
1799 // per_unit = 300.00 / 1.763 = ~170.16449... at 26-digit scale.
1800 let buy = Transaction::new(date(2016, 1, 1), "Buy")
1801 .with_synthesized_posting(
1802 Posting::new("Assets:Brokerage", Amount::new(dec!(1.763), "STOCK")).with_cost(
1803 CostSpec {
1804 number: Some(rustledger_core::CostNumber::Total {
1805 value: dec!(300.00),
1806 }),
1807 currency: Some(Currency::from("USD")),
1808 date: None,
1809 label: None,
1810 merge: false,
1811 },
1812 ),
1813 )
1814 .with_synthesized_posting(Posting::new(
1815 "Assets:Cash",
1816 Amount::new(dec!(-300.00), "USD"),
1817 ));
1818
1819 // Sell: -1.763 STOCK {} @ 191.00 USD — empty cost spec; booking
1820 // lot-matches against the previous buy, filling the high-scale
1821 // derived per_unit. Income is missing, must be interpolated.
1822 let sell = Transaction::new(date(2016, 2, 12), "Sell")
1823 .with_synthesized_posting(Posting::new(
1824 "Assets:Cash",
1825 Amount::new(dec!(336.73), "USD"),
1826 ))
1827 .with_synthesized_posting(
1828 Posting::new("Assets:Brokerage", Amount::new(dec!(-1.763), "STOCK"))
1829 .with_cost(CostSpec::empty())
1830 .with_price(PriceAnnotation::unit(Amount::new(dec!(191.00), "USD"))),
1831 )
1832 .with_synthesized_posting(Posting::auto("Income:Capital-Gains"));
1833
1834 let mut engine = BookingEngine::new();
1835 engine.apply(&buy);
1836
1837 // book_and_interpolate handles the empty `{}` lot match AND
1838 // runs interpolation on the booked transaction. The Income
1839 // residual must end up at USD's 2dp scale — pre-fix this
1840 // inherited the lot's derived 26-digit per_unit scale.
1841 let result = engine
1842 .book_and_interpolate(&sell)
1843 .expect("booking+interpolation should succeed");
1844
1845 let income = &result.transaction.postings[2];
1846 let amount = get_amount(income).expect("Income should have an amount after interpolation");
1847
1848 assert_eq!(amount.currency.as_str(), "USD");
1849 assert!(
1850 amount.number.scale() <= 2,
1851 "residual scale must be ≤ 2 (USD's tracked precision), \
1852 not inherited from the lot's high-scale derived per_unit. \
1853 Got scale={} number={}",
1854 amount.number.scale(),
1855 amount.number
1856 );
1857
1858 // Use `_ = Cost::new` to keep the import live without an
1859 // unrelated unused-import warning if the test grows.
1860 let _ = Cost::new(dec!(1), "USD");
1861 let _: Option<IncompleteAmount> = None;
1862 }
1863
1864 /// UNASSIGNED missing posting (no currency context) instead of a
1865 /// currency-known one. bean-check rejects this because the
1866 /// unassigned could absorb residuals across all currencies including
1867 /// the cost-unknown's; the rejection is conservative-by-design.
1868 /// Pins the empirically-verified bean-check parity (#1026 review).
1869 #[test]
1870 fn test_interpolate_empty_cost_spec_with_unassigned_in_different_currency_errors() {
1871 use rustledger_core::CostSpec;
1872
1873 let txn = Transaction::new(date(2022, 1, 12), "Sale + unassigned absorber")
1874 .with_synthesized_posting(
1875 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1876 .with_cost(CostSpec::empty())
1877 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1878 dec!(150),
1879 "USD",
1880 ))),
1881 )
1882 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")))
1883 .with_synthesized_posting(Posting::new("Expenses:Fee", Amount::new(dec!(5), "EUR")))
1884 .with_synthesized_posting(Posting::auto("Income:Misc"));
1885
1886 let result = interpolate(&txn);
1887 assert!(
1888 matches!(result, Err(InterpolationError::MultipleMissing { .. })),
1889 "cost-unknown + unassigned-missing must error even when in different \
1890 currencies (bean-check parity); got {result:?}"
1891 );
1892 }
1893
1894 // ---- #1309 cluster 2: residual / price arithmetic ----------------
1895 // Exact-value assertions on the residual math so the surviving
1896 // mutants (cost/price `*`, residual `+=`, the multi-currency split
1897 // guard and index math) are killed.
1898
1899 #[test]
1900 fn interpolate_unit_price_is_units_times_price() {
1901 // 10 STK @ 3 USD → the elided cash leg is -30 USD.
1902 let txn = Transaction::new(date(2024, 1, 1), "Buy")
1903 .with_synthesized_posting(
1904 Posting::new("Assets:Stock", Amount::new(dec!(10), "STK")).with_price(
1905 rustledger_core::PriceAnnotation::unit(Amount::new(dec!(3), "USD")),
1906 ),
1907 )
1908 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1909 let r = interpolate(&txn).expect("interpolation should succeed");
1910 let cash = get_amount(&r.transaction.postings[1]).expect("filled");
1911 assert_eq!(cash.currency, "USD");
1912 assert_eq!(cash.number, dec!(-30)); // kills `abs * price` and `* signum -> +`
1913 }
1914
1915 #[test]
1916 fn interpolate_total_price_is_total() {
1917 // 10 STK @@ 30 USD → elided cash -30 USD.
1918 let txn = Transaction::new(date(2024, 1, 1), "Buy")
1919 .with_synthesized_posting(
1920 Posting::new("Assets:Stock", Amount::new(dec!(10), "STK")).with_price(
1921 rustledger_core::PriceAnnotation::total(Amount::new(dec!(30), "USD")),
1922 ),
1923 )
1924 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1925 let r = interpolate(&txn).expect("interpolation should succeed");
1926 let cash = get_amount(&r.transaction.postings[1]).expect("filled");
1927 assert_eq!(cash.number, dec!(-30)); // kills total-price `* signum -> +`
1928 assert_eq!(cash.currency, "USD"); // right magnitude in the right currency
1929 }
1930
1931 #[test]
1932 fn interpolate_three_posting_residual_sum() {
1933 // 100 USD + 25 USD + elided → cash -125 USD.
1934 let txn = Transaction::new(date(2024, 1, 1), "Split")
1935 .with_synthesized_posting(Posting::new("Expenses:A", Amount::new(dec!(100), "USD")))
1936 .with_synthesized_posting(Posting::new("Expenses:B", Amount::new(dec!(25), "USD")))
1937 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1938 let r = interpolate(&txn).expect("interpolation should succeed");
1939 let cash = get_amount(&r.transaction.postings[2]).expect("filled");
1940 assert_eq!(cash.number, dec!(-125)); // kills residual `+= -> -=`/`*=`
1941 }
1942
1943 #[test]
1944 fn interpolate_single_elided_splits_two_currencies() {
1945 // One auto posting absorbs two currency residuals → two filled
1946 // postings (-100 USD, -50 EUR). Exercises the multi-currency
1947 // split path's guard and `len() - 1` index push.
1948 let txn = Transaction::new(date(2024, 1, 1), "FX")
1949 .with_synthesized_posting(Posting::new("Assets:USD", Amount::new(dec!(100), "USD")))
1950 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(50), "EUR")))
1951 .with_synthesized_posting(Posting::auto("Equity:Balance"));
1952 let r = interpolate(&txn).expect("interpolation should succeed");
1953 let filled: Vec<Amount> = r
1954 .filled_indices
1955 .iter()
1956 .map(|&i| {
1957 get_amount(&r.transaction.postings[i])
1958 .expect("filled")
1959 .clone()
1960 })
1961 .collect();
1962 assert_eq!(filled.len(), 2, "one elided posting should split into two");
1963 assert!(
1964 filled
1965 .iter()
1966 .any(|a| a.currency == "USD" && a.number == dec!(-100))
1967 );
1968 assert!(
1969 filled
1970 .iter()
1971 .any(|a| a.currency == "EUR" && a.number == dec!(-50))
1972 );
1973 }
1974
1975 #[test]
1976 fn interpolate_post_fill_residual_returns_to_zero() {
1977 // After filling the elided leg, the tracked residual must return
1978 // to zero (kills the post-fill `residual += interpolated` mutants:
1979 // `-=` → 2R, `*=` → R·interpolated).
1980 let txn = Transaction::new(date(2024, 1, 1), "Split")
1981 .with_synthesized_posting(Posting::new("Expenses:A", Amount::new(dec!(100), "USD")))
1982 .with_synthesized_posting(Posting::new("Expenses:B", Amount::new(dec!(25), "USD")))
1983 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1984 let r = interpolate(&txn).expect("interpolation should succeed");
1985 assert_eq!(
1986 r.residuals.get("USD").copied(),
1987 Some(dec!(0)),
1988 "residual must be exactly zero after the elided leg is filled"
1989 );
1990 }
1991
1992 #[test]
1993 fn interpolate_preserves_subcent_residual() {
1994 // Explicit USD legs net to zero; a 0.001 USD per-unit price
1995 // contribution leaves a sub-cent residual. The currency's tracked
1996 // scale is 2 (from the 1.00 USD legs), so naively rounding the
1997 // -0.001 fill to 0.00 would silently leave the txn unbalanced.
1998 // `round_interpolated` must keep full precision — kills the
1999 // `!residual.is_zero()` guard.
2000 let txn = Transaction::new(date(2024, 1, 1), "subcent")
2001 .with_synthesized_posting(Posting::new("Assets:A", Amount::new(dec!(1.00), "USD")))
2002 .with_synthesized_posting(Posting::new("Assets:B", Amount::new(dec!(-1.00), "USD")))
2003 .with_synthesized_posting(
2004 Posting::new("Assets:Stock", Amount::new(dec!(1), "STK")).with_price(
2005 rustledger_core::PriceAnnotation::unit(Amount::new(dec!(0.001), "USD")),
2006 ),
2007 )
2008 .with_synthesized_posting(Posting::auto("Assets:Cash"));
2009 let r = interpolate(&txn).expect("interpolation should succeed");
2010 let cash = r
2011 .filled_indices
2012 .iter()
2013 .map(|&i| get_amount(&r.transaction.postings[i]).expect("filled"))
2014 .find(|a| a.currency == "USD")
2015 .expect("a USD fill");
2016 assert_eq!(
2017 cash.number,
2018 dec!(-0.001),
2019 "sub-cent residual must be preserved, not rounded to zero"
2020 );
2021 }
2022
2023 #[test]
2024 fn interpolate_currency_only_fill_zeroes_residual() {
2025 // A CurrencyOnly elided leg (`Assets:Cash USD`, number missing)
2026 // is filled via the known-currency path; the post-fill residual
2027 // must return to zero (kills that path's `residual += interpolated`).
2028 let txn = Transaction::new(date(2024, 1, 1), "currency-only")
2029 .with_synthesized_posting(Posting::new("Expenses:X", Amount::new(dec!(100), "USD")))
2030 .with_synthesized_posting(Posting::with_incomplete(
2031 "Assets:Cash",
2032 IncompleteAmount::CurrencyOnly("USD".into()),
2033 ));
2034 let r = interpolate(&txn).expect("interpolation should succeed");
2035 let cash = get_amount(&r.transaction.postings[1]).expect("filled");
2036 assert_eq!(cash.number, dec!(-100));
2037 assert_eq!(r.residuals.get("USD").copied(), Some(dec!(0)));
2038 }
2039
2040 #[test]
2041 fn interpolate_number_only_infers_currency_and_balances() {
2042 // A NumberOnly leg (`-100`, currency missing) infers its currency
2043 // from its OWN price annotation (the arm only consults the
2044 // posting's own cost/price, never siblings — a bare NumberOnly
2045 // with no cost/price would route to the unassigned path instead).
2046 // The `@ 1 USD` price is a currency hint with a unit multiplier,
2047 // so the leg contributes `-100` to the residual via that arm's
2048 // `residual += *number` — which this test kills.
2049 let txn = Transaction::new(date(2024, 1, 1), "number-only")
2050 .with_synthesized_posting(Posting::new("Expenses:X", Amount::new(dec!(100), "USD")))
2051 .with_synthesized_posting(
2052 Posting::with_incomplete("Assets:Cash", IncompleteAmount::NumberOnly(dec!(-100)))
2053 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
2054 dec!(1),
2055 "USD",
2056 ))),
2057 );
2058 let r = interpolate(&txn).expect("interpolation should succeed");
2059 assert_eq!(
2060 r.residuals.get("USD").copied(),
2061 Some(dec!(0)),
2062 "NumberOnly leg's number must net the residual to zero"
2063 );
2064 }
2065}