Skip to main content

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    /// Issue #1705: an augmenting `{}` lot gets its cost inferred from the
1081    /// residual, and a later `{}` reduction of that lot then books cleanly
1082    /// (previously the reduction hit a spurious "2 unknowns" error because
1083    /// the augmenting lot carried no cost basis to match).
1084    #[test]
1085    fn test_augmenting_empty_cost_then_reduce() {
1086        // buy: 1000 USD {} against -900 EUR  → lot booked at 0.90 EUR/unit
1087        let buy = Transaction::new(date(2024, 1, 2), "buy USD")
1088            .with_synthesized_posting(
1089                Posting::new("Assets:Broker", Amount::new(dec!(1000), "USD"))
1090                    .with_cost(CostSpec::empty()),
1091            )
1092            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-900), "EUR")));
1093
1094        // sell: -100 USD {} + 95 EUR + PnL residual  → reduce at 0.90, PnL = -5 EUR
1095        let sell = Transaction::new(date(2024, 1, 5), "sell part")
1096            .with_synthesized_posting(
1097                Posting::new("Assets:Broker", Amount::new(dec!(-100.00), "USD"))
1098                    .with_cost(CostSpec::empty()),
1099            )
1100            .with_synthesized_posting(Posting::new("Expenses:Misc", Amount::new(dec!(95), "EUR")))
1101            .with_synthesized_posting(Posting::auto("Income:Trading:PnL"));
1102
1103        let results = book_transactions(&[buy, sell], BookingMethod::Fifo);
1104        assert_eq!(results.len(), 2);
1105
1106        let buy_txn = &results[0].as_ref().expect("buy should book").transaction;
1107        let buy_cost = buy_txn.postings[0].cost.as_ref().expect("cost present");
1108        assert_eq!(
1109            buy_cost
1110                .number
1111                .as_ref()
1112                .and_then(rustledger_core::CostNumber::per_unit),
1113            Some(dec!(0.90))
1114        );
1115        assert_eq!(buy_cost.currency.as_deref(), Some("EUR"));
1116
1117        // The `{}` reduction booked cleanly and the PnL residual solved to -5 EUR.
1118        let sell_txn = &results[1].as_ref().expect("sell should book").transaction;
1119        let pnl = sell_txn
1120            .postings
1121            .iter()
1122            .find(|p| p.account.as_str() == "Income:Trading:PnL")
1123            .expect("PnL posting present");
1124        let pnl_amount = pnl
1125            .units
1126            .as_ref()
1127            .and_then(|u| u.as_amount())
1128            .expect("PnL filled");
1129        assert_eq!(pnl_amount.number, dec!(-5));
1130        assert_eq!(pnl_amount.currency.as_str(), "EUR");
1131    }
1132
1133    #[test]
1134    fn test_book_augmentation_not_reduction() {
1135        let mut engine = BookingEngine::new();
1136
1137        // First, add existing inventory with positive AAPL
1138        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
1139            .with_synthesized_posting(
1140                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1141                    CostSpec::empty()
1142                        .with_number(rustledger_core::CostNumber::PerUnit {
1143                            value: dec!(150.00),
1144                        })
1145                        .with_currency("USD"),
1146                ),
1147            )
1148            .with_synthesized_posting(Posting::new(
1149                "Assets:Cash",
1150                Amount::new(dec!(-1500.00), "USD"),
1151            ));
1152
1153        engine.apply(&buy);
1154
1155        // Now try to book another buy (augmentation, not reduction)
1156        // This has empty cost but same sign as inventory, so it's not a reduction
1157        let another_buy = Transaction::new(date(2024, 2, 15), "Buy more")
1158            .with_synthesized_posting(
1159                Posting::new("Assets:Stock", Amount::new(dec!(5), "AAPL"))
1160                    .with_cost(CostSpec::empty()), // Empty cost but augmentation
1161            )
1162            .with_synthesized_posting(Posting::new(
1163                "Assets:Cash",
1164                Amount::new(dec!(-750.00), "USD"),
1165            ));
1166
1167        // History: originally pinned "should not error - just skip lot
1168        // matching" (booked the lot UNCOSTED — silent corruption); the
1169        // #1708 interim guard made it a loud error; the #1705 solver now
1170        // books it at the residual-inferred cost like beancount. The
1171        // engine-level book() leaves the {} spec for interpolation, so
1172        // this remains not-an-error with no booked indices.
1173        let booked = engine.book(&another_buy).unwrap();
1174        assert!(
1175            booked.booked_indices.is_empty(),
1176            "augmentation books via interpolation, not lot matching"
1177        );
1178    }
1179
1180    #[test]
1181    fn test_book_no_inventory_for_account() {
1182        let engine = BookingEngine::new();
1183
1184        // Try to book a sell without any prior inventory
1185        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
1186            .with_synthesized_posting(
1187                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1188                    .with_cost(CostSpec::empty()),
1189            )
1190            .with_synthesized_posting(Posting::new(
1191                "Assets:Cash",
1192                Amount::new(dec!(875.00), "USD"),
1193            ));
1194
1195        // Should succeed but with no booked indices (no inventory to match against)
1196        let booked = engine.book(&sell).unwrap();
1197        assert!(
1198            booked.booked_indices.is_empty(),
1199            "No inventory means no lot matching"
1200        );
1201    }
1202
1203    #[test]
1204    fn test_book_zero_gain() {
1205        let mut engine = BookingEngine::new();
1206
1207        // Buy 10 AAPL at $150
1208        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
1209            .with_synthesized_posting(
1210                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1211                    CostSpec::empty()
1212                        .with_number(rustledger_core::CostNumber::PerUnit {
1213                            value: dec!(150.00),
1214                        })
1215                        .with_currency("USD"),
1216                ),
1217            )
1218            .with_synthesized_posting(Posting::new(
1219                "Assets:Cash",
1220                Amount::new(dec!(-1500.00), "USD"),
1221            ));
1222
1223        engine.apply(&buy);
1224
1225        // Sell at same price - zero gain
1226        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
1227            .with_synthesized_posting(
1228                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1229                    .with_cost(CostSpec::empty())
1230                    .with_price(PriceAnnotation::unit(Amount::new(dec!(150.00), "USD"))),
1231            )
1232            .with_synthesized_posting(Posting::new(
1233                "Assets:Cash",
1234                Amount::new(dec!(750.00), "USD"),
1235            ));
1236
1237        let booked = engine.book(&sell).unwrap();
1238
1239        // Zero gain should not be added to gains vector
1240        assert!(booked.gains.is_empty(), "Zero gain should not be recorded");
1241    }
1242
1243    /// Test cost currency inference from other postings (issue #230).
1244    ///
1245    /// When a cost is specified without a currency (e.g., `{1}`), the currency
1246    /// should be inferred from simple postings in the same transaction.
1247    #[test]
1248    fn test_cost_currency_inference_from_other_postings() {
1249        let mut engine = BookingEngine::new();
1250
1251        // Opening balance with cost without currency - should infer USD from other posting
1252        // 2026-01-01 * "Opening balance"
1253        //   Assets:Abc   1 ABC {1}           <- no currency, should infer USD
1254        //   Equity:Opening-Balances -1 USD
1255        let open = Transaction::new(date(2026, 1, 1), "Opening balance")
1256            .with_synthesized_posting(
1257                Posting::new("Assets:Abc", Amount::new(dec!(1), "ABC")).with_cost(
1258                    CostSpec::empty()
1259                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1) }),
1260                ), // No currency!
1261            )
1262            .with_synthesized_posting(Posting::new(
1263                "Equity:Opening-Balances",
1264                Amount::new(dec!(-1), "USD"),
1265            ));
1266
1267        // Book and apply the opening
1268        let booked = engine.book(&open).unwrap();
1269        engine.apply(&booked.transaction);
1270
1271        // Check that the cost spec was filled in with USD
1272        let cost_spec = booked.transaction.postings[0].cost.as_ref().unwrap();
1273        assert_eq!(
1274            cost_spec.currency.as_deref(),
1275            Some("USD"),
1276            "Cost currency should be inferred as USD from other posting"
1277        );
1278
1279        // Check inventory has the position with correct cost
1280        let inv = engine.inventory(&"Assets:Abc".into()).unwrap();
1281        let pos = inv.positions().next().unwrap();
1282        assert!(pos.cost.is_some(), "Position should have cost");
1283        let cost = pos.cost.as_ref().unwrap();
1284        assert_eq!(cost.currency.as_ref(), "USD", "Cost currency should be USD");
1285        assert_eq!(cost.number, dec!(1), "Cost number should be 1");
1286
1287        // Now sell with explicit cost currency - should match the lot
1288        // 2026-01-02 * "Sale"
1289        //   Assets:Abc  -1 ABC {1 USD}
1290        //   Expenses:Abc
1291        let sell = Transaction::new(date(2026, 1, 2), "Sale")
1292            .with_synthesized_posting(
1293                Posting::new("Assets:Abc", Amount::new(dec!(-1), "ABC")).with_cost(
1294                    CostSpec::empty()
1295                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1) })
1296                        .with_currency("USD"),
1297                ),
1298            )
1299            .with_synthesized_posting(Posting::auto("Expenses:Abc"));
1300
1301        // This should succeed - the lot with {1 USD} should be found
1302        let booked_sell = engine.book(&sell).unwrap();
1303
1304        // Check that the lot was matched
1305        assert!(
1306            !booked_sell.booked_indices.is_empty(),
1307            "Sale should match the lot created in opening"
1308        );
1309    }
1310
1311    #[test]
1312    fn test_multi_posting_crosses_lot_boundary() {
1313        // Regression test: Multiple postings in the same transaction reducing
1314        // the same commodity should correctly track inventory state across postings.
1315        // Previously, each posting would see the original inventory instead of
1316        // the updated state after processing previous postings.
1317
1318        let mut engine = BookingEngine::new();
1319
1320        // Create two lots of ADA with different costs
1321        // Lot 1: 100 ADA at $0.50 (2021-01-01)
1322        let buy1 = Transaction::new(date(2021, 1, 1), "Buy lot 1")
1323            .with_synthesized_posting(
1324                Posting::new("Assets:Crypto", Amount::new(dec!(100), "ADA")).with_cost(
1325                    CostSpec::empty()
1326                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0.50) })
1327                        .with_currency("USD")
1328                        .with_date(date(2021, 1, 1)),
1329                ),
1330            )
1331            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD")));
1332        engine.apply(&buy1);
1333
1334        // Lot 2: 100 ADA at $0.52 (2022-05-19)
1335        let buy2 = Transaction::new(date(2022, 5, 19), "Buy lot 2")
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.52) })
1340                        .with_currency("USD")
1341                        .with_date(date(2022, 5, 19)),
1342                ),
1343            )
1344            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-52), "USD")));
1345        engine.apply(&buy2);
1346
1347        // Verify initial inventory: 200 ADA total
1348        let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
1349        assert_eq!(inv.units("ADA"), dec!(200));
1350
1351        // Consume half of lot 1 first
1352        let sell1 = Transaction::new(date(2022, 5, 20), "Sell 50 ADA")
1353            .with_synthesized_posting(
1354                Posting::new("Assets:Crypto", Amount::new(dec!(-50), "ADA"))
1355                    .with_cost(CostSpec::empty()),
1356            )
1357            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(25), "USD")));
1358        let booked1 = engine.book(&sell1).unwrap();
1359        engine.apply(&booked1.transaction);
1360
1361        // Verify: 150 ADA remaining (50 in lot 1, 100 in lot 2)
1362        let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
1363        assert_eq!(inv.units("ADA"), dec!(150));
1364
1365        // Now the critical test: TWO postings in the same transaction
1366        // that together cross the lot boundary.
1367        // - Posting 1: -75 ADA {} → takes 50 from lot 1 + 25 from lot 2
1368        // - Posting 2: -5 ADA {} → should take from lot 2 (continuing)
1369        let sell2 = Transaction::new(date(2022, 5, 22), "Sell 80 ADA (multi-posting)")
1370            .with_synthesized_posting(
1371                Posting::new("Assets:Crypto", Amount::new(dec!(-75), "ADA"))
1372                    .with_cost(CostSpec::empty()),
1373            )
1374            .with_synthesized_posting(
1375                Posting::new("Assets:Crypto", Amount::new(dec!(-5), "ADA"))
1376                    .with_cost(CostSpec::empty()),
1377            )
1378            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(42), "USD")));
1379
1380        // This should succeed - the bug was that the second posting would fail
1381        // with "No matching lot" because it was trying to match against lot 1
1382        // which was already exhausted by the first posting.
1383        let booked2 = engine.book(&sell2);
1384        assert!(
1385            booked2.is_ok(),
1386            "Multi-posting transaction should succeed: {:?}",
1387            booked2.err()
1388        );
1389
1390        // Apply and verify final inventory: 70 ADA remaining (all in lot 2)
1391        engine.apply(&booked2.unwrap().transaction);
1392        let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
1393        assert_eq!(
1394            inv.units("ADA"),
1395            dec!(70),
1396            "Should have 70 ADA remaining in lot 2"
1397        );
1398    }
1399
1400    #[test]
1401    fn test_book_no_cost_specs_fast_path() {
1402        // Test that the fast path for transactions without cost specs
1403        // returns correct empty gains and booked_indices.
1404        let engine = BookingEngine::new();
1405
1406        // Simple expense transaction with no cost specs
1407        let txn = Transaction::new(date(2024, 1, 15), "Groceries")
1408            .with_synthesized_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD")))
1409            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD")));
1410
1411        let result = engine.book(&txn).unwrap();
1412
1413        // Fast path should return empty gains and booked_indices
1414        assert!(result.gains.is_empty(), "Should have no capital gains");
1415        assert!(
1416            result.booked_indices.is_empty(),
1417            "Should have no booked indices"
1418        );
1419
1420        // Transaction should be unchanged
1421        assert_eq!(result.transaction.postings.len(), 2);
1422        assert_eq!(
1423            result.transaction.postings[0].units,
1424            Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD")))
1425        );
1426    }
1427
1428    /// Regression test for #748.
1429    ///
1430    /// The pta-standards `reduction-exceeds-inventory` conformance test
1431    /// asserts on `error_contains: ["not enough"]`. PR #745 made the booking
1432    /// layer propagate `InsufficientUnits` directly to the user instead of
1433    /// letting the validator's "Not enough units in ..." message win, which
1434    /// dropped the "not enough" phrasing. This test pins the user-facing
1435    /// Display string so the conformance assertion (and any downstream user
1436    /// tooling that greps the message) cannot regress silently again.
1437    ///
1438    /// After #750, the canonical Display lives on
1439    /// [`rustledger_core::AccountedBookingError`] and `BookingError::Inventory`
1440    /// delegates to it transparently — so this test exercises the same path
1441    /// the validator and `cmd/check.rs` use.
1442
1443    // =========================================================================
1444    // Regression test for issue #875 / beancount#889
1445    //
1446    // Scenario: buy stock with cost, sell without cost spec (leaves a simple
1447    // negative position), then buy more with cost spec. The third transaction
1448    // must succeed as an augmentation, not fail as a reduction.
1449    // =========================================================================
1450
1451    #[test]
1452    fn test_augmentation_after_sell_without_cost_spec() {
1453        // Regression test for issue #875 / beancount#889.
1454        //
1455        // Before the fix, the sell-without-cost-spec left a -25 HOOG simple
1456        // position, causing the subsequent buy-with-cost-spec to be
1457        // misclassified as a reduction (because is_reduced_by saw opposite
1458        // signs without distinguishing cost-bearing vs simple positions).
1459        let mut engine = BookingEngine::new();
1460
1461        // 2024-01-10: Buy 100 HOOG {1.50 EUR}
1462        let buy1 = Transaction::new(date(2024, 1, 10), "Buy 100 HOOG")
1463            .with_synthesized_posting(
1464                Posting::new("Assets:Stocks", Amount::new(dec!(100), "HOOG")).with_cost(
1465                    CostSpec::empty()
1466                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.50) })
1467                        .with_currency("EUR"),
1468                ),
1469            )
1470            .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-150), "EUR")));
1471
1472        engine.apply(&buy1);
1473
1474        // 2024-01-15: Sell 25 HOOG without cost spec (price-only)
1475        let sell = Transaction::new(date(2024, 1, 15), "Sell 25 HOOG without cost spec")
1476            .with_synthesized_posting(
1477                Posting::new("Assets:Stocks", Amount::new(dec!(-25), "HOOG"))
1478                    .with_price(PriceAnnotation::unit(Amount::new(dec!(1.60), "EUR"))),
1479            )
1480            .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(40), "EUR")));
1481
1482        engine.apply(&sell);
1483
1484        // 2024-01-20: Buy 50 more HOOG {1.70 EUR} - this MUST succeed
1485        let buy2 = Transaction::new(date(2024, 1, 20), "Buy 50 more HOOG - should succeed")
1486            .with_synthesized_posting(
1487                Posting::new("Assets:Stocks", Amount::new(dec!(50), "HOOG")).with_cost(
1488                    CostSpec::empty()
1489                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.70) })
1490                        .with_currency("EUR"),
1491                ),
1492            )
1493            .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-85), "EUR")));
1494
1495        // This should NOT fail. Before the fix, the engine would see the
1496        // -25 HOOG simple position and try to reduce, which would fail
1497        // because the cost spec wouldn't match any existing lot.
1498        let result = engine.book(&buy2);
1499        assert!(
1500            result.is_ok(),
1501            "Buy with cost spec after sell without cost spec should succeed as augmentation, \
1502             but got error: {:?}",
1503            result.err()
1504        );
1505
1506        let booked = result.unwrap();
1507        engine.apply(&booked.transaction);
1508
1509        // Verify final inventory state
1510        let inv = engine.inventory(&"Assets:Stocks".into()).unwrap();
1511        // 100 (original) - 25 (sold simple) + 50 (new lot) = 125 HOOG total
1512        assert_eq!(inv.units("HOOG"), dec!(125));
1513    }
1514
1515    #[test]
1516    fn test_insufficient_units_display_contains_not_enough() {
1517        let err = BookingError::Inventory(
1518            rustledger_core::BookingError::InsufficientUnits {
1519                currency: "AAPL".into(),
1520                requested: dec!(15),
1521                available: dec!(10),
1522            }
1523            .with_account("Assets:Stock".into()),
1524        );
1525        let rendered = format!("{err}");
1526        assert!(
1527            rendered.contains("not enough"),
1528            "InsufficientUnits Display must contain 'not enough' for beancount \
1529             compatibility (#748). Got: {rendered}"
1530        );
1531        assert!(
1532            rendered.contains("Assets:Stock"),
1533            "InsufficientUnits Display must include the account name. Got: {rendered}"
1534        );
1535        assert!(
1536            rendered.contains("15") && rendered.contains("10"),
1537            "InsufficientUnits Display must include requested and available amounts. Got: {rendered}"
1538        );
1539    }
1540
1541    /// Helper: does any posting still have an unfilled (elided) amount?
1542    fn has_elided_posting(txn: &Transaction) -> bool {
1543        txn.postings.iter().any(|p| p.units.is_none())
1544    }
1545
1546    #[test]
1547    fn book_interpolates_elided_posting_and_preserves_order() {
1548        use rustledger_core::Open;
1549
1550        let directives = vec![
1551            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1552            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1553            Directive::Transaction(
1554                Transaction::new(date(2024, 1, 15), "Lunch")
1555                    .with_synthesized_posting(Posting::new(
1556                        "Expenses:Food",
1557                        Amount::new(dec!(50.00), "USD"),
1558                    ))
1559                    .with_synthesized_posting(Posting::auto("Assets:Cash")),
1560            ),
1561        ];
1562
1563        let result = book(&directives, BookingMethod::Strict);
1564        assert!(result.failed.is_empty(), "nothing should fail to book");
1565        assert_eq!(result.booked.len(), 3, "all directives preserved");
1566
1567        // Order preserved: the two Opens come first, unchanged.
1568        assert_eq!(result.booked[0], directives[0]);
1569        assert_eq!(result.booked[1], directives[1]);
1570
1571        // The transaction's elided posting got filled in.
1572        let Directive::Transaction(booked_txn) = &result.booked[2] else {
1573            panic!("third directive should still be a transaction");
1574        };
1575        assert!(
1576            !has_elided_posting(booked_txn),
1577            "the auto posting should have been interpolated"
1578        );
1579    }
1580
1581    #[test]
1582    fn book_is_deterministic() {
1583        use rustledger_core::Open;
1584
1585        let directives = vec![
1586            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1587            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1588            Directive::Transaction(
1589                Transaction::new(date(2024, 1, 15), "Buy")
1590                    .with_synthesized_posting(
1591                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1592                            .with_price(PriceAnnotation::unit(Amount::new(dec!(150.00), "USD"))),
1593                    )
1594                    .with_synthesized_posting(Posting::auto("Assets:Cash")),
1595            ),
1596        ];
1597
1598        let first = book(&directives, BookingMethod::Strict);
1599        let second = book(&directives, BookingMethod::Strict);
1600        assert_eq!(
1601            first.booked, second.booked,
1602            "booking the same ledger twice must produce identical output"
1603        );
1604    }
1605
1606    #[test]
1607    fn book_partitions_failed_transaction() {
1608        use rustledger_core::Open;
1609
1610        // Buy a lot at $150, then sell against a cost basis ($200) that
1611        // matches no existing lot. Under Strict this is a no-matching-lot
1612        // error, so the sell is partitioned into `failed`.
1613        let buy_cost = CostSpec::empty()
1614            .with_number(rustledger_core::CostNumber::PerUnit {
1615                value: dec!(150.00),
1616            })
1617            .with_currency("USD");
1618        let sell_cost = CostSpec::empty()
1619            .with_number(rustledger_core::CostNumber::PerUnit {
1620                value: dec!(200.00),
1621            })
1622            .with_currency("USD");
1623
1624        let directives = vec![
1625            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1626            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1627            Directive::Transaction(
1628                Transaction::new(date(2024, 1, 10), "Buy")
1629                    .with_synthesized_posting(
1630                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1631                            .with_cost(buy_cost),
1632                    )
1633                    .with_synthesized_posting(Posting::new(
1634                        "Assets:Cash",
1635                        Amount::new(dec!(-1500.00), "USD"),
1636                    )),
1637            ),
1638            Directive::Transaction(
1639                Transaction::new(date(2024, 1, 15), "Sell at phantom cost basis")
1640                    .with_synthesized_posting(
1641                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1642                            .with_cost(sell_cost),
1643                    )
1644                    .with_synthesized_posting(Posting::new(
1645                        "Assets:Cash",
1646                        Amount::new(dec!(1000.00), "USD"),
1647                    )),
1648            ),
1649        ];
1650
1651        let result = book(&directives, BookingMethod::Strict);
1652        assert_eq!(result.failed.len(), 1, "the mismatched sell should fail");
1653        // The two Opens and the successful buy survive; the sell is dropped.
1654        assert_eq!(result.booked.len(), 3, "Opens + buy remain in booked");
1655        assert!(
1656            !result.booked.iter().any(|d| matches!(
1657                d,
1658                Directive::Transaction(t) if t.narration.as_ref() == "Sell at phantom cost basis"
1659            )),
1660            "failed sell must not appear in booked"
1661        );
1662    }
1663}