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