rustledger_booking/book.rs
1//! Transaction booking with lot matching.
2//!
3//! This module handles:
4//! - Tracking inventory across transactions
5//! - Matching sold lots against existing holdings
6//! - Calculating capital gains/losses
7//! - Filling in cost specs for lot reductions
8
9// ratchet: fxhash-only — hot path; use FxHashMap/FxHashSet, not std SipHash collections (#1237).
10use rustc_hash::{FxHashMap, FxHashSet};
11use rustledger_core::{
12 AccountedBookingError, Amount, BookingMethod, Cost, CostSpec, Directive, IncompleteAmount,
13 Inventory, Position, Posting, ReductionScope, Transaction,
14};
15use thiserror::Error;
16
17use crate::{InterpolationError, InterpolationResult, interpolate};
18
19// Note: We no longer quantize calculated values during booking.
20// Python beancount preserves full precision during booking and only
21// rounds at display time. Premature rounding of per-unit costs (e.g.,
22// from total cost / units) causes cost basis errors when selling.
23// For example: 300.00 / 1.763 = 170.16505... should NOT be rounded
24// to 170.17, because 1.763 * 170.17 = 300.00971 ≠ 300.00.
25
26/// Errors that can occur during booking.
27///
28/// Inventory-level failures (insufficient units, no matching lot, ambiguous
29/// match, currency mismatch) are unified under [`BookingError::Inventory`],
30/// which carries an [`AccountedBookingError`] from `rustledger-core`. This
31/// keeps the user-facing wording in **one place** so it cannot drift between
32/// the booking layer and the validator — see #748 / #750.
33#[derive(Debug, Clone, Error)]
34pub enum BookingError {
35 /// An inventory-level booking failure (insufficient units, no matching
36 /// lot, ambiguous match, currency mismatch).
37 ///
38 /// `Display` is delegated to the inner [`AccountedBookingError`], which
39 /// is the single canonical source of wording for booking errors. The
40 /// pta-standards `reduction-exceeds-inventory` conformance test depends
41 /// on this Display containing the literal substring `"not enough"`.
42 #[error(transparent)]
43 Inventory(AccountedBookingError),
44
45 /// Interpolation failed after booking.
46 #[error("interpolation failed: {0}")]
47 Interpolation(#[from] InterpolationError),
48}
49
50/// Result of booking a single transaction.
51#[derive(Debug, Clone)]
52pub struct BookedTransaction {
53 /// The transaction with costs filled in.
54 pub transaction: Transaction,
55 /// Capital gains/losses generated by this transaction.
56 pub gains: Vec<CapitalGain>,
57 /// Which posting indices had costs filled in.
58 pub booked_indices: Vec<usize>,
59}
60
61/// A capital gain or loss from a lot sale.
62#[derive(Debug, Clone)]
63pub struct CapitalGain {
64 /// The account holding the asset.
65 pub account: rustledger_core::Account,
66 /// The currency of the asset.
67 pub currency: rustledger_core::Currency,
68 /// The gain amount (positive) or loss (negative).
69 pub amount: Amount,
70 /// Cost basis of the sold lot.
71 pub cost_basis: Amount,
72 /// Sale proceeds.
73 pub proceeds: Amount,
74}
75
76/// Booking engine that tracks inventory across transactions.
77#[derive(Debug, Default)]
78pub struct BookingEngine {
79 /// Inventory per account.
80 inventories: FxHashMap<rustledger_core::Account, Inventory>,
81 /// Default booking method, used for accounts without an explicit
82 /// booking method on their `open` directive.
83 booking_method: BookingMethod,
84 /// Per-account booking method overrides (from `open` directives).
85 /// Looked up first, falling back to `booking_method` if absent.
86 account_methods: FxHashMap<rustledger_core::Account, BookingMethod>,
87}
88
89impl BookingEngine {
90 /// Create a new booking engine with default FIFO booking.
91 #[must_use]
92 pub fn new() -> Self {
93 Self {
94 inventories: FxHashMap::default(),
95 booking_method: BookingMethod::Fifo,
96 account_methods: FxHashMap::default(),
97 }
98 }
99
100 /// Create a booking engine with a specific default booking method.
101 #[must_use]
102 pub fn with_method(method: BookingMethod) -> Self {
103 Self {
104 inventories: FxHashMap::default(),
105 booking_method: method,
106 account_methods: FxHashMap::default(),
107 }
108 }
109
110 /// Register the booking method for a specific account.
111 ///
112 /// Call this for each `open` directive *before* booking transactions for
113 /// that account, so the engine uses the per-account method (e.g. FIFO,
114 /// LIFO, NONE) rather than the engine-wide default. Subsequent calls
115 /// overwrite the previous method for the account.
116 pub fn set_account_method(&mut self, account: rustledger_core::Account, method: BookingMethod) {
117 self.account_methods.insert(account, method);
118 }
119
120 /// Scan a sequence of directives and register any per-account booking
121 /// methods found on `open` directives. Open directives whose booking
122 /// method is absent or fails to parse are silently ignored (they fall
123 /// back to the engine-wide default).
124 ///
125 /// This is a convenience wrapper around [`Self::set_account_method`] for
126 /// the common pipeline pattern of scanning all directives once before
127 /// the booking loop. Call this before booking any transactions so the
128 /// engine uses each account's declared method rather than the
129 /// engine-wide default for every account.
130 pub fn register_account_methods<'a, I>(&mut self, directives: I)
131 where
132 I: IntoIterator<Item = &'a rustledger_core::Directive>,
133 {
134 for directive in directives {
135 if let rustledger_core::Directive::Open(open) = directive
136 && let Some(method_str) = &open.booking
137 && let Ok(method) = method_str.parse::<BookingMethod>()
138 {
139 self.set_account_method(open.account.clone(), method);
140 }
141 }
142 }
143
144 /// Resolve the booking method for an account, falling back to the
145 /// engine-wide default if not registered.
146 fn method_for(&self, account: &rustledger_core::Account) -> BookingMethod {
147 self.account_methods
148 .get(account)
149 .copied()
150 .unwrap_or(self.booking_method)
151 }
152
153 /// Get the inventory for an account.
154 #[must_use]
155 pub fn inventory(&self, account: &rustledger_core::Account) -> Option<&Inventory> {
156 self.inventories.get(account)
157 }
158
159 /// Consume the engine and return its realized per-account inventories.
160 ///
161 /// After [`apply`](Self::apply)ing a booked directive stream, this is the
162 /// canonical realization — each account's `Inventory` carries its lots at
163 /// cost, with reductions already matched (FIFO/LIFO/etc. per the account's
164 /// booking method). Callers that render balances (reports, holdings) read
165 /// this rather than re-summing postings, so cost is retained and lot
166 /// matching is the engine's, not a re-implementation.
167 #[must_use]
168 pub fn into_inventories(self) -> FxHashMap<rustledger_core::Account, Inventory> {
169 self.inventories
170 }
171
172 /// Book a transaction: fill in empty cost specs and calculate gains.
173 ///
174 /// This does NOT modify the internal inventories - call `apply` for that.
175 ///
176 /// When a reduction matches multiple lots (e.g., selling shares that were purchased
177 /// across multiple buy transactions), the posting is expanded into multiple postings,
178 /// one for each matched lot. This matches Python beancount's behavior.
179 pub fn book(&self, txn: &Transaction) -> Result<BookedTransaction, BookingError> {
180 // Fast path: if no postings have cost specs, no booking is needed.
181 // This avoids expensive inventory cloning for simple transactions.
182 let has_cost_specs = txn.postings.iter().any(|p| p.cost.is_some());
183 if !has_cost_specs {
184 return Ok(BookedTransaction {
185 transaction: txn.clone(),
186 gains: Vec::new(),
187 booked_indices: Vec::new(),
188 });
189 }
190
191 let mut result = txn.clone();
192 let mut gains = Vec::new();
193 let mut booked_indices: FxHashSet<usize> =
194 FxHashSet::with_capacity_and_hasher(txn.postings.len(), Default::default());
195 // Track posting expansions: (original_idx, expanded_postings)
196 let mut expansions: Vec<(usize, Vec<rustledger_core::Spanned<Posting>>)> =
197 Vec::with_capacity(txn.postings.len());
198
199 // Create working copies of inventories for this transaction.
200 // This allows us to track inventory changes across multiple postings
201 // within the same transaction (e.g., main sale + fee posting).
202 //
203 // Clone only the inventories we actually need for this transaction's
204 // accounts. Use `entry().or_insert_with(...)` so that a posting list
205 // with repeated accounts (e.g., two postings on `Assets:Stock`) only
206 // triggers one clone per unique account instead of cloning the same
207 // inventory every time it appears. Without deduping, the optimization
208 // would be silently undone by transactions that list the same
209 // account more than once.
210 let mut working_inventories: FxHashMap<rustledger_core::Account, Inventory> =
211 FxHashMap::with_capacity_and_hasher(txn.postings.len(), Default::default());
212 for posting in &txn.postings {
213 if let Some(inv) = self.inventories.get(&posting.account) {
214 working_inventories
215 .entry(posting.account.clone())
216 .or_insert_with(|| inv.clone());
217 }
218 }
219
220 // First pass: identify postings that need lot matching (reductions)
221 for (idx, posting) in txn.postings.iter().enumerate() {
222 // Check if this is a reduction with a cost spec
223 if let Some(IncompleteAmount::Complete(units)) = &posting.units
224 && let Some(cost_spec) = &posting.cost
225 {
226 // Normalize compound `{a # b}` up front (#1700): both the
227 // reduction path (which uses the spec as a lot-match filter
228 // via `per_unit()`) and apply() (which re-reduces from the
229 // BOOKED posting) need a resolved per-unit — the raw
230 // Compound form deliberately exposes neither component as
231 // an effective value. Combined total N*a + b is preserved
232 // exactly for residual math, same as the {{T}} conversion.
233 let normalized_compound: Option<CostSpec> = match cost_spec.number {
234 Some(rustledger_core::CostNumber::Compound { per_unit, total })
235 if !units.number.is_zero() =>
236 {
237 let combined = units.number.abs() * per_unit + total;
238 Some(CostSpec {
239 number: Some(rustledger_core::CostNumber::PerUnitFromTotal(
240 rustledger_core::BookedCost::new(
241 combined / units.number.abs(),
242 combined,
243 units.number,
244 ),
245 )),
246 currency: cost_spec.currency.clone(),
247 // Date deliberately NOT defaulted to txn.date
248 // here: on a REDUCTION the spec date is a lot
249 // match FILTER (injecting the sell date would
250 // match nothing); on an augmentation the lot's
251 // acquisition date defaults via resolve()'s
252 // date parameter as for plain {N} specs.
253 date: cost_spec.date,
254 label: cost_spec.label.clone(),
255 merge: cost_spec.merge,
256 })
257 }
258 _ => None,
259 };
260 let normalized_owned;
261 let cost_spec = if let Some(normalized) = normalized_compound {
262 result.postings[idx].cost = Some(normalized.clone());
263 normalized_owned = normalized;
264 &normalized_owned
265 } else {
266 cost_spec
267 };
268 // Check if this is a reduction (units have opposite sign of inventory)
269 // This handles both:
270 // - Selling long positions (negative units, positive inventory)
271 // - Closing short positions (positive units, negative inventory)
272 if let Some(inv) = working_inventories.get_mut(&posting.account) {
273 // Check if these units reduce existing cost-bearing inventory lots.
274 // Only positions with a cost basis are considered; simple (no-cost)
275 // positions are ignored to avoid misclassifying augmentations.
276 //
277 // Under `option "booking_method" "NONE"` (issue #1182),
278 // reduction matching is skipped entirely: NONE means
279 // "accumulate positions without booking against
280 // existing lots." Otherwise the booker would replace
281 // the user-written `{{ total }}` cost spec with a
282 // FIFO-matched per-unit (line ~282 below), and the
283 // residual calculation downstream would weigh the
284 // posting by the matched lots' costs instead of the
285 // user's stated total — producing a phantom
286 // E3001 imbalance for ledgers that round-trip
287 // cleanly through Python beancount.
288 let method = self.method_for(&posting.account);
289 let is_reduction = method != BookingMethod::None
290 && inv.is_reduced_by(units, ReductionScope::CostBearingOnly);
291
292 if is_reduction {
293 // Use reduce (not try_reduce) to actually update the working inventory.
294 // This ensures subsequent postings in the same transaction see
295 // the updated inventory state (e.g., after first posting exhausts a lot).
296 //
297 // Booking errors (ambiguous match, no matching lot, insufficient
298 // units) are propagated so callers see them once. The full
299 // pipeline path in `rustledger check` filters failed transactions
300 // out of the validator's input to avoid double-reporting against
301 // the validator's independent lot-matching pass.
302 // (`method` is resolved above next to the NONE-method gate.)
303 let booking_result = inv
304 .reduce(units, Some(cost_spec), method)
305 .map_err(|e| convert_core_booking_error(e, &posting.account))?;
306 {
307 // Check if multiple lots were matched
308 if booking_result.matched.len() > 1 {
309 // Expand single posting into multiple postings
310 let mut expanded = Vec::new();
311 for matched_pos in &booking_result.matched {
312 let mut new_posting = posting.clone();
313 // Set units to the matched portion with NEGATED sign
314 // (matched_pos.units has the inventory sign, but we need
315 // the reduction sign which is opposite)
316 let expanded_units = rustledger_core::Amount::new(
317 -matched_pos.units.number, // Negate: inventory→reduction
318 matched_pos.units.currency.clone(),
319 );
320 new_posting.units =
321 Some(IncompleteAmount::Complete(expanded_units));
322 // Set cost from the matched lot
323 if let Some(cost) = &matched_pos.cost {
324 new_posting.cost = Some(CostSpec {
325 number: Some(rustledger_core::CostNumber::PerUnit {
326 value: cost.number,
327 }),
328 currency: Some(cost.currency.clone()),
329 date: cost.date,
330 label: cost.label.clone(),
331 merge: false,
332 });
333 }
334 expanded.push(new_posting);
335 }
336 expansions.push((idx, expanded));
337 booked_indices.insert(idx);
338 } else if let Some(cost_basis) = &booking_result.cost_basis {
339 // Single lot match - update posting in place
340 let per_unit = cost_basis.number / units.number.abs();
341 // Use new_calculated since per_unit is computed from total/units
342 let matched_cost =
343 Cost::new_calculated(per_unit, cost_basis.currency.clone())
344 .with_date_opt(
345 booking_result
346 .matched
347 .first()
348 .and_then(|p| p.cost.as_ref())
349 .and_then(|c| c.date),
350 );
351
352 // Update posting with filled cost. Carry the
353 // matched lot's label (as the date already is) so
354 // the reduction shares lot identity with its
355 // augmenting lot and nets against it — otherwise a
356 // labeled reduction leaves a phantom unlabeled
357 // negative lot in the holdings view (#1666).
358 result.postings[idx].cost = Some(CostSpec {
359 number: Some(rustledger_core::CostNumber::PerUnit {
360 value: matched_cost.number,
361 }),
362 currency: Some(matched_cost.currency.clone()),
363 date: matched_cost.date,
364 label: booking_result
365 .matched
366 .first()
367 .and_then(|p| p.cost.as_ref())
368 .and_then(|c| c.label.clone()),
369 merge: false,
370 });
371 booked_indices.insert(idx);
372 }
373
374 // Calculate capital gain if there's a price
375 if let Some(cost_basis) = &booking_result.cost_basis
376 && let Some(price) = &posting.price
377 && let Some(amt) =
378 price.amount.as_ref().and_then(IncompleteAmount::as_amount)
379 {
380 let sale_price = match price.kind {
381 rustledger_core::PriceKind::Unit => {
382 amt.number * units.number.abs()
383 }
384 rustledger_core::PriceKind::Total => amt.number,
385 };
386
387 let gain_amount = sale_price - cost_basis.number;
388 if !gain_amount.is_zero() {
389 gains.push(CapitalGain {
390 account: posting.account.clone(),
391 currency: units.currency.clone(),
392 amount: Amount::new(gain_amount, &cost_basis.currency),
393 cost_basis: cost_basis.clone(),
394 proceeds: Amount::new(sale_price, &cost_basis.currency),
395 });
396 }
397 }
398 }
399 }
400 // If not a reduction: fall through to augmentation code below
401 }
402
403 if let Some(rustledger_core::CostNumber::Total { value: total }) = cost_spec.number
404 {
405 // Augmentation with total cost — convert to the
406 // post-booking `PerUnitFromTotal` shape:
407 // `1.763 VIIIX {{300.00 USD}}` → derived per-unit
408 // 170.165… with total 300.00 preserved.
409 // The preserved total is load-bearing for
410 // precision-preserving residual math (#1026) —
411 // division-then-multiplication at the
412 // `rust_decimal` 28-digit ceiling loses precision.
413 if let Some(currency) = &cost_spec.currency
414 && !units.number.is_zero()
415 {
416 let per_unit = total / units.number.abs();
417 result.postings[idx].cost = Some(CostSpec {
418 number: Some(rustledger_core::CostNumber::PerUnitFromTotal(
419 rustledger_core::BookedCost::new(per_unit, total, units.number),
420 )),
421 currency: Some(currency.clone()),
422 // Fill in transaction date if no date specified
423 date: cost_spec.date.or(Some(txn.date)),
424 label: cost_spec.label.clone(),
425 merge: cost_spec.merge,
426 });
427 booked_indices.insert(idx);
428 }
429 }
430
431 // Fill in dates and currencies for augmentations (not already booked)
432 if !booked_indices.contains(&idx) && cost_spec.number.is_some() {
433 // Cost spec has a number but may be missing date or currency
434 // Fill in missing parts from price annotation, other postings, and transaction date
435 let inferred_currency = cost_spec.currency.clone().or_else(|| {
436 // First try price annotation on this posting.
437 // `kind` (Unit vs Total) doesn't change the currency,
438 // so it's irrelevant here — we just want whatever
439 // currency the price names, complete or incomplete.
440 posting
441 .price
442 .as_ref()
443 .and_then(|p| p.amount.as_ref())
444 .and_then(|inc| inc.currency().map(Into::into))
445 // Then try inferring from other postings in the transaction
446 .or_else(|| crate::infer_cost_currency_from_postings(txn))
447 });
448
449 // Check if this is a reduction (opposite sign exists in inventory)
450 // Reductions get their date from matched lot, augmentations get txn date
451 let is_reduction = self.inventories.get(&posting.account).is_some_and(|inv| {
452 inv.is_reduced_by(units, ReductionScope::CostBearingOnly)
453 });
454
455 // Fill in date for augmentations only (not reductions)
456 let inferred_date = if is_reduction {
457 None // Reductions get their date from matched lot
458 } else {
459 cost_spec.date.or(Some(txn.date))
460 };
461
462 // Only update if we actually inferred something
463 if inferred_currency.is_some() || inferred_date.is_some() {
464 result.postings[idx].cost = Some(CostSpec {
465 number: cost_spec.number,
466 currency: inferred_currency.or_else(|| cost_spec.currency.clone()),
467 date: inferred_date.or(cost_spec.date),
468 label: cost_spec.label.clone(),
469 merge: cost_spec.merge,
470 });
471 }
472 }
473 }
474 }
475
476 // Apply posting expansions (replace single postings with multiple)
477 // Build new postings Vec in one O(n) pass instead of O(n²) remove+insert
478 if !expansions.is_empty() {
479 // Sort expansions by index for forward iteration
480 expansions.sort_by_key(|(idx, _)| *idx);
481
482 let mut new_postings = Vec::with_capacity(
483 result.postings.len() + expansions.iter().map(|(_, e)| e.len()).sum::<usize>(),
484 );
485 let mut expansion_iter = expansions.into_iter().peekable();
486
487 for (idx, posting) in result.postings.into_iter().enumerate() {
488 if expansion_iter
489 .peek()
490 .is_some_and(|(exp_idx, _)| *exp_idx == idx)
491 {
492 // Replace this posting with expanded postings
493 let (_, expanded) = expansion_iter.next().unwrap();
494 new_postings.extend(expanded);
495 } else {
496 // Keep original posting
497 new_postings.push(posting);
498 }
499 }
500 result.postings = new_postings;
501 }
502
503 // NOTE: Price normalization (@@→@) is NOT done here to preserve exact
504 // total prices for precise residual calculation. Call `normalize_prices()`
505 // on the transaction after validation to convert total prices to per-unit.
506
507 Ok(BookedTransaction {
508 transaction: result,
509 gains,
510 booked_indices: booked_indices.into_iter().collect(),
511 })
512 }
513
514 /// Apply a transaction's postings to the running inventories (update
515 /// balances).
516 ///
517 /// # Precondition
518 ///
519 /// The transaction MUST already be booked — postings filled with complete
520 /// units and resolved costs, as produced by [`Self::book_and_interpolate`]
521 /// or the free [`book`](crate::book) function. Applying an *unbooked*
522 /// transaction can silently over-sell an inventory: a reduction with no
523 /// matching lot yet is dropped (its `reduce` error is otherwise ignored).
524 /// The loader pipeline guarantees this ordering; the in-loop `debug_assert`
525 /// below catches a violating caller in debug builds.
526 pub fn apply(&mut self, txn: &Transaction) {
527 for posting in &txn.postings {
528 if let Some(IncompleteAmount::Complete(units)) = &posting.units {
529 // Resolve the per-account booking method before mutably
530 // borrowing the inventories map.
531 let method = self.method_for(&posting.account);
532 let inv = self.inventories.entry(posting.account.clone()).or_default();
533
534 // Reduction vs augmentation — the single source for this decision
535 // (`Inventory::is_booking_reduction`), shared with the Late
536 // validator so the two can't drift (including the #1182 NONE gate
537 // that previously had to be maintained in both crates).
538 let is_reduction = inv.is_booking_reduction(units, posting.cost.as_ref(), method);
539
540 if is_reduction {
541 // Reduce from inventory. `reduce` only errors when the lot
542 // it would match is missing — a "must book first" precondition
543 // violation (see the fn-level doc). In release builds the
544 // historical behavior (ignore) is kept; in debug builds we
545 // surface the unbooked-apply bug instead of silently
546 // over-selling.
547 let reduced = inv.reduce(units, posting.cost.as_ref(), method);
548 debug_assert!(
549 reduced.is_ok(),
550 "apply() reduction failed — the transaction must be booked \
551 before apply() (postings filled, costs resolved); applying \
552 an unbooked reduction silently over-sells inventory"
553 );
554 // `reduced` is consumed only by the debug assertion above;
555 // release builds keep the historical ignore-the-Result behavior.
556 let _ = reduced;
557 } else {
558 // Add to inventory via the canonical cost-resolve shared with
559 // the Late validator, `build_balances`, and the query engine.
560 // Its per-unit / date / label handling matches the block this
561 // replaced (see `CostSpec::resolve`). The old inline price /
562 // cross-posting cost-currency inference is unnecessary here:
563 // `apply` is contracted to run on *booked* transactions (the
564 // `debug_assert` above; the production pipeline always
565 // `book_and_interpolate`s first), and booking fills the
566 // inferred currency into `cost_spec.currency`. Tests that call
567 // `apply` directly use explicit-currency fixtures, which need
568 // no inference.
569 inv.add(Position::from_posting(
570 units,
571 posting.cost.as_ref(),
572 txn.date,
573 ));
574 }
575 }
576 }
577 }
578
579 /// Book and interpolate a transaction.
580 ///
581 /// This fills in empty cost specs, then interpolates any missing amounts.
582 pub fn book_and_interpolate(
583 &self,
584 txn: &Transaction,
585 ) -> Result<InterpolationResult, BookingError> {
586 // Fast path: with no cost specs, `book` is an identity that only clones
587 // `txn` verbatim (profiling flagged that clone as ~6 MB / 10k txns — the
588 // common case). This method consumes only `booked.transaction` — the
589 // `gains` / `booked_indices` are unused here — and in the fast path that
590 // transaction *equals* `txn`, so `interpolate(&book(txn).transaction)`
591 // is provably identical to `interpolate(txn)`. Interpolate the original
592 // directly and skip the clone.
593 if !txn.postings.iter().any(|p| p.cost.is_some()) {
594 return Ok(interpolate(txn)?);
595 }
596
597 // First book (fill in costs)
598 let booked = self.book(txn)?;
599
600 // Then interpolate (fill in missing amounts)
601 let result = interpolate(&booked.transaction)?;
602
603 Ok(result)
604 }
605}
606
607/// Convert a core inventory `BookingError` into the booking-layer error,
608/// attaching the account context that the core layer doesn't carry.
609///
610/// All inventory-level failures funnel into a single
611/// [`BookingError::Inventory`] variant. The user-facing wording lives in the
612/// `Display` impl on [`AccountedBookingError`] so it cannot drift between
613/// the booking layer and the validator (#748 / #750).
614fn convert_core_booking_error(
615 err: rustledger_core::BookingError,
616 account: &rustledger_core::Account,
617) -> BookingError {
618 BookingError::Inventory(err.with_account(account.clone()))
619}
620
621/// Book and interpolate a list of transactions.
622///
623/// This processes transactions in order, tracking inventory to enable
624/// proper lot matching and capital gains calculation.
625pub fn book_transactions(
626 transactions: &[Transaction],
627 method: BookingMethod,
628) -> Vec<Result<InterpolationResult, BookingError>> {
629 let mut engine = BookingEngine::with_method(method);
630 let mut results = Vec::with_capacity(transactions.len());
631
632 for txn in transactions {
633 let result = engine.book_and_interpolate(txn);
634 if let Ok(ref interpolated) = result {
635 // Apply the booked transaction (with filled-in costs), not the original
636 engine.apply(&interpolated.transaction);
637 }
638 results.push(result);
639 }
640
641 results
642}
643
644/// Outcome of booking an entire ledger in one shot — see [`book`].
645#[derive(Debug, Clone)]
646pub struct LedgerBookResult {
647 /// Successfully booked directives, in the **original input order**.
648 /// Every `Transaction` has its cost specs filled and elided amounts
649 /// interpolated; all other directive kinds pass through unchanged.
650 pub booked: Vec<Directive>,
651 /// Directives whose `Transaction` failed to book, in original input
652 /// order, paired with the error. They are left in their pre-booking
653 /// shape so a caller can still surface the user's original input.
654 pub failed: Vec<(Directive, BookingError)>,
655}
656
657/// Book and interpolate every transaction in a ledger in one shot.
658///
659/// This is the standalone equivalent of the loader's internal booking
660/// pass. Transactions are processed in **booking order** — sorted by
661/// `(date, priority, has_cost_reduction)` — so lot matching and
662/// capital-gains tracking observe inventory in the correct sequence, while
663/// the returned [`LedgerBookResult::booked`] / [`LedgerBookResult::failed`]
664/// vectors preserve the caller's **original input order**. Non-transaction
665/// directives pass through untouched. Per-account booking methods declared
666/// via `Open ... "METHOD"` are honored; `method` is the fallback for
667/// accounts that declare none.
668///
669/// Booking is a pure function of its inputs, so calling it twice on the
670/// same `(directives, method)` yields equal results — this is the booking
671/// half of the #1235 pipeline-boundary invariants.
672#[must_use]
673pub fn book(directives: &[Directive], method: BookingMethod) -> LedgerBookResult {
674 let mut engine = BookingEngine::with_method(method);
675 engine.register_account_methods(directives.iter());
676
677 // Stable sort into booking order. Display order — `(date, priority,
678 // file position)` — is already encoded in the input's positional order,
679 // and a stable sort keeps that as the tiebreak.
680 let mut order: Vec<usize> = (0..directives.len()).collect();
681 order.sort_by_key(|&i| rustledger_core::booking_sort_key(&directives[i]));
682
683 // Book in booking order, recording each transaction's outcome against
684 // its original index so the result can be reassembled in input order.
685 let mut booked_txns: Vec<Option<Transaction>> = directives.iter().map(|_| None).collect();
686 let mut booking_errors: Vec<Option<BookingError>> = directives.iter().map(|_| None).collect();
687 for &i in &order {
688 if let Directive::Transaction(txn) = &directives[i] {
689 match engine.book_and_interpolate(txn) {
690 Ok(result) => {
691 // Apply the booked transaction (filled-in costs), not
692 // the original, so subsequent lot matching is correct.
693 engine.apply(&result.transaction);
694 booked_txns[i] = Some(result.transaction);
695 }
696 Err(e) => booking_errors[i] = Some(e),
697 }
698 }
699 }
700
701 // Reassemble in original input order, partitioning failures out.
702 let mut booked = Vec::with_capacity(directives.len());
703 let mut failed = Vec::new();
704 for (i, directive) in directives.iter().enumerate() {
705 if let Some(e) = booking_errors[i].take() {
706 failed.push((directive.clone(), e));
707 } else if let Some(txn) = booked_txns[i].take() {
708 booked.push(Directive::Transaction(txn));
709 } else {
710 booked.push(directive.clone());
711 }
712 }
713
714 LedgerBookResult { booked, failed }
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720 use rust_decimal_macros::dec;
721 use rustledger_core::{NaiveDate, Posting, PriceAnnotation};
722
723 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
724 rustledger_core::naive_date(year, month, day).unwrap()
725 }
726
727 #[test]
728 fn test_book_simple_buy() {
729 let mut engine = BookingEngine::new();
730
731 // Buy 10 AAPL at $150
732 let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
733 .with_synthesized_posting(
734 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
735 CostSpec::empty()
736 .with_number(rustledger_core::CostNumber::PerUnit {
737 value: dec!(150.00),
738 })
739 .with_currency("USD"),
740 ),
741 )
742 .with_synthesized_posting(Posting::new(
743 "Assets:Cash",
744 Amount::new(dec!(-1500.00), "USD"),
745 ));
746
747 engine.apply(&buy);
748
749 // Check inventory
750 let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
751 assert_eq!(inv.units("AAPL"), dec!(10));
752 }
753
754 #[test]
755 fn test_reduction_carries_matched_lot_label() {
756 // #1666: reducing a labeled lot must carry that lot's label onto the
757 // reduction posting so it nets against the augmenting lot, instead of
758 // leaving a phantom unlabeled negative lot in the holdings view.
759 let mut engine = BookingEngine::new();
760
761 let buy = |label: &str| {
762 let mut cost = CostSpec::empty()
763 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(10) })
764 .with_currency("USD");
765 cost.label = Some(label.to_string());
766 Transaction::new(date(2020, 2, 1), "buy")
767 .with_synthesized_posting(
768 Posting::new("Assets:S", Amount::new(dec!(10), "X")).with_cost(cost),
769 )
770 .with_synthesized_posting(Posting::new(
771 "Assets:Cash",
772 Amount::new(dec!(-100), "USD"),
773 ))
774 };
775 engine.apply(&buy("lot-a"));
776 engine.apply(&buy("lot-b"));
777
778 // Sell 5 X explicitly from lot-b.
779 let mut sell_cost = CostSpec::empty()
780 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(10) })
781 .with_currency("USD");
782 sell_cost.label = Some("lot-b".to_string());
783 let sell = Transaction::new(date(2020, 4, 1), "sell from lot-b")
784 .with_synthesized_posting(
785 Posting::new("Assets:S", Amount::new(dec!(-5), "X")).with_cost(sell_cost),
786 )
787 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(50), "USD")));
788
789 let result = engine
790 .book_and_interpolate(&sell)
791 .expect("sell should book against lot-b");
792 let label = result.transaction.postings[0]
793 .cost
794 .as_ref()
795 .and_then(|c| c.label.clone());
796 assert_eq!(
797 label.as_deref(),
798 Some("lot-b"),
799 "reduction posting must carry the matched lot's label (#1666)"
800 );
801 }
802
803 #[test]
804 fn test_book_sell_with_gain() {
805 let mut engine = BookingEngine::new();
806
807 // Buy 10 AAPL at $150
808 let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
809 .with_synthesized_posting(
810 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
811 CostSpec::empty()
812 .with_number(rustledger_core::CostNumber::PerUnit {
813 value: dec!(150.00),
814 })
815 .with_currency("USD"),
816 ),
817 )
818 .with_synthesized_posting(Posting::new(
819 "Assets:Cash",
820 Amount::new(dec!(-1500.00), "USD"),
821 ));
822
823 engine.apply(&buy);
824
825 // Sell 5 AAPL at $175 with empty cost (needs lot matching)
826 let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
827 .with_synthesized_posting(
828 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
829 .with_cost(CostSpec::empty()) // Empty - needs lot matching
830 .with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
831 )
832 .with_synthesized_posting(Posting::new(
833 "Assets:Cash",
834 Amount::new(dec!(875.00), "USD"),
835 ))
836 .with_synthesized_posting(Posting::auto("Income:CapitalGains")); // Elided
837
838 // Check inventory before sell
839 let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
840 eprintln!("Inventory before sell: {inv:?}");
841
842 let booked = engine.book(&sell).unwrap();
843 eprintln!(
844 "Booked: gains={:?}, indices={:?}",
845 booked.gains, booked.booked_indices
846 );
847 eprintln!("Booked transaction: {:?}", booked.transaction);
848
849 // Check that gain was calculated
850 assert_eq!(
851 booked.gains.len(),
852 1,
853 "Expected 1 gain, got {:?}",
854 booked.gains
855 );
856 let gain = &booked.gains[0];
857 // Gain = 5 * (175 - 150) = 125
858 assert_eq!(gain.amount.number, dec!(125));
859 }
860
861 #[test]
862 fn test_book_with_total_cost() {
863 let mut engine = BookingEngine::new();
864
865 // Buy 1.763 VIIIX with total cost of 300 USD (like healthequity file)
866 let buy = Transaction::new(date(2016, 1, 16), "Buy stock")
867 .with_synthesized_posting(
868 Posting::new("Assets:Stock", Amount::new(dec!(1.763), "VIIIX")).with_cost(
869 CostSpec::empty()
870 .with_number(rustledger_core::CostNumber::Total {
871 value: dec!(300.00),
872 })
873 .with_currency("USD"),
874 ),
875 )
876 .with_synthesized_posting(Posting::new(
877 "Assets:Cash",
878 Amount::new(dec!(-300.00), "USD"),
879 ));
880
881 engine.apply(&buy);
882
883 // Check inventory
884 let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
885 eprintln!("Inventory after total cost buy: {inv:?}");
886 assert_eq!(inv.units("VIIIX"), dec!(1.763));
887
888 // Check cost was calculated correctly (300/1.763 ≈ 170.16)
889 let pos = inv.positions().next().unwrap();
890 assert!(pos.cost.is_some(), "Expected cost on position");
891 eprintln!("Position cost: {:?}", pos.cost);
892 }
893
894 #[test]
895 fn test_book_total_cost_then_sell() {
896 // Test that book() correctly handles total cost syntax and preserves
897 // full precision for accurate capital gains calculation.
898 let mut engine = BookingEngine::new();
899
900 // Buy 1.763 VIIIX with total cost {{300.00 USD}}
901 let buy = Transaction::new(date(2016, 1, 16), "Buy stock")
902 .with_synthesized_posting(
903 Posting::new("Assets:Stock", Amount::new(dec!(1.763), "VIIIX")).with_cost(
904 CostSpec::empty()
905 .with_number(rustledger_core::CostNumber::Total {
906 value: dec!(300.00),
907 })
908 .with_currency("USD"),
909 ),
910 )
911 .with_synthesized_posting(Posting::new(
912 "Assets:Cash",
913 Amount::new(dec!(-300.00), "USD"),
914 ));
915
916 // Use book() to test the booking path with total cost
917 let booked_buy = engine.book(&buy).unwrap();
918 engine.apply(&booked_buy.transaction);
919
920 // Check that per-unit cost was calculated (300/1.763)
921 let buy_posting = &booked_buy.transaction.postings[0];
922 assert!(buy_posting.cost.is_some());
923 let cost_spec = buy_posting.cost.as_ref().unwrap();
924 // Booking should have converted the user-written Total into
925 // the post-booking PerUnitFromTotal shape — the per-unit value
926 // is computed for lot tracking and the total is preserved for
927 // exact residual math.
928 assert!(matches!(
929 cost_spec.number,
930 Some(rustledger_core::CostNumber::PerUnitFromTotal(_))
931 ));
932
933 // Sell all shares at $191 per unit
934 let sell = Transaction::new(date(2016, 6, 15), "Sell stock")
935 .with_synthesized_posting(
936 Posting::new("Assets:Stock", Amount::new(dec!(-1.763), "VIIIX"))
937 .with_cost(CostSpec::empty())
938 .with_price(PriceAnnotation::unit(Amount::new(dec!(191.00), "USD"))),
939 )
940 .with_synthesized_posting(Posting::new(
941 "Assets:Cash",
942 Amount::new(dec!(336.73), "USD"), // 1.763 * 191 = 336.733
943 ))
944 .with_synthesized_posting(Posting::auto("Income:CapitalGains"));
945
946 let booked_sell = engine.book(&sell).unwrap();
947
948 // Capital gain should be: 336.73 - 300.00 = 36.73
949 // With full precision preserved, this should be accurate
950 assert_eq!(booked_sell.gains.len(), 1);
951 let gain = &booked_sell.gains[0];
952 // The gain should be close to 36.73 (sale proceeds - cost basis)
953 // Sale: 1.763 * 191 = 336.733, Cost: 300.00, Gain ≈ 36.73
954 eprintln!("Capital gain: {:?}", gain.amount);
955 }
956
957 #[test]
958 fn test_cost_spec_currency_inference() {
959 let mut engine = BookingEngine::new();
960
961 // SELLOPT: -1 AAPL {40.0} @ 0.4 USD — the cost has a number (40.0) but no
962 // cost currency. Booking infers it from the price annotation and fills it
963 // *into* the cost spec, so by the time `apply` runs the currency is already
964 // resolved. The production pipeline books before applying, so this drives
965 // that real `book_and_interpolate` → `apply` path rather than calling
966 // `apply` standalone.
967 let sell = Transaction::new(date(2022, 6, 17), "SELLOPT")
968 .with_synthesized_posting(
969 Posting::new("Assets:Stock", Amount::new(dec!(-1), "AAPL"))
970 .with_cost(
971 CostSpec::empty().with_number(rustledger_core::CostNumber::PerUnit {
972 value: dec!(40.0),
973 }),
974 )
975 .with_price(PriceAnnotation::unit(Amount::new(dec!(0.4), "USD"))),
976 )
977 .with_synthesized_posting(Posting::new("Assets:Stock", Amount::new(dec!(40.0), "USD")));
978
979 let booked = engine
980 .book_and_interpolate(&sell)
981 .expect("booking should succeed");
982 engine.apply(&booked.transaction);
983
984 let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
985
986 // The AAPL position carries cost with the price-inferred USD currency.
987 let aapl_pos = inv
988 .positions()
989 .find(|p| p.units.currency.as_ref() == "AAPL")
990 .expect("Should have AAPL position");
991
992 assert!(aapl_pos.cost.is_some(), "AAPL position should have cost");
993 let cost = aapl_pos.cost.as_ref().unwrap();
994 assert_eq!(cost.currency.as_ref(), "USD", "Cost currency should be USD");
995 assert_eq!(cost.number, dec!(40.0), "Cost number should be 40.0");
996 }
997
998 #[test]
999 fn test_booking_engine_with_method() {
1000 // Test that with_method creates engine with specified booking method
1001 let engine = BookingEngine::with_method(BookingMethod::Lifo);
1002 assert!(engine.inventories.is_empty());
1003
1004 // Also test default is FIFO
1005 let default_engine = BookingEngine::new();
1006 assert!(default_engine.inventories.is_empty());
1007 }
1008
1009 #[test]
1010 fn test_book_sell_with_total_price() {
1011 let mut engine = BookingEngine::new();
1012
1013 // Buy 10 AAPL at $150
1014 let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
1015 .with_synthesized_posting(
1016 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1017 CostSpec::empty()
1018 .with_number(rustledger_core::CostNumber::PerUnit {
1019 value: dec!(150.00),
1020 })
1021 .with_currency("USD"),
1022 ),
1023 )
1024 .with_synthesized_posting(Posting::new(
1025 "Assets:Cash",
1026 Amount::new(dec!(-1500.00), "USD"),
1027 ));
1028
1029 engine.apply(&buy);
1030
1031 // Sell 5 AAPL with total price annotation (not per-unit)
1032 // Total price = $875 for 5 shares = $175/share
1033 let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
1034 .with_synthesized_posting(
1035 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1036 .with_cost(CostSpec::empty())
1037 .with_price(PriceAnnotation::total(Amount::new(dec!(875.00), "USD"))),
1038 )
1039 .with_synthesized_posting(Posting::new(
1040 "Assets:Cash",
1041 Amount::new(dec!(875.00), "USD"),
1042 ))
1043 .with_synthesized_posting(Posting::auto("Income:CapitalGains"));
1044
1045 let booked = engine.book(&sell).unwrap();
1046
1047 // Check that gain was calculated correctly
1048 // Gain = 875 - (5 * 150) = 875 - 750 = 125
1049 assert_eq!(booked.gains.len(), 1, "Expected 1 gain");
1050 let gain = &booked.gains[0];
1051 assert_eq!(gain.amount.number, dec!(125));
1052 }
1053
1054 #[test]
1055 fn test_book_transactions_multiple() {
1056 // Buy 10 AAPL at $150
1057 let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
1058 .with_synthesized_posting(
1059 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1060 CostSpec::empty()
1061 .with_number(rustledger_core::CostNumber::PerUnit {
1062 value: dec!(150.00),
1063 })
1064 .with_currency("USD"),
1065 ),
1066 )
1067 .with_synthesized_posting(Posting::new(
1068 "Assets:Cash",
1069 Amount::new(dec!(-1500.00), "USD"),
1070 ));
1071
1072 // Sell 5 AAPL
1073 let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
1074 .with_synthesized_posting(
1075 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1076 .with_cost(CostSpec::empty())
1077 .with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
1078 )
1079 .with_synthesized_posting(Posting::new(
1080 "Assets:Cash",
1081 Amount::new(dec!(875.00), "USD"),
1082 ))
1083 .with_synthesized_posting(Posting::auto("Income:CapitalGains"));
1084
1085 let transactions = vec![buy, sell];
1086 let results = book_transactions(&transactions, BookingMethod::Fifo);
1087
1088 assert_eq!(results.len(), 2);
1089 assert!(results[0].is_ok());
1090 assert!(results[1].is_ok());
1091 }
1092
1093 /// Issue #1705: an augmenting `{}` lot gets its cost inferred from the
1094 /// residual, and a later `{}` reduction of that lot then books cleanly
1095 /// (previously the reduction hit a spurious "2 unknowns" error because
1096 /// the augmenting lot carried no cost basis to match).
1097 #[test]
1098 fn test_augmenting_empty_cost_then_reduce() {
1099 // buy: 1000 USD {} against -900 EUR → lot booked at 0.90 EUR/unit
1100 let buy = Transaction::new(date(2024, 1, 2), "buy USD")
1101 .with_synthesized_posting(
1102 Posting::new("Assets:Broker", Amount::new(dec!(1000), "USD"))
1103 .with_cost(CostSpec::empty()),
1104 )
1105 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-900), "EUR")));
1106
1107 // sell: -100 USD {} + 95 EUR + PnL residual → reduce at 0.90, PnL = -5 EUR
1108 let sell = Transaction::new(date(2024, 1, 5), "sell part")
1109 .with_synthesized_posting(
1110 Posting::new("Assets:Broker", Amount::new(dec!(-100.00), "USD"))
1111 .with_cost(CostSpec::empty()),
1112 )
1113 .with_synthesized_posting(Posting::new("Expenses:Misc", Amount::new(dec!(95), "EUR")))
1114 .with_synthesized_posting(Posting::auto("Income:Trading:PnL"));
1115
1116 let results = book_transactions(&[buy, sell], BookingMethod::Fifo);
1117 assert_eq!(results.len(), 2);
1118
1119 let buy_txn = &results[0].as_ref().expect("buy should book").transaction;
1120 let buy_cost = buy_txn.postings[0].cost.as_ref().expect("cost present");
1121 assert_eq!(
1122 buy_cost
1123 .number
1124 .as_ref()
1125 .and_then(rustledger_core::CostNumber::per_unit),
1126 Some(dec!(0.90))
1127 );
1128 assert_eq!(buy_cost.currency.as_deref(), Some("EUR"));
1129
1130 // The `{}` reduction booked cleanly and the PnL residual solved to -5 EUR.
1131 let sell_txn = &results[1].as_ref().expect("sell should book").transaction;
1132 let pnl = sell_txn
1133 .postings
1134 .iter()
1135 .find(|p| p.account.as_str() == "Income:Trading:PnL")
1136 .expect("PnL posting present");
1137 let pnl_amount = pnl
1138 .units
1139 .as_ref()
1140 .and_then(|u| u.as_amount())
1141 .expect("PnL filled");
1142 assert_eq!(pnl_amount.number, dec!(-5));
1143 assert_eq!(pnl_amount.currency.as_str(), "EUR");
1144 }
1145
1146 #[test]
1147 fn test_book_augmentation_not_reduction() {
1148 let mut engine = BookingEngine::new();
1149
1150 // First, add existing inventory with positive AAPL
1151 let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
1152 .with_synthesized_posting(
1153 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1154 CostSpec::empty()
1155 .with_number(rustledger_core::CostNumber::PerUnit {
1156 value: dec!(150.00),
1157 })
1158 .with_currency("USD"),
1159 ),
1160 )
1161 .with_synthesized_posting(Posting::new(
1162 "Assets:Cash",
1163 Amount::new(dec!(-1500.00), "USD"),
1164 ));
1165
1166 engine.apply(&buy);
1167
1168 // Now try to book another buy (augmentation, not reduction)
1169 // This has empty cost but same sign as inventory, so it's not a reduction
1170 let another_buy = Transaction::new(date(2024, 2, 15), "Buy more")
1171 .with_synthesized_posting(
1172 Posting::new("Assets:Stock", Amount::new(dec!(5), "AAPL"))
1173 .with_cost(CostSpec::empty()), // Empty cost but augmentation
1174 )
1175 .with_synthesized_posting(Posting::new(
1176 "Assets:Cash",
1177 Amount::new(dec!(-750.00), "USD"),
1178 ));
1179
1180 // History: originally pinned "should not error - just skip lot
1181 // matching" (booked the lot UNCOSTED — silent corruption); the
1182 // #1708 interim guard made it a loud error; the #1705 solver now
1183 // books it at the residual-inferred cost like beancount. The
1184 // engine-level book() leaves the {} spec for interpolation, so
1185 // this remains not-an-error with no booked indices.
1186 let booked = engine.book(&another_buy).unwrap();
1187 assert!(
1188 booked.booked_indices.is_empty(),
1189 "augmentation books via interpolation, not lot matching"
1190 );
1191 }
1192
1193 #[test]
1194 fn test_book_no_inventory_for_account() {
1195 let engine = BookingEngine::new();
1196
1197 // Try to book a sell without any prior inventory
1198 let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
1199 .with_synthesized_posting(
1200 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1201 .with_cost(CostSpec::empty()),
1202 )
1203 .with_synthesized_posting(Posting::new(
1204 "Assets:Cash",
1205 Amount::new(dec!(875.00), "USD"),
1206 ));
1207
1208 // Should succeed but with no booked indices (no inventory to match against)
1209 let booked = engine.book(&sell).unwrap();
1210 assert!(
1211 booked.booked_indices.is_empty(),
1212 "No inventory means no lot matching"
1213 );
1214 }
1215
1216 #[test]
1217 fn test_book_zero_gain() {
1218 let mut engine = BookingEngine::new();
1219
1220 // Buy 10 AAPL at $150
1221 let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
1222 .with_synthesized_posting(
1223 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1224 CostSpec::empty()
1225 .with_number(rustledger_core::CostNumber::PerUnit {
1226 value: dec!(150.00),
1227 })
1228 .with_currency("USD"),
1229 ),
1230 )
1231 .with_synthesized_posting(Posting::new(
1232 "Assets:Cash",
1233 Amount::new(dec!(-1500.00), "USD"),
1234 ));
1235
1236 engine.apply(&buy);
1237
1238 // Sell at same price - zero gain
1239 let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
1240 .with_synthesized_posting(
1241 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1242 .with_cost(CostSpec::empty())
1243 .with_price(PriceAnnotation::unit(Amount::new(dec!(150.00), "USD"))),
1244 )
1245 .with_synthesized_posting(Posting::new(
1246 "Assets:Cash",
1247 Amount::new(dec!(750.00), "USD"),
1248 ));
1249
1250 let booked = engine.book(&sell).unwrap();
1251
1252 // Zero gain should not be added to gains vector
1253 assert!(booked.gains.is_empty(), "Zero gain should not be recorded");
1254 }
1255
1256 /// Test cost currency inference from other postings (issue #230).
1257 ///
1258 /// When a cost is specified without a currency (e.g., `{1}`), the currency
1259 /// should be inferred from simple postings in the same transaction.
1260 #[test]
1261 fn test_cost_currency_inference_from_other_postings() {
1262 let mut engine = BookingEngine::new();
1263
1264 // Opening balance with cost without currency - should infer USD from other posting
1265 // 2026-01-01 * "Opening balance"
1266 // Assets:Abc 1 ABC {1} <- no currency, should infer USD
1267 // Equity:Opening-Balances -1 USD
1268 let open = Transaction::new(date(2026, 1, 1), "Opening balance")
1269 .with_synthesized_posting(
1270 Posting::new("Assets:Abc", Amount::new(dec!(1), "ABC")).with_cost(
1271 CostSpec::empty()
1272 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1) }),
1273 ), // No currency!
1274 )
1275 .with_synthesized_posting(Posting::new(
1276 "Equity:Opening-Balances",
1277 Amount::new(dec!(-1), "USD"),
1278 ));
1279
1280 // Book and apply the opening
1281 let booked = engine.book(&open).unwrap();
1282 engine.apply(&booked.transaction);
1283
1284 // Check that the cost spec was filled in with USD
1285 let cost_spec = booked.transaction.postings[0].cost.as_ref().unwrap();
1286 assert_eq!(
1287 cost_spec.currency.as_deref(),
1288 Some("USD"),
1289 "Cost currency should be inferred as USD from other posting"
1290 );
1291
1292 // Check inventory has the position with correct cost
1293 let inv = engine.inventory(&"Assets:Abc".into()).unwrap();
1294 let pos = inv.positions().next().unwrap();
1295 assert!(pos.cost.is_some(), "Position should have cost");
1296 let cost = pos.cost.as_ref().unwrap();
1297 assert_eq!(cost.currency.as_ref(), "USD", "Cost currency should be USD");
1298 assert_eq!(cost.number, dec!(1), "Cost number should be 1");
1299
1300 // Now sell with explicit cost currency - should match the lot
1301 // 2026-01-02 * "Sale"
1302 // Assets:Abc -1 ABC {1 USD}
1303 // Expenses:Abc
1304 let sell = Transaction::new(date(2026, 1, 2), "Sale")
1305 .with_synthesized_posting(
1306 Posting::new("Assets:Abc", Amount::new(dec!(-1), "ABC")).with_cost(
1307 CostSpec::empty()
1308 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1) })
1309 .with_currency("USD"),
1310 ),
1311 )
1312 .with_synthesized_posting(Posting::auto("Expenses:Abc"));
1313
1314 // This should succeed - the lot with {1 USD} should be found
1315 let booked_sell = engine.book(&sell).unwrap();
1316
1317 // Check that the lot was matched
1318 assert!(
1319 !booked_sell.booked_indices.is_empty(),
1320 "Sale should match the lot created in opening"
1321 );
1322 }
1323
1324 #[test]
1325 fn test_multi_posting_crosses_lot_boundary() {
1326 // Regression test: Multiple postings in the same transaction reducing
1327 // the same commodity should correctly track inventory state across postings.
1328 // Previously, each posting would see the original inventory instead of
1329 // the updated state after processing previous postings.
1330
1331 let mut engine = BookingEngine::new();
1332
1333 // Create two lots of ADA with different costs
1334 // Lot 1: 100 ADA at $0.50 (2021-01-01)
1335 let buy1 = Transaction::new(date(2021, 1, 1), "Buy lot 1")
1336 .with_synthesized_posting(
1337 Posting::new("Assets:Crypto", Amount::new(dec!(100), "ADA")).with_cost(
1338 CostSpec::empty()
1339 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0.50) })
1340 .with_currency("USD")
1341 .with_date(date(2021, 1, 1)),
1342 ),
1343 )
1344 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD")));
1345 engine.apply(&buy1);
1346
1347 // Lot 2: 100 ADA at $0.52 (2022-05-19)
1348 let buy2 = Transaction::new(date(2022, 5, 19), "Buy lot 2")
1349 .with_synthesized_posting(
1350 Posting::new("Assets:Crypto", Amount::new(dec!(100), "ADA")).with_cost(
1351 CostSpec::empty()
1352 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0.52) })
1353 .with_currency("USD")
1354 .with_date(date(2022, 5, 19)),
1355 ),
1356 )
1357 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-52), "USD")));
1358 engine.apply(&buy2);
1359
1360 // Verify initial inventory: 200 ADA total
1361 let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
1362 assert_eq!(inv.units("ADA"), dec!(200));
1363
1364 // Consume half of lot 1 first
1365 let sell1 = Transaction::new(date(2022, 5, 20), "Sell 50 ADA")
1366 .with_synthesized_posting(
1367 Posting::new("Assets:Crypto", Amount::new(dec!(-50), "ADA"))
1368 .with_cost(CostSpec::empty()),
1369 )
1370 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(25), "USD")));
1371 let booked1 = engine.book(&sell1).unwrap();
1372 engine.apply(&booked1.transaction);
1373
1374 // Verify: 150 ADA remaining (50 in lot 1, 100 in lot 2)
1375 let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
1376 assert_eq!(inv.units("ADA"), dec!(150));
1377
1378 // Now the critical test: TWO postings in the same transaction
1379 // that together cross the lot boundary.
1380 // - Posting 1: -75 ADA {} → takes 50 from lot 1 + 25 from lot 2
1381 // - Posting 2: -5 ADA {} → should take from lot 2 (continuing)
1382 let sell2 = Transaction::new(date(2022, 5, 22), "Sell 80 ADA (multi-posting)")
1383 .with_synthesized_posting(
1384 Posting::new("Assets:Crypto", Amount::new(dec!(-75), "ADA"))
1385 .with_cost(CostSpec::empty()),
1386 )
1387 .with_synthesized_posting(
1388 Posting::new("Assets:Crypto", Amount::new(dec!(-5), "ADA"))
1389 .with_cost(CostSpec::empty()),
1390 )
1391 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(42), "USD")));
1392
1393 // This should succeed - the bug was that the second posting would fail
1394 // with "No matching lot" because it was trying to match against lot 1
1395 // which was already exhausted by the first posting.
1396 let booked2 = engine.book(&sell2);
1397 assert!(
1398 booked2.is_ok(),
1399 "Multi-posting transaction should succeed: {:?}",
1400 booked2.err()
1401 );
1402
1403 // Apply and verify final inventory: 70 ADA remaining (all in lot 2)
1404 engine.apply(&booked2.unwrap().transaction);
1405 let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
1406 assert_eq!(
1407 inv.units("ADA"),
1408 dec!(70),
1409 "Should have 70 ADA remaining in lot 2"
1410 );
1411 }
1412
1413 #[test]
1414 fn test_book_no_cost_specs_fast_path() {
1415 // Test that the fast path for transactions without cost specs
1416 // returns correct empty gains and booked_indices.
1417 let engine = BookingEngine::new();
1418
1419 // Simple expense transaction with no cost specs
1420 let txn = Transaction::new(date(2024, 1, 15), "Groceries")
1421 .with_synthesized_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD")))
1422 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD")));
1423
1424 let result = engine.book(&txn).unwrap();
1425
1426 // Fast path should return empty gains and booked_indices
1427 assert!(result.gains.is_empty(), "Should have no capital gains");
1428 assert!(
1429 result.booked_indices.is_empty(),
1430 "Should have no booked indices"
1431 );
1432
1433 // Transaction should be unchanged
1434 assert_eq!(result.transaction.postings.len(), 2);
1435 assert_eq!(
1436 result.transaction.postings[0].units,
1437 Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD")))
1438 );
1439 }
1440
1441 /// Regression test for #748.
1442 ///
1443 /// The pta-standards `reduction-exceeds-inventory` conformance test
1444 /// asserts on `error_contains: ["not enough"]`. PR #745 made the booking
1445 /// layer propagate `InsufficientUnits` directly to the user instead of
1446 /// letting the validator's "Not enough units in ..." message win, which
1447 /// dropped the "not enough" phrasing. This test pins the user-facing
1448 /// Display string so the conformance assertion (and any downstream user
1449 /// tooling that greps the message) cannot regress silently again.
1450 ///
1451 /// After #750, the canonical Display lives on
1452 /// [`rustledger_core::AccountedBookingError`] and `BookingError::Inventory`
1453 /// delegates to it transparently — so this test exercises the same path
1454 /// the validator and `cmd/check.rs` use.
1455
1456 // =========================================================================
1457 // Regression test for issue #875 / beancount#889
1458 //
1459 // Scenario: buy stock with cost, sell without cost spec (leaves a simple
1460 // negative position), then buy more with cost spec. The third transaction
1461 // must succeed as an augmentation, not fail as a reduction.
1462 // =========================================================================
1463
1464 #[test]
1465 fn test_augmentation_after_sell_without_cost_spec() {
1466 // Regression test for issue #875 / beancount#889.
1467 //
1468 // Before the fix, the sell-without-cost-spec left a -25 HOOG simple
1469 // position, causing the subsequent buy-with-cost-spec to be
1470 // misclassified as a reduction (because is_reduced_by saw opposite
1471 // signs without distinguishing cost-bearing vs simple positions).
1472 let mut engine = BookingEngine::new();
1473
1474 // 2024-01-10: Buy 100 HOOG {1.50 EUR}
1475 let buy1 = Transaction::new(date(2024, 1, 10), "Buy 100 HOOG")
1476 .with_synthesized_posting(
1477 Posting::new("Assets:Stocks", Amount::new(dec!(100), "HOOG")).with_cost(
1478 CostSpec::empty()
1479 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.50) })
1480 .with_currency("EUR"),
1481 ),
1482 )
1483 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-150), "EUR")));
1484
1485 engine.apply(&buy1);
1486
1487 // 2024-01-15: Sell 25 HOOG without cost spec (price-only)
1488 let sell = Transaction::new(date(2024, 1, 15), "Sell 25 HOOG without cost spec")
1489 .with_synthesized_posting(
1490 Posting::new("Assets:Stocks", Amount::new(dec!(-25), "HOOG"))
1491 .with_price(PriceAnnotation::unit(Amount::new(dec!(1.60), "EUR"))),
1492 )
1493 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(40), "EUR")));
1494
1495 engine.apply(&sell);
1496
1497 // 2024-01-20: Buy 50 more HOOG {1.70 EUR} - this MUST succeed
1498 let buy2 = Transaction::new(date(2024, 1, 20), "Buy 50 more HOOG - should succeed")
1499 .with_synthesized_posting(
1500 Posting::new("Assets:Stocks", Amount::new(dec!(50), "HOOG")).with_cost(
1501 CostSpec::empty()
1502 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.70) })
1503 .with_currency("EUR"),
1504 ),
1505 )
1506 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-85), "EUR")));
1507
1508 // This should NOT fail. Before the fix, the engine would see the
1509 // -25 HOOG simple position and try to reduce, which would fail
1510 // because the cost spec wouldn't match any existing lot.
1511 let result = engine.book(&buy2);
1512 assert!(
1513 result.is_ok(),
1514 "Buy with cost spec after sell without cost spec should succeed as augmentation, \
1515 but got error: {:?}",
1516 result.err()
1517 );
1518
1519 let booked = result.unwrap();
1520 engine.apply(&booked.transaction);
1521
1522 // Verify final inventory state
1523 let inv = engine.inventory(&"Assets:Stocks".into()).unwrap();
1524 // 100 (original) - 25 (sold simple) + 50 (new lot) = 125 HOOG total
1525 assert_eq!(inv.units("HOOG"), dec!(125));
1526 }
1527
1528 #[test]
1529 fn test_insufficient_units_display_contains_not_enough() {
1530 let err = BookingError::Inventory(
1531 rustledger_core::BookingError::InsufficientUnits {
1532 currency: "AAPL".into(),
1533 requested: dec!(15),
1534 available: dec!(10),
1535 }
1536 .with_account("Assets:Stock".into()),
1537 );
1538 let rendered = format!("{err}");
1539 assert!(
1540 rendered.contains("not enough"),
1541 "InsufficientUnits Display must contain 'not enough' for beancount \
1542 compatibility (#748). Got: {rendered}"
1543 );
1544 assert!(
1545 rendered.contains("Assets:Stock"),
1546 "InsufficientUnits Display must include the account name. Got: {rendered}"
1547 );
1548 assert!(
1549 rendered.contains("15") && rendered.contains("10"),
1550 "InsufficientUnits Display must include requested and available amounts. Got: {rendered}"
1551 );
1552 }
1553
1554 /// Helper: does any posting still have an unfilled (elided) amount?
1555 fn has_elided_posting(txn: &Transaction) -> bool {
1556 txn.postings.iter().any(|p| p.units.is_none())
1557 }
1558
1559 #[test]
1560 fn book_interpolates_elided_posting_and_preserves_order() {
1561 use rustledger_core::Open;
1562
1563 let directives = vec![
1564 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1565 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1566 Directive::Transaction(
1567 Transaction::new(date(2024, 1, 15), "Lunch")
1568 .with_synthesized_posting(Posting::new(
1569 "Expenses:Food",
1570 Amount::new(dec!(50.00), "USD"),
1571 ))
1572 .with_synthesized_posting(Posting::auto("Assets:Cash")),
1573 ),
1574 ];
1575
1576 let result = book(&directives, BookingMethod::Strict);
1577 assert!(result.failed.is_empty(), "nothing should fail to book");
1578 assert_eq!(result.booked.len(), 3, "all directives preserved");
1579
1580 // Order preserved: the two Opens come first, unchanged.
1581 assert_eq!(result.booked[0], directives[0]);
1582 assert_eq!(result.booked[1], directives[1]);
1583
1584 // The transaction's elided posting got filled in.
1585 let Directive::Transaction(booked_txn) = &result.booked[2] else {
1586 panic!("third directive should still be a transaction");
1587 };
1588 assert!(
1589 !has_elided_posting(booked_txn),
1590 "the auto posting should have been interpolated"
1591 );
1592 }
1593
1594 #[test]
1595 fn book_is_deterministic() {
1596 use rustledger_core::Open;
1597
1598 let directives = vec![
1599 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1600 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1601 Directive::Transaction(
1602 Transaction::new(date(2024, 1, 15), "Buy")
1603 .with_synthesized_posting(
1604 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1605 .with_price(PriceAnnotation::unit(Amount::new(dec!(150.00), "USD"))),
1606 )
1607 .with_synthesized_posting(Posting::auto("Assets:Cash")),
1608 ),
1609 ];
1610
1611 let first = book(&directives, BookingMethod::Strict);
1612 let second = book(&directives, BookingMethod::Strict);
1613 assert_eq!(
1614 first.booked, second.booked,
1615 "booking the same ledger twice must produce identical output"
1616 );
1617 }
1618
1619 #[test]
1620 fn book_partitions_failed_transaction() {
1621 use rustledger_core::Open;
1622
1623 // Buy a lot at $150, then sell against a cost basis ($200) that
1624 // matches no existing lot. Under Strict this is a no-matching-lot
1625 // error, so the sell is partitioned into `failed`.
1626 let buy_cost = CostSpec::empty()
1627 .with_number(rustledger_core::CostNumber::PerUnit {
1628 value: dec!(150.00),
1629 })
1630 .with_currency("USD");
1631 let sell_cost = CostSpec::empty()
1632 .with_number(rustledger_core::CostNumber::PerUnit {
1633 value: dec!(200.00),
1634 })
1635 .with_currency("USD");
1636
1637 let directives = vec![
1638 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1639 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1640 Directive::Transaction(
1641 Transaction::new(date(2024, 1, 10), "Buy")
1642 .with_synthesized_posting(
1643 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1644 .with_cost(buy_cost),
1645 )
1646 .with_synthesized_posting(Posting::new(
1647 "Assets:Cash",
1648 Amount::new(dec!(-1500.00), "USD"),
1649 )),
1650 ),
1651 Directive::Transaction(
1652 Transaction::new(date(2024, 1, 15), "Sell at phantom cost basis")
1653 .with_synthesized_posting(
1654 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1655 .with_cost(sell_cost),
1656 )
1657 .with_synthesized_posting(Posting::new(
1658 "Assets:Cash",
1659 Amount::new(dec!(1000.00), "USD"),
1660 )),
1661 ),
1662 ];
1663
1664 let result = book(&directives, BookingMethod::Strict);
1665 assert_eq!(result.failed.len(), 1, "the mismatched sell should fail");
1666 // The two Opens and the successful buy survive; the sell is dropped.
1667 assert_eq!(result.booked.len(), 3, "Opens + buy remain in booked");
1668 assert!(
1669 !result.booked.iter().any(|d| matches!(
1670 d,
1671 Directive::Transaction(t) if t.narration.as_ref() == "Sell at phantom cost basis"
1672 )),
1673 "failed sell must not appear in booked"
1674 );
1675 }
1676}