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