Skip to main content

rustledger_booking/
book.rs

1//! Transaction booking with lot matching.
2//!
3//! This module handles:
4//! - Tracking inventory across transactions
5//! - Matching sold lots against existing holdings
6//! - Calculating capital gains/losses
7//! - Filling in cost specs for lot reductions
8
9use rustc_hash::FxHashMap;
10use rustledger_core::{
11    AccountedBookingError, Amount, BookingMethod, Cost, CostSpec, IncompleteAmount, InternedStr,
12    Inventory, Position, Posting, ReductionScope, Transaction,
13};
14use thiserror::Error;
15
16use crate::{InterpolationError, InterpolationResult, interpolate};
17
18// Note: We no longer quantize calculated values during booking.
19// Python beancount preserves full precision during booking and only
20// rounds at display time. Premature rounding of per-unit costs (e.g.,
21// from total cost / units) causes cost basis errors when selling.
22// For example: 300.00 / 1.763 = 170.16505... should NOT be rounded
23// to 170.17, because 1.763 * 170.17 = 300.00971 ≠ 300.00.
24
25/// Errors that can occur during booking.
26///
27/// Inventory-level failures (insufficient units, no matching lot, ambiguous
28/// match, currency mismatch) are unified under [`BookingError::Inventory`],
29/// which carries an [`AccountedBookingError`] from `rustledger-core`. This
30/// keeps the user-facing wording in **one place** so it cannot drift between
31/// the booking layer and the validator — see #748 / #750.
32#[derive(Debug, Clone, Error)]
33pub enum BookingError {
34    /// An inventory-level booking failure (insufficient units, no matching
35    /// lot, ambiguous match, currency mismatch).
36    ///
37    /// `Display` is delegated to the inner [`AccountedBookingError`], which
38    /// is the single canonical source of wording for booking errors. The
39    /// pta-standards `reduction-exceeds-inventory` conformance test depends
40    /// on this Display containing the literal substring `"not enough"`.
41    #[error(transparent)]
42    Inventory(AccountedBookingError),
43
44    /// Interpolation failed after booking.
45    #[error("interpolation failed: {0}")]
46    Interpolation(#[from] InterpolationError),
47}
48
49/// Result of booking a single transaction.
50#[derive(Debug, Clone)]
51pub struct BookedTransaction {
52    /// The transaction with costs filled in.
53    pub transaction: Transaction,
54    /// Capital gains/losses generated by this transaction.
55    pub gains: Vec<CapitalGain>,
56    /// Which posting indices had costs filled in.
57    pub booked_indices: Vec<usize>,
58}
59
60/// A capital gain or loss from a lot sale.
61#[derive(Debug, Clone)]
62pub struct CapitalGain {
63    /// The account holding the asset.
64    pub account: InternedStr,
65    /// The currency of the asset.
66    pub currency: InternedStr,
67    /// The gain amount (positive) or loss (negative).
68    pub amount: Amount,
69    /// Cost basis of the sold lot.
70    pub cost_basis: Amount,
71    /// Sale proceeds.
72    pub proceeds: Amount,
73}
74
75/// Booking engine that tracks inventory across transactions.
76#[derive(Debug, Default)]
77pub struct BookingEngine {
78    /// Inventory per account.
79    inventories: FxHashMap<InternedStr, Inventory>,
80    /// Default booking method, used for accounts without an explicit
81    /// booking method on their `open` directive.
82    booking_method: BookingMethod,
83    /// Per-account booking method overrides (from `open` directives).
84    /// Looked up first, falling back to `booking_method` if absent.
85    account_methods: FxHashMap<InternedStr, BookingMethod>,
86}
87
88impl BookingEngine {
89    /// Create a new booking engine with default FIFO booking.
90    #[must_use]
91    pub fn new() -> Self {
92        Self {
93            inventories: FxHashMap::default(),
94            booking_method: BookingMethod::Fifo,
95            account_methods: FxHashMap::default(),
96        }
97    }
98
99    /// Create a booking engine with a specific default booking method.
100    #[must_use]
101    pub fn with_method(method: BookingMethod) -> Self {
102        Self {
103            inventories: FxHashMap::default(),
104            booking_method: method,
105            account_methods: FxHashMap::default(),
106        }
107    }
108
109    /// Register the booking method for a specific account.
110    ///
111    /// Call this for each `open` directive *before* booking transactions for
112    /// that account, so the engine uses the per-account method (e.g. FIFO,
113    /// LIFO, NONE) rather than the engine-wide default. Subsequent calls
114    /// overwrite the previous method for the account.
115    pub fn set_account_method(&mut self, account: InternedStr, method: BookingMethod) {
116        self.account_methods.insert(account, method);
117    }
118
119    /// Scan a sequence of directives and register any per-account booking
120    /// methods found on `open` directives. Open directives whose booking
121    /// method is absent or fails to parse are silently ignored (they fall
122    /// back to the engine-wide default).
123    ///
124    /// This is a convenience wrapper around [`Self::set_account_method`] for
125    /// the common pipeline pattern of scanning all directives once before
126    /// the booking loop. Call this before booking any transactions so the
127    /// engine uses each account's declared method rather than the
128    /// engine-wide default for every account.
129    pub fn register_account_methods<'a, I>(&mut self, directives: I)
130    where
131        I: IntoIterator<Item = &'a rustledger_core::Directive>,
132    {
133        for directive in directives {
134            if let rustledger_core::Directive::Open(open) = directive
135                && let Some(method_str) = &open.booking
136                && let Ok(method) = method_str.parse::<BookingMethod>()
137            {
138                self.set_account_method(open.account.clone(), method);
139            }
140        }
141    }
142
143    /// Resolve the booking method for an account, falling back to the
144    /// engine-wide default if not registered.
145    fn method_for(&self, account: &InternedStr) -> BookingMethod {
146        self.account_methods
147            .get(account)
148            .copied()
149            .unwrap_or(self.booking_method)
150    }
151
152    /// Get the inventory for an account.
153    #[must_use]
154    pub fn inventory(&self, account: &InternedStr) -> Option<&Inventory> {
155        self.inventories.get(account)
156    }
157
158    /// Book a transaction: fill in empty cost specs and calculate gains.
159    ///
160    /// This does NOT modify the internal inventories - call `apply` for that.
161    ///
162    /// When a reduction matches multiple lots (e.g., selling shares that were purchased
163    /// across multiple buy transactions), the posting is expanded into multiple postings,
164    /// one for each matched lot. This matches Python beancount's behavior.
165    pub fn book(&self, txn: &Transaction) -> Result<BookedTransaction, BookingError> {
166        // Fast path: if no postings have cost specs, no booking is needed.
167        // This avoids expensive inventory cloning for simple transactions.
168        let has_cost_specs = txn.postings.iter().any(|p| p.cost.is_some());
169        if !has_cost_specs {
170            return Ok(BookedTransaction {
171                transaction: txn.clone(),
172                gains: Vec::new(),
173                booked_indices: Vec::new(),
174            });
175        }
176
177        let mut result = txn.clone();
178        let mut gains = Vec::new();
179        let mut booked_indices = std::collections::HashSet::with_capacity(txn.postings.len());
180        // Track posting expansions: (original_idx, expanded_postings)
181        let mut expansions: Vec<(usize, Vec<Posting>)> = Vec::with_capacity(txn.postings.len());
182
183        // Create working copies of inventories for this transaction.
184        // This allows us to track inventory changes across multiple postings
185        // within the same transaction (e.g., main sale + fee posting).
186        //
187        // Clone only the inventories we actually need for this transaction's
188        // accounts. Use `entry().or_insert_with(...)` so that a posting list
189        // with repeated accounts (e.g., two postings on `Assets:Stock`) only
190        // triggers one clone per unique account instead of cloning the same
191        // inventory every time it appears. Without deduping, the optimization
192        // would be silently undone by transactions that list the same
193        // account more than once.
194        let mut working_inventories: FxHashMap<InternedStr, Inventory> =
195            FxHashMap::with_capacity_and_hasher(txn.postings.len(), Default::default());
196        for posting in &txn.postings {
197            if let Some(inv) = self.inventories.get(&posting.account) {
198                working_inventories
199                    .entry(posting.account.clone())
200                    .or_insert_with(|| inv.clone());
201            }
202        }
203
204        // First pass: identify postings that need lot matching (reductions)
205        for (idx, posting) in txn.postings.iter().enumerate() {
206            // Check if this is a reduction with a cost spec
207            if let Some(IncompleteAmount::Complete(units)) = &posting.units
208                && let Some(cost_spec) = &posting.cost
209            {
210                // Check if this is a reduction (units have opposite sign of inventory)
211                // This handles both:
212                // - Selling long positions (negative units, positive inventory)
213                // - Closing short positions (positive units, negative inventory)
214                if let Some(inv) = working_inventories.get_mut(&posting.account) {
215                    // Check if these units reduce existing cost-bearing inventory lots.
216                    // Only positions with a cost basis are considered; simple (no-cost)
217                    // positions are ignored to avoid misclassifying augmentations.
218                    let is_reduction = inv.is_reduced_by(units, ReductionScope::CostBearingOnly);
219
220                    if is_reduction {
221                        // Use reduce (not try_reduce) to actually update the working inventory.
222                        // This ensures subsequent postings in the same transaction see
223                        // the updated inventory state (e.g., after first posting exhausts a lot).
224                        //
225                        // Booking errors (ambiguous match, no matching lot, insufficient
226                        // units) are propagated so callers see them once. The full
227                        // pipeline path in `rustledger check` filters failed transactions
228                        // out of the validator's input to avoid double-reporting against
229                        // the validator's independent lot-matching pass.
230                        let method = self.method_for(&posting.account);
231                        let booking_result = inv
232                            .reduce(units, Some(cost_spec), method)
233                            .map_err(|e| convert_core_booking_error(e, &posting.account))?;
234                        {
235                            // Check if multiple lots were matched
236                            if booking_result.matched.len() > 1 {
237                                // Expand single posting into multiple postings
238                                let mut expanded = Vec::new();
239                                for matched_pos in &booking_result.matched {
240                                    let mut new_posting = posting.clone();
241                                    // Set units to the matched portion with NEGATED sign
242                                    // (matched_pos.units has the inventory sign, but we need
243                                    // the reduction sign which is opposite)
244                                    let expanded_units = rustledger_core::Amount::new(
245                                        -matched_pos.units.number, // Negate: inventory→reduction
246                                        matched_pos.units.currency.clone(),
247                                    );
248                                    new_posting.units =
249                                        Some(IncompleteAmount::Complete(expanded_units));
250                                    // Set cost from the matched lot
251                                    if let Some(cost) = &matched_pos.cost {
252                                        new_posting.cost = Some(CostSpec {
253                                            number_per: Some(cost.number),
254                                            number_total: None,
255                                            currency: Some(cost.currency.clone()),
256                                            date: cost.date,
257                                            label: cost.label.clone(),
258                                            merge: false,
259                                        });
260                                    }
261                                    expanded.push(new_posting);
262                                }
263                                expansions.push((idx, expanded));
264                                booked_indices.insert(idx);
265                            } else if let Some(cost_basis) = &booking_result.cost_basis {
266                                // Single lot match - update posting in place
267                                let per_unit = cost_basis.number / units.number.abs();
268                                // Use new_calculated since per_unit is computed from total/units
269                                let matched_cost =
270                                    Cost::new_calculated(per_unit, cost_basis.currency.clone())
271                                        .with_date_opt(
272                                            booking_result
273                                                .matched
274                                                .first()
275                                                .and_then(|p| p.cost.as_ref())
276                                                .and_then(|c| c.date),
277                                        );
278
279                                // Update posting with filled cost
280                                result.postings[idx].cost = Some(CostSpec {
281                                    number_per: Some(matched_cost.number),
282                                    number_total: None,
283                                    currency: Some(matched_cost.currency.clone()),
284                                    date: matched_cost.date,
285                                    label: None,
286                                    merge: false,
287                                });
288                                booked_indices.insert(idx);
289                            }
290
291                            // Calculate capital gain if there's a price
292                            if let Some(cost_basis) = &booking_result.cost_basis
293                                && let Some(price) = &posting.price
294                            {
295                                let sale_price = match price {
296                                    rustledger_core::PriceAnnotation::Unit(a) => {
297                                        a.number * units.number.abs()
298                                    }
299                                    rustledger_core::PriceAnnotation::Total(a) => a.number,
300                                    _ => continue,
301                                };
302
303                                let gain_amount = sale_price - cost_basis.number;
304                                if !gain_amount.is_zero() {
305                                    gains.push(CapitalGain {
306                                        account: posting.account.clone(),
307                                        currency: units.currency.clone(),
308                                        amount: Amount::new(gain_amount, &cost_basis.currency),
309                                        cost_basis: cost_basis.clone(),
310                                        proceeds: Amount::new(sale_price, &cost_basis.currency),
311                                    });
312                                }
313                            }
314                        }
315                    }
316                    // If not a reduction: fall through to augmentation code below
317                }
318
319                if cost_spec.number_total.is_some() && cost_spec.number_per.is_none() {
320                    // This is an augmentation with total cost - convert to per-unit
321                    // e.g., `1.763 VIIIX {{300.00 USD}}` -> `1.763 VIIIX {170.165... USD}`
322                    // Preserve full precision to avoid cost basis errors when selling.
323                    if let (Some(total), Some(currency)) =
324                        (&cost_spec.number_total, &cost_spec.currency)
325                        && !units.number.is_zero()
326                    {
327                        // Calculate per-unit cost - preserve full precision
328                        let per_unit = *total / units.number.abs();
329                        result.postings[idx].cost = Some(CostSpec {
330                            number_per: Some(per_unit),
331                            number_total: cost_spec.number_total, // Preserve for precise residual calculation
332                            currency: Some(currency.clone()),
333                            // Fill in transaction date if no date specified
334                            date: cost_spec.date.or(Some(txn.date)),
335                            label: cost_spec.label.clone(),
336                            merge: cost_spec.merge,
337                        });
338                        booked_indices.insert(idx);
339                    }
340                }
341
342                // Fill in dates and currencies for augmentations (not already booked)
343                if !booked_indices.contains(&idx)
344                    && (cost_spec.number_per.is_some() || cost_spec.number_total.is_some())
345                {
346                    // Cost spec has a number but may be missing date or currency
347                    // Fill in missing parts from price annotation, other postings, and transaction date
348                    let inferred_currency = cost_spec.currency.clone().or_else(|| {
349                        // First try price annotation on this posting
350                        posting
351                                .price
352                                .as_ref()
353                                .and_then(|p| match p {
354                                    rustledger_core::PriceAnnotation::Unit(a)
355                                    | rustledger_core::PriceAnnotation::Total(a) => {
356                                        Some(a.currency.clone())
357                                    }
358                                    rustledger_core::PriceAnnotation::UnitIncomplete(inc)
359                                    | rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
360                                        inc.currency().map(Into::into)
361                                    }
362                                    _ => None,
363                                })
364                                // Then try inferring from other postings in the transaction
365                                .or_else(|| crate::infer_cost_currency_from_postings(txn))
366                    });
367
368                    // Check if this is a reduction (opposite sign exists in inventory)
369                    // Reductions get their date from matched lot, augmentations get txn date
370                    let is_reduction = self.inventories.get(&posting.account).is_some_and(|inv| {
371                        inv.is_reduced_by(units, ReductionScope::CostBearingOnly)
372                    });
373
374                    // Fill in date for augmentations only (not reductions)
375                    let inferred_date = if is_reduction {
376                        None // Reductions get their date from matched lot
377                    } else {
378                        cost_spec.date.or(Some(txn.date))
379                    };
380
381                    // Only update if we actually inferred something
382                    if inferred_currency.is_some() || inferred_date.is_some() {
383                        result.postings[idx].cost = Some(CostSpec {
384                            number_per: cost_spec.number_per,
385                            number_total: cost_spec.number_total,
386                            currency: inferred_currency.or_else(|| cost_spec.currency.clone()),
387                            date: inferred_date.or(cost_spec.date),
388                            label: cost_spec.label.clone(),
389                            merge: cost_spec.merge,
390                        });
391                    }
392                }
393            }
394        }
395
396        // Apply posting expansions (replace single postings with multiple)
397        // Build new postings Vec in one O(n) pass instead of O(n²) remove+insert
398        if !expansions.is_empty() {
399            // Sort expansions by index for forward iteration
400            expansions.sort_by_key(|(idx, _)| *idx);
401
402            let mut new_postings = Vec::with_capacity(
403                result.postings.len() + expansions.iter().map(|(_, e)| e.len()).sum::<usize>(),
404            );
405            let mut expansion_iter = expansions.into_iter().peekable();
406
407            for (idx, posting) in result.postings.into_iter().enumerate() {
408                if expansion_iter
409                    .peek()
410                    .is_some_and(|(exp_idx, _)| *exp_idx == idx)
411                {
412                    // Replace this posting with expanded postings
413                    let (_, expanded) = expansion_iter.next().unwrap();
414                    new_postings.extend(expanded);
415                } else {
416                    // Keep original posting
417                    new_postings.push(posting);
418                }
419            }
420            result.postings = new_postings;
421        }
422
423        // NOTE: Price normalization (@@→@) is NOT done here to preserve exact
424        // total prices for precise residual calculation. Call `normalize_prices()`
425        // on the transaction after validation to convert total prices to per-unit.
426
427        Ok(BookedTransaction {
428            transaction: result,
429            gains,
430            booked_indices: booked_indices.into_iter().collect(),
431        })
432    }
433
434    /// Apply a transaction to the inventories (update balances).
435    pub fn apply(&mut self, txn: &Transaction) {
436        for posting in &txn.postings {
437            if let Some(IncompleteAmount::Complete(units)) = &posting.units {
438                // Resolve the per-account booking method before mutably
439                // borrowing the inventories map.
440                let method = self.method_for(&posting.account);
441                let inv = self.inventories.entry(posting.account.clone()).or_default();
442
443                // Determine if this is a reduction: units reduce inventory when
444                // signs differ for the same currency. Only cost-bearing positions
445                // are considered, so simple (no-cost) positions don't trigger
446                // false reduction detection.
447                let is_reduction = posting.cost.is_some()
448                    && inv.is_reduced_by(units, ReductionScope::CostBearingOnly);
449
450                if is_reduction {
451                    // Reduce from inventory
452                    let _ = inv.reduce(units, posting.cost.as_ref(), method);
453                } else {
454                    // Add to inventory
455                    let position = if let Some(cost_spec) = &posting.cost {
456                        // Try per-unit cost first, then total cost
457                        let per_unit_cost = if let Some(per_unit) = &cost_spec.number_per {
458                            Some(*per_unit)
459                        } else if let Some(total) = &cost_spec.number_total {
460                            // Convert total cost to per-unit cost - preserve full precision
461                            // to avoid cost basis errors when selling
462                            if units.number.is_zero() {
463                                None
464                            } else {
465                                Some(*total / units.number.abs())
466                            }
467                        } else {
468                            None
469                        };
470
471                        // Infer cost currency from price annotation or other postings
472                        let cost_currency = cost_spec.currency.clone().or_else(|| {
473                            // First try price annotation on this posting
474                            posting
475                                .price
476                                .as_ref()
477                                .and_then(|p| match p {
478                                    rustledger_core::PriceAnnotation::Unit(a)
479                                    | rustledger_core::PriceAnnotation::Total(a) => {
480                                        Some(a.currency.clone())
481                                    }
482                                    rustledger_core::PriceAnnotation::UnitIncomplete(inc)
483                                    | rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
484                                        inc.currency().map(Into::into)
485                                    }
486                                    _ => None,
487                                })
488                                // Then try inferring from other postings in the transaction
489                                .or_else(|| crate::infer_cost_currency_from_postings(txn))
490                        });
491
492                        if let (Some(per_unit), Some(currency)) = (per_unit_cost, cost_currency) {
493                            Position::with_cost(
494                                units.clone(),
495                                Cost::new(per_unit, currency)
496                                    .with_date_opt(cost_spec.date.or(Some(txn.date)))
497                                    .with_label_opt(cost_spec.label.clone()),
498                            )
499                        } else {
500                            Position::simple(units.clone())
501                        }
502                    } else {
503                        Position::simple(units.clone())
504                    };
505                    inv.add(position);
506                }
507            }
508        }
509    }
510
511    /// Book and interpolate a transaction.
512    ///
513    /// This fills in empty cost specs, then interpolates any missing amounts.
514    pub fn book_and_interpolate(
515        &self,
516        txn: &Transaction,
517    ) -> Result<InterpolationResult, BookingError> {
518        // First book (fill in costs)
519        let booked = self.book(txn)?;
520
521        // Then interpolate (fill in missing amounts)
522        let result = interpolate(&booked.transaction)?;
523
524        Ok(result)
525    }
526}
527
528/// Convert a core inventory `BookingError` into the booking-layer error,
529/// attaching the account context that the core layer doesn't carry.
530///
531/// All inventory-level failures funnel into a single
532/// [`BookingError::Inventory`] variant. The user-facing wording lives in the
533/// `Display` impl on [`AccountedBookingError`] so it cannot drift between
534/// the booking layer and the validator (#748 / #750).
535fn convert_core_booking_error(
536    err: rustledger_core::BookingError,
537    account: &InternedStr,
538) -> BookingError {
539    BookingError::Inventory(err.with_account(account.clone()))
540}
541
542/// Book and interpolate a list of transactions.
543///
544/// This processes transactions in order, tracking inventory to enable
545/// proper lot matching and capital gains calculation.
546pub fn book_transactions(
547    transactions: &[Transaction],
548    method: BookingMethod,
549) -> Vec<Result<InterpolationResult, BookingError>> {
550    let mut engine = BookingEngine::with_method(method);
551    let mut results = Vec::with_capacity(transactions.len());
552
553    for txn in transactions {
554        let result = engine.book_and_interpolate(txn);
555        if let Ok(ref interpolated) = result {
556            // Apply the booked transaction (with filled-in costs), not the original
557            engine.apply(&interpolated.transaction);
558        }
559        results.push(result);
560    }
561
562    results
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use rust_decimal_macros::dec;
569    use rustledger_core::{NaiveDate, Posting, PriceAnnotation};
570
571    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
572        rustledger_core::naive_date(year, month, day).unwrap()
573    }
574
575    #[test]
576    fn test_book_simple_buy() {
577        let mut engine = BookingEngine::new();
578
579        // Buy 10 AAPL at $150
580        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
581            .with_posting(
582                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
583                    CostSpec::empty()
584                        .with_number_per(dec!(150.00))
585                        .with_currency("USD"),
586                ),
587            )
588            .with_posting(Posting::new(
589                "Assets:Cash",
590                Amount::new(dec!(-1500.00), "USD"),
591            ));
592
593        engine.apply(&buy);
594
595        // Check inventory
596        let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
597        assert_eq!(inv.units("AAPL"), dec!(10));
598    }
599
600    #[test]
601    fn test_book_sell_with_gain() {
602        let mut engine = BookingEngine::new();
603
604        // Buy 10 AAPL at $150
605        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
606            .with_posting(
607                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
608                    CostSpec::empty()
609                        .with_number_per(dec!(150.00))
610                        .with_currency("USD"),
611                ),
612            )
613            .with_posting(Posting::new(
614                "Assets:Cash",
615                Amount::new(dec!(-1500.00), "USD"),
616            ));
617
618        engine.apply(&buy);
619
620        // Sell 5 AAPL at $175 with empty cost (needs lot matching)
621        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
622            .with_posting(
623                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
624                    .with_cost(CostSpec::empty()) // Empty - needs lot matching
625                    .with_price(PriceAnnotation::Unit(Amount::new(dec!(175.00), "USD"))),
626            )
627            .with_posting(Posting::new(
628                "Assets:Cash",
629                Amount::new(dec!(875.00), "USD"),
630            ))
631            .with_posting(Posting::auto("Income:CapitalGains")); // Elided
632
633        // Check inventory before sell
634        let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
635        eprintln!("Inventory before sell: {inv:?}");
636
637        let booked = engine.book(&sell).unwrap();
638        eprintln!(
639            "Booked: gains={:?}, indices={:?}",
640            booked.gains, booked.booked_indices
641        );
642        eprintln!("Booked transaction: {:?}", booked.transaction);
643
644        // Check that gain was calculated
645        assert_eq!(
646            booked.gains.len(),
647            1,
648            "Expected 1 gain, got {:?}",
649            booked.gains
650        );
651        let gain = &booked.gains[0];
652        // Gain = 5 * (175 - 150) = 125
653        assert_eq!(gain.amount.number, dec!(125));
654    }
655
656    #[test]
657    fn test_book_with_total_cost() {
658        let mut engine = BookingEngine::new();
659
660        // Buy 1.763 VIIIX with total cost of 300 USD (like healthequity file)
661        let buy = Transaction::new(date(2016, 1, 16), "Buy stock")
662            .with_posting(
663                Posting::new("Assets:Stock", Amount::new(dec!(1.763), "VIIIX")).with_cost(
664                    CostSpec::empty()
665                        .with_number_total(dec!(300.00))
666                        .with_currency("USD"),
667                ),
668            )
669            .with_posting(Posting::new(
670                "Assets:Cash",
671                Amount::new(dec!(-300.00), "USD"),
672            ));
673
674        engine.apply(&buy);
675
676        // Check inventory
677        let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
678        eprintln!("Inventory after total cost buy: {inv:?}");
679        assert_eq!(inv.units("VIIIX"), dec!(1.763));
680
681        // Check cost was calculated correctly (300/1.763 ≈ 170.16)
682        let pos = inv.positions().first().unwrap();
683        assert!(pos.cost.is_some(), "Expected cost on position");
684        eprintln!("Position cost: {:?}", pos.cost);
685    }
686
687    #[test]
688    fn test_book_total_cost_then_sell() {
689        // Test that book() correctly handles total cost syntax and preserves
690        // full precision for accurate capital gains calculation.
691        let mut engine = BookingEngine::new();
692
693        // Buy 1.763 VIIIX with total cost {{300.00 USD}}
694        let buy = Transaction::new(date(2016, 1, 16), "Buy stock")
695            .with_posting(
696                Posting::new("Assets:Stock", Amount::new(dec!(1.763), "VIIIX")).with_cost(
697                    CostSpec::empty()
698                        .with_number_total(dec!(300.00))
699                        .with_currency("USD"),
700                ),
701            )
702            .with_posting(Posting::new(
703                "Assets:Cash",
704                Amount::new(dec!(-300.00), "USD"),
705            ));
706
707        // Use book() to test the booking path with total cost
708        let booked_buy = engine.book(&buy).unwrap();
709        engine.apply(&booked_buy.transaction);
710
711        // Check that per-unit cost was calculated (300/1.763)
712        let buy_posting = &booked_buy.transaction.postings[0];
713        assert!(buy_posting.cost.is_some());
714        let cost_spec = buy_posting.cost.as_ref().unwrap();
715        // Both total and per-unit should be set (total preserved for precise residual calc)
716        assert!(cost_spec.number_total.is_some());
717        assert!(cost_spec.number_per.is_some());
718
719        // Sell all shares at $191 per unit
720        let sell = Transaction::new(date(2016, 6, 15), "Sell stock")
721            .with_posting(
722                Posting::new("Assets:Stock", Amount::new(dec!(-1.763), "VIIIX"))
723                    .with_cost(CostSpec::empty())
724                    .with_price(PriceAnnotation::Unit(Amount::new(dec!(191.00), "USD"))),
725            )
726            .with_posting(Posting::new(
727                "Assets:Cash",
728                Amount::new(dec!(336.73), "USD"), // 1.763 * 191 = 336.733
729            ))
730            .with_posting(Posting::auto("Income:CapitalGains"));
731
732        let booked_sell = engine.book(&sell).unwrap();
733
734        // Capital gain should be: 336.73 - 300.00 = 36.73
735        // With full precision preserved, this should be accurate
736        assert_eq!(booked_sell.gains.len(), 1);
737        let gain = &booked_sell.gains[0];
738        // The gain should be close to 36.73 (sale proceeds - cost basis)
739        // Sale: 1.763 * 191 = 336.733, Cost: 300.00, Gain ≈ 36.73
740        eprintln!("Capital gain: {:?}", gain.amount);
741    }
742
743    #[test]
744    fn test_cost_spec_currency_inference() {
745        let mut engine = BookingEngine::new();
746
747        // Create SELLOPT: -1 AAPL {40.0} @ 0.4 USD
748        // This has cost number (40.0) but NO cost currency - should infer from price
749        let sell = Transaction::new(date(2022, 6, 17), "SELLOPT")
750            .with_posting(
751                Posting::new("Assets:Stock", Amount::new(dec!(-1), "AAPL"))
752                    .with_cost(CostSpec::empty().with_number_per(dec!(40.0)))
753                    .with_price(PriceAnnotation::Unit(Amount::new(dec!(0.4), "USD"))),
754            )
755            .with_posting(Posting::new("Assets:Stock", Amount::new(dec!(40.0), "USD")));
756
757        eprintln!("SELLOPT posting.cost = {:?}", sell.postings[0].cost);
758        eprintln!("SELLOPT posting.price = {:?}", sell.postings[0].price);
759
760        engine.apply(&sell);
761
762        let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
763        eprintln!("Inventory after SELLOPT: {inv:?}");
764
765        // Check that the AAPL position has cost with USD currency
766        let aapl_pos = inv
767            .positions()
768            .iter()
769            .find(|p| p.units.currency.as_ref() == "AAPL")
770            .expect("Should have AAPL position");
771
772        eprintln!("AAPL position: {aapl_pos:?}");
773
774        assert!(aapl_pos.cost.is_some(), "AAPL position should have cost");
775        let cost = aapl_pos.cost.as_ref().unwrap();
776        assert_eq!(cost.currency.as_ref(), "USD", "Cost currency should be USD");
777        assert_eq!(cost.number, dec!(40.0), "Cost number should be 40.0");
778    }
779
780    #[test]
781    fn test_booking_engine_with_method() {
782        // Test that with_method creates engine with specified booking method
783        let engine = BookingEngine::with_method(BookingMethod::Lifo);
784        assert!(engine.inventories.is_empty());
785
786        // Also test default is FIFO
787        let default_engine = BookingEngine::new();
788        assert!(default_engine.inventories.is_empty());
789    }
790
791    #[test]
792    fn test_book_sell_with_total_price() {
793        let mut engine = BookingEngine::new();
794
795        // Buy 10 AAPL at $150
796        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
797            .with_posting(
798                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
799                    CostSpec::empty()
800                        .with_number_per(dec!(150.00))
801                        .with_currency("USD"),
802                ),
803            )
804            .with_posting(Posting::new(
805                "Assets:Cash",
806                Amount::new(dec!(-1500.00), "USD"),
807            ));
808
809        engine.apply(&buy);
810
811        // Sell 5 AAPL with total price annotation (not per-unit)
812        // Total price = $875 for 5 shares = $175/share
813        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
814            .with_posting(
815                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
816                    .with_cost(CostSpec::empty())
817                    .with_price(PriceAnnotation::Total(Amount::new(dec!(875.00), "USD"))),
818            )
819            .with_posting(Posting::new(
820                "Assets:Cash",
821                Amount::new(dec!(875.00), "USD"),
822            ))
823            .with_posting(Posting::auto("Income:CapitalGains"));
824
825        let booked = engine.book(&sell).unwrap();
826
827        // Check that gain was calculated correctly
828        // Gain = 875 - (5 * 150) = 875 - 750 = 125
829        assert_eq!(booked.gains.len(), 1, "Expected 1 gain");
830        let gain = &booked.gains[0];
831        assert_eq!(gain.amount.number, dec!(125));
832    }
833
834    #[test]
835    fn test_book_transactions_multiple() {
836        // Buy 10 AAPL at $150
837        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
838            .with_posting(
839                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
840                    CostSpec::empty()
841                        .with_number_per(dec!(150.00))
842                        .with_currency("USD"),
843                ),
844            )
845            .with_posting(Posting::new(
846                "Assets:Cash",
847                Amount::new(dec!(-1500.00), "USD"),
848            ));
849
850        // Sell 5 AAPL
851        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
852            .with_posting(
853                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
854                    .with_cost(CostSpec::empty())
855                    .with_price(PriceAnnotation::Unit(Amount::new(dec!(175.00), "USD"))),
856            )
857            .with_posting(Posting::new(
858                "Assets:Cash",
859                Amount::new(dec!(875.00), "USD"),
860            ))
861            .with_posting(Posting::auto("Income:CapitalGains"));
862
863        let transactions = vec![buy, sell];
864        let results = book_transactions(&transactions, BookingMethod::Fifo);
865
866        assert_eq!(results.len(), 2);
867        assert!(results[0].is_ok());
868        assert!(results[1].is_ok());
869    }
870
871    #[test]
872    fn test_book_augmentation_not_reduction() {
873        let mut engine = BookingEngine::new();
874
875        // First, add existing inventory with positive AAPL
876        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
877            .with_posting(
878                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
879                    CostSpec::empty()
880                        .with_number_per(dec!(150.00))
881                        .with_currency("USD"),
882                ),
883            )
884            .with_posting(Posting::new(
885                "Assets:Cash",
886                Amount::new(dec!(-1500.00), "USD"),
887            ));
888
889        engine.apply(&buy);
890
891        // Now try to book another buy (augmentation, not reduction)
892        // This has empty cost but same sign as inventory, so it's not a reduction
893        let another_buy = Transaction::new(date(2024, 2, 15), "Buy more")
894            .with_posting(
895                Posting::new("Assets:Stock", Amount::new(dec!(5), "AAPL"))
896                    .with_cost(CostSpec::empty()), // Empty cost but augmentation
897            )
898            .with_posting(Posting::new(
899                "Assets:Cash",
900                Amount::new(dec!(-750.00), "USD"),
901            ));
902
903        // Should not error - just skip lot matching for augmentation
904        let booked = engine.book(&another_buy).unwrap();
905        assert!(
906            booked.booked_indices.is_empty(),
907            "Augmentation should not have booked indices"
908        );
909    }
910
911    #[test]
912    fn test_book_no_inventory_for_account() {
913        let engine = BookingEngine::new();
914
915        // Try to book a sell without any prior inventory
916        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
917            .with_posting(
918                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
919                    .with_cost(CostSpec::empty()),
920            )
921            .with_posting(Posting::new(
922                "Assets:Cash",
923                Amount::new(dec!(875.00), "USD"),
924            ));
925
926        // Should succeed but with no booked indices (no inventory to match against)
927        let booked = engine.book(&sell).unwrap();
928        assert!(
929            booked.booked_indices.is_empty(),
930            "No inventory means no lot matching"
931        );
932    }
933
934    #[test]
935    fn test_book_zero_gain() {
936        let mut engine = BookingEngine::new();
937
938        // Buy 10 AAPL at $150
939        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
940            .with_posting(
941                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
942                    CostSpec::empty()
943                        .with_number_per(dec!(150.00))
944                        .with_currency("USD"),
945                ),
946            )
947            .with_posting(Posting::new(
948                "Assets:Cash",
949                Amount::new(dec!(-1500.00), "USD"),
950            ));
951
952        engine.apply(&buy);
953
954        // Sell at same price - zero gain
955        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
956            .with_posting(
957                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
958                    .with_cost(CostSpec::empty())
959                    .with_price(PriceAnnotation::Unit(Amount::new(dec!(150.00), "USD"))),
960            )
961            .with_posting(Posting::new(
962                "Assets:Cash",
963                Amount::new(dec!(750.00), "USD"),
964            ));
965
966        let booked = engine.book(&sell).unwrap();
967
968        // Zero gain should not be added to gains vector
969        assert!(booked.gains.is_empty(), "Zero gain should not be recorded");
970    }
971
972    /// Test cost currency inference from other postings (issue #230).
973    ///
974    /// When a cost is specified without a currency (e.g., `{1}`), the currency
975    /// should be inferred from simple postings in the same transaction.
976    #[test]
977    fn test_cost_currency_inference_from_other_postings() {
978        let mut engine = BookingEngine::new();
979
980        // Opening balance with cost without currency - should infer USD from other posting
981        // 2026-01-01 * "Opening balance"
982        //   Assets:Abc   1 ABC {1}           <- no currency, should infer USD
983        //   Equity:Opening-Balances -1 USD
984        let open = Transaction::new(date(2026, 1, 1), "Opening balance")
985            .with_posting(
986                Posting::new("Assets:Abc", Amount::new(dec!(1), "ABC"))
987                    .with_cost(CostSpec::empty().with_number_per(dec!(1))), // No currency!
988            )
989            .with_posting(Posting::new(
990                "Equity:Opening-Balances",
991                Amount::new(dec!(-1), "USD"),
992            ));
993
994        // Book and apply the opening
995        let booked = engine.book(&open).unwrap();
996        engine.apply(&booked.transaction);
997
998        // Check that the cost spec was filled in with USD
999        let cost_spec = booked.transaction.postings[0].cost.as_ref().unwrap();
1000        assert_eq!(
1001            cost_spec.currency.as_deref(),
1002            Some("USD"),
1003            "Cost currency should be inferred as USD from other posting"
1004        );
1005
1006        // Check inventory has the position with correct cost
1007        let inv = engine.inventory(&"Assets:Abc".into()).unwrap();
1008        let pos = inv.positions().first().unwrap();
1009        assert!(pos.cost.is_some(), "Position should have cost");
1010        let cost = pos.cost.as_ref().unwrap();
1011        assert_eq!(cost.currency.as_ref(), "USD", "Cost currency should be USD");
1012        assert_eq!(cost.number, dec!(1), "Cost number should be 1");
1013
1014        // Now sell with explicit cost currency - should match the lot
1015        // 2026-01-02 * "Sale"
1016        //   Assets:Abc  -1 ABC {1 USD}
1017        //   Expenses:Abc
1018        let sell = Transaction::new(date(2026, 1, 2), "Sale")
1019            .with_posting(
1020                Posting::new("Assets:Abc", Amount::new(dec!(-1), "ABC")).with_cost(
1021                    CostSpec::empty()
1022                        .with_number_per(dec!(1))
1023                        .with_currency("USD"),
1024                ),
1025            )
1026            .with_posting(Posting::auto("Expenses:Abc"));
1027
1028        // This should succeed - the lot with {1 USD} should be found
1029        let booked_sell = engine.book(&sell).unwrap();
1030
1031        // Check that the lot was matched
1032        assert!(
1033            !booked_sell.booked_indices.is_empty(),
1034            "Sale should match the lot created in opening"
1035        );
1036    }
1037
1038    #[test]
1039    fn test_multi_posting_crosses_lot_boundary() {
1040        // Regression test: Multiple postings in the same transaction reducing
1041        // the same commodity should correctly track inventory state across postings.
1042        // Previously, each posting would see the original inventory instead of
1043        // the updated state after processing previous postings.
1044
1045        let mut engine = BookingEngine::new();
1046
1047        // Create two lots of ADA with different costs
1048        // Lot 1: 100 ADA at $0.50 (2021-01-01)
1049        let buy1 = Transaction::new(date(2021, 1, 1), "Buy lot 1")
1050            .with_posting(
1051                Posting::new("Assets:Crypto", Amount::new(dec!(100), "ADA")).with_cost(
1052                    CostSpec::empty()
1053                        .with_number_per(dec!(0.50))
1054                        .with_currency("USD")
1055                        .with_date(date(2021, 1, 1)),
1056                ),
1057            )
1058            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD")));
1059        engine.apply(&buy1);
1060
1061        // Lot 2: 100 ADA at $0.52 (2022-05-19)
1062        let buy2 = Transaction::new(date(2022, 5, 19), "Buy lot 2")
1063            .with_posting(
1064                Posting::new("Assets:Crypto", Amount::new(dec!(100), "ADA")).with_cost(
1065                    CostSpec::empty()
1066                        .with_number_per(dec!(0.52))
1067                        .with_currency("USD")
1068                        .with_date(date(2022, 5, 19)),
1069                ),
1070            )
1071            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-52), "USD")));
1072        engine.apply(&buy2);
1073
1074        // Verify initial inventory: 200 ADA total
1075        let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
1076        assert_eq!(inv.units("ADA"), dec!(200));
1077
1078        // Consume half of lot 1 first
1079        let sell1 = Transaction::new(date(2022, 5, 20), "Sell 50 ADA")
1080            .with_posting(
1081                Posting::new("Assets:Crypto", Amount::new(dec!(-50), "ADA"))
1082                    .with_cost(CostSpec::empty()),
1083            )
1084            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(25), "USD")));
1085        let booked1 = engine.book(&sell1).unwrap();
1086        engine.apply(&booked1.transaction);
1087
1088        // Verify: 150 ADA remaining (50 in lot 1, 100 in lot 2)
1089        let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
1090        assert_eq!(inv.units("ADA"), dec!(150));
1091
1092        // Now the critical test: TWO postings in the same transaction
1093        // that together cross the lot boundary.
1094        // - Posting 1: -75 ADA {} → takes 50 from lot 1 + 25 from lot 2
1095        // - Posting 2: -5 ADA {} → should take from lot 2 (continuing)
1096        let sell2 = Transaction::new(date(2022, 5, 22), "Sell 80 ADA (multi-posting)")
1097            .with_posting(
1098                Posting::new("Assets:Crypto", Amount::new(dec!(-75), "ADA"))
1099                    .with_cost(CostSpec::empty()),
1100            )
1101            .with_posting(
1102                Posting::new("Assets:Crypto", Amount::new(dec!(-5), "ADA"))
1103                    .with_cost(CostSpec::empty()),
1104            )
1105            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(42), "USD")));
1106
1107        // This should succeed - the bug was that the second posting would fail
1108        // with "No matching lot" because it was trying to match against lot 1
1109        // which was already exhausted by the first posting.
1110        let booked2 = engine.book(&sell2);
1111        assert!(
1112            booked2.is_ok(),
1113            "Multi-posting transaction should succeed: {:?}",
1114            booked2.err()
1115        );
1116
1117        // Apply and verify final inventory: 70 ADA remaining (all in lot 2)
1118        engine.apply(&booked2.unwrap().transaction);
1119        let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
1120        assert_eq!(
1121            inv.units("ADA"),
1122            dec!(70),
1123            "Should have 70 ADA remaining in lot 2"
1124        );
1125    }
1126
1127    #[test]
1128    fn test_book_no_cost_specs_fast_path() {
1129        // Test that the fast path for transactions without cost specs
1130        // returns correct empty gains and booked_indices.
1131        let engine = BookingEngine::new();
1132
1133        // Simple expense transaction with no cost specs
1134        let txn = Transaction::new(date(2024, 1, 15), "Groceries")
1135            .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD")))
1136            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD")));
1137
1138        let result = engine.book(&txn).unwrap();
1139
1140        // Fast path should return empty gains and booked_indices
1141        assert!(result.gains.is_empty(), "Should have no capital gains");
1142        assert!(
1143            result.booked_indices.is_empty(),
1144            "Should have no booked indices"
1145        );
1146
1147        // Transaction should be unchanged
1148        assert_eq!(result.transaction.postings.len(), 2);
1149        assert_eq!(
1150            result.transaction.postings[0].units,
1151            Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD")))
1152        );
1153    }
1154
1155    /// Regression test for #748.
1156    ///
1157    /// The pta-standards `reduction-exceeds-inventory` conformance test
1158    /// asserts on `error_contains: ["not enough"]`. PR #745 made the booking
1159    /// layer propagate `InsufficientUnits` directly to the user instead of
1160    /// letting the validator's "Not enough units in ..." message win, which
1161    /// dropped the "not enough" phrasing. This test pins the user-facing
1162    /// Display string so the conformance assertion (and any downstream user
1163    /// tooling that greps the message) cannot regress silently again.
1164    ///
1165    /// After #750, the canonical Display lives on
1166    /// [`rustledger_core::AccountedBookingError`] and `BookingError::Inventory`
1167    /// delegates to it transparently — so this test exercises the same path
1168    /// the validator and `cmd/check.rs` use.
1169
1170    // =========================================================================
1171    // Regression test for issue #875 / beancount#889
1172    //
1173    // Scenario: buy stock with cost, sell without cost spec (leaves a simple
1174    // negative position), then buy more with cost spec. The third transaction
1175    // must succeed as an augmentation, not fail as a reduction.
1176    // =========================================================================
1177
1178    #[test]
1179    fn test_augmentation_after_sell_without_cost_spec() {
1180        // Regression test for issue #875 / beancount#889.
1181        //
1182        // Before the fix, the sell-without-cost-spec left a -25 HOOG simple
1183        // position, causing the subsequent buy-with-cost-spec to be
1184        // misclassified as a reduction (because is_reduced_by saw opposite
1185        // signs without distinguishing cost-bearing vs simple positions).
1186        let mut engine = BookingEngine::new();
1187
1188        // 2024-01-10: Buy 100 HOOG {1.50 EUR}
1189        let buy1 = Transaction::new(date(2024, 1, 10), "Buy 100 HOOG")
1190            .with_posting(
1191                Posting::new("Assets:Stocks", Amount::new(dec!(100), "HOOG")).with_cost(
1192                    CostSpec::empty()
1193                        .with_number_per(dec!(1.50))
1194                        .with_currency("EUR"),
1195                ),
1196            )
1197            .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-150), "EUR")));
1198
1199        engine.apply(&buy1);
1200
1201        // 2024-01-15: Sell 25 HOOG without cost spec (price-only)
1202        let sell = Transaction::new(date(2024, 1, 15), "Sell 25 HOOG without cost spec")
1203            .with_posting(
1204                Posting::new("Assets:Stocks", Amount::new(dec!(-25), "HOOG"))
1205                    .with_price(PriceAnnotation::Unit(Amount::new(dec!(1.60), "EUR"))),
1206            )
1207            .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(40), "EUR")));
1208
1209        engine.apply(&sell);
1210
1211        // 2024-01-20: Buy 50 more HOOG {1.70 EUR} - this MUST succeed
1212        let buy2 = Transaction::new(date(2024, 1, 20), "Buy 50 more HOOG - should succeed")
1213            .with_posting(
1214                Posting::new("Assets:Stocks", Amount::new(dec!(50), "HOOG")).with_cost(
1215                    CostSpec::empty()
1216                        .with_number_per(dec!(1.70))
1217                        .with_currency("EUR"),
1218                ),
1219            )
1220            .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-85), "EUR")));
1221
1222        // This should NOT fail. Before the fix, the engine would see the
1223        // -25 HOOG simple position and try to reduce, which would fail
1224        // because the cost spec wouldn't match any existing lot.
1225        let result = engine.book(&buy2);
1226        assert!(
1227            result.is_ok(),
1228            "Buy with cost spec after sell without cost spec should succeed as augmentation, \
1229             but got error: {:?}",
1230            result.err()
1231        );
1232
1233        let booked = result.unwrap();
1234        engine.apply(&booked.transaction);
1235
1236        // Verify final inventory state
1237        let inv = engine.inventory(&"Assets:Stocks".into()).unwrap();
1238        // 100 (original) - 25 (sold simple) + 50 (new lot) = 125 HOOG total
1239        assert_eq!(inv.units("HOOG"), dec!(125));
1240    }
1241
1242    #[test]
1243    fn test_insufficient_units_display_contains_not_enough() {
1244        let err = BookingError::Inventory(
1245            rustledger_core::BookingError::InsufficientUnits {
1246                currency: "AAPL".into(),
1247                requested: dec!(15),
1248                available: dec!(10),
1249            }
1250            .with_account("Assets:Stock".into()),
1251        );
1252        let rendered = format!("{err}");
1253        assert!(
1254            rendered.contains("not enough"),
1255            "InsufficientUnits Display must contain 'not enough' for beancount \
1256             compatibility (#748). Got: {rendered}"
1257        );
1258        assert!(
1259            rendered.contains("Assets:Stock"),
1260            "InsufficientUnits Display must include the account name. Got: {rendered}"
1261        );
1262        assert!(
1263            rendered.contains("15") && rendered.contains("10"),
1264            "InsufficientUnits Display must include requested and available amounts. Got: {rendered}"
1265        );
1266    }
1267}