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