//! Transaction booking with lot matching.
//!
//! This module handles:
//! - Tracking inventory across transactions
//! - Matching sold lots against existing holdings
//! - Calculating capital gains/losses
//! - Filling in cost specs for lot reductions

// ratchet: fxhash-only — hot path; use FxHashMap/FxHashSet, not std SipHash collections (#1237).
use rustc_hash::{FxHashMap, FxHashSet};
use rustledger_core::{
    AccountedBookingError, Amount, BookingMethod, Cost, CostSpec, Decimal, Directive,
    IncompleteAmount, Inventory, Position, Posting, ReductionScope, Transaction,
};
use thiserror::Error;

use crate::{
    InterpolationError, InterpolationResult, interpolate_with_tolerance_map,
    interpolate_with_tolerances,
};

// Note: We no longer quantize calculated values during booking.
// Python beancount preserves full precision during booking and only
// rounds at display time. Premature rounding of per-unit costs (e.g.,
// from total cost / units) causes cost basis errors when selling.
// For example: 300.00 / 1.763 = 170.16505... should NOT be rounded
// to 170.17, because 1.763 * 170.17 = 300.00971 ≠ 300.00.

/// Errors that can occur during booking.
///
/// Inventory-level failures (insufficient units, no matching lot, ambiguous
/// match, currency mismatch) are unified under [`BookingError::Inventory`],
/// which carries an [`AccountedBookingError`] from `rustledger-core`. This
/// keeps the user-facing wording in **one place** so it cannot drift between
/// the booking layer and the validator — see #748 / #750.
#[derive(Debug, Clone, Error)]
pub enum BookingError {
    /// An inventory-level booking failure (insufficient units, no matching
    /// lot, ambiguous match, currency mismatch).
    ///
    /// `Display` is delegated to the inner [`AccountedBookingError`], which
    /// is the single canonical source of wording for booking errors. The
    /// pta-standards `reduction-exceeds-inventory` conformance test depends
    /// on this Display containing the literal substring `"not enough"`.
    #[error(transparent)]
    Inventory(AccountedBookingError),

    /// Interpolation failed after booking.
    #[error("interpolation failed: {0}")]
    Interpolation(#[from] InterpolationError),

    /// A posting reached application with its units still elided, which means
    /// it never went through booking.
    ///
    /// Separate from [`BookingError::Interpolation`] on purpose: interpolation
    /// did not FAIL here, it never ran. Reporting an interpolation failure
    /// would send a reader looking for a residual that does not exist.
    ///
    /// This used to be a silent `Ok(())`. A caller replaying an unbooked
    /// ledger got empty balances and no complaint — a wrong figure delivered
    /// quietly, which is worse than a refusal.
    #[error("posting for {account} was not booked: its units are still elided")]
    NotBooked {
        /// The account whose posting was not booked.
        account: rustledger_core::Account,
    },
}

/// Result of booking a single transaction.
#[derive(Debug, Clone)]
pub struct BookedTransaction {
    /// The transaction with costs filled in.
    pub transaction: Transaction,
    /// Capital gains/losses generated by this transaction.
    pub gains: Vec<CapitalGain>,
    /// Which posting indices had costs filled in.
    pub booked_indices: Vec<usize>,
}

/// A realized capital gain or loss from disposing of **one lot**.
///
/// A sale that crosses several lots produces one `CapitalGain` per matched lot,
/// so each is a self-contained disposal (units, cost basis, proceeds, acquisition
/// date) — the shape tax reporting needs (a Form 8949 line per lot). The gain is
/// only recorded for a reduction that carries a sale `@`price; a costless transfer
/// of a lot is not a disposal.
#[derive(Debug, Clone)]
pub struct CapitalGain {
    /// The account holding the asset.
    pub account: rustledger_core::Account,
    /// The currency of the asset.
    pub currency: rustledger_core::Currency,
    /// Units of this lot disposed of (always positive).
    pub units: Decimal,
    /// Cost basis of this lot (units × per-unit cost), in the lot's cost currency.
    pub cost_basis: Amount,
    /// Sale proceeds attributable to this lot, in the sale-price currency. Equals
    /// the cost-basis currency for an ordinary same-currency disposal; a consumer
    /// derives the realized gain as `proceeds − cost_basis` when the two currencies
    /// match (they cannot be subtracted across currencies without an FX rate — that
    /// policy is the consumer's, not the booking engine's).
    pub proceeds: Amount,
    /// The date of the disposing transaction (the sale/cover date).
    pub sale_date: rustledger_core::NaiveDate,
    /// Acquisition date of the lot, when the lot carried one (the holding-period
    /// start for short vs long term classification).
    pub acquired_date: Option<rustledger_core::NaiveDate>,
    /// True when this disposal CLOSES A SHORT position (the matched lot had
    /// negative units): the lot's cost is the price the units were sold at when
    /// the short was opened, so `proceeds`/`cost_basis` are assigned accordingly.
    /// US tax treats short-sale gains as always short-term.
    pub short_sale: bool,
}

/// Booking engine that tracks inventory across transactions.
#[derive(Debug, Default)]
pub struct BookingEngine {
    /// Inventory per account.
    /// Per-account inventories, behind `Arc` so a caller can take a snapshot
    /// without copying the lots.
    ///
    /// Booking itself never shares one, so `Arc::make_mut` below hands back
    /// `&mut` without copying and the engine pays nothing for this. A caller
    /// that HOLDS a snapshot makes the next mutation of that account copy
    /// once — which is the copy it asked for, and no longer a copy per row
    /// whether anyone wanted it or not (#2086).
    inventories: FxHashMap<rustledger_core::Account, std::sync::Arc<Inventory>>,
    /// Default booking method, used for accounts without an explicit
    /// booking method on their `open` directive.
    booking_method: BookingMethod,
    /// Per-account booking method overrides (from `open` directives).
    /// Looked up first, falling back to `booking_method` if absent.
    account_methods: FxHashMap<rustledger_core::Account, BookingMethod>,
    /// Tolerance knobs used to quantize interpolated amounts. Defaults to
    /// beancount's defaults; the loader overrides it from the ledger's
    /// `option` directives via [`Self::with_tolerance_policy`].
    tolerance: crate::TolerancePolicy,
}

impl BookingEngine {
    /// Create a new booking engine with default FIFO booking.
    #[must_use]
    pub fn new() -> Self {
        Self {
            inventories: FxHashMap::default(),
            booking_method: BookingMethod::Fifo,
            account_methods: FxHashMap::default(),
            tolerance: crate::TolerancePolicy::default(),
        }
    }

    /// Create a booking engine with a specific default booking method.
    #[must_use]
    pub fn with_method(method: BookingMethod) -> Self {
        Self {
            inventories: FxHashMap::default(),
            booking_method: method,
            account_methods: FxHashMap::default(),
            tolerance: crate::TolerancePolicy::default(),
        }
    }

    /// Set the tolerance knobs used to quantize interpolated amounts.
    ///
    /// Interpolation rounds a solved number to the transaction's own balance
    /// tolerance, so a ledger that sets `tolerance_multiplier`,
    /// `infer_tolerance_from_cost` or `inferred_tolerance_default` must pass
    /// them here. Skipping this leaves the engine on beancount's defaults,
    /// which would round against a tolerance the balance validator is not
    /// using.
    #[must_use]
    pub fn with_tolerance_policy(mut self, policy: crate::TolerancePolicy) -> Self {
        self.tolerance = policy;
        self
    }

    /// Register the booking method for a specific account.
    ///
    /// Call this for each `open` directive *before* booking transactions for
    /// that account, so the engine uses the per-account method (e.g. FIFO,
    /// LIFO, NONE) rather than the engine-wide default. Subsequent calls
    /// overwrite the previous method for the account.
    pub fn set_account_method(&mut self, account: rustledger_core::Account, method: BookingMethod) {
        self.account_methods.insert(account, method);
    }

    /// Scan a sequence of directives and register any per-account booking
    /// methods found on `open` directives. Open directives whose booking
    /// method is absent or fails to parse are silently ignored (they fall
    /// back to the engine-wide default).
    ///
    /// This is a convenience wrapper around [`Self::set_account_method`] for
    /// the common pipeline pattern of scanning all directives once before
    /// the booking loop. Call this before booking any transactions so the
    /// engine uses each account's declared method rather than the
    /// engine-wide default for every account.
    pub fn register_account_methods<'a, I>(&mut self, directives: I)
    where
        I: IntoIterator<Item = &'a rustledger_core::Directive>,
    {
        for directive in directives {
            if let rustledger_core::Directive::Open(open) = directive
                && let Some(method_str) = &open.booking
                && let Ok(method) = method_str.parse::<BookingMethod>()
            {
                self.set_account_method(open.account.clone(), method);
            }
        }
    }

    /// Resolve the booking method for an account, falling back to the
    /// engine-wide default if not registered.
    fn method_for(&self, account: &rustledger_core::Account) -> BookingMethod {
        self.account_methods
            .get(account)
            .copied()
            .unwrap_or(self.booking_method)
    }

    /// Get the inventory for an account.
    #[must_use]
    pub fn inventory(&self, account: &rustledger_core::Account) -> Option<&Inventory> {
        self.inventories.get(account).map(AsRef::as_ref)
    }

    /// A snapshot of an account's inventory that costs a refcount bump.
    ///
    /// The engine keeps mutating; this hands back the state as of now, without
    /// copying the lots. The copy happens later and only if it has to — the
    /// next mutation of this account finds the `Arc` shared and clones once
    /// (`Arc::make_mut`). A caller that drops the snapshot before then, such as
    /// a query filter that reads the balance and moves on, causes no copy at
    /// all (#2086).
    #[must_use]
    pub fn inventory_snapshot(
        &self,
        account: &rustledger_core::Account,
    ) -> Option<std::sync::Arc<Inventory>> {
        self.inventories.get(account).map(std::sync::Arc::clone)
    }

    /// Consume the engine and return its realized per-account inventories.
    ///
    /// After [`apply`](Self::apply)ing a booked directive stream, this is the
    /// canonical realization — each account's `Inventory` carries its lots at
    /// cost, with reductions already matched (FIFO/LIFO/etc. per the account's
    /// booking method). Callers that render balances (reports, holdings) read
    /// this rather than re-summing postings, so cost is retained and lot
    /// matching is the engine's, not a re-implementation.
    #[must_use]
    pub fn into_inventories(self) -> FxHashMap<rustledger_core::Account, Inventory> {
        self.inventories
            .into_iter()
            .map(|(account, inv)| {
                // Unwrap when this engine holds the only reference, which is
                // the normal case; a caller still holding a snapshot pays for
                // the copy it kept alive.
                (
                    account,
                    std::sync::Arc::try_unwrap(inv).unwrap_or_else(|shared| (*shared).clone()),
                )
            })
            .collect()
    }

    /// Borrow the realized per-account inventories without consuming the engine.
    ///
    /// The borrowing counterpart to [`into_inventories`](Self::into_inventories),
    /// for callers that must read the realization *mid-walk* and keep applying —
    /// e.g. snapshotting portfolio value at successive dates in one forward pass
    /// (`rustledger-returns`' single-pass TWR). Iteration order is the underlying
    /// `FxHashMap`'s (unspecified); collect into a `BTreeMap` if a stable order
    /// matters.
    // (No `#[must_use]`: the returned `impl Iterator` already carries it.)
    pub fn inventories(&self) -> impl Iterator<Item = (&rustledger_core::Account, &Inventory)> {
        self.inventories
            .iter()
            .map(|(account, inv)| (account, inv.as_ref()))
    }

    /// Book a transaction: fill in empty cost specs and calculate gains.
    ///
    /// This does NOT modify the internal inventories - call `apply` for that.
    ///
    /// When a reduction matches multiple lots (e.g., selling shares that were purchased
    /// across multiple buy transactions), the posting is expanded into multiple postings,
    /// one for each matched lot. This matches Python beancount's behavior.
    pub fn book(&self, txn: &Transaction) -> Result<BookedTransaction, BookingError> {
        // Fast path: if no postings have cost specs, no booking is needed.
        // This avoids expensive inventory cloning for simple transactions.
        let has_cost_specs = txn.postings.iter().any(|p| p.cost.is_some());
        if !has_cost_specs {
            return Ok(BookedTransaction {
                transaction: txn.clone(),
                gains: Vec::new(),
                booked_indices: Vec::new(),
            });
        }

        let mut result = txn.clone();
        let mut gains = Vec::new();
        let mut booked_indices: FxHashSet<usize> =
            FxHashSet::with_capacity_and_hasher(txn.postings.len(), Default::default());
        // Track posting expansions: (original_idx, expanded_postings)
        let mut expansions: Vec<(usize, Vec<rustledger_core::Spanned<Posting>>)> =
            Vec::with_capacity(txn.postings.len());

        // Create working copies of inventories for this transaction.
        // This allows us to track inventory changes across multiple postings
        // within the same transaction (e.g., main sale + fee posting).
        //
        // Clone only the inventories we actually need for this transaction's
        // accounts. Use `entry().or_insert_with(...)` so that a posting list
        // with repeated accounts (e.g., two postings on `Assets:Stock`) only
        // triggers one clone per unique account instead of cloning the same
        // inventory every time it appears. Without deduping, the optimization
        // would be silently undone by transactions that list the same
        // account more than once.
        let mut working_inventories: FxHashMap<rustledger_core::Account, Inventory> =
            FxHashMap::with_capacity_and_hasher(txn.postings.len(), Default::default());
        //
        // Only accounts with MORE THAN ONE reducing posting need a working
        // copy at all. The copy exists so a second posting on the same account
        // sees what the first one took; with a single posting nothing ever
        // reads the mutation back, and `Inventory::try_reduce` answers from
        // `&self` without copying (it stopped being `self.clone().reduce(..)`
        // in this same change).
        //
        // That matters because the copy is O(lots): the engine's stores are
        // `PositionStore::Owned`, so a long-lived investment account copied
        // its entire lot list on every transaction that touched it.
        // `Position::clone` grew 104x for 10x the input on the `investment`
        // profiling shape — the dominant superlinear term left in the
        // pipeline.
        //
        // The gate below must stay in step with the reduction path's own
        // gate (`Complete` units + a cost spec). If it ever drifts toward
        // cloning MORE, the result is only wasted work; drifting toward
        // cloning less would silently drop a second posting's view of the
        // first, which is what `two_reducing_postings_on_one_account_*`
        // pins.
        let mut reducing_postings: FxHashMap<&rustledger_core::Account, usize> =
            FxHashMap::with_capacity_and_hasher(txn.postings.len(), Default::default());
        for posting in &txn.postings {
            if matches!(posting.units, Some(IncompleteAmount::Complete(_)))
                && posting.cost.is_some()
            {
                *reducing_postings.entry(&posting.account).or_default() += 1;
            }
        }
        for posting in &txn.postings {
            if reducing_postings
                .get(&posting.account)
                .is_some_and(|n| *n > 1)
                && let Some(inv) = self.inventories.get(&posting.account)
            {
                working_inventories
                    .entry(posting.account.clone())
                    .or_insert_with(|| (**inv).clone());
            }
        }

        // First pass: identify postings that need lot matching (reductions)
        for (idx, posting) in txn.postings.iter().enumerate() {
            // Check if this is a reduction with a cost spec
            if let Some(IncompleteAmount::Complete(units)) = &posting.units
                && let Some(cost_spec) = &posting.cost
            {
                // Normalize compound `{a # b}` up front (#1700): both the
                // reduction path (which uses the spec as a lot-match filter
                // via `per_unit()`) and apply() (which re-reduces from the
                // BOOKED posting) need a resolved per-unit — the raw
                // Compound form deliberately exposes neither component as
                // an effective value. Combined total N*a + b is preserved
                // exactly for residual math, same as the {{T}} conversion.
                let normalized_compound: Option<CostSpec> = match cost_spec.number {
                    Some(rustledger_core::CostNumber::Compound { per_unit, total })
                        if !units.number.is_zero() =>
                    {
                        // Checked (#1863): `N*a + b` for a compound cost is a
                        // product of two user-supplied numbers, so it leaves
                        // `Decimal`'s range on inputs well inside it. This is
                        // the ONE reachable site the first pass of the fix
                        // missed — the sweep that was supposed to catch it ran
                        // against a wrongly-assigned binary path and silently
                        // tested nothing.
                        let combined = units
                            .number
                            .abs()
                            .checked_mul(per_unit)
                            .and_then(|v| v.checked_add(total))
                            .ok_or_else(|| {
                                convert_core_booking_error(
                                    rustledger_core::BookingError::Overflow(
                                        rustledger_core::OverflowError {
                                            currency: cost_spec
                                                .currency
                                                .clone()
                                                .unwrap_or_else(|| units.currency.clone()),
                                        },
                                    ),
                                    &posting.account,
                                )
                            })?;
                        Some(CostSpec {
                            number: Some(rustledger_core::CostNumber::PerUnitFromTotal(
                                rustledger_core::BookedCost::new(
                                    combined / units.number.abs(),
                                    combined,
                                    units.number,
                                ),
                            )),
                            currency: cost_spec.currency.clone(),
                            // Date deliberately NOT defaulted to txn.date
                            // here: on a REDUCTION the spec date is a lot
                            // match FILTER (injecting the sell date would
                            // match nothing); on an augmentation the lot's
                            // acquisition date defaults via resolve()'s
                            // date parameter as for plain {N} specs.
                            date: cost_spec.date,
                            label: cost_spec.label.clone(),
                            merge: cost_spec.merge,
                        })
                    }
                    _ => None,
                };
                let normalized_owned;
                let cost_spec = if let Some(normalized) = normalized_compound {
                    result.postings[idx].cost = Some(Box::new(normalized.clone()));
                    normalized_owned = normalized;
                    &normalized_owned
                } else {
                    cost_spec
                };
                // Check if this is a reduction (units have opposite sign of inventory)
                // This handles both:
                // - Selling long positions (negative units, positive inventory)
                // - Closing short positions (positive units, negative inventory)
                if let Some(inv) = working_inventories
                    .get(&posting.account)
                    .or_else(|| self.inventories.get(&posting.account).map(AsRef::as_ref))
                {
                    // Check if these units reduce existing cost-bearing inventory lots.
                    // Only positions with a cost basis are considered; simple (no-cost)
                    // positions are ignored to avoid misclassifying augmentations.
                    //
                    // Under `option "booking_method" "NONE"` (issue #1182),
                    // reduction matching is skipped entirely: NONE means
                    // "accumulate positions without booking against
                    // existing lots." Otherwise the booker would replace
                    // the user-written `{{ total }}` cost spec with a
                    // FIFO-matched per-unit (line ~282 below), and the
                    // residual calculation downstream would weigh the
                    // posting by the matched lots' costs instead of the
                    // user's stated total — producing a phantom
                    // E3001 imbalance for ledgers that round-trip
                    // cleanly through Python beancount.
                    let method = self.method_for(&posting.account);
                    let is_reduction = method != BookingMethod::None
                        && inv.is_reduced_by(units, ReductionScope::CostBearingOnly);

                    if is_reduction {
                        // Use reduce (not try_reduce) to actually update the working inventory.
                        // This ensures subsequent postings in the same transaction see
                        // the updated inventory state (e.g., after first posting exhausts a lot).
                        //
                        // Booking errors (ambiguous match, no matching lot, insufficient
                        // units) are propagated so callers see them once. The full
                        // pipeline path in `rustledger check` filters failed transactions
                        // out of the validator's input to avoid double-reporting against
                        // the validator's independent lot-matching pass.
                        // (`method` is resolved above next to the NONE-method gate.)
                        // Mutate the working copy when we made one, so a
                        // later posting on this account sees the reduction.
                        // Otherwise preview against the live inventory:
                        // nothing reads the mutation back, and this way
                        // nothing was copied to produce it. Both call the
                        // same planner inside `Inventory`.
                        let booking_result = match working_inventories.get_mut(&posting.account) {
                            Some(working) => working.reduce(units, Some(cost_spec), method),
                            // Indexing cannot panic here: reaching this arm
                            // means the enclosing `if let` bound `inv` from
                            // its `self.inventories` branch, so the account is
                            // present.
                            None => self.inventories[&posting.account].try_reduce(
                                units,
                                Some(cost_spec),
                                method,
                            ),
                        }
                        .map_err(|e| convert_core_booking_error(e, &posting.account))?;
                        {
                            // Check if multiple lots were matched
                            if booking_result.matched.len() > 1 {
                                // Expand single posting into multiple postings
                                let mut expanded = Vec::new();
                                for matched_pos in &booking_result.matched {
                                    let mut new_posting = posting.clone();
                                    // Set units to the matched portion with NEGATED sign
                                    // (matched_pos.units has the inventory sign, but we need
                                    // the reduction sign which is opposite)
                                    let expanded_units = rustledger_core::Amount::new(
                                        -matched_pos.units.number, // Negate: inventory→reduction
                                        matched_pos.units.currency.clone(),
                                    );
                                    new_posting.units =
                                        Some(IncompleteAmount::Complete(expanded_units));
                                    // Set cost from the matched lot
                                    if let Some(cost) = &matched_pos.cost {
                                        new_posting.cost = Some(Box::new(CostSpec {
                                            number: Some(rustledger_core::CostNumber::PerUnit {
                                                value: cost.number,
                                            }),
                                            currency: Some(cost.currency.clone()),
                                            date: cost.date,
                                            label: cost.label.clone(),
                                            merge: false,
                                        }));
                                    }
                                    expanded.push(new_posting);
                                }
                                expansions.push((idx, expanded));
                                booked_indices.insert(idx);
                            } else if let Some(cost_basis) = &booking_result.cost_basis {
                                // Single lot match - update posting in place.
                                // Prefer the matched lot's exact stored cost to avoid
                                // rounding loss from `(take * c) / take` on full-precision
                                // total-cost lots (#2048).
                                let matched_pos = booking_result.matched.first();
                                let matched_cost = matched_pos
                                    .and_then(|p| p.cost.as_ref())
                                    .cloned()
                                    .unwrap_or_else(|| {
                                        let per_unit = cost_basis.number / units.number.abs();
                                        Cost::new_calculated(per_unit, cost_basis.currency.clone())
                                    });

                                // Update posting with filled cost. Carry the
                                // matched lot's label (as the date already is) so
                                // the reduction shares lot identity with its
                                // augmenting lot and nets against it — otherwise a
                                // labeled reduction leaves a phantom unlabeled
                                // negative lot in the holdings view (#1666).
                                result.postings[idx].cost = Some(Box::new(CostSpec {
                                    number: Some(rustledger_core::CostNumber::PerUnit {
                                        value: matched_cost.number,
                                    }),
                                    currency: Some(matched_cost.currency),
                                    date: matched_cost.date,
                                    label: matched_cost.label,
                                    // Carry `{*}` through to application (#2068).
                                    // Every other cost spec is a FILTER: it
                                    // selects among lots that already exist, so
                                    // booking can safely resolve it into the one
                                    // lot it picked. `{*}` is an OPERATION — it
                                    // merges the pool and only then reduces it.
                                    // Resolving it into the per-unit cost of the
                                    // pool it created names a lot that does not
                                    // exist until the merge has run, so `apply`
                                    // reported `No matching lot` for holdings the
                                    // account plainly had. The marker has to
                                    // survive so application re-runs the merge.
                                    merge: cost_spec.merge,
                                }));
                                booked_indices.insert(idx);
                            }
                        }

                        // Realized capital gains, per matched lot, for a reduction
                        // that carries a sale `@`price. Iterating `matched` covers
                        // the single-lot AND multi-lot (expanded) cases uniformly, so
                        // a sale crossing several lots yields one disposal per lot,
                        // each with its own acquisition date and cost basis (the
                        // shape tax reporting needs). A costless transfer of a lot
                        // (no price) is not a disposal, so it records nothing.
                        if let Some(price) = &posting.price
                            && let Some(amt) =
                                price.amount.as_ref().and_then(IncompleteAmount::as_amount)
                            // A zero-unit reduction is degenerate — no proceeds to
                            // pro-rate — so record no disposal (guards the `@@`
                            // division too).
                            && !units.number.is_zero()
                        {
                            let total_units = units.number.abs();
                            for m in &booking_result.matched {
                                // A matched cost-bearing lot always carries a cost; skip a
                                // (defensive) cost-less one since it has no basis. The
                                // cost and sale currencies are recorded as-is — a
                                // cross-currency disposal (sale price in a different
                                // currency than the lot's cost) is NOT dropped here; whether
                                // its `proceeds − cost_basis` gain is computable is the
                                // consumer's policy, not the booking engine's.
                                let Some(cost) = &m.cost else { continue };
                                let lot_units = m.units.number.abs();
                                // The lot's cost value (in the cost currency).
                                let lot_value = lot_units * cost.number;
                                // The reduction's sale value (in the sale-price currency).
                                // A `Unit` (`@`) price is exact per unit. A `Total` (`@@`)
                                // price is the EXACT pro-rata share `total × units /
                                // total_units` (multiply before divide, so a single lot and
                                // evenly-dividing splits are exact). It is deliberately NOT
                                // rounded here: rounding a money split to a clean per-lot
                                // figure is a presentation concern that needs the currency's
                                // precision (the report's `DisplayContext`), and rounding in
                                // the booking layer to the total's own scale both distorts
                                // round-dollar totals and can drive the last lot negative.
                                // The exact fractions match Python beancount.
                                let sale_value = match price.kind {
                                    rustledger_core::PriceKind::Unit => amt.number * lot_units,
                                    rustledger_core::PriceKind::Total if !total_units.is_zero() => {
                                        amt.number * lot_units / total_units
                                    }
                                    rustledger_core::PriceKind::Total => Decimal::ZERO,
                                };
                                // Closing a SHORT (matched lot has negative units) inverts
                                // the roles: the lot's cost is the price the units were SOLD
                                // at when the short was opened (the proceeds), and the
                                // reduction's price is what is PAID to cover (the basis) —
                                // and the currencies follow suit.
                                let short_sale = m.units.number.is_sign_negative();
                                let (proceeds, cost_basis) = if short_sale {
                                    (
                                        Amount::new(lot_value, &cost.currency),
                                        Amount::new(sale_value, &amt.currency),
                                    )
                                } else {
                                    (
                                        Amount::new(sale_value, &amt.currency),
                                        Amount::new(lot_value, &cost.currency),
                                    )
                                };
                                gains.push(CapitalGain {
                                    account: posting.account.clone(),
                                    currency: units.currency.clone(),
                                    units: lot_units,
                                    cost_basis,
                                    proceeds,
                                    sale_date: txn.date,
                                    acquired_date: cost.date,
                                    short_sale,
                                });
                            }
                        }
                    }
                    // If not a reduction: fall through to augmentation code below
                }

                if let Some(rustledger_core::CostNumber::Total { value: total }) = cost_spec.number
                {
                    // Augmentation with total cost — convert to the
                    // post-booking `PerUnitFromTotal` shape:
                    //   `1.763 VIIIX {{300.00 USD}}` → derived per-unit
                    //   170.165… with total 300.00 preserved.
                    // The preserved total is load-bearing for
                    // precision-preserving residual math (#1026) —
                    // division-then-multiplication at the
                    // `rust_decimal` 28-digit ceiling loses precision.
                    if let Some(currency) = &cost_spec.currency
                        && !units.number.is_zero()
                    {
                        let per_unit = total / units.number.abs();
                        result.postings[idx].cost = Some(Box::new(CostSpec {
                            number: Some(rustledger_core::CostNumber::PerUnitFromTotal(
                                rustledger_core::BookedCost::new(per_unit, total, units.number),
                            )),
                            currency: Some(currency.clone()),
                            // Fill in transaction date if no date specified
                            date: cost_spec.date.or(Some(txn.date)),
                            label: cost_spec.label.clone(),
                            merge: cost_spec.merge,
                        }));
                        booked_indices.insert(idx);
                    }
                }

                // Fill in dates and currencies for augmentations (not already booked)
                if !booked_indices.contains(&idx) && cost_spec.number.is_some() {
                    // Cost spec has a number but may be missing date or currency
                    // Fill in missing parts from price annotation, other postings, and transaction date
                    let inferred_currency = cost_spec.currency.clone().or_else(|| {
                        // First try price annotation on this posting.
                        // `kind` (Unit vs Total) doesn't change the currency,
                        // so it's irrelevant here — we just want whatever
                        // currency the price names, complete or incomplete.
                        posting
                                .price
                                .as_ref()
                                .and_then(|p| p.amount.as_ref())
                                .and_then(|inc| inc.currency().map(Into::into))
                                // Then try inferring from other postings in the transaction
                                .or_else(|| crate::infer_cost_currency_from_postings(txn))
                    });

                    // Check if this is a reduction (opposite sign exists in inventory)
                    // Reductions get their date from matched lot, augmentations get txn date
                    let is_reduction = self.inventories.get(&posting.account).is_some_and(|inv| {
                        inv.is_reduced_by(units, ReductionScope::CostBearingOnly)
                    });

                    // Fill in date for augmentations only (not reductions)
                    let inferred_date = if is_reduction {
                        None // Reductions get their date from matched lot
                    } else {
                        cost_spec.date.or(Some(txn.date))
                    };

                    // Only update if we actually inferred something
                    if inferred_currency.is_some() || inferred_date.is_some() {
                        result.postings[idx].cost = Some(Box::new(CostSpec {
                            number: cost_spec.number,
                            currency: inferred_currency.or_else(|| cost_spec.currency.clone()),
                            date: inferred_date.or(cost_spec.date),
                            label: cost_spec.label.clone(),
                            merge: cost_spec.merge,
                        }));
                    }
                }
            }
        }

        // Apply posting expansions (replace single postings with multiple)
        // Build new postings Vec in one O(n) pass instead of O(n²) remove+insert
        if !expansions.is_empty() {
            // Sort expansions by index for forward iteration
            expansions.sort_by_key(|(idx, _)| *idx);

            let mut new_postings = Vec::with_capacity(
                result.postings.len() + expansions.iter().map(|(_, e)| e.len()).sum::<usize>(),
            );
            let mut expansion_iter = expansions.into_iter().peekable();

            for (idx, posting) in result.postings.into_iter().enumerate() {
                if expansion_iter
                    .peek()
                    .is_some_and(|(exp_idx, _)| *exp_idx == idx)
                {
                    // Replace this posting with expanded postings
                    let (_, expanded) = expansion_iter.next().unwrap();
                    new_postings.extend(expanded);
                } else {
                    // Keep original posting
                    new_postings.push(posting);
                }
            }
            result.postings = new_postings;
        }

        // NOTE: Price normalization (@@→@) is NOT done here to preserve exact
        // total prices for precise residual calculation. Call `normalize_prices()`
        // on the transaction after validation to convert total prices to per-unit.

        Ok(BookedTransaction {
            transaction: result,
            gains,
            booked_indices: booked_indices.into_iter().collect(),
        })
    }

    /// Apply ONE booked posting to the running inventories.
    ///
    /// The canonical "what does this posting do to an inventory" decision:
    /// resolve the account's booking method, then REDUCE against a lot or ADD a
    /// new one. Public because the query engine needs exactly this and was
    /// re-deriving a broken half of it — `Inventory::add` unconditionally, with
    /// no reduction branch at all (#1985). That looked right for FIFO/LIFO,
    /// where booking has already resolved the reduction's cost so its lot key
    /// matches an existing lot and `add` nets it by coincidence, and produced a
    /// dangling negative position under AVERAGE, where the reduction is booked
    /// at the merged average cost — a key belonging to no augmentation.
    ///
    /// # Errors
    ///
    /// [`BookingError`] when the reduction finds no matching lot, or an `add`
    /// overflows. Unlike [`Self::apply`] this is a SINGLE posting, so there is
    /// nothing to roll back — the caller owns transaction atomicity.
    ///
    /// Verify a carried `{*}` against the pool booking recorded (#2068).
    ///
    /// `{*}` is the one cost spec that is an OPERATION rather than a filter, so
    /// booking cannot resolve it into the lot it picked — the merged pool does
    /// not exist until the merge runs. The marker is carried instead, which
    /// means the booked posting re-executes the merge and is only meaningful
    /// against the inventory it was booked against. Applied to different state
    /// it would silently build a DIFFERENT pool.
    ///
    /// Booking records the per-unit cost of the pool it computed, so compare
    /// that against the pool this inventory would produce. Read-only: nothing
    /// has mutated when this reports, so the error needs no rollback to be
    /// honest about the state it leaves behind.
    ///
    /// Skips rather than reports when there is nothing recorded to compare
    /// against — a raw, never-booked `{*}` (`number: None`), a total or
    /// compound cost whose per-unit value is not knowable until units are, or
    /// cost-less lots where `{*}` degrades to AVERAGE. Errors from the merge
    /// plan itself are also skipped: the reduction raises them with its own
    /// message a moment later, and one function should own each message.
    fn verify_merge_precondition(&self, posting: &Posting) -> Result<(), BookingError> {
        let Some(spec) = posting.cost.as_deref() else {
            return Ok(());
        };
        if !spec.merge {
            return Ok(());
        }
        let (Some(units), Some(expected_number), Some(expected_currency)) = (
            posting.amount(),
            spec.number.and_then(|n| n.per_unit()),
            spec.currency.clone(),
        ) else {
            return Ok(());
        };
        let Some(inv) = self.inventories.get(&posting.account) else {
            return Ok(());
        };
        // Only a posting that will actually REDUCE runs the merge; an
        // augmentation carrying `{*}` is added, and comparing it against a pool
        // it never builds would reject a valid posting. Uses the canonical
        // predicate `realize_posting` itself consults, so the two cannot drift.
        //
        // This also keeps `apply`'s rollback guard sound: every posting this
        // can reject is a cost-bearing reduction, which is exactly what
        // `has_reduction` counts, so the undo log is open whenever the check
        // reports. Without the gate, a positive-units `{*}` posting could fail
        // where `rollback_needed` said failure was impossible — an assert, not
        // an error.
        let method = self.method_for(&posting.account);
        if !inv.is_booking_reduction(units, posting.cost.as_deref(), method) {
            return Ok(());
        }
        let Ok(Some(got)) = inv.merged_pool_cost(units) else {
            return Ok(());
        };

        let expected = rustledger_core::Amount::new(expected_number, expected_currency);
        if got == expected {
            return Ok(());
        }
        Err(convert_core_booking_error(
            rustledger_core::BookingError::MergeMismatch {
                currency: units.currency.clone(),
                expected,
                got,
            },
            &posting.account,
        ))
    }

    /// Replay one booked posting against this engine's inventories,
    /// **re-deriving** what it does rather than being told.
    ///
    /// # Which question this answers
    ///
    /// Two different questions run through this engine, and they are not the
    /// same computation done twice:
    ///
    /// - **Realize the booked ledger** — [`Self::apply`]. Every posting of a
    ///   transaction, against the real inventories, in booking's order. What
    ///   booking decided is authoritative, and a disagreement is a defect:
    ///   #2068 and #2070 were both this, and [`Self::apply`]'s `{*}`
    ///   precondition exists to catch a third.
    /// - **Replay a filtered subset** — this method. The query executor feeds
    ///   it whatever transactions a `WHERE` clause left, and asks what the
    ///   inventory would be if only those existed. Under AVERAGE or `{*}` that
    ///   is a genuinely different pool, and answering it with the whole
    ///   ledger's pool is the bug (#1985).
    ///
    /// So re-derivation here is the FEATURE, not a shortcut, and the
    /// disagreement [`Self::apply`] refuses to tolerate is exactly what this
    /// method is for. That is why the `{*}` precondition sits on
    /// [`Self::apply`] and not here.
    ///
    /// The two used to share one public entry point, distinguished only by
    /// which level a caller happened to call and a comment explaining the
    /// difference. Nothing stopped a filtered replay from being handed
    /// booking's answer, or the realize path from quietly re-deriving.
    ///
    /// # Precondition
    ///
    /// Same as [`Self::apply`]: the posting must already be booked.
    ///
    /// This is a precondition, not a check. A posting whose units are still
    /// elided is SKIPPED and reports success — booking is what fills them in,
    /// so replaying a ledger that never went through it produces empty
    /// balances rather than an error. Nothing in the workspace suite reaches
    /// that path, and the callers that exist all replay booked directives
    /// (`process` partitions failed transactions out before the query
    /// executor sees them), but a caller that skipped booking would be told
    /// nothing. Making it loud is a behavior change and belongs in its own
    /// commit, not a rename.
    pub fn replay_posting(
        &mut self,
        posting: &Posting,
        date: rustledger_core::NaiveDate,
    ) -> Result<(), BookingError> {
        self.realize_posting(posting, date)
    }

    /// Realize one booked posting as part of [`Self::apply`].
    ///
    /// Private on purpose. This is the per-posting half of the *realize*
    /// question, and a caller outside this engine has no business reaching it:
    /// realizing means [`Self::apply`], which applies a whole transaction and
    /// runs the `{*}` precondition first. Replaying a filtered stream means
    /// [`Self::replay_posting`], which is a different question. Leaving this
    /// public is what let the two blur together.
    fn realize_posting(
        &mut self,
        posting: &Posting,
        date: rustledger_core::NaiveDate,
    ) -> Result<(), BookingError> {
        let Some(IncompleteAmount::Complete(units)) = &posting.units else {
            // Booking fills these in, so reaching here means the caller skipped
            // it. Refusing beats the silent `Ok(())` this used to return, which
            // handed back empty balances for an unbooked ledger and said
            // nothing.
            return Err(BookingError::NotBooked {
                account: posting.account.clone(),
            });
        };
        // Resolve the per-account booking method before mutably borrowing the
        // inventories map.
        let method = self.method_for(&posting.account);
        // `make_mut` copies only when a caller is holding a snapshot of this
        // account. Booking on its own never is, so this is a plain `&mut`.
        let inv =
            std::sync::Arc::make_mut(self.inventories.entry(posting.account.clone()).or_default());

        // Reduction vs augmentation — the single source for this decision
        // (`Inventory::is_booking_reduction`), shared with the Late validator so
        // the two can't drift (including the #1182 NONE gate that previously had
        // to be maintained in both crates).
        if inv.is_booking_reduction(units, posting.cost.as_deref(), method) {
            // `reduce` only errors when the lot it would match is missing — a
            // "must book first" precondition violation.
            inv.reduce(units, posting.cost.as_deref(), method)
                .map_err(|e| convert_core_booking_error(e, &posting.account))?;
        } else {
            // Add to inventory via the canonical cost-resolve shared with the Late
            // validator, `build_balances`, and the query engine (see
            // `CostSpec::resolve`). `apply`'s callers book first, which fills the
            // inferred currency into `cost_spec.currency`; direct-`apply` tests use
            // explicit-currency fixtures, which need no inference.
            inv.add(Position::from_posting(units, posting.cost.as_deref(), date))
                .map_err(|e| {
                    convert_core_booking_error(
                        rustledger_core::BookingError::Overflow(e),
                        &posting.account,
                    )
                })?;
        }
        Ok(())
    }

    /// Whether any `Inventory::add` this transaction performs could overflow.
    ///
    /// Conservative: `true` means "cannot prove otherwise", never "will". A
    /// `false` lets [`Self::apply`] skip the rollback snapshot, which is pure
    /// cost for the overwhelming majority of transactions (#1897).
    ///
    /// Sound because `add` is the ONLY operation whose failure `apply` rolls
    /// back — `reduce` reports a missing lot, a caller precondition violation
    /// that is deliberately non-fatal, never `Overflow`.
    ///
    /// The headroom offered to each account is the absolute magnitude of the
    /// WHOLE transaction, not of that account's own postings. That over-states
    /// what any one account can consume, which is the safe direction, and it
    /// avoids grouping postings by account and currency — an allocation, which
    /// is the thing being removed.
    /// Whether `apply` must snapshot before mutating.
    ///
    /// Two reasons a posting can fail partway through, and both now abort the
    /// transaction, so both need the earlier postings rolled back:
    ///
    /// * an `add` that overflows (#1863) — [`Self::overflow_is_possible`];
    /// * a `reduce` with no matching lot, which since #1987 is an error rather
    ///   than something to ignore.
    ///
    /// The reduction half is checked separately because `overflow_is_possible`
    /// is about magnitudes and answers `true` for an unfilled posting only by
    /// coincidence. Relying on that coincidence would leave a filled-but-
    /// unbookable transaction (an ambiguous STRICT match with explicit
    /// amounts) mutating half its accounts and then returning `Err`.
    ///
    /// Kept as two predicates rather than folded into one so the #1897
    /// perf reasoning stays legible: snapshotting EVERY transaction cost 2.4x
    /// heap churn and 6% CPU, which is why neither half is unconditional.
    /// Reductions are a minority of transactions in a real ledger, so this
    /// widens the gate without reopening that regression.
    fn rollback_needed(&self, txn: &Transaction) -> bool {
        self.overflow_is_possible(txn) || self.has_reduction(txn)
    }

    /// Whether any posting could fail the way a reduction fails.
    ///
    /// Deliberately NOT [`Inventory::is_booking_reduction`], despite that being
    /// the canonical "is this a reduction" predicate: this one must hold for a
    /// transaction that has not been applied yet, and the inline comment below
    /// records the panic that reading current state caused. The header used to
    /// claim the opposite — it predates #2067's rewrite.
    fn has_reduction(&self, txn: &Transaction) -> bool {
        txn.postings.iter().any(|posting| {
            let Some(units) = posting.amount() else {
                // Unfilled: booking did not complete, so this transaction is
                // exactly the unbookable kind that needs the rollback.
                return true;
            };
            let method = self.method_for(&posting.account);
            if method == BookingMethod::None {
                // NONE accumulates every posting as an augmentation and never
                // matches a lot, so it cannot fail this way.
                return false;
            }
            // Deliberately NOT `is_booking_reduction` against the CURRENT
            // inventory. That asks whether this posting reduces something the
            // account holds *now*, and a transaction can create the very lot a
            // later posting then fails to reduce:
            //
            //     Assets:Stock   10 X {100.00 USD}
            //     Assets:Stock  -20 X {100.00 USD}
            //
            // Against an empty account that answered "no reduction", so no
            // rollback was prepared — and then the second posting failed and
            // the engine hit its own "the guard is unsound" assertion, turning
            // an ordinary bad ledger into a panic out of `rledger check`.
            //
            // Carrying a cost spec is what makes a posting able to reduce, and
            // that does not depend on state, so it cannot be falsified by the
            // transaction itself. Strictly more conservative: every posting
            // this used to catch carries a cost spec too.
            let Some(spec) = posting.cost.as_deref() else {
                return false;
            };
            // `{*}` fails in BOTH directions (#2068). It is checked against the
            // pool booking recorded, and a positive-units posting can be a
            // reduction too — closing a short reduces cost-bearing lots while
            // the sign test below says "augmentation". Left out, a rejected
            // merge on that shape reaches the rollback assert with no log
            // open, turning a reportable error into a panic. Sign-independent
            // and state-independent, so it cannot be falsified mid-transaction
            // either; only `{*}` postings widen the gate, which are rare enough
            // not to reopen the #1897 snapshot regression.
            spec.merge || units.number.is_sign_negative()
        })
    }

    fn overflow_is_possible(&self, txn: &Transaction) -> bool {
        let mut sum_abs = rustledger_core::Decimal::ZERO;
        for posting in &txn.postings {
            let Some(units) = posting.amount() else {
                // An unfilled posting means booking did not complete; do not
                // reason about arithmetic that has not been determined yet.
                return true;
            };
            match sum_abs.checked_add(units.number.abs()) {
                Some(next) => sum_abs = next,
                // The magnitudes alone exceed the range — exactly the case the
                // snapshot exists for.
                None => return true,
            }
        }

        !txn.postings.iter().all(|posting| {
            let Some(units) = posting.amount() else {
                return false;
            };
            // An account with no inventory yet starts from zero, so only the
            // transaction's own magnitudes matter, and `sum_abs` is in range.
            self.inventories
                .get(&posting.account)
                .is_none_or(|inv| inv.add_headroom_for(&units.currency, sum_abs))
        })
    }

    /// Apply a transaction's postings to the running inventories (update
    /// balances).
    ///
    /// # Precondition
    ///
    /// The transaction MUST already be booked — postings filled with complete
    /// units and resolved costs, as produced by [`Self::book_and_interpolate`]
    /// or the free [`book`](crate::book) function. The loader pipeline
    /// guarantees this ordering. A caller that CANNOT guarantee booked input
    /// must not derive a figure from this lot-matching realization — e.g. the
    /// returns engine values **net units at market** (`rustledger-returns`),
    /// never `apply`, so a re-merged booking-failed transaction cannot
    /// over-sell it.
    ///
    /// # Errors
    ///
    /// [`BookingError`] for arithmetic overflow (#1863) or for a reduction
    /// that finds no matching lot (#1987). Either way the transaction is rolled
    /// back whole: no posting of a failing transaction stays applied.
    ///
    /// The reduction case used to be `debug_assert!`-then-ignore, which made
    /// the two build profiles disagree about a user's ledger — debug panicked,
    /// release silently over-stated the holding. Reporting it is what lets a
    /// caller decide, which is the whole point of the precondition.
    pub fn apply(&mut self, txn: &Transaction) -> Result<(), BookingError> {
        // Snapshot every account this transaction touches, so an overflow
        // partway through cannot leave the earlier postings applied.
        //
        // Without this, a transaction the loader then DROPS (`run_booking`'s
        // `failed_indices`) still moved the running balances, and every later
        // transaction booked against the corruption — silently wrong reports
        // and lot matching, which is the outcome this whole change exists to
        // prevent. `Inventory`'s positions are an `imbl::Vector`, so each
        // clone is O(1) structural sharing.
        //
        // `None` records an account that did not exist yet, which must be
        // REMOVED rather than restored to an empty inventory: `apply` is the
        // only thing that creates entries here, and leaving a phantom empty
        // one changes `into_inventories`' output.
        // ...but only when overflow is actually possible. `imbl::Vector`'s clone
        // is O(1), yet it leaves the chunks SHARED, so the next `Inventory::add`
        // pays `Arc::make_mut` and copies a 64-`Position` chunk. Snapshotting
        // every transaction cost 2.4x heap churn and 6% CPU on the nightly
        // profile to guard a case needing values near 7.9e28 (#1897).
        // Record what changes rather than copying what exists.
        //
        // This used to clone every touched account's `Inventory`, justified by
        // a comment saying the positions were an `imbl::Vector` whose clone is
        // O(1). That stopped being true at #2056, when booking's backing became
        // owned — so the guard silently turned into an O(lots) copy per touched
        // account per transaction, and grew into the largest superlinear term
        // left in the pipeline. Ablating it was worth 56% of a
        // 20,000-transaction `investment` run and took the curve from 19.4x to
        // 9.9x per 10x of input.
        //
        // A reduction touches one or two lots; the undo log is proportional to
        // that rather than to what the account holds.
        //
        // Accounts that do not exist yet are tracked separately: `apply` is the
        // only thing that creates entries, and a failed transaction must leave
        // no phantom empty inventory, which would change `into_inventories`'
        // output.
        let recording = self.rollback_needed(txn);
        let mut created: Vec<rustledger_core::Account> = Vec::new();
        if recording {
            // Deduplicate the accounts FIRST, then open one log each.
            //
            // Skipping `begin_undo` when a log is already open would handle a
            // repeated account too, but it would also silently adopt a log left
            // over from an earlier transaction and roll back to the wrong
            // point. `begin_undo` asserts it is not called twice; that
            // assertion only does its job if this never calls it twice for a
            // legitimate reason.
            let mut touched: Vec<&rustledger_core::Account> = Vec::new();
            for posting in &txn.postings {
                if !touched.contains(&&posting.account) {
                    touched.push(&posting.account);
                }
            }
            for account in touched {
                match self.inventories.get_mut(account) {
                    Some(inv) => std::sync::Arc::make_mut(inv).begin_undo(),
                    None => created.push(account.clone()),
                }
            }
        }

        // Roll back only the accounts this transaction touched, found through
        // its postings rather than by sweeping every inventory the engine
        // holds. `undo_is_open` guards the repeat visit when a transaction has
        // two postings on one account.
        let rollback =
            |engine: &mut Self, txn: &Transaction, created: &[rustledger_core::Account]| {
                for posting in &txn.postings {
                    if let Some(inv) = engine.inventories.get_mut(&posting.account)
                        && inv.undo_is_open()
                    {
                        std::sync::Arc::make_mut(inv).rollback_undo();
                    }
                }
                for account in created {
                    engine.inventories.remove(account);
                }
            };

        // Accounts this transaction has already mutated. Booking books every
        // posting against the inventory as it stood BEFORE the transaction,
        // while `apply` mutates sequentially — so once an earlier posting has
        // touched an account, the pool a later `{*}` sees is legitimately not
        // the pool booking recorded, and comparing them would reject a valid
        // ledger (a buy and a `{*}` sale in one transaction). The recorded cost
        // is only comparable against the state booking actually saw.
        let mut touched: rustc_hash::FxHashSet<&rustledger_core::Account> =
            rustc_hash::FxHashSet::default();

        // Apply this transaction's cost-bearing REDUCTIONS before its other
        // postings (#2070).
        //
        // `book` decides every posting against the inventory as it stood before
        // the transaction, threaded with that transaction's earlier reductions
        // and NOTHING else — the same rule beancount's `book_reductions`
        // follows, for the same reason it states: an augmentation's cost may
        // still need interpolation, so it is not a resolvable position yet.
        //
        // `apply` mutated in posting order, so a buy earlier in the transaction
        // was already in the account when a later reduction ran. For a spec
        // naming a concrete lot that changes nothing — the lot is found either
        // way. For the two that RE-DERIVE a pool, `AVERAGE` and `{*}`, it moves
        // the pool, and the engine then disagreed with the journal about cost
        // basis. Silently: `book_apply_agreement` shrinks it to holding 40 @
        // 100, then buying 1 @ 101 and `{*}`-selling 1 in one transaction.
        //
        // Classifying against the PRE-transaction inventory (rather than
        // threading, as `book` does) decides ORDER only. `realize_posting`
        // classifies each posting itself against live state, so a posting
        // sorted into the wrong bucket still does the right thing; and the
        // disagreement can only run one way, since threading removes units and
        // so can only make a posting look less like a reduction. Reductions
        // keep their relative order, which is what a second reduction on one
        // account depends on.
        // Classified ONCE, into a bitmask rather than two vectors: a
        // transaction has a handful of postings, and this path is hot enough
        // that #2061/#2067 exist to keep allocations out of it.
        //
        // `realize_posting` asks the same predicate again, which is not waste to
        // eliminate: it asks about LIVE state, deliberately, so that a posting
        // reclassifies as the transaction proceeds. This asks about the
        // pre-transaction state, which is the question `book` answered. Sharing
        // one answer between them would reintroduce the divergence. The call is
        // a hash lookup either way — `is_reduced_by` has read per-currency sign
        // counts since #2062, not scanned positions.
        let mut reduces: u64 = 0;
        for (i, posting) in txn.postings.iter().enumerate().take(64) {
            let is_reduction = posting.cost.is_some()
                && posting.amount().is_some_and(|units| {
                    self.inventories.get(&posting.account).is_some_and(|inv| {
                        inv.is_booking_reduction(
                            units,
                            posting.cost.as_deref(),
                            self.method_for(&posting.account),
                        )
                    })
                });
            if is_reduction {
                reduces |= 1 << i;
            }
        }
        // A transaction with more than 64 postings keeps source order beyond
        // the 64th. Ordering is an optimization of WHICH state a re-derived
        // pool sees, not a correctness requirement for the postings themselves,
        // so degrading to source order is safe — and `realize_posting` still
        // classifies each posting itself.
        let reduces_first = (0..txn.postings.len()).filter(|i| *i < 64 && reduces & (1 << i) != 0);
        let then_the_rest = (0..txn.postings.len()).filter(|i| *i >= 64 || reduces & (1 << i) == 0);

        for posting in reduces_first.chain(then_the_rest).map(|i| &txn.postings[i]) {
            // EVERY posting failure aborts the transaction and rolls back —
            // overflow and failed reduction alike (#1987).
            //
            // A failed reduction used to be `debug_assert!` then ignored. That
            // meant the two build profiles disagreed about what happens to a
            // user's ledger: debug PANICKED (`report balances` exited 101 on an
            // ambiguous STRICT match), while release dropped the reduction and
            // carried on — reporting 20 AAPL for an account holding 15. A
            // silently over-stated holding is the worse of the two, and it was
            // the one real users got.
            //
            // The precondition is unchanged and still documented: `apply` wants
            // booked input. What changed is that violating it is now reported
            // instead of being asserted in one profile and ignored in the
            // other. Callers already handle this — `book()` records the error
            // and marks the transaction failed, the wasm entry point checks
            // `is_ok()`, and the CLI refuses to derive a figure at all.
            // A carried `{*}` re-executes the merge (#2068), so verify the
            // pool it will build against the one booking recorded BEFORE
            // anything mutates. Checked here rather than per-posting for
            // two reasons: `replay_posting` replays a FILTERED transaction
            // stream for the query executor, where a different pool is the
            // correct answer and not a defect; and a precondition that
            // reports after mutating would depend on rollback to stay honest.
            //
            // Per posting rather than as a pre-pass over the transaction: an
            // earlier posting of this same transaction can legitimately change
            // the pool a later `{*}` sees.
            let checked = if touched.contains(&posting.account) {
                Ok(())
            } else {
                self.verify_merge_precondition(posting)
            };
            touched.insert(&posting.account);
            if let Err(e) = checked.and_then(|()| self.realize_posting(posting, txn.date)) {
                // `snapshot` is `Some` whenever this arm is reachable:
                // `rollback_needed` covers every way this can fail. Overflow
                // and a failed reduction were the original two; `NotBooked` is
                // a third, and both predicates already return `true` for a
                // posting with no amount — deliberately, since an unfilled
                // posting means booking did not complete. Restoring
                // nothing would be silent corruption — precisely the bug being
                // fixed — so a violated guard must be loud rather than quiet.
                assert!(
                    recording,
                    "rollback_needed() returned false but posting application \
                     failed ({e}): the guard is unsound and the earlier \
                     postings of this transaction cannot be rolled back"
                );
                rollback(self, txn, &created);
                return Err(e);
            }
        }

        // Committed: drop the logs and compact the touched accounts.
        // Compaction moved here from `reduce` because it renumbers slots, which
        // an open undo log refers to — this is the one point where no log is
        // open and no rollback can still be required.
        //
        // Skipped entirely when nothing was recorded. Only a reduction creates
        // tombstones, `add` never does, and any reduction makes
        // `rollback_needed` true — so `!recording` means this transaction left
        // nothing to compact and nothing to commit. Without the guard, ledgers
        // that never book a cost spec pay a per-posting map lookup for nothing.
        if !recording {
            return Ok(());
        }
        // Deduplicated: a transaction with several postings on one account
        // would otherwise re-enter `compact_if_sparse` for it, and compaction
        // is O(slots) when it fires.
        let mut committed: Vec<&rustledger_core::Account> = Vec::new();
        for posting in &txn.postings {
            if committed.contains(&&posting.account) {
                continue;
            }
            committed.push(&posting.account);
            if let Some(inv) = self.inventories.get_mut(&posting.account) {
                let inv = std::sync::Arc::make_mut(inv);
                if inv.undo_is_open() {
                    inv.commit_undo();
                }
                inv.compact_if_sparse();
            }
        }
        Ok(())
    }

    /// Book and interpolate a transaction.
    ///
    /// This fills in empty cost specs, then interpolates any missing amounts. Thin
    /// wrapper over [`Self::book_and_interpolate_with_gains`] that discards the
    /// realized gains — a single implementation so the two cannot drift.
    pub fn book_and_interpolate(
        &self,
        txn: &Transaction,
    ) -> Result<InterpolationResult, BookingError> {
        Ok(self.book_and_interpolate_with_gains(txn)?.0)
    }

    /// Book, interpolate and apply `txn` in one step, mutating it in place.
    ///
    /// The three are one step because they share a failure rule: a transaction
    /// that fails any of them is set aside by `run_booking` and merged back
    /// into the ledger by `finalize`, so a user sees it, and it must read as
    /// the author wrote it. Splitting them would hand the caller a
    /// half-interpolated transaction to put back.
    ///
    /// This exists so the common path stops cloning. `book_and_interpolate_
    /// with_gains` hands back an owned transaction the caller assigns over the
    /// original, so every transaction paid a full clone and the original's
    /// drop — 6.9M and 6.0M instructions on a 10,000-transaction ledger, 7.7%
    /// of a warm `check` — to keep a rare failure undoable. Interpolation
    /// touches one or two postings, so `Rollback` saves those instead.
    ///
    /// # Errors
    ///
    /// Any booking, interpolation or apply failure. On every one of them
    /// `txn` is left exactly as it was passed in.
    pub fn book_interpolate_apply(
        &mut self,
        txn: &mut Transaction,
    ) -> Result<Vec<CapitalGain>, BookingError> {
        // Fast path, as in `book_and_interpolate_with_gains`: with no cost
        // specs `book` is an identity, so interpolate the transaction itself.
        if !txn.postings.iter().any(|p| p.cost.is_some()) {
            let tolerances = crate::transaction_tolerances(txn, &self.tolerance.options());
            let undo = crate::interpolate::interpolate_in_place_undoable(txn, &tolerances)?;
            if let Err(e) = self.apply(txn) {
                undo.restore(txn);
                return Err(e);
            }
            return Ok(Vec::new());
        }
        // Slow path: `book` already produces an owned transaction, so work on
        // that and commit only once apply has succeeded. Nothing needs undoing
        // — `txn` is not touched until the end.
        let tolerances = crate::transaction_tolerances(txn, &self.tolerance.options());
        let booked = self.book(txn)?;
        let mut candidate = booked.transaction;
        let _ = crate::interpolate::interpolate_in_place_undoable(&mut candidate, &tolerances)?;
        self.apply(&candidate)?;
        *txn = candidate;
        Ok(booked.gains)
    }

    /// Book and interpolate a transaction, also returning the per-lot
    /// [`CapitalGain`]s `book` computed for it.
    ///
    /// The loader's booking pass uses this so realized gains are captured **once**,
    /// during canonical booking (in booking order, with the ledger's own method,
    /// before `@@` normalization), and exposed on the `Ledger` — rather than each
    /// consumer re-booking the stream and re-deriving them.
    ///
    /// # Errors
    /// Returns a [`BookingError`] if the transaction cannot be booked (e.g. an
    /// ambiguous or unmatched cost reduction) or interpolated (unresolved elided
    /// amounts).
    pub fn book_and_interpolate_with_gains(
        &self,
        txn: &Transaction,
    ) -> Result<(InterpolationResult, Vec<CapitalGain>), BookingError> {
        // Fast path: with no cost specs, `book` is an identity that only clones
        // `txn` verbatim (profiling flagged that clone as ~6 MB / 10k txns — the
        // common case), and it realizes no gains. In the fast path `book(txn)`'s
        // transaction *equals* `txn`, so `interpolate(&book(txn).transaction)` is
        // provably identical to `interpolate(txn)`. Interpolate the original
        // directly, skip the clone, and return no gains (an empty `Vec` does not
        // allocate).
        if !txn.postings.iter().any(|p| p.cost.is_some()) {
            return Ok((
                interpolate_with_tolerances(txn, &self.tolerance.options())?,
                Vec::new(),
            ));
        }
        // Tolerances come from the PRE-booking transaction, matching
        // beancount's `booking_full.book`, which infers them from
        // `entry.postings` before resolving any `{}`. Deriving them after
        // booking would feed the lot's 26-digit per-unit cost into the
        // tolerance and silently disable quantization — see
        // `interpolate_with_tolerance_map`.
        let tolerances = crate::transaction_tolerances(txn, &self.tolerance.options());
        // First book (fill in costs + compute gains), then interpolate amounts.
        let booked = self.book(txn)?;
        let result = interpolate_with_tolerance_map(&booked.transaction, &tolerances)?;
        Ok((result, booked.gains))
    }
}

/// Convert a core inventory `BookingError` into the booking-layer error,
/// attaching the account context that the core layer doesn't carry.
///
/// All inventory-level failures funnel into a single
/// [`BookingError::Inventory`] variant. The user-facing wording lives in the
/// `Display` impl on [`AccountedBookingError`] so it cannot drift between
/// the booking layer and the validator (#748 / #750).
fn convert_core_booking_error(
    err: rustledger_core::BookingError,
    account: &rustledger_core::Account,
) -> BookingError {
    BookingError::Inventory(err.with_account(account.clone()))
}

/// Book and interpolate a list of transactions.
///
/// This processes transactions in order, tracking inventory to enable
/// proper lot matching and capital gains calculation.
pub fn book_transactions(
    transactions: &[Transaction],
    method: BookingMethod,
) -> Vec<Result<InterpolationResult, BookingError>> {
    let mut engine = BookingEngine::with_method(method);
    let mut results = Vec::with_capacity(transactions.len());

    for txn in transactions {
        // An apply-time overflow belongs to THIS transaction's result, so a
        // caller iterating results sees it rather than silently getting a
        // running balance that never absorbed the posting (#1863).
        let result = engine.book_and_interpolate(txn).and_then(|interpolated| {
            // Apply the booked transaction (with filled-in costs), not the original
            engine.apply(&interpolated.transaction)?;
            Ok(interpolated)
        });
        results.push(result);
    }

    results
}

/// Outcome of booking an entire ledger in one shot — see [`book`].
#[derive(Debug, Clone)]
pub struct LedgerBookResult {
    /// Successfully booked directives, in the **original input order**.
    /// Every `Transaction` has its cost specs filled and elided amounts
    /// interpolated; all other directive kinds pass through unchanged.
    pub booked: Vec<Directive>,
    /// Directives whose `Transaction` failed to book, in original input
    /// order, paired with the error. They are left in their pre-booking
    /// shape so a caller can still surface the user's original input.
    pub failed: Vec<(Directive, BookingError)>,
}

/// Book and interpolate every transaction in a ledger in one shot.
///
/// This is the standalone equivalent of the loader's internal booking
/// pass. Transactions are processed in **booking order** — sorted by
/// `(date, priority)`, with same-key transactions left in the caller's order
/// — so lot matching and capital-gains tracking observe inventory in the
/// same sequence Python does (#2093), while
/// the returned [`LedgerBookResult::booked`] / [`LedgerBookResult::failed`]
/// vectors preserve the caller's **original input order**. Non-transaction
/// directives pass through untouched. Per-account booking methods declared
/// via `Open ... "METHOD"` are honored; `method` is the fallback for
/// accounts that declare none.
///
/// Booking is a pure function of its inputs, so calling it twice on the
/// same `(directives, method)` yields equal results — this is the booking
/// half of the #1235 pipeline-boundary invariants.
#[must_use]
pub fn book(directives: &[Directive], method: BookingMethod) -> LedgerBookResult {
    let mut engine = BookingEngine::with_method(method);
    engine.register_account_methods(directives.iter());

    // Stable sort into booking order. Display order — `(date, priority,
    // file position)` — is already encoded in the input's positional order,
    // and a stable sort keeps that as the tiebreak.
    let mut order: Vec<usize> = (0..directives.len()).collect();
    order.sort_by_key(|&i| rustledger_core::booking_sort_key(&directives[i]));

    // Book in booking order, recording each transaction's outcome against
    // its original index so the result can be reassembled in input order.
    let mut booked_txns: Vec<Option<Transaction>> = directives.iter().map(|_| None).collect();
    let mut booking_errors: Vec<Option<BookingError>> = directives.iter().map(|_| None).collect();
    for &i in &order {
        if let Directive::Transaction(txn) = &directives[i] {
            match engine.book_and_interpolate(txn) {
                Ok(result) => {
                    // Apply the booked transaction (filled-in costs), not
                    // the original, so subsequent lot matching is correct.
                    match engine.apply(&result.transaction) {
                        Ok(()) => booked_txns[i] = Some(result.transaction),
                        Err(e) => booking_errors[i] = Some(e),
                    }
                }
                Err(e) => booking_errors[i] = Some(e),
            }
        }
    }

    // Reassemble in original input order, partitioning failures out.
    let mut booked = Vec::with_capacity(directives.len());
    let mut failed = Vec::new();
    for (i, directive) in directives.iter().enumerate() {
        if let Some(e) = booking_errors[i].take() {
            failed.push((directive.clone(), e));
        } else if let Some(txn) = booked_txns[i].take() {
            booked.push(Directive::Transaction(txn));
        } else {
            booked.push(directive.clone());
        }
    }

    LedgerBookResult { booked, failed }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;
    use rustledger_core::{NaiveDate, Posting, PriceAnnotation};

    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
        rustledger_core::naive_date(year, month, day).unwrap()
    }

    #[test]
    fn test_book_simple_buy() {
        let mut engine = BookingEngine::new();

        // Buy 10 AAPL at $150
        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(150.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-1500.00), "USD"),
            ));

        engine.apply(&buy).expect("fixture fits in Decimal");

        // Check inventory
        let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
        assert_eq!(inv.units("AAPL"), dec!(10));
    }

    #[test]
    fn test_reduction_carries_matched_lot_label() {
        // #1666: reducing a labeled lot must carry that lot's label onto the
        // reduction posting so it nets against the augmenting lot, instead of
        // leaving a phantom unlabeled negative lot in the holdings view.
        let mut engine = BookingEngine::new();

        let buy = |label: &str| {
            let mut cost = CostSpec::empty()
                .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(10) })
                .with_currency("USD");
            cost.label = Some(label.to_string());
            Transaction::new(date(2020, 2, 1), "buy")
                .with_synthesized_posting(
                    Posting::new("Assets:S", Amount::new(dec!(10), "X")).with_cost(cost),
                )
                .with_synthesized_posting(Posting::new(
                    "Assets:Cash",
                    Amount::new(dec!(-100), "USD"),
                ))
        };
        engine
            .apply(&buy("lot-a"))
            .expect("fixture fits in Decimal");
        engine
            .apply(&buy("lot-b"))
            .expect("fixture fits in Decimal");

        // Sell 5 X explicitly from lot-b.
        let mut sell_cost = CostSpec::empty()
            .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(10) })
            .with_currency("USD");
        sell_cost.label = Some("lot-b".to_string());
        let sell = Transaction::new(date(2020, 4, 1), "sell from lot-b")
            .with_synthesized_posting(
                Posting::new("Assets:S", Amount::new(dec!(-5), "X")).with_cost(sell_cost),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(50), "USD")));

        let result = engine
            .book_and_interpolate(&sell)
            .expect("sell should book against lot-b");
        let label = result.transaction.postings[0]
            .cost
            .as_ref()
            .and_then(|c| c.label.clone());
        assert_eq!(
            label.as_deref(),
            Some("lot-b"),
            "reduction posting must carry the matched lot's label (#1666)"
        );
    }

    #[test]
    fn test_book_sell_with_gain() {
        let mut engine = BookingEngine::new();

        // Buy 10 AAPL at $150
        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(150.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-1500.00), "USD"),
            ));

        engine.apply(&buy).expect("fixture fits in Decimal");

        // Sell 5 AAPL at $175 with empty cost (needs lot matching)
        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
                    .with_cost(CostSpec::empty()) // Empty - needs lot matching
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(875.00), "USD"),
            ))
            .with_synthesized_posting(Posting::auto("Income:CapitalGains")); // Elided

        // Check inventory before sell
        let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
        eprintln!("Inventory before sell: {inv:?}");

        let booked = engine.book(&sell).unwrap();
        eprintln!(
            "Booked: gains={:?}, indices={:?}",
            booked.gains, booked.booked_indices
        );
        eprintln!("Booked transaction: {:?}", booked.transaction);

        // Check that gain was calculated
        assert_eq!(
            booked.gains.len(),
            1,
            "Expected 1 gain, got {:?}",
            booked.gains
        );
        let gain = &booked.gains[0];
        // Gain = 5 * (175 - 150) = 125
        assert_eq!((gain.proceeds.number - gain.cost_basis.number), dec!(125));
    }

    #[test]
    fn test_book_with_total_cost() {
        let mut engine = BookingEngine::new();

        // Buy 1.763 VIIIX with total cost of 300 USD (like healthequity file)
        let buy = Transaction::new(date(2016, 1, 16), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(1.763), "VIIIX")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::Total {
                            value: dec!(300.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-300.00), "USD"),
            ));

        engine.apply(&buy).expect("fixture fits in Decimal");

        // Check inventory
        let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
        eprintln!("Inventory after total cost buy: {inv:?}");
        assert_eq!(inv.units("VIIIX"), dec!(1.763));

        // Check cost was calculated correctly (300/1.763 ≈ 170.16)
        let pos = inv.positions().next().unwrap();
        assert!(pos.cost.is_some(), "Expected cost on position");
        eprintln!("Position cost: {:?}", pos.cost);
    }

    #[test]
    fn test_book_partial_wildcard_reduction_of_total_cost_lot() {
        // #2048: partial `{}` reduction of a `{{total-cost}}` lot must preserve
        // the exact stored lot cost rather than re-dividing `(take * c) / take`,
        // which loses 96-bit decimal precision and fails subsequent application.
        let mut engine = BookingEngine::new();

        // Buy 0.923641233 ETH {{590645.78 CLP}}
        let mut buy_cost = CostSpec::empty()
            .with_number(rustledger_core::CostNumber::Total {
                value: dec!(590645.78),
            })
            .with_currency("CLP");
        buy_cost.label = Some("eth-batch-1".to_string());
        let buy = Transaction::new(date(2017, 12, 3), "buy with total cost")
            .with_synthesized_posting(
                Posting::new("Assets:ETH", Amount::new(dec!(0.923641233), "ETH"))
                    .with_cost(buy_cost),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:CLP",
                Amount::new(dec!(-590645.78), "CLP"),
            ));

        engine.apply(&buy).expect("buy fits in Decimal");

        // Partial sell 1: -0.02 ETH {} (failed on base: E4001 NoMatchingLot)
        let sell1 = Transaction::new(date(2017, 12, 29), "partial sell 1")
            .with_synthesized_posting(
                Posting::new("Assets:ETH", Amount::new(dec!(-0.02), "ETH"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(Posting::new("Assets:CLP", Amount::new(dec!(100000), "CLP")))
            .with_synthesized_posting(Posting::auto("Income:PnL"));

        let booked1 = engine
            .book_and_interpolate(&sell1)
            .expect("booking sell 1 should succeed");

        assert_eq!(
            booked1.transaction.postings[0]
                .cost
                .as_ref()
                .and_then(|c| c.label.as_deref()),
            Some("eth-batch-1"),
            "matched lot label must be preserved on single-lot reduction"
        );

        engine
            .apply(&booked1.transaction)
            .expect("applying booked partial reduction 1 must succeed");

        // Partial sell 2: -0.029 ETH {} (also failed on base)
        let sell2 = Transaction::new(date(2018, 1, 15), "partial sell 2")
            .with_synthesized_posting(
                Posting::new("Assets:ETH", Amount::new(dec!(-0.029), "ETH"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(Posting::new("Assets:CLP", Amount::new(dec!(150000), "CLP")))
            .with_synthesized_posting(Posting::auto("Income:PnL"));

        let booked2 = engine
            .book_and_interpolate(&sell2)
            .expect("booking sell 2 should succeed");

        engine
            .apply(&booked2.transaction)
            .expect("applying booked partial reduction 2 must succeed");

        let inv = engine.inventory(&"Assets:ETH".into()).unwrap();
        assert_eq!(inv.units("ETH"), dec!(0.874641233));
    }

    #[test]
    fn test_book_total_cost_then_sell() {
        // Test that book() correctly handles total cost syntax and preserves
        // full precision for accurate capital gains calculation.
        let mut engine = BookingEngine::new();

        // Buy 1.763 VIIIX with total cost {{300.00 USD}}
        let buy = Transaction::new(date(2016, 1, 16), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(1.763), "VIIIX")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::Total {
                            value: dec!(300.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-300.00), "USD"),
            ));

        // Use book() to test the booking path with total cost
        let booked_buy = engine.book(&buy).unwrap();
        engine
            .apply(&booked_buy.transaction)
            .expect("fixture fits in Decimal");

        // Check that per-unit cost was calculated (300/1.763)
        let buy_posting = &booked_buy.transaction.postings[0];
        assert!(buy_posting.cost.is_some());
        let cost_spec = buy_posting.cost.as_deref().unwrap();
        // Booking should have converted the user-written Total into
        // the post-booking PerUnitFromTotal shape — the per-unit value
        // is computed for lot tracking and the total is preserved for
        // exact residual math.
        assert!(matches!(
            cost_spec.number,
            Some(rustledger_core::CostNumber::PerUnitFromTotal(_))
        ));

        // Sell all shares at $191 per unit
        let sell = Transaction::new(date(2016, 6, 15), "Sell stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-1.763), "VIIIX"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(191.00), "USD"))),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(336.73), "USD"), // 1.763 * 191 = 336.733
            ))
            .with_synthesized_posting(Posting::auto("Income:CapitalGains"));

        let booked_sell = engine.book(&sell).unwrap();

        // Capital gain should be: 336.73 - 300.00 = 36.73
        // With full precision preserved, this should be accurate
        assert_eq!(booked_sell.gains.len(), 1);
        let gain = &booked_sell.gains[0];
        // The gain should be close to 36.73 (sale proceeds - cost basis)
        // Sale: 1.763 * 191 = 336.733, Cost: 300.00, Gain ≈ 36.73
        eprintln!(
            "Capital gain: {:?}",
            gain.proceeds.number - gain.cost_basis.number
        );
    }

    #[test]
    fn test_cost_spec_currency_inference() {
        let mut engine = BookingEngine::new();

        // SELLOPT: -1 AAPL {40.0} @ 0.4 USD — the cost has a number (40.0) but no
        // cost currency. Booking infers it from the price annotation and fills it
        // *into* the cost spec, so by the time `apply` runs the currency is already
        // resolved. The production pipeline books before applying, so this drives
        // that real `book_and_interpolate` → `apply` path rather than calling
        // `apply` standalone.
        let sell = Transaction::new(date(2022, 6, 17), "SELLOPT")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-1), "AAPL"))
                    .with_cost(
                        CostSpec::empty().with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(40.0),
                        }),
                    )
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(0.4), "USD"))),
            )
            .with_synthesized_posting(Posting::new("Assets:Stock", Amount::new(dec!(40.0), "USD")));

        let booked = engine
            .book_and_interpolate(&sell)
            .expect("booking should succeed");
        engine
            .apply(&booked.transaction)
            .expect("fixture fits in Decimal");

        let inv = engine.inventory(&"Assets:Stock".into()).unwrap();

        // The AAPL position carries cost with the price-inferred USD currency.
        let aapl_pos = inv
            .positions()
            .find(|p| p.units.currency.as_ref() == "AAPL")
            .expect("Should have AAPL position");

        assert!(aapl_pos.cost.is_some(), "AAPL position should have cost");
        let cost = aapl_pos.cost.as_ref().unwrap();
        assert_eq!(cost.currency.as_ref(), "USD", "Cost currency should be USD");
        assert_eq!(cost.number, dec!(40.0), "Cost number should be 40.0");
    }

    #[test]
    fn test_booking_engine_with_method() {
        // Test that with_method creates engine with specified booking method
        let engine = BookingEngine::with_method(BookingMethod::Lifo);
        assert!(engine.inventories.is_empty());

        // Also test default is FIFO
        let default_engine = BookingEngine::new();
        assert!(default_engine.inventories.is_empty());
    }

    #[test]
    fn test_book_sell_with_total_price() {
        let mut engine = BookingEngine::new();

        // Buy 10 AAPL at $150
        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(150.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-1500.00), "USD"),
            ));

        engine.apply(&buy).expect("fixture fits in Decimal");

        // Sell 5 AAPL with total price annotation (not per-unit)
        // Total price = $875 for 5 shares = $175/share
        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::total(Amount::new(dec!(875.00), "USD"))),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(875.00), "USD"),
            ))
            .with_synthesized_posting(Posting::auto("Income:CapitalGains"));

        let booked = engine.book(&sell).unwrap();

        // Check that gain was calculated correctly
        // Gain = 875 - (5 * 150) = 875 - 750 = 125
        assert_eq!(booked.gains.len(), 1, "Expected 1 gain");
        let gain = &booked.gains[0];
        assert_eq!((gain.proceeds.number - gain.cost_basis.number), dec!(125));
    }

    #[test]
    fn test_book_transactions_multiple() {
        // Buy 10 AAPL at $150
        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(150.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-1500.00), "USD"),
            ));

        // Sell 5 AAPL
        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(875.00), "USD"),
            ))
            .with_synthesized_posting(Posting::auto("Income:CapitalGains"));

        let transactions = vec![buy, sell];
        let results = book_transactions(&transactions, BookingMethod::Fifo);

        assert_eq!(results.len(), 2);
        assert!(results[0].is_ok());
        assert!(results[1].is_ok());
    }

    /// Issue #1705: an augmenting `{}` lot gets its cost inferred from the
    /// residual, and a later `{}` reduction of that lot then books cleanly
    /// (previously the reduction hit a spurious "2 unknowns" error because
    /// the augmenting lot carried no cost basis to match).
    #[test]
    fn test_augmenting_empty_cost_then_reduce() {
        // buy: 1000 USD {} against -900 EUR  → lot booked at 0.90 EUR/unit
        let buy = Transaction::new(date(2024, 1, 2), "buy USD")
            .with_synthesized_posting(
                Posting::new("Assets:Broker", Amount::new(dec!(1000), "USD"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-900), "EUR")));

        // sell: -100 USD {} + 95 EUR + PnL residual  → reduce at 0.90, PnL = -5 EUR
        let sell = Transaction::new(date(2024, 1, 5), "sell part")
            .with_synthesized_posting(
                Posting::new("Assets:Broker", Amount::new(dec!(-100.00), "USD"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(Posting::new("Expenses:Misc", Amount::new(dec!(95), "EUR")))
            .with_synthesized_posting(Posting::auto("Income:Trading:PnL"));

        let results = book_transactions(&[buy, sell], BookingMethod::Fifo);
        assert_eq!(results.len(), 2);

        let buy_txn = &results[0].as_ref().expect("buy should book").transaction;
        let buy_cost = buy_txn.postings[0].cost.as_ref().expect("cost present");
        assert_eq!(
            buy_cost
                .number
                .as_ref()
                .and_then(rustledger_core::CostNumber::per_unit),
            Some(dec!(0.90))
        );
        assert_eq!(buy_cost.currency.as_deref(), Some("EUR"));

        // The `{}` reduction booked cleanly and the PnL residual solved to -5 EUR.
        let sell_txn = &results[1].as_ref().expect("sell should book").transaction;
        let pnl = sell_txn
            .postings
            .iter()
            .find(|p| p.account.as_str() == "Income:Trading:PnL")
            .expect("PnL posting present");
        let pnl_amount = pnl
            .units
            .as_ref()
            .and_then(|u| u.as_amount())
            .expect("PnL filled");
        assert_eq!(pnl_amount.number, dec!(-5));
        assert_eq!(pnl_amount.currency.as_str(), "EUR");
    }

    #[test]
    fn test_book_augmentation_not_reduction() {
        let mut engine = BookingEngine::new();

        // First, add existing inventory with positive AAPL
        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(150.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-1500.00), "USD"),
            ));

        engine.apply(&buy).expect("fixture fits in Decimal");

        // Now try to book another buy (augmentation, not reduction)
        // This has empty cost but same sign as inventory, so it's not a reduction
        let another_buy = Transaction::new(date(2024, 2, 15), "Buy more")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(5), "AAPL"))
                    .with_cost(CostSpec::empty()), // Empty cost but augmentation
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-750.00), "USD"),
            ));

        // History: originally pinned "should not error - just skip lot
        // matching" (booked the lot UNCOSTED — silent corruption); the
        // #1708 interim guard made it a loud error; the #1705 solver now
        // books it at the residual-inferred cost like beancount. The
        // engine-level book() leaves the {} spec for interpolation, so
        // this remains not-an-error with no booked indices.
        let booked = engine.book(&another_buy).unwrap();
        assert!(
            booked.booked_indices.is_empty(),
            "augmentation books via interpolation, not lot matching"
        );
    }

    #[test]
    fn test_book_no_inventory_for_account() {
        let engine = BookingEngine::new();

        // Try to book a sell without any prior inventory
        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(875.00), "USD"),
            ));

        // Should succeed but with no booked indices (no inventory to match against)
        let booked = engine.book(&sell).unwrap();
        assert!(
            booked.booked_indices.is_empty(),
            "No inventory means no lot matching"
        );
    }

    #[test]
    fn test_book_zero_gain() {
        let mut engine = BookingEngine::new();

        // Buy 10 AAPL at $150
        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(150.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-1500.00), "USD"),
            ));

        engine.apply(&buy).expect("fixture fits in Decimal");

        // Sell at same price - zero gain
        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(150.00), "USD"))),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(750.00), "USD"),
            ));

        let booked = engine.book(&sell).unwrap();

        // A break-even sale is still a reportable disposal (a Form 8949 line), so it
        // IS recorded — with a zero gain, the lot's units/basis/proceeds, and the
        // acquisition date. (`CapitalGain` records disposals, not only non-zero
        // gains; this is the shape the capital-gains report consumes.)
        assert_eq!(booked.gains.len(), 1, "the disposal is recorded");
        let g = &booked.gains[0];
        assert_eq!(
            (g.proceeds.number - g.cost_basis.number),
            dec!(0),
            "zero gain"
        );
        assert_eq!(g.units, dec!(5));
        assert_eq!(g.cost_basis.number, dec!(750.00));
        assert_eq!(g.proceeds.number, dec!(750.00));
        assert_eq!(g.acquired_date, Some(date(2024, 1, 15)));
    }

    /// A sale crossing MULTIPLE lots yields one `CapitalGain` per matched lot, each
    /// with its own acquisition date, cost basis, and pro-rated proceeds (Form 8949
    /// shape). Previously a multi-lot reduction recorded no gains at all.
    #[test]
    fn test_book_gain_across_multiple_lots() {
        let mut engine = BookingEngine::new(); // FIFO
        let lot = |d, n, cost| {
            Transaction::new(d, "buy")
                .with_synthesized_posting(
                    Posting::new("Assets:Stock", Amount::new(n, "AAPL")).with_cost(
                        CostSpec::empty()
                            .with_number(rustledger_core::CostNumber::PerUnit { value: cost })
                            .with_currency("USD"),
                    ),
                )
                .with_synthesized_posting(Posting::new(
                    "Assets:Cash",
                    Amount::new(-n * cost, "USD"),
                ))
        };
        engine
            .apply(&lot(date(2020, 1, 1), dec!(5), dec!(100)))
            .expect("fixture fits in Decimal");
        engine
            .apply(&lot(date(2020, 6, 1), dec!(5), dec!(120)))
            .expect("fixture fits in Decimal");

        // Sell 8 @ 150: FIFO takes 5 from lot 1 (@100) then 3 from lot 2 (@120).
        let sell = Transaction::new(date(2021, 1, 1), "sell")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-8), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(150), "USD"))),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1200), "USD")));
        let g = engine.book(&sell).unwrap().gains;

        assert_eq!(g.len(), 2, "one disposal per matched lot");
        // Lot 1: 5 units, acquired 2020-01-01, basis 500, proceeds 750, gain 250.
        assert_eq!(g[0].units, dec!(5));
        assert_eq!(g[0].acquired_date, Some(date(2020, 1, 1)));
        assert_eq!(g[0].cost_basis.number, dec!(500));
        assert_eq!(g[0].proceeds.number, dec!(750));
        assert_eq!((g[0].proceeds.number - g[0].cost_basis.number), dec!(250));
        // Lot 2: 3 units, acquired 2020-06-01, basis 360, proceeds 450, gain 90.
        assert_eq!(g[1].units, dec!(3));
        assert_eq!(g[1].acquired_date, Some(date(2020, 6, 1)));
        assert_eq!(g[1].cost_basis.number, dec!(360));
        assert_eq!(g[1].proceeds.number, dec!(450));
        assert_eq!((g[1].proceeds.number - g[1].cost_basis.number), dec!(90));
    }

    /// A single-lot `@@` (total) sale records the stated total as proceeds
    /// EXACTLY, with no `total / units × units` division tail — even when the
    /// total does not divide evenly by the units.
    #[test]
    fn test_book_total_price_single_lot_exact() {
        let mut engine = BookingEngine::new();
        // Buy 3 AAPL at $30 (basis 90).
        engine
            .apply(
                &Transaction::new(date(2024, 1, 1), "buy")
                    .with_synthesized_posting(
                        Posting::new("Assets:Stock", Amount::new(dec!(3), "AAPL")).with_cost(
                            CostSpec::empty()
                                .with_number(rustledger_core::CostNumber::PerUnit {
                                    value: dec!(30),
                                })
                                .with_currency("USD"),
                        ),
                    )
                    .with_synthesized_posting(Posting::new(
                        "Assets:Cash",
                        Amount::new(dec!(-90), "USD"),
                    )),
            )
            .expect("fixture fits in Decimal");
        // Sell all 3 for a TOTAL of $100 (100 / 3 does not terminate).
        let sell = Transaction::new(date(2024, 6, 1), "sell")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-3), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::total(Amount::new(dec!(100), "USD"))),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(100), "USD")));
        let g = engine.book(&sell).unwrap().gains;
        assert_eq!(g.len(), 1);
        // Exact — NOT 99.9999…28dp — because the last (only) lot takes the remainder.
        assert_eq!(g[0].proceeds.number, dec!(100));
        assert_eq!(g[0].cost_basis.number, dec!(90));
        assert_eq!((g[0].proceeds.number - g[0].cost_basis.number), dec!(10));
    }

    /// A multi-lot `@@` (total) sale pro-rates the stated total across lots so the
    /// per-lot proceeds sum EXACTLY to the total (no over- or under-counting).
    #[test]
    fn test_book_total_price_multi_lot_sums_to_total() {
        let mut engine = BookingEngine::new(); // FIFO
        let lot = |d, n, cost| {
            Transaction::new(d, "buy")
                .with_synthesized_posting(
                    Posting::new("Assets:Stock", Amount::new(n, "AAPL")).with_cost(
                        CostSpec::empty()
                            .with_number(rustledger_core::CostNumber::PerUnit { value: cost })
                            .with_currency("USD"),
                    ),
                )
                .with_synthesized_posting(Posting::new(
                    "Assets:Cash",
                    Amount::new(-n * cost, "USD"),
                ))
        };
        engine
            .apply(&lot(date(2020, 1, 1), dec!(5), dec!(100)))
            .expect("fixture fits in Decimal");
        engine
            .apply(&lot(date(2020, 6, 1), dec!(5), dec!(120)))
            .expect("fixture fits in Decimal");
        // Sell 8 for a TOTAL of $1200: FIFO 5 from lot 1, 3 from lot 2.
        let sell = Transaction::new(date(2021, 1, 1), "sell")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-8), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::total(Amount::new(dec!(1200), "USD"))),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1200), "USD")));
        let g = engine.book(&sell).unwrap().gains;
        assert_eq!(g.len(), 2);
        assert_eq!(g[0].proceeds.number, dec!(750)); // 1200 × 5/8
        assert_eq!(g[1].proceeds.number, dec!(450)); // remainder: 1200 − 750
        assert_eq!(
            g[0].proceeds.number + g[1].proceeds.number,
            dec!(1200),
            "per-lot proceeds sum exactly to the stated total"
        );
    }

    /// A multi-lot `@@` (total) sale whose total does NOT divide evenly gives each
    /// lot its EXACT pro-rata share (`total × units / total_units`) — never rounded
    /// or forced (which distorts round-dollar totals and can go negative). The
    /// shares are exact fractions that round to the expected cents and sum to the
    /// stated total.
    #[test]
    fn test_book_total_price_uneven_split_is_exact_prorata() {
        let mut engine = BookingEngine::new(); // FIFO
        let lot = |d, n, cost| {
            Transaction::new(d, "buy")
                .with_synthesized_posting(
                    Posting::new("Assets:Stock", Amount::new(n, "AAPL")).with_cost(
                        CostSpec::empty()
                            .with_number(rustledger_core::CostNumber::PerUnit { value: cost })
                            .with_currency("USD"),
                    ),
                )
                .with_synthesized_posting(Posting::new(
                    "Assets:Cash",
                    Amount::new(-n * cost, "USD"),
                ))
        };
        engine
            .apply(&lot(date(2020, 1, 1), dec!(1), dec!(10)))
            .expect("fixture fits in Decimal");
        engine
            .apply(&lot(date(2020, 6, 1), dec!(2), dec!(10)))
            .expect("fixture fits in Decimal");
        // Sell 3 for a round-dollar TOTAL of 100 (scale 0): FIFO 1 + 2. 100/3 does
        // not terminate — the shares must NOT be rounded to whole dollars.
        let sell = Transaction::new(date(2021, 1, 1), "sell")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-3), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::total(Amount::new(dec!(100), "USD"))),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(100), "USD")));
        let g = engine.book(&sell).unwrap().gains;
        assert_eq!(g.len(), 2);
        // Exact pro-rata: 100 × 1/3 and 100 × 2/3 (NOT 33 and 67 whole-dollar).
        assert_eq!(g[0].proceeds.number, dec!(100) * dec!(1) / dec!(3));
        assert_eq!(g[1].proceeds.number, dec!(100) * dec!(2) / dec!(3));
        // Positive (never a negative last-lot remainder), rounding to the true cents.
        assert!(g[0].proceeds.number > dec!(0) && g[1].proceeds.number > dec!(0));
        assert_eq!(g[0].proceeds.number.round_dp(2), dec!(33.33));
        assert_eq!(g[1].proceeds.number.round_dp(2), dec!(66.67));
        assert_eq!(
            (g[0].proceeds.number + g[1].proceeds.number).round_dp(2),
            dec!(100.00),
            "shares sum to the stated total at cent precision"
        );
    }

    /// A coarse-scale `@@` total split across MANY lots never produces a negative
    /// per-lot proceeds (the old round-to-scale-plus-remainder scheme could:
    /// `@@ 2 USD` over four lots rounded the first three UP past the total, driving
    /// the last lot negative). Exact pro-rata keeps every lot positive.
    #[test]
    fn test_book_total_price_coarse_scale_never_negative() {
        let mut engine = BookingEngine::new(); // FIFO
        let lot = |d, n: Decimal| {
            Transaction::new(d, "buy")
                .with_synthesized_posting(
                    Posting::new("Assets:Stock", Amount::new(n, "AAPL")).with_cost(
                        CostSpec::empty()
                            .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1) })
                            .with_currency("USD"),
                    ),
                )
                .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(-n, "USD")))
        };
        engine
            .apply(&lot(date(2020, 1, 1), dec!(3)))
            .expect("fixture fits in Decimal");
        engine
            .apply(&lot(date(2020, 2, 1), dec!(3)))
            .expect("fixture fits in Decimal");
        engine
            .apply(&lot(date(2020, 3, 1), dec!(3)))
            .expect("fixture fits in Decimal");
        engine
            .apply(&lot(date(2020, 4, 1), dec!(1)))
            .expect("fixture fits in Decimal");
        // Sell all 10 for a round-dollar total of 2 (scale 0), FIFO 3+3+3+1.
        let sell = Transaction::new(date(2021, 1, 1), "sell")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::total(Amount::new(dec!(2), "USD"))),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(2), "USD")));
        let g = engine.book(&sell).unwrap().gains;
        assert_eq!(g.len(), 4);
        assert!(
            g.iter().all(|x| x.proceeds.number >= dec!(0)),
            "no lot has negative proceeds: {:?}",
            g.iter().map(|x| x.proceeds.number).collect::<Vec<_>>()
        );
        let total: Decimal = g.iter().map(|x| x.proceeds.number).sum();
        assert_eq!(total.round_dp(2), dec!(2.00), "shares sum to the total");
    }

    /// A disposal whose sale-price currency differs from the lot's cost currency is
    /// RECORDED with proceeds in the sale currency and basis in the cost currency
    /// (the booking engine does not decide whether a cross-currency gain is
    /// computable — that policy is the consumer's; the report filters such rows).
    #[test]
    fn test_book_cross_currency_disposal_records_both_currencies() {
        let mut engine = BookingEngine::new();
        // Buy 3 AAPL at cost 30 USD.
        engine
            .apply(
                &Transaction::new(date(2024, 1, 1), "buy")
                    .with_synthesized_posting(
                        Posting::new("Assets:Stock", Amount::new(dec!(3), "AAPL")).with_cost(
                            CostSpec::empty()
                                .with_number(rustledger_core::CostNumber::PerUnit {
                                    value: dec!(30),
                                })
                                .with_currency("USD"),
                        ),
                    )
                    .with_synthesized_posting(Posting::new(
                        "Assets:Cash",
                        Amount::new(dec!(-90), "USD"),
                    )),
            )
            .expect("fixture fits in Decimal");
        // Sell priced in EUR.
        let sell = Transaction::new(date(2024, 6, 1), "sell")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-3), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(50), "EUR"))),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(150), "EUR")));
        let g = engine.book(&sell).unwrap().gains;
        assert_eq!(g.len(), 1);
        // Proceeds in the sale currency (EUR), basis in the cost currency (USD).
        assert_eq!(g[0].proceeds.number, dec!(150));
        assert_eq!(g[0].proceeds.currency.as_ref(), "EUR");
        assert_eq!(g[0].cost_basis.number, dec!(90));
        assert_eq!(g[0].cost_basis.currency.as_ref(), "USD");
    }

    /// A sale that FIFO-matches lots held in DIFFERENT cost currencies records the
    /// same-currency lot AND the cross-currency lot — both are recorded, each with
    /// its own proceeds/basis currencies (the report, not the booking engine,
    /// decides a cross-currency row's gain is not computable).
    #[test]
    fn test_book_mixed_currency_lots_records_both() {
        let mut engine = BookingEngine::new(); // FIFO
        // Lot 1: 5 AAPL at 100 USD (2020). Lot 2: 5 AAPL at 90 EUR (2021).
        engine
            .apply(
                &Transaction::new(date(2020, 1, 1), "buy usd")
                    .with_synthesized_posting(
                        Posting::new("Assets:Stock", Amount::new(dec!(5), "AAPL")).with_cost(
                            CostSpec::empty()
                                .with_number(rustledger_core::CostNumber::PerUnit {
                                    value: dec!(100),
                                })
                                .with_currency("USD"),
                        ),
                    )
                    .with_synthesized_posting(Posting::new(
                        "Assets:Cash",
                        Amount::new(dec!(-500), "USD"),
                    )),
            )
            .expect("fixture fits in Decimal");
        engine
            .apply(
                &Transaction::new(date(2021, 1, 1), "buy eur")
                    .with_synthesized_posting(
                        Posting::new("Assets:Stock", Amount::new(dec!(5), "AAPL")).with_cost(
                            CostSpec::empty()
                                .with_number(rustledger_core::CostNumber::PerUnit {
                                    value: dec!(90),
                                })
                                .with_currency("EUR"),
                        ),
                    )
                    .with_synthesized_posting(Posting::new(
                        "Assets:Cash",
                        Amount::new(dec!(-450), "EUR"),
                    )),
            )
            .expect("fixture fits in Decimal");
        // Sell 8 @ 150 USD: FIFO takes 5 from the USD lot, 3 from the EUR lot.
        let sell = Transaction::new(date(2023, 1, 1), "sell")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-8), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(150), "USD"))),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1200), "USD")));
        let g = engine.book(&sell).unwrap().gains;
        assert_eq!(g.len(), 2, "both lots recorded: {g:?}");
        // USD lot: same-currency, computable gain 250.
        assert_eq!(g[0].units, dec!(5));
        assert_eq!(g[0].cost_basis.number, dec!(500));
        assert_eq!(g[0].cost_basis.currency.as_ref(), "USD");
        assert_eq!(g[0].proceeds.number, dec!(750));
        assert_eq!(g[0].proceeds.currency.as_ref(), "USD");
        // EUR lot: cross-currency — proceeds in USD (the sale price), basis in EUR.
        assert_eq!(g[1].units, dec!(3));
        assert_eq!(g[1].proceeds.number, dec!(450));
        assert_eq!(g[1].proceeds.currency.as_ref(), "USD");
        assert_eq!(g[1].cost_basis.number, dec!(270));
        assert_eq!(g[1].cost_basis.currency.as_ref(), "EUR");
    }

    /// Closing a SHORT position: the gain sign and proceeds/basis are the mirror of
    /// a long disposal — you profit when you cover BELOW the price you shorted at.
    /// Sold 5 @ 100 (received 500), cover 5 @ 80 (paid 400) => +100 gain.
    #[test]
    fn test_book_short_cover_gain_sign_and_flag() {
        let mut engine = BookingEngine::new();
        // Open the short: sell 5 not held, at cost 100 (the price shorted at).
        engine
            .apply(
                &Transaction::new(date(2020, 1, 1), "open short")
                    .with_synthesized_posting(
                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
                            CostSpec::empty()
                                .with_number(rustledger_core::CostNumber::PerUnit {
                                    value: dec!(100),
                                })
                                .with_currency("USD"),
                        ),
                    )
                    .with_synthesized_posting(Posting::new(
                        "Assets:Cash",
                        Amount::new(dec!(500), "USD"),
                    )),
            )
            .expect("fixture fits in Decimal");
        // Cover: buy 5 back at 80.
        let cover = Transaction::new(date(2020, 6, 1), "cover")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(5), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(80), "USD"))),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-400), "USD")));
        let g = engine.book(&cover).unwrap().gains;
        assert_eq!(g.len(), 1);
        assert!(g[0].short_sale, "closing a short is flagged");
        assert_eq!(
            g[0].proceeds.number,
            dec!(500),
            "short-open value is the proceeds"
        );
        assert_eq!(g[0].cost_basis.number, dec!(400), "cover cost is the basis");
        assert_eq!(
            (g[0].proceeds.number - g[0].cost_basis.number),
            dec!(100),
            "gain = proceeds - basis = +100"
        );
        assert_eq!(g[0].acquired_date, Some(date(2020, 1, 1)));
    }

    /// Test cost currency inference from other postings (issue #230).
    ///
    /// When a cost is specified without a currency (e.g., `{1}`), the currency
    /// should be inferred from simple postings in the same transaction.
    #[test]
    fn test_cost_currency_inference_from_other_postings() {
        let mut engine = BookingEngine::new();

        // Opening balance with cost without currency - should infer USD from other posting
        // 2026-01-01 * "Opening balance"
        //   Assets:Abc   1 ABC {1}           <- no currency, should infer USD
        //   Equity:Opening-Balances -1 USD
        let open = Transaction::new(date(2026, 1, 1), "Opening balance")
            .with_synthesized_posting(
                Posting::new("Assets:Abc", Amount::new(dec!(1), "ABC")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1) }),
                ), // No currency!
            )
            .with_synthesized_posting(Posting::new(
                "Equity:Opening-Balances",
                Amount::new(dec!(-1), "USD"),
            ));

        // Book and apply the opening
        let booked = engine.book(&open).unwrap();
        engine
            .apply(&booked.transaction)
            .expect("fixture fits in Decimal");

        // Check that the cost spec was filled in with USD
        let cost_spec = booked.transaction.postings[0].cost.as_ref().unwrap();
        assert_eq!(
            cost_spec.currency.as_deref(),
            Some("USD"),
            "Cost currency should be inferred as USD from other posting"
        );

        // Check inventory has the position with correct cost
        let inv = engine.inventory(&"Assets:Abc".into()).unwrap();
        let pos = inv.positions().next().unwrap();
        assert!(pos.cost.is_some(), "Position should have cost");
        let cost = pos.cost.as_ref().unwrap();
        assert_eq!(cost.currency.as_ref(), "USD", "Cost currency should be USD");
        assert_eq!(cost.number, dec!(1), "Cost number should be 1");

        // Now sell with explicit cost currency - should match the lot
        // 2026-01-02 * "Sale"
        //   Assets:Abc  -1 ABC {1 USD}
        //   Expenses:Abc
        let sell = Transaction::new(date(2026, 1, 2), "Sale")
            .with_synthesized_posting(
                Posting::new("Assets:Abc", Amount::new(dec!(-1), "ABC")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1) })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::auto("Expenses:Abc"));

        // This should succeed - the lot with {1 USD} should be found
        let booked_sell = engine.book(&sell).unwrap();

        // Check that the lot was matched
        assert!(
            !booked_sell.booked_indices.is_empty(),
            "Sale should match the lot created in opening"
        );
    }

    #[test]
    fn test_multi_posting_crosses_lot_boundary() {
        // Regression test: Multiple postings in the same transaction reducing
        // the same commodity should correctly track inventory state across postings.
        // Previously, each posting would see the original inventory instead of
        // the updated state after processing previous postings.

        let mut engine = BookingEngine::new();

        // Create two lots of ADA with different costs
        // Lot 1: 100 ADA at $0.50 (2021-01-01)
        let buy1 = Transaction::new(date(2021, 1, 1), "Buy lot 1")
            .with_synthesized_posting(
                Posting::new("Assets:Crypto", Amount::new(dec!(100), "ADA")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0.50) })
                        .with_currency("USD")
                        .with_date(date(2021, 1, 1)),
                ),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD")));
        engine.apply(&buy1).expect("fixture fits in Decimal");

        // Lot 2: 100 ADA at $0.52 (2022-05-19)
        let buy2 = Transaction::new(date(2022, 5, 19), "Buy lot 2")
            .with_synthesized_posting(
                Posting::new("Assets:Crypto", Amount::new(dec!(100), "ADA")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0.52) })
                        .with_currency("USD")
                        .with_date(date(2022, 5, 19)),
                ),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-52), "USD")));
        engine.apply(&buy2).expect("fixture fits in Decimal");

        // Verify initial inventory: 200 ADA total
        let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
        assert_eq!(inv.units("ADA"), dec!(200));

        // Consume half of lot 1 first
        let sell1 = Transaction::new(date(2022, 5, 20), "Sell 50 ADA")
            .with_synthesized_posting(
                Posting::new("Assets:Crypto", Amount::new(dec!(-50), "ADA"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(25), "USD")));
        let booked1 = engine.book(&sell1).unwrap();
        engine
            .apply(&booked1.transaction)
            .expect("fixture fits in Decimal");

        // Verify: 150 ADA remaining (50 in lot 1, 100 in lot 2)
        let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
        assert_eq!(inv.units("ADA"), dec!(150));

        // Now the critical test: TWO postings in the same transaction
        // that together cross the lot boundary.
        // - Posting 1: -75 ADA {} → takes 50 from lot 1 + 25 from lot 2
        // - Posting 2: -5 ADA {} → should take from lot 2 (continuing)
        let sell2 = Transaction::new(date(2022, 5, 22), "Sell 80 ADA (multi-posting)")
            .with_synthesized_posting(
                Posting::new("Assets:Crypto", Amount::new(dec!(-75), "ADA"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(
                Posting::new("Assets:Crypto", Amount::new(dec!(-5), "ADA"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(42), "USD")));

        // This should succeed - the bug was that the second posting would fail
        // with "No matching lot" because it was trying to match against lot 1
        // which was already exhausted by the first posting.
        let booked2 = engine.book(&sell2);
        assert!(
            booked2.is_ok(),
            "Multi-posting transaction should succeed: {:?}",
            booked2.err()
        );

        // Apply and verify final inventory: 70 ADA remaining (all in lot 2)
        engine
            .apply(&booked2.unwrap().transaction)
            .expect("fixture fits in Decimal");
        let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
        assert_eq!(
            inv.units("ADA"),
            dec!(70),
            "Should have 70 ADA remaining in lot 2"
        );
    }

    #[test]
    fn test_book_no_cost_specs_fast_path() {
        // Test that the fast path for transactions without cost specs
        // returns correct empty gains and booked_indices.
        let engine = BookingEngine::new();

        // Simple expense transaction with no cost specs
        let txn = Transaction::new(date(2024, 1, 15), "Groceries")
            .with_synthesized_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD")))
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD")));

        let result = engine.book(&txn).unwrap();

        // Fast path should return empty gains and booked_indices
        assert!(result.gains.is_empty(), "Should have no capital gains");
        assert!(
            result.booked_indices.is_empty(),
            "Should have no booked indices"
        );

        // Transaction should be unchanged
        assert_eq!(result.transaction.postings.len(), 2);
        assert_eq!(
            result.transaction.postings[0].units,
            Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD")))
        );
    }

    /// Regression test for #748.
    ///
    /// The pta-standards `reduction-exceeds-inventory` conformance test
    /// asserts on `error_contains: ["not enough"]`. PR #745 made the booking
    /// layer propagate `InsufficientUnits` directly to the user instead of
    /// letting the validator's "Not enough units in ..." message win, which
    /// dropped the "not enough" phrasing. This test pins the user-facing
    /// Display string so the conformance assertion (and any downstream user
    /// tooling that greps the message) cannot regress silently again.
    ///
    /// After #750, the canonical Display lives on
    /// [`rustledger_core::AccountedBookingError`] and `BookingError::Inventory`
    /// delegates to it transparently — so this test exercises the same path
    /// the validator and `cmd/check.rs` use.

    // =========================================================================
    // Regression test for issue #875 / beancount#889
    //
    // Scenario: buy stock with cost, sell without cost spec (leaves a simple
    // negative position), then buy more with cost spec. The third transaction
    // must succeed as an augmentation, not fail as a reduction.
    // =========================================================================

    #[test]
    fn test_augmentation_after_sell_without_cost_spec() {
        // Regression test for issue #875 / beancount#889.
        //
        // Before the fix, the sell-without-cost-spec left a -25 HOOG simple
        // position, causing the subsequent buy-with-cost-spec to be
        // misclassified as a reduction (because is_reduced_by saw opposite
        // signs without distinguishing cost-bearing vs simple positions).
        let mut engine = BookingEngine::new();

        // 2024-01-10: Buy 100 HOOG {1.50 EUR}
        let buy1 = Transaction::new(date(2024, 1, 10), "Buy 100 HOOG")
            .with_synthesized_posting(
                Posting::new("Assets:Stocks", Amount::new(dec!(100), "HOOG")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.50) })
                        .with_currency("EUR"),
                ),
            )
            .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-150), "EUR")));

        engine.apply(&buy1).expect("fixture fits in Decimal");

        // 2024-01-15: Sell 25 HOOG without cost spec (price-only)
        let sell = Transaction::new(date(2024, 1, 15), "Sell 25 HOOG without cost spec")
            .with_synthesized_posting(
                Posting::new("Assets:Stocks", Amount::new(dec!(-25), "HOOG"))
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(1.60), "EUR"))),
            )
            .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(40), "EUR")));

        engine.apply(&sell).expect("fixture fits in Decimal");

        // 2024-01-20: Buy 50 more HOOG {1.70 EUR} - this MUST succeed
        let buy2 = Transaction::new(date(2024, 1, 20), "Buy 50 more HOOG - should succeed")
            .with_synthesized_posting(
                Posting::new("Assets:Stocks", Amount::new(dec!(50), "HOOG")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.70) })
                        .with_currency("EUR"),
                ),
            )
            .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-85), "EUR")));

        // This should NOT fail. Before the fix, the engine would see the
        // -25 HOOG simple position and try to reduce, which would fail
        // because the cost spec wouldn't match any existing lot.
        let result = engine.book(&buy2);
        assert!(
            result.is_ok(),
            "Buy with cost spec after sell without cost spec should succeed as augmentation, \
             but got error: {:?}",
            result.err()
        );

        let booked = result.unwrap();
        engine
            .apply(&booked.transaction)
            .expect("fixture fits in Decimal");

        // Verify final inventory state
        let inv = engine.inventory(&"Assets:Stocks".into()).unwrap();
        // 100 (original) - 25 (sold simple) + 50 (new lot) = 125 HOOG total
        assert_eq!(inv.units("HOOG"), dec!(125));
    }

    #[test]
    fn test_insufficient_units_display_contains_not_enough() {
        let err = BookingError::Inventory(
            rustledger_core::BookingError::InsufficientUnits {
                currency: "AAPL".into(),
                requested: dec!(15),
                available: dec!(10),
            }
            .with_account("Assets:Stock".into()),
        );
        let rendered = format!("{err}");
        assert!(
            rendered.contains("not enough"),
            "InsufficientUnits Display must contain 'not enough' for beancount \
             compatibility (#748). Got: {rendered}"
        );
        assert!(
            rendered.contains("Assets:Stock"),
            "InsufficientUnits Display must include the account name. Got: {rendered}"
        );
        assert!(
            rendered.contains("15") && rendered.contains("10"),
            "InsufficientUnits Display must include requested and available amounts. Got: {rendered}"
        );
    }

    /// Helper: does any posting still have an unfilled (elided) amount?
    fn has_elided_posting(txn: &Transaction) -> bool {
        txn.postings.iter().any(|p| p.units.is_none())
    }

    #[test]
    fn book_interpolates_elided_posting_and_preserves_order() {
        use rustledger_core::Open;

        let directives = vec![
            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
            Directive::Transaction(
                Transaction::new(date(2024, 1, 15), "Lunch")
                    .with_synthesized_posting(Posting::new(
                        "Expenses:Food",
                        Amount::new(dec!(50.00), "USD"),
                    ))
                    .with_synthesized_posting(Posting::auto("Assets:Cash")),
            ),
        ];

        let result = book(&directives, BookingMethod::Strict);
        assert!(result.failed.is_empty(), "nothing should fail to book");
        assert_eq!(result.booked.len(), 3, "all directives preserved");

        // Order preserved: the two Opens come first, unchanged.
        assert_eq!(result.booked[0], directives[0]);
        assert_eq!(result.booked[1], directives[1]);

        // The transaction's elided posting got filled in.
        let Directive::Transaction(booked_txn) = &result.booked[2] else {
            panic!("third directive should still be a transaction");
        };
        assert!(
            !has_elided_posting(booked_txn),
            "the auto posting should have been interpolated"
        );
    }

    #[test]
    fn book_is_deterministic() {
        use rustledger_core::Open;

        let directives = vec![
            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
            Directive::Transaction(
                Transaction::new(date(2024, 1, 15), "Buy")
                    .with_synthesized_posting(
                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
                            .with_price(PriceAnnotation::unit(Amount::new(dec!(150.00), "USD"))),
                    )
                    .with_synthesized_posting(Posting::auto("Assets:Cash")),
            ),
        ];

        let first = book(&directives, BookingMethod::Strict);
        let second = book(&directives, BookingMethod::Strict);
        assert_eq!(
            first.booked, second.booked,
            "booking the same ledger twice must produce identical output"
        );
    }

    #[test]
    fn book_partitions_failed_transaction() {
        use rustledger_core::Open;

        // Buy a lot at $150, then sell against a cost basis ($200) that
        // matches no existing lot. Under Strict this is a no-matching-lot
        // error, so the sell is partitioned into `failed`.
        let buy_cost = CostSpec::empty()
            .with_number(rustledger_core::CostNumber::PerUnit {
                value: dec!(150.00),
            })
            .with_currency("USD");
        let sell_cost = CostSpec::empty()
            .with_number(rustledger_core::CostNumber::PerUnit {
                value: dec!(200.00),
            })
            .with_currency("USD");

        let directives = vec![
            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
            Directive::Transaction(
                Transaction::new(date(2024, 1, 10), "Buy")
                    .with_synthesized_posting(
                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
                            .with_cost(buy_cost),
                    )
                    .with_synthesized_posting(Posting::new(
                        "Assets:Cash",
                        Amount::new(dec!(-1500.00), "USD"),
                    )),
            ),
            Directive::Transaction(
                Transaction::new(date(2024, 1, 15), "Sell at phantom cost basis")
                    .with_synthesized_posting(
                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
                            .with_cost(sell_cost),
                    )
                    .with_synthesized_posting(Posting::new(
                        "Assets:Cash",
                        Amount::new(dec!(1000.00), "USD"),
                    )),
            ),
        ];

        let result = book(&directives, BookingMethod::Strict);
        assert_eq!(result.failed.len(), 1, "the mismatched sell should fail");
        // The two Opens and the successful buy survive; the sell is dropped.
        assert_eq!(result.booked.len(), 3, "Opens + buy remain in booked");
        assert!(
            !result.booked.iter().any(|d| matches!(
                d,
                Directive::Transaction(t) if t.narration.as_ref() == "Sell at phantom cost basis"
            )),
            "failed sell must not appear in booked"
        );
    }

    /// The snapshot is skipped for ordinary transactions and kept whenever
    /// overflow cannot be ruled out (#1897).
    ///
    /// Both directions matter. Skipping when overflow IS possible is silent
    /// corruption; keeping it always is the 2.4x heap churn this guard exists
    /// to remove. The third case is the one a naive guard gets wrong: the
    /// posting is tiny, but the account it lands in already sits at the
    /// ceiling, so the add still overflows.
    /// Two reducing postings on the SAME account in one transaction: the
    /// second must see what the first took.
    ///
    /// This is the case the working-copy clone exists for, and the only one
    /// that still gets a copy — everything else previews against the live
    /// inventory without copying. If the "does this account need a working
    /// copy" gate ever drifts toward cloning less, both postings would match
    /// against the full 10-unit lot and a 6+6 oversell would book cleanly.
    ///
    /// 10 AAPL held; sell 6 and 6 in one transaction. The second reduction
    /// must fail on insufficient units.
    #[test]
    fn two_reducing_postings_on_one_account_see_each_other() {
        let mut engine = BookingEngine::new();

        let buy = Transaction::new(date(2024, 1, 1), "buy 10")
            .with_synthesized_posting(
                Posting::new("Assets:Stocks", Amount::new(dec!(10), "AAPL")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-1000), "USD")));
        engine.apply(&buy).expect("buy applies");

        let oversell = Transaction::new(date(2024, 1, 2), "sell 6 then 6")
            .with_synthesized_posting(
                Posting::new("Assets:Stocks", Amount::new(dec!(-6), "AAPL"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(
                Posting::new("Assets:Stocks", Amount::new(dec!(-6), "AAPL"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(1200), "USD")));

        let result = engine.book(&oversell);
        assert!(
            result.is_err(),
            "6 + 6 against a 10-unit lot must fail: the second posting has to \
             see the first one's reduction. Got {result:?}",
        );

        // And the control: 6 + 4 exactly drains the lot and must succeed.
        let mut engine = BookingEngine::new();
        engine.apply(&buy).expect("buy applies");
        let exact = Transaction::new(date(2024, 1, 2), "sell 6 then 4")
            .with_synthesized_posting(
                Posting::new("Assets:Stocks", Amount::new(dec!(-6), "AAPL"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(
                Posting::new("Assets:Stocks", Amount::new(dec!(-4), "AAPL"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(1000), "USD")));
        assert!(
            engine.book(&exact).is_ok(),
            "6 + 4 exactly drains the lot and must book",
        );
    }

    #[test]
    fn the_snapshot_is_skipped_only_when_overflow_is_impossible() {
        let mut engine = BookingEngine::new();

        let ordinary = Transaction::new(date(2024, 1, 1), "groceries")
            .with_synthesized_posting(Posting::new(
                "Assets:Bank",
                Amount::new(dec!(-42.50), "USD"),
            ))
            .with_synthesized_posting(Posting::new(
                "Expenses:Food",
                Amount::new(dec!(42.50), "USD"),
            ));
        assert!(
            !engine.overflow_is_possible(&ordinary),
            "a two-posting transaction of ordinary magnitudes must take the \
             fast path, or the guard buys nothing"
        );
        engine.apply(&ordinary).expect("applies");
        assert!(
            !engine.overflow_is_possible(&ordinary),
            "and still does once the accounts hold a balance"
        );

        // Magnitudes alone exceed the range.
        let huge = Transaction::new(date(2024, 1, 2), "huge")
            .with_synthesized_posting(Posting::new("Assets:A", Amount::new(Decimal::MAX, "USD")))
            .with_synthesized_posting(Posting::new("Assets:B", Amount::new(Decimal::MAX, "USD")));
        assert!(engine.overflow_is_possible(&huge));

        // The case that makes the guard non-trivial: a SMALL posting into an
        // account already parked at the ceiling.
        let park = Transaction::new(date(2024, 1, 3), "park").with_synthesized_posting(
            Posting::new("Assets:Full", Amount::new(Decimal::MAX, "USD")),
        );
        engine.apply(&park).expect("one MAX position fits");
        let small_but_doomed = Transaction::new(date(2024, 1, 4), "small")
            .with_synthesized_posting(Posting::new("Assets:Full", Amount::new(dec!(1.00), "USD")));
        assert!(
            engine.overflow_is_possible(&small_but_doomed),
            "the posting is tiny but the destination is at the ceiling — a \
             guard that looked only at the transaction would miss this"
        );
        // ...and the currency it cannot reach is unaffected.
        let other_currency = Transaction::new(date(2024, 1, 4), "eur")
            .with_synthesized_posting(Posting::new("Assets:Full", Amount::new(dec!(1.00), "EUR")));
        assert!(
            !engine.overflow_is_possible(&other_currency),
            "the ceiling is per currency; EUR has room"
        );
    }

    /// A transaction whose `apply` overflows partway must leave the running
    /// balances exactly as it found them.
    ///
    /// The loader DROPS such a transaction (`run_booking`'s `failed_indices`),
    /// so any posting applied before the failing one is a mutation from an
    /// entry the user is told was rejected. Every later transaction then books
    /// against it — wrong lot matching and wrong balances, reported as fact.
    /// Found in review of #1863; the pad path had the identical defect.
    #[test]
    fn a_failed_apply_leaves_no_partial_mutation() {
        let mut engine = BookingEngine::new();

        // Park an account at the ceiling so the SECOND posting overflows.
        let load = Transaction::new(date(2024, 1, 5), "load")
            .with_synthesized_posting(Posting::new("Assets:B", Amount::new(Decimal::MAX, "USD")));
        engine.apply(&load).expect("one MAX position fits");

        let bad = Transaction::new(date(2024, 2, 1), "partial")
            .with_synthesized_posting(Posting::new("Assets:A", Amount::new(dec!(10.00), "USD")))
            .with_synthesized_posting(Posting::new("Assets:B", Amount::new(Decimal::MAX, "USD")));
        engine
            .apply(&bad)
            .expect_err("the second posting overflows");

        let inventories = engine.into_inventories();
        assert!(
            !inventories.contains_key(&rustledger_core::Account::from("Assets:A")),
            "an account first seen in the failed transaction must not survive it"
        );
        assert_eq!(
            inventories
                .get(&rustledger_core::Account::from("Assets:B"))
                .expect("Assets:B predates the failed transaction")
                .units(&rustledger_core::Currency::from("USD")),
            Decimal::MAX,
            "an account that predates the failed transaction must be restored to \
             its prior value, not left doubled or cleared"
        );
    }
}
