Skip to main content

rustledger_booking/
book.rs

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